diff --git a/package-lock.json b/package-lock.json index 2ae356b..8fae74e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index de5c8bf..49ea989 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/search/package.json b/search/package.json new file mode 100644 index 0000000..d1778f2 --- /dev/null +++ b/search/package.json @@ -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" + } +} diff --git a/search/test/harness.mjs b/search/test/harness.mjs new file mode 100644 index 0000000..ac20a5b --- /dev/null +++ b/search/test/harness.mjs @@ -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; diff --git a/search/test/normalizer.test.mjs b/search/test/normalizer.test.mjs new file mode 100644 index 0000000..4886282 --- /dev/null +++ b/search/test/normalizer.test.mjs @@ -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"); +}); diff --git a/search/test/runner.mjs b/search/test/runner.mjs new file mode 100644 index 0000000..eb6ba70 --- /dev/null +++ b/search/test/runner.mjs @@ -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: ") — the run never crashes mid-way. + * - Per case: PASS / FAIL (+ 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; diff --git a/search/tsconfig.json b/search/tsconfig.json new file mode 100644 index 0000000..bad2c37 --- /dev/null +++ b/search/tsconfig.json @@ -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"] +}