diff --git a/web/test/route-surface.test.mjs b/web/test/route-surface.test.mjs index e4deec1..7fe699d 100644 --- a/web/test/route-surface.test.mjs +++ b/web/test/route-surface.test.mjs @@ -11,13 +11,23 @@ * RS4 controller state machine — drawn / select / walk-back / pending / * exception (fail-honest), camera law, console tokens * RS5 card structure — createElement + textContent law (T-05-10) - * RS6 D-10 absence law — zero guidance furniture - * RS7 BackButton walk — overlay walks, root closes (T-05-11) - * RS8 console discipline — only the two state tokens (D-15/T-05-13) + * RS6 guidance-implementation boundary — progress/maneuver/btn-primary + * tokens only on ../guidance/ import lines or direct call sites + * (re-pinned 06-06: guidance IMPLEMENTATION lives in web/src/guidance/) + * RS7 BackButton walk — overlay walks, root closes (T-05-11) + the 06-06 + * PX-01 full-state-graph walk (search → routes → guidance → confirm → + * routes → search, zero dead ends, close() only at the true root) + * RS8 console discipline — only the ARAHARAH-ROUTE/GUIDE state tokens + * (D-15/T-05-13; re-pinned 06-06 for the guidance token family) * RS9 strings-only copy — the copy lives ONLY in strings.ts (COPY5's * companion check) * RS10 no-storage floor bump — the route family exists under the GEO3 scan * + * 06-06 additions: RS-CAM (the D-10 locked-follow guidance camera — + * constants pinned RS4-style, easeTo args, unlock law, byte-identical + * no-opts follow), RS7's full walk, and the guidance pump / envelope + * flows (G*) that drive the REAL controller through recording fakes. + * * Styles: behavioral cases drive REAL modules through recording fakes * (RS3/RS4/RS7 — Node 24 type-stripping loads the .ts sources; the DOM/ * maplibre concerns are injected deps so no DOM is touched); structural @@ -73,10 +83,13 @@ function listFiles(dir, exts, acc = []) { return acc; } -/** A recording MapLike fake — every interaction is captured for assertion. */ +/** A recording MapLike fake — every interaction is captured for assertion. + * 06-06 extension: flyTo recording (the follow-controller camera seam) and + * an on/emit movestart registry (the originalEvent unlock law, RS12). */ function fakeMap({ bearing = 42 } = {}) { const sources = new Map(); - const recorded = { addSource: [], addLayer: [], setData: [], fitBounds: [] }; + const handlers = {}; + const recorded = { addSource: [], addLayer: [], setData: [], fitBounds: [], flyTo: [] }; return { recorded, addSource(id, data) { @@ -97,9 +110,18 @@ function fakeMap({ bearing = 42 } = {}) { fitBounds(bounds, options) { recorded.fitBounds.push({ bounds, options }); }, + flyTo(options) { + recorded.flyTo.push(options); + }, getBearing() { return bearing; }, + on(event, cb) { + (handlers[event] ??= []).push(cb); + }, + emit(event, e) { + for (const cb of handlers[event] ?? []) cb(e); + }, }; } @@ -566,3 +588,176 @@ test("RS4 camera constants — exported values match the UI-SPEC table exactly", assert.equal(ROUTE_CAMERA_PITCH, 35); assert.deepEqual(ROUTE_LAYER_IDS, ["route-alt-casing", "route-alt-line", "route-casing", "route-line"]); }); + +/* ============================================================ + * 06-06 extensions — the guidance wiring gates + * ============================================================ + * RS7b PX-01 full walk — the context-aware BackButton resolver + * walks search → routes → guidance → (confirm) → routes → search, + * never dead-ends, close() ONLY at the true root + * RS11 guidance camera — constants pinned + the locked-follow pose + * (padding {320,64,64,64}, pitch 35, zoom 16, 400ms, bearing NEVER) + * RS12 camera unlock law — programmatic moves never unlock; a user + * movestart (originalEvent) unlocks → Phase 3 free state returns + * RS13 follow(fix) defaults — byte-identical camera law when called + * without opts (Phase 3/5 call sites keep their exact behavior) + */ + +/** A recording TelegramWebAppLike fake — the RS7 factory, hoisted for the + * 06-06 walk tests. Records show/hide/click/close counts. */ +function mkRecordingWa() { + const wa = { + initData: "", + isVersionAtLeast: () => true, + closeCount: 0, + shown: 0, + hidden: 0, + clicks: [], + close() { + wa.closeCount += 1; + }, + BackButton: { + show() { + wa.shown += 1; + }, + hide() { + wa.hidden += 1; + }, + onClick(cb) { + wa.clicks.push(cb); + }, + offClick() {}, + }, + }; + return wa; +} + +test("RS7b PX-01 full walk — the context-aware resolver walks every state, close() NEVER fires from a pushed state", async () => { + const { wireBackButton } = await import("../src/telegram/backbutton.ts"); + const wa = mkRecordingWa(); + // The app's pushed-state surface — exactly what main.ts's resolver reads + // once 06-06 wires the full graph: root | route | guidance | confirm | ended. + const app = { surface: "root" }; + const walked = []; + let guidanceTornDown = false; + const wire = wireBackButton(wa, () => { + switch (app.surface) { + case "route": + return { kind: "callback", fn: () => walked.push("route-walk") }; + case "guidance": + return { kind: "callback", fn: () => walked.push("open-confirm") }; + case "confirm": + return { kind: "callback", fn: () => walked.push("continue") }; + case "ended": + return { kind: "callback", fn: () => walked.push("ended-walk") }; + default: + return { kind: "hidden" }; + } + }); + assert.equal(typeof wire.sync, "function", "the wire exposes a re-apply (sync) for state changes"); + assert.equal(wa.hidden, 1, "the wire hides at init — boot starts at the root"); + const click = wa.clicks[0]; + assert.equal(wa.clicks.length, 1, "exactly one click handler — the resolver is consulted per click"); + + // root: hidden, nothing pushed + wire.sync(); + assert.equal(wa.hidden, 2, "root stays hidden after sync"); + assert.equal(wa.shown, 0, "nothing shown at the root"); + + // routes: shown; click → Phase 5 walk; never close() + app.surface = "route"; + wire.sync(); + assert.ok(wa.shown >= 1, "route state shows the button"); + click(); + assert.deepEqual(walked, ["route-walk"], "route click walks back (Phase 5 walk)"); + app.surface = "root"; + + // guidance: shown; click → OPENS THE CONFIRM CARD, not teardown (D-09) + app.surface = "guidance"; + wire.sync(); + click(); + assert.deepEqual(walked, ["route-walk", "open-confirm"], "guidance click opens the confirm card"); + assert.equal(guidanceTornDown, false, "guidance is NOT torn down by Back (PX-03: never silently)"); + + // confirm open: click → CONTINUE (dismiss, guidance alive) — no stacked dialogs + app.surface = "confirm"; + wire.sync(); + click(); + assert.deepEqual(walked, ["route-walk", "open-confirm", "continue"], "confirm-state Back means CONTINUE"); + app.surface = "guidance"; + assert.equal(guidanceTornDown, false, "guidance alive after CONTINUE"); + + // YES, ABANDON (the confirm card's own action — 06-05's surface, wired in + // task 2): teardown → chrome clears → the route card state + guidanceTornDown = true; + app.surface = "ended"; + wire.sync(); + click(); // the trip-ended / post-abandon state: click → Phase 5 walk → search root + assert.deepEqual(walked, ["route-walk", "open-confirm", "continue", "ended-walk"], "ended click walks to the search root"); + app.surface = "root"; + wire.sync(); + assert.equal(wa.hidden, 3, "the walk landed at the root — hidden again (the graph's exit ramp)"); + + // THE LAW: close() recorded ZERO times across every pushed-state click. + assert.equal(wa.closeCount, 0, "close() fires only at the true root — never from a pushed state"); +}); + +test("RS7b root close — the resolver's {kind:'close'} keeps Telegram's own back semantics", async () => { + const { wireBackButton } = await import("../src/telegram/backbutton.ts"); + const wa = mkRecordingWa(); + wireBackButton(wa, () => ({ kind: "close" })); + assert.ok(wa.shown >= 1, "an explicit close state SHOWS the button (it is actionable)"); + wa.clicks[0](); + assert.equal(wa.closeCount, 1, "the explicit close path is the root's Telegram semantics"); +}); + +test("RS11 guidance camera — constants pinned at the UI-SPEC pose; bearing NEVER passed; reroute never moves the camera", async () => { + const { createFollowController } = await import("../src/map/follow.ts"); + const cam = await import("../src/guidance/camera.ts"); + const { GUIDANCE_CAMERA_PADDING, GUIDANCE_ZOOM, GUIDANCE_EASE_MS, GUIDANCE_CAMERA_PITCH, createGuidanceCamera } = cam; + assert.deepEqual(GUIDANCE_CAMERA_PADDING, { top: 320, bottom: 64, left: 64, right: 64 }, "GUIDANCE_CAMERA_PADDING (center ≈65% down-screen)"); + assert.equal(GUIDANCE_ZOOM, 16, "GUIDANCE_ZOOM — urban detail"); + assert.equal(GUIDANCE_EASE_MS, 400, "GUIDANCE_EASE_MS — the one easing discipline"); + assert.equal(GUIDANCE_CAMERA_PITCH, 35, "GUIDANCE_CAMERA_PITCH — the standing pitch"); + const map = fakeMap(); + const follow = createFollowController(map, () => {}); + const camera = createGuidanceCamera(map, follow); + camera.easeToSnapped({ lat: 3.11, lng: 101.69 }); + assert.equal(map.recorded.flyTo.length, 1, "the pose rides the follow controller's easing"); + const opts = map.recorded.flyTo[0]; + assert.deepEqual(opts.center, [101.69, 3.11], "centered on the SNAPPED point (never the raw fix)"); + assert.deepEqual(opts.padding, { top: 320, bottom: 64, left: 64, right: 64 }, "the guidance padding"); + assert.equal(opts.pitch, 35, "pitch 35 — the standing pitch, never changed by guidance"); + assert.equal(opts.zoom, 16, "zoom 16"); + assert.equal(opts.duration, 400, "400ms ease"); + assert.ok(!("bearing" in opts), "bearing NEVER passed — north-up is the recorded call"); + const before = map.recorded.flyTo.length; + camera.onRerouteApplied(); + assert.equal(map.recorded.flyTo.length, before, "reroute snap-in: NO camera jump (setData happens in the route layer)"); +}); + +test("RS12 camera unlock law — programmatic moves never unlock; a user movestart unlocks (Phase 3 free state returns)", async () => { + const { createFollowController } = await import("../src/map/follow.ts"); + const map = fakeMap(); + const modes = []; + const follow = createFollowController(map, (m) => modes.push(m)); + follow.follow({ lat: 3.1, lng: 101.68 }); // the per-fix ease is programmatic + map.emit("movestart", {}); // a programmatic move mid-flight (no originalEvent) + assert.deepEqual(modes, ["following"], "a programmatic move NEVER reads as the user panning (T-03-16)"); + map.emit("movestart", { originalEvent: { type: "pointerdown" } }); + assert.deepEqual(modes, ["following", "free"], "a user gesture unlocks → the recenter control's notch clears"); +}); + +test("RS13 follow(fix) without opts — byte-identical camera law for the Phase 3/5 call sites", async () => { + const { createFollowController } = await import("../src/map/follow.ts"); + const map = fakeMap(); + const follow = createFollowController(map, () => {}); + follow.follow({ lat: 3.1, lng: 101.68, accuracyM: 8, headingDeg: null, ts: 1, source: "webview" }); + assert.equal(map.recorded.flyTo.length, 1); + const opts = map.recorded.flyTo[0]; + assert.deepEqual(opts.center, [101.68, 3.1]); + assert.equal(opts.padding, 96, "CAMERA_PADDING_PX unchanged"); + assert.equal(opts.duration, 400, "RECENTER_DURATION_MS unchanged"); + assert.ok(!("pitch" in opts), "no pitch key when opts absent"); + assert.ok(!("zoom" in opts), "no zoom key when opts absent"); +});