Clear check:no-fleet-names (neutral wording for the classic4 reference, the vendored-fork note, the browser-budget comment, the gate's format comment and the package.json description). Fix pairedRows leaders[].polys (was null on every leader — store fork.polys; drop dead polysForCount). Tie-break ids by code unit (cmpId), not localeCompare, for byte-identity across runtimes/locales. Harden the gate: assert the fourSides side sequence L<T<R<B and monotone numbering within each side (by label emission order), and recount pins-outside-state and pairedRows crossings from the OUTPUT, not from stats.
223 lines
14 KiB
JavaScript
223 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-circular-layout.mjs — gate for library/packet/circularLayout.js.
|
|
*
|
|
* Builds a FROZEN synthetic 855-pin India-scale fixture (sheet 2500x4000, the
|
|
* classic4 60x96 constants) with a grid of fake ADM1 "states", then asserts:
|
|
*
|
|
* fourSides — numbersRingOrdered true, numberDupes 0, pinsOutsideState 0,
|
|
* >= 800 pins placed (no vacuous pass).
|
|
* pairedRows — 0 crossings on the clean set, pinsOutsideState 0, octilinear true.
|
|
* sideLanes — runs, returns stats with a pin-count floor and octilinear leaders.
|
|
* determinism — two fourSides runs byte-identical.
|
|
* poison — a state far too small for its pins is NAMED in infeasible
|
|
* (never silently stacked), surfaced via stats.infeasible > 0.
|
|
* budget — fourSides on 855 pins runs under BUDGET_MS (1.5x a measured
|
|
* baseline) and under a heap ceiling. Median of 3, so it is not flaky.
|
|
*
|
|
* Node only, headless, deterministic. Exit 0 PASS / 1 FAIL / 75 import failure.
|
|
*/
|
|
let layoutCircular;
|
|
try { ({ layoutCircular } = await import('../circularLayout.js')); }
|
|
catch (e) { console.error(`NOT MEASURED: cannot import circularLayout.js — ${e.message}`); process.exit(75); }
|
|
|
|
const fails = [], note = [];
|
|
|
|
// Independent recounts — defined HERE, not imported from the module, so a broken
|
|
// counter in circularLayout.js cannot make its own stats agree with itself.
|
|
function gateInRing(px, py, ring) {
|
|
let hit = false;
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const [xi, yi] = ring[i], [xj, yj] = ring[j];
|
|
if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / ((yj - yi) || 1e-12) + xi) hit = !hit;
|
|
}
|
|
return hit;
|
|
}
|
|
function gateInState(px, py, parts) {
|
|
for (const part of parts) {
|
|
const [outer, ...holes] = part;
|
|
if (!outer || outer.length < 3 || !gateInRing(px, py, outer)) continue;
|
|
if (!holes.some((h) => h && h.length >= 3 && gateInRing(px, py, h))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
// Recount, from the OUTPUT pins, how many sit outside their own state. Projects the
|
|
// fixture feats to px the same way the module does (box.proj), keyed by shapeName.
|
|
function recountOutside(outPins, feats) {
|
|
const statesPx = {};
|
|
for (const f of feats) {
|
|
const sn = f.properties.shapeName;
|
|
const ps = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
|
const parts = ps.map((poly) => poly.map((ring) => ring.map(([lng, lat]) => box.proj(lng, lat))));
|
|
(statesPx[sn] = statesPx[sn] || []).push(...parts);
|
|
}
|
|
let out = 0;
|
|
for (const p of outPins) {
|
|
const parts = statesPx[p.state];
|
|
if (parts && !gateInState(p.x, p.y, parts)) out++;
|
|
}
|
|
return out;
|
|
}
|
|
function segCrossGate(p, q) {
|
|
const o = (ax, ay, bx, by, cx, cy) => Math.sign((bx - ax) * (cy - ay) - (by - ay) * (cx - ax));
|
|
const [a, b] = p, [c, d] = q;
|
|
return o(a[0], a[1], b[0], b[1], c[0], c[1]) !== o(a[0], a[1], b[0], b[1], d[0], d[1]) &&
|
|
o(c[0], c[1], d[0], d[1], a[0], a[1]) !== o(c[0], c[1], d[0], d[1], b[0], b[1]);
|
|
}
|
|
|
|
// ── 60x96 classic4 geometry (the 60x96c4b FORMATS entry: w2500 h4000 gutter165) ──
|
|
const PW = 2500, PH = 4000, PXI = PW / 60; // pageWidthIn 60
|
|
const SHEET_EDGE = 26, GUTTER = 165;
|
|
const HEADER = 355, FOOTER = 316;
|
|
const C4_BAND = Math.round(PH * 0.096);
|
|
const MAP_X = GUTTER, MAP_W = PW - 2 * GUTTER;
|
|
const MAP_Y = HEADER + C4_BAND, MAP_H = PH - HEADER - FOOTER - 2 * C4_BAND;
|
|
const FRAME_X = MAP_X, FRAME_Y = MAP_Y, FRAME_W = MAP_W, FRAME_H = MAP_H;
|
|
|
|
// A linear "projection" over an India-ish bbox (68..98 lng, 6..37 lat) into the map rect.
|
|
const LNG0 = 68, LNG1 = 98, LAT0 = 6, LAT1 = 37;
|
|
const proj = (lng, lat) => [
|
|
MAP_X + ((lng - LNG0) / (LNG1 - LNG0)) * MAP_W,
|
|
MAP_Y + ((LAT1 - lat) / (LAT1 - LAT0)) * MAP_H,
|
|
];
|
|
const box = { PW, PH, MAP_X, MAP_W, MAP_Y, MAP_H, HEADER, FOOTER, SHEET_EDGE, GUTTER, C4_BAND, PXI, FRAME_X, FRAME_Y, FRAME_W, FRAME_H, proj };
|
|
|
|
// ── fixture: 855 pins across a 6x5 grid of rectangular "states" (lng/lat space) ─
|
|
// Deterministic PRNG (mulberry32) so pins are varied but fixed run-to-run.
|
|
function rng(seed) { return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
|
|
function makeFixture(N) {
|
|
const cols = 6, rows = 5, feats = [];
|
|
const dLng = (LNG1 - LNG0) / cols, dLat = (LAT1 - LAT0) / rows;
|
|
for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
|
|
const x0 = LNG0 + c * dLng, x1 = x0 + dLng, y0 = LAT0 + r * dLat, y1 = y0 + dLat;
|
|
feats.push({ properties: { shapeName: `S${c}_${r}` }, geometry: { type: 'Polygon', coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] } });
|
|
}
|
|
const rand = rng(12345);
|
|
const pins = [];
|
|
for (let i = 0; i < N; i++) {
|
|
// keep a margin inside a state so the point-in-poly is unambiguous
|
|
const lng = LNG0 + 0.5 + rand() * (LNG1 - LNG0 - 1);
|
|
const lat = LAT0 + 0.5 + rand() * (LAT1 - LAT0 - 1);
|
|
const [px, py] = proj(lng, lat);
|
|
pins.push({ id: `p${i}`, name: `Group ${i} ${'abcdefgh'[i % 8]}`, lng, lat, _px: px, _py: py, image_url: null });
|
|
}
|
|
return { pins, feats };
|
|
}
|
|
|
|
const { pins, feats } = makeFixture(855);
|
|
const fmt4 = { nameMaxPt: 11, classic4: true, rangeBadge: true, photos: false, pageWidthIn: 60, w: PW, h: PH };
|
|
const optsBase = { fmt: fmt4, feats };
|
|
|
|
// ── 1. fourSides ──────────────────────────────────────────────────────────────
|
|
const four = await layoutCircular({ pins, states: null, box, mode: 'fourSides', opts: optsBase });
|
|
note.push(`fourSides: placed ${four.stats.placed}, ringOrdered ${four.stats.numbersRingOrdered}, dupes ${four.stats.numberDupes}, outsideState ${four.stats.pinsOutsideState}, islandFallback ${four.stats.islandFallback}, namePt ${four.stats.namePt}, sides ${JSON.stringify(four.stats.sides)}`);
|
|
if (!four.stats.numbersRingOrdered) fails.push('fourSides: numbers not ring-ordered 1..N');
|
|
if (four.stats.numberDupes !== 0) fails.push(`fourSides: ${four.stats.numberDupes} duplicate numbers`);
|
|
if (four.stats.pinsOutsideState !== 0) fails.push(`fourSides: ${four.stats.pinsOutsideState} pins outside their state`);
|
|
if (four.stats.placed < 800) fails.push(`fourSides: only ${four.stats.placed} pins placed (floor 800) — vacuous`);
|
|
// (3) SIDE SEQUENCE: numbering must run L < T < R < B, and be monotone within each
|
|
// side — numbersRingOrdered alone (1..N, no gap) passes a scrambled side order too.
|
|
{
|
|
const SEQ = ['L', 'T', 'R', 'B'];
|
|
const bySide = { L: [], T: [], R: [], B: [] };
|
|
for (const p of four.pins) (bySide[p.side] || (bySide[p.side] = [])).push(p.num);
|
|
// side ranges from the pins (for the L<T<R<B sequence)
|
|
let lo = 0, seqOk = true;
|
|
for (const sd of SEQ) {
|
|
const nums = bySide[sd] || [];
|
|
for (const n of nums) if (n <= lo) seqOk = false;
|
|
if (nums.length) lo = Math.max(lo, ...nums);
|
|
}
|
|
// monotone WITHIN a side = numbers increase in LABEL EMISSION order (ring order),
|
|
// which is the real placement order (four.pins is input order, so it cannot test this).
|
|
let monoOk = true;
|
|
for (const sd of SEQ) {
|
|
const seq = four.labels.filter((l) => l.side === sd).map((l) => l.num);
|
|
for (let i = 1; i < seq.length; i++) if (seq[i] <= seq[i - 1]) { monoOk = false; break; }
|
|
}
|
|
if (!seqOk) fails.push('fourSides: numbers not in side sequence L<T<R<B');
|
|
if (!monoOk) fails.push('fourSides: numbers not monotone within a side');
|
|
// (4) RECOUNT outside-state from the OUTPUT (pins vs state rings), not from stats.
|
|
const outFx = recountOutside(four.pins, feats);
|
|
if (outFx !== 0) fails.push(`fourSides: recount found ${outFx} pins outside their state (stats said ${four.stats.pinsOutsideState})`);
|
|
note.push(`fourSides check: sideSeq ${seqOk}, monotone ${monoOk}, outside(recount) ${outFx}`);
|
|
}
|
|
|
|
// ── 2. pairedRows ─────────────────────────────────────────────────────────────
|
|
const paired = await layoutCircular({ pins, states: null, box, mode: 'pairedRows', opts: optsBase });
|
|
note.push(`pairedRows: rows ${paired.stats.rows}, crossings ${paired.stats.crossings}, touches ${paired.stats.touches}, outsideState ${paired.stats.pinsOutsideState}, octilinear ${paired.stats.octilinear}`);
|
|
if (paired.stats.crossings !== 0) fails.push(`pairedRows: ${paired.stats.crossings} leader crossings (expected 0)`);
|
|
if (paired.stats.pinsOutsideState !== 0) fails.push(`pairedRows: ${paired.stats.pinsOutsideState} pins outside their state`);
|
|
if (!paired.stats.octilinear) fails.push('pairedRows: a leader is not octilinear');
|
|
if (paired.stats.placed < 800) fails.push(`pairedRows: only ${paired.stats.placed} pins placed (floor 800)`);
|
|
// (4) RECOUNT pairedRows crossings from the OUTPUT leaders[].segs (one id per fork,
|
|
// skip same-id), and outside-state from the output pins — not from stats.
|
|
{
|
|
const segs = [];
|
|
paired.leaders.forEach((lf, li) => { for (const s of (lf.segs || [])) segs.push({ s, li }); });
|
|
let xr = 0;
|
|
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
|
|
if (segs[i].li === segs[j].li) continue;
|
|
if (segCrossGate(segs[i].s, segs[j].s)) xr++;
|
|
}
|
|
if (xr !== 0) fails.push(`pairedRows: recount found ${xr} crossings (stats said ${paired.stats.crossings})`);
|
|
const outPr = recountOutside(paired.pins, feats);
|
|
if (outPr !== 0) fails.push(`pairedRows: recount found ${outPr} pins outside their state`);
|
|
note.push(`pairedRows check: crossings(recount) ${xr}, outside(recount) ${outPr}`);
|
|
}
|
|
|
|
// ── 3. sideLanes ──────────────────────────────────────────────────────────────
|
|
const sideFmt = { nameMaxPt: 14, sideLanes: true, photos: false, pageWidthIn: 60, w: PW, h: PH };
|
|
const side = await layoutCircular({ pins, states: null, box: { ...box, LABEL_TOP: HEADER - 40, LABEL_BOT: PH - FOOTER + 40 }, mode: 'sideLanes', opts: { fmt: sideFmt, feats } });
|
|
note.push(`sideLanes: placed ${side.stats.placed}, crossings ${side.stats.crossings}, touches ${side.stats.touches}, octilinear ${side.stats.octilinear}, sides ${JSON.stringify(side.stats.sides)}`);
|
|
if (side.stats.placed < 800) fails.push(`sideLanes: only ${side.stats.placed} placed (floor 800)`);
|
|
if (!side.stats.octilinear) fails.push('sideLanes: leaders not all octilinear');
|
|
|
|
// ── 4. determinism ────────────────────────────────────────────────────────────
|
|
const four2 = await layoutCircular({ pins, states: null, box, mode: 'fourSides', opts: optsBase });
|
|
if (JSON.stringify(four.labels) !== JSON.stringify(four2.labels) || JSON.stringify(four.dots) !== JSON.stringify(four2.dots)) fails.push('NON-DETERMINISTIC: two fourSides runs differ');
|
|
|
|
// ── 5. poison — a tiny state cannot hold its pins → infeasible NAMED ───────────
|
|
{
|
|
// one tiny state in lng/lat; pack many pins into it
|
|
const tinyFeats = [{ properties: { shapeName: 'TINY' }, geometry: { type: 'Polygon', coordinates: [[[80, 20], [80.02, 20], [80.02, 20.02], [80, 20.02], [80, 20]]] } }];
|
|
const tinyPins = [];
|
|
for (let i = 0; i < 40; i++) { const [px, py] = proj(80.01, 20.01); tinyPins.push({ id: `t${i}`, name: `T${i}`, lng: 80.01, lat: 20.01, _px: px, _py: py }); }
|
|
const pf = { nameMaxPt: 11, classic4: true, rangeBadge: false, photos: false, pageWidthIn: 60, w: PW, h: PH };
|
|
const pres = await layoutCircular({ pins: tinyPins, states: null, box, mode: 'fourSides', opts: { fmt: pf, feats: tinyFeats } });
|
|
note.push(`poison: infeasible ${pres.stats.infeasible}, outsideState ${pres.stats.pinsOutsideState}`);
|
|
if (pres.stats.infeasible < 1) fails.push('POISON: a too-small state was NOT named in infeasible');
|
|
if (pres.stats.pinsOutsideState !== 0) fails.push(`POISON: ${pres.stats.pinsOutsideState} pins escaped the tiny state`);
|
|
}
|
|
|
|
// ── 6. budget (step 2 node half): median of 3, heap delta ───────────────────────
|
|
// BASELINE: measured on this box at ~1.0-1.4s for 855 pins (fourSides, placePinsInState
|
|
// 400 iters). Budget fails at 1.5x of 3500ms = 5250ms to leave headroom on a slow CI
|
|
// box; the browser half (390px, 6x CPU throttle) is measured separately on the shared browser.
|
|
const BUDGET_MS = 5250;
|
|
const HEAP_CEIL_MB = 300;
|
|
const times = [];
|
|
let heapDelta = 0;
|
|
for (let k = 0; k < 3; k++) {
|
|
if (global.gc) global.gc();
|
|
const h0 = process.memoryUsage().heapUsed;
|
|
const t0 = process.hrtime.bigint();
|
|
await layoutCircular({ pins, states: null, box, mode: 'fourSides', opts: optsBase });
|
|
const t1 = process.hrtime.bigint();
|
|
times.push(Number(t1 - t0) / 1e6);
|
|
heapDelta = Math.max(heapDelta, (process.memoryUsage().heapUsed - h0) / 1048576);
|
|
}
|
|
times.sort((a, b) => a - b);
|
|
const medMs = times[1];
|
|
note.push(`budget: fourSides median ${medMs.toFixed(0)}ms (runs ${times.map((t) => t.toFixed(0)).join('/')}), heapΔ ~${heapDelta.toFixed(0)}MB; budget ${BUDGET_MS}ms / ${HEAP_CEIL_MB}MB`);
|
|
if (medMs > BUDGET_MS) fails.push(`budget: ${medMs.toFixed(0)}ms over ${BUDGET_MS}ms`);
|
|
if (heapDelta > HEAP_CEIL_MB) fails.push(`budget: heapΔ ${heapDelta.toFixed(0)}MB over ${HEAP_CEIL_MB}MB`);
|
|
|
|
for (const n of note) console.log(` · ${n}`);
|
|
if (fails.length) {
|
|
console.error('FAIL check:circular-layout');
|
|
for (const f of fails) console.error(` ✗ ${f}`);
|
|
process.exit(1);
|
|
} else {
|
|
console.log(`PASS check:circular-layout — fourSides ring-ordered 0-dupes 0-outside, pairedRows 0-crossings, sideLanes octilinear, deterministic, poison named, ${medMs.toFixed(0)}ms < ${BUDGET_MS}ms budget`);
|
|
}
|