From 06af1e047d67d84d0fc8161af5cb1827a76f1b08 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Mon, 19 Oct 2026 02:16:57 +0700 Subject: [PATCH] up --- electron/main.ts | 2 + scripts/online-smoke.cjs | 11 +- scripts/previews-smoke.cjs | 96 +++++++++++++ scripts/smoke.cjs | 7 +- src/components/ControlPage.vue | 16 ++- src/components/OutputPage.vue | 158 +++------------------ src/components/ResultsTable.vue | 16 +-- src/components/SceneView.vue | 203 +++++++++++++++++++++++++++ src/components/ScreenPlanPanel.vue | 20 +-- src/components/ScreenStepPreview.vue | 112 ++++++--------- src/types/screen.ts | 2 + 11 files changed, 405 insertions(+), 238 deletions(-) create mode 100644 scripts/previews-smoke.cjs create mode 100644 src/components/SceneView.vue diff --git a/electron/main.ts b/electron/main.ts index be93b2e..261a318 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -90,6 +90,8 @@ function host(): HostInfo { .filter((d) => d.id !== primary) .map((d) => ({ id: d.id, + width: d.size.width, + height: d.size.height, label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`, })), } diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index cc5db25..78b6639 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -292,13 +292,13 @@ module.exports = async function onlineSmoke({ await poll( () => evaluate( - `document.querySelectorAll('main.control > .n-card').length === 5 && document.querySelectorAll('[data-testid="game-control"] .n-card').length === 0 && document.querySelectorAll('.n-card-header__main').length === 6`, + `document.querySelectorAll('main.control > .n-card').length === 4 && document.querySelectorAll('[data-testid="game-control"] .n-card').length === 0 && document.querySelectorAll('.n-card-header__main').length === 6`, ), 'six control cards with merged game controls', ) assert.deepEqual( await evaluate( - `Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? e).textContent.trim())`, + `Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child, .game-heading > span:first-child') ?? e).textContent.trim())`, ), ['Сервер', 'Материалы', 'Игра', 'Экран', 'План экранов', 'План музыки'], ) @@ -461,9 +461,9 @@ module.exports = async function onlineSmoke({ return !content?.querySelector('.game-selection') && !buttons.some(b => b.textContent.trim() === 'Подключиться') && buttons.some(b => b.textContent.trim() === 'Отключиться' - && b.closest('.n-space')?.textContent.includes('Экран №')) + && !!b.closest('.n-card-header__extra')) })()`), - 'selected game hides selector and connect button, disconnect follows screen name', + 'selected game hides selector and connect button, disconnect stays in game header', ) assert.equal((await state()).outputOpen, false) await evaluate(`window.screenBridge.show(${displayId})`) @@ -475,7 +475,7 @@ 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() === 'Игра' + return card?.querySelector('.game-heading > span:first-child')?.textContent.trim() === 'Игра' && ['Назначить звук этому экрану', 'Отключиться'].every(label => !Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label)) })()`), @@ -649,6 +649,7 @@ module.exports = async function onlineSmoke({ await clickPlan('Редактировать') await clickPlan('Добавить шаг') await setInput('[data-testid="screen-plan"] textarea', 'Текст из редактора') + await poll(() => evaluate(`Array.from(document.querySelectorAll('[data-testid="screen-plan"] .step-preview .message')).some(e => e.textContent === 'Текст из редактора')`), 'draft miniature updates before save') await clickPlan('Добавить шаг') await evaluate( `Array.from(document.querySelectorAll('[data-testid="screen-plan"] .n-base-selection')).at(-1).click()`, diff --git a/scripts/previews-smoke.cjs b/scripts/previews-smoke.cjs new file mode 100644 index 0000000..9b2706c --- /dev/null +++ b/scripts/previews-smoke.cjs @@ -0,0 +1,96 @@ +const assert = require('node:assert/strict') +const { writeFileSync } = require('node:fs') +const { nativeImage, BrowserWindow } = require('electron') + +module.exports = async function previewsSmoke({ control, output, evaluate, inspect, state, command, poll, targetDisplay }) { + const miniature = (index) => `[data-testid="screen-plan"] [data-step-index="${index}"] .step-preview` + const metrics = (root, videoSelector) => `(() => { + const root = document.querySelector(${JSON.stringify(root)}); + if (!root) return null; + const bounds = root.getBoundingClientRect(); + return ['.background', '.cover', '.message', '.intro-waveform', '.timer h1', '.timer strong', '.results', '.results h1', '.results .empty', '.results table', ${JSON.stringify(videoSelector)}].flatMap(selector => { + const e = root.querySelector(selector); if (!e) return []; + const r = e.getBoundingClientRect(); const s = getComputedStyle(e); if (s.display === 'none') return []; + return [{ selector: selector.includes('video') ? 'video' : selector, text: e.textContent.trim(), x: (r.x-bounds.x)/bounds.width, y: (r.y-bounds.y)/bounds.height, width: r.width/bounds.width, height: r.height/bounds.height, font: s.fontSize, fit: s.objectFit, filter: s.filter }]; + }); + })()` + const compare = async (index) => { + const actual = await inspect(metrics('.output', 'video')) + const thumbnail = await evaluate(metrics(miniature(index), 'video')) + // Hidden main video remains mounted in the output to preserve playback state. + const expected = actual.filter(item => item.selector !== 'video' || index === 3) + assert.equal(thumbnail.length, expected.length, `step ${index + 1}: ${JSON.stringify({ expected, thumbnail })}`) + for (let i = 0; i < expected.length; i++) { + const a = expected[i], b = thumbnail[i] + for (const key of ['selector', 'text', 'font', 'fit', 'filter']) { + if (a.selector === 'video' && key === 'font') continue + assert.equal(b[key], a[key], `step ${index + 1}: ${key}`) + } + for (const key of ['x', 'y', 'width', 'height']) + assert.ok(Math.abs(a[key] - b[key]) < 0.002, `step ${index + 1}: ${key}: ${a[key]} / ${b[key]}`) + } + } + for (const [width, height] of [[1280, 720], [1024, 768]]) { + targetDisplay.size = { width, height } + output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width, height }, viewSize: { width, height }, deviceScaleFactor: 1, scale: 1 }) + await poll(() => inspect(`innerWidth === ${width} && innerHeight === ${height}`), 'output viewport') + await poll(() => evaluate(`(() => { const r = document.querySelector('${miniature(0)}').getBoundingClientRect(); return Math.abs(r.width/r.height - ${width/height}) < 0.001 && document.querySelector('${miniature(0)} .preview-viewport').style.width === '${width}px' })()`), 'thumbnail viewport follows display') + for (let index = 0; index < 8; index++) { + await command({ action: 'step', index }) + await poll(async () => { + const s = await state() + if (s.scene.status === 'error') throw new Error(s.scene.error) + return s.scene.stepIndex === index && s.scene.status === 'ready' + }, `scene ${index + 1}`) + await poll(() => evaluate(`Array.from(document.querySelectorAll('${miniature(index)} img')).every(i => i.complete && i.naturalWidth > 0)`), 'thumbnail images loaded') + if (index === 3) { + await poll(() => evaluate(`document.querySelector('${miniature(index)} video').readyState >= 2`), 'video thumbnail first frame') + assert.ok(await evaluate(`(() => { const v = document.querySelector('${miniature(index)} video'); return v.paused && v.muted && v.currentTime === 0 })()`)) + } + await compare(index) + assert.ok(await evaluate('Array.from(document.querySelectorAll(".step-preview video")).every(v => v.paused && v.muted)')) + assert.equal((await state()).channels.main.desired, 'stopped') + const staticScroll = await evaluate(`document.querySelector('${miniature(5)} .results-scroll')?.scrollTop ?? 0`) + assert.equal(staticScroll, 0) + } + await command({ action: 'step', index: 6 }) + await evaluate(`document.querySelector('${miniature(6)}').scrollIntoView({ block: 'center' })`) + writeFileSync(`/tmp/screen-previews-${width}.png`, (await control.webContents.capturePage()).toPNG()) + writeFileSync(`/tmp/screen-output-${width}.png`, (await output.webContents.capturePage()).toPNG()) + } + await evaluate('window.screenBridge.closeOutput()') + await poll(async () => !(await state()).outputOpen, 'output closed') + assert.equal(await evaluate('document.querySelectorAll(".step-preview").length'), 8) + assert.ok(await evaluate('Array.from(document.querySelectorAll(".step-preview video")).every(v => v.paused && v.muted)')) + await evaluate(`window.screenBridge.show(${targetDisplay.id})`) + output = await poll(() => BrowserWindow.getAllWindows().find(w => w.webContents.getURL() === 'screen-app://app/index.html?output=1'), 'reopen after static preview') + output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width: 1024, height: 768 }, viewSize: { width: 1024, height: 768 }, deviceScaleFactor: 1, scale: 1 }) + await poll(async () => (await state()).scene.status === 'ready', 'reopened scene') + // Exercise missing media, long text, portrait cover and different result snapshots. + const original = await state() + let revision = original.revision + 1000 + const portrait = nativeImage.createFromBitmap(Buffer.alloc(80 * 160 * 4, 180), { width: 80, height: 160 }).toDataURL() + const cases = [ + { index: 0, catalog: { ...original.catalog, cover: portrait } }, + { index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((step, i) => i === 7 ? { ...step, text: 'Длинный многострочный текст\n' + 'Расследование продолжается. '.repeat(12) } : step) } }, + { index: 1, catalog: { ...original.catalog, media: [] } }, + { index: 3, catalog: { ...original.catalog, media: [] } }, + { index: 0, catalog: { ...original.catalog, cover: null } }, + { index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [], teams: [] } }, + { index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [{ code: 'A', text: 'Вопрос' }], teams: [{ id: 'one', name: 'Детективы', place: 1, totalScore: 5, actionsCount: 1, answers: [{ questionCode: 'A', score: 5 }] }] } }, + ] + for (const item of cases) { + const catalog = item.catalog ?? original.catalog + const step = catalog.steps[item.index] + const snapshot = { ...original, revision: ++revision, catalog, results: item.results ?? original.results, + scene: { ...original.scene, revision, mode: step.mode, text: step.text, stepIndex: item.index }, + channels: { ...original.channels, main: { ...original.channels.main, source: catalog.media.find(m => m.kind === (step.mode === 2 ? 'audio' : 'video')) ?? null } } } + control.webContents.send('state', snapshot) + output.webContents.send('state', snapshot) + await poll(() => inspect(`document.querySelector('.output').dataset.mode === '${step.mode}'`), 'fixture output') + await poll(() => evaluate(`document.querySelector('${miniature(item.index)} .scene-view').dataset.mode === '${step.mode}'`), 'fixture thumbnail') + await new Promise(resolve => setTimeout(resolve, 50)) + await compare(item.index) + } + console.log('SCREEN_PREVIEWS_PASS') +} diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index f5485ec..595755b 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -317,7 +317,7 @@ app.whenReady().then(async () => { } assert.equal(await evaluate('typeof window.require'), 'undefined') assert.equal((await evaluate('window.screenBridge.packages()')).length, 1) - if (process.argv.includes('--intro-waveform')) + if (process.argv.includes('--intro-waveform') || process.argv.includes('--previews')) await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) await evaluate(`window.screenBridge.show(${targetDisplay.id})`) let output = await poll( @@ -328,6 +328,11 @@ app.whenReady().then(async () => { BrowserWindow.getAllWindows() .find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1') .webContents.executeJavaScript(source) + if (process.argv.includes('--previews')) { + await require('./previews-smoke.cjs')({ control, output, evaluate, inspect, state, command, poll, targetDisplay }) + app.exit(0) + return + } if (process.argv.includes('--intro-waveform')) { const selectedDevice = (await evaluate('window.screenBridge.host()')).audioOutputs .find(device => device.id && !['default', 'communications'].includes(device.id)) diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index 4699e04..b1d28ad 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -57,6 +57,12 @@ const host = ref({ preferredDisplayId: null, }) const display = ref(null) +const previewViewport = computed(() => { + const selected = host.value.displays.find(item => item.id === display.value) + return selected && selected.width > 0 && selected.height > 0 + ? { width: selected.width, height: selected.height } + : { width: 1920, height: 1080 } +}) const isBusy = ref(false) const isCheckingSound = ref(false) async function checkSound() { @@ -283,9 +289,9 @@ onUnmounted(() => { - {{ host.displays.length ? 'Подключён' : 'Не подключён' }} + {{ state?.outputOpen ? 'Показ открыт' : 'Показ закрыт' }} { (null) @@ -131,12 +131,6 @@ let unsubscribe: (() => void) | undefined let revision = -1 let sceneToken = '' let sceneAbort: AbortController | null = null -// Новый адрес исключает повторное использование уже декодированной картинки Chromium. -function imageURL(url: string) { - const result = new URL(url) - result.searchParams.set('scene', `${state.value?.session}:${state.value?.scene.revision}`) - return result.href -} function waitImage(image: HTMLImageElement, signal: AbortSignal) { return new Promise((resolve, reject) => { const finish = (error?: string) => { @@ -240,60 +234,23 @@ onUnmounted(() => {