diff --git a/scripts/fixtures/w2/cctv.mjs b/scripts/fixtures/w2/cctv.mjs new file mode 100644 index 000000000..46cb857c5 --- /dev/null +++ b/scripts/fixtures/w2/cctv.mjs @@ -0,0 +1,197 @@ +import sharp from 'sharp'; + +// Transport fixtures are parsed by the real CCTV catalog and media owners. +export const cameras = Array.from({ length: 25 }, (_, i) => ({ + id: `w2-cctv-${String(i).padStart(3, '0')}`, + name: i === 12 ? 'Cámara pública — Congress' : `W2 camera ${i}`, + cityId: 'austin', + city: 'Austin', + lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018, + lon: -97.7431 + ((i % 5) - 2) * 0.0018, + headingDeg: (i * 37) % 360, + fovDeg: i % 2 ? 44 : 56, + rangeM: i % 2 ? 145 : 210, + mountHeightM: i % 2 ? 8 : 10, + pitchDeg: i % 2 ? -18 : -24, + groundElevationM: 150, + headingConfidence: i % 3 ? 'medium' : 'low', + sourceKind: 'configured', + feedType: 'image', + provider: 'W2 deterministic fixture', + url: 'https://cctv-fixture.invalid/frame.png', +})); + +export async function cameraFrame() { + const width = 320, + height = 180; + const pixels = Buffer.alloc(width * height * 3); + const colors = [ + [240, 45, 70], + [35, 220, 90], + [40, 90, 235], + [245, 210, 30], + ]; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const color = + colors[(x >= width / 2 ? 1 : 0) + (y >= height / 2 ? 2 : 0)]; + const grid = x % 40 < 2 || y % 30 < 2; + const at = (y * width + x) * 3; + for (let c = 0; c < 3; c++) pixels[at + c] = grid ? 15 : color[c]; + } + } + return sharp(pixels, { raw: { width, height, channels: 3 } }) + .png() + .toBuffer(); +} + +export async function installCctvFixture(page, { video } = {}) { + const frame = await cameraFrame(); + const catalog = cameras.map((camera) => ({ ...camera })); + if (video) + catalog.push({ + ...cameras[0], + id: 'w2-cctv-video', + name: 'W2 moving video', + lat: 30.2718, + lon: -97.744, + feedType: 'video', + url: 'https://cctv-fixture.invalid/camera.mp4', + }); + const ids = new Set(catalog.map((camera) => camera.id)); + // Global Puppeteer interception can stall Cesium's module workers in + // Chromium 152. Intercept only fixture API routes on the page's CDP session. + const session = await page.createCDPSession(); + let disposed = false; + const routes = [ + '/api/cctv/*', + '/api/flights*', + '/api/military*', + '/api/vessels*', + '/api/google/nearby-places*', + ]; + await session.send('Fetch.enable', { + patterns: ['localhost', '127.0.0.1'].flatMap((host) => + routes.map((route) => ({ urlPattern: `http://${host}:*${route}` })), + ), + }); + const respond = ( + requestId, + { status, contentType, headers = {}, body = '' }, + ) => + session.send('Fetch.fulfillRequest', { + requestId, + responseCode: status, + responseHeaders: Object.entries({ + ...(contentType ? { 'content-type': contentType } : {}), + ...headers, + }).map(([name, value]) => ({ name, value: String(value) })), + body: Buffer.from(body).toString('base64'), + }); + const handle = (request) => { + const url = new URL(request.url()); + if (!['localhost', '127.0.0.1'].includes(url.hostname)) { + return request.continue(); + } + const json = (body) => + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + const auxiliary = { + '/api/flights': { states: [] }, + '/api/military': { ac: [] }, + '/api/vessels': { connected: false, vessels: [] }, + }; + if (auxiliary[url.pathname]) { + return json(auxiliary[url.pathname]); + } + if (url.pathname === '/api/cctv/sources') { + return json({ sources: catalog }); + } + if (url.pathname === '/api/cctv/health') { + return json({ + cameras: catalog.map(({ id, feedType }) => ({ + id, + status: 'online', + sourceKind: feedType, + label: 'W2 deterministic fixture', + updatedAt: Date.now(), + })), + }); + } + const media = url.pathname.match(/^\/api\/cctv\/(frame|media)\/([^/]+)$/); + if (media) { + const id = decodeURIComponent(media[2]); + if ( + !ids.has(id) || + (media[1] === 'media' && (id !== 'w2-cctv-video' || !video)) + ) { + return request.respond({ status: 404, body: 'Unknown fixture camera' }); + } + if (media[1] === 'frame') { + return request.respond({ + status: 200, + contentType: 'image/png', + body: frame, + }); + } + const range = request.headers().range?.match(/^bytes=(\d+)-(\d*)$/); + const start = range ? Number(range[1]) : 0; + const end = + range && range[2] + ? Math.min(Number(range[2]), video.length - 1) + : video.length - 1; + if (start > end || start >= video.length) { + return request.respond({ + status: 416, + headers: { 'content-range': `bytes */${video.length}` }, + }); + } + return request.respond({ + status: range ? 206 : 200, + contentType: 'video/mp4', + headers: { + 'accept-ranges': 'bytes', + ...(range + ? { 'content-range': `bytes ${start}-${end}/${video.length}` } + : {}), + }, + body: video.subarray(start, end + 1), + }); + } + if (url.pathname === '/api/google/nearby-places') { + return json({ places: [] }); + } + return request.continue(); + }; + session.on('Fetch.requestPaused', (event) => { + const request = { + url: () => event.request.url, + headers: () => + Object.fromEntries( + Object.entries(event.request.headers).map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ), + respond: (response) => respond(event.requestId, response), + continue: () => + session.send('Fetch.continueRequest', { requestId: event.requestId }), + }; + Promise.resolve() + .then(() => handle(request)) + .catch((error) => { + if (!disposed && !page.isClosed()) page.emit('pageerror', error); + }); + }); + return { + cameras: catalog, + frame, + async dispose() { + disposed = true; + await session.detach(); + }, + }; +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 78f1becb9..7498a99b7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -539,6 +539,7 @@ "src/layers/cctv/state.js", "src/layers/cctv/policy.js", "src/layers/cctv/projection.js", + "src/layers/cctv/projectionScheduling.test.mjs", "src/layers/cctv/cards.js", "src/layers/cctv/rendering.js", "src/layers/cctv/testing.js", @@ -580,7 +581,9 @@ "src/layers/alpr/render/navaraOverlay.js", "src/layers/alpr/render/navaraOverlay.test.mjs", "scripts/fixtures/w2/alpr.mjs", + "scripts/fixtures/w2/cctv.mjs", "scripts/qa-w2-alpr.mjs", + "scripts/qa-w2-cctv.mjs", "src/layers/alpr/visuals.js", "src/layers/alpr/render/cesiumOverlay.js", "src/layers/alpr/overlay.test.mjs", diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 08995603f..6f88a7920 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -38,12 +38,10 @@ * issuing zero transient samples, without touching the save-gated store; * camera-controller inputs restore and parts hide on mode off. * - * SwiftShader caveat (proven in Task 3): under headless software GL, plane - * image materials do not visually FILL — screenshotting for a pixel-based - * pass/fail is not viable here. All four assertion groups therefore check - * entity/counter/localStorage/network STATE (GL-independent), never rendered - * pixels. Screenshots are still captured for human visual review but do not - * gate the exit code. + * This legacy harness is the geometry, lifecycle and real-input oracle. + * Migration runs use headless Metal and strict runtime error observation. + * Screenshots remain visual-review evidence here; the W2 companion must + * independently prove actual marker, image-plane and video pixels. * * No `cesium` package import for in-page math: the app does not expose a * `window.Cesium` global, so every in-page evaluate() either (a) reads plain @@ -69,12 +67,14 @@ * so the coincide check is deterministic whichever camera activates, but it * is no longer needed to avoid a plane/wireframe disagreement. * - * Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173 + * Run: node scripts/qa-cctv-v2.mjs --url --out [--fixture] [--preview] * * Exit 0 = all asserts passed. Non-zero = a hard failure. */ import puppeteer from 'puppeteer'; +import { watchW1Page, redact } from './qa-browserEvidence.mjs'; +import { installCctvFixture } from './fixtures/w2/cctv.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -93,12 +93,29 @@ const getOpt = (name, dflt) => { }; const getFlag = (name) => argv.includes(name); -const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; -const CCTV_URL = new URL(getOpt('--url', BASE_URL)); +const explicitUrl = getOpt('--url', process.env.QA_BASE_URL); +const output = getOpt('--out'); +if (!explicitUrl || !output) + throw new Error('Explicit --url and --out are required'); +const CCTV_URL = new URL(explicitUrl); +if ( + ['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + CCTV_URL.port, + ) +) + throw new Error('CCTV QA may not use a reserved port'); +if (getFlag('--headful')) throw new Error('CCTV migration QA is headless only'); CCTV_URL.searchParams.set('welcome', '0'); const APP_URL = CCTV_URL.href; -const HEADFUL = getFlag('--headful'); -const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2'); +const SHOTS_DIR = path.resolve(output); +const evidence = { checks: [], browserErrors: [], pass: false }; +const saveEvidence = () => { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + fs.writeFileSync( + path.join(SHOTS_DIR, 'result.json'), + JSON.stringify(evidence, null, 2) + '\n', + ); +}; const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -125,8 +142,9 @@ function findChromeExecutable() { return null; } -const results = []; +const results = evidence.checks; function record(name, ok, detail) { + detail = redact(detail); results.push({ name, ok, detail }); const tag = ok === null @@ -346,54 +364,56 @@ function findSafeCalibrationPatch(basePose, groundAltM) { async function main() { console.log(`\nCCTV v2 Subsystem Proof`); - console.log(` App URL : ${APP_URL}`); - console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`); + console.log(` App URL : ${redact(APP_URL)}`); + console.log(' Mode : headless Metal\n'); try { const res = await fetch(APP_URL, { method: 'GET' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error( - `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + redact(`Dev server not reachable: ${e.message}`), ); - process.exit(2); + throw new Error('CCTV prerequisite unavailable'); } fs.mkdirSync(SHOTS_DIR, { recursive: true }); const chromeExecutable = findChromeExecutable(); const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', + headless: 'new', ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', - ...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']), + '--use-angle=metal', + '--enable-gpu', '--disable-dev-shm-usage', - '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800', ], }); - const consoleErrors = []; + const consoleErrors = evidence.browserErrors; let exitCode = 0; + let fixture = null; try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); - page.on('console', (msg) => { - if (msg.type() === 'error') { - const t = msg.text(); - if ( - !/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test( - t, - ) - ) { - consoleErrors.push(t); - } - } + watchW1Page(page, evidence, { + name: 'cctv-legacy', + baseUrl: APP_URL, + preview: getFlag('--preview'), + }); + fixture = getFlag('--fixture') + ? await installCctvFixture(page) + : null; + await page.evaluateOnNewDocument(() => { + localStorage.clear(); + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'); }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -404,6 +424,14 @@ async function main() { window.__godsEyeView.dataManager, { timeout: 60000 }, ); + await page.evaluate(async () => { + const app = window.__godsEyeView; + await app.styleManager.initialRestorePromise; + app.styleManager.orbitController.stop(); + for (const [id, layer] of app.dataManager.layers) + if (layer.enabled) await app.dataManager.setEnabled(id, false); + app.styleManager._setDetectionMode('OFF'); + }); // Let the initial fly-to Austin and first tiles settle. await sleep(4000); @@ -441,7 +469,7 @@ async function main() { console.error( '\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m', ); - process.exit(2); + throw new Error('CCTV prerequisite unavailable'); } const readCounters = () => @@ -469,9 +497,20 @@ async function main() { // ~800ms per real ground sample measured under SwiftShader (each // scene.sampleHeight forces tile loads at the probe point) — real GPU is // far faster, so this is a headless-CI ceiling, not an app expectation. - const drainBudgetMs = HEADFUL - ? Math.max(30000, camCount * 800 + 30000) - : Math.min(120000, Math.max(30000, camCount * 800 + 30000)); + evidence.cameraIds = await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('cctv') + .module.getUIState() + .cameras.map(({ id }) => id) + .sort(), + ); + if (fixture) + record( + 'real catalog parser retains every fixture camera ID', + JSON.stringify(evidence.cameraIds) === + JSON.stringify(fixture.cameras.map(({ id }) => id).sort()), + ); + const drainBudgetMs = Math.max(30000, camCount * 800 + 30000); console.log( `Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`, ); @@ -489,12 +528,10 @@ async function main() { .catch(() => false); record( `geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`, - drained ? true : HEADFUL ? false : null, + drained, drained ? 'loading.active === false' - : HEADFUL - ? 'timed out waiting for drain on the real-GPU sign-off path' - : 'headless GL geometry sampling did not settle; current headful evidence is required', + : 'timed out waiting for the real-GPU geometry drain', ); const cAfterDrain = await readCounters(); @@ -532,6 +569,7 @@ async function main() { .activeCameraId, ); + evidence.activeCameraId = activeId; const pickDeltaActivation = cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay; record( @@ -591,7 +629,7 @@ async function main() { const cFloor = await readCounters(); record( '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', - tilesSeenLoaded ? sampleFloorOk : null, + tilesSeenLoaded && sampleFloorOk, tilesSeenLoaded ? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}` : 'tilesLoaded never observed within 45s — floor not assessable this run', @@ -889,8 +927,27 @@ async function main() { return serializeGeom(camId); })(${JSON.stringify(camId)})`); const geomBefore30s = await serializeGeom(activeId); - await sleep(30000); + await sleep(28000); + evidence.staticIdle = await page.evaluate(async () => { + const app = window.__godsEyeView; + const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js'); + let frames = 0; + const remove = app.engine.frames.postRender.addEventListener(() => frames++); + const started = performance.now(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + remove(); + return { frames, durationMs: performance.now() - started, + governor: getRenderGovernorDiagnostics() }; + }); const geomAfter30s = await serializeGeom(activeId); + evidence.geometry = { + initial: geomInfo, + beforeIdle: geomBefore30s, + afterIdle: geomAfter30s, + }; + evidence.countersAfterIdle = await readCounters(); + record('static image projection settles to zero frames over 2s', + evidence.staticIdle.frames === 0, JSON.stringify(evidence.staticIdle)); const geomStable = JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s); record( @@ -1062,7 +1119,7 @@ async function main() { ); // ========================================================================= - // Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat) + // Group 4: frame-loop health (state oracle; media pixels have a separate gate) // ========================================================================= console.log( 'Checking frame-loop health (placeholder -> frame swap, URL family)...', @@ -1319,25 +1376,18 @@ async function main() { const gev = window.__godsEyeView; const viewer = gev.viewer; const scene = viewer.scene; - const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js'); - const canvasPoint = new Cesium.Cartesian2( - emptyPoint.canvasX, - emptyPoint.canvasY, - ); + const canvasPoint = { x: emptyPoint.canvasX, y: emptyPoint.canvasY }; const ray = viewer.camera.getPickRay(canvasPoint); if (!ray) return null; let position = null; let projected = null; for (const distance of [1_000, 10_000, 100_000, 1_000_000]) { - const candidate = Cesium.Ray.getPoint( + const candidate = ray.constructor.getPoint( ray, distance, - new Cesium.Cartesian3(), - ); - const screen = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - candidate, + new ray.origin.constructor(), ); + const screen = scene.cartesianToCanvasCoordinates(candidate); if ( screen && Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2 @@ -1353,7 +1403,7 @@ async function main() { position, point: { pixelSize: 64, - color: Cesium.Color.MAGENTA, + color: new scene.backgroundColor.constructor(1, 0, 1, 1), disableDepthTestDistance: Number.POSITIVE_INFINITY, }, }); @@ -1635,6 +1685,11 @@ async function main() { // Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag) // ========================================================================= console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...'); + // NEXT above selects a different catalog camera without moving the view. + // Frame that actual camera before requiring an on-screen physical drag. + await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv') + .module.setParams({ focusSelected: true, focusDurationSec: 0.2 })); + await sleep(800); const GIZMO_PARTS = [ 'ring-heading', 'ring-pitch', @@ -1883,7 +1938,7 @@ async function main() { record( 'east-arrow drag changes the east offset (live, unsaved)', null, - `${missDetail} — verify on a current headful GPU surface`, + `${missDetail} — required physical drag evidence is missing from this headless Metal run`, ); record( 'east-arrow drag keeps mount elevation frozen until mouse-up', @@ -1907,7 +1962,7 @@ async function main() { ); } else { const unavailable = - 'no on-screen east-arrow shaft found under this GL stack — verify on real GPU'; + 'no on-screen east-arrow shaft found in the headless Metal view'; record( 'east-arrow drag changes the east offset (live, unsaved)', null, @@ -1958,8 +2013,8 @@ async function main() { ); // ----------------------------------------------------------------------- - // Screenshots for human visual review (NOT a pass/fail signal — SwiftShader - // cannot fill plane image materials under headless software GL). + // Screenshots supplement this state/geometry oracle. Positive rendered + // media pixels are a separate mandatory W2 companion gate. // ----------------------------------------------------------------------- await sleep(1000); const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); @@ -1976,10 +2031,16 @@ async function main() { ); for (const r of results) { - if (r.ok === false) exitCode = 1; + if (r.ok !== true) exitCode = 1; } } finally { + await fixture?.dispose(); await browser.close(); + evidence.pass = + results.length > 0 && + results.every((r) => r.ok === true) && + !consoleErrors.length; + saveEvidence(); } console.log('\n' + '─'.repeat(60)); @@ -1994,6 +2055,9 @@ async function main() { } main().catch((e) => { - console.error('\x1b[31mHarness error:\x1b[0m', e); + evidence.pass = false; + evidence.error = redact(e?.stack || e); + saveEvidence(); + console.error('Harness error:', evidence.error); process.exit(3); }); diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index d137b74b2..1062d177d 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -142,6 +142,7 @@ export function createFrames({ state: layerState, services, parts, source }) { runtime.lastTextureSwapAt = now; runtime.lastSwappedCanvasStamp = runtime.canvasStamp; runtime.planeMaterial.image = buffer; + return true; } /** diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js index b7349171a..3bba97da7 100644 --- a/src/layers/cctv/projection.js +++ b/src/layers/cctv/projection.js @@ -7,6 +7,7 @@ import { PLANE_OUTLINE_COLOR, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, + PROJECTION_TEXTURE_SWAP_MS, } from './policy.js'; export function createProjection({ @@ -15,7 +16,12 @@ export function createProjection({ parts, source, }) { - const { holdContinuousRender, releaseContinuousRender } = services.render; + const { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + } = services.render; + let loopRevision = 0; /** * Build the protected label associated with one active monitor plane. @@ -352,14 +358,15 @@ export function createProjection({ function startProjectionLoop() { if (layerState._projectionRaf) return; if (!parts.model.projectionLoopIsNeeded()) return; - // The armed projection loop uploads video textures / runs focus fades per - // frame — the scene must render continuously while it runs. Released when - // the tick self-stops. (perf wave 2) - holdContinuousRender('cctv-projection'); + clearTimeout(layerState._projectionTimer); + layerState._projectionTimer = 0; + const revision = ++loopRevision; const tick = () => { + if (revision !== loopRevision) return; + layerState._projectionRaf = 0; + layerState._projectionTimer = 0; if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) { - layerState._projectionRaf = 0; releaseContinuousRender('cctv-projection'); return; } @@ -371,11 +378,29 @@ export function createProjection({ ensureProjectionRuntime(active); if (active.projection && !active.projection.video) { parts.frames.drawProjectionFrame(active); - parts.frames.refreshProjectionTextures(active); + if (parts.frames.refreshProjectionTextures(active)) + governorRequestRender('cctv-projection-image'); } } - layerState._projectionRaf = requestAnimationFrame(tick); + const continuous = + (layerState._showProjection && active?.projection?.video) || + services.focus.focusPassIsNeeded( + services.focus.getFocusTarget(), + layerState._activeFocusStyleCount, + ); + if (continuous) { + holdContinuousRender('cctv-projection'); + layerState._projectionRaf = requestAnimationFrame(tick); + } else { + // Stills retain the existing fetch/texture cadence without rendering + // unchanged pixels at display refresh rate. + releaseContinuousRender('cctv-projection'); + layerState._projectionTimer = setTimeout( + tick, + PROJECTION_TEXTURE_SWAP_MS, + ); + } }; layerState._projectionRaf = requestAnimationFrame(tick); @@ -384,6 +409,9 @@ export function createProjection({ /** Cancels the projection animation loop. */ function stopProjectionLoop() { + loopRevision++; + clearTimeout(layerState._projectionTimer); + layerState._projectionTimer = 0; if (layerState._projectionRaf) { cancelAnimationFrame(layerState._projectionRaf); layerState._projectionRaf = 0; diff --git a/src/layers/cctv/projectionScheduling.test.mjs b/src/layers/cctv/projectionScheduling.test.mjs new file mode 100644 index 000000000..1b4c8043e --- /dev/null +++ b/src/layers/cctv/projectionScheduling.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createProjection } from './projection.js'; + +function setup(t) { + let next = 0; + const raf = new Map(); + const timers = new Map(); + const originals = new Map(); + for (const [name, value] of Object.entries({ + requestAnimationFrame: (fn) => { + raf.set(++next, fn); + return next; + }, + cancelAnimationFrame: (id) => raf.delete(id), + setTimeout: (fn) => { + timers.set(++next, fn); + return next; + }, + clearTimeout: (id) => timers.delete(id), + })) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + } + const holds = new Set(); + const requests = []; + let uploads = 0; + let dirty = false; + let focus = false; + const record = { projection: { mode: 'image', disposed: false } }; + const state = { + _enabled: true, + _viewer: {}, + _showProjection: true, + _projectionRaf: 0, + _projectionTimer: 0, + _activeFocusStyleCount: 0, + }; + const projection = createProjection({ + state, + services: { + render: { + holdContinuousRender: (id) => holds.add(id), + releaseContinuousRender: (id) => holds.delete(id), + governorRequestRender: (reason) => requests.push(reason), + }, + focus: { + getFocusTarget: () => (focus ? {} : null), + focusPassIsNeeded: (target) => !!target, + }, + }, + parts: { + model: { projectionLoopIsNeeded: () => state._enabled }, + selection: { getActiveRecord: () => record }, + rendering: { refreshCctvFocusStyles() {} }, + frames: { + drawProjectionFrame() {}, + refreshProjectionTextures() { + if (!dirty) return; + dirty = false; + uploads++; + return true; + }, + }, + }, + }); + t.after(() => { + projection.stopProjectionLoop(); + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete globalThis[name]; + } + }); + function run(queue) { + const [id, fn] = queue.entries().next().value || []; + assert.equal(typeof fn, 'function', 'scheduled work exists'); + queue.delete(id); + fn(); + } + return { + projection, + state, + record, + holds, + requests, + raf, + timers, + frame: () => run(raf), + timer: () => run(timers), + changeImage: () => { + dirty = true; + }, + focus: (value) => { + focus = value; + }, + uploads: () => uploads, + }; +} + +test('an unchanged still keeps fetching cadence without holding or requesting GPU frames', (t) => { + const h = setup(t); + h.projection.startProjectionLoop(); + assert.equal(h.holds.size, 0); + h.frame(); + for (let i = 0; i < 20; i++) h.timer(); + assert.equal(h.raf.size, 0); + assert.equal(h.holds.size, 0); + assert.deepEqual(h.requests, []); + h.changeImage(); + h.timer(); + assert.equal(h.uploads(), 1); + assert.deepEqual(h.requests, ['cctv-projection-image']); + h.timer(); + assert.equal(h.requests.length, 1); +}); + +test('video and focus fades retain continuous rendering, then a still releases it', (t) => { + const h = setup(t); + h.record.projection.video = {}; + h.projection.startProjectionLoop(); + h.frame(); + assert.deepEqual([...h.holds], ['cctv-projection']); + assert.equal(h.timers.size, 0); + h.record.projection.video = null; + h.focus(true); + h.frame(); + assert.equal(h.holds.size, 1); + h.focus(false); + h.frame(); + assert.equal(h.holds.size, 0); + assert.equal(h.raf.size, 0); + assert.equal(h.timers.size, 1); +}); + +test('stopping cancels a still timer and a retired callback cannot restart it', (t) => { + const h = setup(t); + h.projection.startProjectionLoop(); + h.frame(); + const retired = [...h.timers.values()][0]; + h.projection.stopProjectionLoop(); + assert.equal(h.timers.size, 0); + retired(); + assert.equal(h.raf.size + h.timers.size, 0); + h.projection.startProjectionLoop(); + h.frame(); + retired(); + assert.equal(h.timers.size, 1); + h.state._enabled = false; + h.timer(); + assert.equal(h.raf.size + h.timers.size + h.holds.size, 0); +}); + +test('a newly tracked focus wakes a sleeping still without waiting for its next timer', (t) => { + const h = setup(t); + h.projection.startProjectionLoop(); + h.frame(); + const retired = [...h.timers.values()][0]; + h.focus(true); + h.projection.startProjectionLoop(); + assert.equal(h.timers.size, 0); + retired(); + assert.equal(h.raf.size, 1); + h.frame(); + assert.equal(h.holds.size, 1); +}); diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index 725d77e4f..59e2ebfcb 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -65,6 +65,7 @@ export function createState({ services }) { layerState._listeners = new Set(); layerState._projectionRaf = 0; + layerState._projectionTimer = 0; layerState._removeFocusAppearListener = null;