test(04-01): RED — search workspace scaffold + normalizer vector suite (T1-T9)
- add the search/ npm workspace (root workspaces + search:test/search:typecheck delegates) - zero-dependency runner/harness copied from the bot/test precedent - 9 named normalizer laws: rule vectors, symmetry, whole-token-only, slash preservation, glued split, vowel-drop, idempotence, folding, table-is-data (>= 47 rows, one generic builder) All 9 cases fail while src/normalize.ts is absent — the RED gate.
This commit is contained in:
Generated
+12
-1
@@ -8,7 +8,8 @@
|
||||
"workspaces": [
|
||||
"theme",
|
||||
"bot",
|
||||
"web"
|
||||
"web",
|
||||
"search"
|
||||
]
|
||||
},
|
||||
"bot": {
|
||||
@@ -27,6 +28,10 @@
|
||||
"resolved": "bot",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@araharah/search": {
|
||||
"resolved": "search",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@araharah/theme": {
|
||||
"resolved": "theme",
|
||||
"link": true
|
||||
@@ -3944,6 +3949,12 @@
|
||||
"error-stack-parser-es": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"name": "@araharah/search",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"name": "@araharah/theme",
|
||||
"dependencies": {
|
||||
|
||||
+4
-1
@@ -5,7 +5,8 @@
|
||||
"workspaces": [
|
||||
"theme",
|
||||
"bot",
|
||||
"web"
|
||||
"web",
|
||||
"search"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build -w theme && npm run build -w web",
|
||||
@@ -22,6 +23,8 @@
|
||||
"bot:commands": "npm run commands -w bot",
|
||||
"web:test": "npm test -w web",
|
||||
"web:typecheck": "npm run typecheck -w web",
|
||||
"search:test": "npm test -w search",
|
||||
"search:typecheck": "npm run typecheck -w search",
|
||||
"web:dev": "npm run dev -w web",
|
||||
"web:build": "npm run build -w theme && npm run build -w web",
|
||||
"web:build:harness": "npm run build:harness -w web",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@araharah/search",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node test/runner.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* harness.mjs — zero-dependency test registry.
|
||||
* Same hand-rolled shape as bot/test/harness.mjs (the Phase 2 precedent):
|
||||
* suites import { test } and register async cases; runner.mjs drains the registry.
|
||||
*/
|
||||
const tests = [];
|
||||
export function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
export const registry = tests;
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* normalizer.test.mjs — the Malay normalizer vector suite (D-09, SRCH-02).
|
||||
* ----------------------------------------------------------------------
|
||||
* RED phase (plan 04-01): every case below fails while ../src/normalize.ts
|
||||
* does not exist — the module import happens lazily inside each case, so
|
||||
* T1–T9 report one FAIL per law instead of a lost suite load.
|
||||
*
|
||||
* Contract under test (D-09 — rule table + folding + fuzzy-residual):
|
||||
* T1 rule vectors — every expansion/folding vector from the plan's
|
||||
* normalizer contract, asserted one by one
|
||||
* T2 symmetry law — "Jln Ampang" and "Jalan Ampang" normalize to the
|
||||
* IDENTICAL string (the canonical form is always the long form)
|
||||
* T3 whole-token-only expansion — "Pdang" is never expanded; "pdg" is
|
||||
* T4 slash preservation — "Jln 3/1" keeps its "3/1"
|
||||
* T5 glued-word split — "tamanbangsar" splits; "jalankanpar" yields a
|
||||
* standalone "jalan" token
|
||||
* T6 vowel-drop — "Prsn 15" and "Presint 15" both hit "presint 15"
|
||||
* T7 idempotence — normalize(normalize(v)) === normalize(v) for every
|
||||
* vector in this suite
|
||||
* T8 case/whitespace folding
|
||||
* T9 the table is DATA — abbreviations.ts is data plus one generic
|
||||
* builder (no algorithm code), and the vendored table is >= 47 rows
|
||||
*
|
||||
* Run: npm run search:test (exit 0 = all green)
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "./harness.mjs";
|
||||
|
||||
// Lazy normalizer load — missing module => per-case ERR_MODULE_NOT_FOUND (the RED state).
|
||||
let normalizePromise = null;
|
||||
function loadNormalizer() {
|
||||
if (!normalizePromise) {
|
||||
normalizePromise = import("../src/normalize.ts").then((m) => m.normalize);
|
||||
}
|
||||
return normalizePromise;
|
||||
}
|
||||
|
||||
// Lazy rules-table load (T9 asserts on the module itself, not just its consumer).
|
||||
let rulesPromise = null;
|
||||
function loadRules() {
|
||||
if (!rulesPromise) {
|
||||
rulesPromise = import("../src/rules/abbreviations.ts");
|
||||
}
|
||||
return rulesPromise;
|
||||
}
|
||||
|
||||
/** The plan's minimum vector set — every row is [input, expectedNormalForm]. */
|
||||
const VECTORS = [
|
||||
["Jln Ampang", "jalan ampang"],
|
||||
["Jalan Ampang", "jalan ampang"],
|
||||
["Jln Tmn Desa", "jalan taman desa"],
|
||||
["Tmn Bangsar", "taman bangsar"],
|
||||
["Kg Ampang", "kampung ampang"],
|
||||
["Kpg Ampang", "kampung ampang"],
|
||||
["Prsn 15", "presint 15"],
|
||||
["Presint 15", "presint 15"],
|
||||
["tamanbangsar", "taman bangsar"],
|
||||
["Jln 3/1", "jalan 3/1"],
|
||||
["Bdr Baru", "bandar baru"],
|
||||
["Sunway Pyramid", "sunway pyramid"],
|
||||
[" SUNWAY PYRAMID ", "sunway pyramid"],
|
||||
["Kuala Lumpur", "kuala lumpur"],
|
||||
["PJ", "pj"],
|
||||
];
|
||||
|
||||
test("T1 rule vectors — the plan's minimum expansion/folding set", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
for (const [input, expected] of VECTORS) {
|
||||
assert.equal(normalize(input), expected, `normalize(${JSON.stringify(input)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test("T2 symmetry law — abbreviated and full forms normalize identically", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
assert.equal(normalize("Jln Ampang"), normalize("Jalan Ampang"), "Jln/Jalan must fold to one form");
|
||||
assert.equal(normalize("Tmn Bangsar"), normalize("Taman Bangsar"), "Tmn/Taman must fold to one form");
|
||||
});
|
||||
|
||||
test("T3 whole-token-only expansion — 'Pdang' is never expanded, 'pdg' is", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
assert.equal(normalize("Pdang"), "pdang", "a non-token substring must never expand");
|
||||
assert.equal(normalize("pdg"), "padang", "the standalone abbreviation must expand");
|
||||
assert.ok(!normalize("Pdang").includes("padang"), "Pdang must not contain the expansion");
|
||||
});
|
||||
|
||||
test("T4 slash preservation — Malaysian plot shorthand 'Jln 3/1' keeps its slash", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
assert.equal(normalize("Jln 3/1"), "jalan 3/1");
|
||||
});
|
||||
|
||||
test("T5 glued-word split — 'tamanbangsar' splits; 'jalankanpar' yields a standalone 'jalan'", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
assert.equal(normalize("tamanbangsar"), "taman bangsar");
|
||||
const tokens = normalize("jalankanpar").split(" ");
|
||||
assert.ok(tokens.includes("jalan"), `"jalan" must be its own token, got: ${tokens.join("|")}`);
|
||||
});
|
||||
|
||||
test("T6 vowel-drop — 'Prsn 15' and 'Presint 15' both reach 'presint 15'", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
assert.equal(normalize("Prsn 15"), "presint 15");
|
||||
assert.equal(normalize("Presint 15"), "presint 15");
|
||||
});
|
||||
|
||||
test("T7 idempotence — normalize(normalize(v)) === normalize(v) for every vector", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
for (const [input] of VECTORS) {
|
||||
const once = normalize(input);
|
||||
assert.equal(normalize(once), once, `idempotence broken for ${JSON.stringify(input)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("T8 case/whitespace folding — ' SUNWAY PYRAMID ' folds to 'sunway pyramid'", async () => {
|
||||
const normalize = await loadNormalizer();
|
||||
assert.equal(normalize(" SUNWAY PYRAMID "), "sunway pyramid");
|
||||
});
|
||||
|
||||
test("T9 the table is DATA — data plus one generic builder, >= 47 rows", async () => {
|
||||
const path = new URL("../src/rules/abbreviations.ts", import.meta.url);
|
||||
const raw = readFileSync(path, "utf8");
|
||||
// Strip block comments and full-line // comments before asserting code shape.
|
||||
const stripped = raw
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
const builderMarker = "export function buildAbbreviationMap";
|
||||
const builderAt = stripped.indexOf(builderMarker);
|
||||
assert.ok(builderAt !== -1, "buildAbbreviationMap must exist");
|
||||
const dataSection = stripped.slice(0, builderAt);
|
||||
for (const banned of [/\bfunction\b/, /\bif\b/, /\bfor\b/, /\bwhile\b/, /=>/]) {
|
||||
assert.doesNotMatch(dataSection, banned, `the DATA section must not contain ${banned}`);
|
||||
}
|
||||
const functionCount = (stripped.match(/\bfunction\b/g) ?? []).length;
|
||||
assert.equal(functionCount, 1, "the only function in the file must be buildAbbreviationMap");
|
||||
assert.doesNotMatch(stripped, /=>/, "no arrow functions anywhere — data plus one loop-based builder");
|
||||
|
||||
const rules = await loadRules();
|
||||
assert.ok(Array.isArray(rules.ABBREVIATION_ROWS), "ABBREVIATION_ROWS must be an array");
|
||||
assert.ok(rules.ABBREVIATION_ROWS.length >= 47, `table must be >= 47 rows, got ${rules.ABBREVIATION_ROWS.length}`);
|
||||
for (const row of rules.ABBREVIATION_ROWS) {
|
||||
assert.ok(Array.isArray(row) && row.length === 2, "every row is a [fullForm, abbreviations] pair");
|
||||
assert.equal(typeof row[0], "string");
|
||||
assert.equal(typeof row[1], "string");
|
||||
assert.ok(row[0].length > 0 && row[1].length > 0, "neither side of a row may be empty");
|
||||
}
|
||||
const map = rules.buildAbbreviationMap();
|
||||
assert.equal(map.get("jln")?.[0], "jalan", "jln must map to jalan");
|
||||
assert.deepEqual(map.get("presint"), ["presint"], "the canonical long form maps to itself");
|
||||
assert.deepEqual(map.get("prsn"), ["presint"], "Prsn -> Presint (the D-09 vowel-drop row)");
|
||||
assert.ok(map.get("kampung")?.includes("kampung"), "kampung maps to itself");
|
||||
assert.ok(map.get("kg")?.includes("kampung"), "kg must reach kampung");
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* runner.mjs — hand-rolled zero-dependency test runner.
|
||||
*
|
||||
* Same shape as bot/test/runner.mjs (the Phase 2 precedent):
|
||||
* - Auto-discovers *.test.mjs in this directory (sorted) and dynamic-imports them.
|
||||
* - A suite that fails to LOAD is recorded as one failing case
|
||||
* ("suite load failed: <file>") — the run never crashes mid-way.
|
||||
* - Per case: PASS <name> / FAIL <name> (+ error detail).
|
||||
* - Summary line + exit codes: exit 0 only when every case passed.
|
||||
*/
|
||||
import { readdirSync } from "node:fs";
|
||||
import { registry } from "./harness.mjs";
|
||||
|
||||
const dir = import.meta.dirname;
|
||||
const suites = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".test.mjs"))
|
||||
.sort();
|
||||
|
||||
for (const file of suites) {
|
||||
try {
|
||||
await import(`./${file}`);
|
||||
} catch (loadErr) {
|
||||
registry.push({
|
||||
name: `suite load failed: ${file}`,
|
||||
fn: () => {
|
||||
throw loadErr;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let passedCount = 0;
|
||||
let failedCount = 0;
|
||||
for (const { name, fn } of registry) {
|
||||
try {
|
||||
await fn();
|
||||
passedCount++;
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.log(`FAIL ${name}`);
|
||||
console.error(` ${err && err.stack ? err.stack : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[runner] ${passedCount} passed, ${failedCount} failed`);
|
||||
process.exitCode = failedCount > 0 ? 1 : 0;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user