up
This commit is contained in:
+295
-21
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user