/** * qa-cctv-v2.mjs — CCTV v2 subsystem proof harness * * Replaces qa-cctv-b9.mjs + qa-cctv-drift-b9b.mjs, which proved fixes in * systems v2 deleted outright (chunked auto-cal raycast storms, periodic * ground re-sync deadband/oscillation). v2 has no per-frame scene queries at * all, no auto-calibration, and no periodic re-grounding — so this harness * asserts the NEW invariants instead of re-proving retired ones: * * 1. Raycast invariant (design §9.1-amended): scene.pickFromRay fires * exactly once per camera ACTIVATION (the §9.1 obstruction probe) and * never again in steady state; shared mesh-floor sampling fires at most * once per coarse cell during the staggered geometry-load drain, and * actually COMPLETES once tiles load (>=1 real sample — the * ceiling alone would pass trivially at Δ=0 if the snap never finished), * stays flat while idle, and stays flat for non-positional pose edits. * 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane * exist for the active camera, the plane's corners coincide with the * 4 corner-ray endpoints, and the whole geometry is byte-stable over a * 30s idle window (nothing resamples/rewrites without a reason to). * 3. Calibration round-trip: a manual patch writes the v2 localStorage key * with source:'manual', rotates the geometry, flips the panel calBadge * to 'calibrated'; reset removes the entry and restores base geometry. * 4. Frame-loop health: the plane's projection runtime starts on a * placeholder and reaches a "frame considered fresh" state, and the * panel + the plane's underlying fetch share the same * /api/cctv/frame/ URL family. * 5. Empty-space ownership: real canvas gestures prove ADJUST and sibling * picks preserve the active camera, while a true empty-space click * publishes exactly one null transition without moving the camera or * emitting a focus request; a repeat click is idempotent. * 6. Viewshed mode (v3, 2026-07-05): coverage tri-state round-trip + * boolean compat, color-coded volume primitives exist only in * 'viewshed' for the visible set, idle-stable by object identity, * raycast counters flat across cycling. * 7. Calibration gizmo (v3): 8 handle entities in ADJUST mode, synthetic * E-arrow drag edits the live pose while freezing mount elevation and * 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. * * 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 * numbers off live Cesium objects (Cartesian3 -> {x,y,z}, Quaternion -> * {x,y,z,w}) and does its own vector/quaternion math in plain JS, or * (b) borrows static methods off the *constructor* of a live instance, which * works because Cesium classes' statics live on the constructor, not a * namespace object. Separately, this harness DOES import * `computeFrustumGeometry` directly from `src/data/cctv.js` (a pure, * side-effect-free export with no Cesium/scene dependency) to use as a * Node-side ground-truth oracle — see the "safe pose" search below. * * Ground-clamp caveat (discovered writing this harness, see task report): * Austin's fabricated pose priors (pitch -18/-24°, FOV 44/56°, range 145/210m, * mount 8/10m — vite.config.js's two "personalities") routinely put the far * cap's bottom edge below ground truth. `computeFrustumGeometry` clamps each * WIREFRAME corner independently to `groundAlt + FRUSTUM_GROUND_CLEARANCE_M`, * but the PLANE entity (`createProjectionRuntime`/`updatePlanePlacement`) is * always an undistorted rectangle (fixed halfW/halfH, unclamped tilt) sitting * at the (possibly-clamped) capCenter. When the pose clamps asymmetrically, * the plane and the wireframe corners genuinely do NOT coincide — this is a * real property of the shipped geometry, not a harness bug. To test the * intended "coincide" contract deterministically (independent of which * camera activates), this harness first applies a temporary calibration * patch (large upward pitch offset + shrunk FOV + minimum range) computed * via a Node-side search against the SAME `computeFrustumGeometry` oracle, * chosen to clear the ground clamp with margin, then resets it afterward. * * Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173 * * Exit 0 = all asserts passed. Non-zero = a hard failure. */ import puppeteer from 'puppeteer'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { computeFrustumGeometry, FRUSTUM_GROUND_CLEARANCE_M } from '../src/data/cctv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); const argv = process.argv.slice(2); const getOpt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; }; const getFlag = (name) => argv.includes(name); const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const APP_URL = getOpt('--url', BASE_URL); const HEADFUL = getFlag('--headful'); const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, // Prefer puppeteer's version-pinned Chrome-for-Testing over the system // Chrome: /Applications auto-updates underneath the harnesses, and its // software-GL behavior shifts across majors (system Chrome 150 blew the // tile-gated drain budget under SwiftShader on 2026-07-30 — six // false-negative qa-cctv-v2 runs against a healthy build). A deterministic // pinned browser beats the newest one for regression harnesses. (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', ].filter(Boolean); function findChromeExecutable() { for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ } } return null; } const results = []; function record(name, ok, detail) { results.push({ name, ok, detail }); const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // SwiftShader can spend tens of seconds servicing the hover drillPick emitted // by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the // pointer while ADJUST is disabled, then re-enable it and explicitly render a // few frames so the real press/drag still exercises Cesium's input + pick path. async function positionPointerForGizmoDrag(page, point, frameCount = 3) { await page.evaluate(() => { window.__godsEyeView.dataManager.layers.get('cctv').module .setParams({ calibrationMode: false }); }); await page.mouse.move(point.x, point.y); return page.evaluate(async (count) => { const gev = window.__godsEyeView; gev.dataManager.layers.get('cctv').module.setParams({ calibrationMode: true }); const scene = gev.viewer.scene; let rendered = 0; return new Promise((resolve) => { let settled = false; const finish = (complete) => { if (settled) return; settled = true; clearTimeout(timer); remove(); resolve(complete); }; const remove = scene.postRender.addEventListener(() => { rendered += 1; if (rendered >= count) { finish(true); return; } scene.requestRender(); }); const timer = setTimeout(() => finish(false), 5000); scene.requestRender(); }); }, frameCount); } // In-page helper (stringified and re-defined inside each evaluate that needs // it, since functions can't cross the puppeteer boundary): serializes the // 5 frustum polylines + plane position/orientation/dimensions for a camera // into plain arrays of numbers, using only the camera's own clock time (no // Cesium namespace needed — Property.getValue just needs *a* JulianDate, // and the viewer's own clock.currentTime is a valid live instance of one). const SERIALIZE_GEOM_SRC = ` function serializeGeom(camId) { const viewer = window.__godsEyeView.viewer; const time = viewer.clock.currentTime; const roles = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap']; const poly = {}; for (const role of roles) { const ent = viewer.entities.getById('cctv-' + camId + '-' + role); poly[role] = ent && ent.polyline ? ent.polyline.positions.getValue(time).map((p) => [p.x, p.y, p.z]) : null; } const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); let plane = null; if (planeEnt) { const pos = planeEnt.position.getValue(time); const orient = planeEnt.orientation.getValue(time); const dims = planeEnt.plane.dimensions.getValue(time); plane = { position: [pos.x, pos.y, pos.z], orientation: [orient.x, orient.y, orient.z, orient.w], dimensions: [dims.x, dims.y], }; } return { poly, plane }; } `; // In-page helper: bearing (degrees, mount -> cap-center) shift between two // serializeGeom() snapshots of the SAME camera. Plain-JS spherical bearing // math over ECEF points (no Cesium namespace) — a local east/north basis is // built from the mount's geocentric "up" via cross products, which is // equivalent to a proper ENU bearing at these ranges (hundreds of metres; // cctv.js's own math already treats this scale as flat-earth, see // projectPoint). Used both to prove a heading patch rotates the geometry AND // that reset un-rotates it — comparing BEARING (not absolute cap-center // position) is deliberate: the far-cap's ALTITUDE can legitimately drift a // few metres between two ground-clamp actions because scene.sampleHeight // against a still-streaming 3D tileset is not perfectly deterministic call to // call (observed while writing this harness — see report), which would give // a false-negative on an absolute-position epsilon that heading math alone // doesn't share. const BEARING_SHIFT_SRC = ` function bearingShiftDeg(before, after) { function capCenter(g) { const pts = g.poly.cap; // [tl, tr, br, bl, tl] const avg = [0, 0, 0]; for (let i = 0; i < 4; i++) { avg[0] += pts[i][0]; avg[1] += pts[i][1]; avg[2] += pts[i][2]; } return [avg[0] / 4, avg[1] / 4, avg[2] / 4]; } const mount = before.poly['ray-tl'][0]; const up = mount; const upLen = Math.hypot(up[0], up[1], up[2]); const upN = [up[0] / upLen, up[1] / upLen, up[2] / upLen]; const ref = Math.abs(upN[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]; const eastRaw = [ ref[1] * upN[2] - ref[2] * upN[1], ref[2] * upN[0] - ref[0] * upN[2], ref[0] * upN[1] - ref[1] * upN[0], ]; const eastLen = Math.hypot(eastRaw[0], eastRaw[1], eastRaw[2]); const east = [eastRaw[0] / eastLen, eastRaw[1] / eastLen, eastRaw[2] / eastLen]; const north = [ upN[1] * east[2] - upN[2] * east[1], upN[2] * east[0] - upN[0] * east[2], upN[0] * east[1] - upN[1] * east[0], ]; function bearingTo(target) { const v = [target[0] - mount[0], target[1] - mount[1], target[2] - mount[2]]; const e = v[0] * east[0] + v[1] * east[1] + v[2] * east[2]; const n = v[0] * north[0] + v[1] * north[1] + v[2] * north[2]; return (Math.atan2(e, n) * 180 / Math.PI + 360) % 360; } const b0 = bearingTo(capCenter(before)); const b1 = bearingTo(capCenter(after)); let delta = b1 - b0; delta = ((delta + 540) % 360) - 180; // normalize to [-180, 180] return delta; } `; /** * Waits for the scene's 3D tileset to report tilesLoaded===true. Cesium * flips tilesLoaded back to false whenever a view/pose change causes new * tiles to be requested (observed directly while writing this harness: it * can go false again even after an earlier wait already saw it true) — so * every call site that depends on a REAL ground sample landing (not the * B9c guard's pure recompute from the cached real ground) must wait fresh * immediately before the action, not rely on an earlier wait. * @param {import('puppeteer').Page} page * @param {number} [timeoutMs=15000] * @returns {Promise} True if tilesLoaded was observed within the timeout. */ function waitForTilesLoaded(page, timeoutMs = 15000) { return page.waitForFunction( () => { const scene = window.__godsEyeView.viewer.scene; const prims = scene.primitives; for (let i = 0; i < prims.length; i++) { const p = prims.get(i); if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { return p.tilesLoaded === true; } } return true; // no tileset in the scene — nothing to wait for }, { timeout: timeoutMs } ).then(() => true).catch(() => false); } /** * Searches for a calibration patch (pitchDeg/fovDeg/rangeScale offsets) that * clears the ground clamp for a given base pose, using the SAME * computeFrustumGeometry the app renders with as the oracle. Pushes pitch to * its allowed maximum, shrinks range to its allowed minimum, then shrinks FOV * in 2-degree steps until every corner sits comfortably above * `groundAltM + FRUSTUM_GROUND_CLEARANCE_M` (3m safety margin beyond the * clamp floor). Returns null if no patch in the legal offset ranges clears it * (shouldn't happen for the catalog's fabricated poses, but the caller must * treat that as INCONCLUSIVE, not a hard failure). * @param {Object} basePose - { pitchDeg, fovDeg, rangeM, mountHeightM }. * @param {number} groundAltM - Ground altitude at the mount. * @returns {{ pitchDeg: number, fovDeg: number, rangeScale: number }|null} Calibration patch offsets. */ function findSafeCalibrationPatch(basePose, groundAltM) { const pitchDeg = Math.max(-70, Math.min(10, basePose.pitchDeg + 45)); const pitchOffset = Number((pitchDeg - basePose.pitchDeg).toFixed(1)); const rangeScale = 0.35; // minimum legal rangeScale (normalizeCalibration clamp) const rangeM = Math.max(120, basePose.rangeM * rangeScale); const clampFloor = groundAltM + FRUSTUM_GROUND_CLEARANCE_M; for (let fovOffset = 0; fovOffset >= -50; fovOffset -= 2) { const fovDeg = Math.max(20, Math.min(130, basePose.fovDeg + fovOffset)); const camera = { lat: 30, lon: -97, headingDeg: 0, pitchDeg, fovDeg, rangeM, mountHeightM: basePose.mountHeightM }; const geom = computeFrustumGeometry(camera, groundAltM, rangeM); const minAlt = Math.min(geom.corners.tl.alt, geom.corners.tr.alt, geom.corners.br.alt, geom.corners.bl.alt); if (minAlt > clampFloor + 3) { return { pitchDeg: pitchOffset, fovDeg: fovOffset, rangeScale }; } } return null; } async function main() { console.log(`\nCCTV v2 Subsystem Proof`); console.log(` App URL : ${APP_URL}`); console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\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`); process.exit(2); } fs.mkdirSync(SHOTS_DIR, { recursive: true }); const chromeExecutable = findChromeExecutable(); const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']), '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800', ], }); const consoleErrors = []; let exitCode = 0; 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); } } }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager, { timeout: 60000 } ); // Let the initial fly-to Austin and first tiles settle. await sleep(4000); // ----------------------------------------------------------------------- // Install raycast counters BEFORE the layer is enabled (§9.1-amended: the // zero-raycast invariant is steady-state-only; the single activation // obstruction probe is the one legal pickFromRay). Wrapping happens // in-page against the live scene, counting ALL calls (shared scene) — the // harness only ever asserts DELTAS around CCTV-specific actions, so noise // from unrelated subsystems cancels out by construction. // ----------------------------------------------------------------------- const wrapInstalled = await page.evaluate(() => { const scene = window.__godsEyeView?.viewer?.scene; if (!scene || typeof scene.pickFromRay !== 'function' || typeof scene.sampleHeight !== 'function') { return false; } window.__qaCounters = { pickFromRay: 0, sampleHeight: 0 }; const origPick = scene.pickFromRay.bind(scene); scene.pickFromRay = (...args) => { window.__qaCounters.pickFromRay += 1; return origPick(...args); }; const origSample = scene.sampleHeight.bind(scene); scene.sampleHeight = (...args) => { window.__qaCounters.sampleHeight += 1; return origSample(...args); }; return true; }); if (!wrapInstalled) { console.error('\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m'); process.exit(2); } const readCounters = () => page.evaluate(() => ({ ...window.__qaCounters })); console.log('Enabling CCTV layer...'); const c0 = await readCounters(); await page.evaluate(async () => { const dm = window.__godsEyeView.dataManager; const entry = dm.layers.get('cctv'); if (!entry.enabled) await dm.toggle('cctv'); }); // Wait for the staggered geometry-load queue to drain (shared mesh-floor // sampling: <=1 sampleHeight per coarse cell, gated on tiles-ready). The // budget scales with catalog size: the queue staggers ~4 records/120ms // and the one-shot tiles-ready completion pass can re-enqueue the whole // catalog once — 30s was calibrated for the old 36-camera default and // times out spuriously at the 250-camera default (2026-07-04). const camCount = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().count); // ~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)); console.log(`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`); const drained = await page.waitForFunction( () => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const ui = mod.getUIState(); return ui.loading && ui.loading.active === false; }, { timeout: drainBudgetMs } ).then(() => true).catch(() => false); record(`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`, drained ? true : (HEADFUL ? false : null), 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'); const cAfterDrain = await readCounters(); record('pickFromRay stays 0 before any activation', cAfterDrain.pickFromRay - c0.pickFromRay === 0, `Δ=${cAfterDrain.pickFromRay - c0.pickFromRay}`); // Every camera maps to at most one shared coarse cell, so ≤1×N remains a // safe ceiling while nearby cameras reuse the same accepted floor. const sampleAfterDrain = cAfterDrain.sampleHeight - c0.sampleHeight; record(`sampleHeight <= 1×N after drain (N=${camCount})`, sampleAfterDrain <= camCount, `Δ=${sampleAfterDrain}, ceiling=${camCount} (one-shot shared mesh-floor cell)`); // Pick the active camera (or nearest) to activate. console.log('Activating a camera (focusNearest)...'); const activeIdBeforeActivation = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().activeCameraId); const cBeforeActivate = await readCounters(); await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.focusNearest({ durationSec: 0.1 }); }); await sleep(500); // let the activation's synchronous work (probe + geometry rewrite) land const cAfterActivate = await readCounters(); const activeId = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().activeCameraId); const pickDeltaActivation = cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay; record('pickFromRay fires exactly once for the activation (§9.1 probe)', pickDeltaActivation === 1, `Δ=${pickDeltaActivation} (camera=${activeId}, was=${activeIdBeforeActivation})`); // Re-selecting the ALREADY-ACTIVE camera is a no-op (owner field test // 2026-07-04: every click on the monitor plane picks its own camera, and // re-running activation rewrote the plane entity → visible flash). No new // probe, no geometry rewrite. const geomBeforeReselect = await page.evaluate(`(() => { ${SERIALIZE_GEOM_SRC} return JSON.stringify(serializeGeom(${JSON.stringify(activeId)})); })()`); await page.evaluate((id) => { window.__godsEyeView.dataManager.layers.get('cctv').module.setParams({ selectedCameraId: id }); }, activeId); await sleep(400); const cAfterReselect = await readCounters(); const geomAfterReselect = await page.evaluate(`(() => { ${SERIALIZE_GEOM_SRC} return JSON.stringify(serializeGeom(${JSON.stringify(activeId)})); })()`); record('re-selecting the active camera is a no-op (no probe, no geometry rewrite)', cAfterReselect.pickFromRay - cAfterActivate.pickFromRay === 0 && geomBeforeReselect === geomAfterReselect, `probeΔ=${cAfterReselect.pickFromRay - cAfterActivate.pickFromRay}, geometryChanged=${geomBeforeReselect !== geomAfterReselect}`); // ----------------------------------------------------------------------- // Completion FLOOR (locks update()'s one-shot tiles-ready re-enqueue): // the "<=1×N" ceiling above passes trivially at Δ=0 — e.g. if every drain // pass ran before tiles loaded (fallback height, record left unresolved) // and nothing ever completed the snap. Once tilesLoaded has been observed // true, at least ONE real scene.sampleHeight must have fired since enable // (existence, not just ceiling). The wait below tolerates the update-tick // latency of the completion pass (update interval is 10s). // ----------------------------------------------------------------------- console.log('Waiting for tiles + the one-shot completion pass...'); const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); let sampleFloorOk = false; if (tilesSeenLoaded) { sampleFloorOk = await page.waitForFunction( (base) => window.__qaCounters.sampleHeight - base >= 1, { timeout: 30000 }, c0.sampleHeight ).then(() => true).catch(() => false); } const cFloor = await readCounters(); record('>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', tilesSeenLoaded ? sampleFloorOk : null, tilesSeenLoaded ? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}` : 'tilesLoaded never observed within 45s — floor not assessable this run'); // Let the completion pass finish before the steady-state window: require // the sampleHeight counter quiet for 12s (> the 10s update interval, so // the one-shot latch has provably had a tick in which to fire and drain) // — otherwise the idle assertion below could observe the legitimate tail // of the one-shot pass and misread it as steady-state sampling. The cap // scales with N like the drain budget: when the ENABLE-time drain ran // before tiles loaded (fallback path, zero real samples), the completion // pass re-enqueues the WHOLE catalog and each real sampleHeight costs // ~1s under SwiftShader — at N=250 that tail is ~4 minutes, and the old // fixed 60s cap expired mid-pass, bleeding legitimate one-shot samples // into the idle window (observed 2026-07-05: idle Δ=12, pose-edit Δ=5). let completionPassQuiet = false; { const settleBudgetMs = Math.max(60000, camCount * 1200 + 30000); console.log(`Waiting for the one-shot completion pass to go quiet (budget ${Math.round(settleBudgetMs / 1000)}s)...`); let last = (await readCounters()).sampleHeight; let quietMs = 0; const settleStart = Date.now(); while (quietMs < 12000 && Date.now() - settleStart < settleBudgetMs) { await sleep(1000); const cur = (await readCounters()).sampleHeight; if (cur === last) { quietMs += 1000; } else { quietMs = 0; last = cur; } } completionPassQuiet = quietMs >= 12000; } // ----------------------------------------------------------------------- // Steady-state idle window (>=15s): pickFromRay must not grow at all; // sampleHeight must stay flat (queue already drained, no periodic re-sync // in v2). // ----------------------------------------------------------------------- console.log('Idling 15s to check steady-state raycast invariant...'); const cIdleStart = await readCounters(); await sleep(15000); const cIdleEnd = await readCounters(); const pickDuringIdle = cIdleEnd.pickFromRay - cIdleStart.pickFromRay; const sampleDuringIdle = cIdleEnd.sampleHeight - cIdleStart.sampleHeight; record('pickFromRay does not grow during 15s steady-state idle', pickDuringIdle === 0, `Δ=${pickDuringIdle}`); record('sampleHeight stays flat during 15s steady-state idle', completionPassQuiet ? sampleDuringIdle === 0 : null, completionPassQuiet ? `Δ=${sampleDuringIdle}` : `one-shot floor completion did not become quiet under this GL stack; observed tail Δ=${sampleDuringIdle}`); // ----------------------------------------------------------------------- // A rotational calibration edit preserves the existing shared floor and // therefore adds zero sampleHeight calls. The activation obstruction probe // remains activation-only. console.log('Applying a calibration patch (heading +30°)...'); const cBeforeCal = await readCounters(); await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, patch: { headingDeg: 30 } } }); }, activeId); await sleep(500); const cAfterCal = await readCounters(); const sampleDeltaCal = cAfterCal.sampleHeight - cBeforeCal.sampleHeight; const pickDeltaCal = cAfterCal.pickFromRay - cBeforeCal.pickFromRay; record('sampleHeight stays flat after a heading-only calibration edit', completionPassQuiet ? sampleDeltaCal === 0 : null, completionPassQuiet ? `Δ=${sampleDeltaCal}` : `one-shot floor completion was still unresolved; observed tail Δ=${sampleDeltaCal}`); record('pickFromRay does not fire on a calibration patch (activation-only)', pickDeltaCal === 0, `Δ=${pickDeltaCal}`); // Undo that patch before the geometry-contract assertions below so they // observe the record's steady, unpatched frustum. await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); await sleep(300); // ========================================================================= // Group 2: geometry contract // ========================================================================= console.log('Checking geometry contract (frustum + plane)...'); const geomInfo = await page.evaluate(`(function(camId){ ${SERIALIZE_GEOM_SRC} const g = serializeGeom(camId); const viewer = window.__godsEyeView.viewer; // Overlay unification: the plane label is no longer a native entity — // it publishes as a protected host entry under the cctv-projection // source. Assert the migrated surface via the host diagnostics. const diag = window.__gevWorldOverlay?.getDiagnostics?.(); g.hasLabel = (diag?.entriesBySource?.['cctv-projection'] || 0) >= 1; const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); g.planeShow = planeEnt ? planeEnt.show : null; return g; })(${JSON.stringify(activeId)})`); const allPolylinesPresent = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap'].every((r) => Array.isArray(geomInfo.poly[r])); record('all 5 frustum polyline entities exist for the active camera', allPolylinesPresent, Object.keys(geomInfo.poly).map((k) => `${k}=${geomInfo.poly[k] ? 'ok' : 'MISSING'}`).join(', ')); record('plane + plane-label entities exist for the active camera', !!geomInfo.plane && geomInfo.hasLabel, `plane=${!!geomInfo.plane} label=${geomInfo.hasLabel} show=${geomInfo.planeShow}`); // Plane corner positions must coincide with the 4 corner-ray endpoints // (ε < 0.5m) — but ONLY when the frustum doesn't hit the ground clamp // (see the file-header "Ground-clamp caveat"). Austin's fabricated pitch // priors clamp routinely at the base pose, so apply a temporary // calibration patch (computed against the same computeFrustumGeometry // oracle the app uses) that pushes pitch up + shrinks FOV/range enough to // clear the clamp with margin, run the coincidence check against THAT // pose, then reset back to the base pose before the byte-stability check. const activeCameraForPatch = await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; return mod.getUIState().cameras.find((c) => c.id === camId); }, activeId); const basePose = activeCameraForPatch.basePose; const groundAltMForPatch = activeCameraForPatch.elevationM - activeCameraForPatch.mountHeightM; const safePatch = basePose ? findSafeCalibrationPatch(basePose, groundAltMForPatch) : null; record('found a calibration patch that clears the ground clamp (test setup, not an app assertion)', safePatch !== null, safePatch ? JSON.stringify(safePatch) : 'no legal offset combination cleared the clamp — corner-coincidence check below will run against the (possibly clamped) base pose'); // updateRecordGeometry's B9c guard now recomputes PURELY from the cached // real ground when tilesLoaded flips false at the instant of the patch // (final-review fix — previously it skipped the rewrite entirely, leaving // the pre-patch polylines/plane dimensions stale), so a pose patch always // lands in the entity geometry. Cesium can still re-flip tilesLoaded // false on a pose change even after an earlier wait already saw it true // (a shrunk FOV/pitch change requests a different tile set), so the // wait-then-patch retry loop is kept as belt-and-braces, verifying the // plane's dimensions actually changed before trusting geomForCorners. let geomForCorners = geomInfo; let patchGeometryApplied = !safePatch; if (safePatch) { const dimsBefore = geomInfo.plane ? JSON.stringify(geomInfo.plane.dimensions) : null; for (let attempt = 0; attempt < 3 && !patchGeometryApplied; attempt++) { await waitForTilesLoaded(page, 15000); await page.evaluate(({ camId, patch }) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, patch } }); }, { camId: activeId, patch: safePatch }); await sleep(400); geomForCorners = await page.evaluate(`(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(activeId)})`); const dimsAfter = geomForCorners.plane ? JSON.stringify(geomForCorners.plane.dimensions) : null; patchGeometryApplied = !!dimsAfter && dimsAfter !== dimsBefore; } } record('calibration patch geometry landed (test setup, not an app assertion)', patchGeometryApplied, patchGeometryApplied ? 'plane dimensions changed vs pre-patch' : 'plane dimensions never changed — tiles kept flipping mid-stream; corner-coincidence check below may be running against a stale pose'); let cornerEps = null; if (allPolylinesPresent && geomForCorners.plane) { cornerEps = await page.evaluate((geom) => { // Rotate vector v by quaternion q=[x,y,z,w] (standard formula). function rotate(q, v) { const [qx, qy, qz, qw] = q; const ix = qw * v[0] + qy * v[2] - qz * v[1]; const iy = qw * v[1] + qz * v[0] - qx * v[2]; const iz = qw * v[2] + qx * v[1] - qy * v[0]; const iw = -qx * v[0] - qy * v[1] - qz * v[2]; return [ ix * qw + iw * -qx + iy * -qz - iz * -qy, iy * qw + iw * -qy + iz * -qx - ix * -qz, iz * qw + iw * -qz + ix * -qy - iy * -qx, ]; } const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); const { poly, plane } = geom; const center = plane.position; const halfW = plane.dimensions[0] / 2; const halfH = plane.dimensions[1] / 2; // planeOrientationFor builds the frame as columns [right, up, normal] // — local +X is right, +Y is up (matches Cesium's row/col Matrix3 // constructor call order in cctv.js's planeOrientationFor). const corner = (sx, sy) => { const local = [sx * halfW, sy * halfH, 0]; const world = rotate(plane.orientation, local); return [center[0] + world[0], center[1] + world[1], center[2] + world[2]]; }; const planeCorners = { tl: corner(-1, 1), tr: corner(1, 1), br: corner(1, -1), bl: corner(-1, -1) }; const rayEndpoint = (role) => poly[role][poly[role].length - 1]; const rayCorners = { tl: rayEndpoint('ray-tl'), tr: rayEndpoint('ray-tr'), br: rayEndpoint('ray-br'), bl: rayEndpoint('ray-bl') }; return { tl: dist(planeCorners.tl, rayCorners.tl), tr: dist(planeCorners.tr, rayCorners.tr), br: dist(planeCorners.br, rayCorners.br), bl: dist(planeCorners.bl, rayCorners.bl), }; }, geomForCorners); const maxEps = Math.max(...Object.values(cornerEps)); const epsLabel = safePatch ? 'plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m, ground-clamp-cleared pose)' : 'plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m)'; record(epsLabel, maxEps < 0.5, `tl=${cornerEps.tl.toFixed(3)}m tr=${cornerEps.tr.toFixed(3)}m br=${cornerEps.br.toFixed(3)}m bl=${cornerEps.bl.toFixed(3)}m`); } else { record('plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m)', null, 'skipped — geometry entities missing'); } // Restore the base (unpatched) pose before the byte-stability window so // that check observes the record's normal steady-state frustum, not the // synthetic clamp-clearing test pose. if (safePatch) { await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); await sleep(300); } // Byte-stable over 30s idle: serialize + compare after 30s. Nothing // resamples on a timer in v2, so this is a pure drift regression. console.log('Checking geometry byte-stability over 30s idle...'); const serializeGeom = (camId) => page.evaluate(`(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(camId)})`); const geomBefore30s = await serializeGeom(activeId); await sleep(30000); const geomAfter30s = await serializeGeom(activeId); const geomStable = JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s); record('geometry is byte-stable over 30s idle (no per-frame/timer resample)', geomStable, geomStable ? 'identical serialization' : 'serialization CHANGED — see harness output above'); if (!geomStable) { console.log(' before:', JSON.stringify(geomBefore30s)); console.log(' after :', JSON.stringify(geomAfter30s)); } // ========================================================================= // Group 3: calibration round-trip // ========================================================================= console.log('Checking calibration round-trip (patch -> save -> reset)...'); const baseGeom = await serializeGeom(activeId); const baseBadge = await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const ui = mod.getUIState(); return ui.cameras.find((c) => c.id === camId)?.calBadge; }, activeId); const baseStoreEmpty = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); const map = raw ? JSON.parse(raw) : {}; return !(camId in map); }, activeId); record('CAL badge is raw-prior (or curated) before any manual save', baseBadge !== 'calibrated', `calBadge=${baseBadge}`); record('v2 store has no entry for this camera before any manual save', baseStoreEmpty, baseStoreEmpty ? 'no entry' : 'unexpected pre-existing entry'); await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, patch: { headingDeg: 30 } } }); }, activeId); await sleep(300); // Save-gated persistence (v3 design §3e): a PATCH edits the live pose // only — the store must stay untouched and the camera must read as // dirty/EDITED until the explicit save action below. const afterPatch = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); const map = raw ? JSON.parse(raw) : {}; const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().cameras.find((c) => c.id === camId); return { stored: camId in map, calDirty: cam?.calDirty, calBadge: cam?.calBadge }; }, activeId); record('patch does NOT write the v2 store (save-gated)', afterPatch.stored === false, afterPatch.stored ? 'entry written on patch — save-gating broken' : 'store untouched'); record('patch marks the camera calDirty (EDITED chip)', afterPatch.calDirty === true, `calDirty=${afterPatch.calDirty}`); record('calBadge stays non-calibrated after an unsaved patch', afterPatch.calBadge !== 'calibrated', `calBadge=${afterPatch.calBadge}`); await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, save: true } }); }, activeId); await sleep(200); const storeEntry = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); const map = raw ? JSON.parse(raw) : {}; return map[camId] || null; }, activeId); record('v2 localStorage key written with source:manual + savedAt on SAVE', !!(storeEntry && storeEntry.source === 'manual' && Number.isFinite(storeEntry.savedAt)), storeEntry ? `source=${storeEntry.source} savedAt=${storeEntry.savedAt}` : 'no entry written'); const afterSave = await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().cameras.find((c) => c.id === camId); return { calBadge: cam?.calBadge, calDirty: cam?.calDirty }; }, activeId); record('calBadge flips to calibrated after SAVE (and dirty clears)', afterSave.calBadge === 'calibrated' && afterSave.calDirty === false, `calBadge=${afterSave.calBadge} calDirty=${afterSave.calDirty}`); // Geometry rotated: cap-center bearing from the mount should have shifted // ~30 degrees vs the pre-patch baseline (shared BEARING_SHIFT_SRC helper // — see its header comment for why bearing, not absolute position). const geomAfterCal = await serializeGeom(activeId); const bearingShiftDegPatch = await page.evaluate(`(function(before, after){ ${BEARING_SHIFT_SRC} return bearingShiftDeg(before, after); })(${JSON.stringify(baseGeom)}, ${JSON.stringify(geomAfterCal)})`); const rotatedByAbout30 = Math.abs(Math.abs(bearingShiftDegPatch) - 30) < 5; record('geometry cap-center bearing shifts ~30° after headingDeg+30 patch', rotatedByAbout30, `Δbearing=${bearingShiftDegPatch.toFixed(2)}°`); // Reset: entry removed, base geometry restored, badge back to raw-prior. await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); await sleep(300); const storeAfterReset = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); const map = raw ? JSON.parse(raw) : {}; return camId in map; }, activeId); record('v2 localStorage entry removed after reset', !storeAfterReset, storeAfterReset ? 'entry still present' : 'removed'); const badgeAfterReset = await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; return mod.getUIState().cameras.find((c) => c.id === camId)?.calBadge; }, activeId); record('calBadge returns to raw-prior (or curated) after reset', badgeAfterReset !== 'calibrated', `calBadge=${badgeAfterReset}`); // Reset must undo the heading+30 rotation back to ~0°. Ground now resolves // through the stable shared floor cache instead of repeated raw samples. const geomAfterReset = await serializeGeom(activeId); const bearingShiftDegReset = await page.evaluate(`(function(before, after){ ${BEARING_SHIFT_SRC} return bearingShiftDeg(before, after); })(${JSON.stringify(baseGeom)}, ${JSON.stringify(geomAfterReset)})`); record('cap-center bearing restored to base (~0° vs baseline) after reset', Math.abs(bearingShiftDegReset) < 5, `Δbearing=${bearingShiftDegReset.toFixed(2)}°`); // ========================================================================= // Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat) // ========================================================================= console.log('Checking frame-loop health (placeholder -> frame swap, URL family)...'); // The plane's material.image is always defined (placeholder canvas from // the moment the runtime is created, real frame content painted onto the // same canvas/video reference once the fetch resolves) — assert it's // never null/undefined once the runtime exists, i.e. there's no gap where // the plane shows nothing. const materialInfo = await page.evaluate((camId) => { const viewer = window.__godsEyeView.viewer; const time = viewer.clock.currentTime; const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); const mat = planeEnt?.plane?.material; const image = mat?.image; const resolved = image && typeof image.getValue === 'function' ? image.getValue(time) : image; return { hasMaterial: !!mat, hasImage: !!resolved, tagName: resolved?.tagName || null, show: planeEnt?.show, }; }, activeId); record('plane material + image are defined for the active camera (no blank gap)', materialInfo.hasMaterial && materialInfo.hasImage, `material=${materialInfo.hasMaterial} image=${materialInfo.hasImage} tag=${materialInfo.tagName} show=${materialInfo.show}`); // Poll /api/cctv/frame/ directly (the same endpoint frameUrlFor // targets) to confirm the backend serves SOMETHING for this camera // (upstream / streetview / synthetic fallback all count as "the frame // loop is healthy" — a hard 5xx/network failure would not). const frameFetch = await page.evaluate(async (camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().cameras.find((c) => c.id === camId); const res = await fetch(cam.frameUrl); return { ok: res.ok, status: res.status, contentType: res.headers.get('content-type'), url: cam.frameUrl, mediaUrl: cam.mediaUrl }; }, activeId); record('backend serves a frame for the active camera (2xx, image content-type)', frameFetch.ok && /^image\//.test(frameFetch.contentType || ''), `status=${frameFetch.status} content-type=${frameFetch.contentType} url=${frameFetch.url}`); // Panel src and the plane's frame fetch share the same // /api/cctv/frame/ URL family (same endpoint + camera id segment). const panelImgSrc = await page.evaluate(() => document.getElementById('cctv-frame')?.getAttribute('src') || null); const sameFamily = !!panelImgSrc && panelImgSrc.includes(`/api/cctv/frame/${encodeURIComponent(activeId)}`); record('panel and plane share the same /api/cctv/frame/ URL family', sameFamily, `panelSrc=${panelImgSrc}`); // ========================================================================= // Group 5: installed canvas-click ownership + true-empty deselection // ========================================================================= console.log('Checking installed canvas click ownership and empty-space deselection...'); const emptyClickPoint = await page.evaluate(async () => { const gev = window.__godsEyeView; const viewer = gev.viewer; const scene = viewer.scene; const canvas = scene.canvas; const rect = canvas.getBoundingClientRect(); const { hitTestWorldOverlay } = await import('/src/overlays/worldOverlay.js'); const candidates = [ [0.55, 0.72], [0.45, 0.72], [0.62, 0.62], [0.38, 0.62], [0.58, 0.22], [0.42, 0.22], [0.5, 0.52], ]; const resolveId = (picked) => { const direct = picked?.id?.id ?? picked?.id; if (direct !== undefined && direct !== null) return String(direct); const primitive = picked?.primitive?.id?.id ?? picked?.primitive?.id; return primitive === undefined || primitive === null ? null : String(primitive); }; for (const [fx, fy] of candidates) { const x = Math.round(canvas.clientWidth * fx); const y = Math.round(canvas.clientHeight * fy); const picked = scene.pick({ x, y }); const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' }); const top = document.elementFromPoint(rect.left + x, rect.top + y); if (resolveId(picked) === null && !card && (top === canvas || canvas.contains(top))) { return { x: rect.left + x, y: rect.top + y, canvasX: x, canvasY: y }; } } return null; }); record('true-empty canvas target is available (no scene owner or CCTV card)', !!emptyClickPoint, emptyClickPoint ? `canvas=(${emptyClickPoint.canvasX},${emptyClickPoint.canvasY})` : 'no clean canvas point found'); const clickEvidenceSetup = await page.evaluate(() => { const gev = window.__godsEyeView; const viewer = gev.viewer; const mod = gev.dataManager.layers.get('cctv').module; const snapshotPose = () => ({ position: [viewer.camera.positionWC.x, viewer.camera.positionWC.y, viewer.camera.positionWC.z], direction: [viewer.camera.directionWC.x, viewer.camera.directionWC.y, viewer.camera.directionWC.z], up: [viewer.camera.upWC.x, viewer.camera.upWC.y, viewer.camera.upWC.z], right: [viewer.camera.rightWC.x, viewer.camera.rightWC.y, viewer.camera.rightWC.z], transform: Array.from({ length: 16 }, (_, i) => viewer.camera.transform[i]), heading: viewer.camera.heading, pitch: viewer.camera.pitch, roll: viewer.camera.roll, trackedId: viewer.trackedEntity?.id ?? null, }); const cameraPoseMatches = (a, b, epsilon = 1e-5) => { const vectorKeys = ['position', 'direction', 'up', 'right', 'transform']; const scalarKeys = ['heading', 'pitch', 'roll']; return vectorKeys.every((key) => ( Array.isArray(a?.[key]) && Array.isArray(b?.[key]) && a[key].length === b[key].length && a[key].every((value, index) => Math.abs(value - b[key][index]) <= epsilon) )) && scalarKeys.every((key) => Math.abs(a?.[key] - b?.[key]) <= epsilon) && a?.trackedId === b?.trackedId; }; window.__qaCctvClickEvidence?.dispose?.(); const evidence = { activeId: mod.getUIState().activeCameraId, publications: [], activeTransitions: [], focusEvents: 0, pose: snapshotPose(), snapshotPose, cameraPoseMatches, }; evidence.lastActiveId = evidence.activeId; evidence.unsubscribe = mod.subscribe((state) => { evidence.publications.push(state.activeCameraId); if (state.activeCameraId !== evidence.lastActiveId) { evidence.activeTransitions.push([evidence.lastActiveId, state.activeCameraId]); evidence.lastActiveId = state.activeCameraId; } }); evidence.baselineTransitions = evidence.activeTransitions.length; evidence.onFocus = () => { evidence.focusEvents += 1; }; window.addEventListener('gev:cctv-request-focus', evidence.onFocus); evidence.dispose = () => { evidence.unsubscribe?.(); window.removeEventListener('gev:cctv-request-focus', evidence.onFocus); }; window.__qaCctvClickEvidence = evidence; return { activeId: evidence.activeId, baselineTransitions: evidence.baselineTransitions }; }); if (emptyClickPoint) { await page.evaluate(() => { window.__godsEyeView.dataManager.layers.get('cctv').module .setParams({ calibrationMode: true }); }); await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); await sleep(250); const adjustClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibrationMode: false }); evidence.siblingBaselineTransitions = evidence.activeTransitions.length; return { activeId: mod.getUIState().activeCameraId, publications: evidence.publications.length, focusEvents: evidence.focusEvents, poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), }; }); record('ADJUST-mode true-empty canvas click preserves active camera', adjustClick.activeId === clickEvidenceSetup.activeId && adjustClick.focusEvents === 0 && adjustClick.poseSame, `active=${adjustClick.activeId} focus=${adjustClick.focusEvents} poseSame=${adjustClick.poseSame}`); } else { record('ADJUST-mode true-empty canvas click preserves active camera', false, 'no true-empty target'); } const siblingTarget = await page.evaluate(async (emptyPoint) => { if (!emptyPoint) return null; 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 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(ray, distance, new Cesium.Cartesian3()); const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, candidate); if (screen && Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2) { position = candidate; projected = screen; break; } } if (!position) return null; const owner = viewer.entities.add({ id: 'qa-cctv-sibling-owner', position, point: { pixelSize: 64, color: Cesium.Color.MAGENTA, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, }); window.__qaCctvSiblingOwner = owner; await new Promise((resolve) => { let frames = 0; const remove = scene.postRender.addEventListener(() => { if (++frames < 3) { scene.requestRender(); return; } remove(); resolve(); }); scene.requestRender(); }); const rect = scene.canvas.getBoundingClientRect(); const picked = scene.pick(canvasPoint); const pickedId = picked?.id?.id ?? picked?.id ?? null; window.__qaCctvClickEvidence.siblingBaselineTransitions = window.__qaCctvClickEvidence.activeTransitions.length; return { x: rect.left + emptyPoint.canvasX, y: rect.top + emptyPoint.canvasY, pickedId: typeof pickedId === 'string' ? pickedId : pickedId?.id ?? null, ownerId: owner.id, ownsExactObject: picked?.id === owner, projectionDelta: Math.hypot(projected.x - canvasPoint.x, projected.y - canvasPoint.y), }; }, emptyClickPoint); record('sibling test object owns its canvas pick', siblingTarget ? siblingTarget.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject : null, siblingTarget ? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}` : 'no previously verified empty canvas ray was available'); if (siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject) { await page.mouse.click(siblingTarget.x, siblingTarget.y); await sleep(250); } const siblingClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; const gev = window.__godsEyeView; const mod = gev.dataManager.layers.get('cctv').module; if (window.__qaCctvSiblingOwner) { gev.viewer.entities.remove(window.__qaCctvSiblingOwner); delete window.__qaCctvSiblingOwner; } return { activeId: mod.getUIState().activeCameraId, transitions: evidence.activeTransitions.length, baselineTransitions: evidence.siblingBaselineTransitions, focusEvents: evidence.focusEvents, poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), }; }); record('sibling canvas click passes through without CCTV selection or deselection', siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject ? siblingClick.activeId === clickEvidenceSetup.activeId && siblingClick.transitions === siblingClick.baselineTransitions && siblingClick.focusEvents === 0 && siblingClick.poseSame : null, `active=${siblingClick.activeId} transitions=${siblingClick.transitions - siblingClick.baselineTransitions} focus=${siblingClick.focusEvents} poseSame=${siblingClick.poseSame}`); if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); await sleep(250); } const firstEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; const state = window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(); return { activeId: state.activeCameraId, enabled: state.enabled, transitions: evidence.activeTransitions.length, baselineTransitions: evidence.siblingBaselineTransitions, lastTransition: evidence.activeTransitions.at(-1) ?? null, focusEvents: evidence.focusEvents, poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), }; }); record('real true-empty canvas click publishes one active-to-null transition', !!emptyClickPoint && firstEmptyClick.activeId === null && firstEmptyClick.enabled === true && firstEmptyClick.transitions === firstEmptyClick.baselineTransitions + 1 && firstEmptyClick.lastTransition?.[0] === clickEvidenceSetup.activeId && firstEmptyClick.lastTransition?.[1] === null, `active=${firstEmptyClick.activeId} enabled=${firstEmptyClick.enabled} transitions=${firstEmptyClick.transitions - firstEmptyClick.baselineTransitions}`); record('real true-empty deselection preserves pose/tracking and emits no focus request', !!emptyClickPoint && firstEmptyClick.poseSame && firstEmptyClick.focusEvents === 0, `poseSame=${firstEmptyClick.poseSame} focus=${firstEmptyClick.focusEvents}`); if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); await sleep(250); } const repeatEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; const state = window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(); evidence.dispose(); delete window.__qaCctvClickEvidence; return { activeId: state.activeCameraId, transitions: evidence.activeTransitions.length, baselineTransitions: evidence.siblingBaselineTransitions, focusEvents: evidence.focusEvents, }; }); record('repeat true-empty canvas click is null-idempotent', !!emptyClickPoint && repeatEmptyClick.activeId === null && repeatEmptyClick.transitions === repeatEmptyClick.baselineTransitions + 1 && repeatEmptyClick.focusEvents === 0, `active=${repeatEmptyClick.activeId} transitions=${repeatEmptyClick.transitions - repeatEmptyClick.baselineTransitions} focus=${repeatEmptyClick.focusEvents}`); // Explicit navigation from null must remain available for the remaining // coverage and gizmo groups. NEXT is the product route pinned by N4. const resumedId = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.cycleCamera(1); return mod.getUIState().activeCameraId; }); record('explicit NEXT resumes from null at the first catalog camera', typeof resumedId === 'string' && !!resumedId, `active=${resumedId}`); // ========================================================================= // Group 6: viewshed mode (v3 design §3b — coverage tri-state + volumes) // ========================================================================= console.log('Checking viewshed mode (coverage tri-state + color-coded volumes)...'); const countVolumes = () => page.evaluate(() => { const prims = window.__godsEyeView.viewer.scene.primitives; let n = 0; for (let i = 0; i < prims.length; i++) { if (prims.get(i) && prims.get(i)._gevViewshed) n += 1; } return n; }); const cBeforeViewshed = await readCounters(); const modeAfterSet = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ coverageMode: 'viewshed' }); return mod.getUIState().coverageMode; }); await sleep(400); record('coverageMode setParams round-trip (viewshed)', modeAfterSet === 'viewshed', `coverageMode=${modeAfterSet}`); const volumesOn = await countVolumes(); record('viewshed volumes exist for the visible set (1..15)', volumesOn >= 1 && volumesOn <= 15, `${volumesOn} volume primitives (visible-set cap is 14 + active)`); // Boolean back-compat shim: showCoverage=false → 'off' (0 volumes), // showCoverage=true → 'on' (wireframes, still 0 volumes). const compat = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ showCoverage: false }); const off = mod.getUIState().coverageMode; mod.setParams({ showCoverage: true }); const on = mod.getUIState().coverageMode; return { off, on }; }); await sleep(200); const volumesInOnMode = await countVolumes(); record('boolean showCoverage compat (false→off, true→on)', compat.off === 'off' && compat.on === 'on', `false→${compat.off}, true→${compat.on}`); record("mode 'on' renders zero volumes (wireframes only)", volumesInOnMode === 0, `${volumesInOnMode} volumes in 'on' mode`); // Back to viewshed: volumes must be STABLE while idle (no churn — the // rebuild sites are pose edits and style refreshes only). Object identity // over 8s proves nothing recreated them. await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ coverageMode: 'viewshed' }); }); await sleep(400); await page.evaluate(() => { const prims = window.__godsEyeView.viewer.scene.primitives; window.__qaViewshedRefs = []; for (let i = 0; i < prims.length; i++) { if (prims.get(i) && prims.get(i)._gevViewshed) window.__qaViewshedRefs.push(prims.get(i)); } }); await sleep(8000); const viewshedIdle = await page.evaluate(() => { const prims = window.__godsEyeView.viewer.scene.primitives; const now = []; for (let i = 0; i < prims.length; i++) { if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i)); } const before = window.__qaViewshedRefs || []; const sameCount = now.length === before.length; const sameIdentity = sameCount && before.every((p) => now.includes(p)); return { sameCount, sameIdentity, count: now.length }; }); const cAfterViewshed = await readCounters(); const lateFloorSamples = cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight; record('viewshed volumes idle-stable over 8s (same primitives, no churn)', lateFloorSamples > 0 ? null : viewshedIdle.sameIdentity, lateFloorSamples > 0 ? `late one-shot floor completion sampled ${lateFloorSamples} cells and legitimately rebuilt geometry` : `count=${viewshedIdle.count} sameCount=${viewshedIdle.sameCount} sameIdentity=${viewshedIdle.sameIdentity}`); record('raycast counters flat across viewshed cycling + idle', lateFloorSamples > 0 ? null : cAfterViewshed.pickFromRay === cBeforeViewshed.pickFromRay, lateFloorSamples > 0 ? `late one-shot floor completion: ΔsampleHeight=${lateFloorSamples}; ΔpickFromRay=${cAfterViewshed.pickFromRay - cBeforeViewshed.pickFromRay}` : `ΔpickFromRay=${cAfterViewshed.pickFromRay - cBeforeViewshed.pickFromRay} ΔsampleHeight=0`); const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png'); await page.screenshot({ path: viewshedShot }); console.log(` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`); // ========================================================================= // Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag) // ========================================================================= console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...'); const GIZMO_PARTS = ['ring-heading', 'ring-pitch', 'move-east', 'move-north', 'move-up', 'handle-range', 'handle-fov-l', 'handle-fov-r']; const gizmoStates = await page.evaluate((parts) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibrationMode: true }); const viewer = window.__godsEyeView.viewer; return parts.map((p) => { const e = viewer.entities.getById('cctv-gizmo-' + p); return e ? (e.show ? 'shown' : 'hidden') : 'missing'; }); }, GIZMO_PARTS); record('all 8 gizmo parts exist and show in ADJUST mode', gizmoStates.every((s) => s === 'shown'), gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' ')); // Synthetic CDP drag on the EAST arrow — the owner's field-test // regression. Sample effective elevation after every mouse move: it must // stay frozen until release, with no transient sampleHeight calls. const eastDrag = await page.evaluate(() => { const viewer = window.__godsEyeView.viewer; const e = viewer.entities.getById('cctv-gizmo-move-east'); if (!e) return null; const pts = e.polyline.positions.getValue(viewer.clock.currentTime); const scene = viewer.scene; const w = scene.canvas.clientWidth; const h = scene.canvas.clientHeight; const a = scene.cartesianToCanvasCoordinates(pts[0]); const b = scene.cartesianToCanvasCoordinates(pts[pts.length - 1]); if (!a || !b || !Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return null; const dx = b.x - a.x; const dy = b.y - a.y; const mag = Math.hypot(dx, dy); if (mag < 10) return null; const candidates = [0.12, 0.2, 0.28, 0.36, 0.44, 0.52, 0.6, 0.68, 0.76, 0.84, 0.92] .map((t) => ({ x: a.x + dx * t, y: a.y + dy * t })) .filter((p) => p.x > 60 && p.y > 60 && p.x < w - 60 && p.y < h - 60); if (b.x > 60 && b.y > 60 && b.x < w - 60 && b.y < h - 60) { candidates.unshift({ x: b.x, y: b.y }); } if (!candidates.length) return null; return { ...candidates[0], candidates, ux: dx / mag, uy: dy / mag }; }); const calBeforeDrag = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().activeCamera; return cam ? { calibration: cam.calibration, elevationM: cam.elevationM, groundResolveCount: cam.groundResolveCount, groundMeshSampleRequestCount: cam.groundMeshSampleRequestCount, } : null; }); const transientDragStates = []; const countersBeforeDrag = await readCounters(); let countersBeforeRelease = countersBeforeDrag; if (eastDrag) { // Batched polylines can miss one synthetic LEFT_DOWN while their pick // buffer settles. Try several visible shaft points; stop after the first // actual offset change so a successful drag still has exactly one release. for (const grab of eastDrag.candidates) { const pickBufferReady = await positionPointerForGizmoDrag(page, grab); if (!pickBufferReady) continue; const ownsPick = await page.evaluate((point) => { const scene = window.__godsEyeView.viewer.scene; const partFrom = (picked) => { const id = picked?.id?.id ?? picked?.id; return typeof id === 'string' && id.startsWith('cctv-gizmo-') ? id.slice('cctv-gizmo-'.length) : null; }; const direct = partFrom(scene.pick(point, 14, 14)); if (direct) return direct === 'move-east'; const firstGizmo = (scene.drillPick(point, 6, 14, 14) || []) .map(partFrom) .find(Boolean); return firstGizmo === 'move-east'; }, grab); if (!ownsPick) continue; await page.mouse.down(); for (let i = 1; i <= 5; i++) { await page.mouse.move( grab.x + eastDrag.ux * i * 12, grab.y + eastDrag.uy * i * 12 ); await sleep(40); transientDragStates.push(await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().activeCamera; return cam ? { elevationM: cam.elevationM, eastOffsetM: cam.calibration?.offsetEastM, groundMeshSampleRequestCount: cam.groundMeshSampleRequestCount, } : null; })); } const candidateCountersBeforeRelease = await readCounters(); await page.mouse.up(); await sleep(400); const candidateEast = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; return mod.getUIState().activeCamera?.calibration?.offsetEastM ?? null; }); if (Number.isFinite(candidateEast) && Math.abs(candidateEast - (calBeforeDrag?.calibration?.offsetEastM ?? 0)) > 0.05) { countersBeforeRelease = candidateCountersBeforeRelease; break; } } } const dragOutcome = await page.evaluate((pt) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const viewer = window.__godsEyeView.viewer; const cam = mod.getUIState().activeCamera; const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); const map = raw ? JSON.parse(raw) : {}; let pickable = null; let ownsPick = null; if (pt) { try { const picked = viewer.scene.drillPick({ x: pt.x, y: pt.y }, 6, 14, 14) || []; pickable = picked.some((r) => String(r?.id?.id ?? r?.id ?? '').startsWith('cctv-gizmo-')); const firstGizmo = picked .map((r) => String(r?.id?.id ?? r?.id ?? '')) .find((id) => id.startsWith('cctv-gizmo-')); ownsPick = firstGizmo === 'cctv-gizmo-move-east'; } catch { pickable = null; } } return { eastOffsetM: cam?.calibration?.offsetEastM ?? null, groundResolveCount: cam?.groundResolveCount ?? null, calDirty: cam?.calDirty ?? null, stored: cam ? (cam.id in map) : null, inputsEnabled: viewer.scene.screenSpaceCameraController.enableInputs, pickable, ownsPick, }; }, eastDrag); const eastChanged = eastDrag && Number.isFinite(dragOutcome.eastOffsetM) && Math.abs(dragOutcome.eastOffsetM - (calBeforeDrag?.calibration?.offsetEastM ?? 0)) > 0.05; const transientElevations = transientDragStates.map((state) => state?.elevationM).filter(Number.isFinite); const maxTransientElevationDelta = transientElevations.length && Number.isFinite(calBeforeDrag?.elevationM) ? Math.max(...transientElevations.map((height) => Math.abs(height - calBeforeDrag.elevationM))) : Infinity; const transientSampleDelta = countersBeforeRelease.sampleHeight - countersBeforeDrag.sampleHeight; const activeMeshRequestsBeforeRelease = transientDragStates .map((state) => state?.groundMeshSampleRequestCount) .filter(Number.isFinite) .at(-1); const transientActiveMeshRequestDelta = Number.isFinite(activeMeshRequestsBeforeRelease) ? activeMeshRequestsBeforeRelease - (calBeforeDrag?.groundMeshSampleRequestCount ?? 0) : Infinity; if (eastChanged) { record('east-arrow drag changes the east offset (live, unsaved)', true, `offsetEastM ${calBeforeDrag?.calibration?.offsetEastM ?? 0} → ${dragOutcome.eastOffsetM}`); record('east-arrow drag keeps mount elevation frozen until mouse-up', maxTransientElevationDelta < 0.01, `max transient Δelevation=${maxTransientElevationDelta.toFixed(4)}m over ${transientElevations.length} moves`); record('east-arrow transient moves issue zero active-camera mesh-floor requests', transientActiveMeshRequestDelta === 0, `active-camera request Δ=${transientActiveMeshRequestDelta}; global sampleHeight Δ=${transientSampleDelta} (other cells may finish asynchronously)`); record('east-arrow release resolves the committed floor exactly once', dragOutcome.groundResolveCount === (calBeforeDrag?.groundResolveCount ?? 0) + 1, `ground resolutions ${calBeforeDrag?.groundResolveCount ?? 0} → ${dragOutcome.groundResolveCount}`); record('drag leaves the camera dirty and the store untouched', dragOutcome.calDirty === true && dragOutcome.stored === false, `calDirty=${dragOutcome.calDirty} stored=${dragOutcome.stored}`); } else if (eastDrag) { const missDetail = dragOutcome.pickable === false ? 'drill-pick cannot see a gizmo under this GL stack' : dragOutcome.ownsPick === false ? 'the east arrow did not own any tested shaft pick' : 'LEFT_DOWN did not begin an east-arrow drag despite the ownership precondition'; record('east-arrow drag changes the east offset (live, unsaved)', null, `${missDetail} — verify on a current headful GPU surface`); record('east-arrow drag keeps mount elevation frozen until mouse-up', null, 'skipped with drag'); record('east-arrow transient moves issue zero active-camera mesh-floor requests', null, 'skipped with drag'); record('east-arrow release resolves the committed floor exactly once', null, 'skipped with drag'); record('drag leaves the camera dirty and the store untouched', null, 'skipped with drag'); } else { const unavailable = 'no on-screen east-arrow shaft found under this GL stack — verify on real GPU'; record('east-arrow drag changes the east offset (live, unsaved)', null, unavailable); record('east-arrow drag keeps mount elevation frozen until mouse-up', null, 'skipped: drag target unavailable'); record('east-arrow transient moves issue zero active-camera mesh-floor requests', null, 'skipped: drag target unavailable'); record('east-arrow release resolves the committed floor exactly once', null, 'skipped: drag target unavailable'); record('drag leaves the camera dirty and the store untouched', null, 'skipped: drag target unavailable'); } record('camera-controller inputs re-enabled after drag', dragOutcome.inputsEnabled === true, `enableInputs=${dragOutcome.inputsEnabled}`); // ADJUST off: parts hidden; leave a clean calibration for the next group. const gizmoOff = await page.evaluate((parts) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibrationMode: false }); mod.setParams({ calibration: { reset: true } }); const viewer = window.__godsEyeView.viewer; return parts.map((p) => { const e = viewer.entities.getById('cctv-gizmo-' + p); return e ? (e.show ? 'shown' : 'hidden') : 'missing'; }); }, GIZMO_PARTS); record('gizmo parts hide when ADJUST mode turns off', gizmoOff.every((s) => s === 'hidden'), gizmoOff.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' ')); // ----------------------------------------------------------------------- // Screenshots for human visual review (NOT a pass/fail signal — SwiftShader // cannot fill plane image materials under headless software GL). // ----------------------------------------------------------------------- await sleep(1000); const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); await page.screenshot({ path: shot }); console.log(` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`); // No console errors during the whole exercise. record('no console errors during CCTV v2 exercise', consoleErrors.length === 0, consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean'); for (const r of results) { if (r.ok === false) exitCode = 1; } } finally { await browser.close(); } console.log('\n' + '─'.repeat(60)); const pass = results.filter((r) => r.ok === true).length; const fail = results.filter((r) => r.ok === false).length; const inconclusive = results.filter((r) => r.ok === null).length; console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`); console.log('─'.repeat(60) + '\n'); process.exit(exitCode); } main().catch((e) => { console.error('\x1b[31mHarness error:\x1b[0m', e); process.exit(3); });