up
This commit is contained in:
@@ -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; физический звук,
|
||||||
|
проектор и остальные ОС не проверялись, релизные архивы не пересобирались.
|
||||||
|
|||||||
@@ -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) описывает полный
|
||||||
|
|||||||
@@ -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 до получения
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
Reference in New Issue
Block a user