This commit is contained in:
2026-10-27 13:07:24 +07:00
parent 066134f6ef
commit 6e83c014e5
18 changed files with 1445 additions and 174 deletions
+295 -21
View File
@@ -43,7 +43,12 @@ async function fixture(t: TestContext, secure = true) {
{ ID: 2, name: 'Два' },
],
},
],
] as {
ID: number
name: string
groups?: { ID: number; name: string; position: number; endMode?: string }[]
tracks: { ID: number; name: string; groupId?: number }[]
}[],
playlistError: 0,
upload: null as { filename: string; data: Buffer } | null,
bindings: [] as Record<string, unknown>[],
@@ -67,6 +72,7 @@ async function fixture(t: TestContext, secure = true) {
refreshCalls: 0,
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0,
advanceHandler: null as (() => Promise<Response>) | null,
advanceRequest: null as Record<string, unknown> | null,
advanceResponse: null as Record<string, unknown> | null,
advanceError: 0,
@@ -144,6 +150,7 @@ async function fixture(t: TestContext, secure = true) {
if (path === '/api/screens/music/advance') {
model.advanceCalls++
model.advanceRequest = JSON.parse(String(init?.body)) as Record<string, unknown>
if (model.advanceHandler) return model.advanceHandler()
if (model.advanceError) return json({}, model.advanceError)
return json({ state: model.advanceResponse ?? objectMusic(3, 1) })
}
@@ -260,6 +267,8 @@ async function fixture(t: TestContext, secure = true) {
const state = objectMusic(20, Number(body.trackIndex ?? 0))
model.remote.music = {
...state,
playlist:
(model.remote.music as { playlist?: unknown } | undefined)?.playlist ?? state.playlist,
playback: body.playback,
volume: body.volume,
loop: body.loop,
@@ -275,11 +284,13 @@ async function fixture(t: TestContext, secure = true) {
{
ID: 1,
name: 'Музыка',
groups: model.library[0]?.groups,
tracks: [0, 1].map((i) => ({
ID: i + 1,
name: `Трек ${i}`,
filename: `https://media.test/${i}.wav`,
position: i,
groupId: model.library[0]?.tracks.find((track) => track.ID === i + 1)?.groupId,
})),
},
],
@@ -311,6 +322,35 @@ async function fixture(t: TestContext, secure = true) {
}
if (path === '/api/playlists/1' && init?.method === 'PUT') model.library[0]!.name = data.name
if (path === '/api/playlists/2' && init?.method === 'DELETE') model.library.pop()
const playlist = model.library[0]!
if (path === '/api/playlists/1/groups' && init?.method === 'POST') {
playlist.groups ??= []
const current = playlist.groups.find((group) => group.ID === data.groupID)
if (current) Object.assign(current, { name: data.name, endMode: data.endMode })
else
playlist.groups.push({
ID: Math.max(0, ...playlist.groups.map((group) => group.ID)) + 1,
name: data.name,
position: playlist.groups.length,
endMode: data.endMode,
})
}
if (path === '/api/playlists/1/groups/order') {
playlist.groups = data.groupIDs.map((id: number, position: number) => ({
...playlist.groups!.find((group) => group.ID === id)!,
position,
}))
}
if (/^\/api\/playlists\/1\/groups\/\d+$/.test(path) && init?.method === 'DELETE') {
const groupId = Number(path.split('/').at(-1))
playlist.groups = playlist.groups?.filter((group) => group.ID !== groupId)
playlist.tracks.forEach((track) => {
if (track.groupId === groupId) delete track.groupId
})
}
if (/^\/api\/playlists\/1\/tracks\/\d+\/group$/.test(path))
playlist.tracks.find((track) => track.ID === Number(path.split('/')[5]))!.groupId =
data.groupID
if (path === '/api/playlists/1/tracks/order') {
const tracks = model.library[0]!.tracks
model.library[0]!.tracks = data.trackIDs.map((id: number) =>
@@ -679,17 +719,26 @@ test('отмена подготовки не ждёт общий refresh и не
}
const cancelled = f.online.act({ action: 'check', gameId: 7 }).then(
() => 'completed',
(error: Error) => { assert.match(error.message, /отмен|ABORTED/); return 'cancelled' },
(error: Error) => {
assert.match(error.message, /отмен|ABORTED/)
return 'cancelled'
},
)
await refreshStarted
const background = f.online.refreshPlan()
await new Promise<void>((resolve) => setImmediate(resolve))
await f.online.act({ action: 'cancel' })
try {
assert.equal(await Promise.race([
cancelled,
new Promise<string>((resolve) => { const timer = setTimeout(() => resolve('still waiting'), 1000); timer.unref() }),
]), 'cancelled')
assert.equal(
await Promise.race([
cancelled,
new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve('still waiting'), 1000)
timer.unref()
}),
]),
'cancelled',
)
assert.equal(f.model.refreshed, false)
} finally {
release()
@@ -2247,46 +2296,271 @@ test('список команд обновляется, сохраняется
assert.equal(c.state.teams, null)
})
function groupedMusic(version: number, index = 0, endMode = 'PLAYLIST_GROUP_END_MODE_REPEAT') {
return {
...objectMusic(version, index),
groupId: index === 0 ? 42 : 43,
playlist: {
ID: 1,
name: 'Группы',
groups: [
{ ID: 42, name: 'Сбор', position: 0, endMode },
{ ID: 44, name: 'Пустая', position: 1 },
{ ID: 43, name: 'Финал', position: 2, endMode: 'PLAYLIST_GROUP_END_MODE_STOP' },
],
tracks: [0, 1].map((i) => ({
ID: i + 1,
name: `Трек ${i}`,
filename: `https://media.test/${i}.wav`,
position: i,
groupId: i === 0 ? 42 : 43,
})),
},
}
}
for (const action of ['volume', 'pause', 'resume', 'replay'] as const) {
test(`группа сохраняется при команде ${action} нативного плеера`, async (t) => {
const f = await fixture(t)
const music = groupedMusic(2)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
f.model.remote.music = { ...objectMusic(2), groupId: 42 }
await f.online.poll()
await f.online.planCommand(action === 'volume' ? { action, channel: 'music', volume: 55 } : { action, channel: 'music' })
assert.equal((f.model.remote.music as { groupId: number }).groupId, 42)
await assert.rejects(f.online.planCommand({ action: 'next', channel: 'music' }), /активной группы/)
await assert.rejects(f.online.planCommand({ action: 'track', channel: 'music', trackIndex: 1 }), /активной группы/)
await assert.rejects(f.online.planCommand({ action: 'loop', channel: 'music', enabled: true }), /активной группы/)
await assert.rejects(
f.online.planCommand({ action: 'loop', channel: 'music', enabled: true }),
/редакторе/,
)
await f.online.planCommand({ action: 'track', channel: 'music', trackIndex: 1 })
assert.equal(f.controller.state.channels.music.groupId, 43)
})
}
test('повтор одного трека группы передаёт точную версию и перезапускает трек', async (t) => {
const f = await fixture(t)
const music = { ...groupedMusic(2), stateVersion: '9007199254740995', positionMs: 0 }
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
const version = '9007199254740995'
f.model.remote.music = { ...objectMusic(2), stateVersion: version, groupId: 42, positionMs: 0 }
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
const before = f.controller.state.channels.music.operation
f.model.advanceResponse = { ...objectMusic(3), stateVersion: '9007199254740996', groupId: 42, positionMs: 0 }
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: before, status: 'ended', position: 0.1, duration: 0.1 })
assert.equal(f.model.advanceRequest?.expectedStateVersion, version)
f.model.advanceResponse = { ...groupedMusic(3), stateVersion: '9007199254740996', positionMs: 0 }
await f.online.musicEnded({
session: f.controller.state.session,
channel: 'music',
operation: before,
status: 'ended',
position: 0.1,
duration: 0.1,
})
assert.equal(f.model.advanceRequest?.expectedStateVersion, music.stateVersion)
assert.ok(f.controller.state.channels.music.operation > before)
assert.equal(f.controller.state.channels.music.trackIndex, 0)
assert.equal(f.controller.state.channels.music.groupId, 42)
})
test('группа останавливается при потере связи вместо выхода в общий список', async (t) => {
for (const [mode, index, groupId, desired] of [
['PLAYLIST_GROUP_END_MODE_REPEAT', 0, 42, 'playing'],
['PLAYLIST_GROUP_END_MODE_STOP', 0, 42, 'stopped'],
['PLAYLIST_GROUP_END_MODE_NEXT', 1, 43, 'playing'],
] as const) {
test(`офлайн-завершение группы: ${mode}`, async (t) => {
const f = await fixture(t)
const music = groupedMusic(2, 0, mode)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
f.model.advanceError = 503
await f.online.musicEnded({
session: f.controller.state.session,
channel: 'music',
operation: f.controller.state.channels.music.operation,
status: 'ended',
position: 4,
duration: 4,
})
assert.equal(f.controller.state.channels.music.desired, desired)
assert.equal(f.controller.state.channels.music.groupId, groupId)
assert.equal(f.controller.state.channels.music.trackIndex, index)
await f.online.poll()
assert.equal(
f.controller.state.channels.music.groupId,
42,
'server wins on reconnect even with unchanged music version',
)
assert.equal(f.controller.state.channels.music.trackIndex, 0)
})
}
test('редактор сохраняет группы и переносит треки через существующие API', async (t) => {
const f = await fixture(t)
await f.login()
await f.online.act({
action: 'group-save',
playlistId: 1,
groupId: 0,
name: 'Сбор',
endMode: 'repeat',
})
await f.online.act({
action: 'group-save',
playlistId: 1,
groupId: 0,
name: 'Финал',
endMode: 'stop',
})
const groups = f.online.snapshot().playlists[0]!.groups!
assert.deepEqual(
groups.map((g) => g.endMode),
['repeat', 'stop'],
)
await f.online.act({ action: 'track-group', playlistId: 1, trackId: 1, groupId: groups[0]!.id })
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, groups[0]!.id)
await f.online.act({
action: 'group-reorder',
playlistId: 1,
groupIds: [groups[1]!.id, groups[0]!.id],
})
assert.equal(f.online.snapshot().playlists[0]!.groups![0]!.name, 'Финал')
await f.online.act({
action: 'group-save',
playlistId: 1,
groupId: groups[0]!.id,
name: 'Гости',
endMode: 'next',
})
assert.equal(f.online.snapshot().playlists[0]!.groups![1]!.endMode, 'next')
await f.online.act({ action: 'group-delete', playlistId: 1, groupId: groups[0]!.id })
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, undefined)
assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2)
})
test('запоздалая ошибка подготовки группы не останавливает новую группу', async (t) => {
const f = await fixture(t)
const music = groupedMusic(2)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
await f.online.poll()
const pkg = f.store.description(f.store.list()[0]!.key)!.package
const target = pkg.playlists[0]!.tracks[0]!.assetId
const canRead = f.store.canRead.bind(f.store),
ensure = f.store.ensureAsset.bind(f.store)
let reject!: (error: Error) => void,
started = false,
shouldDefer = true
const pending = new Promise<void>((_resolve, failure) => {
reject = failure
})
f.store.canRead = (key, id) => (id === target && shouldDefer ? false : canRead(key, id))
f.store.ensureAsset = (...args) => {
if (args[1] === target && shouldDefer) {
shouldDefer = false
started = true
return pending
}
return ensure(...args)
}
f.model.offline = true
const old = f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
await eventually(() => started, 'старая загрузка началась')
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
const operation = f.controller.state.channels.music.operation
reject(new Error('old preparation failure'))
await old
assert.equal(f.controller.state.channels.music.groupId, 43)
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.equal(f.controller.state.channels.music.operation, operation)
})
test('запоздалая ошибка офлайн-перехода не останавливает новый запуск', async (t) => {
const f = await fixture(t)
const music = groupedMusic(2, 0, 'PLAYLIST_GROUP_END_MODE_NEXT')
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
f.model.remote.music = { ...objectMusic(2), groupId: 42 }
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
const pkg = f.store.description(f.store.list()[0]!.key)!.package
const target = pkg.playlists[0]!.tracks[1]!.assetId
const ensure = f.store.ensureAsset.bind(f.store)
let reject!: (error: Error) => void,
started = false,
shouldDefer = true
const pending = new Promise<void>((_resolve, failure) => {
reject = failure
})
f.store.ensureAsset = (...args) => {
if (args[1] === target && shouldDefer) {
shouldDefer = false
started = true
return pending
}
return ensure(...args)
}
f.model.advanceError = 503
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: f.controller.state.channels.music.operation, status: 'ended', position: 4, duration: 4 })
assert.equal(f.controller.state.channels.music.desired, 'stopped')
assert.equal(f.controller.state.channels.music.trackIndex, 0)
const old = f.online.musicEnded({
session: f.controller.state.session,
channel: 'music',
operation: f.controller.state.channels.music.operation,
status: 'ended',
position: 4,
duration: 4,
})
await eventually(() => started, 'офлайн-переход готовит файл')
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
const operation = f.controller.state.channels.music.operation
reject(new Error('old next failure'))
await old
assert.equal(f.controller.state.channels.music.groupId, 42)
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.equal(f.controller.state.channels.music.operation, operation)
assert.equal(f.online.snapshot().connection, 'connected')
})
test('ожидающее окончание старой группы не блокирует окончание новой короткой группы', async (t) => {
const f = await fixture(t); const music = groupedMusic(2)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }; f.model.remote.music = music
await f.prepare(); f.controller.output(true); await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
let resolve!: (response: Response) => void
const pending = new Promise<Response>((done) => { resolve = done })
let calls = 0
f.model.advanceHandler = () => ++calls === 1 ? pending : Promise.resolve(Response.json({ state: { ...groupedMusic(21, 1), playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 } }))
const report = () => ({ session: f.controller.state.session, channel: 'music' as const, operation: f.controller.state.channels.music.operation, status: 'ended' as const, position: .1, duration: .1 })
const old = f.online.musicEnded(report())
await eventually(() => calls === 1, 'старый RPC ожидает ответа')
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
await f.online.musicEnded(report())
assert.equal(calls, 2)
assert.equal(f.controller.state.channels.music.groupId, 43)
assert.equal(f.controller.state.channels.music.desired, 'stopped')
resolve(Response.json({ state: groupedMusic(3) })); await old
assert.equal(f.controller.state.channels.music.groupId, 43)
assert.equal(f.controller.state.channels.music.desired, 'stopped')
})
test('группа без подготовленного локального описания не продолжает общий плейлист', async (t) => {
const f = await fixture(t)
await f.prepare(); f.controller.output(true); await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
assert.equal(f.controller.state.channels.music.groupId, 0)
f.model.remote.music = groupedMusic(2)
await f.online.poll()
assert.equal(f.controller.state.channels.music.desired, 'stopped')
assert.match(f.online.snapshot().preparation.error, /Группа/)
f.model.offline = true
await f.online.poll()
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: f.controller.state.channels.music.operation, status: 'ended', position: 4, duration: 4 })
assert.equal(f.controller.state.channels.music.trackIndex, 0)
assert.equal(f.model.advanceCalls, 0)
})