- GC1-GC7: PURE projectTurnCards behavior — projection math, cadence table, dead-trip gate, 4-line caption anatomy, street-bearing DEFAULT (D-12) vs bare-verb degradation, real makeStreetAt against the committed mini-graph - GC8: the pure-module structural law (no console, routing-workspace imports, nameId street hop, zero KV writes) - CS7 in commands.test.mjs pins the 21 UI-SPEC turn-card keys in en + ms
265 lines
12 KiB
JavaScript
265 lines
12 KiB
JavaScript
#!/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`,
|
|
);
|
|
}
|
|
}
|
|
});
|