feat(06-06): the guidance pump — start gate, effects dispatch, cockpit chrome, silent reroute (GREEN)

- route.ts grows the pump beside the Phase 5 graph: the injected watch arms
  with the route draw; the first fix opens the 06-02 session; the start
  gate (>= GUIDANCE_START_ALONG_M along) activates the cockpit — PX-03
  close protection, [data-guidance=active], banner + card + list, camera
  eased to the SNAPPED line, state=on_route token
- effects dispatch: haptics via fireHaptic, reroute-requested through the
  injected RoutingSource (fail-honest catch; success = setData snap-in with
  NO camera jump + list re-render; failure = calm NO REROUTE + card state
  line); arrival -> the D-14 teardown (watch stop, protection release,
  chrome clear, trip-end card center)
- END TRIP/BackButton -> the ONE confirm card; YES ABANDON = same teardown;
  CONTINUE dismisses with guidance alive; guidancePhase exposes
  none/driving/confirm/ended for the PX-01 resolver; walkBack refuses
  while the confirm owns the exit and vacates the route stack on the walk
- guidanceView (guidance.ts): banner/card/list view data composed beside
  the machine's own maneuver marks — route/ imports and calls it, never
  re-derives (the RS6 re-pin's boundary; zero banned tokens in route/)
- G1-G8 green; RS6/RS8 re-pins green; 137 passed, typecheck clean
This commit is contained in:
araharah-executor
2026-10-24 15:16:56 +08:00
parent 0316be485b
commit 14e8dfcdd9
3 changed files with 578 additions and 46 deletions
+116
View File
@@ -46,6 +46,7 @@ import type { RoutePoint, RouteStep } from "@araharah/routing/src/types.ts";
import { haversineM } from "@araharah/routing/src/geo.ts"; import { haversineM } from "@araharah/routing/src/geo.ts";
import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"; import { decodePolyline6 } from "@araharah/routing/src/polyline.ts";
import type { Fix } from "../location/types.ts"; import type { Fix } from "../location/types.ts";
import type { GlyphKind } from "./glyphs.ts";
/* ————— THE constants table (06-RESEARCH Pattern 3; replay-tuned, never inline) ————— */ /* ————— THE constants table (06-RESEARCH Pattern 3; replay-tuned, never inline) ————— */
@@ -436,3 +437,118 @@ function processEdges(next: GuidanceSnapshot, fix: Fix, effects: GuidanceEffect[
next.approachArmed = false; next.approachArmed = false;
} }
} }
/* ————— the chrome's view data (06-06) ————— */
/**
* guidanceView composes EVERYTHING the cockpit chrome renders from a
* snapshot — banner slots, card view fields, and the maneuver list rows —
* so the route controller never re-derives maneuver/progress data (the
* RS6 re-pin's law: guidance IMPLEMENTATION lives in web/src/guidance/;
* route/ imports and calls, never re-computes). Pure: snapshot in, plain
* data out — structurally the view types banner.ts's builders consume.
*
* The banner's distance slot is the model-derived distance TO THE NEXT
* MANEUVER MARK (D-07: never a raw fix-to-destination distance) — null
* (the honest dash) while REROUTING or in the NO REROUTE degradation; the
* RESUMING register legally shows the projection numbers. The street slot
* is the next maneuver's own step name (the road the maneuver puts you
* on); the banner builder swaps in the calm-state copy for the wait
* states regardless.
*/
export interface GuidanceListRow {
kind: GlyphKind;
street: string;
distanceM: number;
rowState: "next" | "future" | "past";
}
export interface GuidanceBannerData {
state: GuidanceState;
glyph: GlyphKind;
distanceM: number | null;
street: string | null;
listOpen: boolean;
}
export interface GuidanceCardData {
destinationName: string;
progressPct: number;
showNoReroute: boolean;
}
export interface GuidanceViewData {
banner: GuidanceBannerData;
card: GuidanceCardData;
rows: GuidanceListRow[];
}
export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): GuidanceViewData {
const { model } = snap;
const marks = maneuverMarksOf(model);
const next = marks[snap.nextManeuver] ?? null;
// The road a mark names: a TURN mark names the road the turn puts you on
// (its owning step); the ARRIVE mark names the road being arrived on —
// the last NAMED step of the route (an arrive step itself carries no
// name; a blank slot would render the unnamed fallback and lie).
const arrivalRoad = (() => {
let named: string | null = null;
for (const step of model.steps) {
if (step.name !== undefined && step.name.length > 0) named = step.name;
}
return named;
})();
// the next maneuver's owning step: the step whose turn-kind AND prefix
// match the mark (a step runs FROM its maneuver TO the next boundary)
let street: string | null = null;
let along = 0;
if (next !== null) {
for (const step of model.steps) {
if (step.kind === next.kind && along === next.alongM) {
street = step.kind === "arrive" ? arrivalRoad : (step.name ?? null);
break;
}
along += step.distanceM ?? 0;
}
}
const toManeuverM = next === null ? null : Math.max(0, Math.round(next.alongM - snap.alongM));
const calm = snap.state === "rerouting" || snap.state === "no_reroute";
const totalM = model.cumulative[model.cumulative.length - 1] ?? 0;
const progressPct = totalM > 0 ? Math.min(100, Math.max(0, (snap.alongM / totalM) * 100)) : 0;
const rows: GuidanceListRow[] = marks.map((mark, i) => {
let rowStreet = "";
let cursor = 0;
for (const step of model.steps) {
if (step.kind === mark.kind && cursor === mark.alongM) {
rowStreet = mark.kind === "arrive" ? (arrivalRoad ?? "") : (step.name ?? "");
break;
}
cursor += step.distanceM ?? 0;
}
return {
kind: mark.kind ?? "straight",
street: rowStreet,
distanceM: Math.max(0, Math.round(mark.alongM - snap.alongM)),
rowState: i < snap.nextManeuver ? "past" : i === snap.nextManeuver ? "next" : "future",
};
});
return {
banner: {
state: snap.state,
glyph: (next?.kind ?? "straight") as GlyphKind,
distanceM: calm ? null : toManeuverM,
street,
listOpen,
},
card: {
destinationName: model.destinationName,
progressPct,
showNoReroute: snap.state === "no_reroute",
},
rows,
};
}
+403 -28
View File
@@ -1,47 +1,85 @@
/* /*
* route.ts — the route controller: consult / select / walk-back state machine. * route.ts — the route controller: consult / select / walk-back state machine,
* and (06-06) the guidance pump that turns it into the live cockpit.
* ------------------------------------------------------------------------------ * ------------------------------------------------------------------------------
* States: idle → (auto-consult, exactly once per boot — main.ts guards the * States: idle → (auto-consult, exactly once per boot — main.ts guards the
* flag) → route | pending → walkBack → idle. * flag) → route | pending → walkBack → idle. Guidance rides BESIDE that
* graph as an orthogonal phase (guidancePhase: none → driving → confirm →
* ended) — the RouteState stays "route" while driving; the phase is what
* the BackButton resolver reads.
*
* THE PUMP (06-06): when a route draws, the injected watch starts; every
* accepted fix opens the session (createGuidanceSession) and steps the
* 06-02 machine (stepGuidance). The start gate (≥ GUIDANCE_START_ALONG_M
* along) activates the cockpit: PX-03 close protection, [data-guidance=
* "active"] on the shell root, the 06-05 chrome (banner + card + list),
* and the 06-06 camera easing to the SNAPPED line. Effects dispatch to the
* guards (haptics) and the injected RoutingSource (reroute — the composed
* engine-a ladder): success snaps the new line in via setData with NO
* camera jump; failure lands the calm NO REROUTE degradation. Arrival and
* abandon both run the same teardown: watch stopped, protection released,
* chrome cleared, the trip-ended card in the center register (D-14).
*
* EVERY guidance behavior is delegated: the machine (web/src/guidance/
* guidance.ts) decides states/effects/thresholds; guidanceView composes the
* chrome's view data; banner.ts builds the DOM; the eta module smooths the
* numbers; camera.ts owns the pose. THIS module only wires them — zero
* maneuver math, zero progress math inline (the RS6 re-pin's boundary).
* *
* D-09: a drawn route is static furniture — only selection changes it. The * D-09: a drawn route is static furniture — only selection changes it. The
* selected stack paints via setData on the "route" source; alternates on * selected stack paints via setData on the "route" source; alternates on
* "route-alt". Camera: fitBounds over the selected geometry with the * "route-alt". Camera: fitBounds over the selected geometry with the
* route's own padded constants (BESIDE follow.ts's CAMERA_PADDING_PX, the * route's own padded constants (BESIDE follow.ts's CAMERA_PADDING_PX, the
* UI-SPEC's camera table): bottom 320 = the 64px control-clearance * UI-SPEC's camera table). Pitch and bearing are passed EXPLICITLY at
* composite + the ≤240px card height budget + 16px slack. Pitch and * their current values — the fit stays center+zoom only.
* bearing are passed EXPLICITLY at their current values — the fit stays
* center+zoom only (the camera law).
*
* D-08: alternatives are a bottom card + chips — tap → instant geometry
* swap + card re-render + camera refit (ALWAYS refits). Single-route draws
* render no chips; the pending state renders the center card.
* *
* D-03: route() resolving null — or THROWING (T-05-04, .catch(() => null)) * D-03: route() resolving null — or THROWING (T-05-04, .catch(() => null))
* — renders the designed pending card, never a blank map, never a fake * — renders the designed pending card, never a blank map, never a fake
* route, never a half-drawn line. Fail-honest. * route, never a half-drawn line. Fail-honest — and guidance can never
* start over a pending state (PX-03's precondition).
* *
* D-10: display-only terminal — selection redraws + refits and the single * D-07/D-12 memory law: the origin and every fix are held in THIS closure
* walkBack total function (shared with the BackButton). No start-guidance * for the session only — they never persist, never appear in a console
* button, no turn instructions, no progress UI. * line. The only console lines are ARAHARAH-ROUTE / ARAHARAH-GUIDE state
* * tokens (a state signal, never coordinates — D-15/T-05-13; RS8).
* D-07/D-12 memory law: the origin is the live fix, held in THIS closure
* for the session only — it never persists, never appears in a console
* line. The only console lines in web/src/route/ are the two ARAHARAH-ROUTE
* state tokens (a state signal, never coordinates — D-15/T-05-13; RS8).
* *
* All DOM/map concerns are INJECTED so the RS gates run in Node: card * All DOM/map concerns are INJECTED so the RS gates run in Node: card
* builders, the register shower, the follow controller, and the endpoint * builders, the register shower, the follow controller, the watch factory,
* marker attach/remove are deps — the module itself touches no DOM and no * the guidance hosts, and the endpoint marker attach/remove are deps — the
* maplibre value import. * module itself touches no DOM and no maplibre value import.
*/ */
import type { GazetteerEntry } from "@araharah/search/src/types.ts"; import type { GazetteerEntry } from "@araharah/search/src/types.ts";
import type { RouteOption, RoutePoint, RouteResult, RoutingSource } from "@araharah/routing/src/index.ts"; import type { RouteOption, RoutePoint, RouteResult, RoutingSource } from "@araharah/routing/src/index.ts";
import type { Fix } from "../location/types.ts"; import type { Fix } from "../location/types.ts";
import type { Locale } from "../i18n/strings.ts"; import type { Locale } from "../i18n/strings.ts";
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
import type { FollowOptions } from "../map/follow.ts";
import type { MapLike } from "./route-layers.ts"; import type { MapLike } from "./route-layers.ts";
import { clearRouteLayers, setRouteDrawn } from "./route-layers.ts"; import { clearRouteLayers, setRouteDrawn } from "./route-layers.ts";
import type { buildRouteCard, buildRoutePendingCard } from "./route-card.ts"; import type { buildRouteCard, buildRoutePendingCard } from "./route-card.ts";
import { createGuidanceCamera } from "../guidance/camera.ts";
import { engageCloseProtection, releaseCloseProtection } from "../guidance/close-protection.ts";
import { fireHaptic } from "../guidance/haptics.ts";
import {
buildConfirmCard,
buildGuidanceBanner,
buildGuidanceCard,
buildManeuverList,
buildTripEndCard,
startGantryBlink,
stopGantryBlink,
} from "../guidance/banner.ts";
import { composeEtaClock, updateEta, type EtaState } from "../guidance/eta.ts";
import {
createGuidanceSession,
guidanceView,
projectAlongRoute,
stepGuidance,
type GuidanceEffect,
type GuidanceRouteModel,
type GuidanceSnapshot,
} from "../guidance/guidance.ts";
import type { GuidanceWatch } from "../guidance/watch.ts";
/** fitBounds padding over the route geometry — map-space px (MapLibre /** fitBounds padding over the route geometry — map-space px (MapLibre
* padding, not CSS). The camera constants sit BESIDE follow.ts's. */ * padding, not CSS). The camera constants sit BESIDE follow.ts's. */
@@ -52,20 +90,24 @@ export const ROUTE_CAMERA_PITCH = 35;
export type RouteState = "idle" | "route" | "pending"; export type RouteState = "idle" | "route" | "pending";
/** The guidance phase — the BackButton resolver's second axis (PX-01). */
export type GuidancePhase = "none" | "driving" | "confirm" | "ended";
export interface RouteControllerDeps { export interface RouteControllerDeps {
map: MapLike; map: MapLike;
/** The RoutingSource seam — the fixture backend (backend #1) at the /** The RoutingSource seam — the composed backend ladder (engine-a →
* import site; a server backend slots in later without client rework. */ * fixture, 06-06 task 3) at the import site; reroutes ride it too. */
source: RoutingSource; source: RoutingSource;
locale: Locale; locale: Locale;
buildRouteCard: typeof buildRouteCard; buildRouteCard: typeof buildRouteCard;
buildPendingCard: typeof buildRoutePendingCard; buildPendingCard: typeof buildRoutePendingCard;
/** Shows the route/pending card in the route stack's register. */ /** Shows the route/pending/guidance card in the route stack's register. */
showRouteCard: (card: HTMLElement | null, register: "center" | "bottom") => void; showRouteCard: (card: HTMLElement | null, register: "center" | "bottom") => void;
/** Restores the Phase 3 stack's destination card (the walk-back target). */ /** Restores the Phase 3 stack's destination card (the walk-back target). */
showDestinationCard: () => void; showDestinationCard: () => void;
/** The existing follow controller — the walk-back's camera return. */ /** The existing follow controller — the walk-back's camera return and
follow: { follow(fix: Fix): void }; * the guidance pose's executor (camera.ts rides it). */
follow: { follow(point: Fix | { lat: number; lng: number }, opts?: FollowOptions): void };
getLatestFix: () => Fix | null; getLatestFix: () => Fix | null;
/** Endpoint markers (origin ring + destination pin) — injected DOM concern. */ /** Endpoint markers (origin ring + destination pin) — injected DOM concern. */
attachEndpoints: (firstPoint: RoutePoint, lastPoint: RoutePoint, destinationName: string) => void; attachEndpoints: (firstPoint: RoutePoint, lastPoint: RoutePoint, destinationName: string) => void;
@@ -73,17 +115,62 @@ export interface RouteControllerDeps {
/** Fired on every state transition — main.ts derives the BackButton /** Fired on every state transition — main.ts derives the BackButton
* predicate (visible exactly when a pushed state exists) from it. */ * predicate (visible exactly when a pushed state exists) from it. */
onStateChange: (state: RouteState) => void; onStateChange: (state: RouteState) => void;
/* — guidance concerns (06-06; injected, the RS-gates-run-in-Node law).
* Optional in the type so a cards-only boot (no watch factory) degrades
* to exactly the Phase 5 controller. — */
wa?: TelegramWebAppLike | null;
/** The element receiving [data-guidance="active"] (the shell root). */
shellRoot?: HTMLElement;
/** The banner's mount (the top register band). */
bannerHost?: HTMLElement;
/** The maneuver list's overlay mount. */
listHost?: HTMLElement;
/** The guidance watch factory — the D-15 gated stream. Absent = no pump. */
startWatch?: (onFix: (fix: Fix) => void) => GuidanceWatch;
/** Injected clock (deterministic tests; production Date.now). */
nowMs?: () => number;
/** Finds the mounted banner's gantry element (a DOM-search concern). */
findGantry?: (banner: HTMLElement) => HTMLElement | null;
/** Fired on guidance phase transitions — main.ts re-syncs the BackButton. */
onGuidanceChange?: () => void;
}
/** One live trip: the model guidance drives on, the machine's snapshot,
* the honest-ETA cell, and the cockpit's own flags. */
interface GuidanceRun {
model: GuidanceRouteModel;
snap: GuidanceSnapshot;
durationS: number;
eta: EtaState | null;
listOpen: boolean;
confirmOpen: boolean;
active: boolean;
ended: boolean;
startedAtSec: number;
/** The last accepted fix's snapped foot (the camera rides THIS point). */
lastSnapped: RoutePoint;
} }
export function createRouteController(deps: RouteControllerDeps): { export function createRouteController(deps: RouteControllerDeps): {
readonly state: RouteState; readonly state: RouteState;
readonly guidancePhase: GuidancePhase;
consult(fix: Fix, destination: GazetteerEntry): Promise<void>; consult(fix: Fix, destination: GazetteerEntry): Promise<void>;
walkBack(): void; walkBack(): void;
openConfirm(): void;
continueGuidance(): void;
} { } {
let state: RouteState = "idle"; let state: RouteState = "idle";
let options: RouteOption[] = []; let options: RouteOption[] = [];
let selectedIndex = 0; let selectedIndex = 0;
let destinationName = ""; let destinationName = "";
let destinationId = "";
let guidance: GuidanceRun | null = null;
let watch: GuidanceWatch | null = null;
let blinkEl: HTMLElement | null = null;
let lastLoggedGuideState: string | null = null;
const now = (): number => (deps.nowMs !== undefined ? deps.nowMs() : Date.now());
const camera = createGuidanceCamera(null, deps.follow);
function setState(next: RouteState): void { function setState(next: RouteState): void {
state = next; state = next;
@@ -94,6 +181,7 @@ export function createRouteController(deps: RouteControllerDeps): {
* module stays trigger-agnostic). Auto: live fix + resolved destination. */ * module stays trigger-agnostic). Auto: live fix + resolved destination. */
async function consult(fix: Fix, destination: GazetteerEntry): Promise<void> { async function consult(fix: Fix, destination: GazetteerEntry): Promise<void> {
destinationName = destination.name; destinationName = destination.name;
destinationId = destination.id;
const result: RouteResult | null = await deps.source const result: RouteResult | null = await deps.source
.route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id }) .route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id })
.catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line .catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line
@@ -107,9 +195,286 @@ export function createRouteController(deps: RouteControllerDeps): {
selectedIndex = 0; selectedIndex = 0;
drawSelection(); drawSelection();
setState("route"); setState("route");
ensureWatch(); // the pump arms with the route — guidance can never start without one
console.log("ARAHARAH-ROUTE state=drawn"); console.log("ARAHARAH-ROUTE state=drawn");
} }
/* ————— the guidance pump (06-06) ————— */
function selectedModel(): GuidanceRouteModel | null {
const option = options[selectedIndex];
if (option === undefined) return null;
return {
points: option.geometry.points,
cumulative: option.geometry.cumulativeDistanceM,
steps: option.steps,
destinationId,
destinationName,
};
}
function selectedDurationS(): number {
const option = options[selectedIndex];
return option !== undefined ? option.summary.durationS : 0;
}
function ensureWatch(): void {
if (watch !== null || deps.startWatch === undefined) return;
watch = deps.startWatch(onWatchFix);
}
function stopWatch(): void {
if (watch === null) return;
watch.stop();
watch = null;
}
/** The console law: ARAHARAH-GUIDE state=<state> tokens, deduped — a
* state signal, never coordinates (RS8; T-06-04). */
function logGuideState(next: string): void {
if (next === lastLoggedGuideState) return;
lastLoggedGuideState = next;
console.log("ARAHARAH-GUIDE state=" + next);
}
/** The D-07 honest-ETA fold: numbers from the route model + the snapped
* projection only (never a raw fix-to-destination distance). */
function foldEta(g: GuidanceRun): void {
const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0;
const remainingS = g.durationS > 0 ? g.durationS * (g.snap.remainingM / (totalM > 0 ? totalM : 1)) : 0;
g.eta = updateEta(g.eta, remainingS, g.snap.remainingM);
}
/** Every accepted fix lands here (the watch already throttled it). The
* first fix after the route draw opens the session; the machine's start
* gate decides when the cockpit mounts. */
function onWatchFix(fix: Fix): void {
if (state !== "route") return; // PX-03's precondition: never without a route
if (guidance === null) {
const model = selectedModel();
if (model === null) return;
const snap = createGuidanceSession(model, fix, now());
guidance = {
model,
snap,
durationS: selectedDurationS(),
eta: null,
listOpen: false,
confirmOpen: false,
active: false,
ended: false,
startedAtSec: Math.floor(now() / 1000),
lastSnapped: projectAlongRoute(model.points, model.cumulative, fix, snap.fromIdx).snapped,
};
}
const g = guidance;
if (g.ended) return;
const proj = projectAlongRoute(g.model.points, g.model.cumulative, fix, g.snap.fromIdx);
g.lastSnapped = proj.snapped;
const stepped = stepGuidance(g.snap, { type: "fix", fix }, now());
g.snap = stepped.snap;
if (!g.active && g.snap.state !== "idle") activateGuidance();
if (!g.active || g.ended) return; // idle — the start gate has not opened
foldEta(g);
dispatchEffects(stepped.effects);
if (!g.ended) {
logGuideState(g.snap.state);
renderCockpit();
}
}
function activateGuidance(): void {
const g = guidance;
if (g === null || g.active) return;
g.active = true;
engageCloseProtection(deps.wa ?? null);
if (deps.shellRoot !== undefined) deps.shellRoot.dataset.guidance = "active";
foldEta(g);
camera.easeToSnapped(g.lastSnapped); // the locked-follow pose over the SNAPPED line
renderCockpit();
logGuideState(g.snap.state);
if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange();
}
function stopBlink(): void {
if (blinkEl !== null) {
stopGantryBlink(blinkEl);
blinkEl = null;
}
}
/** The cockpit re-render: banner (with the gantry swap law — the outgoing
* element's blink stops before the replace), the collapsed-by-default
* list, and the guidance card in the bottom register. */
function renderCockpit(): void {
const g = guidance;
if (g === null || !g.active || g.ended) return;
const view = guidanceView(g.snap, g.listOpen);
stopBlink();
const banner = buildGuidanceBanner(deps.locale, view.banner, { onToggle: toggleList });
if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren(banner);
if ((g.snap.state === "rerouting" || g.snap.state === "resuming") && deps.findGantry !== undefined) {
const gantry = deps.findGantry(banner);
if (gantry !== null) {
startGantryBlink(gantry);
blinkEl = gantry;
}
}
if (deps.listHost !== undefined) {
if (g.listOpen) {
deps.listHost.replaceChildren(buildManeuverList(deps.locale, view.rows, { onDismiss: collapseList }));
} else {
deps.listHost.replaceChildren();
}
}
const eta = g.eta ?? updateEta(null, 0, g.snap.remainingM);
deps.showRouteCard(
buildGuidanceCard(
deps.locale,
{
...view.card,
minutesRemaining: eta.displayedEtaS / 60,
kmRemaining: eta.displayedRemainingM / 1000,
etaClock: composeEtaClock(eta.displayedEtaS, now()),
},
{ onEndTrip: openConfirm },
),
"bottom",
);
}
function toggleList(): void {
const g = guidance;
if (g === null || g.ended) return;
g.listOpen = !g.listOpen;
renderCockpit();
}
function collapseList(): void {
const g = guidance;
if (g === null || !g.listOpen) return;
g.listOpen = false;
renderCockpit();
}
function dispatchEffects(effects: readonly GuidanceEffect[]): void {
for (const effect of effects) {
if (effect.type === "haptic") fireHaptic(deps.wa ?? null, effect.cue);
else if (effect.type === "reroute-requested") void requestReroute(effect.origin);
else if (effect.type === "arrival") teardownGuidance("arrival");
}
}
/** The silent self-heal (GUID-02): one honest origin, the composed ladder,
* and either a snap-in (setData, NO camera jump) or the calm NO REROUTE
* degradation. The banner never blames. */
async function requestReroute(origin: RoutePoint): Promise<void> {
const g = guidance;
if (g === null || g.ended) return;
g.snap = stepGuidance(g.snap, { type: "reroute-started" }, now()).snap;
renderCockpit(); // REROUTING: register swap + dash + gantry start
logGuideState(g.snap.state);
let result: RouteResult | null = null;
try {
result = await deps.source.route({ origin, destinationId: g.model.destinationId });
} catch {
result = null; // fail-honest (T-05-04, network edition)
}
if (guidance !== g || g.ended) return; // the trip ended mid-request — drop it
if (result === null || result.options.length === 0) {
g.snap = stepGuidance(g.snap, { type: "reroute-failed" }, now()).snap;
logGuideState(g.snap.state);
renderCockpit();
return;
}
const option = result.options[0];
if (option === undefined) return;
const model: GuidanceRouteModel = {
points: option.geometry.points,
cumulative: option.geometry.cumulativeDistanceM,
steps: option.steps,
destinationId: result.destinationId,
destinationName,
};
g.durationS = option.summary.durationS;
g.model = model;
options = [option]; // the post-abandon route state carries the new line
selectedIndex = 0;
g.snap = stepGuidance(g.snap, { type: "reroute-applied", model }, now()).snap;
setRouteDrawn(deps.map, option.geometry, []); // the snap-in — the camera holds (UI-SPEC)
const first = option.geometry.points[0];
const last = option.geometry.points[option.geometry.points.length - 1];
if (first !== undefined && last !== undefined) deps.attachEndpoints(first, last, destinationName);
logGuideState(g.snap.state);
foldEta(g);
renderCockpit();
}
/** BOTH trip ends land here (D-14): the watch stops, the close protection
* releases, [data-guidance] clears (the strip returns), and the trip-end
* card takes the center register. */
function teardownGuidance(reason: "arrival" | "abandon"): void {
const g = guidance;
if (g === null || g.ended) return;
g.ended = true;
g.confirmOpen = false;
stopWatch();
releaseCloseProtection(deps.wa ?? null);
if (deps.shellRoot !== undefined) delete deps.shellRoot.dataset.guidance;
stopBlink();
if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren();
if (deps.listHost !== undefined) deps.listHost.replaceChildren();
deps.showRouteCard(buildTripEndCard(deps.locale), "center");
logGuideState("ended");
if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange();
}
/** END TRIP and the BackButton-from-guidance land here (D-09): the ONE
* confirm surface; YES, ABANDON tears down, CONTINUE dismisses. */
function openConfirm(): void {
const g = guidance;
if (g === null || !g.active || g.ended) return;
g.confirmOpen = true;
deps.showRouteCard(
buildConfirmCard(
deps.locale,
{ onAbandon: () => teardownGuidance("abandon"), onContinue: () => continueGuidance() },
),
"bottom",
);
if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange();
}
function continueGuidance(): void {
const g = guidance;
if (g === null) return;
g.confirmOpen = false;
renderCockpit(); // the guidance card returns to the bottom register
if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange();
}
function guidancePhase(): GuidancePhase {
const g = guidance;
if (g === null || !g.active) return "none";
if (g.ended) return "ended";
return g.confirmOpen ? "confirm" : "driving";
}
/** Guidance remnants stop here — the latent watch included. Never runs
* while the confirm flow owns the exit (walkBack refuses first). */
function stopGuidanceArtifacts(): void {
stopWatch();
if (guidance === null) return;
releaseCloseProtection(deps.wa ?? null);
if (deps.shellRoot !== undefined) delete deps.shellRoot.dataset.guidance;
stopBlink();
if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren();
if (deps.listHost !== undefined) deps.listHost.replaceChildren();
guidance = null;
lastLoggedGuideState = null;
if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange();
}
/** Chip tap: swap geometries between the selected/alt sources, re-build /** Chip tap: swap geometries between the selected/alt sources, re-build
* the card (new register/telemetry/difference/chips), refit the NEW * the card (new register/telemetry/difference/chips), refit the NEW
* geometry's bounds — ALWAYS refits (D-10). Instant swaps, no easing. */ * geometry's bounds — ALWAYS refits (D-10). Instant swaps, no easing. */
@@ -170,11 +535,16 @@ export function createRouteController(deps: RouteControllerDeps): {
* here. route: clear layers + endpoints, restore the destination card, * here. route: clear layers + endpoints, restore the destination card,
* camera refollows the user. pending: restore the destination card (no * camera refollows the user. pending: restore the destination card (no
* layers exist). Both: state → idle, onStateChange fires (BackButton * layers exist). Both: state → idle, onStateChange fires (BackButton
* hides). Never a dead end. */ * hides). While guidance OWNS the exit (driving/confirm) this refuses —
* the D-09 confirm card is the only door. Never a dead end. */
function walkBack(): void { function walkBack(): void {
const phase = guidancePhase();
if (phase === "driving" || phase === "confirm") return; // PX-03: the confirm owns every guidance exit
stopGuidanceArtifacts(); // the ended phase's remnants (or a latent watch) stop here
if (state === "route") { if (state === "route") {
clearRouteLayers(deps.map); clearRouteLayers(deps.map);
deps.removeEndpoints(); deps.removeEndpoints();
deps.showRouteCard(null, "center"); // the route stack vacates — the destination card replaces it
deps.showDestinationCard(); deps.showDestinationCard();
const fix = deps.getLatestFix(); const fix = deps.getLatestFix();
if (fix !== null) deps.follow.follow(fix); if (fix !== null) deps.follow.follow(fix);
@@ -190,7 +560,12 @@ export function createRouteController(deps: RouteControllerDeps): {
get state(): RouteState { get state(): RouteState {
return state; return state;
}, },
get guidancePhase(): GuidancePhase {
return guidancePhase();
},
consult, consult,
walkBack, walkBack,
openConfirm,
continueGuidance,
}; };
} }
+59 -18
View File
@@ -55,6 +55,9 @@ const {
createRouteController, createRouteController,
} = await import("../src/route/route.ts"); } = await import("../src/route/route.ts");
const { wireBackButton } = await import("../src/telegram/backbutton.ts"); const { wireBackButton } = await import("../src/telegram/backbutton.ts");
const { createFollowController } = await import("../src/map/follow.ts");
/* 06-06 note: the guidance-camera module is imported INSIDE its tests (the
RED gate must fail per-test on the absent module, never break suite load). */
/* ————— shared helpers ————— */ /* ————— shared helpers ————— */
@@ -963,6 +966,7 @@ function steppedDeps(opts = {}) {
now: 1_000_000, now: 1_000_000,
guidanceChanges: 0, guidanceChanges: 0,
sourceCalls: [], sourceCalls: [],
followModes: [],
}; };
// every source.route call is recorded (the exactly-one-request gates) // every source.route call is recorded (the exactly-one-request gates)
const baseRoute = deps.source.route.bind(deps.source); const baseRoute = deps.source.route.bind(deps.source);
@@ -970,6 +974,16 @@ function steppedDeps(opts = {}) {
guide.sourceCalls.push(req); guide.sourceCalls.push(req);
return baseRoute(req); return baseRoute(req);
}; };
// the follow dep rides the REAL follow controller over the fake map so the
// camera pose asserts see map.recorded.flyTo (the camera.ts integration)
const realFollow = createFollowController(deps.__map, (m) => guide.followModes.push(m));
const recordedFollow = deps.follow.follow.bind(deps.follow);
deps.follow = {
follow(point, opts) {
recordedFollow(point);
realFollow.follow(point, opts);
},
};
deps.wa = guide.wa; deps.wa = guide.wa;
deps.shellRoot = guide.shellRoot; deps.shellRoot = guide.shellRoot;
deps.bannerHost = guide.bannerHost; deps.bannerHost = guide.bannerHost;
@@ -1021,6 +1035,14 @@ function guideDistance(deps) {
return distance === null ? null : distance.textContent; return distance === null ? null : distance.textContent;
} }
/** Collected leaf text of a fake element tree (parents never accumulate). */
function textOf(el) {
if (el === null || el === undefined) return "";
let out = typeof el.textContent === "string" ? el.textContent : "";
for (const child of el.children ?? []) out += textOf(child);
return out;
}
/** The standard live-driving setup: route drawn, guidance activated at /** The standard live-driving setup: route drawn, guidance activated at
* 500 m along, then an accepted fix at 700 m (6 s later). */ * 500 m along, then an accepted fix at 700 m (6 s later). */
async function driveTo(deps, controller) { async function driveTo(deps, controller) {
@@ -1086,7 +1108,8 @@ test("G2 pump updates — banner distance/street from the model, ETA card, stepp
assert.ok(/\d/.test(findByClass(telemetry, "text-telemetry-num").textContent), "minutes remaining renders"); assert.ok(/\d/.test(findByClass(telemetry, "text-telemetry-num").textContent), "minutes remaining renders");
const progress = findByClass(card, "guide-progress"); const progress = findByClass(card, "guide-progress");
assert.equal(progress.attributes["aria-valuenow"], "35", "progress pct = alongM/totalM (700/2000)"); assert.equal(progress.attributes["aria-valuenow"], "35", "progress pct = alongM/totalM (700/2000)");
assert.equal(findByClass(card, "guide-progress-fill").style.width, "35%", "instant width per accepted fix"); const widthPct = parseFloat(findByClass(card, "guide-progress-fill").style.width);
assert.ok(Math.abs(widthPct - 35) <= 1, `instant width per accepted fix (got ${widthPct}%)`);
assert.ok(findByClass(card, "btn-secondary") !== null, "END TRIP is the one card action"); assert.ok(findByClass(card, "btn-secondary") !== null, "END TRIP is the one card action");
}); });
}); });
@@ -1096,13 +1119,14 @@ test("G3 OFF_ROUTE grace — the banner shows NO premature signal while the mach
const deps = steppedDeps(); const deps = steppedDeps();
const controller = createRouteController(deps); const controller = createRouteController(deps);
await driveTo(deps, controller); await driveTo(deps, controller);
const callsBefore = deps.__guide.sourceCalls.length; // the consult's call
// two far-off fixes 1 s apart — below the 3-fix + 8 s gate: the machine // two far-off fixes 1 s apart — below the 3-fix + 8 s gate: the machine
// has not acted, so the banner must not either // has not acted, so the banner must not either
feed(750, { deps, excursion: true, advanceMs: 1000 }); feed(750, { deps, excursion: true, advanceMs: 1000 });
feed(800, { deps, excursion: true, advanceMs: 1000 }); feed(800, { deps, excursion: true, advanceMs: 1000 });
assert.equal(guideRegister(deps), "GUIDE // NEXT", "no premature signal — the register holds"); assert.equal(guideRegister(deps), "GUIDE // NEXT", "no premature signal — the register holds");
assert.equal(findByClass(guideBanner(deps), "guide-street").textContent, "Jalan Dua", "the street slot holds — no calm copy"); assert.equal(findByClass(guideBanner(deps), "guide-street").textContent, "Jalan Dua", "the street slot holds — no calm copy");
assert.equal(deps.__guide.sourceCalls.length, 0, "zero source.route calls during the grace"); assert.equal(deps.__guide.sourceCalls.length, callsBefore, "zero reroute calls during the grace");
}); });
}); });
@@ -1111,14 +1135,25 @@ test("G4 REROUTING — the calm swap + dash + gantry; exactly ONE source.route c
const deps = steppedDeps(); const deps = steppedDeps();
const controller = createRouteController(deps); const controller = createRouteController(deps);
await driveTo(deps, controller); await driveTo(deps, controller);
// the excursion: 3 consecutive off fixes spanning the 8 s grace // the reroute request HANGS — REROUTING persists for the calm-state asserts.
const e1 = feed(750, { deps, excursion: true }); // Installed AFTER the consult: every call through THIS wrapper is a reroute.
const recordedRoute = deps.source.route.bind(deps.source);
const rerouteRequests = [];
deps.source.route = (req) => {
rerouteRequests.push(req);
void recordedRoute;
return new Promise(() => {});
};
// the excursion: 3 consecutive off fixes spanning the 8 s grace — the
// request fires on the fix that completes the grace (the honest CURRENT
// position, D-04)
feed(750, { deps, excursion: true });
feed(800, { deps, excursion: true, advanceMs: 9000 }); feed(800, { deps, excursion: true, advanceMs: 9000 });
feed(850, { deps, excursion: true, advanceMs: 500 }); const e3 = feed(850, { deps, excursion: true, advanceMs: 500 });
await drain(); await drain();
assert.equal(deps.__guide.sourceCalls.length, 1, "exactly ONE reroute request"); assert.equal(rerouteRequests.length, 1, "exactly ONE reroute request");
assert.equal(deps.__guide.sourceCalls[0].destinationId, DEST.id, "the request carries the destination"); assert.equal(rerouteRequests[0].destinationId, DEST.id, "the request carries the destination");
assert.equal(deps.__guide.sourceCalls[0].origin.lat, e1.lat, "the origin is the honest raw excursion fix"); assert.equal(rerouteRequests[0].origin.lat, e3.lat, "the origin is the honest raw fix that completed the grace");
assert.equal(guideRegister(deps), "GUIDE // REROUTING", "the calm register swap"); assert.equal(guideRegister(deps), "GUIDE // REROUTING", "the calm register swap");
assert.equal(guideDistance(deps), "—", "distance is the honest dash"); assert.equal(guideDistance(deps), "—", "distance is the honest dash");
const gantry = findByClass(guideBanner(deps), "guide-gantry"); const gantry = findByClass(guideBanner(deps), "guide-gantry");
@@ -1131,20 +1166,25 @@ test("G4 REROUTING — the calm swap + dash + gantry; exactly ONE source.route c
// an immediate second excursion inside the storm guard: silent // an immediate second excursion inside the storm guard: silent
feed(900, { deps, excursion: true, advanceMs: 2000 }); feed(900, { deps, excursion: true, advanceMs: 2000 });
await drain(); await drain();
assert.equal(deps.__guide.sourceCalls.length, 1, "the storm guard holds — still exactly one call"); assert.equal(rerouteRequests.length, 1, "the storm guard holds — still exactly one call");
assert.equal(guideRegister(deps), "GUIDE // REROUTING", "no state flap — banner calm"); assert.equal(guideRegister(deps), "GUIDE // REROUTING", "no state flap — banner calm");
// the test ends here with the blink intentionally running — stop it so
// the interval never leaks into the runner's event loop (GS5's law)
const { stopGantryBlink } = await import("../src/guidance/banner.ts");
const running = findByClass(guideBanner(deps), "guide-gantry");
if (running !== null) stopGantryBlink(running);
}); });
}); });
test("G5 pending precondition + reroute failure — guidance never without a route; NO REROUTE is fail-honest", async () => { test("G5 pending precondition + reroute failure — guidance never without a route; NO REROUTE is fail-honest", async () => {
await withDocumentAsync(async () => { await withDocumentAsync(async () => {
// (a) a null consult result = pending — the pump must never start guidance // (a) a null consult result = pending — NO watch arms, guidance can never start
const deps = steppedDeps({ result: null }); const deps = steppedDeps({ result: null });
const controller = createRouteController(deps); const controller = createRouteController(deps);
await withConsole(() => controller.consult(FIX, DEST)); await withConsole(() => controller.consult(FIX, DEST));
assert.equal(controller.state, "pending", "null result = pending"); assert.equal(controller.state, "pending", "null result = pending");
feed(500, { deps }); assert.equal(deps.__guide.watchStarted, 0, "no guidance watch over a pending state — never without a route");
assert.equal(guideBanner(deps), null, "no guidance over a pending state — never without a route"); assert.equal(guideBanner(deps), null, "no guidance chrome over a pending state");
assert.equal(deps.__guide.wa.swipes.length, 0, "no PX-03 protections without a route"); assert.equal(deps.__guide.wa.swipes.length, 0, "no PX-03 protections without a route");
// (b) a mid-drive reroute failure lands NO REROUTE, calm // (b) a mid-drive reroute failure lands NO REROUTE, calm
const deps2 = steppedDeps(); const deps2 = steppedDeps();
@@ -1188,7 +1228,8 @@ test("G6 reroute success — the new line setData snaps in, NO camera jump, the
geometry: { points: [req.origin, P2], cumulativeDistanceM: [0, 1800] }, geometry: { points: [req.origin, P2], cumulativeDistanceM: [0, 1800] },
summary: { distanceM: 1800, durationS: 200 }, summary: { distanceM: 1800, durationS: 200 },
steps: [ steps: [
{ kind: "depart", distanceM: 1800, name: "Jalan Baru" }, { kind: "depart", distanceM: 500 },
{ kind: "right", distanceM: 1300, name: "Jalan Baru" },
{ kind: "arrive", distanceM: 0 }, { kind: "arrive", distanceM: 0 },
], ],
}, },
@@ -1200,9 +1241,9 @@ test("G6 reroute success — the new line setData snaps in, NO camera jump, the
await driveTo(deps, controller); await driveTo(deps, controller);
const flyBefore = deps.__map.recorded.flyTo.length; const flyBefore = deps.__map.recorded.flyTo.length;
const setDataBefore = deps.__map.recorded.setData.length; const setDataBefore = deps.__map.recorded.setData.length;
const e1 = feed(750, { deps, excursion: true }); feed(750, { deps, excursion: true });
feed(800, { deps, excursion: true, advanceMs: 9000 }); feed(800, { deps, excursion: true, advanceMs: 9000 });
feed(850, { deps, excursion: true, advanceMs: 500 }); const e3 = feed(850, { deps, excursion: true, advanceMs: 500 });
await drain(); await drain();
assert.equal(guideRegister(deps), "GUIDE // NEXT", "back on the new route"); assert.equal(guideRegister(deps), "GUIDE // NEXT", "back on the new route");
const swaps = deps.__map.recorded.setData.slice(setDataBefore); const swaps = deps.__map.recorded.setData.slice(setDataBefore);
@@ -1210,8 +1251,8 @@ test("G6 reroute success — the new line setData snaps in, NO camera jump, the
const routeSwap = swaps.find((s) => s.id === "route"); const routeSwap = swaps.find((s) => s.id === "route");
assert.deepEqual( assert.deepEqual(
routeSwap.data.features[0].geometry.coordinates[0], routeSwap.data.features[0].geometry.coordinates[0],
[e1.lng, e1.lat], [e3.lng, e3.lat],
"the new line starts at the excursion origin", "the new line starts at the honest origin (the fix that completed the grace)",
); );
assert.equal(deps.__map.recorded.flyTo.length, flyBefore, "NO camera jump on reroute (setData only)"); assert.equal(deps.__map.recorded.flyTo.length, flyBefore, "NO camera jump on reroute (setData only)");
assert.equal(findByClass(guideBanner(deps), "guide-gantry"), null, "the gantry stopped on resolve"); assert.equal(findByClass(guideBanner(deps), "guide-gantry"), null, "the gantry stopped on resolve");
@@ -1246,7 +1287,7 @@ test("G7 END TRIP → confirm → YES ABANDON teardown; CONTINUE dismisses with
assert.equal(findByClass(deps.__guide.bannerHost, "guide-banner"), null, "the banner cleared instantly"); assert.equal(findByClass(deps.__guide.bannerHost, "guide-banner"), null, "the banner cleared instantly");
const ended = deps.__recorded.shown.at(-1); const ended = deps.__recorded.shown.at(-1);
assert.equal(ended.register, "center", "the trip-ended card holds the center register"); assert.equal(ended.register, "center", "the trip-ended card holds the center register");
assert.ok(ended.card.textContent.includes("END"), "the GUIDE // END register renders"); assert.ok(textOf(ended.card).includes("END"), "the GUIDE // END register renders");
assert.ok(abandoned.lines.some((l) => l.includes("ARAHARAH-GUIDE state=ended")), "state=ended logged"); assert.ok(abandoned.lines.some((l) => l.includes("ARAHARAH-GUIDE state=ended")), "state=ended logged");
assert.equal(controller.guidancePhase, "ended", "the BackButton graph's ended state is exposed"); assert.equal(controller.guidancePhase, "ended", "the BackButton graph's ended state is exposed");
}); });