up
This commit is contained in:
+322
-2
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
|
||||
import { Controller } from './controller.js'
|
||||
import { Online, type SecretStorage } from './online.js'
|
||||
import { PackageStore } from './package-store.js'
|
||||
import { parsePlan } from './screen-plan.js'
|
||||
import { applyRemote, parseRemote, send } from './remote-state.js'
|
||||
|
||||
async function fixture(t: TestContext, secure = true) {
|
||||
@@ -49,6 +50,12 @@ async function fixture(t: TestContext, secure = true) {
|
||||
positionMs: '0',
|
||||
volume: 80,
|
||||
} as Record<string, unknown>,
|
||||
plan: parsePlan({ steps: [{ mode: 2 }] }),
|
||||
planError: 0,
|
||||
planSaves: 0,
|
||||
planTransitions: 0,
|
||||
planResponse: null as (() => Promise<Response>) | null,
|
||||
stepResponse: null as (() => Promise<Response>) | null,
|
||||
stateResponse: null as (() => Promise<Response>) | null,
|
||||
}
|
||||
const json = (value: unknown, status = 200) => Response.json(value, { status })
|
||||
@@ -131,7 +138,43 @@ async function fixture(t: TestContext, secure = true) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if (path === '/api/games/7/screen-plan') return json({ plan: { steps: [{ mode: 2 }] } })
|
||||
if (path === '/api/games/7/screen-plan') {
|
||||
if (model.planResponse) return model.planResponse()
|
||||
if (model.planError) return json({}, model.planError)
|
||||
if (init?.method === 'PUT') {
|
||||
model.planSaves++
|
||||
model.plan = parsePlan(JSON.parse(String(init.body)))
|
||||
}
|
||||
return json({ plan: model.plan.steps.length ? model.plan : null })
|
||||
}
|
||||
if (path === '/api/games/7/screen-plan/step') {
|
||||
if (model.stepResponse) return model.stepResponse()
|
||||
model.planTransitions++
|
||||
if (model.planError) return json({}, model.planError)
|
||||
const data = JSON.parse(String(init?.body)) as { currentStep: number }
|
||||
const step = model.plan.steps[data.currentStep]
|
||||
if (!step) return json({}, 400)
|
||||
model.plan.currentStep = data.currentStep
|
||||
model.remote = {
|
||||
...model.remote,
|
||||
mode: step.mode,
|
||||
text: step.text,
|
||||
command: 0,
|
||||
timerLabel: step.timerLabel,
|
||||
timerDurationSeconds: step.timerDurationSeconds || 600,
|
||||
timerState: 1,
|
||||
timerRemainingSeconds: step.timerDurationSeconds,
|
||||
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
return json({ plan: model.plan })
|
||||
}
|
||||
if (path === '/api/games/7/screens/command') {
|
||||
if (model.planError) return json({}, model.planError)
|
||||
const data = JSON.parse(String(init?.body)) as { command: number }
|
||||
model.remote.command = data.command
|
||||
model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n)
|
||||
return json({})
|
||||
}
|
||||
if (path === '/api/games/7/music')
|
||||
return json({
|
||||
gamePlaylistId: 1,
|
||||
@@ -426,7 +469,10 @@ test('перестановка прежних треков останавлив
|
||||
})
|
||||
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка изменилась/)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
assert.deepEqual(f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id), [1, 2])
|
||||
assert.deepEqual(
|
||||
f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id),
|
||||
[1, 2],
|
||||
)
|
||||
})
|
||||
|
||||
test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => {
|
||||
@@ -625,3 +671,277 @@ test('назначение звука подтверждается сервер
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const key = f.controller.state.catalog.key!
|
||||
assert.equal(f.online.planStatus().editable, true)
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 'SCREEN_MODE_TEXT', text: ' Новый текст ' },
|
||||
{ mode: 7, timerDurationSeconds: '60' },
|
||||
],
|
||||
}).steps
|
||||
const sceneRevision = f.controller.state.scene.revision
|
||||
await f.online.act({ action: 'plan-save', key, steps })
|
||||
assert.deepEqual(f.model.plan.steps, steps)
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
assert.equal(f.controller.state.scene.revision, sceneRevision)
|
||||
assert.equal(f.model.planSaves, 1)
|
||||
await assert.rejects(
|
||||
f.online.act({ action: 'plan-save', key, steps: [{ mode: 8, text: ' ' }] }),
|
||||
/Текст шага/,
|
||||
)
|
||||
await assert.rejects(
|
||||
f.online.act({ action: 'plan-save', key: 'wrong', steps }),
|
||||
/игра изменилась/,
|
||||
)
|
||||
assert.equal(f.model.planSaves, 1)
|
||||
f.model.planError = 403
|
||||
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Нет доступа/)
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
f.model.planError = 0
|
||||
f.model.offline = true
|
||||
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Сервер недоступен/)
|
||||
const restoredController = new Controller(() => {})
|
||||
const restored = new Online(
|
||||
join(f.directory, 'connection'),
|
||||
f.store,
|
||||
restoredController,
|
||||
f.secrets,
|
||||
f.fetchAPI,
|
||||
)
|
||||
await restored.initialize()
|
||||
assert.deepEqual(restoredController.state.catalog.steps, steps)
|
||||
assert.equal(restored.planStatus().key, key)
|
||||
await restored.select(key)
|
||||
f.model.offline = false
|
||||
await f.online.act({ action: 'plan-save', key, steps: [] })
|
||||
assert.deepEqual(f.controller.state.catalog.steps, [])
|
||||
assert.deepEqual(
|
||||
JSON.parse(await readFile(join(f.directory, 'connection', `plan-${key}.json`), 'utf8')).steps,
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('план: онлайн-переход общий, без автозапуска и повторного применения; офлайн-переход не отправляется после возврата сети', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 7, timerLabel: 'Отдых', timerDurationSeconds: 60 },
|
||||
{ mode: 8, text: 'Локальный шаг' },
|
||||
],
|
||||
}).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
assert.equal(f.model.plan.currentStep, 0)
|
||||
assert.equal(f.controller.state.scene.stepIndex, 0)
|
||||
assert.equal(f.controller.state.timer.status, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
await f.online.planCommand({ action: 'timer-start' })
|
||||
const timerRevision = f.controller.state.timer.revision
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.timer.revision, timerRevision)
|
||||
assert.equal(f.controller.state.timer.status, 'running')
|
||||
f.model.offline = true
|
||||
await f.online.planCommand({ action: 'step', index: 1 })
|
||||
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
||||
assert.match(f.online.planStatus().notice, /локально/)
|
||||
f.model.offline = false
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
||||
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.model.planTransitions, 2)
|
||||
})
|
||||
|
||||
test('план: изменение на сайте между опросом и переходом применяет подтверждённое содержимое', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
await f.online.refreshPlan()
|
||||
f.model.plan = parsePlan({ steps: [{ mode: 8, text: 'Изменено на сайте' }] })
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.controller.state.scene.mode, 8)
|
||||
assert.equal(f.controller.state.scene.text, 'Изменено на сайте')
|
||||
assert.equal(f.controller.state.scene.stepIndex, 0)
|
||||
})
|
||||
|
||||
test('план: поздний ответ старой игры и опрос до сохранения не заменяют новый план', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const key = f.controller.state.catalog.key!
|
||||
let resolve!: (response: Response) => void
|
||||
f.model.planResponse = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const pending = f.online.refreshPlan()
|
||||
await new Promise((done) => setImmediate(done))
|
||||
f.model.planResponse = null
|
||||
const steps = parsePlan({ steps: [{ mode: 8, text: 'Сохранено позже' }] }).steps
|
||||
await f.online.act({ action: 'plan-save', key, steps })
|
||||
resolve(Response.json({ plan: { steps: [{ mode: 2 }] } }))
|
||||
await pending
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
f.model.planResponse = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const oldGame = f.online.refreshPlan()
|
||||
await new Promise((done) => setImmediate(done))
|
||||
await f.online.select(null)
|
||||
resolve(Response.json({ plan: { steps: [{ mode: 8, text: 'Старая игра' }] } }))
|
||||
await oldGame
|
||||
assert.deepEqual(f.controller.state.catalog.steps, [])
|
||||
assert.equal(f.online.planStatus().editable, false)
|
||||
})
|
||||
|
||||
test('план: локальные переходы не ждут незавершённый запрос и поздний ответ не меняет показ', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 8, text: 'Первый' },
|
||||
{ mode: 8, text: 'Второй' },
|
||||
],
|
||||
}).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
let finish!: (response: Response) => void
|
||||
f.model.stepResponse = () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const pending = f.online.planCommand({ action: 'step', index: 0 })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
f.model.stepResponse = null
|
||||
f.model.offline = true
|
||||
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||
assert.equal(f.controller.state.scene.text, 'Второй')
|
||||
finish(Response.json({ plan: { steps: [{ mode: 8, text: 'Устаревший' }], currentStep: 0 } }))
|
||||
await Promise.all([pending, second])
|
||||
assert.equal(f.controller.state.scene.text, 'Второй')
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
})
|
||||
|
||||
test('план: изменения с сайта загружаются при закрытом показе; удалённый переход выделяет актуальный шаг', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.model.plan = parsePlan({
|
||||
steps: [
|
||||
{ mode: 8, text: 'Один' },
|
||||
{ mode: 8, text: 'Два' },
|
||||
],
|
||||
})
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.outputOpen, false)
|
||||
assert.deepEqual(f.controller.state.catalog.steps, f.model.plan.steps)
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
f.model.plan.currentStep = 1
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
mode: 8,
|
||||
text: 'Два',
|
||||
command: 0,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.text, 'Два')
|
||||
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||
})
|
||||
|
||||
test('план: быстрые переходы сериализованы, неотправленные промежуточные шаги пропускаются', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
let finish!: (response: Response) => void
|
||||
f.model.stepResponse = () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const first = f.online.planCommand({ action: 'step', index: 0 })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||
const third = f.online.planCommand({ action: 'step', index: 2 })
|
||||
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
||||
assert.equal(f.model.planTransitions, 0)
|
||||
f.model.stepResponse = null
|
||||
finish(Response.json({ plan: { steps, currentStep: 0 } }))
|
||||
await Promise.all([first, second, third])
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
assert.equal(f.model.plan.currentStep, 2)
|
||||
assert.equal(f.controller.state.scene.stepIndex, 2)
|
||||
})
|
||||
|
||||
test('план: конкурентный серверный таймер не теряется при подтверждении собственного перехода', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 7, timerLabel: 'Первый', timerDurationSeconds: 60 },
|
||||
{ mode: 7, timerLabel: 'Второй', timerDurationSeconds: 120 },
|
||||
],
|
||||
}).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
f.controller.output(true)
|
||||
f.model.stateResponse = async () => {
|
||||
f.model.plan.currentStep = 1
|
||||
return Response.json({
|
||||
state: {
|
||||
gameID: 7,
|
||||
mode: 7,
|
||||
command: 0,
|
||||
stateVersion: '99',
|
||||
timerLabel: 'Второй',
|
||||
timerDurationSeconds: 120,
|
||||
timerRemainingSeconds: 120,
|
||||
},
|
||||
})
|
||||
}
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.controller.state.timer.label, 'Второй')
|
||||
assert.equal(f.controller.state.timer.durationMs, 120000)
|
||||
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||
})
|
||||
|
||||
test('план: сетевой сбой отбрасывает все ожидающие переходы, включая устаревшие промежуточные', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
let fail!: (error: Error) => void
|
||||
f.model.stepResponse = () =>
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject
|
||||
})
|
||||
const first = f.online.planCommand({ action: 'step', index: 0 })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||
const third = f.online.planCommand({ action: 'step', index: 2 })
|
||||
f.model.stepResponse = null
|
||||
fail(new Error('network'))
|
||||
await Promise.all([first, second, third])
|
||||
assert.equal(f.model.planTransitions, 0)
|
||||
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
})
|
||||
|
||||
test('план: первое подтверждение собственной команды учитывает назначение звука', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
f.model.remote.soundEnabled = false
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
assert.equal(f.online.state.soundEnabled, false)
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user