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:
@@ -55,6 +55,9 @@ const {
|
||||
createRouteController,
|
||||
} = await import("../src/route/route.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 ————— */
|
||||
|
||||
@@ -963,6 +966,7 @@ function steppedDeps(opts = {}) {
|
||||
now: 1_000_000,
|
||||
guidanceChanges: 0,
|
||||
sourceCalls: [],
|
||||
followModes: [],
|
||||
};
|
||||
// every source.route call is recorded (the exactly-one-request gates)
|
||||
const baseRoute = deps.source.route.bind(deps.source);
|
||||
@@ -970,6 +974,16 @@ function steppedDeps(opts = {}) {
|
||||
guide.sourceCalls.push(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.shellRoot = guide.shellRoot;
|
||||
deps.bannerHost = guide.bannerHost;
|
||||
@@ -1021,6 +1035,14 @@ function guideDistance(deps) {
|
||||
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
|
||||
* 500 m along, then an accepted fix at 700 m (6 s later). */
|
||||
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");
|
||||
const progress = findByClass(card, "guide-progress");
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1096,13 +1119,14 @@ test("G3 OFF_ROUTE grace — the banner shows NO premature signal while the mach
|
||||
const deps = steppedDeps();
|
||||
const controller = createRouteController(deps);
|
||||
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
|
||||
// has not acted, so the banner must not either
|
||||
feed(750, { 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(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 controller = createRouteController(deps);
|
||||
await driveTo(deps, controller);
|
||||
// the excursion: 3 consecutive off fixes spanning the 8 s grace
|
||||
const e1 = feed(750, { deps, excursion: true });
|
||||
// the reroute request HANGS — REROUTING persists for the calm-state asserts.
|
||||
// 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(850, { deps, excursion: true, advanceMs: 500 });
|
||||
const e3 = feed(850, { deps, excursion: true, advanceMs: 500 });
|
||||
await drain();
|
||||
assert.equal(deps.__guide.sourceCalls.length, 1, "exactly ONE reroute request");
|
||||
assert.equal(deps.__guide.sourceCalls[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.length, 1, "exactly ONE reroute request");
|
||||
assert.equal(rerouteRequests[0].destinationId, DEST.id, "the request carries the destination");
|
||||
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(guideDistance(deps), "—", "distance is the honest dash");
|
||||
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
|
||||
feed(900, { deps, excursion: true, advanceMs: 2000 });
|
||||
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");
|
||||
// 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 () => {
|
||||
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 controller = createRouteController(deps);
|
||||
await withConsole(() => controller.consult(FIX, DEST));
|
||||
assert.equal(controller.state, "pending", "null result = pending");
|
||||
feed(500, { deps });
|
||||
assert.equal(guideBanner(deps), null, "no guidance over a pending state — never without a route");
|
||||
assert.equal(deps.__guide.watchStarted, 0, "no guidance watch 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");
|
||||
// (b) a mid-drive reroute failure lands NO REROUTE, calm
|
||||
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] },
|
||||
summary: { distanceM: 1800, durationS: 200 },
|
||||
steps: [
|
||||
{ kind: "depart", distanceM: 1800, name: "Jalan Baru" },
|
||||
{ kind: "depart", distanceM: 500 },
|
||||
{ kind: "right", distanceM: 1300, name: "Jalan Baru" },
|
||||
{ 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);
|
||||
const flyBefore = deps.__map.recorded.flyTo.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(850, { deps, excursion: true, advanceMs: 500 });
|
||||
const e3 = feed(850, { deps, excursion: true, advanceMs: 500 });
|
||||
await drain();
|
||||
assert.equal(guideRegister(deps), "GUIDE // NEXT", "back on the new route");
|
||||
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");
|
||||
assert.deepEqual(
|
||||
routeSwap.data.features[0].geometry.coordinates[0],
|
||||
[e1.lng, e1.lat],
|
||||
"the new line starts at the excursion origin",
|
||||
[e3.lng, e3.lat],
|
||||
"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(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");
|
||||
const ended = deps.__recorded.shown.at(-1);
|
||||
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.equal(controller.guidancePhase, "ended", "the BackButton graph's ended state is exposed");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user