diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index a5ac2d4..8bdf775 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -108,6 +108,10 @@ - Следующий шаг: SCREEN-006 — существующая авторизация, выбор игры/скачивание через интерфейс, снимок результатов и получение команд существующего сервера. +- Исправление по ревизии (2026-10-17): повторный снимок той же таблицы обновляет + дату получения без смены ревизии сцены и сброса прокрутки. Изменение вопросов, + строк или очистка результатов по-прежнему обновляют сцену. Добавлен регрессионный тест. + ## SCREEN-006 — Подключение игры и синхронизация с сервером - Статус: `DONE` @@ -174,3 +178,13 @@ не пересобирались. SCREEN-001/SCREEN-004 сохраняют IN_PROGRESS. - Инструкция: 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; физический звук, + проектор и остальные ОС не проверялись, релизные архивы не пересобирались. diff --git a/README.md b/README.md index 0071ed5..41d1551 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,8 @@ capturedAt, вопросы и публичные строки таблицы (ID фиктивные результаты в приложении не создаются. Длинная таблица прокручивается снизу вверх, обновления позиций медиа её не перезапускают. Сетевое получение и сохранение снимка реализуются в SCREEN-006; публичного IPC редактирования результатов нет. +Повторный снимок с той же таблицей обновляет дату без перезапуска прокрутки; +изменение вопросов или строк таблицы начинает показ результатов заново. `npm run acceptance` теперь создаёт локальный пакет с обложкой, фоном и восемью шагами; [инструкция SCREEN-005](docs/SCREEN-005_ACCEPTANCE.md) описывает полный diff --git a/TASKS.md b/TASKS.md index 9c80195..e596f6e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -225,6 +225,10 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе git diff --check — PASS. Руководство и чек-лист обновлены. Архивы кандидата не пересобирались; отличие текущих исходников от архивов явно описано в README. Бэкенд, сайт, protobuf и зависимости не менялись. +- Ревизия SCREEN-005/006 (2026-10-17): исправлены назначение звука после обрыва, + сброс прокрутки неизменной таблицы и пропуск перестановки треков. 77 тестов, + типы, линтеры, сборка, GUI smoke и проверка diff/стандарта — PASS; подробности + в COMPLETED_TASKS.md. Архивы не пересобирались; аппаратная приёмка остаётся открытой. - Открыто: получить macOS x64/arm64 через workflow после commit/push владельцем; выполнить аппаратную матрицу, включая оба Linux-дистрибутива, час офлайн, проектор/звук/телефон и физические замеры на M1. Статус IN_PROGRESS до получения diff --git a/electron/controller.ts b/electron/controller.ts index b324911..4c23acb 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' +import { isDeepStrictEqual } from 'node:util' import type { Catalog, Channel, @@ -348,8 +349,11 @@ export class Controller { const key = this.state.catalog.key if (value !== null && !key) throw new Error('Сначала выберите пакет игры') 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 - if (this.state.scene.mode === 6) this.invalidateScene() + if (hasChanged && this.state.scene.mode === 6) this.invalidateScene() this.broadcast() } reportScene(value: unknown) { diff --git a/electron/online.test.ts b/electron/online.test.ts index 825cdb4..747850b 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -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) => { const f = await fixture(t) await f.prepare() diff --git a/electron/online.ts b/electron/online.ts index bd285d4..218c8a9 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -90,7 +90,7 @@ export class Online { private previous: RemoteState | null = null private pollBusy = false private pollSession = '' - private initialSound = true + private lastSound: boolean | null = null private lastResults = -Infinity private resultsBusy = false private advanceBusy = false @@ -179,7 +179,6 @@ export class Online { this.screen = c.screen this.state.screenId = c.screen.id this.state.connection = 'waiting' - this.initialSound = true } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') @@ -221,6 +220,7 @@ export class Online { this.generation++ this.screen = null this.previous = null + this.lastSound = null this.state.soundEnabled = null this.state.screenId = null this.state.connection = 'disabled' @@ -483,7 +483,6 @@ export class Online { this.screen = s this.state.screenId = s.id this.state.connection = 'waiting' - this.initialSound = true await this.persistSecrets() return } @@ -624,10 +623,10 @@ export class Online { this.state.soundEnabled = next.sound if (this.state.connection === 'offline') this.state.error = '' this.state.connection = 'connected' - if (!previous && this.initialSound) { - this.initialSound = false + if (!previous && this.lastSound !== next.sound) { send(this.controller, { action: 'mute', enabled: !next.sound }) } + this.lastSound = next.sound if (previous) { try { applyRemote(this.controller, previous, next) diff --git a/electron/presentation.test.ts b/electron/presentation.test.ts index 00fdacf..86cdadd 100644 --- a/electron/presentation.test.ts +++ b/electron/presentation.test.ts @@ -252,6 +252,34 @@ test('снимок результатов ограничен текущей иг f.c.selectPackage(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('не принимает повреждённые снимки, повторные команды и неизвестные коды ответов', () => { const f = fixture() for (const mutate of [ diff --git a/electron/remote-state.ts b/electron/remote-state.ts index 172a1fc..d063c1e 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -189,7 +189,7 @@ export function applyRemote(controller: Controller, previous: RemoteState, next: if ( !playlist || 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) send(controller, { action: 'stop', channel: 'music' })