501 lines
17 KiB
TypeScript
501 lines
17 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { setImmediate } from 'node:timers/promises'
|
|
import { test, type TestContext } from 'node:test'
|
|
import { Controller } from './controller.js'
|
|
import { MediaPlayer } from '../src/utils/media-player.js'
|
|
import { buildScreenPackage } from './screen-package.js'
|
|
import { packageKey, type LocalPackage } from './package-store.js'
|
|
import type { Intent, Report } from '../src/types/screen.js'
|
|
import { applyRemote, parseRemote } from './remote-state.js'
|
|
|
|
function send(c: Controller, intent: Intent) {
|
|
c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent })
|
|
}
|
|
function testPackage(): LocalPackage {
|
|
const pkg = buildScreenPackage('https://example.test', 7, {
|
|
game: {
|
|
game: {
|
|
ID: 7,
|
|
name: 'Тест',
|
|
scenario: {
|
|
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
|
|
},
|
|
},
|
|
},
|
|
plan: {},
|
|
music: {
|
|
gamePlaylistId: 1,
|
|
playlists: [
|
|
{
|
|
ID: 1,
|
|
name: 'Музыка',
|
|
tracks: [
|
|
{ ID: 1, name: 'Первый', filename: 'one.wav', position: 0 },
|
|
{ ID: 2, name: 'Второй', filename: 'two.wav', position: 1 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
return {
|
|
formatVersion: 1,
|
|
key: packageKey(pkg.server, 7),
|
|
version: 'a'.repeat(64),
|
|
package: pkg,
|
|
files: pkg.assets.map((asset) => ({
|
|
id: asset.id,
|
|
size: 1,
|
|
sha256: 'b'.repeat(64),
|
|
mime: 'audio/wav',
|
|
})),
|
|
}
|
|
}
|
|
|
|
class FakeMedia extends EventTarget {
|
|
src = ''
|
|
readyState = 3
|
|
duration = 180
|
|
paused = true
|
|
muted = false
|
|
volume = 1
|
|
preload = 'auto'
|
|
seeking = false
|
|
ended = false
|
|
error: MediaError | null = null
|
|
playCalls = 0
|
|
delayPlay = false
|
|
pending: (() => void)[] = []
|
|
private position = 0
|
|
get currentTime() {
|
|
return this.position
|
|
}
|
|
set currentTime(value: number) {
|
|
this.position = value
|
|
this.ended = false
|
|
queueMicrotask(() => this.dispatchEvent(new Event('seeked')))
|
|
}
|
|
getAttribute(name: string) {
|
|
return name === 'src' ? this.src || null : null
|
|
}
|
|
removeAttribute(name: string) {
|
|
if (name === 'src') this.src = ''
|
|
}
|
|
load() {
|
|
this.error = null
|
|
this.readyState = this.src ? 3 : 0
|
|
this.position = 0
|
|
this.ended = false
|
|
}
|
|
pause() {
|
|
this.paused = true
|
|
}
|
|
play() {
|
|
this.playCalls++
|
|
if (this.delayPlay)
|
|
return new Promise<void>((resolve) =>
|
|
this.pending.push(() => {
|
|
this.paused = false
|
|
resolve()
|
|
}),
|
|
)
|
|
this.paused = false
|
|
return Promise.resolve()
|
|
}
|
|
finish() {
|
|
this.ended = true
|
|
this.paused = true
|
|
this.dispatchEvent(new Event('ended'))
|
|
}
|
|
}
|
|
async function fixture(t: TestContext) {
|
|
const audio = new FakeMedia(),
|
|
video = new FakeMedia(),
|
|
a = new FakeMedia(),
|
|
b = new FakeMedia()
|
|
const reports: Report[] = []
|
|
const c = new Controller(() => {})
|
|
c.selectPackage(testPackage())
|
|
c.output(true)
|
|
const player = new MediaPlayer(
|
|
{
|
|
audio: audio as unknown as HTMLAudioElement,
|
|
video: video as unknown as HTMLVideoElement,
|
|
music: [a as unknown as HTMLAudioElement, b as unknown as HTMLAudioElement],
|
|
},
|
|
(report) => reports.push(report),
|
|
)
|
|
t.after(() => player.destroy())
|
|
const apply = () => player.apply(structuredClone(c.state))
|
|
apply()
|
|
await setImmediate()
|
|
return { c, player, audio, video, a, b, reports, apply }
|
|
}
|
|
|
|
test('DOM: pause не возвращает позицию к последнему отчёту, seek сохраняет паузу', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.currentTime = 44.5
|
|
send(f.c, { action: 'pause', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.audio.currentTime, 44.5)
|
|
f.c.report(f.reports.at(-1)!)
|
|
send(f.c, { action: 'seek', channel: 'main', position: 90 })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.currentTime, 90)
|
|
assert.equal(f.audio.paused, true)
|
|
send(f.c, { action: 'resume', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.currentTime, 90)
|
|
assert.equal(f.audio.paused, false)
|
|
send(f.c, { action: 'replay', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.currentTime, 0)
|
|
})
|
|
test('DOM: поздний play не оживляет приостановленный или сменившийся канал', async (t) => {
|
|
const f = await fixture(t)
|
|
f.audio.delayPlay = true
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
send(f.c, { action: 'pause', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.pending.shift()!()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
send(f.c, { action: 'select', channel: 'main', sourceId: 'conclusion' })
|
|
f.apply()
|
|
await setImmediate()
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.pending.shift()!()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.video.paused, false)
|
|
assert.equal(f.reports.filter((r) => r.status === 'playing' && r.channel === 'main').length, 1)
|
|
})
|
|
test('DOM: музыка независима, только следующий трек подготовлен без воспроизведения', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const playing = [f.a, f.b].find((media) => !media.paused)!
|
|
const spare = [f.a, f.b].find((media) => media !== playing)!
|
|
assert.ok(spare.src.includes(f.c.state.catalog.playlists[0]!.tracks[1]!.source.url))
|
|
assert.equal(spare.playCalls, 0)
|
|
assert.equal(spare.muted, true)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
send(f.c, { action: 'resume', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(playing.paused, false)
|
|
assert.equal(f.audio.paused, false)
|
|
f.audio.currentTime = 27
|
|
send(f.c, { action: 'volume', channel: 'main', volume: 80 })
|
|
send(f.c, { action: 'mute', enabled: true })
|
|
f.apply()
|
|
assert.equal(f.audio.volume, 0.8)
|
|
assert.equal(f.audio.currentTime, 27)
|
|
assert.equal(f.audio.muted, true)
|
|
assert.equal(playing.muted, true)
|
|
send(f.c, { action: 'next', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(playing.paused, true)
|
|
assert.equal(spare.paused, false)
|
|
assert.equal(f.audio.paused, false)
|
|
})
|
|
test('DOM: повторный snapshot не повторяет play, новая сессия не запускает звук', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const old = structuredClone(f.c.state)
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.playCalls, 1)
|
|
f.c.output(false)
|
|
f.c.output(true)
|
|
f.apply()
|
|
await setImmediate()
|
|
f.player.apply(old)
|
|
await setImmediate()
|
|
assert.equal(f.audio.playCalls, 1)
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.audio.currentTime, 0)
|
|
})
|
|
test('DOM: ошибка файла подтверждается и не мешает другому каналу', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
f.audio.dispatchEvent(new Event('error'))
|
|
assert.ok(f.reports.some((r) => r.channel === 'main' && r.status === 'error'))
|
|
assert.equal([f.a, f.b].filter((media) => !media.paused).length, 1)
|
|
})
|
|
|
|
test('DOM: быстрые play → seek → pause не теряют ещё не применённую перемотку', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
send(f.c, { action: 'seek', channel: 'main', position: 90 })
|
|
f.apply()
|
|
send(f.c, { action: 'pause', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.paused, true)
|
|
assert.equal(f.audio.currentTime, 90)
|
|
assert.equal(f.reports.at(-1)!.status, 'paused')
|
|
})
|
|
|
|
test('DOM: повторный play перезагружает источник после ошибки чтения', async (t) => {
|
|
const f = await fixture(t)
|
|
f.audio.error = { code: 2, message: 'Read error' } as MediaError
|
|
f.audio.dispatchEvent(new Event('error'))
|
|
f.c.report(f.reports.at(-1)!)
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(f.audio.error, null)
|
|
assert.equal(f.audio.paused, false)
|
|
assert.equal(f.reports.at(-1)!.status, 'playing')
|
|
})
|
|
|
|
test('DOM: следующий трек не предзагружается до завершения локального файла', async (t) => {
|
|
const f = await fixture(t)
|
|
const next = f.c.state.catalog.playlists[0]!.tracks[1]!.source
|
|
next.ready = false
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const playing = [f.a, f.b].find((media) => !media.paused)!
|
|
const spare = [f.a, f.b].find((media) => media !== playing)!
|
|
assert.equal(spare.src, '')
|
|
f.c.markAssetReady(next.url.split('/').at(-1)!)
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(spare.src, next.url)
|
|
assert.equal(spare.playCalls, 0)
|
|
send(f.c, { action: 'next', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(spare.paused, false)
|
|
assert.equal(playing.paused, true)
|
|
})
|
|
|
|
test('DOM: пауза музыки затихает за секунду, snapshots не возвращают громкость и позицию', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'volume', channel: 'music', volume: 80 })
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const media = [f.a, f.b].find((m) => !m.paused)!
|
|
media.currentTime = 44
|
|
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
|
|
send(f.c, { action: 'pause', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(media.paused, false)
|
|
t.mock.timers.tick(500)
|
|
assert.ok(Math.abs(media.volume - 0.4) < 0.01)
|
|
assert.equal(f.c.state.channels.music.volume, 80)
|
|
f.c.state.revision++
|
|
f.apply()
|
|
assert.ok(Math.abs(media.volume - 0.4) < 0.01)
|
|
// Повтор команды тоже не продлевает затухание.
|
|
send(f.c, { action: 'pause', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
media.currentTime = 45
|
|
t.mock.timers.tick(500)
|
|
await setImmediate()
|
|
assert.equal(media.paused, true)
|
|
assert.equal(media.volume, 0.8)
|
|
assert.equal(media.currentTime, 45)
|
|
const report = f.reports.at(-1)!
|
|
assert.equal(report.status, 'paused')
|
|
assert.equal(report.position, 45)
|
|
f.c.report(report)
|
|
assert.equal(f.c.state.channels.music.position, 45)
|
|
})
|
|
|
|
test('DOM: продолжение отменяет затухание, изменение громкости не сбрасывает его', async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const media = [f.a, f.b].find((m) => !m.paused)!
|
|
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
|
|
send(f.c, { action: 'pause', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
t.mock.timers.tick(500)
|
|
send(f.c, { action: 'volume', channel: 'music', volume: 60 })
|
|
f.apply()
|
|
assert.ok(Math.abs(media.volume - 0.3) < 0.01)
|
|
send(f.c, { action: 'resume', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(media.volume, 0.6)
|
|
assert.equal(media.paused, false)
|
|
t.mock.timers.tick(1500)
|
|
assert.equal(media.paused, false)
|
|
assert.equal(media.volume, 0.6)
|
|
})
|
|
|
|
for (const action of ['stop', 'next', 'error', 'session', 'destroy'] as const) {
|
|
test(`DOM: затухание отменяется при ${action}, поздняя пауза не затрагивает новый звук`, async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const media = [f.a, f.b].find((m) => !m.paused)!
|
|
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
|
|
send(f.c, { action: 'pause', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
t.mock.timers.tick(400)
|
|
if (action === 'destroy') f.player.destroy()
|
|
else if (action === 'error') {
|
|
media.error = { code: 3 } as MediaError
|
|
media.dispatchEvent(new Event('error'))
|
|
} else if (action === 'session') {
|
|
f.c.selectPackage(testPackage())
|
|
f.apply()
|
|
} else {
|
|
send(f.c, { action, channel: 'music' })
|
|
f.apply()
|
|
}
|
|
await setImmediate()
|
|
assert.equal(media.paused, true)
|
|
if (action === 'next' || action === 'stop') {
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const playing = [f.a, f.b].find((m) => !m.paused)!
|
|
assert.ok(playing)
|
|
t.mock.timers.tick(1500)
|
|
assert.equal(playing.paused, false)
|
|
assert.equal(playing.volume, f.c.state.channels.music.volume / 100)
|
|
} else {
|
|
const reports = f.reports.length
|
|
t.mock.timers.tick(1500)
|
|
assert.equal(f.reports.length, reports)
|
|
assert.equal(f.a.paused, true)
|
|
assert.equal(f.b.paused, true)
|
|
}
|
|
})
|
|
}
|
|
|
|
for (const silent of ['muted', 'zero', 'stopped'] as const) {
|
|
test(`DOM: беззвучная музыка ставится на паузу сразу: ${silent}`, async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const media = [f.a, f.b].find((m) => !m.paused)!
|
|
if (silent === 'muted') send(f.c, { action: 'mute', enabled: true })
|
|
if (silent === 'zero') send(f.c, { action: 'volume', channel: 'music', volume: 0 })
|
|
if (silent === 'stopped') {
|
|
send(f.c, { action: 'stop', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
}
|
|
send(f.c, { action: 'pause', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(media.paused, true)
|
|
assert.equal(f.reports.at(-1)!.status, 'paused')
|
|
})
|
|
}
|
|
|
|
for (const mode of [2, 4]) {
|
|
test(`DOM: серверная автопауза при режиме ${mode} затихает и возобновляется без остановки основного канала`, async (t) => {
|
|
const f = await fixture(t)
|
|
const music = (version: number, playback: number, positionMs: number) => ({
|
|
stateVersion: String(version), playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] },
|
|
trackIndex: 0, playback, positionMs, volume: 80, loop: true,
|
|
})
|
|
const playing = parseRemote({ mode: 1, stateVersion: '1', music: music(1, 2, 0) })
|
|
applyRemote(f.c, parseRemote({}), playing)
|
|
f.apply()
|
|
await setImmediate()
|
|
const media = [f.a, f.b].find((m) => !m.paused)!
|
|
media.currentTime = 12
|
|
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
|
|
const paused = parseRemote({ mode, command: 1, stateVersion: '2', music: music(2, 3, 11500) })
|
|
const seekOperation = f.c.state.channels.music.seekOperation
|
|
applyRemote(f.c, playing, paused)
|
|
assert.equal(f.c.state.channels.music.seekOperation, seekOperation)
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(media.paused, false)
|
|
t.mock.timers.tick(500)
|
|
assert.ok(Math.abs(media.volume - 0.4) < 0.01)
|
|
assert.equal((mode === 2 ? f.audio : f.video).paused, false)
|
|
media.currentTime = 13
|
|
t.mock.timers.tick(500)
|
|
await setImmediate()
|
|
assert.equal(media.paused, true)
|
|
assert.equal(media.currentTime, 13)
|
|
const resumed = parseRemote({ mode: 1, stateVersion: '3', music: music(3, 2, 11500) })
|
|
applyRemote(f.c, paused, resumed)
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(media.paused, false)
|
|
assert.equal(media.volume, 0.8)
|
|
assert.equal(media.currentTime, 11.5)
|
|
// A separate seek in the paused state still synchronizes position.
|
|
applyRemote(f.c, resumed, parseRemote({ mode: 1, stateVersion: '4', music: music(4, 3, 14000) }))
|
|
const previous = parseRemote({ mode: 1, stateVersion: '4', music: music(4, 3, 14000) })
|
|
applyRemote(f.c, previous, parseRemote({ mode: 1, stateVersion: '5', music: music(5, 3, 15000) }))
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(media.currentTime, 15)
|
|
assert.equal(media.paused, true)
|
|
})
|
|
}
|
|
|
|
for (const mode of [2, 4] as const) {
|
|
test(`DOM: локальная автопауза на ${mode} затихает и продолжается с фактической позиции`, async (t) => {
|
|
const f = await fixture(t)
|
|
send(f.c, { action: 'volume', channel: 'music', volume: 80 })
|
|
send(f.c, { action: 'play', channel: 'music' })
|
|
f.apply()
|
|
await setImmediate()
|
|
const media = [f.a, f.b].find(m => !m.paused)!
|
|
media.currentTime = 12
|
|
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 })
|
|
send(f.c, { action: 'mode', mode })
|
|
send(f.c, { action: 'play', channel: 'main' })
|
|
f.apply()
|
|
await setImmediate()
|
|
t.mock.timers.tick(500)
|
|
assert.equal(media.paused, false)
|
|
assert.ok(Math.abs(media.volume - 0.4) < 0.01)
|
|
assert.equal((mode === 2 ? f.audio : f.video).paused, false)
|
|
media.currentTime = 13
|
|
t.mock.timers.tick(500)
|
|
await setImmediate()
|
|
assert.equal(media.paused, true)
|
|
f.c.report(f.reports.filter(report => report.channel === 'music' && report.status === 'paused').at(-1)!)
|
|
send(f.c, { action: 'mode', mode: 1 })
|
|
f.apply()
|
|
await setImmediate()
|
|
assert.equal(media.paused, false)
|
|
assert.equal(media.currentTime, 13)
|
|
assert.equal(media.volume, 0.8)
|
|
})
|
|
}
|