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:
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user