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:
araharah-executor
2026-10-24 21:30:01 +08:00
parent d0d5a7dc27
commit 17bb950042
+568 -9
View File
@@ -33,6 +33,13 @@
* shell-route-dark.png route drawn, Telegram dark theme (PX-02 route half, 05-05) * 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 * 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) * 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 * Also runs the GEO-03 runtime probe in-page after boot-to-live and fails
* the run on any non-empty storage surface. * 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 { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright'; 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 SCRIPTS = dirname(fileURLToPath(import.meta.url));
const WEB = resolve(SCRIPTS, '..'); 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 DIST = resolve(SCRIPTS, "..", "dist-harness"); // the HARNESS build — never web/dist (see header)
const SHOTS = join(WEB, 'screenshots'); const SHOTS = join(WEB, 'screenshots');
const MOCKUP_COMPARE = resolve(REPOROOT, 'stitch_minimalist_route_navigator', 'navigate_map_overview', 'screen.png'); 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). */ /* Minimum plausible non-blank PNG (the Phase-1 blank bar, carried over). */
const MIN_BYTES = 10240; const MIN_BYTES = 10240;
/* DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first */ /* DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first */
const DENIED_LEG_TIMEOUT_MS = 90000; const DENIED_LEG_TIMEOUT_MS = 90000;
const SIGNAL_TIMEOUT_MS = 30000; 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 = { const MIME = {
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
@@ -168,7 +198,7 @@ log.push(`serving web/dist-harness/ → ${base}/`);
const browser = await chromium.launch(); 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 five route legs (05-05) are DATA-DRIVEN from the committed corpus:
* the demo pair is picked AT RUNTIME — never a hardcoded pair literal. * the demo pair is picked AT RUNTIME — never a hardcoded pair literal.
@@ -187,6 +217,252 @@ if (routeDestEntry === undefined) {
process.exit(1); 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 * 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 * 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'; * gets the same PX-02 honesty check as 'live';
* `interact(page)` runs after the waits, pre-shot — * `interact(page)` runs after the waits, pre-shot —
* the alt leg's chip tap) * 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 target = join(SHOTS, name);
const context = await browser.newContext({ const context = await browser.newContext({
viewport: { width: 390, height: 844 }, 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 }); await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });
} }
const page = await context.newPage(); 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 consoleLines = [];
const allConsoleLines = []; // unbounded — the guidance legs' D-15 hygiene scan reads the WHOLE leg
page.on('console', (m) => { page.on('console', (m) => {
for (const s of SIGNALS) if (m.text().startsWith(s)) seen[s] = true; for (const s of SIGNALS) if (m.text().startsWith(s)) seen[s] = true;
consoleLines.push(m.text()); consoleLines.push(m.text());
allConsoleLines.push(m.text());
if (consoleLines.length > 20) consoleLines.shift(); if (consoleLines.length > 20) consoleLines.shift();
}); });
const signalDiagnostics = () => 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 { try {
await page.goto(`${base}/?${query}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); 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)}`); 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) 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') { } else if (waitMode === 'denied') {
// Wait on the denied strip lamp specifically — the awaiting-pin variant of // 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 // 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) ————— */ /* ————— DSGN-02 composer (shared by legs 8, 13, and 06-08's guidance gallery) ————— */
async function composeCompare(target, implementedFile, implementedCaption) { async function composeCompare(target, implementedFile, implementedCaption, mockupFile, mockupCaption) {
if (!existsSync(MOCKUP_COMPARE)) throw new Error(`committed mockup missing: ${MOCKUP_COMPARE}`); 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}`); if (!existsSync(implementedFile)) throw new Error(`implemented capture missing: ${implementedFile}`);
const liveB64 = readFileSync(implementedFile).toString('base64'); 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 context = await browser.newContext({ viewport: { width: 880, height: 960 }, deviceScaleFactor: 1 });
const page = await context.newPage(); const page = await context.newPage();
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style> 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"> </style></head><body><div class="row">
<figure><figcaption>IMPLEMENTED — ${implementedCaption}</figcaption> <figure><figcaption>IMPLEMENTED — ${implementedCaption}</figcaption>
<img class="cell" src="data:image/png;base64,${liveB64}"></figure> <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> <img class="cell mockimg" src="data:image/png;base64,${mockB64}"></figure>
</div></body></html>`; </div></body></html>`;
await page.goto(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`, { waitUntil: 'load', timeout: 30000 }); 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}`); 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 { } finally {
await browser.close(); await browser.close();
server.close(); server.close();
@@ -571,6 +1123,13 @@ const expected = [
'shell-route-dark.png', 'shell-route-dark.png',
'gallery-compare.png', 'gallery-compare.png',
'route-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 missing = expected.filter((f) => !existsSync(join(SHOTS, f)));
const undersized = expected.filter((f) => existsSync(join(SHOTS, f)) && sizeOf(join(SHOTS, f)) < MIN_BYTES); const undersized = expected.filter((f) => existsSync(join(SHOTS, f)) && sizeOf(join(SHOTS, f)) < MIN_BYTES);