#!/usr/bin/env node /* * converter.test.mjs — the D-05 JSON→SQLite converter's laws. * ----------------------------------------------------------- * Backend #2 (the Malaysia-only WASM geocoder) must stay a FORMAT change, * not a rewrite (D-05): the converter reads the SAME committed dataset the * Worker serves from and emits a dataset with the SAME id set, today. * * CV1 the converter preserves the id set — same members, same count * CV2 the converter reads the committed dataset (kl-gazetteer.json) * CV3 the converter is not a runtime dependency — nothing under * bot/src/ or web/src/ mentions sqlite or the converter * * Run: npm run search:test (exit 0 = all green) */ import { strict as assert } from "node:assert"; import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { test } from "./harness.mjs"; const SEARCH_DIR = path.resolve(import.meta.dirname, ".."); const CONVERTER = path.join(SEARCH_DIR, "scripts", "json-to-sqlite.mjs"); const GAZETTEER = path.join(SEARCH_DIR, "src", "data", "kl-gazetteer.json"); const SRC_TREES = [ path.join(SEARCH_DIR, "..", "bot", "src"), path.join(SEARCH_DIR, "..", "web", "src"), ]; function scriptSource() { return readFileSync(CONVERTER, "utf8"); } function gazetteerIds() { const gazetteer = JSON.parse(readFileSync(GAZETTEER, "utf8")); return gazetteer.entries.map((e) => e.id).sort(); } /** Recursively list every file under dir (source-introspection walk). */ function walkFiles(dir) { const out = []; if (!existsSync(dir)) return out; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) out.push(...walkFiles(full)); else out.push(full); } return out; } /** Run the converter into a fresh temp dir; returns { dir, files }. */ function runConverter() { const dir = mkdtempSync(path.join(tmpdir(), "araharah-conv-")); execFileSync(process.execPath, [CONVERTER, "--out", dir], { encoding: "utf8" }); return { dir, files: readdirSync(dir) }; } /** Read the id set back out of whatever artifact the converter emitted * (a real .sqlite via node:sqlite, or the documented .sql dump). */ function readIdsFrom(dir, files) { const sqliteFile = files.find((f) => f.endsWith(".sqlite")); if (sqliteFile) { const db = new DatabaseSync(path.join(dir, sqliteFile), { readOnly: true }); const rows = db.prepare("SELECT id FROM places ORDER BY id").all(); db.close(); return rows.map((r) => r.id); } const dumpFile = files.find((f) => f.endsWith(".sql")); assert.ok(dumpFile, `converter emitted no readable artifact: ${files.join(", ")}`); const dump = readFileSync(path.join(dir, dumpFile), "utf8"); const ids = []; for (const match of dump.matchAll(/INSERT INTO places[\s\S]*?\(\s*'([^']+)'/g)) { ids.push(match[1]); } return ids.sort(); } /* ————— CV1 — the id set is preserved exactly ————— */ test("CV1 the converter preserves the gazetteer's id set exactly", () => { const { dir, files } = runConverter(); try { assert.ok(files.length > 0, "converter emitted nothing"); const emitted = readIdsFrom(dir, files); assert.deepEqual( emitted, gazetteerIds(), "backend #2's dataset format must carry the SAME id set as the committed JSON (D-05)", ); } finally { rmSync(dir, { recursive: true, force: true }); } }); /* ————— CV2 — it reads the committed dataset ————— */ test("CV2 the converter reads the committed dataset", () => { const src = scriptSource(); assert.match( src, /kl-gazetteer\.json/, "the converter must read the same committed JSON the Worker serves from (D-07 single source)", ); }); /* ————— CV3 — never a runtime dependency ————— */ test("CV3 the converter is not a runtime dependency", () => { const offenders = []; for (const tree of SRC_TREES) { for (const file of walkFiles(tree)) { const src = readFileSync(file, "utf8"); if (/sqlite|json-to-sqlite/i.test(src)) offenders.push(path.relative(process.cwd(), file)); } } assert.equal( offenders.length, 0, `D-05 violation: sqlite/json-to-sqlite appears under bot/src/ or web/src/ — ` + `the converter is a repo tool (the Worker's canonical format is typed JSON, D-07): ` + `${offenders.join(", ")}`, ); });