gods-eye-view/scripts/qa-enrich-ambient.mjs

583 lines
29 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-enrich-ambient.mjs — headless proof for AMBIENT fleet type enrichment
* (fleet-enrichment widening of skylight Task 14).
*
* Field data (2026-07-02): OpenSky's live category field is 0/"no info" for
* ~94% of planes, so ambient classification defaulted nearly the whole fleet
* to the airliner silhouette. This harness reproduces exactly that case —
* 12 synthetic planes, ALL category 0 — and proves that the per-poll
* on-screen enrichment sweep (`_sweepAmbientEnrichment` in flights.js) gives
* them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while
* honoring the politeness bounds.
*
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
* - /api/opensky → 12 straight-flying planes, category 0
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* F16, GLID, B77W, A320, B738, PC12, R44) + one
* found:false miss; responses are HELD until the
* harness releases them (deterministic baseline),
* then served with 400 ms latency; every request
* start/inflight count is logged for the bounds
* assertions.
*
* Machine assertions:
* E1 ingest : all 12 synthetic planes in the flights layer
* E2 baseline : BEFORE any enrichment answer, all 12 billboards carry the
* default airliner glyph at scale 1 (the real-world problem)
* E3 requests : the sweep requested all 12 hexes, each exactly once
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
* enrichment (expected: 8)
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
* drip), total ≤300 (budget ceiling)
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
* stop being requested once the bucket is empty (shrunk via
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
* be waited out headlessly)
* E11 resume : after a refill window passes, ambient enrichment RESUMES
* for the still-unrequested planes (the 2026-07-03 field bug:
* the old one-shot session cap never refilled, so hours-long
* sessions showed airliner monoculture in every new region)
* E12 clean : no console errors
*
* Visual output (qa-shots/enrich-ambient/, untracked): before/after top-down
* spreads of the row.
*
* Run: node scripts/qa-enrich-ambient.mjs --url http://localhost:4304
* Exits non-zero if any assertion fails. Never commits anything.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js';
import { aircraftIcon } from '../src/data/aircraftIcons.js';
// ---------------------------------------------------------------------------
// Args (same shape as qa-sprites-b5.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/enrich-ambient');
// Client-side bounds under test (flights.js constants):
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
// land mid-phase); E11 then shortens it in-page so refill windows have
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
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;
}
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}` : ''}`);
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
// ---------------------------------------------------------------------------
// Synthetic fleet: one east-west row near Austin, ALL category 0 ("no info" —
// the 94% real-world case). adsbdb type answers cover 8 distinct classes;
// aa000c answers found:false (negative-cache case → stays airliner).
// ---------------------------------------------------------------------------
const ROW_LAT = 30.30;
const ROW_LON0 = -97.82;
const ROW_STEP_DEG = 0.012; // ~1.15 km — 12 planes span ~12.7 km, fits a 20 km-up frame
/** hex → adsbdb icao_type (null = adsbdb miss / negative cache). */
const TYPES = {
aa0001: 'C172', // light
aa0002: 'B744', // quadjet
aa0003: 'DH8D', // turboprop
aa0004: 'H60', // helicopter
aa0005: 'F16', // fastjet
aa0006: 'GLID', // glider
aa0007: 'B77W', // widebody
aa0008: 'A320', // airliner
aa0009: 'B738', // airliner (known code outside the special sets)
aa000a: 'PC12', // turboprop
aa000b: 'R44', // helicopter
aa000c: null, // adsbdb 404 → found:false → stays airliner, never re-asked
};
const SPEC = {
timeOffsetSec: 0,
responseDelayMs: 400, // adsbdb latency so concurrency is actually observable
budgetQa: BUDGET_QA,
types: TYPES,
planes: Object.keys(TYPES).map((hex, i) => ({
icao: hex,
callsign: `AMB${String(i + 1).padStart(3, '0')}`,
lon0: ROW_LON0 + i * ROW_STEP_DEG,
lat0: ROW_LAT,
courseDeg: 0,
speedMps: 70,
altM: 2400 + i * 40,
})),
};
// Node-side expectations (same classifier inputs the layer sees).
const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
]));
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
// ---------------------------------------------------------------------------
async function main() {
console.log(`\nAmbient fleet type-enrichment QA (fleet-enrichment widening)`);
console.log(` App URL : ${APP_URL}\n`);
console.log(` Expected classes: ${[...expected.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\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 });
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--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 = [];
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}`));
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
await page.evaluateOnNewDocument((spec) => {
window.__ENR = spec;
window.__ENR.epochMs = Date.now();
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
// the shrunk knobs are read from the very first sweep (see BUDGET_QA).
window.__GEV_ENRICH_AMBIENT_QA = { ...spec.budgetQa };
// Request log: starts (hex + performance.now), live/max concurrency,
// hold gate (responses park until the harness releases them so the
// pre-enrichment baseline is deterministic).
window.__ENRICH_LOG = { starts: [], inflight: 0, maxInflight: 0, held: true, holds: [] };
window.__ENRICH_RELEASE = () => {
window.__ENRICH_LOG.held = false;
window.__ENRICH_LOG.holds.splice(0).forEach((fn) => fn());
};
// Straight line from (lon0, lat0) along courseDeg at speedMps.
window.__ENR.stateAt = (p, tSec) => {
const cRad = (p.courseDeg * Math.PI) / 180;
const dist = p.speedMps * tSec;
const east = dist * Math.sin(cRad);
const north = dist * Math.cos(cRad);
const lat = p.lat0 + north / 111320;
const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180));
return { lon, lat, course: p.courseDeg, speedMps: p.speedMps };
};
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 S = window.__ENR;
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
const tRel = nowSec - S.epochMs / 1000;
if (url.includes('/api/adsbdb/type/')) {
const hex = url.split('/').pop().split('?')[0].toLowerCase();
const L = window.__ENRICH_LOG;
L.starts.push({ hex, t: performance.now() });
L.inflight += 1;
L.maxInflight = Math.max(L.maxInflight, L.inflight);
const tc = S.types[hex];
const body = tc
? { found: true, typeCode: tc, typeName: `Synthetic ${tc}`, registration: `N${hex.slice(-3).toUpperCase()}` }
: { found: false };
return new Promise((resolve) => {
const finish = () => setTimeout(() => { L.inflight -= 1; resolve(jsonResponse(body)); }, S.responseDelayMs);
if (L.held) L.holds.push(finish); else finish();
});
}
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
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/adsblol/mil')) {
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.includes('/api/opensky')) {
const states = S.planes.map((f) => {
const s = S.stateAt(f, tRel);
return [
f.icao, f.callsign, 'Synthetica',
Math.floor(nowSec), Math.floor(nowSec),
s.lon, s.lat, f.altM,
false, // on_ground
s.speedMps, s.course,
0, null, null, null, false, 0,
0, // state[17] — category 0 = "no info" (the 94% case)
];
});
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
return realFetch(input, init);
};
}, SPEC);
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.');
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
await page.evaluate(() => {
window.__collectBillboards = function () {
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.image !== undefined && p.alignedAxis !== undefined) {
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
}
}
};
walk(v.scene.primitives);
return out;
};
});
// ---- Frame the row FIRST (top-down, all 12 on-screen), then prime ------
// The ambient sweep runs inside update(), so the camera must already be
// framing the fleet when the layer polls — otherwise the frustum test
// (correctly) skips off-screen planes.
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
await page.evaluate(({ lat, lon, height }) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
const C3 = v.camera.position.constructor;
v.camera.setView({
destination: C3.fromDegrees(lon, lat, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
console.log('Priming straight-flight history through the render delay (30 s)...');
const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer;
window.__ENR.timeOffsetSec = -32;
await dm.setEnabled('flights', true);
const fl = dm.layers.get('flights').module;
for (const off of [-24, -16, -8, 0]) {
window.__ENR.timeOffsetSec = off;
await fl.update(v);
}
window.__ENR.timeOffsetSec = 0;
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
});
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
record('E1 ingest: all synthetic category-0 planes in the flights layer',
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
if (primed.count === 0) { finish(); return; }
// ========================================================================
// E2 — deterministic pre-enrichment baseline (responses still held)
// ========================================================================
console.log('\nE2 — pre-enrichment baseline (adsbdb responses held)');
const before = await page.evaluate(() => window.__collectBillboards());
const beforeById = new Map(before.map((b) => [b.id, b]));
const baselineBad = [];
for (const hex of Object.keys(TYPES)) {
const bb = beforeById.get(hex);
if (!bb) baselineBad.push(`${hex}:missing`);
else if (bb.image !== AIRLINER_ICON) baselineBad.push(`${hex}:not-default-glyph`);
else if (Math.abs(bb.scale - (CLASS_SCALE_2D.airliner || 1)) > 1e-9) baselineBad.push(`${hex}:scale=${bb.scale}`);
}
record('E2 baseline: all 12 category-0 planes default to the airliner glyph',
baselineBad.length === 0,
baselineBad.length ? baselineBad.join(' ') : 'all 12 identical airliner silhouettes (the field-data problem)');
await sleep(1200); // let a fleet tick land rotations before the "before" shot
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-before-monoculture.png') });
console.log(' saved enrich-before-monoculture.png (12 identical airliner glyphs)');
// ========================================================================
// Release adsbdb + wait for the drip to finish, then for glyph swaps
// ========================================================================
console.log('\nReleasing adsbdb responses; waiting for the bounded drip to drain...');
await page.evaluate(() => window.__ENRICH_RELEASE());
const drained = await page.waitForFunction(
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
{ timeout: 30000, polling: 100 }, SPEC.planes.length
).then(() => true).catch(() => false);
if (!drained) console.log(' \x1b[33mdrip did not fully drain within 30 s\x1b[0m');
// Give the enrichment callbacks a beat to apply billboard swaps.
await page.waitForFunction(() => {
const images = new Set(window.__collectBillboards().map((b) => b.image));
return images.size >= 5;
}, { timeout: 10000, polling: 200 }).catch(() => { /* asserted below */ });
// ========================================================================
// E3E6 — requests + ambient glyph/scale swaps (nothing tracked, 3D off)
// ========================================================================
console.log('\nE3E6 — ambient enrichment results');
const log1 = await page.evaluate(() => ({
starts: window.__ENRICH_LOG.starts.map((s) => ({ hex: s.hex, t: s.t })),
maxInflight: window.__ENRICH_LOG.maxInflight,
}));
const startHexes = log1.starts.map((s) => s.hex);
const uniqueHexes = new Set(startHexes);
const allRequested = Object.keys(TYPES).every((h) => uniqueHexes.has(h));
record('E3 requests: sweep requested all 12 hexes, each exactly once',
allRequested && startHexes.length === Object.keys(TYPES).length,
`${startHexes.length} requests, ${uniqueHexes.size} unique`);
const after = await page.evaluate(() => window.__collectBillboards());
const afterById = new Map(after.map((b) => [b.id, b]));
const syntheticImages = new Set(
Object.keys(TYPES).map((hex) => afterById.get(hex)?.image).filter(Boolean)
);
record('E4 diversity: ≥5 distinct glyph data-URIs displayed ambiently',
syntheticImages.size >= 5, `${syntheticImages.size} distinct glyphs (expected 8)`);
const glyphBad = [];
const scaleBad = [];
for (const [hex, klass] of expected) {
const bb = afterById.get(hex);
const wantImg = aircraftIcon(klass);
const wantScale = CLASS_SCALE_2D[klass] || 1;
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
}
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-after-diverse.png') });
console.log(' saved enrich-after-diverse.png (type-diverse row, still untracked)');
// ========================================================================
// E7 — politeness bounds (concurrency, drip rate, session cap)
// ========================================================================
console.log('\nE7 — client-side bounds');
const gaps = [];
for (let i = 1; i < log1.starts.length; i++) gaps.push(log1.starts[i].t - log1.starts[i - 1].t);
const minGap = gaps.length ? Math.min(...gaps) : Infinity;
// Max dispatches in any sliding 1 s window (reported for context).
let maxPerSec = 0;
for (let i = 0; i < log1.starts.length; i++) {
let n = 0;
for (let j = i; j < log1.starts.length && log1.starts[j].t - log1.starts[i].t < 1000; j++) n++;
maxPerSec = Math.max(maxPerSec, n);
}
const boundsOk = log1.maxInflight <= MAX_INFLIGHT
&& minGap >= (DISPATCH_GAP_MS - GAP_TOLERANCE_MS)
&& log1.starts.length <= SESSION_CAP;
record(`E7 bounds: ≤${MAX_INFLIGHT} concurrent, ≥${DISPATCH_GAP_MS}ms drip gap, ≤${SESSION_CAP}/session`,
boundsOk,
`maxConcurrent=${log1.maxInflight} minGap=${Number.isFinite(minGap) ? minGap.toFixed(1) : 'n/a'}ms `
+ `maxIn1s=${maxPerSec} total=${log1.starts.length}`);
// ========================================================================
// E8 — next poll: klass persists (poll re-derive keeps enriched types) and
// the session dedupe never re-requests an answered/negative hex
// ========================================================================
console.log('\nE8 — next poll after enrichment');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer;
await dm.layers.get('flights').module.update(v);
});
await sleep(500);
const afterPoll = await page.evaluate(() => ({
billboards: window.__collectBillboards(),
starts: window.__ENRICH_LOG.starts.length,
}));
const afterPollById = new Map(afterPoll.billboards.map((b) => [b.id, b]));
const pollBad = [];
for (const [hex, klass] of expected) {
const bb = afterPollById.get(hex);
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
}
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
record('E8 next poll: enriched glyphs persist and no hex is re-requested',
pollBad.length === 0 && noReRequests,
pollBad.length ? pollBad.join(' ') : `glyphs stable, requests still ${afterPoll.starts}`);
// ========================================================================
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
// old ONE-SHOT session cap burned out and never refilled, so every new
// region after the first polls showed airliner monoculture. With the seam
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
// on-screen planes must stall at exactly 8 new requests, and further polls
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
// no refill can land mid-phase). Pre-fix code fails this phase: there is
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
// admits the whole batch).
// ========================================================================
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
const BATCH_COUNT = 26;
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer;
// Two fresh rows just north of the original one — still inside the
// top-down 20 km frame, so the sweep's frustum test keeps them.
// STATIONARY (speed 0): moving planes drift north out of the frustum at
// run-time-dependent rates, which made this phase timing-sensitive.
hexes.forEach((hex, i) => {
window.__ENR.planes.push({
icao: hex,
callsign: `BGT${String(i + 1).padStart(3, '0')}`,
lon0: rowLon0 + (i % 13) * stepDeg,
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
courseDeg: 0,
speedMps: 0,
altM: 2600 + i * 15,
});
});
const fl = dm.layers.get('flights').module;
await fl.update(v); // sweep enqueues the last 8 tokens
await fl.update(v); // bucket empty — must enqueue nothing more
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
// Wait for the drip to drain whatever was enqueued, then settle.
await page.waitForFunction(
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
).catch(() => { /* asserted below */ });
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
// One more poll while exhausted — still nothing new.
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
});
await sleep(1000);
const exhausted = await page.evaluate(() => ({
starts: window.__ENRICH_LOG.starts.length,
inflight: window.__ENRICH_LOG.inflight,
}));
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
// ========================================================================
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
// time since the bucket's refill anchor now spans whole windows; the next
// sweep refills (clamped at ceil) and the waiting planes get requested.
// ========================================================================
console.log('\nE11 — refill window passes; ambient enrichment resumes');
await page.evaluate((winMs) => { window.__GEV_ENRICH_AMBIENT_QA.windowMs = winMs; }, RESUME_WINDOW_MS);
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
});
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
const resumed = await page.waitForFunction(
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
{ timeout: 25000, polling: 100 }, expectedTotal
).then(() => true).catch(() => false);
const resumeLog = await page.evaluate(() => window.__ENRICH_LOG.starts.map((s) => s.hex));
const batchRequested = batchHexes.filter((h) => resumeLog.includes(h)).length;
const dupes = resumeLog.length !== new Set(resumeLog).size;
record('E11 resume: enrichment resumes after a refill window (all waiting planes requested, once each)',
resumed && batchRequested === BATCH_COUNT && resumeLog.length === expectedTotal && !dupes,
`starts=${resumeLog.length} (want ${expectedTotal}) batchRequested=${batchRequested}/${BATCH_COUNT} dupes=${dupes}`);
record('E12: no console errors during QA run', consoleErrors.length === 0,
consoleErrors.length ? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}` : '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);
});