#!/usr/bin/env node /* * commands.test.mjs — command-surface gates (ONBD-01, PRIV-01, D-02, D-03, D-04, D-05). * ------------------------------------------------------------------------------------- * Automated half of the command surface: * CS1 strings integrity — en/ms key-sets identical (D-05) * CS2 pickLocale fallback rule — ms-tagged → "ms", everything else → "en" * CS3 PRIV-01 copy gate — privacy copy states the never-facts * (location / routes / history) and the stored TTL wording * CS4 D-02 structural gate — command tree source contains no reply-menu * or inline-button surface (grep: zero "keyboard"-shaped tokens) * CARVE (the SECOND documented exception, after the GEO-02 reply * keyboard in handlers/location-fallback.ts): commands/cari.ts ships * the D-02/D-10 venue card whose INLINE keyboard (InlineKeyboard + * reply_markup with inline_keyboard: the OPEN IN ARAHARAH web_app * button + alternates) is a command's output — hiding it from this * gate would be the more dangerous choice, so the gate scans cari.ts * for the REPLY-keyboard shape specifically and still bans it there. * A reply keyboard remains a handlers/ surface only. * CS4b link previews — any command source calling ctx.reply( disables * link previews (a query echoed into a refusal must not become a * link preview) * CS5 D-03 menu gap-freedom — helpMenu lists exactly /help, /privacy, * and /cari, and the registration script derives all three scopes * from it (no inline description literals; /pergi_ke stays a banned * future stub — reserved for Phase 5, D-01) * CS6 D-04 alias completeness — command stack registers exactly * /start /mula /help /privacy /privasi /cari /search (help * unprefixed-alias only) * * Source-introspection style (privacy.test.mjs precedent): structural * claims are pinned by reading the shipped source, not by behavior mocks. */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; const { strings, pickLocale } = await import("../src/i18n/strings.ts"); const BOT_SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src", "bot"); const commandSources = [ "bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts", "commands/cari.ts", ].map((rel) => path.join(BOT_SRC, ...rel.split("/"))); /* ————— CS1 — en/ms key-set identity (D-05) ————— */ test("CS1 strings module: en and ms locales carry identical key-sets", () => { const enKeys = Object.keys(strings.en).sort(); const msKeys = Object.keys(strings.ms).sort(); assert.deepEqual(msKeys, enKeys, "ms missing/duplicated keys vs en (D-05 locale parity)"); assert.ok(enKeys.length >= 4, "expected the full BotStrings key-set (start, privacy x2, help)"); }); /* ————— CS2 — pickLocale fallback rule (D-05) ————— */ test("CS2 pickLocale: ms-tagged codes pick ms; everything else falls back to en", () => { assert.equal(pickLocale("ms"), "ms"); assert.equal(pickLocale("ms-MY"), "ms"); assert.equal(pickLocale("en"), "en"); assert.equal(pickLocale("en-GB"), "en"); assert.equal(pickLocale("id"), "en", "unknown/neighbor locale falls back to en"); assert.equal(pickLocale("zh-hans"), "en"); assert.equal(pickLocale(null), "en"); assert.equal(pickLocale(undefined), "en"); assert.equal(pickLocale(""), "en"); }); /* ————— CS3 — PRIV-01 copy gate ————— */ test("CS3 PRIV-01: privacy copy states the never-facts in both locales", () => { assert.ok(/location/i.test(strings.en.privacyNotStored), "en never-facts must name location"); assert.ok(/routes/i.test(strings.en.privacyNotStored), "en never-facts must name routes"); assert.ok(/history/i.test(strings.en.privacyNotStored), "en never-facts must name trip history"); assert.ok(/profil/i.test(strings.en.privacyNotStored), "en never-facts must name profile"); assert.ok(/lokasi/i.test(strings.ms.privacyNotStored), "ms never-facts must name lokasi"); assert.ok(/laluan/i.test(strings.ms.privacyNotStored), "ms never-facts must name laluan"); assert.ok(/sejarah/i.test(strings.ms.privacyNotStored), "ms never-facts must name sejarah"); assert.ok(/profil/i.test(strings.ms.privacyNotStored), "ms never-facts must name profil"); }); test("CS3 PRIV-01: stored-facts copy carries the TTL-minutes wording in both locales", () => { assert.ok(/15 minutes/.test(strings.en.privacyStored), "en stored-facts must carry the ~15-minutes TTL wording"); assert.ok(/15 minit/.test(strings.ms.privacyStored), "ms stored-facts must carry the ~15-minit TTL wording"); }); /* ————— CS4 — D-02 structural gate ————— */ test("CS4 D-02: the command tree has no reply-menu surface — the /cari inline-keyboard carve", () => { for (const file of commandSources) { const source = readFileSync(file, "utf8"); if (file.endsWith("commands/cari.ts")) { // The documented carve (second exception after the GEO-02 reply // keyboard): the venue card's INLINE keyboard is allowed, but a // REPLY keyboard must never appear in a command source. assert.doesNotMatch( source, /reply_markup:\s*\{\s*keyboard/, "cari.ts — a reply-keyboard shape appeared (reply keyboards live in handlers/, not commands/)", ); assert.doesNotMatch( source, /ReplyKeyboardMarkup/, "cari.ts — ReplyKeyboardMarkup appeared (the venue card carries an INLINE keyboard only)", ); continue; } assert.doesNotMatch( source, /keyboard/i, `${path.basename(file)} — a menu/button surface token appeared (D-02: text-only replies)`, ); } }); test("CS4 D-02: all replies go out with link previews disabled and nothing else attached", () => { for (const file of commandSources) { const source = readFileSync(file, "utf8"); const replyCalls = source.match(/ctx\.reply\(/g) ?? []; if (replyCalls.length > 0) { assert.match(source, /link_preview_options:\s*\{\s*is_disabled:\s*true\s*\}/, `${file} must disable link previews`); } } }); /* ————— CS5 — D-03 menu gap-freedom ————— */ /** Parse the `cmd — text` lines out of a helpMenu string. */ function menuEntries(helpMenu) { return helpMenu .split("\n") .filter((line) => line.startsWith("/")) .map((line) => { const em = line.indexOf(" — "); assert.ok(em !== -1, `helpMenu line lacks its " — " description marker: ${line}`); return { command: line.slice(1, em), description: line.slice(em + 3).trim() }; }); } test("CS5 D-03: helpMenu lists exactly /help, /privacy, and /cari — no future-command stubs", () => { for (const locale of ["en", "ms"]) { const entries = menuEntries(strings[locale].helpMenu); assert.deepEqual( entries.map((e) => e.command), ["help", "privacy", "cari"], `${locale} menu must contain exactly the live commands, in helpMenu order (D-03; /pergi_ke stays reserved for Phase 5, D-01)`, ); } }); test("CS5 D-03: register-commands derives every scope from helpMenu — single fetch site, all shadowing scopes armed", () => { const source = readFileSync( path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "scripts", "register-commands.mjs"), "utf8", ); // Device-gate incident (2026-10-24): a stale all_private_chats set shadowed // the default scope and hid /cari — the script must arm EVERY scope that can // shadow a private chat, not just default. One fetch site, scope table. const fetchCalls = source.match(/\bawait fetch\(/g) ?? []; assert.equal(fetchCalls.length, 1, "register-commands must have a single fetch call site (scope loop)"); for (const scope of ["default", "all_private_chats"]) { assert.ok(source.includes(`type: "${scope}"`), `the ${scope} scope must be armed explicitly`); } for (const scope of ["all_group_chats", "all_chat_administrators"]) { assert.ok(source.includes(`type: "${scope}"`), `the stale ${scope} set must be explicitly cleared`); } assert.ok(source.includes('language_code: "ms"'), "the ms scope payload must set language_code 'ms'"); assert.ok(source.includes("menuFor(\"en\")"), "default scope derives from the en helpMenu"); assert.ok(source.includes("menuFor(\"ms\")"), "ms scope derives from the ms helpMenu"); assert.doesNotMatch( source, /description:\s*["'`]/, "registration script must not inline copy — descriptions derive from strings.ts (D-05)", ); assert.doesNotMatch( source, /pergi_ke/, "no future-command stubs may be registered before their phases exist (/pergi_ke stays reserved for Phase 5, D-01; /cari is a live command since 04-03)", ); }); /* ————— CS6 — D-04 alias completeness on the command stack ————— */ test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /privasi /cari /search", () => { const registered = new Set(); for (const file of commandSources) { const source = readFileSync(file, "utf8"); for (const match of source.matchAll(/bot\.command\("([^"]+)"/g)) { registered.add(match[1]); } } assert.deepEqual( [...registered].sort(), ["cari", "help", "mula", "privasi", "privacy", "search", "start"].sort(), "the command stack must be exactly the phase surface (D-04; no drift, no extras)", ); }); /* ————— CS7 — GUID-05 turn-card copy gate (06-07; D-12/D-13, UI-SPEC verbatim) ————— */ test("CS7 D-12/D-13: the 21 turn-card keys exist verbatim in en + ms — 8 street-bearing verbs, 8 bare degradation verbs, street fallback, ETA template, honesty line, OPEN button", () => { const expected = { en: { cardActionDepart: "DEPART", cardActionStraight: "CONTINUE ON", cardActionLeft: "TURN LEFT ONTO", cardActionRight: "TURN RIGHT ONTO", cardActionSlightLeft: "KEEP LEFT ONTO", cardActionSlightRight: "KEEP RIGHT ONTO", cardActionUturn: "MAKE A U-TURN ONTO", cardActionArrive: "ARRIVE AT", cardActionBareDepart: "DEPART", cardActionBareStraight: "CONTINUE", cardActionBareLeft: "TURN LEFT", cardActionBareRight: "TURN RIGHT", cardActionBareSlightLeft: "KEEP LEFT", cardActionBareSlightRight: "KEEP RIGHT", cardActionBareUturn: "MAKE A U-TURN", cardActionBareArrive: "ARRIVE", cardStreetUnnamed: "UNNAMED ROAD", cardEtaTemplate: "ETA {time} · {km} KM LEFT", cardHonesty: "Best-effort projection — updates while the app is minimized.", cardOpenButton: "OPEN GUIDANCE", }, ms: { cardActionDepart: "MULA", cardActionStraight: "TERUS KE", cardActionLeft: "BELOK KIRI KE", cardActionRight: "BELOK KANAN KE", cardActionSlightLeft: "SEDAKIT KIRI KE", cardActionSlightRight: "SEDAKIT KANAN KE", cardActionUturn: "BUAT PUSARAN KE", cardActionArrive: "TIBA DI", cardActionBareDepart: "MULA", cardActionBareStraight: "TERUS", cardActionBareLeft: "BELOK KIRI", cardActionBareRight: "BELOK KANAN", cardActionBareSlightLeft: "SEDAKIT KIRI", cardActionBareSlightRight: "SEDAKIT KANAN", cardActionBareUturn: "BUAT PUSARAN", cardActionBareArrive: "TIBA", cardStreetUnnamed: "JALAN TANPA NAMA", cardEtaTemplate: "ETA {masa} · {km} KM LAGI", cardHonesty: "Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan.", cardOpenButton: "BUKA PANDUAN", }, }; for (const locale of ["en", "ms"]) { for (const [key, value] of Object.entries(expected[locale])) { assert.equal( strings[locale][key], value, `${locale}.${key} must carry the UI-SPEC verbatim turn-card value`, ); } } });