feat(06-08): the seven guidance capture legs + the DSGN-02 guidance gallery
- guide wait mode: the ARAHARAH-GUIDE state-token race (expectGuide on_route|rerouting) + DOM text polls + the PX-02 theme-slot honesty check + the D-15 coordinate-hygiene scan over the WHOLE leg's console (assertNoCoordinateLines — state tokens only, never coordinates) - the engine-A-shaped /api/route stub (per-leg Playwright interception, installed pre-goto): POST #1 = the consult — the drive model built from the committed fixture corpus + the drive's recorded maneuver indices + street names snapToGraph'd off the LOCAL road graph (the road the turn puts you on); later POSTs = reroutes — the merge model held 5s so the calm REROUTING window is photographable; the REAL http-source validation still runs on every answer - legs 14-19 per the UI-SPEC capture table: active (banner slots + compact strip + card + progressbar + LIST default), list (scripted banner tap -> CLOSE + NEXT row lamp), rerouting (calm copy + dash + gantry + NO functional-red computed colors), confirm (bottom register + both CTAs + no red), tripend (center card + deletion clause + chrome cleared + strip returned), dark (matte-black chassis constant) - leg 20: guidance-gallery-compare.png vs active_route_guidance/screen.png (the composer generalized with mockup params; legs 8/13 unchanged) - prior 13 legs unbroken: 20/20 non-blank captures
This commit is contained in:
+568
-9
@@ -33,6 +33,13 @@
|
||||
* shell-route-dark.png route drawn, Telegram dark theme (PX-02 route half, 05-05)
|
||||
* gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png
|
||||
* route-gallery-compare.png DSGN-02 route fidelity artifact (05-05, same composer)
|
||||
* shell-guidance-active.png the LIVE cockpit over a replayed drive (?drive=, 06-08)
|
||||
* shell-guidance-list.png the banner tap's expanded list + NEXT row lamp (06-08)
|
||||
* shell-guidance-rerouting.png captured INSIDE the REROUTING window, no red (06-08)
|
||||
* shell-guidance-confirm.png the abandon confirm card, bottom register (06-08)
|
||||
* shell-guidance-tripend.png the trip-ended card, chrome cleared (06-08)
|
||||
* shell-guidance-dark.png the cockpit, Telegram dark theme (PX-02, 06-08)
|
||||
* guidance-gallery-compare.png DSGN-02 guidance fidelity artifact vs active_route_guidance/screen.png (06-08)
|
||||
*
|
||||
* Also runs the GEO-03 runtime probe in-page after boot-to-live and fails
|
||||
* the run on any non-empty storage surface.
|
||||
@@ -44,6 +51,17 @@ import { readFile } from 'node:fs/promises';
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
decodeGraph,
|
||||
decodePolyline6,
|
||||
deriveCumulativeDistanceM,
|
||||
encodePolyline6,
|
||||
snapToGraph,
|
||||
} from '@araharah/routing/src/index.ts';
|
||||
/* The pump's projection — ONE authority, imported (never re-derived):
|
||||
* the stub's merge answer walks the same forward-only projection the
|
||||
* cron and the replay tests use. */
|
||||
import { projectAlongRoute } from '../src/guidance/guidance.ts';
|
||||
|
||||
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
||||
const WEB = resolve(SCRIPTS, '..');
|
||||
@@ -51,12 +69,24 @@ const REPOROOT = resolve(WEB, '..');
|
||||
const DIST = resolve(SCRIPTS, "..", "dist-harness"); // the HARNESS build — never web/dist (see header)
|
||||
const SHOTS = join(WEB, 'screenshots');
|
||||
const MOCKUP_COMPARE = resolve(REPOROOT, 'stitch_minimalist_route_navigator', 'navigate_map_overview', 'screen.png');
|
||||
/* 06-08 — the guidance gallery's mockup reference (the cockpit screen). */
|
||||
const GUIDE_MOCKUP_COMPARE = resolve(REPOROOT, 'stitch_minimalist_route_navigator', 'active_route_guidance', 'screen.png');
|
||||
|
||||
/* Minimum plausible non-blank PNG (the Phase-1 blank bar, carried over). */
|
||||
const MIN_BYTES = 10240;
|
||||
/* DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first */
|
||||
const DENIED_LEG_TIMEOUT_MS = 90000;
|
||||
const SIGNAL_TIMEOUT_MS = 30000;
|
||||
/* The guidance legs' race window (06-08): the missed-turn drive's excursion
|
||||
* fires its reroute ~26s into the replay (the D-15 throttle accepts a fix
|
||||
* every ~2s at the drive's pace, so the 3-consecutive + grace gates span
|
||||
* ~22-26s of drive time). 60s covers it with boot margin; a timeout is a
|
||||
* HARD FAILURE with diagnostics, never a blank-but-passing shot. */
|
||||
const GUIDE_LEG_TIMEOUT_MS = 60000;
|
||||
/* The route stub's hold on REROUTE requests: the window must be comfortably
|
||||
* photographable — the leg polls state=rerouting and shoots inside it. The
|
||||
* CONSULT request answers instantly (the first POST only). */
|
||||
const REROUTE_HOLD_MS = 5000;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -168,7 +198,7 @@ log.push(`serving web/dist-harness/ → ${base}/`);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
const SIGNALS = ['RAMS-BASEMAP-READY', 'ARAHARAH-LIVE', 'ARAHARAH-DEST', 'ARAHARAH-ROUTE'];
|
||||
const SIGNALS = ['RAMS-BASEMAP-READY', 'ARAHARAH-LIVE', 'ARAHARAH-DEST', 'ARAHARAH-ROUTE', 'ARAHARAH-GUIDE'];
|
||||
|
||||
/* The five route legs (05-05) are DATA-DRIVEN from the committed corpus:
|
||||
* the demo pair is picked AT RUNTIME — never a hardcoded pair literal.
|
||||
@@ -187,6 +217,252 @@ if (routeDestEntry === undefined) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ————— 06-08 — the drive-replay legs' ground truth + the engine-A-shaped route stub ————— */
|
||||
|
||||
/* The D-16 browser leg replays a COMMITTED drive fixture through the LIVE
|
||||
* shell: the mock's ?drive= knob streams the fixture into the real watch.
|
||||
* The shell's route consult rides /api/route through the composed ladder —
|
||||
* intercepted HERE (Playwright route fulfilment) with an engine-A-SHAPED
|
||||
* answer built from the same committed data: the pair's ODbL geometry
|
||||
* (routing/src/data/route-fixtures.json) + the drive's recorded maneuver
|
||||
* indices + street names read off the LOCAL road graph
|
||||
* (routing/data/kl-roadgraph.v1.bin, the same OSM-derived source engine A
|
||||
* serves) via snapToGraph on the segment AFTER each maneuver — the road the
|
||||
* turn puts you on. A graph-absent fork degrades to unnamed steps (the
|
||||
* streetUnnamed fallback renders — honest). The first /api/route POST of a
|
||||
* page is the consult (the drive model); later POSTs are reroutes (a merge
|
||||
* model from the requesting fix, held REROUTE_HOLD_MS so the calm window is
|
||||
* photographable). The REAL http-source validation still runs on every
|
||||
* answer — the stub cannot smuggle a malformed payload past the client. */
|
||||
let guideGraph = null;
|
||||
try {
|
||||
const buf = readFileSync(resolve(REPOROOT, 'routing/data/kl-roadgraph.v1.bin'));
|
||||
guideGraph = decodeGraph(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
||||
} catch {
|
||||
guideGraph = null; // a fresh fork without the local graph: unnamed steps, honest
|
||||
}
|
||||
|
||||
function buildDriveModel(driveName) {
|
||||
const fx = JSON.parse(readFileSync(join(REPOROOT, 'web/test/fixtures/drives', `${driveName}.json`), 'utf8'));
|
||||
const pair = routePairs.find((p) => p.pairId === fx.pairId);
|
||||
if (pair === undefined) throw new Error(`drive "${driveName}" names unknown pair ${fx.pairId}`);
|
||||
const points = decodePolyline6(pair.routes[0].geometry);
|
||||
const cumulative = deriveCumulativeDistanceM(points);
|
||||
const totalM = cumulative[cumulative.length - 1];
|
||||
const steps = [{ kind: 'depart', distanceM: cumulative[fx.maneuverIdx[0]] }];
|
||||
fx.maneuverIdx.forEach((idx, n) => {
|
||||
const nextAlong = n + 1 < fx.maneuverIdx.length ? cumulative[fx.maneuverIdx[n + 1]] : totalM;
|
||||
const step = {
|
||||
kind: fx.kinds[n],
|
||||
distanceM: nextAlong - cumulative[idx],
|
||||
geometry: encodePolyline6(points.slice(idx, idx + 2)),
|
||||
};
|
||||
// the road the turn puts you on: the snap of the POST-maneuver segment's
|
||||
// midpoint (engine A names the FOLLOWING segment — deriveSteps semantics)
|
||||
try {
|
||||
const a = points[idx];
|
||||
const b = points[idx + 1];
|
||||
const mid = { lat: (a.lat + b.lat) / 2, lng: (a.lng + b.lng) / 2 };
|
||||
const snap = guideGraph !== null ? snapToGraph(guideGraph, mid) : null;
|
||||
const name = snap !== null && snap.nameId !== null ? guideGraph.nameAt(snap.nameId) : undefined;
|
||||
if (name !== undefined) step.name = name;
|
||||
} catch {
|
||||
/* an absent/corrupt local graph degrades to unnamed steps — honest */
|
||||
}
|
||||
steps.push(step);
|
||||
});
|
||||
steps.push({ kind: 'arrive', distanceM: 0 });
|
||||
return {
|
||||
points,
|
||||
cumulative,
|
||||
steps,
|
||||
destinationId: pair.destinationId,
|
||||
destinationName: routeGazetteer.entries.find((e) => e.id === pair.destinationId)?.name ?? pair.destinationId,
|
||||
durationS: pair.routes[0].durationS,
|
||||
};
|
||||
}
|
||||
|
||||
function routeOptionOf(model) {
|
||||
return {
|
||||
geometry: { points: model.points, cumulativeDistanceM: model.cumulative },
|
||||
summary: { distanceM: model.cumulative[model.cumulative.length - 1], durationS: model.durationS },
|
||||
steps: model.steps,
|
||||
};
|
||||
}
|
||||
|
||||
/** The reroute's engine-A-shaped answer (the 06-02 replay controller's
|
||||
* buildRerouteModel semantics): origin at the requesting fix, the original
|
||||
* line onward from the projection's next segment — terminal marks only. */
|
||||
function rerouteResultOf(model, origin, destinationId) {
|
||||
const fix = { ...origin, accuracyM: null, headingDeg: null, ts: 0, source: 'webview' };
|
||||
const proj = projectAlongRoute(model.points, model.cumulative, fix, 0);
|
||||
const startIdx = Math.min(proj.idx + 1, model.points.length - 1);
|
||||
const pts = [{ lat: origin.lat, lng: origin.lng }, ...model.points.slice(startIdx)];
|
||||
const cumulative = deriveCumulativeDistanceM(pts);
|
||||
const totalM = cumulative[cumulative.length - 1];
|
||||
const origTotalM = model.cumulative[model.cumulative.length - 1];
|
||||
const durationS = origTotalM > 0 ? Math.round((totalM / origTotalM) * model.durationS) : model.durationS;
|
||||
return {
|
||||
destinationId,
|
||||
origin: { lat: origin.lat, lng: origin.lng },
|
||||
backend: 'engine-a',
|
||||
options: [
|
||||
{
|
||||
geometry: { points: pts, cumulativeDistanceM: cumulative },
|
||||
summary: { distanceM: totalM, durationS },
|
||||
steps: [
|
||||
{ kind: 'depart', distanceM: totalM },
|
||||
{ kind: 'arrive', distanceM: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Install the guidance route stub on a leg's page. Deterministic per page:
|
||||
* POST #1 = the consult (the drive model), later POSTs = reroutes. */
|
||||
async function stubGuidanceRoute(page, model) {
|
||||
let requestCount = 0;
|
||||
await page.route('**/api/route', async (route) => {
|
||||
let body = {};
|
||||
try {
|
||||
body = route.request().postDataJSON() ?? {};
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const destinationId = typeof body.destinationId === 'string' && body.destinationId.length > 0
|
||||
? body.destinationId
|
||||
: model.destinationId;
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
destinationId,
|
||||
origin: body.origin ?? { lat: model.points[0].lat, lng: model.points[0].lng },
|
||||
backend: 'engine-a',
|
||||
options: [routeOptionOf(model)],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// a reroute: hold the calm REROUTING window open, then the merge answer
|
||||
await new Promise((r) => setTimeout(r, REROUTE_HOLD_MS));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(rerouteResultOf(model, body.origin ?? model.points[0], destinationId)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* The guidance legs' console hygiene (D-15/T-06-04 carried into captures):
|
||||
* the replay's console carries ONLY state tokens — a coordinate-bearing
|
||||
* line is a captured BUG. The pattern: a lat,lng decimal pair, or a
|
||||
* lat/lng/latitude/longitude field read. */
|
||||
const COORD_BEARING_RE = /\b\d{1,3}\.\d{4,}\b|\blat(itude)?\s*[:=]|\blng(gitude)?\s*[:=]/i;
|
||||
|
||||
async function assertNoCoordinateLines(allLines, legName) {
|
||||
const offenders = allLines.filter((l) => COORD_BEARING_RE.test(l));
|
||||
if (offenders.length > 0) {
|
||||
throw new Error(`${legName}: coordinate-bearing console lines (D-15 hygiene): ${JSON.stringify(offenders.slice(0, 3))}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* The no-red law (rerouting/confirm legs): functional-red #ED3F1C appears
|
||||
* NOWHERE in guidance chrome — computed colors are the honest check (class
|
||||
* lists cannot carry the token), plus a class-scan for the denied lamp. */
|
||||
const FUNCTIONAL_RED_RGB = 'rgb(237, 63, 28)';
|
||||
|
||||
async function scanForRed(page, rootSelectors) {
|
||||
return page.evaluate((args) => {
|
||||
const red = args.red;
|
||||
const offenders = [];
|
||||
for (const sel of args.selectors) {
|
||||
for (const root of document.querySelectorAll(sel)) {
|
||||
const els = [root, ...root.querySelectorAll('*')];
|
||||
for (const el of els) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.color === red) offenders.push(`${el.className}:color`);
|
||||
if (cs.backgroundColor === red) offenders.push(`${el.className}:background`);
|
||||
if (cs.borderTopColor === red) offenders.push(`${el.className}:border`);
|
||||
}
|
||||
if (/\b(red|denied)\b/.test(root.className)) offenders.push(`${root.className}:class`);
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
}, { red: FUNCTIONAL_RED_RGB, selectors: rootSelectors });
|
||||
}
|
||||
|
||||
/* ————— the guidance DOM asserts (each leg's interact runs them pre-shot) ————— */
|
||||
|
||||
const GUIDE_WAIT = { timeout: 15000, polling: 200 };
|
||||
|
||||
async function waitGuideRegister(page, expected) {
|
||||
await page.waitForFunction(
|
||||
(want) => document.querySelector('.guide-banner .guide-register')?.textContent === want,
|
||||
expected,
|
||||
GUIDE_WAIT,
|
||||
);
|
||||
}
|
||||
|
||||
async function assertCockpitChrome(page, destName) {
|
||||
const banner = await page.evaluate(() => {
|
||||
const b = document.querySelector('.guide-banner');
|
||||
if (b === null) return null;
|
||||
return {
|
||||
register: b.querySelector('.guide-register')?.textContent ?? '',
|
||||
distance: b.querySelector('.guide-distance')?.textContent ?? '',
|
||||
street: b.querySelector('.guide-street')?.textContent ?? '',
|
||||
toggle: b.querySelector('.guide-toggle')?.textContent ?? '',
|
||||
arrow: b.querySelector('.guide-plate svg') !== null,
|
||||
bg: getComputedStyle(b).backgroundColor,
|
||||
};
|
||||
});
|
||||
if (banner === null) throw new Error('the guidance banner never mounted');
|
||||
if (banner.register !== 'GUIDE // NEXT') throw new Error(`register=${banner.register}`);
|
||||
if (!/^\d+(\.\d+)?$/.test(banner.distance) || banner.distance === '0') {
|
||||
throw new Error(`distance=${banner.distance} — the glance datum must be a live number`);
|
||||
}
|
||||
if (banner.street.length === 0) throw new Error('the street slot is empty');
|
||||
if (!banner.arrow) throw new Error('the arrow plate is missing');
|
||||
if (banner.toggle !== 'LIST') throw new Error(`toggle=${banner.toggle} — the list must be collapsed by default`);
|
||||
const compacted = await page.evaluate(() => document.querySelector('main')?.dataset.guidance ?? null);
|
||||
if (compacted !== 'active') throw new Error(`data-guidance=${String(compacted)} — the strip never compacted`);
|
||||
const card = await page.evaluate(() => {
|
||||
const stack = document.querySelector('.route-stack[data-register="bottom"]');
|
||||
const bar = stack?.querySelector('.guide-progress');
|
||||
return {
|
||||
text: stack?.textContent ?? '',
|
||||
role: bar?.getAttribute('role') ?? null,
|
||||
fill: bar?.querySelector('.guide-progress-fill')?.style.width ?? '',
|
||||
};
|
||||
});
|
||||
for (const t of [destName, 'MIN', 'KM', 'ETA ', 'END TRIP']) {
|
||||
if (!card.text.includes(t)) throw new Error(`guidance card missing "${t}" — got ${JSON.stringify(card.text.slice(0, 200))}`);
|
||||
}
|
||||
if (card.role !== 'progressbar') throw new Error('the progress bar is missing');
|
||||
if (!/^\d+(\.\d+)?%$/.test(card.fill)) throw new Error(`progress fill=${card.fill}`);
|
||||
return banner;
|
||||
}
|
||||
|
||||
const guidePairFx = JSON.parse(readFileSync(join(REPOROOT, 'web/test/fixtures/drives/corridor-follow.json'), 'utf8'));
|
||||
const guidePair = routePairs.find((p) => p.pairId === guidePairFx.pairId);
|
||||
if (guidePair === undefined) {
|
||||
console.error(`[capture] FAILED the corridor drive names unknown pair "${guidePairFx.pairId}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
const guideDestEntry = routeGazetteer.entries.find((e) => e.id === guidePair.destinationId);
|
||||
if (guideDestEntry === undefined) {
|
||||
console.error(`[capture] FAILED the guide pair destination "${guidePair.destinationId}" is not a gazetteer id`);
|
||||
process.exit(1);
|
||||
}
|
||||
const corridorModel = buildDriveModel('corridor-follow');
|
||||
const missedModel = buildDriveModel('missed-turn-excursion');
|
||||
const guideQuery = `mock=android&dest=${guidePair.destinationId}&fixpair=${guidePair.pairId}&drive=corridor-follow`;
|
||||
|
||||
|
||||
/*
|
||||
* One capture leg = one context + one screenshot. The Phase-1 READY-race
|
||||
* shape: a console watcher records per-signal truth, the leg waits on its
|
||||
@@ -203,8 +479,13 @@ if (routeDestEntry === undefined) {
|
||||
* gets the same PX-02 honesty check as 'live';
|
||||
* `interact(page)` runs after the waits, pre-shot —
|
||||
* the alt leg's chip tap)
|
||||
* 'guide' → ARAHARAH-GUIDE state-token race + expectGuide
|
||||
* (06-08: 'on_route'|'rerouting') + expectText DOM poll
|
||||
* + the D-15 coordinate-hygiene scan over the WHOLE
|
||||
* leg's console; the replay's ?drive= knob streams
|
||||
* the committed fixture through the real watch
|
||||
*/
|
||||
async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, interact }) {
|
||||
async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact }) {
|
||||
const target = join(SHOTS, name);
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
@@ -216,15 +497,21 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expect
|
||||
await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });
|
||||
}
|
||||
const page = await context.newPage();
|
||||
const seen = { 'RAMS-BASEMAP-READY': false, 'ARAHARAH-LIVE': false, 'ARAHARAH-DEST': false, 'ARAHARAH-ROUTE': false, live: 'no' };
|
||||
/* Per-leg setup runs BEFORE goto (06-08: the guidance legs install the
|
||||
* /api/route stub here — Playwright interception must exist before the
|
||||
* consult's fetch). */
|
||||
if (setup) await setup(page);
|
||||
const seen = { 'RAMS-BASEMAP-READY': false, 'ARAHARAH-LIVE': false, 'ARAHARAH-DEST': false, 'ARAHARAH-ROUTE': false, 'ARAHARAH-GUIDE': false, live: 'no' };
|
||||
const consoleLines = [];
|
||||
const allConsoleLines = []; // unbounded — the guidance legs' D-15 hygiene scan reads the WHOLE leg
|
||||
page.on('console', (m) => {
|
||||
for (const s of SIGNALS) if (m.text().startsWith(s)) seen[s] = true;
|
||||
consoleLines.push(m.text());
|
||||
allConsoleLines.push(m.text());
|
||||
if (consoleLines.length > 20) consoleLines.shift();
|
||||
});
|
||||
const signalDiagnostics = () =>
|
||||
`${seen['RAMS-BASEMAP-READY'] ? 'RAMS-BASEMAP-READY yes' : 'RAMS-BASEMAP-READY no'}; ${seen['ARAHARAH-LIVE'] ? 'ARAHARAH-LIVE yes' : 'ARAHARAH-LIVE no'}; ${seen['ARAHARAH-DEST'] ? 'ARAHARAH-DEST yes' : 'ARAHARAH-DEST no'}; ${seen['ARAHARAH-ROUTE'] ? 'ARAHARAH-ROUTE yes' : 'ARAHARAH-ROUTE no'}; last console: ${JSON.stringify(consoleLines.slice(-5))}`;
|
||||
`${seen['RAMS-BASEMAP-READY'] ? 'RAMS-BASEMAP-READY yes' : 'RAMS-BASEMAP-READY no'}; ${seen['ARAHARAH-LIVE'] ? 'ARAHARAH-LIVE yes' : 'ARAHARAH-LIVE no'}; ${seen['ARAHARAH-DEST'] ? 'ARAHARAH-DEST yes' : 'ARAHARAH-DEST no'}; ${seen['ARAHARAH-ROUTE'] ? 'ARAHARAH-ROUTE yes' : 'ARAHARAH-ROUTE no'}; ${seen['ARAHARAH-GUIDE'] ? 'ARAHARAH-GUIDE yes' : 'ARAHARAH-GUIDE no'}; last console: ${JSON.stringify(consoleLines.slice(-5))}`;
|
||||
try {
|
||||
await page.goto(`${base}/?${query}`, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
@@ -307,6 +594,44 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expect
|
||||
throw new Error(`theme slot mismatch: expected ${colorScheme}, documentElement[data-tg-theme]=${String(themed)}`);
|
||||
}
|
||||
await page.waitForTimeout(2000); // tile render settle (route + camera refit at 400ms)
|
||||
} else if (waitMode === 'guide') {
|
||||
/* 06-08: race the ARAHARAH-GUIDE state token — a STATE TOKEN only
|
||||
* (D-15 hygiene: no coordinates in console; the drive replay emits
|
||||
* nothing per fix). expectGuide = 'on_route' | 'rerouting'. The
|
||||
* machine's deduped logger means the token appears exactly once, so
|
||||
* the race is the activation/window signal. */
|
||||
const signal = await Promise.race([
|
||||
page.waitForEvent('console', {
|
||||
predicate: (m) => m.text().startsWith('ARAHARAH-GUIDE') && m.text().includes(`state=${expectGuide}`),
|
||||
timeout: GUIDE_LEG_TIMEOUT_MS,
|
||||
}).then(() => 'signal'),
|
||||
new Promise((r) => setTimeout(() => r('timeout'), GUIDE_LEG_TIMEOUT_MS)),
|
||||
]);
|
||||
if (signal === 'timeout') {
|
||||
throw new Error(`ARAHARAH-GUIDE state=${expectGuide} race timed out after ${GUIDE_LEG_TIMEOUT_MS}ms — ${signalDiagnostics()}`);
|
||||
}
|
||||
const guideLine = consoleLines.filter((l) => l.startsWith('ARAHARAH-GUIDE')).pop() ?? '';
|
||||
if (!guideLine.includes(`state=${expectGuide}`)) {
|
||||
throw new Error(`guide state mismatch: "${guideLine}" — expected state=${expectGuide}`);
|
||||
}
|
||||
for (const text of expectText ?? []) {
|
||||
await page.waitForFunction(
|
||||
(t) => (document.body.textContent ?? '').includes(t),
|
||||
text,
|
||||
{ timeout: 15000, polling: 250 },
|
||||
);
|
||||
}
|
||||
/* PX-02 honesty check (anti-fake, T-03-30): the theme slot ACTUALLY
|
||||
* flipped to the requested scheme before shooting. */
|
||||
const themed = await page.evaluate(() => document.documentElement.dataset.tgTheme ?? null);
|
||||
if (themed !== colorScheme) {
|
||||
throw new Error(`theme slot mismatch: expected ${colorScheme}, documentElement[data-tg-theme]=${String(themed)}`);
|
||||
}
|
||||
/* D-15 console hygiene: the WHOLE leg's console carried no
|
||||
* coordinate-bearing line — the replay proves the state tokens, never
|
||||
* the fix stream. */
|
||||
await assertNoCoordinateLines(allConsoleLines, name);
|
||||
await page.waitForTimeout(2000); // tile render settle (the locked pose eased at 400ms)
|
||||
} else if (waitMode === 'denied') {
|
||||
// Wait on the denied strip lamp specifically — the awaiting-pin variant of
|
||||
// the card also carries a CTA during LINK 3's 60s poll, so the CARD alone
|
||||
@@ -414,12 +739,14 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— DSGN-02 composer (shared by legs 8 and 13) ————— */
|
||||
async function composeCompare(target, implementedFile, implementedCaption) {
|
||||
if (!existsSync(MOCKUP_COMPARE)) throw new Error(`committed mockup missing: ${MOCKUP_COMPARE}`);
|
||||
/* ————— DSGN-02 composer (shared by legs 8, 13, and 06-08's guidance gallery) ————— */
|
||||
async function composeCompare(target, implementedFile, implementedCaption, mockupFile, mockupCaption) {
|
||||
const mockupPath = mockupFile ?? MOCKUP_COMPARE;
|
||||
const mockupLabel = mockupCaption ?? 'stitch_minimalist_route_navigator/navigate_map_overview/screen.png';
|
||||
if (!existsSync(mockupPath)) throw new Error(`committed mockup missing: ${mockupPath}`);
|
||||
if (!existsSync(implementedFile)) throw new Error(`implemented capture missing: ${implementedFile}`);
|
||||
const liveB64 = readFileSync(implementedFile).toString('base64');
|
||||
const mockB64 = readFileSync(MOCKUP_COMPARE).toString('base64');
|
||||
const mockB64 = readFileSync(mockupPath).toString('base64');
|
||||
const context = await browser.newContext({ viewport: { width: 880, height: 960 }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
@@ -432,7 +759,7 @@ async function composeCompare(target, implementedFile, implementedCaption) {
|
||||
</style></head><body><div class="row">
|
||||
<figure><figcaption>IMPLEMENTED — ${implementedCaption}</figcaption>
|
||||
<img class="cell" src="data:image/png;base64,${liveB64}"></figure>
|
||||
<figure><figcaption>MOCKUP REFERENCE — stitch_minimalist_route_navigator/navigate_map_overview/screen.png</figcaption>
|
||||
<figure><figcaption>MOCKUP REFERENCE — ${mockupLabel}</figcaption>
|
||||
<img class="cell mockimg" src="data:image/png;base64,${mockB64}"></figure>
|
||||
</div></body></html>`;
|
||||
await page.goto(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`, { waitUntil: 'load', timeout: 30000 });
|
||||
@@ -549,6 +876,231 @@ async function composeCompare(target, implementedFile, implementedCaption) {
|
||||
failures.push(`route-gallery-compare.png: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— 14–20 — the guidance cockpit (06-08, D-16's browser leg) —————
|
||||
* Every leg rides the ?drive= replay knob: the mock streams a committed
|
||||
* drive fixture through the REAL watch and the machine's start gate
|
||||
* activates the cockpit. The /api/route stub answers engine-A-shaped
|
||||
* routes built from the same committed data (real street names off the
|
||||
* local graph). Each leg races its ARAHARAH-GUIDE state token, asserts
|
||||
* the DOM the UI-SPEC names, and freezes the replay before the shot. */
|
||||
|
||||
/* 14 — guidance ACTIVE, light: banner + compact strip + guidance card +
|
||||
* progress bar + locked camera pose. */
|
||||
await captureLeg({
|
||||
name: 'shell-guidance-active.png',
|
||||
setup: (page) => stubGuidanceRoute(page, corridorModel),
|
||||
query: guideQuery,
|
||||
colorScheme: 'light',
|
||||
grantGeo: true,
|
||||
waitMode: 'guide',
|
||||
expectGuide: 'on_route',
|
||||
expectText: [guideDestEntry.name, 'END TRIP'],
|
||||
interact: async (page) => {
|
||||
const banner = await assertCockpitChrome(page, guideDestEntry.name);
|
||||
log.push(`shell-guidance-active: banner ${JSON.stringify({ distance: banner.distance + ' M', street: banner.street, bg: banner.bg })}`);
|
||||
await page.waitForTimeout(300); // the frozen chrome settles
|
||||
},
|
||||
});
|
||||
|
||||
/* 15 — the banner tap's LIST: expanded panel over the map + NEXT row lamp
|
||||
* (the collapsed-default proof is the active leg's LIST toggle). */
|
||||
await captureLeg({
|
||||
name: 'shell-guidance-list.png',
|
||||
setup: (page) => stubGuidanceRoute(page, corridorModel),
|
||||
query: guideQuery,
|
||||
colorScheme: 'light',
|
||||
grantGeo: true,
|
||||
waitMode: 'guide',
|
||||
expectGuide: 'on_route',
|
||||
expectText: [guideDestEntry.name],
|
||||
interact: async (page) => {
|
||||
await assertCockpitChrome(page, guideDestEntry.name);
|
||||
await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause()); // stable chrome for the tap
|
||||
await page.click('.guide-banner'); // the whole band is the toggle
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('guide-maneuver-list') !== null,
|
||||
undefined,
|
||||
{ timeout: 5000, polling: 100 },
|
||||
);
|
||||
const expanded = await page.evaluate(() => {
|
||||
const banner = document.querySelector('.guide-banner');
|
||||
const row = document.querySelector('#guide-maneuver-list .guide-row[data-row-state="next"]');
|
||||
return {
|
||||
ariaExpanded: banner?.getAttribute('aria-expanded') ?? null,
|
||||
toggle: banner?.querySelector('.guide-toggle')?.textContent ?? '',
|
||||
lamp: row?.querySelector('.guide-lamp') !== null,
|
||||
rowText: row?.textContent ?? '',
|
||||
};
|
||||
});
|
||||
if (expanded.ariaExpanded !== 'true') throw new Error(`aria-expanded=${String(expanded.ariaExpanded)}`);
|
||||
if (expanded.toggle !== 'CLOSE') throw new Error(`toggle=${expanded.toggle} — the expanded banner reads CLOSE`);
|
||||
if (!expanded.lamp) throw new Error('the NEXT row lamp is missing');
|
||||
if (expanded.rowText.length === 0) throw new Error('the NEXT row carries no text');
|
||||
log.push(`shell-guidance-list: NEXT row ${JSON.stringify(expanded.rowText.slice(0, 80))}`);
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
});
|
||||
|
||||
/* 16 — REROUTING (D-04's calm state): the missed-turn drive fires its
|
||||
* reroute ~26s in; the stub holds the window; the leg races state=rerouting
|
||||
* and asserts the calm banner + the NO-RED law. */
|
||||
await captureLeg({
|
||||
name: 'shell-guidance-rerouting.png',
|
||||
setup: (page) => stubGuidanceRoute(page, missedModel),
|
||||
query: `mock=android&dest=${guidePair.destinationId}&fixpair=${guidePair.pairId}&drive=missed-turn-excursion`,
|
||||
colorScheme: 'light',
|
||||
grantGeo: true,
|
||||
waitMode: 'guide',
|
||||
expectGuide: 'rerouting',
|
||||
expectText: ['Finding a new route from where you are.'],
|
||||
interact: async (page) => {
|
||||
const calm = await page.evaluate(() => {
|
||||
const b = document.querySelector('.guide-banner');
|
||||
if (b === null) return null;
|
||||
return {
|
||||
register: b.querySelector('.guide-register')?.textContent ?? '',
|
||||
distance: b.querySelector('.guide-distance')?.textContent ?? '',
|
||||
street: b.querySelector('.guide-street')?.textContent ?? '',
|
||||
gantry: b.querySelector('.guide-gantry') !== null,
|
||||
streetColor: getComputedStyle(b.querySelector('.guide-street')).color,
|
||||
};
|
||||
});
|
||||
if (calm === null) throw new Error('the banner never mounted');
|
||||
if (calm.register !== 'GUIDE // REROUTING') throw new Error(`register=${calm.register}`);
|
||||
if (calm.distance !== '—') throw new Error(`distance=${calm.distance} — the calm state dashes`);
|
||||
if (calm.gantry !== true) throw new Error('the REROUTING gantry is missing');
|
||||
const offenders = await scanForRed(page, ['.guide-banner', '.route-stack[data-register="bottom"] .card']);
|
||||
if (offenders.length > 0) throw new Error(`functional-red found in guidance chrome: ${offenders.join(', ')}`);
|
||||
await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause()); // freeze inside the window
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
});
|
||||
|
||||
/* 17 — the abandon confirm card (D-09/PX-03): scripted END TRIP tap → the
|
||||
* confirm card in the BOTTOM register, both CTAs, no red. */
|
||||
await captureLeg({
|
||||
name: 'shell-guidance-confirm.png',
|
||||
setup: (page) => stubGuidanceRoute(page, corridorModel),
|
||||
query: guideQuery,
|
||||
colorScheme: 'light',
|
||||
grantGeo: true,
|
||||
waitMode: 'guide',
|
||||
expectGuide: 'on_route',
|
||||
expectText: [guideDestEntry.name, 'END TRIP'],
|
||||
interact: async (page) => {
|
||||
await assertCockpitChrome(page, guideDestEntry.name);
|
||||
await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause());
|
||||
await page.click('.route-stack[data-register="bottom"] .card .card-actions .btn-secondary'); // END TRIP
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.route-stack[data-register="bottom"] .card.guide-confirm') !== null,
|
||||
undefined,
|
||||
{ timeout: 5000, polling: 100 },
|
||||
);
|
||||
const confirm = await page.evaluate(() => {
|
||||
const card = document.querySelector('.route-stack[data-register="bottom"] .card.guide-confirm');
|
||||
return {
|
||||
register: card?.querySelector('.card-ref')?.textContent ?? '',
|
||||
headline: card?.querySelector('h2')?.textContent ?? '',
|
||||
primary: card?.querySelector('.btn-primary')?.textContent ?? '',
|
||||
secondary: card?.querySelector('.btn-secondary')?.textContent ?? '',
|
||||
};
|
||||
});
|
||||
if (confirm.register !== 'GUIDE // CONFIRM') throw new Error(`register=${confirm.register}`);
|
||||
if (confirm.headline !== 'Abandon route?') throw new Error(`headline=${confirm.headline}`);
|
||||
if (confirm.primary !== 'YES, ABANDON') throw new Error(`primary=${confirm.primary}`);
|
||||
if (confirm.secondary !== 'CONTINUE') throw new Error(`secondary=${confirm.secondary}`);
|
||||
const offenders = await scanForRed(page, ['.guide-confirm']);
|
||||
if (offenders.length > 0) throw new Error(`functional-red found in the confirm card: ${offenders.join(', ')}`);
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
});
|
||||
|
||||
/* 18 — the trip end (D-14): YES, ABANDON → the trip-ended card in the
|
||||
* CENTER register, the banner/list/cockpit cleared, the deletion clause
|
||||
* visible. */
|
||||
await captureLeg({
|
||||
name: 'shell-guidance-tripend.png',
|
||||
setup: (page) => stubGuidanceRoute(page, corridorModel),
|
||||
query: guideQuery,
|
||||
colorScheme: 'light',
|
||||
grantGeo: true,
|
||||
waitMode: 'guide',
|
||||
expectGuide: 'on_route',
|
||||
expectText: [guideDestEntry.name, 'END TRIP'],
|
||||
interact: async (page) => {
|
||||
await assertCockpitChrome(page, guideDestEntry.name);
|
||||
await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause());
|
||||
await page.click('.route-stack[data-register="bottom"] .card .card-actions .btn-secondary'); // END TRIP
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.route-stack[data-register="bottom"] .card.guide-confirm') !== null,
|
||||
undefined,
|
||||
{ timeout: 5000, polling: 100 },
|
||||
);
|
||||
await page.click('.route-stack[data-register="bottom"] .card.guide-confirm .btn-primary'); // YES, ABANDON
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const stack = document.querySelector('.route-stack[data-register="center"]');
|
||||
return stack !== null && (stack.textContent ?? '').includes('Trip ended');
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 5000, polling: 100 },
|
||||
);
|
||||
const cleared = await page.evaluate(() => ({
|
||||
banner: document.querySelector('.guide-banner') === null,
|
||||
list: document.getElementById('guide-maneuver-list') === null,
|
||||
guidanceAttr: document.querySelector('main')?.dataset.guidance,
|
||||
endText: document.querySelector('.route-stack[data-register="center"]')?.textContent ?? '',
|
||||
}));
|
||||
if (!cleared.banner) throw new Error('the banner survived the teardown');
|
||||
if (!cleared.list) throw new Error('the list survived the teardown');
|
||||
if (cleared.guidanceAttr !== undefined) throw new Error(`data-guidance=${String(cleared.guidanceAttr)} — the strip never returned`);
|
||||
if (!cleared.endText.includes('deleted')) {
|
||||
throw new Error(`the trip-end card is missing the deletion clause (PRIV-01): ${JSON.stringify(cleared.endText.slice(0, 200))}`);
|
||||
}
|
||||
log.push('shell-guidance-tripend: chrome cleared, deletion clause visible');
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
});
|
||||
|
||||
/* 19 — the cockpit, dark theme (PX-02 cold-boot half): chrome re-slots,
|
||||
* the banner chassis stays Rams-constant matte-black. */
|
||||
await captureLeg({
|
||||
name: 'shell-guidance-dark.png',
|
||||
setup: (page) => stubGuidanceRoute(page, corridorModel),
|
||||
query: `${guideQuery}&scheme=dark`,
|
||||
colorScheme: 'dark',
|
||||
grantGeo: true,
|
||||
waitMode: 'guide',
|
||||
expectGuide: 'on_route',
|
||||
expectText: [guideDestEntry.name, 'END TRIP'],
|
||||
interact: async (page) => {
|
||||
const banner = await assertCockpitChrome(page, guideDestEntry.name);
|
||||
if (banner.bg !== 'rgb(26, 26, 26)') {
|
||||
throw new Error(`banner chassis=${banner.bg} — matte-black is Rams-constant under both themes`);
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
});
|
||||
|
||||
/* 20 — DSGN-02 guidance fidelity artifact: the active-cockpit capture
|
||||
* beside the active_route_guidance mockup (the phase gallery's cockpit
|
||||
* row; fidelity review rides the blocking checkpoint). */
|
||||
{
|
||||
const target = join(SHOTS, 'guidance-gallery-compare.png');
|
||||
try {
|
||||
await composeCompare(
|
||||
target,
|
||||
join(SHOTS, 'shell-guidance-active.png'),
|
||||
'web/screenshots/shell-guidance-active.png (?dest=' + guidePair.destinationId + '&drive=corridor-follow, harness build)',
|
||||
GUIDE_MOCKUP_COMPARE,
|
||||
'stitch_minimalist_route_navigator/active_route_guidance/screen.png',
|
||||
);
|
||||
log.push(`captured guidance-gallery-compare.png (${sizeOf(target)}B, DSGN-02 guidance side-by-side)`);
|
||||
} catch (err) {
|
||||
failures.push(`guidance-gallery-compare.png: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
@@ -571,6 +1123,13 @@ const expected = [
|
||||
'shell-route-dark.png',
|
||||
'gallery-compare.png',
|
||||
'route-gallery-compare.png',
|
||||
'shell-guidance-active.png',
|
||||
'shell-guidance-list.png',
|
||||
'shell-guidance-rerouting.png',
|
||||
'shell-guidance-confirm.png',
|
||||
'shell-guidance-tripend.png',
|
||||
'shell-guidance-dark.png',
|
||||
'guidance-gallery-compare.png',
|
||||
];
|
||||
const missing = expected.filter((f) => !existsSync(join(SHOTS, f)));
|
||||
const undersized = expected.filter((f) => existsSync(join(SHOTS, f)) && sizeOf(join(SHOTS, f)) < MIN_BYTES);
|
||||
|
||||
Reference in New Issue
Block a user