diff --git a/web/test/route-surface.test.mjs b/web/test/route-surface.test.mjs index 5ec5b12..380e4f1 100644 --- a/web/test/route-surface.test.mjs +++ b/web/test/route-surface.test.mjs @@ -967,6 +967,8 @@ function steppedDeps(opts = {}) { guidanceChanges: 0, sourceCalls: [], followModes: [], + fetches: [], + fetchMode: "ok", // "ok" | "404" — the idempotent-delete tolerance flip }; // every source.route call is recorded (the exactly-one-request gates) const baseRoute = deps.source.route.bind(deps.source); @@ -995,6 +997,18 @@ function steppedDeps(opts = {}) { }; deps.nowMs = () => guide.now; deps.findGantry = (banner) => findByClass(banner, "guide-gantry"); + // the envelope endpoint + an injectable recording fetch (the stubFetch of + // the plan's acceptance) — every envelope call lands in guide.fetches + deps.endpoint = "/api/guidance"; + deps.bearer = () => "test-token"; + deps.fetchImpl = async (url, init = {}) => { + guide.fetches.push({ url, init }); + if (String(init.method) === "DELETE" && guide.fetchMode === "404") { + return { ok: false, status: 404, json: async () => ({ error: "not_found" }) }; + } + if (String(init.method) === "DELETE") return { ok: true, status: 200, json: async () => ({}) }; + return { ok: true, status: 201, json: async () => ({ id: "0c0ffee0-0000-4000-8000-000000000000" }) }; + }; deps.onGuidanceChange = () => { guide.guidanceChanges += 1; }; @@ -1308,3 +1322,158 @@ test("G8 arrival — the machine's arrival effect runs the same teardown", async assert.equal(controller.guidancePhase, "ended", "the ended phase exposes to the BackButton graph"); }); }); + +/* ============================================================ + * 06-06 task 3 — the envelope flows + the composed ladder (G9..G12) + * ============================================================ + * G9 minimize POST — the frozen six-field snapshot (id/chat_id/ + * created_at are SERVER-minted), keepalive:true, the Bearer header; + * a live envelope is never double-minted + * G10 DELETE on activated (the cron pauses) AND at teardown, both with + * the stored id (the second is the idempotent one) + * G11 ?g= resume — RESUMING chrome with projected numbers, the watch + * armed, the line drawn; the first fix flips live ON_ROUTE + * G12 main.ts composition — the engine-a→fixture ladder, the ?g= + * UUID-validated carrier, the activated/deactivated wiring, the + * guidance-aware camera (structural, the code-constructed law) + */ + +function stubFetch(log) { + return (url, init) => { + log.push({ url, init }); + if (init.method === "POST") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: "env-1" }) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }; +} + +test("G9 minimize POST — the frozen six-field snapshot, keepalive, bearer; never double-minted", async () => { + await withDocumentAsync(async () => { + const calls = []; + const deps = steppedDeps(); + deps.fetchImpl = stubFetch(calls); + deps.bearer = () => "tok-1"; + const controller = createRouteController(deps); + await driveTo(deps, controller); + await withConsole(async () => { + await controller.onDeactivated(); + return null; + }); + const posts = calls.filter((c) => c.init.method === "POST"); + assert.equal(posts.length, 1, "exactly one envelope POST"); + const call = posts[0]; + assert.equal(call.url, "/api/guidance"); + assert.equal(call.init.keepalive, true, "the minimize race is belt-and-braces (keepalive)"); + assert.equal(call.init.headers.authorization, "Bearer tok-1", "the memory-held token rides the header"); + const body = JSON.parse(call.init.body); + assert.deepEqual( + Object.keys(body).sort(), + ["destinationId", "destinationName", "distanceM", "durationS", "geometry", "startedAtSec"], + "EXACTLY the frozen six fields — id/chat_id/created_at are server-minted", + ); + assert.equal(body.destinationId, DEST.id); + assert.equal(body.destinationName, DEST.name); + assert.equal(body.distanceM, 2000, "the selected route's summary distance"); + assert.equal(body.durationS, 240); + assert.ok(Number.isInteger(body.startedAtSec) && body.startedAtSec > 0, "the trip-start anchor"); + const { decodePolyline6 } = await import("@araharah/routing/src/index.ts"); + assert.deepEqual(decodePolyline6(body.geometry), CORRIDOR_POINTS, "the geometry re-encodes the live model"); + // a second minimize while the envelope is live does not double-mint + await controller.onDeactivated(); + assert.equal(posts.length, 1, "the live envelope is not double-minted"); + }); +}); + +test("G10 DELETE on activated AND at teardown — both with the stored id (the second is idempotent)", async () => { + await withDocumentAsync(async () => { + const calls = []; + const deps = steppedDeps(); + deps.fetchImpl = stubFetch(calls); + deps.bearer = () => "tok-1"; + const controller = createRouteController(deps); + await driveTo(deps, controller); + await controller.onDeactivated(); // POST → id env-1 stored + await controller.onActivated(); // DELETE — the cron pauses (structural) + const deletes = calls.filter((c) => c.init.method === "DELETE"); + assert.equal(deletes.length, 1, "activated deletes the envelope"); + assert.equal(deletes[0].url, "/api/guidance?id=env-1"); + // trip end deletes AGAIN with the same id — the server 404s it, tolerated + findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP + await withConsole(async () => { + findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON + return null; + }); + await drain(); + const deletesAfter = calls.filter((c) => c.init.method === "DELETE"); + assert.equal(deletesAfter.length, 2, "teardown deletes the envelope (the D-14 active sweep)"); + assert.equal(deletesAfter[1].url, "/api/guidance?id=env-1", "the SAME stored id — idempotent"); + // a second teardown would not delete a third time (the id is consumed) + await controller.onDeactivated(); // no live trip → no mint + assert.equal(calls.filter((c) => c.init.method === "POST").length, 1, "no envelope without a live trip"); + }); +}); + +test("G11 ?g= resume — RESUMING chrome with projected numbers; the first fix flips live ON_ROUTE", async () => { + await withDocumentAsync(async () => { + const deps = steppedDeps(); + const controller = createRouteController(deps); + const model = { + points: CORRIDOR_POINTS, + cumulative: CORRIDOR_CUMULATIVE, + steps: [], + destinationId: DEST.id, + destinationName: DEST.name, + }; + const resumed = await withConsole(async () => { + controller.resume({ model, durationS: 240, projectedRemainingM: 1400, startedAtSec: 900, envelopeId: "env-9" }); + return null; + }); + assert.equal(controller.state, "route", "the resumed trip holds the route state"); + assert.equal(controller.guidancePhase, "driving", "RESUMING is a driving phase — the confirm owns the exit"); + 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.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(deps.__guide.watchStarted, 1, "the watch arms for the resumed trip"); + 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.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) + const live = await withConsole(async () => { + feed(600, { deps }); + return null; + }); + 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.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) + findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP + await withConsole(async () => { + findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON + return null; + }); + assert.equal(controller.guidancePhase, "ended", "the resumed trip ends through the same confirm"); + }); +}); + +test("G12 main.ts composition — the ladder, the ?g= carrier, the envelope events, the guidance-aware camera", () => { + const mainCode = codeText(path.join(SRC, "main.ts")); + assert.ok(mainCode.includes("createHttpRoutingSource"), "backend #2 is code-constructed in main.ts"); + assert.ok(/\?\? \(await /.test(mainCode), "the composed ladder: engine-a first, fixture fallback"); + 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( + /[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", + ); + assert.ok( + /onEvent\("deactivated"/.test(mainCode) && /onEvent\("activated"/.test(mainCode), + "the envelope lifecycle rides Telegram's activated/deactivated events (T-06-06 guards)", + ); + assert.ok(/\.onDeactivated\(\)/.test(mainCode) && /\.onActivated\(\)/.test(mainCode), "the controller's envelope flows are wired"); + assert.ok(/guidancePhase === "driving"/.test(mainCode), "the chain's fix does not fight the guidance camera"); + assert.ok(/GUIDANCE_CAMERA_PADDING/.test(mainCode), "recenter re-locks at the guidance pose"); + 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"); +});