- 8 hand-authored 32x32 SVGs per the UI-SPEC geometry table (3px stroke, square caps, orthogonal/45° only; uturn = machined staple, no arc) - glyphs.ts: GLYPH_KINDS/GLYPHS via Vite ?raw (committed files are the single source) + svgHost — the ONE sanctioned innerHTML site, a static import value - strings.ts: 18 ShellStrings keys appended in en + ms, values verbatim from the 06-UI-SPEC copywriting contract - copy-gate: COPY1 floor 40→58 + COPY8 pins all 18 guidance keys verbatim in both locales
202 lines
11 KiB
JavaScript
202 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* copy-gate.test.mjs — COPY1..COPY7, the UI-SPEC copy gate (mirrors bot CS1-CS3).
|
|
* --------------------------------------------------------------------------------
|
|
* COPY1 locale key-set parity · COPY2/COPY3 verbatim-contract content checks ·
|
|
* COPY4 pickLocale fallback rule · COPY5 no inline copy in shell modules ·
|
|
* COPY6 localeFromInitData over the raw initData string ·
|
|
* COPY7 route copy verbatim (the Phase 5 route card + pending card keys).
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { test } from "./harness.mjs";
|
|
|
|
const { strings, pickLocale, localeFromInitData } = await import("../src/i18n/strings.ts");
|
|
|
|
const UI_DIR = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "ui");
|
|
const ROUTE_DIR = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "route");
|
|
|
|
/* ————— COPY1 — en/ms key-set identity (Phase 2 D-05 precedent) ————— */
|
|
|
|
test("COPY1 en and ms carry identical key-sets (parity gate, both locales)", () => {
|
|
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 >= 58, `expected the full ShellStrings surface (58 keys incl. the 10 route + 18 guidance keys), found ${enKeys.length}`);
|
|
assert.ok(enKeys.includes("deniedRegister"), "deniedRegister is a keys — cards.ts renders GEO //");
|
|
assert.ok(enKeys.includes("lostRegister"), "lostRegister is a key — cards.ts renders SYS //");
|
|
assert.ok(enKeys.includes("routeRegister"), "routeRegister is a key — route-card.ts renders ROUTE");
|
|
assert.ok(enKeys.includes("routePendingRegister"), "routePendingRegister is a key — the pending card renders ROUTE // ROUTER");
|
|
assert.ok(enKeys.includes("ctaSearchAgain"), "ctaSearchAgain is a key — the D-10 restart affordance");
|
|
assert.ok(enKeys.includes("guideRegister"), "guideRegister is a key — the banner/card renders GUIDE");
|
|
assert.ok(enKeys.includes("ctaEndTrip"), "ctaEndTrip is a key — the guidance card's only action");
|
|
assert.ok(enKeys.includes("guideConfirmHeadline"), "guideConfirmHeadline is a key — the D-09 confirm guard");
|
|
});
|
|
|
|
/* ————— COPY2 — denied copy names location ————— */
|
|
|
|
test("COPY2 the denied copy names location in both locales", () => {
|
|
assert.ok(/location/i.test(strings.en.deniedHeadline), "en denied headline names location");
|
|
assert.ok(/location/i.test(strings.en.deniedBody), "en denied body names location");
|
|
assert.ok(/lokasi/i.test(strings.ms.deniedHeadline), "ms denied headline names lokasi");
|
|
assert.ok(/lokasi/i.test(strings.ms.deniedBody), "ms denied body names lokasi");
|
|
});
|
|
|
|
/* ————— COPY3 — the never-persisted fact is on the boot card ————— */
|
|
|
|
test("COPY3 the boot-2 body states the never-persisted fact", () => {
|
|
assert.ok(/never written to storage/i.test(strings.en.bootAcquiringBody), "en boot-2 body carries the never-fact");
|
|
assert.ok(/tidak pernah disimpan/i.test(strings.ms.bootAcquiringBody), "ms boot-2 body carries the never-fact");
|
|
});
|
|
|
|
/* ————— COPY4 — pickLocale fallback rule ————— */
|
|
|
|
test("COPY4 pickLocale: ms and ms-MY select ms; en, en-GB, id, null, and empty string select 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(null), "en");
|
|
assert.equal(pickLocale(""), "en");
|
|
});
|
|
|
|
/* ————— COPY5 — no inline copy in the shell ————— */
|
|
|
|
test("COPY5 no shell module inlines copy — source-introspection grep over web/src/ui/*.ts, web/src/route/*.ts and web/src/main.ts", () => {
|
|
const banned =
|
|
/Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT|Location is switched off|Signal lost|MENYEMAK|MENDAPAT|LANGSUNG|GEO \/\/ LOCATION|SYS \/\/ POSITION|BOOT-01 \/\/|BOOT-02 \/\/|CUBA LAGI|MINTA VIA CHAT|ISYARAT HILANG|LOKASI MATI|AWAITING PIN|OpenStreetMap contributors|Recenter map|Tetapkan semula peta|Routing not wired up yet|SEARCH AGAIN|CARI SEMULA|ROUTE \/\/ ROUTER|Laluan terpantas|Penghalaan belum dipasang|Alternative routes/;
|
|
const files = existsSync(UI_DIR)
|
|
? readdirSync(UI_DIR)
|
|
.filter((f) => f.endsWith(".ts"))
|
|
.map((f) => path.join(UI_DIR, f))
|
|
: [];
|
|
if (existsSync(ROUTE_DIR)) {
|
|
files.push(
|
|
...readdirSync(ROUTE_DIR)
|
|
.filter((f) => f.endsWith(".ts"))
|
|
.map((f) => path.join(ROUTE_DIR, f)),
|
|
); // the route module family joins the scanned shell surface (Phase 5)
|
|
}
|
|
files.push(path.join(UI_DIR, "..", "main.ts")); // the composition root — always scanned
|
|
assert.ok(files.length >= 1, `scanned a real shell tree (found ${files.length} files)`);
|
|
for (const file of files) {
|
|
const src = readFileSync(file, "utf8");
|
|
// The gate scans code AND prose-in-comments alike? No — comments may cite
|
|
// the register labels in their own names (e.g. a header example). Strip
|
|
// block and line comments first: prose is not a call site (GEO3 pattern).
|
|
const code = src
|
|
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
|
const hit = banned.exec(code);
|
|
assert.ok(!hit, `${path.relative(process.cwd(), file)} inlines copy: ${hit && hit[0]} — copy lives only in web/src/i18n/strings.ts`);
|
|
}
|
|
});
|
|
|
|
/* ————— COPY6 — localeFromInitData over the raw initData string ————— */
|
|
|
|
test("COPY6 localeFromInitData derives the locale from the raw initData string", () => {
|
|
assert.equal(
|
|
localeFromInitData('user=%7B%22language_code%22%3A%22ms%22%7D&auth_date=1&hash=x'),
|
|
"ms",
|
|
"ms language_code inside the signed user field selects ms",
|
|
);
|
|
assert.equal(
|
|
localeFromInitData('user=%7B%22language_code%22%3A%22en-GB%22%7D&auth_date=1&hash=x'),
|
|
"en",
|
|
"en-GB falls back to en",
|
|
);
|
|
assert.equal(localeFromInitData(""), "en", "empty initData falls back to en");
|
|
assert.equal(localeFromInitData("not-initdata"), "en", "no user= component falls back to en");
|
|
assert.equal(localeFromInitData("user=%7Bbad-json"), "en", "decode/JSON failure must not throw");
|
|
});
|
|
|
|
/* ————— COPY7 — route copy verbatim (the 10 route keys, en + ms) ————— */
|
|
|
|
test("COPY7 route copy verbatim (the 10 route keys, en + ms)", () => {
|
|
assert.equal(strings.en.routePendingHeadline, "Routing not wired up yet");
|
|
assert.ok(
|
|
strings.en.routePendingBody.includes("never sent anywhere"),
|
|
"the en pending body carries the privacy clause",
|
|
);
|
|
assert.equal(strings.en.ctaSearchAgain, "SEARCH AGAIN");
|
|
assert.equal(strings.ms.routePendingHeadline, "Penghalaan belum dipasang");
|
|
assert.equal(strings.ms.ctaSearchAgain, "CARI SEMULA");
|
|
assert.equal(strings.en.routeDiffSlower, "+{min} MIN VS ROUTE {n}");
|
|
assert.equal(strings.ms.routeDiffSlower, "+{min} MIN VS LALUAN {n}");
|
|
assert.equal(strings.en.ariaRouteChip, "Route {n}: {min} minutes, {km} kilometres");
|
|
assert.equal(strings.en.routePendingRegister, "ROUTE // ROUTER");
|
|
assert.equal(
|
|
strings.ms.routePendingRegister,
|
|
"ROUTE // ROUTER",
|
|
"instrument vocabulary — identical in both locales",
|
|
);
|
|
});
|
|
|
|
/* ————— COPY8 — guidance copy verbatim (the 18 guidance keys, en + ms) ————— */
|
|
|
|
test("COPY8 guidance copy verbatim (the 18 guidance keys, en + ms, 06-UI-SPEC table)", () => {
|
|
// register + street fallback
|
|
assert.equal(strings.en.guideRegister, "GUIDE");
|
|
assert.equal(strings.ms.guideRegister, "GUIDE", "instrument vocabulary — identical in both locales");
|
|
assert.equal(strings.en.guideStreetUnnamed, "Unnamed road");
|
|
assert.equal(strings.ms.guideStreetUnnamed, "Jalan tanpa nama");
|
|
// the calm banner states (D-04/D-06/D-03)
|
|
assert.equal(strings.en.guideReroutingLine, "Finding a new route from where you are.");
|
|
assert.equal(strings.ms.guideReroutingLine, "Mencari laluan baharu dari kedudukan anda.");
|
|
assert.equal(strings.en.guideResumingLine, "Resuming from your last known progress.");
|
|
assert.equal(strings.ms.guideResumingLine, "Sambung semula dari kemajuan terakhir anda.");
|
|
assert.equal(strings.en.guideNoRerouteLine, "Rerouting unavailable on this server.");
|
|
assert.equal(strings.ms.guideNoRerouteLine, "Pengiraan semula tiada pada pelayan ini.");
|
|
assert.equal(
|
|
strings.en.guideNoRerouteBody,
|
|
"No road graph here — follow the drawn route; recalculation is unavailable.",
|
|
);
|
|
assert.equal(
|
|
strings.ms.guideNoRerouteBody,
|
|
"Tiada graf jalan di sini — ikut laluan yang dilukis; pengiraan semula tidak tersedia.",
|
|
);
|
|
// banner toggle + the guidance card's single action
|
|
assert.equal(strings.en.ctaBannerList, "LIST");
|
|
assert.equal(strings.ms.ctaBannerList, "SENARAI");
|
|
assert.equal(strings.en.ctaBannerClose, "CLOSE");
|
|
assert.equal(strings.ms.ctaBannerClose, "TUTUP");
|
|
assert.equal(strings.en.ctaEndTrip, "END TRIP");
|
|
assert.equal(strings.ms.ctaEndTrip, "TAMAT PERJALANAN");
|
|
// the D-09 confirm guard — the exact privacy copy at the moment it executes
|
|
assert.equal(strings.en.guideConfirmHeadline, "Abandon route?");
|
|
assert.equal(strings.ms.guideConfirmHeadline, "Tinggalkan laluan?");
|
|
assert.equal(
|
|
strings.en.guideConfirmBody,
|
|
"Guidance stops and this trip's record is deleted. Nothing is stored.",
|
|
);
|
|
assert.equal(
|
|
strings.ms.guideConfirmBody,
|
|
"Panduan berhenti dan rekod perjalanan ini dipadam. Tiada apa-apa disimpan.",
|
|
);
|
|
assert.equal(strings.en.ctaConfirmAbandon, "YES, ABANDON");
|
|
assert.equal(strings.ms.ctaConfirmAbandon, "YA, TINGGALKAN");
|
|
assert.equal(strings.en.ctaConfirmContinue, "CONTINUE");
|
|
assert.equal(strings.ms.ctaConfirmContinue, "TERUSKAN");
|
|
// the D-14 trip-end card — ONE card for arrival and abandon
|
|
assert.equal(strings.en.guideEndHeadline, "Trip ended");
|
|
assert.equal(strings.ms.guideEndHeadline, "Perjalanan tamat");
|
|
assert.equal(
|
|
strings.en.guideEndBody,
|
|
"Route guidance stopped and the trip record is deleted — nothing about this drive remains.",
|
|
);
|
|
assert.equal(
|
|
strings.ms.guideEndBody,
|
|
"Panduan laluan dihentikan dan rekod perjalanan dipadam — tiada apa-apa tentang pemanduan ini kekal.",
|
|
);
|
|
// aria labels
|
|
assert.equal(strings.en.ariaManeuverList, "Full maneuver list");
|
|
assert.equal(strings.ms.ariaManeuverList, "Senarai penuh manuver");
|
|
assert.equal(strings.en.ariaBannerToggle, "Next maneuver");
|
|
assert.equal(strings.ms.ariaBannerToggle, "Manuver seterusnya");
|
|
assert.equal(strings.en.ariaTripProgress, "Trip progress");
|
|
assert.equal(strings.ms.ariaTripProgress, "Kemajuan perjalanan");
|
|
});
|