gods-eye-view/scripts/qa-heading-b3.mjs

1116 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* qa-heading-b3.mjs — throwaway QA harness for skylight Task 4
* (path-derived, rate-limited display course; Batch 3 overnight run).
*
* The permanent track-regression harness flies planes STRAIGHT, so it cannot
* exercise the new heading behavior. This script injects synthetic TURNING
* aircraft — the fetch shim computes each plane's position ANALYTICALLY on a
* constant-rate-turn circle from Date.now() at serve time, so every poll
* (this script's driver or the layers' own setInterval pollers) serves a fix
* whose position, track, and timestamp are mutually consistent.
*
* What it checks, on the REAL app (same puppeteer flags as
* scripts/track-regression.mjs):
*
* LOGIC — track a turning plane in 3D, sample the DISPLAYED course every
* frame for ~40 s. Sampling happens in scene.preRender, which fires AFTER
* the layer's _fleetTick/_updateTrackedModel listener (Cesium events fire in
* add order and the app registered first), i.e. at the exact wall-clock the
* rate limiter advanced — postRender sampling skews apparent rates by the
* variable render-pass duration (~40% at SwiftShader's ~3 fps). The course
* is derived from the tracked standalone Cesium.Model's modelMatrix (the
* actually-rendered transform), inverting _modelMatrix: with pitch=roll=0
* the matrix is ENU·Rz(h), so local x in ENU = (cos h, sin h, 0) →
* h = atan2(x·north, x·east), course = norm360(deg(h) MODEL_HEADING_OFFSET).
* The 15 s fix cadence × 3°/s turn = 45° course step per interpolation
* segment, so the 60°/s-limited glide lasts 0.75 s — multiple frames even
* under SwiftShader.
* Assertions:
* A1 rate-limit : per-frame |Δcourse|/Δt ≤ COURSE_MAX_DPS (60°/s) + 15% tol
* (pairs with |Δ| ≤ 1.5° skipped — noise-dominated rates)
* A2 no snap : no single frame carries the whole ~45° segment step:
* fail if |Δ| ≥ 33.75° (0.75×step) in a frame of ≤ 500 ms
* (the limiter needs ≥ 562 ms to emit that legitimately)
* A3 spread : the slew is spread across ≥4 frames (>0.5°) with ≥2
* consecutive — the old snap was ONE spike per poll
* A4 turning : total unwrapped course change ≥ +40° over the window
* (the plane really turns 3°/s ≈ +120°/40 s)
* A5 alignment : sampled course agrees with the analytic arc tangent at
* the DISPLAYED (render-delayed) time within 35° — catches
* sign/offset inversion in the whole chain (chord lags the
* tangent by ≤ half a segment ≈ 22.5°)
* Both layers: flights (airplane.glb, offset 180°) then military (jet.glb,
* normalized nose -X, offset 180°).
*
* VISUAL — with the turning flights plane tracked in 3D, capture screenshots
* (two zoom levels × two orbit angles) into qa-shots/b3/ for human review:
* the nose must lead the curved trail, never sideways/backwards.
*
* LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra
* flights-layer scenarios that the fast turning planes cannot catch:
*
* Phase 3 — HOVERING HELICOPTER (klass=helicopter via OpenSky category 8):
* position drifts a few metres (smooth pseudo-random GPS walk), reported
* track flips ±45° around 90° (the velocity-vector noise a hovering
* transponder actually emits), velocity 1 m/s. Assertions:
* H1 coverage : >=12 sampled frames spanning >=30 s
* H2 stability : total |Δcourse| <= 25° over the ~35 s window (pre-fix the
* nose chased the flipping reported track at 60°/s: ~90°
* per poll)
* H3 no spin : max unwrapped excursion from the initial course <= 60°
*
* Phase 4 — SLOW PLANE, TIGHT CONTINUOUS TURN (25 kt, 4°/s right, R=184 m):
* fixes every ~15 s → the chord course used to STEP 60° at each segment
* boundary (a 1 s whip at the 60°/s cap, then ~14 s frozen). Assertions:
* S1 smooth : max per-frame course rate (pairs with |Δ|>1.5°) <= 20°/s
* — the real turn is 4°/s; pre-fix bursts hit the 60°/s cap
* S2 monotonic : course tracks the turn direction — total counter-turn
* movement (sum of negative deltas) >=
* S3 turning : total course change >= 0.5 × (4°/s × window)
* Screenshots for both into qa-shots/heading-v2/ (heli nose stable between
* two shots 8 s apart; slow plane nose tangent to its curved trail).
*
* Phase 5 — TRACKED↔FLEET COURSE HANDOFF (2026-07-03 field report: tracking
* a Bell 429 at 65 kt FLIPPED the nose on click/click-away — the fleet pass
* and the tracked path kept SEPARATE smoothed-course states, so the fleet's
* per-icao entry froze while tracked and snapped on release). A 65 kt
* helicopter (category 8, klass=helicopter) orbits at 2°/s; the RENDERED
* orientation is sampled continuously across an untrack and a re-track:
* C1 (click-away, 3D OFF): the SCREEN rotation actually drawn — the
* tracked entity's rotation callback while tracked, the fleet
* billboard's rotation after — with the camera static across the
* release-in-place, so rotation deltas are course deltas. This is the
* exact pixel the owner saw flip; it also catches the stale-rotation
* restore (the fleet billboard reappearing with its frozen pre-track
* rotation for up to a rotation-refresh).
* C2 (click, 3D ON): world course from the model matrix (fleet model →
* tracked standalone model, both id=icao) — camera-independent, so the
* re-track camera flight can't contaminate it.
* Assertions (each direction): near the click boundary, no consecutive-
* sample step exceeds the real turn by > 15°, and no near-boundary rate
* exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen
* divergence at the 60°/s slew cap).
*
* Run: node scripts/qa-heading-b3.mjs --url http://localhost:4300
* Exits non-zero if any assertion fails. Never commits anything.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
// ---------------------------------------------------------------------------
// Args (same shape as track-regression.mjs)
// ---------------------------------------------------------------------------
const argv = process.argv.slice(2);
const getFlag = (name) => argv.includes(name);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/b3');
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-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 { /* skip */ }
}
return null;
}
// ---------------------------------------------------------------------------
// PASS/FAIL reporting
// ---------------------------------------------------------------------------
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? `${detail}` : ''}`);
}
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
const viewer = window.__godsEyeView?.viewer;
const scene = viewer?.scene;
const history = [];
const startedAt = performance.now();
let removePostRender = null;
let requestTimer = null;
let timeoutTimer = null;
let finished = false;
const finish = (observed) => {
if (finished) return;
finished = true;
removePostRender?.();
clearInterval(requestTimer);
clearTimeout(timeoutTimer);
resolve({ observed, history, sample: history.at(-1) || null });
};
const sample = () => {
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.() || {};
const tracked = viewer?.trackedEntity;
const current = {
elapsedMs: Math.round(performance.now() - startedAt),
entryId: tracked?.gevTrackedId || null,
hasPresentationModel: Boolean(tracked?.gevLabelModel?.title),
candidateCount: diagnostics.candidateCount || 0,
projectedCount: diagnostics.projectedCount || 0,
selectedCount: diagnostics.selectedCount || 0,
entryCount: diagnostics.entriesBySource?.tracked || 0,
painted: diagnostics.paintedBySource?.tracked || 0,
paintedCount: diagnostics.paintedCount || 0,
};
history.push(current);
if (history.length > 80) history.shift();
if (current.entryCount === 1 && current.painted >= 1) finish(true);
};
if (!scene?.postRender?.addEventListener) {
finish(false);
return;
}
// The overlay registered first during bootstrap, so this later listener
// sees projection and paint from the same frame before diagnostics reset.
removePostRender = scene.postRender.addEventListener(sample);
requestTimer = setInterval(() => scene.requestRender(), 100);
timeoutTimer = setTimeout(() => finish(false), boundedTimeoutMs);
scene.requestRender();
}), timeoutMs);
}
const norm360 = (d) => ((d % 360) + 360) % 360;
const norm180 = (d) => { const n = norm360(d); return n > 180 ? n - 360 : n; };
// ---------------------------------------------------------------------------
// Synthetic TURNING aircraft — constant-rate-turn circles near Austin.
// alphaDeg0 = position angle on the circle at shim-install epoch; the plane
// sits at center + R·(sin α, cos α) and flies CLOCKWISE (right turn):
// course(t) = α(t) + 90, turn rate = turnDps (course °/s), speed = R·ω.
// 3°/s × 15 s driver cadence → ~45° course step per interpolation segment.
// ---------------------------------------------------------------------------
const TURN = {
timeOffsetSec: 0, // shim knob: serve fixes as of (now + offset) — used to back-date priming fixes
flights: [
{ icao: 'aaa001', callsign: 'TRN001', cLon: -97.7431, cLat: 30.2672, radiusM: 2673, alphaDeg0: 0, turnDps: 3, altM: 3000 },
{ icao: 'aaa002', callsign: 'TRN002', cLon: -97.7800, cLat: 30.2900, radiusM: 2673, alphaDeg0: 120, turnDps: 3, altM: 3400 },
{ icao: 'aaa003', callsign: 'TRN003', cLon: -97.7100, cLat: 30.2400, radiusM: 2673, alphaDeg0: 240, turnDps: 3, altM: 2800 },
],
military: [
{ hex: 'bbb201', flight: 'TRNMIL1', cLon: -97.7550, cLat: 30.2750, radiusM: 2947, alphaDeg0: 45, turnDps: 3, altFt: 12000, t: 'F16', r: 'AF-201' },
{ hex: 'bbb202', flight: 'TRNMIL2', cLon: -97.7250, cLat: 30.2500, radiusM: 2947, alphaDeg0: 200, turnDps: 3, altFt: 14000, t: 'F18', r: 'AF-202' },
],
// Low-speed scenarios (heading-v2). The heli HOVERS: position = smooth
// pseudo-random GPS drift (driftAmpM metres per sinusoid pair → fix-to-fix
// chords well under the 25 m gate), reported track flips ±45° around
// baseTrack every ~trackFlipSec/2 (hover velocity-vector noise), category 8
// classifies it klass=helicopter at ingest. The slow plane reuses the circle
// math: R=184.2 m at 4°/s ⇒ ground speed R·ω = 12.86 m/s ≈ 25 kt; category 2
// (light). Both served by the flights (OpenSky) shim branch.
hover: { icao: 'aaa010', callsign: 'HOVER1', cLon: -97.7431, cLat: 30.2672, altM: 450, driftAmpM: 4, baseTrack: 90, trackFlipSec: 29, category: 8 },
slow: { icao: 'aaa011', callsign: 'SLOW25', cLon: -97.7000, cLat: 30.2300, radiusM: 184.2, alphaDeg0: 0, turnDps: 4, altM: 900, category: 2 },
// Phase 5 (course-handoff consistency): 65 kt helicopter in a wide orbit —
// R=958 m at 2°/s ⇒ ground speed R·ω = 33.4 m/s ≈ 65 kt (the owner's Bell
// 429 case). Category 8 classifies it klass=helicopter, so its display
// course is the reported per-fix track (chords are ignored for rotorcraft).
// `active` gates it INTO the feed only when phase 5 starts, so phases 14
// run against exactly the fleet they were calibrated on.
heli65: { icao: 'aaa012', callsign: 'HELI65', cLon: -97.7750, cLat: 30.2150, radiusM: 958, alphaDeg0: 0, turnDps: 2, altM: 600, category: 8, active: false },
};
/** Analytic ground truth (Node side too, for A5): plane state at epoch-relative tSec. */
function arcState(p, tSec) {
const alpha = p.alphaDeg0 + p.turnDps * tSec;
const aRad = (alpha * Math.PI) / 180;
const east = p.radiusM * Math.sin(aRad);
const north = p.radiusM * Math.cos(aRad);
const lat = p.cLat + north / 111320;
const lon = p.cLon + east / (111320 * Math.cos((p.cLat * Math.PI) / 180));
const course = norm360(alpha + 90); // clockwise tangent
const speedMps = p.radiusM * ((p.turnDps * Math.PI) / 180);
return { lon, lat, course, speedMps };
}
// ---------------------------------------------------------------------------
// Node-side analysis of a sampled {tMs, course} series
// ---------------------------------------------------------------------------
function analyze(label, samples, plan) {
const { capDps = 60, tolFactor = 1.15, snapCeilDeg = 33.75, snapDtCeilMs = 500, minTotalDeg = 40 } = plan;
const valid = samples.filter((s) => s && Number.isFinite(s.course));
const windowSec = valid.length ? (valid[valid.length - 1].tMs - valid[0].tMs) / 1000 : 0;
console.log(`\n ${label}: ${valid.length}/${samples.length} valid frames over ${windowSec.toFixed(1)} s`);
if (valid.length < 12 || windowSec < 30) {
record(`${label}: >=12 sampled frames spanning >=30 s`, false, `${valid.length} frames over ${windowSec.toFixed(1)} s`);
return;
}
record(`${label}: >=12 sampled frames spanning >=30 s`, true, `${valid.length} frames over ${windowSec.toFixed(1)} s`);
const deltas = []; // {d, dtMs, rate, tMs}
let total = 0;
for (let i = 1; i < valid.length; i++) {
const dtMs = valid[i].tMs - valid[i - 1].tMs;
if (dtMs < 4 || dtMs > 10000) continue; // keep slow SwiftShader frames; skip only genuine stalls
const d = norm180(valid[i].course - valid[i - 1].course);
total += d;
deltas.push({ d, dtMs, rate: Math.abs(d) / (dtMs / 1000), tMs: valid[i].tMs });
}
const dts = deltas.map((x) => x.dtMs).sort((a, b) => a - b);
const dtMed = dts[Math.floor(dts.length / 2)] || 0;
// Rate check only on pairs with meaningful motion — sub-1.5° deltas divided
// by small dts are numerically noise, not slew.
const moving = deltas.filter((x) => Math.abs(x.d) > 1.5);
const maxRate = moving.length ? Math.max(...moving.map((x) => x.rate)) : 0;
const maxAbsD = Math.max(...deltas.map((x) => Math.abs(x.d)));
const snapFrames = deltas.filter((x) => Math.abs(x.d) >= snapCeilDeg && x.dtMs <= snapDtCeilMs);
const active = deltas.filter((x) => Math.abs(x.d) > 0.5);
let maxRun = 0, run = 0;
for (const x of deltas) { run = Math.abs(x.d) > 0.5 ? run + 1 : 0; if (run > maxRun) maxRun = run; }
const top = [...deltas].sort((a, b) => Math.abs(b.d) - Math.abs(a.d)).slice(0, 5);
const t0 = valid[0].tMs;
console.log(` frame dt median=${dtMed.toFixed(0)} ms | max rate (|Δ|>1.5°)=${maxRate.toFixed(1)} °/s | max |Δ|=${maxAbsD.toFixed(2)}°`);
console.log(` active frames (|Δ|>0.5°): ${active.length} (longest consecutive run ${maxRun}) | total signed change=${total.toFixed(1)}°`);
console.log(` top deltas: ${top.map((x) => `${x.d.toFixed(1)}°/${x.dtMs.toFixed(0)}ms@t+${((x.tMs - t0) / 1000).toFixed(1)}s`).join(' ')}`);
record(`${label} A1: per-frame course rate <= ${capDps}°/s cap (+${Math.round((tolFactor - 1) * 100)}%)`,
maxRate <= capDps * tolFactor,
`max ${maxRate.toFixed(1)} °/s vs ${(capDps * tolFactor).toFixed(0)} °/s (over ${moving.length} moving pairs)`);
record(`${label} A2: no once-per-poll snap (no |Δ| >= ${snapCeilDeg}° in a <= ${snapDtCeilMs} ms frame; full step ~45°)`,
snapFrames.length === 0,
snapFrames.length ? `snap frames: ${snapFrames.map((x) => `${x.d.toFixed(1)}°/${x.dtMs.toFixed(0)}ms`).join(' ')}` : `max |Δ| ${maxAbsD.toFixed(2)}°`);
record(`${label} A3: slew spread over frames (>=4 active, >=2 consecutive)`,
active.length >= 4 && maxRun >= 2,
`${active.length} active, run ${maxRun}`);
record(`${label} A4: really turning (total change >= +${minTotalDeg}°)`,
total >= minTotalDeg,
`total ${total.toFixed(1)}° over ${((valid[valid.length - 1].tMs - t0) / 1000).toFixed(1)} s`);
}
/** Per-frame deltas of a sampled {tMs, course} series (shared by the
* low-speed analyzers): skips invalid frames and stalled/double-fired pairs. */
function courseDeltas(samples) {
const valid = samples.filter((s) => s && Number.isFinite(s.course));
const deltas = [];
for (let i = 1; i < valid.length; i++) {
const dtMs = valid[i].tMs - valid[i - 1].tMs;
if (dtMs < 4 || dtMs > 10000) continue;
deltas.push({ d: norm180(valid[i].course - valid[i - 1].course), dtMs });
}
return { valid, deltas };
}
/** Phase 3 (hovering helicopter): the displayed course must HOLD — chord and
* reported track are both noise at hover, so any movement is chasing noise. */
function analyzeHover(label, samples, { maxTotalAbsDeg = 25, maxExcursionDeg = 60 } = {}) {
const { valid, deltas } = courseDeltas(samples);
const windowSec = valid.length ? (valid[valid.length - 1].tMs - valid[0].tMs) / 1000 : 0;
console.log(`\n ${label}: ${valid.length}/${samples.length} valid frames over ${windowSec.toFixed(1)} s`);
const covered = valid.length >= 12 && windowSec >= 30;
record(`${label} H1: >=12 sampled frames spanning >=30 s`, covered, `${valid.length} frames over ${windowSec.toFixed(1)} s`);
if (!covered) return;
let totalAbs = 0, cum = 0, maxExcursion = 0;
for (const x of deltas) {
totalAbs += Math.abs(x.d);
cum += x.d;
maxExcursion = Math.max(maxExcursion, Math.abs(cum));
}
console.log(` total |Δcourse|=${totalAbs.toFixed(1)}° | max excursion from start=${maxExcursion.toFixed(1)}° | start=${valid[0].course.toFixed(0)}° end=${valid[valid.length - 1].course.toFixed(0)}°`);
record(`${label} H2: displayed course stays put (total |Δ| <= ${maxTotalAbsDeg}° over ${windowSec.toFixed(0)} s)`,
totalAbs <= maxTotalAbsDeg, `total |Δ| ${totalAbs.toFixed(1)}°`);
record(`${label} H3: no spins (max excursion from initial course <= ${maxExcursionDeg}°)`,
maxExcursion <= maxExcursionDeg, `max excursion ${maxExcursion.toFixed(1)}°`);
}
/** Phase 4 (25 kt tight turn): the course must ADVANCE like the real 4°/s turn —
* smooth (no 60°/s boundary whips), monotonic (no counter-turn reversals). */
function analyzeSlowTurn(label, samples, {
maxRateDps = 20,
maxReversalDeg = 8,
expectDps = 4,
minSamples = 12,
minWindowSec = 30,
minProgressFraction = 0.4,
scoreProgress = true,
} = {}) {
const { valid, deltas } = courseDeltas(samples);
const windowSec = valid.length ? (valid[valid.length - 1].tMs - valid[0].tMs) / 1000 : 0;
console.log(`\n ${label}: ${valid.length}/${samples.length} valid frames over ${windowSec.toFixed(1)} s`);
const covered = valid.length >= minSamples && windowSec >= minWindowSec;
record(`${label} S0: >=${minSamples} frames spanning >=${minWindowSec} s`, covered, `${valid.length} frames over ${windowSec.toFixed(1)} s`);
if (!covered) return;
const moving = deltas.filter((x) => Math.abs(x.d) > 1.5);
const maxRate = moving.length ? Math.max(...moving.map((x) => Math.abs(x.d) / (x.dtMs / 1000))) : 0;
let total = 0, reversal = 0;
for (const x of deltas) { total += x.d; if (x.d < 0) reversal += x.d; }
const top = [...deltas].sort((a, b) => Math.abs(b.d) - Math.abs(a.d)).slice(0, 4);
console.log(` max rate (|Δ|>1.5°)=${maxRate.toFixed(1)} °/s | total=${total.toFixed(1)}° | counter-turn sum=${reversal.toFixed(1)}°`);
console.log(` top deltas: ${top.map((x) => `${x.d.toFixed(1)}°/${x.dtMs.toFixed(0)}ms`).join(' ')}`);
record(`${label} S1: smooth slow turn (max per-frame rate <= ${maxRateDps}°/s; real turn ${expectDps}°/s)`,
maxRate <= maxRateDps, `max ${maxRate.toFixed(1)} °/s (over ${moving.length} moving pairs)`);
// The default 100 ms request cadence can still produce multi-second render
// gaps under SwiftShader. Use it for load/coverage/rate, but score direction
// and progress only in the dedicated 600 ms cadence pass below, which
// demonstrated stable consecutive samples on this backend.
if (!scoreProgress) return;
record(`${label} S2: monotonic in the turn direction (counter-turn sum >= -${maxReversalDeg}°)`,
reversal >= -maxReversalDeg, `counter-turn ${reversal.toFixed(1)}°`);
// The window can begin halfway through the 15 s fix interval, before the
// next segment-boundary slew starts. Require substantial forward progress
// without assuming a favorable phase alignment.
const minTotal = minProgressFraction * expectDps * windowSec;
record(`${label} S3: really turning (total >= ${minTotal.toFixed(0)}°)`,
total >= minTotal, `total ${total.toFixed(1)}° over ${windowSec.toFixed(1)} s`);
}
/** Phase 5 (tracked↔fleet handoff): the sampled RENDERED course must stay
* continuous across a click/click-away, modulo the real 2°/s turn. Two
* signals, both scored only NEAR the action (1 s .. +postWindowMs):
* - step excess: |Δcourse| of a consecutive-sample pair minus the truth
* advance truthDps·dt — a snapped handoff carries the whole frozen-state
* divergence in one pair;
* - course rate: the pre-fix snap-back replayed the divergence at the
* 60°/s slew cap, so ANY two near-boundary samples catch it even when the
* fleet model appears a few frames late. */
function analyzeHandoff(label, { samples, actionTMs }, {
truthDps = 2, stepExcessTolDeg = 12, rateTolDps = 12, postWindowMs = 4000,
minSamples = 30, pairDtCeilMs = 3000, rateDtFloorMs = 0,
} = {}) {
const valid = samples.filter((s) => s && Number.isFinite(s.course));
const t0 = valid.length ? valid[0].tMs : 0;
console.log(`\n ${label}: ${valid.length}/${samples.length} valid frames; action at t+${Number.isFinite(actionTMs) ? ((actionTMs - t0) / 1000).toFixed(1) : '?'} s`);
if (valid.length < minSamples || !Number.isFinite(actionTMs)) {
record(`${label}: enough samples around the transition`, false,
`valid=${valid.length} (need ${minSamples}) actionTMs=${actionTMs}`);
return;
}
let maxExcess = 0;
let maxExcessAtMs = 0;
let maxRate = 0;
let boundaryPairs = 0;
for (let i = 1; i < valid.length; i++) {
const dtMs = valid[i].tMs - valid[i - 1].tMs;
if (dtMs < 4 || dtMs > pairDtCeilMs) continue; // stalled / model-swap-gap outliers
// (Step excess is truth-compensated, so long low-fps pairs stay valid —
// SwiftShader near ground-level 3D tiles can drop under 1 fps.)
const nearAction = valid[i].tMs >= actionTMs - 1000 && valid[i - 1].tMs <= actionTMs + postWindowMs;
if (!nearAction) continue;
boundaryPairs += 1;
const d = Math.abs(norm180(valid[i].course - valid[i - 1].course));
const excess = d - truthDps * (dtMs / 1000);
if (excess > maxExcess) { maxExcess = excess; maxExcessAtMs = valid[i].tMs - actionTMs; }
// Rate metric: dt floored at the display's own refresh quantum — the fleet
// billboard's rotation advances in ROTATION_REFRESH_MS batches, so a
// short-dt sample pair spanning one refresh reads a full second of course
// advance over a fraction of a second. The pre-fix snap (~120° in one
// refresh) still reads >100°/s through the floor.
if (d > 1.5) maxRate = Math.max(maxRate, d / (Math.max(dtMs, rateDtFloorMs) / 1000)); // sub-1.5° deltas are noise
}
console.log(` boundary pairs=${boundaryPairs} | max step excess=${maxExcess.toFixed(1)}° @ action+${(maxExcessAtMs / 1000).toFixed(2)} s | max rate=${maxRate.toFixed(1)} °/s`);
record(`${label}: no course flip across the handoff (step excess <= ${stepExcessTolDeg}°, rate <= ${rateTolDps}°/s near the boundary)`,
boundaryPairs > 0 && maxExcess <= stepExcessTolDeg && maxRate <= rateTolDps,
`maxExcess=${maxExcess.toFixed(1)}° maxRate=${maxRate.toFixed(1)}°/s over ${boundaryPairs} pairs (truth ${truthDps}°/s)`);
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log(`\nTurning-plane heading QA (Batch 3 / skylight Task 4)`);
console.log(` App URL : ${APP_URL}\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(SHOT_DIR, { recursive: true });
fs.mkdirSync(SHOT_DIR_V2, { 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 = [];
const failedResponses = [];
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => {
if (msg.type() === 'error') {
const text = msg.text();
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
}
});
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
page.on('response', (response) => {
if (response.status() >= 500) failedResponses.push(`${response.status()} ${response.url()}`);
});
// ---- Turning-plane fetch shim (installed before any app code runs) ------
await page.evaluateOnNewDocument((turn) => {
window.__TURN = turn;
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
window.__TURN_HITS = { opensky: 0, mil: 0 };
const norm360 = (d) => ((d % 360) + 360) % 360;
// Same arc math as the Node side (keep in sync with arcState()).
window.__TURN.stateAt = (p, tSec) => {
const alpha = p.alphaDeg0 + p.turnDps * tSec;
const aRad = (alpha * Math.PI) / 180;
const east = p.radiusM * Math.sin(aRad);
const north = p.radiusM * Math.cos(aRad);
const lat = p.cLat + north / 111320;
const lon = p.cLon + east / (111320 * Math.cos((p.cLat * Math.PI) / 180));
return {
lon, lat,
course: norm360(alpha + 90),
speedMps: p.radiusM * ((p.turnDps * Math.PI) / 180),
};
};
// Hovering-heli truth (keep in sync with hoverState() on the Node side):
// smooth bounded GPS drift + square-wave reported-track flips.
window.__TURN.hoverAt = (p, tSec) => {
const east = p.driftAmpM * (Math.sin(2 * Math.PI * tSec / 41) + 0.6 * Math.sin(2 * Math.PI * tSec / 13.7));
const north = p.driftAmpM * (Math.sin(2 * Math.PI * tSec / 53 + 1.3) + 0.6 * Math.sin(2 * Math.PI * tSec / 17.3 + 0.7));
const lat = p.cLat + north / 111320;
const lon = p.cLon + east / (111320 * Math.cos((p.cLat * Math.PI) / 180));
const flip = Math.sin(2 * Math.PI * tSec / p.trackFlipSec) >= 0 ? 1 : -1;
return { lon, lat, course: norm360(p.baseTrack + flip * 45), speedMps: 1.0 };
};
const realFetch = window.fetch.bind(window);
const jsonResponse = (obj) => new Response(JSON.stringify(obj), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : (input && input.url) || '';
const T = window.__TURN;
if (url.includes('/api/openai/hud-summary')) {
return Promise.resolve(jsonResponse({ summary: 'Turning-flight QA' }));
}
// Serve-time truth: position/track/timestamp all as of (now + offset).
const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0);
const tRel = nowSec - T.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/opensky')) {
T.__hitsGuard = ++window.__TURN_HITS.opensky;
const row = (id, callsign, s, altM, category) => [
id, callsign, 'Synthetica',
Math.floor(nowSec), // 3 time_position — the fix epoch the layer stamps history with
Math.floor(nowSec), // 4 last_contact
s.lon, s.lat, altM, // 5,6,7
false, // 8 on_ground
s.speedMps, // 9 velocity (m/s)
s.course, // 10 true_track (deg) — honest arc tangent (hover: flipping GPS-vector noise)
0, null, null, null, false, 0,
category ?? null, // 17 extended=1 emitter category (8 = rotorcraft → klass helicopter)
];
const states = T.flights.map((f) => row(f.icao, f.callsign, T.stateAt(f, tRel), f.altM, null));
states.push(row(T.hover.icao, T.hover.callsign, T.hoverAt(T.hover, tRel), T.hover.altM, T.hover.category));
states.push(row(T.slow.icao, T.slow.callsign, T.stateAt(T.slow, tRel), T.slow.altM, T.slow.category));
if (T.heli65.active) states.push(row(T.heli65.icao, T.heli65.callsign, T.stateAt(T.heli65, tRel), T.heli65.altM, T.heli65.category));
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
if (url.includes('/api/adsblol/mil')) {
window.__TURN_HITS.mil++;
const ac = T.military.map((m) => {
const s = T.stateAt(m, tRel);
return {
hex: m.hex, flight: m.flight,
lon: s.lon, lat: s.lat, alt_baro: m.altFt,
track: s.course, gs: s.speedMps * 1.9438,
t: m.t, r: m.r, ownOp: 'SYNTH AF',
seen_pos: Math.max(0, -(T.timeOffsetSec || 0)), // age → fixTime = now + offset
};
});
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac }));
}
// Classification is supplied by the synthetic OpenSky category, so the
// best-effort ADSBDB enrichment has no bearing on this scenario. Stub
// it rather than letting one unrelated public-provider 502 turn a
// renderer/heading result into a console-cleanliness false negative.
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
return realFetch(input, init);
};
}, TURN);
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, polling: 200 }
);
console.log(' App globals ready.');
// ---- Model finder + course derivation, installed once ------------------
await page.evaluate(() => {
// Every sampler in this harness targets a KNOWN aircraft, and both the
// fleet models and the tracked standalone model carry id=icao — so the
// finder selects by id when given one. (The old nearest-to-tracked
// fallback could mis-pick ANOTHER plane's model for a frame whenever
// the tracked display position read null, which injected a one-frame
// ~90° course spike into the sampled series — a measurement flake, not
// a product regression.)
window.__findTrackedModel = function (icao) {
const v = window.__godsEyeView.viewer;
const out = [];
const walk = (coll) => {
const n = coll.length;
for (let i = 0; i < n; i++) {
const p = coll.get(i);
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.modelMatrix && typeof p.ready !== 'undefined') {
if (icao && p.id !== icao) continue;
out.push(p);
}
}
};
walk(v.scene.primitives);
const shown = out.filter((m) => m.show && m.ready);
if (icao) return shown[0] || null; // deterministic: the plane's own model or nothing
const pool = shown.length ? shown : out;
if (!pool.length) return null;
const ent = v.trackedEntity;
let tracked = null;
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
if (!tracked) return pool[0];
let best = null, bestD = Infinity;
for (const m of pool) {
const mm = m.modelMatrix;
const dx = mm[12] - tracked.x, dy = mm[13] - tracked.y, dz = mm[14] - tracked.z;
const d = dx * dx + dy * dy + dz * dz;
if (d < bestD) { bestD = d; best = m; }
}
return best;
};
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, sin h, 0)
// → h = atan2(x·north, x·east); world course = h headingOffsetDeg.
window.__courseFromModelMatrix = function (mm, headingOffsetDeg) {
const v = window.__godsEyeView.viewer;
const C3 = v.camera.position.constructor;
const Carto = v.camera.positionCartographic.constructor;
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
if (!carto) return null;
const sLat = Math.sin(carto.latitude), cLat = Math.cos(carto.latitude);
const sLon = Math.sin(carto.longitude), cLon = Math.cos(carto.longitude);
const ex = -sLon, ey = cLon, ez = 0;
const nx = -sLat * cLon, ny = -sLat * sLon, nz = cLat;
const xe = mm[0] * ex + mm[1] * ey + mm[2] * ez;
const xn = mm[0] * nx + mm[1] * ny + mm[2] * nz;
const hDeg = (Math.atan2(-xn, xe) * 180) / Math.PI;
return (((hDeg - headingOffsetDeg) % 360) + 360) % 360;
};
// Google Photorealistic Tiles can starve SwiftShader's ambient render
// loop below one frame per second. Drive a bounded, fixed-cadence Viewer
// render loop while sampling instead: it still invokes the production
// preUpdate/preRender listeners and reads the rendered model matrix, but
// does not depend on ambient render-loop scheduling for sample count.
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
const v = window.__godsEyeView.viewer;
const tileset = window.__godsEyeView.tileset;
const out = [];
const start = performance.now();
const priorDefaultLoop = v.useDefaultRenderLoop;
const priorTilesetShow = tileset?.show;
v.useDefaultRenderLoop = false;
// This harness measures the production model-matrix slew, not tile LOD.
// Temporarily removing Google 3D Tiles from scene traversal keeps each
// explicit render below the sampling cadence under SwiftShader. Restore
// it before screenshots so the visual evidence remains photoreal.
if (tileset) tileset.show = false;
try {
await new Promise((resolve) => {
const remove = v.scene.preRender.addEventListener(() => {
const m = window.__findTrackedModel(icao);
const c = m ? window.__courseFromModelMatrix(m.modelMatrix, headingOffsetDeg) : null;
out.push({ tMs: performance.now(), course: c, epochMs: Date.now() });
});
const step = () => {
if (performance.now() - start >= windowMs) {
remove();
resolve();
return;
}
v.render();
setTimeout(step, stepMs);
};
step();
});
} finally {
if (tileset) tileset.show = priorTilesetShow;
v.useDefaultRenderLoop = priorDefaultLoop;
v.scene.requestRender();
}
return out;
};
});
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------
// First update() fires inside setEnabled, so the FIRST fix must already be
// the oldest (history appends only monotonically-newer fix times).
console.log('Priming turning history through the render delay (30 s / 15 s)...');
const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer;
window.__TURN.timeOffsetSec = -32;
await dm.setEnabled('flights', true);
await dm.setEnabled('military', true);
const fl = dm.layers.get('flights').module;
const mil = dm.layers.get('military').module;
fl.setParams({ models3d: true });
mil.setParams({ models3d: true });
for (const off of [-24, -16, -8, 0]) {
window.__TURN.timeOffsetSec = off;
await fl.update(v);
await mil.update(v);
}
window.__TURN.timeOffsetSec = 0;
// Live driver: a fresh fix every 15 s from here on (the layers' own 30 s /
// 15 s pollers add consistent extras — the shim serves arc truth at serve
// time). 15 s × 3°/s = 45° course step per segment boundary.
window.__TURN_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000);
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__TURN_HITS };
});
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
record('inject: turning synthetic planes ingested (both layers)', primed.fl > 0 && primed.mil > 0,
`flights=${primed.fl} military=${primed.mil}`);
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; }
// ========================================================================
// Phase 1 — FLIGHTS layer: track TRN001, sample displayed course 30 s
// ========================================================================
console.log('\nPhase 1 — flights layer (airplane.glb, heading offset 180°)');
await page.evaluate((icao) => {
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
}, TURN.flights[0].icao);
const flModelUp = await page.waitForFunction((icao) => {
const m = window.__findTrackedModel(icao);
return !!(m && m.ready && m.show);
}, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false);
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
if (!flModelUp) { finish(); return; }
await sleep(1500); // let the follow camera + reconciliation settle
const readoutObservation = await observeTrackedHostPaint(page);
const readoutHost = await page.evaluate(() => ({
dedicatedCanvas: Boolean(document.getElementById('tracked-readout')),
hostCanvas: Boolean(document.getElementById('world-overlay-canvas')),
}));
const readoutSample = readoutObservation.sample || {};
record(
'tracked readout: host-painted model with no dedicated canvas',
!readoutHost.dedicatedCanvas && readoutHost.hostCanvas
&& readoutObservation.observed
&& readoutSample.hasPresentationModel
&& readoutSample.entryCount === 1
&& readoutSample.painted >= 1,
readoutObservation.observed
? `id=${readoutSample.entryId} entries=${readoutSample.entryCount} hostPainted=${readoutSample.painted} legacy=${readoutHost.dedicatedCanvas}`
: `timeout history=${JSON.stringify(readoutObservation.history)} legacy=${readoutHost.dedicatedCanvas}`,
);
// Sample in preRender: this listener was added AFTER the layer's _fleetTick
// (Cesium events fire in add order), so it reads the model matrix at the
// same wall-clock instant the rate limiter advanced — no render-pass skew.
const flSamples = await page.evaluate(
(icao) => window.__sampleModelCourse(icao, 180, 40000),
TURN.flights[0].icao,
);
analyze('flights', flSamples, {});
// A5 — absolute alignment vs the analytic arc tangent at the DISPLAYED time
// (render delay 30 s; the chord course lags the tangent by ≤ half a segment).
{
const last = [...flSamples].reverse().find((s) => Number.isFinite(s.course));
const epochMs = await page.evaluate(() => window.__TURN.epochMs);
if (last) {
const tDispRel = (last.epochMs - epochMs) / 1000 - 30;
const expected = arcState(TURN.flights[0], tDispRel).course;
const err = Math.abs(norm180(last.course - expected));
record('flights A5: displayed course aligned with arc tangent at displayed time (±35°)',
err <= 35, `sampled ${last.course.toFixed(1)}° vs tangent ${expected.toFixed(1)}° (|err| ${err.toFixed(1)}°)`);
} else {
record('flights A5: displayed course aligned with arc tangent', false, 'no valid sample');
}
}
// ---- Screenshots: two zoom levels × two orbit angles --------------------
console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/b3/');
await page.evaluate(() => {
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
});
await sleep(800);
const shots = [
['flights-far-orbitA.png', async () => { /* default follow framing */ }],
['flights-far-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.9); })],
['flights-near-orbitA.png', () => page.evaluate(() => {
const cam = window.__godsEyeView.viewer.camera;
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
// readout anchor, tracking line, and trail can be judged close-up.
cam.rotateRight(-1.9); cam.zoomIn(5200);
})],
['flights-near-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateLeft(1.2); })],
];
for (const [name, move] of shots) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
await sleep(1200); // let a few frames render at the new pose
await page.screenshot({ path: path.join(SHOT_DIR, name) });
console.log(` saved ${name}`);
}
await page.evaluate(() => {
window.__godsEyeView.styleManager._setDetectionMode('OFF');
});
// ========================================================================
// Phase 2 — MILITARY layer: track TRNMIL1 (flights layer auto-clears)
// ========================================================================
console.log('\nPhase 2 — military layer (jet.glb, heading offset 180°)');
await page.evaluate((hex) => {
window.__godsEyeView.dataManager.layers.get('military').module.trackById(hex);
}, TURN.military[0].hex);
const milModelUp = await page.waitForFunction((hex) => {
const m = window.__findTrackedModel(hex);
return !!(m && m.ready && m.show);
}, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false);
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
if (milModelUp) {
await sleep(1500);
const milSamples = await page.evaluate(
(icao) => window.__sampleModelCourse(icao, 180, 40000),
TURN.military[0].hex,
);
analyze('military', milSamples, {});
{
const last = [...milSamples].reverse().find((s) => Number.isFinite(s.course));
const epochMs = await page.evaluate(() => window.__TURN.epochMs);
if (last) {
const tDispRel = (last.epochMs - epochMs) / 1000 - 15; // military render delay 15 s
const expected = arcState(TURN.military[0], tDispRel).course;
const err = Math.abs(norm180(last.course - expected));
record('military A5: displayed course aligned with arc tangent at displayed time (±35°)',
err <= 35, `sampled ${last.course.toFixed(1)}° vs tangent ${expected.toFixed(1)}° (|err| ${err.toFixed(1)}°)`);
} else {
record('military A5: displayed course aligned with arc tangent', false, 'no valid sample');
}
}
await page.evaluate(() => {
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
});
await sleep(800);
for (const [name, move] of [
['military-orbitA.png', async () => { }],
['military-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.6); })],
['military-near-orbitA.png', () => page.evaluate(() => {
const cam = window.__godsEyeView.viewer.camera;
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
cam.rotateLeft(1.6); cam.zoomIn(5700);
})],
['military-near-orbitB.png', () => page.evaluate(() => {
window.__godsEyeView.viewer.camera.rotateRight(1.2);
})],
]) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
await sleep(1200);
await page.screenshot({ path: path.join(SHOT_DIR, name) });
console.log(` saved ${name}`);
}
await page.evaluate(() => {
window.__godsEyeView.styleManager._setDetectionMode('OFF');
});
}
// ========================================================================
// Low-speed phases (heading-v2). Shared sampler: same preRender discipline
// as phases 12 (reads the tracked model matrix at the instant the rate
// limiter advanced). Both scenarios live on the flights layer (offset 180°).
// ========================================================================
const sampleCourse = (offsetDeg, windowMs, icao, stepMs = 100) => page.evaluate(
({ id, offset, duration, step }) => window.__sampleModelCourse(id, offset, duration, step),
{ id: icao, offset: offsetDeg, duration: windowMs, step: stepMs },
);
const trackFlightsAndWaitModel = async (icao, label) => {
await page.evaluate((id) => {
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(id);
}, icao);
const up = await page.waitForFunction((id) => {
const m = window.__findTrackedModel(id);
return !!(m && m.ready && m.show);
}, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false);
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
return up;
};
// ---- Phase 3 — hovering helicopter --------------------------------------
console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)');
if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) {
await sleep(1500);
const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao);
analyzeHover('hover-heli', heliSamples, {});
for (const [name, waitMs] of [['hover-heli-T0.png', 0], ['hover-heli-T8s.png', 8000]]) {
if (waitMs) await sleep(waitMs);
await page.screenshot({ path: path.join(SHOT_DIR_V2, name) });
console.log(` saved ${name}`);
}
}
// ---- Phase 4 — 25 kt plane in a continuous 4°/s right turn --------------
console.log('\nPhase 4 — slow plane in a tight turn (25 kt, 4°/s, R=184 m)');
if (await trackFlightsAndWaitModel(TURN.slow.icao, 'slow-turn')) {
await sleep(1500);
const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao);
analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false });
// Exercise the production dt clamp with render gaps above
// COURSE_SLEW_DT_MAX_SEC. The regular 10 Hz sampling path cannot enter
// this regime, so it would miss a slow ambient-loop recovery spike.
const slowCadenceSamples = await sampleCourse(180, 18000, TURN.slow.icao, 600);
analyzeSlowTurn('slow-turn / 600 ms cadence', slowCadenceSamples, {
minSamples: 6,
minWindowSec: 12,
maxRateDps: 20,
maxReversalDeg: 8,
});
for (const [name, move] of [
['slowturn-far.png', async () => { /* default follow framing */ }],
['slowturn-near.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.zoomIn(2500); })],
]) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
await sleep(1200);
await page.screenshot({ path: path.join(SHOT_DIR_V2, name) });
console.log(` saved ${name}`);
}
}
// ========================================================================
// Phase 5 — tracked↔fleet course-handoff consistency (65 kt helicopter).
// The tracked standalone model AND the fleet model both carry id=icao, so
// one finder reads the RENDERED course through the whole transition.
// ========================================================================
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
await page.evaluate(() => {
window.__findModelByIcao = function (icao) {
const v = window.__godsEyeView.viewer;
const out = [];
const walk = (coll) => {
const n = coll.length;
for (let i = 0; i < n; i++) {
let p;
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === icao && p.show && p.ready) out.push(p);
}
};
walk(v.scene.primitives);
return out[0] || null;
};
});
// Activate the heli in the feed now (kept out of phases 14), ingest it,
// and let it fly UNTRACKED for a few seconds so the fleet pass owns its
// display-course state first — the exact state the click must hand off.
// 3D OFF for C1: the billboard is the rendered visual on both sides of
// the untrack, so the screen rotation IS what the user sees flip (or not).
await page.evaluate(async () => {
window.__TURN.heli65.active = true;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
fl.setParams({ models3d: false });
await fl.update(window.__godsEyeView.viewer); // ingest the first heli fix
});
await sleep(4000); // several fleet ticks establish the heli's fleet course
await page.evaluate((icao) => {
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
}, TURN.heli65.icao);
const heliTracked = await page.waitForFunction((icao) => {
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const ti = fl.getTrackedInfo();
return !!(ti && ti.icao24 === icao && window.__godsEyeView.viewer.trackedEntity);
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
heliTracked ? 'tracked' : 'tracking never engaged');
if (heliTracked) {
// Stay tracked for 60 s: pre-fix, the fleet's per-icao course entry
// FROZE at click time, so a snapped handoff replays ~2°/s × 60 s ≈ 120°
// of divergence; the post-fix shared state must hand off seamlessly.
console.log(' accumulating tracked time (60 s) so a frozen fleet entry would diverge ~120°...');
await sleep(60000);
// C1 — click-away. Sample the rendered SCREEN rotation per preRender:
// tracked entity rotation callback while tracked, fleet billboard
// rotation after. The release-in-place keeps the camera static, so
// rotation deltas across the boundary are course deltas.
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
const v = window.__godsEyeView.viewer;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const findFleetBillboard = (id) => {
const out = [];
const walk = (coll) => {
const n = coll.length;
for (let i = 0; i < n; i++) {
let p;
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.image !== undefined && p.alignedAxis !== undefined && p.id === id && p.show) out.push(p);
}
};
walk(v.scene.primitives);
return out[0] || null;
};
const out = [];
const start = performance.now();
let actionTMs = null;
setTimeout(() => { actionTMs = performance.now(); fl.stopTracking(); }, actionAtMs);
await new Promise((resolve) => {
const remove = v.scene.preRender.addEventListener(() => {
let rotRad = null;
const ent = v.trackedEntity;
if (ent && ent.billboard && ent.billboard.rotation) {
try { rotRad = ent.billboard.rotation.getValue(v.clock.currentTime); } catch { rotRad = null; }
} else {
const bb = findFleetBillboard(icao);
if (bb) rotRad = bb.rotation;
}
out.push({ tMs: performance.now(), course: rotRad == null ? null : (rotRad * 180) / Math.PI });
if (performance.now() - start >= windowMs) { remove(); resolve(); }
});
v.scene.requestRender();
});
return { samples: out, actionTMs };
}, { icao: TURN.heli65.icao, actionAtMs: 14000, windowMs: 40000 });
analyzeHandoff('heli65 C1 (click-away, screen rotation)', c1, {
truthDps: TURN.heli65.turnDps, stepExcessTolDeg: 15, rateTolDps: 15,
// Low-fps tolerant: SwiftShader can drop under 1 fps near ground-level
// 3D tiles. Step excess is truth-compensated so long pairs stay exact;
// the boundary pair (which always spans the click) carries the snap.
postWindowMs: 10000, minSamples: 12, pairDtCeilMs: 8000, rateDtFloorMs: 1000,
});
// C2 — click. 3D back ON: the fleet model (camera is already parked a
// few km out by the release-in-place) then the tracked standalone model
// both carry id=icao, and the model matrix gives the WORLD course, so
// the re-track camera flight cannot contaminate the series.
await page.evaluate(() => {
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
});
const fleetModelUp = await page.evaluate(async (icao) => {
const v = window.__godsEyeView.viewer;
const tileset = window.__godsEyeView.tileset;
const priorDefaultLoop = v.useDefaultRenderLoop;
const priorTilesetShow = tileset?.show;
v.useDefaultRenderLoop = false;
if (tileset) tileset.show = false;
try {
const deadline = performance.now() + 40000;
while (performance.now() < deadline) {
v.render();
if (window.__findModelByIcao(icao)) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
} finally {
if (tileset) tileset.show = priorTilesetShow;
v.useDefaultRenderLoop = priorDefaultLoop;
v.scene.requestRender();
}
}, TURN.heli65.icao);
if (!fleetModelUp) {
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
} else {
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
const v = window.__godsEyeView.viewer;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const out = [];
const start = performance.now();
let actionTMs = null;
setTimeout(() => { actionTMs = performance.now(); fl.trackById(icao); }, actionAtMs);
await new Promise((resolve) => {
const remove = v.scene.preRender.addEventListener(() => {
const m = window.__findModelByIcao(icao);
const c = m ? window.__courseFromModelMatrix(m.modelMatrix, offsetDeg) : null;
out.push({ tMs: performance.now(), course: c });
if (performance.now() - start >= windowMs) { remove(); resolve(); }
});
v.scene.requestRender();
});
return { samples: out, actionTMs };
}, { icao: TURN.heli65.icao, offsetDeg: 180, actionAtMs: 12000, windowMs: 35000 });
analyzeHandoff('heli65 C2 (click, model course)', c2, {
truthDps: TURN.heli65.turnDps, stepExcessTolDeg: 15, rateTolDps: 15,
postWindowMs: 10000, minSamples: 12, pairDtCeilMs: 8000,
});
}
await page.evaluate(() => {
window.__godsEyeView.dataManager.layers.get('flights').module.stopTracking();
});
}
record('no console errors during QA run', consoleErrors.length === 0,
consoleErrors.length
? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}; responses=${failedResponses.slice(0, 3).join(' | ') || 'unidentified'}`
: 'clean');
finish();
} finally {
await browser.close();
}
function finish() {
const failed = results.filter((r) => r.ok === false);
const passed = results.filter((r) => r.ok === true);
console.log(`\n${'─'.repeat(60)}`);
console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`);
console.log(`${'─'.repeat(60)}\n`);
process.exitCode = failed.length > 0 ? 1 : 0;
}
}
main().catch((err) => {
console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err);
process.exit(2);
});