Files
Arah2/bot/test/commands.test.mjs
araharah-executor b4393fbcf3 feat(02-03): strings module + text-only command surface with Malay aliases (D-01..D-05)
- src/i18n/strings.ts: single copy source of truth, en + ms identical
  key-sets, clinical/honest start + privacy copy, pickLocale fallback rule
- /start /mula, /help, /privacy /privasi handlers: plain text replies with
  link previews disabled, zero menu/button surface (D-02, grep-gated)
- createBot(env) factory registers exactly the phase command stack
- scripts/register-commands.mjs: one-time setMyCommands, two scopes
  (default + language_code ms), descriptions derived from helpMenu —
  no inline copy, no future-command stubs (D-03/D-05)
- test/commands.test.mjs: CS1-CS6 gates — locale parity, pickLocale
  matrix, PRIV-01 copy gate vs never-facts + TTL wording, D-02 structural
  grep, D-03 menu gap-freedom, D-04 alias completeness (27 passing)
2026-10-22 11:48:50 +08:00

164 lines
7.2 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)
* CS5 D-03 menu gap-freedom — helpMenu lists exactly /help and /privacy,
* and the registration script derives both scopes from it (no
* inline description literals, no future-command stubs)
* CS6 D-04 alias completeness — command stack registers exactly
* /start /mula /help /privacy /privasi (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"].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 source has no reply-menu or inline-button surface", () => {
for (const file of commandSources) {
const source = readFileSync(file, "utf8");
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 and /privacy — 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"],
`${locale} menu must contain exactly the two live commands (D-03)`,
);
}
});
test("CS5 D-03: register-commands derives both scopes from helpMenu — two fetch calls, ms scope marked", () => {
const source = readFileSync(
path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "scripts", "register-commands.mjs"),
"utf8",
);
const fetchCalls = source.match(/\bawait fetch\(/g) ?? [];
assert.equal(fetchCalls.length, 2, "register-commands must issue exactly two setMyCommands fetch calls (default + ms)");
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,
/cari|pergi_ke/,
"no future-command stubs may be registered before their phases exist (D-03)",
);
});
/* ————— CS6 — D-04 alias completeness on the command stack ————— */
test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /privasi", () => {
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(),
["help", "mula", "privasi", "privacy", "start"].sort(),
"the command stack must be exactly the phase surface (D-04; no drift, no extras)",
);
});