Files
arah2/search/test/converter.test.mjs
T
araharah-executor ec98c8d454 test(04-02): RED — D-06 topup law suite (TP1-TP7) + D-05 converter suite (CV1-CV3)
- TP1 no 'overpass' under bot/src|web/src (never a runtime dependency)
- TP2 identifying User-Agent pinned (missing UA = HTTP 406, live-verified)
- TP3 bbox-bounded query tuple present
- TP4 busy fixture -> exit 0 + 'Overpass instance busy' soft-exit line
- TP5 committed fixture -> PROPOSAL output with name= lines + NEVER-writes law
- TP6 source never names the curated dataset file
- TP7 exactly one fetch( call site (shared helper for both live paths)
- CV1 converter preserves the gazetteer id set exactly (read back via node:sqlite)
- CV2 converter reads the committed kl-gazetteer.json
- CV3 no sqlite/json-to-sqlite under bot/src|web/src

8 cases verified failing (scripts absent) before GREEN.
2026-10-24 00:56:08 +08:00

126 lines
4.5 KiB
JavaScript

#!/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(", ")}`,
);
});