583 lines
29 KiB
JavaScript
583 lines
29 KiB
JavaScript
#!/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 */ });
|
||
|
||
// ========================================================================
|
||
// E3–E6 — requests + ambient glyph/scale swaps (nothing tracked, 3D off)
|
||
// ========================================================================
|
||
console.log('\nE3–E6 — 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);
|
||
});
|