120 lines
10 KiB
JavaScript
120 lines
10 KiB
JavaScript
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', '.qr-scene', '.qr-caption', '.teams-scene', '.team-names', '.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 step of [
|
|
{ mode: 9, qrUrl: 'https://example.org/join?game=7', text: 'Присоединяйтесь\nк расследованию' },
|
|
{ mode: 9, qrUrl: 'https://example.org/join', text: '' },
|
|
{ mode: 10, text: '' },
|
|
]) cases.push({ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((old, i) => i === 7 ? { ...old, ...step } : old) }, teams: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), teams: Array.from({ length: 32 }, (_, i) => ({ id: String(i), name: `Команда ${i + 1} — Длинное название` })) } })
|
|
for (const count of [1, 8, 20]) cases.push({ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((old, i) => i === 7 ? { ...old, mode: 10 } : old) }, teams: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), teams: Array.from({ length: count }, (_, i) => ({ id: String(i), name: `Команда ${i + 1} — Длинное название` })) } })
|
|
cases.push({ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((old, i) => i === 7 ? { ...old, mode: 10 } : old) }, teams: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), teams: [] } })
|
|
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, teams: item.teams ?? original.teams,
|
|
scene: { ...original.scene, revision, mode: step.mode, text: step.text, qrUrl: step.qrUrl, 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)
|
|
if (step.mode === 9) {
|
|
await poll(() => inspect("!!document.querySelector('.qr-scene canvas')"), 'QR canvas')
|
|
assert.ok(await inspect("(() => { const c = document.querySelector('.qr-scene canvas'), r = c.getBoundingClientRect(); return r.width > 200 && r.height > 200 && r.bottom <= innerHeight && r.right <= innerWidth })()"))
|
|
if (step.text) writeFileSync('/tmp/ed054-qr-output.png', (await output.webContents.capturePage()).toPNG())
|
|
}
|
|
if (step.mode === 10) {
|
|
assert.equal(await inspect("document.querySelectorAll('.team-names p').length"), item.teams.teams.length)
|
|
assert.ok(await inspect("Array.from(document.querySelectorAll('.team-names p')).every(e => { const r = e.getBoundingClientRect(); return r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth })"))
|
|
const singleColumn = (root) => `(() => {
|
|
const rows = Array.from(document.querySelectorAll('${root} .team-names p'), e => e.getBoundingClientRect());
|
|
return rows.every((row, i) => Math.abs(row.left - rows[0].left) < 1 && Math.abs(row.width - rows[0].width) < 1 && (i === 0 || row.top >= rows[i - 1].bottom));
|
|
})()`
|
|
assert.ok(await inspect(singleColumn('.output')), 'output teams form one column')
|
|
assert.ok(await evaluate(singleColumn(miniature(item.index))), 'preview teams form one column')
|
|
if (item.teams.teams.length) writeFileSync('/tmp/ed054-teams-output.png', (await output.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
console.log('SCREEN_PREVIEWS_PASS')
|
|
}
|