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": [
|
"workspaces": [
|
||||||
"theme",
|
"theme",
|
||||||
"bot",
|
"bot",
|
||||||
"web"
|
"web",
|
||||||
|
"search"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"bot": {
|
"bot": {
|
||||||
@@ -27,6 +28,10 @@
|
|||||||
"resolved": "bot",
|
"resolved": "bot",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@araharah/search": {
|
||||||
|
"resolved": "search",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@araharah/theme": {
|
"node_modules/@araharah/theme": {
|
||||||
"resolved": "theme",
|
"resolved": "theme",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -3944,6 +3949,12 @@
|
|||||||
"error-stack-parser-es": "^1.0.5"
|
"error-stack-parser-es": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"name": "@araharah/search",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"name": "@araharah/theme",
|
"name": "@araharah/theme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,8 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"theme",
|
"theme",
|
||||||
"bot",
|
"bot",
|
||||||
"web"
|
"web",
|
||||||
|
"search"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build -w theme && npm run build -w web",
|
"build": "npm run build -w theme && npm run build -w web",
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
"bot:commands": "npm run commands -w bot",
|
"bot:commands": "npm run commands -w bot",
|
||||||
"web:test": "npm test -w web",
|
"web:test": "npm test -w web",
|
||||||
"web:typecheck": "npm run typecheck -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:dev": "npm run dev -w web",
|
||||||
"web:build": "npm run build -w theme && npm run build -w web",
|
"web:build": "npm run build -w theme && npm run build -w web",
|
||||||
"web:build:harness": "npm run build:harness -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