From a3317dbf7a50c261cdd0b7ef239302bb13491bce Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 18:00:12 +0700 Subject: [PATCH] up --- electron/online.test.ts | 78 ++++- electron/online.ts | 20 +- scripts/online-smoke.cjs | 92 +++--- scripts/smoke.cjs | 18 +- src/components/ConnectionPanel.vue | 382 ++++++++++++----------- src/components/ControlPage.vue | 216 ++++++++----- src/components/OutputPage.vue | 2 +- src/components/ScreenConnectionPanel.vue | 70 +++-- src/components/ScreenPlanPanel.vue | 307 ++++++++++++------ src/components/ScreenStepPreview.vue | 92 ++++++ src/types/online.ts | 2 +- 11 files changed, 834 insertions(+), 445 deletions(-) create mode 100644 src/components/ScreenStepPreview.vue diff --git a/electron/online.test.ts b/electron/online.test.ts index e7ebb79..fbf5996 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -331,6 +331,38 @@ async function fixture(t: TestContext, secure = true) { prepare, } } +test('подтверждение игры загружает материалы без подключения экрана и сохраняет выбор', async (t) => { + const f = await fixture(t) + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + const selected = f.online.snapshot() + assert.equal(selected.currentGameId, 7) + assert.equal(selected.screenId, null) + assert.equal(selected.connection, 'disabled') + assert.ok(selected.materials.length > 0) + assert.ok(selected.materials.every((m) => m.status === 'ready')) + assert.equal(f.model.bindings.length, 0) + assert.ok(!f.requests.some((r) => r.path === '/api/screens')) + const saved = JSON.parse(await readFile(join(f.directory, 'connection', 'selection.json'), 'utf8')) + assert.equal(saved.key, f.controller.state.catalog.key) + await f.online.act({ action: 'connect' }) + assert.equal(f.online.snapshot().screenId, '12') + assert.equal(f.model.bindings.at(-1)!.gameID, 7) +}) + +test('ошибка подготовки и открытый показ не подтверждают выбор игры', async (t) => { + const f = await fixture(t) + await f.login() + f.controller.output(true) + await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 }), /Закройте показ/) + f.controller.output(false) + f.model.failMedia = true + await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 })) + assert.equal(f.controller.state.catalog.key, null) + assert.equal(f.online.snapshot().currentGameId, null) + assert.equal(f.model.bindings.length, 0) +}) + test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => { const f = await fixture(t) assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art') @@ -446,6 +478,50 @@ for (const gameId of [7, 8]) { }) } +for (const status of [200, 404]) { + test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => { + const f = await fixture(t) + await f.prepare() + const key = f.controller.state.catalog.key + const materials = f.online.snapshot().materials + f.model.unbindError = status === 200 ? 0 : status + await f.online.act({ action: 'disconnect' }) + assert.equal(f.requests.at(-1)!.path, '/api/screens/12/unbind') + assert.equal(f.online.snapshot().screenId, null) + assert.equal(f.online.snapshot().connection, 'disabled') + assert.equal(f.controller.state.catalog.key, key) + assert.deepEqual(f.online.snapshot().materials, materials) + const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin')))) + assert.equal(saved.screen, null) + const restored = new Online(join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI) + t.after(() => restored.close()) + await restored.initialize() + assert.equal(restored.snapshot().screenId, null) + assert.equal(restored.snapshot().currentGameId, 7) + const count = f.requests.length + await f.online.act({ action: 'disconnect' }) + assert.equal(f.requests.length, count) + }) +} + +for (const failure of ['server', 'network'] as const) { + test(`ошибка отключения сохраняет сессию для повтора: ${failure}`, async (t) => { + const f = await fixture(t) + await f.prepare() + const saved = await readFile(join(f.directory, 'connection', 'credentials.bin')) + f.model.unbindError = failure === 'server' ? 500 : 0 + f.model.offline = failure === 'network' + await assert.rejects(f.online.act({ action: 'disconnect' })) + assert.equal(f.online.snapshot().screenId, '12') + assert.equal(f.online.snapshot().connection, 'waiting') + assert.deepEqual(await readFile(join(f.directory, 'connection', 'credentials.bin')), saved) + f.model.unbindError = 0 + f.model.offline = false + await f.online.act({ action: 'disconnect' }) + assert.equal(f.online.snapshot().screenId, null) + }) +} + test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => { const f = await fixture(t) await f.prepare() @@ -1001,7 +1077,7 @@ test('назначение звука подтверждается сервер const unbindsBeforeDisconnect = f.requests.filter((r) => r.path.endsWith('/unbind')).length await f.online.act({ action: 'disconnect' }) assert.equal(f.online.state.soundEnabled, null) - assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect) + assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect + 1) }) test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => { diff --git a/electron/online.ts b/electron/online.ts index c28d712..ca956f1 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -1171,6 +1171,7 @@ export class Online { login: 'Вход на сервер…', logout: 'Выход из аккаунта…', games: 'Загрузка списка игр…', + 'select-game': 'Подготовка выбранной игры…', connect: 'Подключение игры…', disconnect: 'Отключение игры…', download: 'Загрузка материалов игры…', @@ -1278,10 +1279,13 @@ export class Online { return case 'games': return this.loadGames() + case 'select-game': case 'download': case 'check': { const gameId = integer(data.gameId, 1) const key = packageKey(this.state.server, gameId) + if (action === 'select-game' && this.controller.state.outputOpen) + throw new Error('Закройте показ перед выбором игры') if ( action === 'download' && key === this.controller.state.catalog.key && @@ -1289,7 +1293,7 @@ export class Online { ) throw new Error('Закройте показ перед обновлением выбранной игры') if (!this.credentials) throw new ApiError(401) - this.downloadKey = action === 'download' ? key : null + this.downloadKey = action === 'check' ? null : key this.cancellation = new AbortController() this.state.canCancel = true try { @@ -1324,7 +1328,8 @@ export class Online { }, }) this.state.packagesRevision++ - if (key === this.controller.state.catalog.key) { + if (action === 'select-game' || key === this.controller.state.catalog.key) { + if (action === 'select-game') this.detach() this.controller.selectPackage(pkg) this.plan = { steps: structuredClone(this.controller.state.catalog.steps), @@ -1335,6 +1340,10 @@ export class Online { await this.atomic(`plan-${key}.json`, this.cachedPlan) this.previous = null await this.restoreResults(key) + if (action === 'select-game') { + await this.saveSelection() + await this.persistSecrets() + } } this.state.notice = 'Материалы проверены и готовы к показу' } finally { @@ -1406,6 +1415,13 @@ export class Online { return } case 'disconnect': + if (this.screen) { + try { + await this.client().UnbindScreen({ screenID: this.screen.id }) + } catch (error) { + if (!(error instanceof ApiError && error.status === 404)) throw error + } + } this.detach() await this.persistSecrets() return diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index 19866aa..1cf20a2 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -278,6 +278,8 @@ module.exports = async function onlineSmoke({ async () => !(await evaluate('window.screenBridge.onlineState()')).busy, 'login complete', ) + await evaluate(`Array.from(document.querySelectorAll('button')) + .find(b => b.textContent.trim() === 'Сбросить')?.click()`) await poll( () => evaluate( @@ -288,15 +290,15 @@ module.exports = async function onlineSmoke({ await poll( () => evaluate( - `document.querySelectorAll('main.control > .n-card').length === 4 && document.querySelectorAll('.n-card-header__main').length === 5`, + `document.querySelectorAll('main.control > .n-card').length === 3 && document.querySelectorAll('main.control > .game-grid > .n-card').length === 2 && document.querySelectorAll('.n-card-header__main').length === 6`, ), - 'five control cards', + 'six control cards', ) assert.deepEqual( await evaluate( `Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? e).textContent.trim())`, ), - ['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'], + ['Сервер', 'Игра', 'Экран игры', 'Экран', 'План экранов', 'План музыки'], ) assert.equal( await evaluate(`document.querySelectorAll('details').length`), @@ -319,7 +321,7 @@ module.exports = async function onlineSmoke({ ]) assert.equal( await evaluate( - `Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === ${JSON.stringify(label)})`, + `Array.from(document.querySelectorAll('button')).some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})`, ), false, 'removed button: ' + label, @@ -375,12 +377,11 @@ module.exports = async function onlineSmoke({ `Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.includes('HTTP smoke · Завершена')).click()`, ) const selectedBeforeDownload = (await state()).catalog.name - await poll( - () => - evaluate( - `document.querySelector('[data-testid="control-game"]')?.textContent.includes(${JSON.stringify(selectedBeforeDownload)})`, - ), - 'control target stays on local package despite server game selection', + assert.equal((await state()).catalog.key, null, 'server selection does not prepare a game') + assert.equal( + await evaluate(`!!document.querySelector('[data-testid="control-game"]')`), + false, + 'current game stays empty until connection', ) assert.notEqual(selectedBeforeDownload, 'HTTP smoke') invalidStory = true @@ -405,6 +406,24 @@ module.exports = async function onlineSmoke({ 'anywhere', ) invalidStory = false + await poll( + () => evaluate(`(() => { + const buttons = Array.from(document.querySelectorAll('button')) + const select = buttons.find(b => b.textContent.trim() === 'Выбрать игру') + const connect = buttons.find(b => b.textContent.trim() === 'Подключиться') + if (!select || select.disabled || !connect?.disabled) return false + select.click(); return true + })()`), + 'confirm selected game before screen connection', + ) + await poll( + async () => { + const online = await evaluate('window.screenBridge.onlineState()') + return !online.busy && online.currentGameId === 6 && online.screenId === null + && online.materials.every(m => m.status === 'ready') + }, + 'game prepared without screen connection', + ) await poll( () => evaluate(`(() => { @@ -434,8 +453,15 @@ module.exports = async function onlineSmoke({ ) await checkBadges(true, true) await poll( - () => evaluate(`document.querySelector('.connection-content .n-base-selection')?.textContent.includes('HTTP smoke')`), - 'current game automatically selected', + () => evaluate(`(() => { + const content = document.querySelector('.connection-content') + const buttons = Array.from(document.querySelector('[data-testid="game-connection-status"]')?.closest('.n-card')?.querySelectorAll('button') ?? []) + return !content?.querySelector('.game-selection') + && !buttons.some(b => b.textContent.trim() === 'Подключиться') + && buttons.some(b => b.textContent.trim() === 'Отключиться' + && b.closest('.n-space')?.textContent.includes('Экран №')) + })()`), + 'selected game hides selector and connect button, disconnect follows screen name', ) assert.equal((await state()).outputOpen, false) await evaluate(`window.screenBridge.show(${displayId})`) @@ -447,12 +473,12 @@ module.exports = async function onlineSmoke({ await evaluate(`(() => { const assignment = document.querySelector('[data-testid="sound-assignment"]') const card = assignment?.closest('.n-card') - return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Игра' - && ['Назначить звук этому экрану', 'Отключить управление'].every(label => + return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран игры' + && ['Назначить звук этому экрану', 'Отключиться'].every(label => !Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label)) })()`), true, - 'server screen controls belong to game card', + 'server screen controls belong to game screen card', ) await poll( () => @@ -602,7 +628,7 @@ module.exports = async function onlineSmoke({ () => evaluate(`(() => { const button = Array.from(document.querySelectorAll('[data-testid="screen-plan"] button')) - .find(b => b.textContent.trim() === ${JSON.stringify(label)}) + .find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)}) if (!button || button.disabled) return false button.click(); return true })()`), @@ -653,14 +679,14 @@ module.exports = async function onlineSmoke({ assert.equal((await state()).scene.revision, beforeSaveScene) assert.equal(plan.steps[1].text, 'Текст из редактора') assert.equal(plan.steps[2].timerDurationSeconds, 60) - await evaluate(`document.querySelector('[data-step-index="1"] button').click()`) + await evaluate(`document.querySelector('[data-step-index="1"] [data-testid="show-step"]').click()`) await poll( async () => (await state()).scene.text === 'Текст из редактора' && (await state()).scene.stepIndex === 1, 'show text step', ) - await evaluate(`document.querySelector('[data-step-index="2"] button').click()`) + await evaluate(`document.querySelector('[data-step-index="2"] [data-testid="show-step"]').click()`) await poll(async () => (await state()).scene.stepIndex === 2, 'show timer step') assert.equal((await state()).timer.status, 'stopped') assert.equal((await state()).channels.main.desired, 'stopped') @@ -704,7 +730,7 @@ module.exports = async function onlineSmoke({ poll( () => evaluate(`(() => { - const button = Array.from(document.querySelectorAll('[data-testid="music-plan-editor"] button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}) + const button = Array.from(document.querySelectorAll('[data-testid="music-plan-editor"] button')).find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)}) if (!button || button.disabled) return false button.click(); return true })()`), @@ -823,25 +849,17 @@ module.exports = async function onlineSmoke({ assert.equal(afterOrder.desired, 'playing') assert.ok(afterOrder.position >= beforeOrder.position) console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS') - await act({ action: 'disconnect' }) + await evaluate(`Array.from(document.querySelectorAll('button')) + .find(b => b.textContent.trim() === 'Отключиться').click()`) await poll( - () => - evaluate(`(() => { - const select = document.querySelector('.connection-content .n-base-selection') - select?.dispatchEvent(new MouseEvent('mouseenter')) - const clear = select?.querySelector('.n-base-clear__clear') - if (!clear) return false - clear.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) - clear.click(); return true - })()`), - 'clear game selection', - ) - await poll( - () => - evaluate( - `document.querySelector('.connection-content .n-base-selection')?.textContent.includes('Выберите игру')`, - ), - 'server selection cleared', + () => evaluate(`(() => { + const card = document.querySelector('[data-testid="game-connection-status"]')?.closest('.n-card') + return card?.textContent.includes('Не подключено') + && card.textContent.includes('Подключиться') + && !card.textContent.includes('Отключиться') + && !document.querySelector('.game-selection') + })()`), + 'disconnect keeps selected game and restores connect in game screen card', ) await poll( () => diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index eac3c67..9787a14 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -407,12 +407,12 @@ app.whenReady().then(async () => { ) if (index === 0) assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320) - else assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320) + assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320) if (index === 1 || index === 3) { const clickMedia = (label) => evaluate(`(() => { const row = document.querySelector('[data-step-index="${index}"]') - const button = Array.from(row.querySelectorAll('button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}) + const button = Array.from(row.querySelectorAll('button')).find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)}) if (!button || button.disabled) throw new Error('Main control unavailable') button.click() })()`) @@ -427,19 +427,7 @@ app.whenReady().then(async () => { 'plan media paused', ) await evaluate(`(() => { - const input = document.querySelector('#position-main') - input.focus(); input.value = '1' - input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - input.blur() - })()`) - await clickMedia('Перемотать') - await poll( - async () => Math.abs((await state()).channels.main.position - 1) < 0.1, - 'plan media seek', - ) - await evaluate(`(() => { - const input = document.querySelector('#volume-main') + const input = document.querySelector('.plan-step-current input[id^="volume-main-"]') input.focus(); input.value = '42' input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index 88b0669..2159153 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -209,190 +209,217 @@ const gameOptions = computed(() => { {{ state.notice }} - - -
-
- Текущая игра: {{ selectedName }} - Сбросить -
- diff --git a/src/components/OutputPage.vue b/src/components/OutputPage.vue index 5e44e13..2921447 100644 --- a/src/components/OutputPage.vue +++ b/src/components/OutputPage.vue @@ -114,7 +114,7 @@ onUnmounted(() => { @@ -215,10 +264,62 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({ .plan-step { border: 1px solid var(--brand-background); + display: flex; + flex-direction: column; + gap: 12px; overflow-wrap: anywhere; padding: 12px; } +.plan-step-header { + align-items: stretch; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.plan-step-content { + display: flex; + flex: 1 1 380px; + flex-direction: column; + gap: 8px; + justify-content: space-between; + min-width: 0; +} + +.plan-step-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-start; +} + + +.plan-step-actions > [data-testid="show-step"] { + margin-left: auto; +} + +.plan-step-preview { + flex: 0 0 160px; +} + +.plan-edit-header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.plan-edit-controls { + align-items: center; + display: grid; + flex: 1 1 320px; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto auto auto; + min-width: 0; +} + .plan-step-current { border-color: var(--brand-text); } diff --git a/src/components/ScreenStepPreview.vue b/src/components/ScreenStepPreview.vue new file mode 100644 index 0000000..4ec1ec8 --- /dev/null +++ b/src/components/ScreenStepPreview.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/types/online.ts b/src/types/online.ts index b2e02fc..4cdefde 100644 --- a/src/types/online.ts +++ b/src/types/online.ts @@ -30,7 +30,7 @@ export type OnlineIntent = | { action: 'retry-materials' } | { action: 'connect'; gameId?: number } | { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' } - | { action: 'download' | 'check'; gameId: number } + | { action: 'select-game' | 'download' | 'check'; gameId: number } | { action: 'remove'; key: string } export interface OnlineState { server: string