Files
tiennm99 6569f095c9 feat(game): serve panorama index from Neon Postgres
- panoramas + pano_provinces tables replace 28MB bundled JSON; pano-index.js
  now draws via cached COUNT + ORDER BY id OFFSET with rejection sampling,
  composite (province,id)/(district,id) indexes support the skip
- scripts/seed-pano-db.mjs validates pipeline artifacts (the old real-data
  vitest invariants, extracted to scripts/lib/pano-artifacts.mjs), stages
  into panoramas_next, verifies, renames into place in one transaction,
  keeps panoramas_old as backup; --province reseeds in place; --check
  validates only
- pipeline writes gitignored data-build/panos/; pano barrel removed
- tests run against PGlite mocked in at the @neondatabase/serverless
  boundary, mirroring the fake-upstash pattern; fixtures replace real data
- infrastructure errors rethrow instead of reading as missing coverage;
  session ids via crypto.randomUUID (uuid package dropped)
2026-08-31 09:44:03 +07:00

60 lines
2.9 KiB
JavaScript

// Structural validation of one panorama pipeline artifact, shared by
// scripts/seed-pano-db.mjs (the gate before anything is uploaded) and its
// tests. Every check here used to be a vitest assertion against the committed
// JSON index; once the JSON left the repo, seed time became the last moment
// these invariants can see the actual artifacts.
/**
* Throw if an artifact is not fit to serve.
*
* Pure with respect to the filesystem and the region tree: the caller supplies
* the expectations, so tests can feed synthetic artifacts without a tree.
* @param {string} code Province code the artifact must describe.
* @param {Object} index Parsed artifact JSON.
* @param {number[]} bbox The province bbox from the region tree, [w,s,e,n].
* @param {string[]} expectedDistricts The tree's mapped leaves for the province.
*/
export function validatePanoArtifact(code, index, bbox, expectedDistricts) {
const fail = (why) => {
throw new Error(`${code}: ${why}`);
};
if (index.code !== code) fail(`artifact says it is ${index.code}`);
if (!Array.isArray(index.panos) || index.panos.length === 0) fail('no panoramas');
// Well-formed rows, unique ids, inside the province bbox from the tree.
const [minLng, minLat, maxLng, maxLat] = bbox;
const ids = new Set();
for (const p of index.panos) {
if (typeof p.id !== 'string' || p.id.length === 0) fail('malformed id');
if (!Number.isFinite(p.lat) || !Number.isFinite(p.lng)) fail(`bad coordinates on ${p.id}`);
if (ids.has(p.id)) fail(`duplicate id ${p.id}`);
ids.add(p.id);
if (p.lat < minLat || p.lat > maxLat || p.lng < minLng || p.lng > maxLng) {
fail(`${p.id} sits outside the province bbox`);
}
if (p.d !== undefined && !(p.d >= 0 && p.d < index.districts.length)) {
fail(`${p.id} has an out-of-range district index`);
}
}
// District partition: complete, nothing left unassigned, and the fallback
// placements close enough to trust (see assign-pano-districts.mjs). The
// schema tolerates NULL districts (province-only crediting), but the current
// pipeline always assigns, so an unassigned point here means the assign step
// was skipped, not that the data is legitimately province-only.
const assigned = Object.values(index.districtCounts).reduce((a, b) => a + b, 0);
if (assigned + index.unassigned !== index.panos.length) fail('district counts do not add up');
if (index.unassigned !== 0) fail(`${index.unassigned} panoramas left unassigned`);
if (index.stranded / index.panos.length >= 0.02) {
fail(`${((index.stranded / index.panos.length) * 100).toFixed(2)}% stranded`);
}
if (index.worstStrandedKm >= 1.1) fail(`worst stranded placement ${index.worstStrandedKm}km`);
// District codes must be exactly the tree's mapped leaves for this province.
const got = [...index.districts].sort();
if (JSON.stringify(got) !== JSON.stringify([...expectedDistricts].sort())) {
fail(`districts do not match the region tree: ${got.join(',')}`);
}
}