diff --git a/web/test/guidance-surface.test.mjs b/web/test/guidance-surface.test.mjs index 902daad..4422ef0 100644 --- a/web/test/guidance-surface.test.mjs +++ b/web/test/guidance-surface.test.mjs @@ -50,6 +50,8 @@ const { buildTripEndCard, MANEUVER_LIST_ID, } = await import("../src/guidance/banner.ts"); +const { fireHaptic } = await import("../src/guidance/haptics.ts"); +const { engageCloseProtection, releaseCloseProtection } = await import("../src/guidance/close-protection.ts"); /* ————— shared helpers ————— */ @@ -625,3 +627,99 @@ test("GS12 banner.ts places .btn-primary exactly once — the confirm card's YES assert.equal((src.match(/btn-primary/g) ?? []).length, 1, "only the confirm card builds a primary CTA"); assert.equal((src.match(/btn-secondary/g) ?? []).length, 1, "every other action rides the shared secondary-button helper"); }); + +/* ————— GS13/GS14 — the capability guards (D-08 haptics, PX-03 close + protection): recording fakes prove every call is gated BEFORE it happens + and degrades to an honest no-op (T-06-06) ————— */ + +/** Bot-API semver compare ("7.7" vs "8.0"). */ +function atLeast(actual, wanted) { + const [aMaj, aMin] = actual.split(".").map(Number); + const [wMaj, wMin] = wanted.split(".").map(Number); + return aMaj > wMaj || (aMaj === wMaj && aMin >= wMin); +} + +/** A recording TelegramWebAppLike fake — every guard-relevant call lands in + * `rec`; members can be deleted to prove the no-op paths. */ +function fakeWa({ version = "8.0", haptics = true, versionGate = true } = {}) { + const rec = { haptic: [], close: [], versionChecks: [] }; + const wa = { + initData: "", + HapticFeedback: haptics + ? { + selectionChanged() { rec.haptic.push("selection"); }, + impactOccurred(style) { rec.haptic.push(`impact:${style}`); }, + notificationOccurred(type) { rec.haptic.push(`notify:${type}`); }, + } + : undefined, + disableVerticalSwipes() { rec.close.push("disableVerticalSwipes"); }, + enableVerticalSwipes() { rec.close.push("enableVerticalSwipes"); }, + enableClosingConfirmation() { rec.close.push("enableClosingConfirmation"); }, + disableClosingConfirmation() { rec.close.push("disableClosingConfirmation"); }, + }; + if (versionGate) { + wa.isVersionAtLeast = (v) => { + rec.versionChecks.push(v); + return atLeast(version, v); + }; + } + return { wa, rec }; +} + +test("GS13 fireHaptic routes the three cues — selectionChanged / impactOccurred('medium') / notificationOccurred('success')", () => { + const { wa, rec } = fakeWa(); + fireHaptic(wa, "approach"); + fireHaptic(wa, "maneuver"); + fireHaptic(wa, "arrive"); + assert.deepEqual(rec.haptic, ["selection", "impact:medium", "notify:success"], "the UI-SPEC haptics table, verbatim"); +}); + +test("GS13b fireHaptic degrades silently — no HapticFeedback member and a null wa are no-ops, never throws (D-08)", () => { + const bare = fakeWa({ haptics: false }); + fireHaptic(bare.wa, "approach"); + fireHaptic(bare.wa, "maneuver"); + fireHaptic(bare.wa, "arrive"); + assert.deepEqual(bare.rec.haptic, [], "nothing recorded, nothing thrown"); + fireHaptic(null, "arrive"); // plain browser — must not throw + // the no-haptic law is documented in the module header (D-08 + no blame) + const src = readFileSync(path.join(GUIDANCE_DIR, "haptics.ts"), "utf8"); + assert.ok(src.includes("D-08"), "the header cites D-08"); + assert.ok(src.toLowerCase().includes("reroute"), "the header carries the no-haptic-on-reroute law"); +}); + +test("GS14 engageCloseProtection — body-swipe off at 7.7+, native confirm at 6.2+, each BEFORE its call, layered in order", () => { + const modern = fakeWa({ version: "8.0" }); + engageCloseProtection(modern.wa); + assert.deepEqual(modern.rec.close, ["disableVerticalSwipes", "enableClosingConfirmation"], "both layers engage in order"); + assert.deepEqual(modern.rec.versionChecks, ["7.7", "6.2"], "each gate checked before its own call"); + const mid = fakeWa({ version: "7.0" }); + engageCloseProtection(mid.wa); + assert.deepEqual(mid.rec.close, ["enableClosingConfirmation"], "7.0: the 7.7 layer stays a no-op"); + const old = fakeWa({ version: "6.1" }); + engageCloseProtection(old.wa); + assert.deepEqual(old.rec.close, [], "6.1: nothing engages — silent no-op"); + const gateless = fakeWa({ versionGate: false }); + engageCloseProtection(gateless.wa); + assert.deepEqual(gateless.rec.close, [], "no version gate → no calls, no throw (Pitfall 5)"); + engageCloseProtection(null); // must not throw +}); + +test("GS14b releaseCloseProtection inverts both layers under the same gates", () => { + const modern = fakeWa({ version: "8.0" }); + releaseCloseProtection(modern.wa); + assert.deepEqual(modern.rec.close, ["enableVerticalSwipes", "disableClosingConfirmation"], "trip end: both layers release"); + const mid = fakeWa({ version: "6.5" }); + releaseCloseProtection(mid.wa); + assert.deepEqual(mid.rec.close, ["disableClosingConfirmation"], "6.5: only the 6.2 layer releases"); + const old = fakeWa({ version: "6.1" }); + releaseCloseProtection(old.wa); + assert.deepEqual(old.rec.close, []); + releaseCloseProtection(null); // must not throw +}); + +test("GS14c the version literals are pinned — isVersionAtLeast(\"7.7\") and isVersionAtLeast(\"6.2\") in close-protection.ts", () => { + const src = readFileSync(path.join(GUIDANCE_DIR, "close-protection.ts"), "utf8"); + assert.ok(src.includes('isVersionAtLeast("7.7")'), "the 7.7 body-swipe gate"); + assert.ok(src.includes('isVersionAtLeast("6.2")'), "the 6.2 native-confirm gate"); + assert.ok(src.includes("?.") || src.includes("?."), "optional-member chains throughout"); +});