#!/usr/bin/env node /* * gazetteer.test.mjs — dataset invariants + licensing gates (D-04, D-06, * D-07, D-08, D-15). * -------------------------------------------------------------------- * RED phase (plan 04-01 task 3): the data-dependent cases fail while * kl-gazetteer.json does not exist, and the licensing cases fail while * LICENSE / LICENSES.md / the README correction are absent. * * G1 entry schema — id/name/aliases/lat/lng/category/address * G2 id uniqueness + slug charset (/^[a-z0-9-]{1,64}$/) * G3 canonical names vs shorthand — "Suria KLCC" is the NAME, "KLCC" * is an ALIAS (RESEARCH Verification A3: the alias list IS the product) * G4 "DUKE" is an alias of the-duke; no entry is NAMED "DUKE" * G5 coordinates inside the KL metro bbox * G6 the ODbL header travels INSIDE the JSON * G7 LICENSE exists at the repo root and is the GPL-3.0 text * G8 LICENSES.md documents BOTH licences + the OSM attribution * G9 the README no longer claims "all rights reserved" * G10 the shared module has no logging and no storage (D-15/T-04-01/02) * G11 the loader is lazy — no index build at module top level (T-04-04) * * Run: npm run search:test (exit 0 = all green) */ import { strict as assert } from "node:assert"; import { readFileSync, readdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; const here = dirname(fileURLToPath(import.meta.url)); // search/test const srcDir = join(here, "..", "src"); // search/src const repoRoot = join(here, "..", ".."); // repo root /** Closed category set the seed contract pins. */ const CATEGORIES = new Set([ "mall", "office-tower", "park", "station", "hospital", "road", "highway", "landmark", "convention", "village", "education", "government", ]); function loadGazetteerJson() { const text = readFileSync(join(srcDir, "data", "kl-gazetteer.json"), "utf8"); return JSON.parse(text); } /** Strip block comments and full-line // comments. */ function stripComments(text) { return text.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/^\s*\/\/.*$/gm, " "); } /** Every .ts file under search/src (recursive). */ function srcTsFiles() { const files = readdirSync(srcDir, { recursive: true }); const out = []; for (const f of files) { if (typeof f === "string" && f.endsWith(".ts")) out.push(join(srcDir, f)); } return out; } test("G1 entry schema — every entry is complete, finite, and categorised", () => { const { entries } = loadGazetteerJson(); assert.ok(Array.isArray(entries) && entries.length >= 30, `seed floor is 30 entries, got ${entries.length}`); for (const e of entries) { assert.ok(typeof e.id === "string" && e.id.length > 0, `id: ${e.id}`); assert.ok(typeof e.name === "string" && e.name.length > 0, `name of ${e.id}`); assert.ok(Array.isArray(e.aliases) && e.aliases.length > 0, `aliases of ${e.id}`); assert.ok(e.aliases.includes(e.name), `${e.id}: aliases must include the canonical name`); assert.ok(Number.isFinite(e.lat) && Number.isFinite(e.lng), `coords of ${e.id}`); assert.ok(CATEGORIES.has(e.category), `category of ${e.id}: ${e.category}`); assert.ok(typeof e.address === "string" && e.address.length > 0, `address of ${e.id}`); } }); test("G2 id uniqueness + slug charset", () => { const { entries } = loadGazetteerJson(); const ids = entries.map((e) => e.id); assert.equal(new Set(ids).size, ids.length, "ids must be unique — they are the deep-link currency (D-11)"); for (const id of ids) { assert.match(id, /^[a-z0-9-]{1,64}$/, `id charset: ${id}`); } }); test("G3 canonical name vs shorthand — 'Suria KLCC' is the name, 'KLCC' is an alias", () => { const { entries } = loadGazetteerJson(); const klcc = entries.find((e) => e.id === "suria-klcc"); assert.notEqual(klcc, undefined, "suria-klcc must exist"); assert.equal(klcc.name, "Suria KLCC"); assert.ok(klcc.aliases.includes("KLCC"), "'KLCC' must be an explicit alias — it is not the OSM name"); assert.ok(!entries.some((e) => e.name === "KLCC"), "no entry may be NAMED the shorthand"); }); test("G4 'DUKE' is an alias; no entry is named the acronym", () => { const { entries } = loadGazetteerJson(); const duke = entries.find((e) => e.id === "the-duke"); assert.notEqual(duke, undefined, "the-duke must exist"); assert.ok(duke.aliases.includes("DUKE"), "the highway acronym rides as an alias"); assert.notEqual(duke.name, "DUKE", "DUKE is an acronym, not a landmark name"); for (const e of entries) { assert.notEqual(e.name, "DUKE"); } }); test("G5 coordinates sit inside the KL metro bbox", () => { const { entries } = loadGazetteerJson(); for (const e of entries) { assert.ok(e.lat >= 2.9 && e.lat <= 3.4, `lat of ${e.id}: ${e.lat}`); assert.ok(e.lng >= 101.4 && e.lng <= 102.0, `lng of ${e.id}: ${e.lng}`); } }); test("G6 the ODbL header travels inside the JSON (ODbL §4.4 share-alike)", () => { const data = loadGazetteerJson(); assert.ok(String(data._license).includes("ODbL"), "_license must name ODbL"); assert.ok(String(data._attribution).includes("OpenStreetMap"), "_attribution must credit OpenStreetMap"); assert.ok( String(data._notice).includes("opendatacommons.org/licenses/odbl"), "_notice must link the ODbL text", ); assert.ok(data.version >= 1); }); test("G7 LICENSE exists at the repo root and is the GPL-3.0 text (D-04)", () => { const license = readFileSync(join(repoRoot, "LICENSE"), "utf8"); assert.ok(license.includes("GNU GENERAL PUBLIC LICENSE")); assert.ok(license.includes("Version 3, 29 June 2007")); assert.ok(license.includes("TERMS AND CONDITIONS")); }); test("G8 LICENSES.md documents both licences + the OSM attribution", () => { const doc = readFileSync(join(repoRoot, "LICENSES.md"), "utf8"); for (const needle of ["GPL-3.0", "ODbL", "OpenStreetMap"]) { assert.ok(doc.includes(needle), `LICENSES.md must name ${needle}`); } }); test("G9 the README licence section is no longer self-contradictory", () => { const readme = readFileSync(join(repoRoot, "README.md"), "utf8"); assert.ok(readme.includes("GPL-3.0"), "README must point at the GPL-3.0 LICENSE"); assert.ok(!readme.includes("all rights reserved"), "the private-repo claim must be gone"); }); test("G10 the shared module has no logging and no storage surface (D-15)", () => { const banned = ["console.log", "localStorage", "sessionStorage", "indexedDB", "document.cookie"]; for (const file of srcTsFiles()) { const stripped = stripComments(readFileSync(file, "utf8")); for (const token of banned) { assert.ok(!stripped.includes(token), `${file} must not contain ${token} — the query text is user payload`); } } }); test("G11 the loader is lazy — the index is built inside a function, never at module scope", async () => { const loaderPath = join(srcDir, "gazetteer.ts"); const stripped = stripComments(readFileSync(loaderPath, "utf8")); const firstFn = stripped.search(/\bfunction\b/); assert.ok(firstFn >= 0, "gazetteer.ts must declare functions"); const topLevel = stripped.slice(0, firstFn); assert.ok(!topLevel.includes("new Map("), "no index construction at module top level (Workers 1s startup, T-04-04)"); assert.ok(!topLevel.includes("JSON."), "no JSON traversal at module top level"); assert.ok(!topLevel.includes("for ("), "no dataset walking at module top level"); const mod = await import("../src/gazetteer.ts"); for (const fn of ["getEntries", "getEntryById", "getIndex"]) { assert.equal(typeof mod[fn], "function", `export ${fn} must be a function`); } const entries = mod.getEntries(); assert.ok(entries.length >= 30); const byId = mod.getEntryById("suria-klcc"); assert.notEqual(byId, null, "honest lookup finds the seed anchor"); assert.equal(mod.getEntryById("not-a-real-id"), null, "unknown id -> honest null"); const index = mod.getIndex(); assert.ok(index instanceof Map); assert.ok(index.has("klcc"), "the index is keyed by NORMALIZED aliases (D-09 symmetry)"); });