diff --git a/web/scripts/capture.mjs b/web/scripts/capture.mjs index 65bfad1..a580aab 100644 --- a/web/scripts/capture.mjs +++ b/web/scripts/capture.mjs @@ -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 = `