- 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.
126 lines
4.5 KiB
JavaScript
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(", ")}`,
|
|
);
|
|
});
|