This commit is contained in:
2026-10-17 22:35:50 +07:00
parent 688d900cdc
commit beb8ca8f16
8 changed files with 107 additions and 7 deletions
+14
View File
@@ -108,6 +108,10 @@
- Следующий шаг: SCREEN-006 — существующая авторизация, выбор игры/скачивание - Следующий шаг: SCREEN-006 — существующая авторизация, выбор игры/скачивание
через интерфейс, снимок результатов и получение команд существующего сервера. через интерфейс, снимок результатов и получение команд существующего сервера.
- Исправление по ревизии (2026-10-17): повторный снимок той же таблицы обновляет
дату получения без смены ревизии сцены и сброса прокрутки. Изменение вопросов,
строк или очистка результатов по-прежнему обновляют сцену. Добавлен регрессионный тест.
## SCREEN-006 — Подключение игры и синхронизация с сервером ## SCREEN-006 — Подключение игры и синхронизация с сервером
- Статус: `DONE` - Статус: `DONE`
@@ -174,3 +178,13 @@
не пересобирались. SCREEN-001/SCREEN-004 сохраняют IN_PROGRESS. не пересобирались. SCREEN-001/SCREEN-004 сохраняют IN_PROGRESS.
- Инструкция: docs/SCREEN-006_ACCEPTANCE.md. Следующий этап: SCREEN-007 — - Инструкция: docs/SCREEN-006_ACCEPTANCE.md. Следующий этап: SCREEN-007 —
устойчивость при отключении дисплея, управление сном и восстановление окон. устойчивость при отключении дисплея, управление сном и восстановление окон.
- Исправления по ревизии (2026-10-17, SCREEN-005/006): последнее полученное
назначение звука сохраняется отдельно от исходной отметки команд. Изменение
назначения после обрыва сети или повторного открытия показа применяется;
неизменное назначение не отменяет локальный mute. Перестановка тех же треков
теперь останавливает музыку с требованием повторной подготовки пакета.
- Проверка исправлений: 77 тестов (3 новых), type-check, ESLint, Stylelint,
сборка, GUI smoke и git diff --check — PASS. Вручную проверен весь diff на
применимые требования стандарта; нарушений не найдено. GUI: Linux x64,
синтетический дисплей, no-sandbox/mute-audio/disable-gpu; физический звук,
проектор и остальные ОС не проверялись, релизные архивы не пересобирались.
+2
View File
@@ -256,6 +256,8 @@ capturedAt, вопросы и публичные строки таблицы (ID
фиктивные результаты в приложении не создаются. Длинная таблица прокручивается фиктивные результаты в приложении не создаются. Длинная таблица прокручивается
снизу вверх, обновления позиций медиа её не перезапускают. Сетевое получение и снизу вверх, обновления позиций медиа её не перезапускают. Сетевое получение и
сохранение снимка реализуются в SCREEN-006; публичного IPC редактирования результатов нет. сохранение снимка реализуются в SCREEN-006; публичного IPC редактирования результатов нет.
Повторный снимок с той же таблицей обновляет дату без перезапуска прокрутки;
изменение вопросов или строк таблицы начинает показ результатов заново.
`npm run acceptance` теперь создаёт локальный пакет с обложкой, фоном и восемью `npm run acceptance` теперь создаёт локальный пакет с обложкой, фоном и восемью
шагами; [инструкция SCREEN-005](docs/SCREEN-005_ACCEPTANCE.md) описывает полный шагами; [инструкция SCREEN-005](docs/SCREEN-005_ACCEPTANCE.md) описывает полный
+4
View File
@@ -225,6 +225,10 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
git diff --check — PASS. Руководство и чек-лист обновлены. Архивы кандидата git diff --check — PASS. Руководство и чек-лист обновлены. Архивы кандидата
не пересобирались; отличие текущих исходников от архивов явно описано в README. не пересобирались; отличие текущих исходников от архивов явно описано в README.
Бэкенд, сайт, protobuf и зависимости не менялись. Бэкенд, сайт, protobuf и зависимости не менялись.
- Ревизия SCREEN-005/006 (2026-10-17): исправлены назначение звука после обрыва,
сброс прокрутки неизменной таблицы и пропуск перестановки треков. 77 тестов,
типы, линтеры, сборка, GUI smoke и проверка diff/стандарта — PASS; подробности
в COMPLETED_TASKS.md. Архивы не пересобирались; аппаратная приёмка остаётся открытой.
- Открыто: получить macOS x64/arm64 через workflow после commit/push владельцем; - Открыто: получить macOS x64/arm64 через workflow после commit/push владельцем;
выполнить аппаратную матрицу, включая оба Linux-дистрибутива, час офлайн, выполнить аппаратную матрицу, включая оба Linux-дистрибутива, час офлайн,
проектор/звук/телефон и физические замеры на M1. Статус IN_PROGRESS до получения проектор/звук/телефон и физические замеры на M1. Статус IN_PROGRESS до получения
+5 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks' import { performance } from 'node:perf_hooks'
import { isDeepStrictEqual } from 'node:util'
import type { import type {
Catalog, Catalog,
Channel, Channel,
@@ -348,8 +349,11 @@ export class Controller {
const key = this.state.catalog.key const key = this.state.catalog.key
if (value !== null && !key) throw new Error('Сначала выберите пакет игры') if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
const snapshot = value === null ? null : parseResults(value, key!) const snapshot = value === null ? null : parseResults(value, key!)
const hasChanged =
!isDeepStrictEqual(this.state.results?.questions, snapshot?.questions) ||
!isDeepStrictEqual(this.state.results?.teams, snapshot?.teams)
this.state.results = snapshot this.state.results = snapshot
if (this.state.scene.mode === 6) this.invalidateScene() if (hasChanged && this.state.scene.mode === 6) this.invalidateScene()
this.broadcast() this.broadcast()
} }
reportScene(value: unknown) { reportScene(value: unknown) {
+49
View File
@@ -415,6 +415,55 @@ test('музыкальная громкость не возвращает ста
) )
}) })
test('перестановка прежних треков останавливает музыку до повторной подготовки', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'music' })
const previous = parseRemote({ music: objectMusic(1) })
const next = parseRemote({
music: { ...objectMusic(2), playlist: { ID: 1, tracks: [{ ID: 2 }, { ID: 1 }] } },
})
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])
})
test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
await f.online.poll()
assert.equal(f.controller.state.muted, false)
send(f.controller, { action: 'mute', enabled: true })
f.model.offline = true
await f.online.poll()
f.model.offline = false
await f.online.poll()
assert.equal(f.controller.state.muted, true)
send(f.controller, { action: 'mute', enabled: false })
f.model.offline = true
await f.online.poll()
f.model.remote.soundEnabled = false
f.model.offline = false
await f.online.poll()
await f.online.poll()
assert.equal(f.controller.state.muted, true)
assert.equal(f.controller.state.channels.main.desired, 'stopped')
await f.online.act({ action: 'sound' })
f.model.remote.soundEnabled = true
await f.online.poll()
assert.equal(f.controller.state.muted, false)
f.controller.output(false)
f.controller.output(true)
f.model.remote.soundEnabled = false
await f.online.poll()
assert.equal(f.controller.state.muted, true)
assert.equal(f.controller.state.channels.main.desired, 'stopped')
})
test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => { test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => {
const f = await fixture(t) const f = await fixture(t)
await f.prepare() await f.prepare()
+4 -5
View File
@@ -90,7 +90,7 @@ export class Online {
private previous: RemoteState | null = null private previous: RemoteState | null = null
private pollBusy = false private pollBusy = false
private pollSession = '' private pollSession = ''
private initialSound = true private lastSound: boolean | null = null
private lastResults = -Infinity private lastResults = -Infinity
private resultsBusy = false private resultsBusy = false
private advanceBusy = false private advanceBusy = false
@@ -179,7 +179,6 @@ export class Online {
this.screen = c.screen this.screen = c.screen
this.state.screenId = c.screen.id this.state.screenId = c.screen.id
this.state.connection = 'waiting' this.state.connection = 'waiting'
this.initialSound = true
} }
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
@@ -221,6 +220,7 @@ export class Online {
this.generation++ this.generation++
this.screen = null this.screen = null
this.previous = null this.previous = null
this.lastSound = null
this.state.soundEnabled = null this.state.soundEnabled = null
this.state.screenId = null this.state.screenId = null
this.state.connection = 'disabled' this.state.connection = 'disabled'
@@ -483,7 +483,6 @@ export class Online {
this.screen = s this.screen = s
this.state.screenId = s.id this.state.screenId = s.id
this.state.connection = 'waiting' this.state.connection = 'waiting'
this.initialSound = true
await this.persistSecrets() await this.persistSecrets()
return return
} }
@@ -624,10 +623,10 @@ export class Online {
this.state.soundEnabled = next.sound this.state.soundEnabled = next.sound
if (this.state.connection === 'offline') this.state.error = '' if (this.state.connection === 'offline') this.state.error = ''
this.state.connection = 'connected' this.state.connection = 'connected'
if (!previous && this.initialSound) { if (!previous && this.lastSound !== next.sound) {
this.initialSound = false
send(this.controller, { action: 'mute', enabled: !next.sound }) send(this.controller, { action: 'mute', enabled: !next.sound })
} }
this.lastSound = next.sound
if (previous) { if (previous) {
try { try {
applyRemote(this.controller, previous, next) applyRemote(this.controller, previous, next)
+28
View File
@@ -252,6 +252,34 @@ test('снимок результатов ограничен текущей иг
f.c.selectPackage(null) f.c.selectPackage(null)
assert.equal(f.c.state.results, null) assert.equal(f.c.state.results, null)
}) })
test('новая дата тех же результатов сохраняет готовую сцену; изменение таблицы обновляет её', () => {
const f = fixture()
f.c.output(true)
f.send({ action: 'mode', mode: 6 })
const data = snapshot(f.key)
f.c.setResults(data)
const revision = f.c.state.scene.revision
f.c.reportScene({ session: f.c.state.session, revision, status: 'ready' })
data.capturedAt = '2026-10-17T10:00:05Z'
f.c.setResults(structuredClone(data))
assert.equal(f.c.state.results!.capturedAt, '2026-10-17T10:00:05.000Z')
assert.equal(f.c.state.scene.revision, revision)
assert.equal(f.c.state.scene.status, 'ready')
data.teams[0]!.totalScore = 10
f.c.setResults(data)
assert.equal(f.c.state.scene.revision, revision + 1)
assert.equal(f.c.state.results!.teams[0]!.totalScore, 10)
data.questions[0]!.text = 'Исправленный вопрос'
f.c.setResults(data)
assert.equal(f.c.state.scene.revision, revision + 2)
f.c.setResults(null)
assert.equal(f.c.state.scene.revision, revision + 3)
f.c.setResults(null)
assert.equal(f.c.state.scene.revision, revision + 3)
})
test('не принимает повреждённые снимки, повторные команды и неизвестные коды ответов', () => { test('не принимает повреждённые снимки, повторные команды и неизвестные коды ответов', () => {
const f = fixture() const f = fixture()
for (const mutate of [ for (const mutate of [
+1 -1
View File
@@ -189,7 +189,7 @@ export function applyRemote(controller: Controller, previous: RemoteState, next:
if ( if (
!playlist || !playlist ||
playlist.tracks.length !== m.trackIds.length || playlist.tracks.length !== m.trackIds.length ||
m.trackIds.some((id) => !playlist.tracks.some((t) => t.id === id)) m.trackIds.some((id, index) => playlist.tracks[index]!.id !== id)
) { ) {
if (controller.state.channels.music.source) if (controller.state.channels.music.source)
send(controller, { action: 'stop', channel: 'music' }) send(controller, { action: 'stop', channel: 'music' })