1116 lines
58 KiB
JavaScript
1116 lines
58 KiB
JavaScript
#!/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) >= −8°
|
||
* 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 1–4
|
||
// 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 1–2 (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 1–4), 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);
|
||
});
|