forked from c-fu/arah2
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)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* register-commands.mjs — one-time setMyCommands registration (D-03, D-04, D-17).
|
||||
* -------------------------------------------------------------------------------
|
||||
* Registers the bot's persistent command menu in two scopes:
|
||||
* 1. default scope — English /help and /privacy descriptions
|
||||
* 2. default + language_code "ms" — Malay descriptions for ms users
|
||||
*
|
||||
* D-03: the menu lists ONLY commands that exist in this phase (/help,
|
||||
* /privacy) — no stub entries reserved for future commands. There are
|
||||
* exactly two fetch calls below, one per scope.
|
||||
*
|
||||
* D-05 (single copy source): the descriptions are not inline here — they
|
||||
* are derived from src/i18n/strings.ts `helpMenu` lines ("/cmd — text"),
|
||||
* the same text /help replies with, so reply and registered menu cannot
|
||||
* drift apart.
|
||||
*
|
||||
* Secrets stay secret (T-02-04): BOT_TOKEN is read from bot/.dev.vars
|
||||
* (never committed, D-15) and is never echoed — only the API response's
|
||||
* ok/result fields are printed.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { strings } from "../src/i18n/strings.ts";
|
||||
|
||||
/* ————— configuration (D-15: local secrets from gitignored .dev.vars) ————— */
|
||||
|
||||
function readDevVars() {
|
||||
const devVarsPath = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", ".dev.vars");
|
||||
let raw = "";
|
||||
try {
|
||||
raw = readFileSync(devVarsPath, "utf8");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const vars = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
const token = process.env.BOT_TOKEN ?? readDevVars().BOT_TOKEN;
|
||||
if (!token) {
|
||||
console.error(
|
||||
"BOT_TOKEN missing — copy bot/.dev.vars.example to bot/.dev.vars (gitignored) and paste your @BotFather token.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ————— menu derived from the strings module (D-03/D-05, gap-free) ————— */
|
||||
|
||||
/** Extract the `/cmd — description` entries from a locale's helpMenu. */
|
||||
function menuFor(locale) {
|
||||
const lines = strings[locale].helpMenu.split("\n");
|
||||
return ["help", "privacy"].map((command) => {
|
||||
const marker = `/${command} — `;
|
||||
const line = lines.find((l) => l.startsWith(marker));
|
||||
if (line === undefined) {
|
||||
throw new Error(`helpMenu for "${locale}" is missing its /${command} line (D-03 copy drift)`);
|
||||
}
|
||||
return { command, description: line.slice(marker.length).trim() };
|
||||
});
|
||||
}
|
||||
|
||||
/* ————— exactly two API calls: default scope, then the ms scope (D-03/D-04) ————— */
|
||||
|
||||
const API = `https://api.telegram.org/bot${token}/setMyCommands`;
|
||||
const JSON_HEADERS = { "content-type": "application/json" };
|
||||
|
||||
const defaultPayload = { commands: menuFor("en"), bot_command_scope: { type: "default" } };
|
||||
const msPayload = { commands: menuFor("ms"), bot_command_scope: { type: "default" }, language_code: "ms" };
|
||||
|
||||
const defaultRes = await fetch(API, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(defaultPayload) });
|
||||
const defaultBody = await defaultRes.json();
|
||||
console.log(`default scope -> ok:${defaultBody.ok} result:${JSON.stringify(defaultBody.result ?? null)}`);
|
||||
|
||||
const msRes = await fetch(API, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(msPayload) });
|
||||
const msBody = await msRes.json();
|
||||
console.log(`ms scope -> ok:${msBody.ok} result:${JSON.stringify(msBody.result ?? null)}`);
|
||||
|
||||
if (!defaultBody.ok || !msBody.ok) {
|
||||
const firstError = !defaultBody.ok ? defaultBody : msBody;
|
||||
console.error(`setMyCommands failed: ${firstError.error_code ?? "?"} ${firstError.description ?? "unknown error"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* bot.ts — the grammY bot factory (D-02, D-05, D-11).
|
||||
* ----------------------------------------------------
|
||||
* `createBot(env)` instantiates the Bot with the env-bound token and
|
||||
* registers the command-stack handlers (aliases included). It runs inside
|
||||
* the Worker's fetch handler (Workers have no server startup hook, and the
|
||||
* token only arrives with `env`).
|
||||
*
|
||||
* D-02 (structural, pinned by test/commands.test.mjs): handlers send plain
|
||||
* text replies only — no reply menus, no inline button payloads, no
|
||||
* callback routes, no menu augmentation per request. The persistent menu
|
||||
* is registered once by scripts/register-commands.mjs (D-03).
|
||||
*/
|
||||
import { Bot } from "grammy";
|
||||
import type { Env } from "../config.ts";
|
||||
import { registerHelp } from "./commands/help.ts";
|
||||
import { registerPrivacy } from "./commands/privacy.ts";
|
||||
import { registerStart } from "./commands/start.ts";
|
||||
|
||||
export function createBot(env: Env): Bot {
|
||||
const bot = new Bot(env.BOT_TOKEN);
|
||||
registerStart(bot); // /start + /mula (D-04)
|
||||
registerHelp(bot); // /help
|
||||
registerPrivacy(bot); // /privacy + /privasi (D-04)
|
||||
return bot;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* help.ts — the /help handler (D-03, D-04, D-05).
|
||||
* ------------------------------------------------
|
||||
* No Malay alias in this phase (D-04 names /mula and /privasi only —
|
||||
* later-flow aliases belong to their own phases).
|
||||
*
|
||||
* The reply is the helpMenu text, whose "cmd — description" lines are the
|
||||
* same descriptions the setMyCommands registration script lists in the
|
||||
* command menu — menu and reply cannot drift apart (D-03, gap-free).
|
||||
*
|
||||
* Plain text-only reply, D-02-pinned; copy lives only in strings.ts (D-05).
|
||||
*/
|
||||
import type { Bot, Context } from "grammy";
|
||||
import { pickLocale, strings } from "../../i18n/strings.ts";
|
||||
import type { Locale } from "../../i18n/strings.ts";
|
||||
|
||||
/** Pure builder — the exact text the /help surface replies with. */
|
||||
export function helpReplyText(locale: Locale): string {
|
||||
return strings[locale].helpMenu;
|
||||
}
|
||||
|
||||
async function handleHelp(ctx: Context): Promise<void> {
|
||||
const locale = pickLocale(ctx.from?.language_code);
|
||||
await ctx.reply(helpReplyText(locale), { link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
|
||||
/** Register the command stack entry: /help. */
|
||||
export function registerHelp(bot: Bot): void {
|
||||
bot.command("help", handleHelp);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* privacy.ts — the /privacy and /privasi handlers (PRIV-01, D-02, D-04, D-05).
|
||||
* -----------------------------------------------------------------------------
|
||||
* This is the visible half of the privacy promise (PRIV-01): what is stored
|
||||
* and what is never stored, said as plainly as the storage code does it.
|
||||
* The stored-facts text must keep matching src/kv/sessions.ts's D-08 claim
|
||||
* fields and TTL — the copy gate in test/commands.test.mjs holds that line.
|
||||
*
|
||||
* Aliases share one handler on the same command stack (D-04):
|
||||
* `/privacy` and `/privasi` are grammatically the same command.
|
||||
*
|
||||
* Plain text-only reply, D-02-pinned; copy lives only in strings.ts (D-05).
|
||||
*/
|
||||
import type { Bot, Context } from "grammy";
|
||||
import { pickLocale, strings } from "../../i18n/strings.ts";
|
||||
import type { Locale } from "../../i18n/strings.ts";
|
||||
|
||||
/** Pure builder — stored-facts block, then the never-facts block. */
|
||||
export function privacyReplyText(locale: Locale): string {
|
||||
const s = strings[locale];
|
||||
return `${s.privacyStored}\n\n${s.privacyNotStored}`;
|
||||
}
|
||||
|
||||
async function handlePrivacy(ctx: Context): Promise<void> {
|
||||
const locale = pickLocale(ctx.from?.language_code);
|
||||
await ctx.reply(privacyReplyText(locale), { link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
|
||||
/** Register the command stack entry: /privacy and its Malay alias /privasi. */
|
||||
export function registerPrivacy(bot: Bot): void {
|
||||
bot.command("privacy", handlePrivacy);
|
||||
bot.command("privasi", handlePrivacy);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* start.ts — the /start and /mula handlers (D-01, D-02, D-04, D-05).
|
||||
* -------------------------------------------------------------------
|
||||
* Aliases share one handler on the same command stack (D-04):
|
||||
* `/start` and `/mula` are grammatically the same command.
|
||||
*
|
||||
* D-02 (structural, pinned by test/commands.test.mjs): the reply is plain
|
||||
* text with link previews disabled — no reply menus, no inline button
|
||||
* payloads, no callback routes anywhere in this tree. Telegram commands
|
||||
* are the interface until the Mini App launch path lands (Phase 4).
|
||||
*
|
||||
* All copy comes from src/i18n/strings.ts (D-05) — nothing inline here.
|
||||
*/
|
||||
import type { Bot, Context } from "grammy";
|
||||
import { pickLocale, strings } from "../../i18n/strings.ts";
|
||||
import type { Locale } from "../../i18n/strings.ts";
|
||||
|
||||
/** Pure builder — the exact text the /start surface replies with. */
|
||||
export function startReplyText(locale: Locale): string {
|
||||
return strings[locale].startIntro;
|
||||
}
|
||||
|
||||
async function handleStart(ctx: Context): Promise<void> {
|
||||
const locale = pickLocale(ctx.from?.language_code);
|
||||
await ctx.reply(startReplyText(locale), { link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
|
||||
/** Register the command stack entry: /start and its Malay alias /mula. */
|
||||
export function registerStart(bot: Bot): void {
|
||||
bot.command("start", handleStart);
|
||||
bot.command("mula", handleStart);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* strings.ts — the copy's tokens.css (D-05).
|
||||
* -------------------------------------------
|
||||
* Every user-facing string the bot sends lives here and NOWHERE else —
|
||||
* handlers import from this module, the setMyCommands registration script
|
||||
* derives its menu descriptions from `helpMenu`, and there is no inline
|
||||
* copy anywhere in the command tree.
|
||||
*
|
||||
* Locales: English default ("en") + Bahasa Melayu ("ms"). Tone is clinical
|
||||
* + honest (D-01): one sentence for what araharah is, plain statements of
|
||||
* what is stored and what is not — no emoji, no marketing flourishes.
|
||||
*
|
||||
* Claim-surface honesty (D-17 / PRIV-01): `privacyStored` names the exact
|
||||
* D-08 claim fields written by src/kv/sessions.ts (user id, chat id,
|
||||
* language, ~15-minute TTL) and nothing more; `privacyNotStored` names the
|
||||
* exact never-facts. Any matching change to the storage layer must be
|
||||
* reflected here and the copy gate in test/commands.test.mjs will keep
|
||||
* the two in step.
|
||||
*/
|
||||
|
||||
export interface BotStrings {
|
||||
/** D-01: what araharah is, in one sentence. */
|
||||
startIntro: string;
|
||||
/** PRIV-01: exactly what is stored — must mirror the D-08 claim fields and the TTL. */
|
||||
privacyStored: string;
|
||||
/** PRIV-01: exactly what is NOT stored — the never-facts. */
|
||||
privacyNotStored: string;
|
||||
/** D-03: lists only commands that exist in this phase — derived lines
|
||||
* ("cmd — description") feed the setMyCommands registration script. */
|
||||
helpMenu: string;
|
||||
}
|
||||
|
||||
export type Locale = "en" | "ms";
|
||||
|
||||
export const strings: Record<Locale, BotStrings> = {
|
||||
en: {
|
||||
startIntro:
|
||||
"araharah — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means.",
|
||||
privacyStored:
|
||||
"What araharah keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone.",
|
||||
privacyNotStored:
|
||||
"What araharah never keeps: your location, your routes, your trip history, any profile about you.",
|
||||
helpMenu:
|
||||
"Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores",
|
||||
},
|
||||
ms: {
|
||||
startIntro:
|
||||
"araharah — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya.",
|
||||
privacyStored:
|
||||
"Yang araharah simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang.",
|
||||
privacyNotStored:
|
||||
"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.",
|
||||
helpMenu:
|
||||
"Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* D-05 fallback rule: a Telegram `language_code` that starts with the
|
||||
* Malay tag selects "ms"; every other value (including null/undefined and
|
||||
* unknown languages) falls back to English.
|
||||
*/
|
||||
export function pickLocale(telegramLanguageCode?: string | null): Locale {
|
||||
const code = telegramLanguageCode ?? "";
|
||||
return code === "ms" || code.startsWith("ms-") ? "ms" : "en";
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/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)",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user