feat(06-06): envelope flows + composed ladder — minimize POST, resume GET, trip-end DELETE (GREEN)

- route.ts: onDeactivated POSTs the FROZEN six-field snapshot with
  keepalive (the minimize race's belt) and stores the server-minted id;
  onActivated DELETEs (the cron pauses — structural) and RETAINS the id;
  teardown's deleteEnvelope completes the idempotent pair; a live
  envelope is never double-minted; best-effort honesty throughout (D-13)
- resume(trip): the controller computes the projection from
  startedAtSec + avg speed (the cron's own math, one formula), renders
  GUIDE // RESUMING + gantry + projected card, draws the rebuilt line,
  arms the watch, DELETEs immediately (a resumed boot is foregrounded),
  and seeds the session mid-route on the first fix through the machine's
  own resuming path — synthesized terminal marks keep the arrive mark at
  the line's END (a steps-less envelope model would instantly arrive)
- backAction(): the PX-01 resolver's single authority — driving →
  confirm, confirm → CONTINUE, ended/route → the walk, root → close
- guidance.ts: resumingView keeps the progress math in guidance/ (RS6)
- main.ts: the COMPOSED LADDER (engine-a first, fixture fallback —
  code-constructed, zero env reads), the ?g= UUID-validated reader with
  the defensive envelope validation (fail-honest → honest boot), the
  capability-guarded activated/deactivated wiring, the recenter
  re-lock at the guidance pose, the chain-fix camera skip while driving,
  and the no-double-consult resume guard
- merged suite: G9-G12 + the E-family (co-executor tests reconciled:
  pre-gate probe, builder-format asserts, terminal-mark fixture)
  — 146 passed, typecheck clean
This commit is contained in:
araharah-executor
2026-10-24 15:31:51 +08:00
parent 6448e31a29
commit d5e104b2e9
4 changed files with 560 additions and 42 deletions
+14 -2
View File
@@ -482,8 +482,7 @@ export interface GuidanceViewData {
rows: GuidanceListRow[]; rows: GuidanceListRow[];
} }
export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): GuidanceViewData { export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): GuidanceViewData { const { model } = snap;
const { model } = snap;
const marks = maneuverMarksOf(model); const marks = maneuverMarksOf(model);
const next = marks[snap.nextManeuver] ?? null; const next = marks[snap.nextManeuver] ?? null;
@@ -552,3 +551,16 @@ export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): Guidanc
rows, rows,
}; };
} }
/** The RESUMING register's card view (06-06): the projected progress math
* composed HERE — the guidance-implementation boundary (RS6) keeps every
* progress rendering out of route/, which only spreads the result into
* the builders. Pure numbers in, the card's view fields out. */
export function resumingView(
destinationName: string,
totalM: number,
projectedRemainingM: number,
): GuidanceCardData {
const pct = totalM > 0 ? Math.min(100, Math.max(0, ((totalM - projectedRemainingM) / totalM) * 100)) : 0;
return { destinationName, progressPct: pct, showNoReroute: false };
}
+169 -26
View File
@@ -44,11 +44,20 @@ import { installMockWebApp } from "./mock/telegram-mock.ts";
import { readDestinationId, resolveDestination, setDestination } from "./search/resolve-destination.ts"; import { readDestinationId, resolveDestination, setDestination } from "./search/resolve-destination.ts";
import { buildDestinationCard, buildDestinationUnavailableCard } from "./search/destination-card.ts"; import { buildDestinationCard, buildDestinationUnavailableCard } from "./search/destination-card.ts";
import { wireHomeScreen } from "./telegram/homescreen.ts"; import { wireHomeScreen } from "./telegram/homescreen.ts";
import { createFixtureRoutingSource } from "@araharah/routing/src/index.ts"; import {
createFixtureRoutingSource,
createHttpRoutingSource,
decodePolyline6,
deriveCumulativeDistanceM,
type RoutingSource,
} from "@araharah/routing/src/index.ts";
import { createRouteController, type RouteState } from "./route/route.ts"; import { createRouteController, type RouteState } from "./route/route.ts";
import { buildRouteCard, buildRoutePendingCard } from "./route/route-card.ts"; import { buildRouteCard, buildRoutePendingCard } from "./route/route-card.ts";
import { attachRouteEndpoints, removeRouteEndpoints } from "./route/route-markers.ts"; import { attachRouteEndpoints, removeRouteEndpoints } from "./route/route-markers.ts";
import { installRouteLayers, type MapLike } from "./route/route-layers.ts"; import { installRouteLayers, type MapLike } from "./route/route-layers.ts";
import { GUIDANCE_CAMERA_PADDING, GUIDANCE_CAMERA_PITCH, GUIDANCE_ZOOM, GUIDANCE_EASE_MS } from "./guidance/camera.ts";
import type { BackAction } from "./telegram/backbutton.ts";
import { createGuidanceWatch } from "./guidance/watch.ts";
declare global { declare global {
interface Window { interface Window {
@@ -64,13 +73,13 @@ async function boot(): Promise<void> {
// 1 — safe-area vars are set before any layout reads them (PX-03 groundwork). // 1 — safe-area vars are set before any layout reads them (PX-03 groundwork).
applySafeArea(resolveSafeInsets(getWebApp())); applySafeArea(resolveSafeInsets(getWebApp()));
// 3 (declared ahead of its first use inside the BackButton closure) — the // 3 (declared ahead of their first use inside the closures below) — the
// single-shell predicate for the no-dead-ends wire, now carrying the // single-shell state the BackButton resolver and the stale branch read:
// route phase: the BackButton is visible exactly when a PUSHED state // the route phase, the walk total function, the controller itself, and
// exists (route or pending). Both closures are assigned for real once // the wire's re-apply handle (assigned once the wire exists).
// the route controller exists (below) — before that, idle.
let routePhase: () => RouteState = () => "idle"; let routePhase: () => RouteState = () => "idle";
let routeWalkBack = (): void => {}; let routeController: ReturnType<typeof createRouteController> | null = null;
let backSync: (() => void) | null = null;
// 12 — the locale comes from the RAW initData string (the session POST // 12 — the locale comes from the RAW initData string (the session POST
// returns only {token} — the claim's language_code is not reachable // returns only {token} — the claim's language_code is not reachable
@@ -80,13 +89,20 @@ async function boot(): Promise<void> {
const wa = getWebApp(); const wa = getWebApp();
const locale: Locale = wa ? localeFromInitData(wa.initData) : "en"; const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";
// 2 — Telegram bindings: theme slot switch + BackButton total wire. // 2 — Telegram bindings: theme slot switch + the BackButton's full PX-01
// The predicate + walkBack ship as ONE change (UI-SPEC): the pushed- // graph. The resolver is CONTEXT-AWARE: the controller owns the walk
// state click walks to the root via the SAME total function SEARCH // decision (guidance → confirm; confirm → CONTINUE; ended → the
// AGAIN invokes — never close() from a pushed state (T-05-11). // search-root walk; route → the Phase 5 walk; root → close), the wire
// only applies it — per click AND per state change (wire.sync()).
if (wa) { if (wa) {
watchTheme(wa); watchTheme(wa);
wireBackButton(wa, () => routePhase() !== "idle", () => routeWalkBack()); const backResolve = (): BackAction => {
const controller = routeController;
if (controller !== null) return controller.backAction();
return { kind: "hidden" }; // pre-boot — nothing pushed yet
};
const wire = wireBackButton(wa, () => backResolve());
backSync = wire.sync;
} }
// 3 — the map register mounts FIRST (D-08/D-09 anti-blank invariant). // 3 — the map register mounts FIRST (D-08/D-09 anti-blank invariant).
@@ -149,6 +165,13 @@ async function boot(): Promise<void> {
routeStack.style.display = "none"; routeStack.style.display = "none";
mainEl.appendChild(routeStack); mainEl.appendChild(routeStack);
// 06-06 — the guidance chrome's mounts: the banner band (the top register
// occupant) and the maneuver list's overlay layer (collapsed default).
const bannerHost = document.createElement("div");
mainEl.appendChild(bannerHost);
const listHost = document.createElement("div");
mainEl.appendChild(listHost);
// 12 — the strip consumes the locale derived above the bindings block. // 12 — the strip consumes the locale derived above the bindings block.
strip.setLocale(locale); strip.setLocale(locale);
@@ -164,6 +187,14 @@ async function boot(): Promise<void> {
const destPayload = readDestinationId(window.location.search, wa?.initData ?? null); const destPayload = readDestinationId(window.location.search, wa?.initData ?? null);
const destination = resolveDestination(destPayload.id); const destination = resolveDestination(destPayload.id);
setDestination(destination); setDestination(destination);
// 13 — the ?g= resume carrier (GUID-05/06): a killed webview re-opens
// straight into its live trip. The id is an opaque UUID validated
// BEFORE any GET (V5 posture); a failed/foreign GET is an honest
// destination-less boot — never a fake route.
const resumeId = (() => {
const raw = new URLSearchParams(window.location.search).get("g");
return raw !== null && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw) ? raw : null;
})();
// The destination surface — the confirmation card when the id resolved, the // The destination surface — the confirmation card when the id resolved, the
// honest unavailable card when an id ARRIVED and resolved to nothing, and // honest unavailable card when an id ARRIVED and resolved to nothing, and
// null when no id arrived at all: the Phase 3 boot is then byte-identical // null when no id arrived at all: the Phase 3 boot is then byte-identical
@@ -189,7 +220,20 @@ async function boot(): Promise<void> {
console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent")); console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent"));
const recenter = createRecenterControl(mainEl, locale, () => { const recenter = createRecenterControl(mainEl, locale, () => {
if (lastFix !== null && map !== null) follow?.follow(lastFix); if (lastFix !== null && map !== null) {
if (routeController !== null && routeController.guidancePhase === "driving") {
// re-lock at the GUIDANCE pose (UI-SPEC camera table) — the Phase 3
// padding would put the puck back under the strip
follow?.follow(lastFix, {
padding: GUIDANCE_CAMERA_PADDING,
pitch: GUIDANCE_CAMERA_PITCH,
zoom: GUIDANCE_ZOOM,
durationMs: GUIDANCE_EASE_MS,
});
} else {
follow?.follow(lastFix);
}
}
}); });
// Cards are built once at boot (boot-time locale) and reused across re-runs. // Cards are built once at boot (boot-time locale) and reused across re-runs.
@@ -362,8 +406,18 @@ async function boot(): Promise<void> {
fitBounds: () => {}, fitBounds: () => {},
getBearing: () => 0, getBearing: () => 0,
}; };
const routingSource = createFixtureRoutingSource(); // Phase 6 — the COMPOSED LADDER (D-01's law, code-constructed — no env
const routeController = createRouteController({ // read, no registry, no config): engine-a answers when the Worker can
// (arbitrary origins, real reroutes); the fixture corpus carries the
// golden-demo path and every honest-null fallback. The bearer getter is
// the memory-held session token — minted after this line is still sent.
const fixtureSource = createFixtureRoutingSource();
const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() });
const routingSource: RoutingSource = {
name: "ladder",
route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)),
};
routeController = createRouteController({
map: routeMap, map: routeMap,
source: routingSource, source: routingSource,
locale, locale,
@@ -371,7 +425,7 @@ async function boot(): Promise<void> {
buildPendingCard: buildRoutePendingCard, buildPendingCard: buildRoutePendingCard,
showRouteCard, showRouteCard,
showDestinationCard: () => showCard(destinationCard), showDestinationCard: () => showCard(destinationCard),
follow: { follow: (fix) => follow?.follow(fix) }, // null-tolerant wrapper — the controller sees a total object follow: { follow: (fix, opts) => follow?.follow(fix, opts) }, // null-tolerant wrapper — the controller sees a total object
getLatestFix: () => lastFix, getLatestFix: () => lastFix,
attachEndpoints: (firstPoint, lastPoint, name) => { attachEndpoints: (firstPoint, lastPoint, name) => {
if (map !== null) attachRouteEndpoints(map, firstPoint, lastPoint, name); if (map !== null) attachRouteEndpoints(map, firstPoint, lastPoint, name);
@@ -381,13 +435,24 @@ async function boot(): Promise<void> {
}, },
onStateChange: (s) => { onStateChange: (s) => {
routePhase = () => s; // the ONE source of truth for the overlay predicate AND the stale branch routePhase = () => s; // the ONE source of truth for the overlay predicate AND the stale branch
if (wa) { if (backSync !== null) backSync(); // visible exactly when a pushed state exists
if (s === "idle") wa.BackButton?.hide();
else wa.BackButton?.show(); // visible exactly when a pushed state exists
}
}, },
// — the guidance concerns (06-06) —
wa,
shellRoot: mainEl, // [data-guidance="active"] compacts the strip through CSS
bannerHost,
listHost,
startWatch: (onFix) => createGuidanceWatch(navigator.geolocation, onFix),
findGantry: (banner) => {
const el = banner.querySelector(".guide-gantry");
return el instanceof HTMLElement ? el : null;
},
onGuidanceChange: () => {
if (backSync !== null) backSync(); // confirm open/close + activation/teardown re-apply the graph
},
fetchImpl: fetch.bind(globalThis),
bearer: getSession,
}); });
routeWalkBack = () => routeController.walkBack(); // the SEARCH AGAIN total function, now real
// 5 — mock install is a fallback, never an override: the capture runner // 5 — mock install is a fallback, never an override: the capture runner
// page installs a profile-configured mock BEFORE this module runs, and // page installs a profile-configured mock BEFORE this module runs, and
@@ -400,6 +465,77 @@ async function boot(): Promise<void> {
// renders the persistent denied card (D-06) and stops — never blank. // renders the persistent denied card (D-06) and stops — never blank.
await runFromSession(); await runFromSession();
// 14 — the envelope lifecycle (D-11's structural foreground gate) + the
// ?g= resume. Both ride Telegram's activated/deactivated events
// behind the capability guard (T-06-06); the minimize POST carries
// keepalive so a frozen webview still hands off (RESEARCH Pitfall 2).
if (wa && typeof wa.onEvent === "function" && routeController !== null) {
const guided = routeController;
wa.onEvent("deactivated", () => void guided.onDeactivated());
wa.onEvent("activated", () => guided.onActivated());
}
if (resumeId !== null && routeController !== null && map !== null) {
void tryResume(resumeId, routeController);
}
/** GET the ?g= envelope and resume: rebuild the model from the frozen
* snapshot (decode + cumulative + destination fields), hand the RAW
* anchors to the controller — it owns the projection. A failed,
* foreign, or malformed GET is an honest boot (Phase 4 flow) — never a
* fake route, never guidance chrome over nothing. */
async function tryResume(id: string, controller: NonNullable<typeof routeController>): Promise<void> {
const token = getSession();
if (token === null) return; // no session yet — the honest boot continues
try {
const res = await fetch(`/api/guidance?id=${id}`, { headers: { authorization: `Bearer ${token}` } });
if (!res.ok) return; // absent/foreign → byte-indistinguishable 404 → the honest boot
const payload: unknown = await res.json();
if (payload === null || typeof payload !== "object") return;
const snap = (payload as { route_snapshot?: unknown }).route_snapshot;
if (snap === null || typeof snap !== "object") return;
const s = snap as {
destinationId?: unknown;
destinationName?: unknown;
distanceM?: unknown;
durationS?: unknown;
geometry?: unknown;
startedAtSec?: unknown;
};
if (
typeof s.destinationId !== "string" ||
typeof s.destinationName !== "string" ||
typeof s.geometry !== "string" ||
typeof s.distanceM !== "number" ||
!Number.isFinite(s.distanceM) ||
typeof s.durationS !== "number" ||
!Number.isFinite(s.durationS) ||
typeof s.startedAtSec !== "number" ||
!Number.isSafeInteger(s.startedAtSec) ||
s.startedAtSec <= 0
) {
return; // a corrupt envelope is no envelope (the defensive-read law)
}
const points = decodePolyline6(s.geometry); // throws on truncation → the catch
if (points.length < 2) return;
await mapLayersReady; // the route sources exist before the line draws
controller.resume({
model: {
points,
cumulative: deriveCumulativeDistanceM(points),
steps: [], // the envelope carries geometry only — the controller synthesizes the terminal marks
destinationId: s.destinationId,
destinationName: s.destinationName,
},
envelopeId: id,
distanceM: s.distanceM,
durationS: s.durationS,
startedAtSec: s.startedAtSec,
});
} catch {
return; // fail-honest — never a fabricated trip
}
}
async function runFromSession(): Promise<void> { async function runFromSession(): Promise<void> {
renderState({ kind: "validating" }); renderState({ kind: "validating" });
const session = await createSession(wa?.initData ?? ""); const session = await createSession(wa?.initData ?? "");
@@ -447,19 +583,26 @@ async function boot(): Promise<void> {
stopGantry(gantryOf(bootCard2)); stopGantry(gantryOf(bootCard2));
if (map !== null) { if (map !== null) {
marker.update(map, fix); marker.update(map, fix);
if (routeController !== null && routeController.guidancePhase === "driving") {
// the guidance camera owns the viewport during a live trip — the
// chain's fix refreshes marker/strip; the camera settles on the
// next throttled ease (never a pose fight)
} else {
follow?.follow(fix); follow?.follow(fix);
} }
}
marker.setStale(false); marker.setStale(false);
recenter.setFixState("fresh"); recenter.setFixState("fresh");
renderState({ kind: "live", fix }); renderState({ kind: "live", fix });
// Auto-consult (the state-coexistence law): the fixture RoutingSource is // Auto-consult (the state-coexistence law): the RoutingSource ladder is
// consulted ONCE per boot, only when the location chain is live AND a // consulted ONCE per boot, only when the location chain is live AND a
// destination resolved — destination-unavailable and no-destination // destination resolved — destination-unavailable and no-destination
// boots never make a route request. The consult itself waits for the // boots never make a route request. A ?g= resume boot NEVER consults
// map's load (route sources installed) — see mapLayersReady above. // (the envelope's route IS the trip); a failed resume stays honest
if (destination !== null && !routeConsulted) { // (no destination drawn). The consult waits for the map's load.
if (destination !== null && !routeConsulted && resumeId === null) {
routeConsulted = true; routeConsulted = true;
void mapLayersReady.then(() => routeController.consult(fix, destination)); void mapLayersReady.then(() => routeController?.consult(fix, destination));
} }
if (!tickStarted) startTick(); if (!tickStarted) startTick();
// emitted UNCONDITIONALLY (outside the map guard above): a WebGL2-absent // emitted UNCONDITIONALLY (outside the map guard above): a WebGL2-absent
+223 -8
View File
@@ -53,6 +53,7 @@ import type { RouteOption, RoutePoint, RouteResult, RoutingSource } from "@araha
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 { TelegramWebAppLike } from "../telegram/webapp.ts";
import type { BackAction } from "../telegram/backbutton.ts";
import type { FollowOptions } from "../map/follow.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";
@@ -70,10 +71,12 @@ import {
stopGantryBlink, stopGantryBlink,
} from "../guidance/banner.ts"; } from "../guidance/banner.ts";
import { composeEtaClock, updateEta, type EtaState } from "../guidance/eta.ts"; import { composeEtaClock, updateEta, type EtaState } from "../guidance/eta.ts";
import { encodePolyline6 } from "@araharah/routing/src/index.ts";
import { import {
createGuidanceSession, createGuidanceSession,
guidanceView, guidanceView,
projectAlongRoute, projectAlongRoute,
resumingView,
stepGuidance, stepGuidance,
type GuidanceEffect, type GuidanceEffect,
type GuidanceRouteModel, type GuidanceRouteModel,
@@ -133,13 +136,31 @@ export interface RouteControllerDeps {
findGantry?: (banner: HTMLElement) => HTMLElement | null; findGantry?: (banner: HTMLElement) => HTMLElement | null;
/** Fired on guidance phase transitions — main.ts re-syncs the BackButton. */ /** Fired on guidance phase transitions — main.ts re-syncs the BackButton. */
onGuidanceChange?: () => void; onGuidanceChange?: () => void;
/** Injectable fetch (the envelope POST/DELETE — tests pass a stub). */
fetchImpl?: typeof fetch;
/** The memory-held session token getter (the Bearer header). */
bearer?: () => string | null;
}
/** One resumed trip (?g= boot): the rebuilt model + the envelope's own
* anchors. The controller projects the displayed numbers from
* startedAtSec + avg speed — the cron's projection math, one formula
* across surfaces (D-11); main.ts passes the envelope fields verbatim. */
export interface ResumedTrip {
model: GuidanceRouteModel;
envelopeId: string;
distanceM: number;
durationS: number;
startedAtSec: number;
} }
/** One live trip: the model guidance drives on, the machine's snapshot, /** One live trip: the model guidance drives on, the machine's snapshot,
* the honest-ETA cell, and the cockpit's own flags. */ * the honest-ETA cell, and the cockpit's own flags. */
interface GuidanceRun { interface GuidanceRun {
model: GuidanceRouteModel; model: GuidanceRouteModel;
snap: GuidanceSnapshot; /** Null only while a RESUMED trip shows its projection (before the
* first fix seeds the session mid-trip). */
snap: GuidanceSnapshot | null;
durationS: number; durationS: number;
eta: EtaState | null; eta: EtaState | null;
listOpen: boolean; listOpen: boolean;
@@ -158,6 +179,15 @@ export function createRouteController(deps: RouteControllerDeps): {
walkBack(): void; walkBack(): void;
openConfirm(): void; openConfirm(): void;
continueGuidance(): void; continueGuidance(): void;
/** D-11's write-at-minimize: the frozen envelope POST (keepalive). */
onDeactivated(): Promise<void>;
/** The cron pauses while foregrounded — the structural DELETE. */
onActivated(): void;
/** The ?g= boot's entry: RESUMING chrome over the rebuilt model. */
resume(trip: ResumedTrip): void;
/** The BackButton resolver's whole vocabulary — main.ts's wire reads
* THIS per click (the context-aware PX-01 graph, one authority). */
backAction(): BackAction;
} { } {
let state: RouteState = "idle"; let state: RouteState = "idle";
let options: RouteOption[] = []; let options: RouteOption[] = [];
@@ -168,6 +198,9 @@ export function createRouteController(deps: RouteControllerDeps): {
let watch: GuidanceWatch | null = null; let watch: GuidanceWatch | null = null;
let blinkEl: HTMLElement | null = null; let blinkEl: HTMLElement | null = null;
let lastLoggedGuideState: string | null = null; let lastLoggedGuideState: string | null = null;
/** The live guidance envelope's id — minted at minimize (or inherited
* from the ?g= boot), consumed idempotently at trip end. */
let envelopeId: string | null = null;
const now = (): number => (deps.nowMs !== undefined ? deps.nowMs() : Date.now()); const now = (): number => (deps.nowMs !== undefined ? deps.nowMs() : Date.now());
const camera = createGuidanceCamera(null, deps.follow); const camera = createGuidanceCamera(null, deps.follow);
@@ -240,6 +273,7 @@ export function createRouteController(deps: RouteControllerDeps): {
/** The D-07 honest-ETA fold: numbers from the route model + the snapped /** The D-07 honest-ETA fold: numbers from the route model + the snapped
* projection only (never a raw fix-to-destination distance). */ * projection only (never a raw fix-to-destination distance). */
function foldEta(g: GuidanceRun): void { function foldEta(g: GuidanceRun): void {
if (g.snap === null) return;
const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0; 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; const remainingS = g.durationS > 0 ? g.durationS * (g.snap.remainingM / (totalM > 0 ? totalM : 1)) : 0;
g.eta = updateEta(g.eta, remainingS, g.snap.remainingM); g.eta = updateEta(g.eta, remainingS, g.snap.remainingM);
@@ -268,10 +302,20 @@ export function createRouteController(deps: RouteControllerDeps): {
}; };
} }
const g = guidance; const g = guidance;
if (g.ended) return; if (g === null || g.ended) return;
const proj = projectAlongRoute(g.model.points, g.model.cumulative, fix, g.snap.fromIdx); if (g.snap === null) {
// a RESUMED trip seeds its session mid-route here: the machine's own
// resuming register confirms on_route on the first in-threshold fix —
// never a re-run of the start gate on a trip that began long ago
const snap = createGuidanceSession(g.model, fix, now());
const seeded: GuidanceSnapshot = { ...snap, state: "resuming" };
g.snap = seeded;
}
const snapNow = g.snap;
if (snapNow === null) return;
const proj = projectAlongRoute(g.model.points, g.model.cumulative, fix, snapNow.fromIdx);
g.lastSnapped = proj.snapped; g.lastSnapped = proj.snapped;
const stepped = stepGuidance(g.snap, { type: "fix", fix }, now()); const stepped = stepGuidance(snapNow, { type: "fix", fix }, now());
g.snap = stepped.snap; g.snap = stepped.snap;
if (!g.active && g.snap.state !== "idle") activateGuidance(); if (!g.active && g.snap.state !== "idle") activateGuidance();
if (!g.active || g.ended) return; // idle — the start gate has not opened if (!g.active || g.ended) return; // idle — the start gate has not opened
@@ -292,7 +336,7 @@ export function createRouteController(deps: RouteControllerDeps): {
foldEta(g); foldEta(g);
camera.easeToSnapped(g.lastSnapped); // the locked-follow pose over the SNAPPED line camera.easeToSnapped(g.lastSnapped); // the locked-follow pose over the SNAPPED line
renderCockpit(); renderCockpit();
logGuideState(g.snap.state); if (g.snap !== null) logGuideState(g.snap.state);
if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange();
} }
@@ -308,7 +352,7 @@ export function createRouteController(deps: RouteControllerDeps): {
* list, and the guidance card in the bottom register. */ * list, and the guidance card in the bottom register. */
function renderCockpit(): void { function renderCockpit(): void {
const g = guidance; const g = guidance;
if (g === null || !g.active || g.ended) return; if (g === null || !g.active || g.ended || g.snap === null) return;
const view = guidanceView(g.snap, g.listOpen); const view = guidanceView(g.snap, g.listOpen);
stopBlink(); stopBlink();
const banner = buildGuidanceBanner(deps.locale, view.banner, { onToggle: toggleList }); const banner = buildGuidanceBanner(deps.locale, view.banner, { onToggle: toggleList });
@@ -370,7 +414,7 @@ export function createRouteController(deps: RouteControllerDeps): {
* degradation. The banner never blames. */ * degradation. The banner never blames. */
async function requestReroute(origin: RoutePoint): Promise<void> { async function requestReroute(origin: RoutePoint): Promise<void> {
const g = guidance; const g = guidance;
if (g === null || g.ended) return; if (g === null || g.ended || g.snap === null) return;
g.snap = stepGuidance(g.snap, { type: "reroute-started" }, now()).snap; g.snap = stepGuidance(g.snap, { type: "reroute-started" }, now()).snap;
renderCockpit(); // REROUTING: register swap + dash + gantry start renderCockpit(); // REROUTING: register swap + dash + gantry start
logGuideState(g.snap.state); logGuideState(g.snap.state);
@@ -411,7 +455,8 @@ export function createRouteController(deps: RouteControllerDeps): {
} }
/** BOTH trip ends land here (D-14): the watch stops, the close protection /** BOTH trip ends land here (D-14): the watch stops, the close protection
* releases, [data-guidance] clears (the strip returns), and the trip-end * releases, [data-guidance] clears (the strip returns), the envelope is
* DELETEd (the TTL law's promise, executed actively), and the trip-end
* card takes the center register. */ * card takes the center register. */
function teardownGuidance(reason: "arrival" | "abandon"): void { function teardownGuidance(reason: "arrival" | "abandon"): void {
const g = guidance; const g = guidance;
@@ -419,6 +464,7 @@ export function createRouteController(deps: RouteControllerDeps): {
g.ended = true; g.ended = true;
g.confirmOpen = false; g.confirmOpen = false;
stopWatch(); stopWatch();
deleteEnvelope();
releaseCloseProtection(deps.wa ?? null); releaseCloseProtection(deps.wa ?? null);
if (deps.shellRoot !== undefined) delete deps.shellRoot.dataset.guidance; if (deps.shellRoot !== undefined) delete deps.shellRoot.dataset.guidance;
stopBlink(); stopBlink();
@@ -460,6 +506,171 @@ export function createRouteController(deps: RouteControllerDeps): {
return g.confirmOpen ? "confirm" : "driving"; return g.confirmOpen ? "confirm" : "driving";
} }
/* ————— the envelope lifecycle (06-06 task 3: D-11/D-13/D-14) ————— */
const authHeaders = (): Record<string, string> => {
const token = deps.bearer !== undefined ? deps.bearer() : null;
return token !== null ? { authorization: `Bearer ${token}` } : {};
};
/** D-11's write-at-minimize: the FROZEN six-field snapshot POSTed with
* keepalive (the minimize race's belt — Pitfall 2). The id/chat_id/
* created_at are SERVER-minted; a lost race still TTL-expires (the D-13
* best-effort honesty). A live envelope is never double-minted. */
async function onDeactivated(): Promise<void> {
const g = guidance;
if (g === null || !g.active || g.ended) return;
if (envelopeId !== null) return;
if (deps.fetchImpl === undefined) return;
const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0;
const snapshot = {
destinationId: g.model.destinationId,
destinationName: g.model.destinationName,
distanceM: totalM,
durationS: g.durationS,
geometry: encodePolyline6(g.model.points),
startedAtSec: g.startedAtSec,
};
try {
const res = await deps.fetchImpl("/api/guidance", {
method: "POST",
headers: { "content-type": "application/json", ...authHeaders() },
body: JSON.stringify(snapshot),
keepalive: true,
});
if (!res.ok) return; // best-effort honesty — the TTL still bounds the record
const payload: unknown = await res.json();
const id = typeof payload === "object" && payload !== null ? (payload as { id?: unknown }).id : undefined;
if (typeof id === "string") envelopeId = id;
} catch {
return; // the minimize race lost — honest, bounded by the TTL
}
}
/** The cron pauses while the app is foregrounded — structural (D-11).
* The id is RETAINED: the trip end deletes the SAME envelope. */
function onActivated(): void {
const id = envelopeId;
if (id === null || deps.fetchImpl === undefined) return;
void deps.fetchImpl(`/api/guidance?id=${id}`, { method: "DELETE", headers: authHeaders() });
}
/** The trip-end active sweep (D-14's client half). 404-tolerant by the
* server's contract; the id is consumed so a second call is a no-op. */
function deleteEnvelope(): void {
const id = envelopeId;
envelopeId = null;
if (id === null || deps.fetchImpl === undefined) return;
void deps.fetchImpl(`/api/guidance?id=${id}`, { method: "DELETE", headers: authHeaders() });
}
/** The ?g= boot's entry (GUID-05/06): the rebuilt model draws, RESUMING
* chrome shows the projected numbers, the watch arms, and the first
* accepted fix flips the register live through the machine's own
* resuming path. The envelope id is inherited; the resume DELETE fires
* NOW (a resumed boot is foregrounded — the cron pauses, D-11). */
function resume(trip: ResumedTrip): void {
if (guidance !== null) return; // one trip per boot
const points = trip.model.points;
// The envelope carries geometry only — synthesize the terminal marks
// when the rebuilt model has no steps, so the machine's arrive mark
// sits at the END of the line (a steps-less model would put it at 0:
// any in-threshold fix would instantly "arrive")
const totalM = trip.model.cumulative[trip.model.cumulative.length - 1] ?? 0;
const model: GuidanceRouteModel =
trip.model.steps.length > 0
? trip.model
: {
...trip.model,
steps: [
{ kind: "depart", distanceM: totalM },
{ kind: "arrive", distanceM: 0 },
],
};
guidance = {
model,
snap: null,
durationS: trip.durationS,
eta: null,
listOpen: false,
confirmOpen: false,
active: true, // the RESUMING chrome is guidance surface — the confirm owns the exit
ended: false,
startedAtSec: trip.startedAtSec,
lastSnapped: points[0] ?? { lat: 0, lng: 0 },
};
envelopeId = trip.envelopeId;
engageCloseProtection(deps.wa ?? null);
if (deps.shellRoot !== undefined) deps.shellRoot.dataset.guidance = "active";
setRouteDrawn(deps.map, { points: trip.model.points }, []);
const first = points[0];
const last = points[points.length - 1];
if (first !== undefined && last !== undefined) deps.attachEndpoints(first, last, trip.model.destinationName);
const avgSpeedMs = trip.durationS > 0 ? trip.distanceM / trip.durationS : 0;
const elapsedSec = Math.max(0, Math.floor(now() / 1000) - trip.startedAtSec);
const projectedRemainingM =
trip.distanceM > 0
? trip.distanceM * Math.max(0, 1 - Math.min(1, (elapsedSec * avgSpeedMs) / trip.distanceM))
: 0;
renderResuming(projectedRemainingM);
onActivated(); // the cron pauses NOW — structural, not an event
ensureWatch();
logGuideState("resuming");
setState("route");
if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange();
}
/** The BackButton resolver's whole vocabulary (PX-01): driving → the D-09
* confirm; confirm open → CONTINUE; ended → the walk to the search root;
* a pushed route → the Phase 5 walk; the true root → Telegram's close. */
function backAction(): BackAction {
const phase = guidancePhase();
if (phase === "driving") return { kind: "callback", fn: openConfirm };
if (phase === "confirm") return { kind: "callback", fn: continueGuidance };
if (phase === "ended") return { kind: "callback", fn: walkBack };
if (state !== "idle") return { kind: "callback", fn: walkBack };
return { kind: "close" }; // the true root — Telegram's own semantics
}
/** The RESUMING register: projected banner + gantry + card numbers from
* startedAtSec + avg speed — never a fabricated position (D-13). */
function renderResuming(projectedRemainingM: number): void {
const g = guidance;
if (g === null) return;
stopBlink();
const banner = buildGuidanceBanner(
deps.locale,
{ state: "resuming", glyph: "straight", distanceM: projectedRemainingM, street: null, listOpen: false },
{ onToggle: toggleList },
);
if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren(banner);
if (deps.findGantry !== undefined) {
const gantry = deps.findGantry(banner);
if (gantry !== null) {
startGantryBlink(gantry);
blinkEl = gantry;
}
}
const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0;
const avgSpeedMs = g.durationS > 0 ? totalM / g.durationS : 0;
const remainingS = avgSpeedMs > 0 ? projectedRemainingM / avgSpeedMs : 0;
g.eta = updateEta(null, remainingS, projectedRemainingM);
const eta = g.eta;
deps.showRouteCard(
buildGuidanceCard(
deps.locale,
{
...resumingView(g.model.destinationName, totalM, projectedRemainingM),
minutesRemaining: eta.displayedEtaS / 60,
kmRemaining: eta.displayedRemainingM / 1000,
etaClock: composeEtaClock(eta.displayedEtaS, now()),
},
{ onEndTrip: openConfirm },
),
"bottom",
);
}
/** Guidance remnants stop here — the latent watch included. Never runs /** Guidance remnants stop here — the latent watch included. Never runs
* while the confirm flow owns the exit (walkBack refuses first). */ * while the confirm flow owns the exit (walkBack refuses first). */
function stopGuidanceArtifacts(): void { function stopGuidanceArtifacts(): void {
@@ -567,5 +778,9 @@ export function createRouteController(deps: RouteControllerDeps): {
walkBack, walkBack,
openConfirm, openConfirm,
continueGuidance, continueGuidance,
onDeactivated,
onActivated,
resume,
backAction,
}; };
} }
+152 -4
View File
@@ -1426,7 +1426,7 @@ test("G11 ?g= resume — RESUMING chrome with projected numbers; the first fix f
destinationName: DEST.name, destinationName: DEST.name,
}; };
const resumed = await withConsole(async () => { const resumed = await withConsole(async () => {
controller.resume({ model, durationS: 240, projectedRemainingM: 1400, startedAtSec: 900, envelopeId: "env-9" }); controller.resume({ model, envelopeId: "env-9", distanceM: 2000, durationS: 240, startedAtSec: 900 });
return null; return null;
}); });
assert.equal(controller.state, "route", "the resumed trip holds the route state"); assert.equal(controller.state, "route", "the resumed trip holds the route state");
@@ -1434,12 +1434,19 @@ test("G11 ?g= resume — RESUMING chrome with projected numbers; the first fix f
assert.equal(deps.__guide.shellRoot.dataset.guidance, "active", "the strip compacts for the resumed trip"); assert.equal(deps.__guide.shellRoot.dataset.guidance, "active", "the strip compacts for the resumed trip");
assert.equal(guideRegister(deps), "GUIDE // RESUMING", "the projected register"); assert.equal(guideRegister(deps), "GUIDE // RESUMING", "the projected register");
assert.ok(findByClass(guideBanner(deps), "guide-gantry") !== null, "the gantry rides the wait state"); assert.ok(findByClass(guideBanner(deps), "guide-gantry") !== null, "the gantry rides the wait state");
assert.equal(guideDistance(deps), "1.4", "the projected remaining distance renders (RESUMING legally shows numbers)"); assert.equal(guideDistance(deps), "1.2", "the controller-computed projection renders (≈1167 m of 2000)");
assert.equal(deps.__guide.watchStarted, 1, "the watch arms for the resumed trip"); assert.equal(deps.__guide.watchStarted, 1, "the watch arms for the resumed trip");
const routeSwap = deps.__map.recorded.setData.find((s) => s.id === "route"); const routeSwap = deps.__map.recorded.setData.find((s) => s.id === "route");
assert.equal(routeSwap.data.features[0].geometry.coordinates.length, 3, "the resumed line draws"); assert.equal(routeSwap.data.features[0].geometry.coordinates.length, 3, "the resumed line draws");
assert.ok(resumed.lines.some((l) => l.includes("ARAHARAH-GUIDE state=resuming")), "state=resuming logged"); assert.ok(resumed.lines.some((l) => l.includes("ARAHARAH-GUIDE state=resuming")), "state=resuming logged");
// the first accepted fix flips to live ON_ROUTE (the machine's own path) // the resume DELETE pauses the cron immediately (a resumed boot is foregrounded)
assert.equal(
deps.__guide.fetches.filter((f) => f.init.method === "DELETE").length,
1,
"the resume DELETE fired with the ?g= id",
);
// the first accepted fix flips to live ON_ROUTE (the machine's own path) —
// the synthesized terminal marks keep the arrive mark at the line's end
const live = await withConsole(async () => { const live = await withConsole(async () => {
feed(600, { deps }); feed(600, { deps });
return null; return null;
@@ -1447,13 +1454,19 @@ test("G11 ?g= resume — RESUMING chrome with projected numbers; the first fix f
assert.equal(guideRegister(deps), "GUIDE // NEXT", "live ON_ROUTE after the first fix"); assert.equal(guideRegister(deps), "GUIDE // NEXT", "live ON_ROUTE after the first fix");
assert.equal(findByClass(guideBanner(deps), "guide-gantry"), null, "the gantry stopped on the live flip"); assert.equal(findByClass(guideBanner(deps), "guide-gantry"), null, "the gantry stopped on the live flip");
assert.ok(live.lines.some((l) => l.includes("ARAHARAH-GUIDE state=on_route")), "state=on_route logged"); assert.ok(live.lines.some((l) => l.includes("ARAHARAH-GUIDE state=on_route")), "state=on_route logged");
// the resumed trip's envelope deletes at teardown (the ?g= id) // the resumed trip's envelope deletes at teardown (the ?g= id, idempotent pair)
findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP
await withConsole(async () => { await withConsole(async () => {
findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON
return null; return null;
}); });
await drain();
assert.equal(controller.guidancePhase, "ended", "the resumed trip ends through the same confirm"); assert.equal(controller.guidancePhase, "ended", "the resumed trip ends through the same confirm");
assert.equal(
deps.__guide.fetches.filter((f) => f.init.method === "DELETE").length,
2,
"the teardown DELETE completes the idempotent pair",
);
}); });
}); });
@@ -1464,6 +1477,7 @@ test("G12 main.ts composition — the ladder, the ?g= carrier, the envelope even
assert.ok(!mainCode.includes("process.env"), "no env reads — the backend selection is code-constructed (T-05-04)"); assert.ok(!mainCode.includes("process.env"), "no env reads — the backend selection is code-constructed (T-05-04)");
assert.ok(/get\("g"\)/.test(mainCode), "the ?g= carrier is read from the URL (the destination-preload pattern)"); assert.ok(/get\("g"\)/.test(mainCode), "the ?g= carrier is read from the URL (the destination-preload pattern)");
assert.ok( assert.ok(
mainCode.includes("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") ||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.test(mainCode), /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.test(mainCode),
"the resume id is UUID-validated before any GET", "the resume id is UUID-validated before any GET",
); );
@@ -1477,3 +1491,137 @@ test("G12 main.ts composition — the ladder, the ?g= carrier, the envelope even
assert.ok(/resumeId === null/.test(mainCode), "a resume boot never double-consults"); assert.ok(/resumeId === null/.test(mainCode), "a resume boot never double-consults");
assert.ok(/kind: "callback"/.test(mainCode) || /kind: "hidden"/.test(mainCode), "the BackButton resolver walks the full graph"); assert.ok(/kind: "callback"/.test(mainCode) || /kind: "hidden"/.test(mainCode), "the BackButton resolver walks the full graph");
}); });
/* ============================================================
* 06-06 task 3 — the complementary envelope/resume gates (E1..E5)
* ============================================================
* G9-G12 pin the core flows; these pin the deltas they do not:
* E1 the pre-gate guard — fixes below the start gate POST nothing
* E3' the COMPUTED projection input (distanceM + startedAtSec) renders
* the same RESUMING chrome, and the resume DELETE pauses the cron
* E4 a 404 second delete is tolerated — fail-honest, chrome completes
* E5 backAction — the controller's resolver walks driving → confirm →
* ended → root with zero dead ends (the wire's contract, task 1)
* E6 main.ts structural deltas — no env reads in the ladder region,
* the onEvent capability guard, the resolver delegation
*/
const RESUME_ENVELOPE_ID = "0b501b1e-0000-4000-8000-00000000e1e5";
test("E1 pre-gate guard — fixes below the start gate never mint an envelope", async () => {
await withDocumentAsync(async () => {
const deps = steppedDeps();
const controller = createRouteController(deps);
await withConsole(() => controller.consult(FIX, DEST));
feed(10, { deps }); // below GUIDANCE_START_ALONG_M — guidance never activates
await controller.onDeactivated();
assert.equal(deps.__guide.fetches.length, 0, "no envelope before guidance is live");
feed(500, { deps }); // activation
await controller.onDeactivated();
assert.equal(deps.__guide.fetches.length, 1, "the live drive POSTs on minimize");
});
});
test("E3' computed projection — resume({distanceM, startedAtSec}) renders the projected RESUMING chrome; the resume DELETE pauses the cron", async () => {
await withDocumentAsync(async () => {
const deps = steppedDeps();
const controller = createRouteController(deps);
controller.resume({
model: {
points: CORRIDOR_POINTS,
cumulative: CORRIDOR_CUMULATIVE,
steps: [],
destinationId: DEST.id,
destinationName: DEST.name,
},
envelopeId: RESUME_ENVELOPE_ID,
distanceM: 2000,
durationS: 240,
startedAtSec: 900, // now() = 1_000_000 ms → 100 s elapsed → remaining ≈ 1167 m / 140 s
});
assert.equal(guideRegister(deps), "GUIDE // RESUMING");
assert.equal(guideDistance(deps), "1.2", "the computed projection renders (1167 m)");
const card = deps.__recorded.shown.at(-1).card;
const minutes = Number(findByClass(findByClass(card, "route-telemetry"), "text-telemetry-num").textContent);
assert.ok(minutes === 2, `projected minutes render rounded (got ${minutes})`); // 140 s
const deletes = deps.__guide.fetches.filter((f) => f.init.method === "DELETE");
assert.equal(deletes.length, 1, "the resume deletes the envelope — the cron pauses NOW (D-11)");
assert.equal(deletes[0].url, `/api/guidance?id=${RESUME_ENVELOPE_ID}`);
const { stopGantryBlink } = await import("../src/guidance/banner.ts");
feed(500, { deps }); // the live flip
const staleGantry = findByClass(guideBanner(deps), "guide-gantry");
if (staleGantry !== null) stopGantryBlink(staleGantry);
assert.equal(guideRegister(deps), "GUIDE // NEXT");
});
});
test("E4 trip end deletes idempotently — a 404 second delete is tolerated, the chrome still completes", async () => {
await withDocumentAsync(async () => {
const deps = steppedDeps();
deps.__guide.fetchMode = "404"; // every DELETE answers 404 not_found
const controller = createRouteController(deps);
controller.resume({
model: {
points: CORRIDOR_POINTS,
cumulative: CORRIDOR_CUMULATIVE,
steps: [
{ kind: "depart", distanceM: 2000 },
{ kind: "arrive", distanceM: 0 },
],
destinationId: DEST.id,
destinationName: DEST.name,
},
envelopeId: RESUME_ENVELOPE_ID,
distanceM: 2000,
durationS: 240,
startedAtSec: 900,
});
feed(500, { deps });
findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP
findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON
await drain();
const deletes = deps.__guide.fetches.filter((f) => f.init.method === "DELETE");
assert.ok(deletes.length >= 2, "resume delete + teardown delete (the idempotent pair)");
assert.equal(controller.guidancePhase, "ended", "the 404 changed nothing — fail-honest");
assert.equal(deps.__guide.shellRoot.dataset.guidance, undefined, "the chrome completed");
assert.equal(findByClass(deps.__guide.bannerHost, "guide-banner"), null, "the banner cleared");
});
});
test("E5 backAction — the resolver walks driving → confirm → ended → root; close only at the root", async () => {
await withDocumentAsync(async () => {
const deps = steppedDeps();
const controller = createRouteController(deps);
await driveTo(deps, controller);
feed(500, { deps });
let action = controller.backAction();
assert.equal(action.kind, "callback", "driving: back means the confirm card");
action.fn();
assert.equal(controller.guidancePhase, "confirm");
action = controller.backAction();
assert.equal(action.kind, "callback", "confirm open: back means CONTINUE");
action.fn();
assert.equal(controller.guidancePhase, "driving", "guidance alive after CONTINUE");
findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP
findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON
action = controller.backAction();
assert.equal(action.kind, "callback", "ended: back walks to the search root");
action.fn();
assert.equal(controller.state, "idle", "the walk landed at the root");
action = controller.backAction();
assert.deepEqual(action, { kind: "close" }, "the true root keeps Telegram's own semantics");
});
});
test("E6 main.ts structural deltas — no env reads in the ladder region, the onEvent capability guard, the resolver delegation", () => {
const mainCode = codeText(path.join(SRC, "main.ts"));
const ladderStart = mainCode.indexOf("createFixtureRoutingSource");
const ladderEnd = mainCode.indexOf("createRouteController(");
assert.ok(ladderStart >= 0 && ladderEnd > ladderStart, "the ladder region exists");
assert.ok(
!mainCode.slice(ladderStart, ladderEnd).includes("import.meta.env"),
"backend selection is code-constructed — no env reads in the ladder region (T-05-04)",
);
assert.ok(/typeof wa\.onEvent === "function"/.test(mainCode), "the onEvent wiring is capability-guarded (T-06-06)");
assert.ok(/wireBackButton\(\s*wa,\s*\(\)\s*=>\s*backResolve\(\)\)/.test(mainCode), "the wire delegates to the controller's resolver");
});