#!/usr/bin/env node /* * score.test.mjs — the disjoint-tier scorer suite (D-09, SRCH-02). * ---------------------------------------------------------------- * RED phase (plan 04-01): every case fails while ../src/score.ts does not * exist — the import happens lazily inside each case. * * Contract under test: * S1 tier disjointness — EXACT > ALIAS_EXACT > PREFIX > TOKEN_SET > * FUZZY, with FUZZY the 0.60 CEILING of its band (never a 0.00 * multiplier) and every admitted fuzzy score strictly inside (0, 0.60) * S2 min-length guard — either side < 3 chars scores 0, EVEN on an * exact match (the guard fires first — Pitfall 5) * S3 exact outranks fuzzy — always * S4 prefix tier — a mid-word prefix lands exactly on TIER.PREFIX * S5 token-set tier — a query whose every token is present in the * candidate's tokens is TOKEN_SET; a non-subset must NOT be * S6 fuzzy residual + admission boundary — 'kclc'/'klcc' is * 0.75 * TIER.FUZZY exactly; sim 0.75 >= 0.72 admits while the * 'penang'/'padang' shape (sim 0.667) stays below FUZZY_ADMIT_SIMILARITY * S7 matchedAlias provenance — scoreCandidate returns the RAW alias * string that produced the score, and an exact alias hit rides the * ALIAS_EXACT band (so a name-exact on another entry outranks it) * * Run: npm run search:test (exit 0 = all green) */ import { strict as assert } from "node:assert"; import { test } from "./harness.mjs"; // Lazy module loads — missing modules => per-case ERR_MODULE_NOT_FOUND (the RED state). let scorePromise = null; function loadScore() { if (!scorePromise) { scorePromise = import("../src/score.ts"); } return scorePromise; } let distancePromise = null; function loadDamerau() { if (!distancePromise) { distancePromise = import("../src/distance.ts").then((m) => m.damerau); } return distancePromise; } /** The whole-string similarity law the scorer uses: 1 - damerau/maxLen. */ async function sim(a, b) { const damerau = await loadDamerau(); return 1 - damerau(a, b) / Math.max(a.length, b.length); } test("S1 tier disjointness — strictly descending bands, FUZZY is the 0.60 ceiling", async () => { const { TIER, scoreOne } = await loadScore(); assert.ok(TIER.EXACT > TIER.ALIAS_EXACT, "EXACT > ALIAS_EXACT"); assert.ok(TIER.ALIAS_EXACT > TIER.PREFIX, "ALIAS_EXACT > PREFIX"); assert.ok(TIER.PREFIX > TIER.TOKEN_SET, "PREFIX > TOKEN_SET"); assert.ok(TIER.TOKEN_SET > TIER.FUZZY, "TOKEN_SET > FUZZY"); assert.equal(TIER.FUZZY, 0.60, "FUZZY is the band ceiling, not a 0.00 multiplier"); const fuzzyScore = scoreOne("kclc", "klcc"); assert.ok(fuzzyScore > 0, "an admitted fuzzy score is > 0"); assert.ok(fuzzyScore < TIER.FUZZY, "an admitted fuzzy score is < 0.60 — a sim of 1.0 would be EXACT"); }); test("S2 min-length guard — sub-3-char sides score 0 even on an exact match", async () => { const { scoreOne } = await loadScore(); assert.equal(scoreOne("ab", "abcd"), 0, "short query is refused"); assert.equal(scoreOne("kl", "kl"), 0, "the guard fires BEFORE the exact tier"); }); test("S3 exact outranks fuzzy — always", async () => { const { TIER, scoreOne } = await loadScore(); assert.ok(scoreOne("klcc", "klcc") > scoreOne("klcc", "klca"), "exact beats the near-miss"); assert.ok(scoreOne("klcc", "klcc") > TIER.FUZZY, "EXACT sits above the whole fuzzy band"); }); test("S4 prefix tier — 'jalan amp' vs 'jalan ampang' lands exactly on TIER.PREFIX", async () => { const { TIER, scoreOne } = await loadScore(); assert.equal(scoreOne("jalan amp", "jalan ampang"), TIER.PREFIX); }); test("S5 token-set tier — full token subset is TOKEN_SET; a non-subset is not", async () => { const { TIER, scoreOne } = await loadScore(); assert.equal( scoreOne("taman bangsar", "taman bangsar kib"), TIER.TOKEN_SET, "every query token present in the candidate's tokens", ); const nonSubset = scoreOne("taman bangsar", "taman bukit"); assert.notEqual(nonSubset, TIER.TOKEN_SET, "a non-subset must NOT ride the token-set band"); assert.ok(nonSubset < TIER.FUZZY, "it falls into the fuzzy band strictly below the ceiling"); }); test("S6 fuzzy residual + admission boundary — 0.75 admits, the 0.667 shape does not", async () => { const { TIER, FUZZY_ADMIT_SIMILARITY, scoreOne } = await loadScore(); assert.equal(scoreOne("kclc", "klcc"), 0.75 * TIER.FUZZY, "damerau 1 over len 4 -> sim 0.75 exactly"); const typoSim = await sim("kclc", "klcc"); assert.equal(typoSim, 0.75); assert.ok(typoSim >= FUZZY_ADMIT_SIMILARITY, "the KCLC->KLCC typo bucket is ADMITTED"); const negativeSim = await sim("penang", "padang"); assert.equal(negativeSim, 1 - 2 / 6, "two substitutions over 6 chars -> sim 0.667"); assert.ok(negativeSim < FUZZY_ADMIT_SIMILARITY, "the Penang/Padang near-miss shape is REFUSED"); }); test("S7 matchedAlias provenance — the RAW alias string rides the hit", async () => { const { TIER, FUZZY_ADMIT_SIMILARITY, scoreCandidate } = await loadScore(); const aliasHit = scoreCandidate("klcc", ["Suria KLCC", "KLCC"]); assert.notEqual(aliasHit, null, "an exact alias match must score"); assert.equal(aliasHit.matchedAlias, "KLCC", "provenance is the raw, un-normalized alias"); assert.ok( aliasHit.score === TIER.ALIAS_EXACT || aliasHit.score === TIER.EXACT, `an exact alias hit rides the exact/alias-exact band, got ${aliasHit.score}`, ); assert.equal(aliasHit.score, TIER.ALIAS_EXACT, "an alias exact (name differs) is ALIAS_EXACT"); const nameHit = scoreCandidate("suria klcc", ["Suria KLCC", "KLCC"]); assert.notEqual(nameHit, null); assert.equal(nameHit.score, TIER.EXACT, "a name exact is the EXACT band"); assert.equal(nameHit.matchedAlias, "Suria KLCC", "the name produced it"); // Out-of-area: scoreCandidate still SCORES ("Kuching" lands somewhere in // the fuzzy band) — but its SIMILARITY stays below FUZZY_ADMIT_SIMILARITY, // so the source refuses it. Admission is source.ts's job, not scoring's. const refused = scoreCandidate("kuching", ["Suria KLCC", "KLCC"]); if (refused !== null) { assert.ok( refused.score < TIER.FUZZY, "the out-of-area shape must not ride a cheap tier", ); assert.ok( refused.score / TIER.FUZZY < FUZZY_ADMIT_SIMILARITY, "the out-of-area similarity must stay below the 0.72 admission floor", ); } });