diff --git a/test/draft-live-preflight.test.mjs b/test/draft-live-preflight.test.mjs index b3be77d0..95db14ad 100644 --- a/test/draft-live-preflight.test.mjs +++ b/test/draft-live-preflight.test.mjs @@ -5,6 +5,8 @@ import { readFileSync } from 'node:fs'; import { draftLiveCandidateSpace, draftLiveSeed, isSingleDraftAppend, } from '../test-build/draft-live-preflight.js'; +import { increasedViolations, junctionLimitViolations } from '../test-build/junction-limits.js'; +import { GRID_STEP_N } from '../test-build/space-geometry.js'; const room = (id, x, y, size = 0.1) => ({ id, @@ -17,6 +19,45 @@ const wallSegments = (one) => one.poly.map((a, index) => ({ owners: [one.id], })); +const cmUnits = (value) => value / 5 * GRID_STEP_N; + +const limitSegments = (space) => [ + ...(space.wall_segments || []), + ...(space.partitions || []), + ...(space.room_drafts || []).flatMap((draft) => + (draft.segments || []).map((segment, index) => ({ + id: segment.id, + a: draft.points[index], b: draft.points[index + 1], cm: segment.cm, + }))), +]; + +const introducedRules = (previous, candidate) => increasedViolations( + junctionLimitViolations( + { model_version: 9, spaces: [candidate] }, candidate.id, + limitSegments(candidate), { status: 'lightweight' }, + ), + junctionLimitViolations( + { model_version: 9, spaces: [previous] }, previous.id, + limitSegments(previous), { status: 'lightweight' }, + ), +).map((item) => item.rule).sort(); + +const paritySpace = (partitions, seedLengthCm = 40) => ({ + id: 'space', cell_cm: 5, + rooms: [], walls: [], wall_segments: [], openings: [], open_spans: [], + wall_columns: [], + // Valid remote work makes the full input genuinely larger than the local + // projection without adding an inherited limit violation. + partitions: [ + ...partitions, + { id: 'remote', a: [2, 2], b: [2 + cmUnits(40), 2], cm: 0 }, + ], + room_drafts: [{ + id: 'active', points: [[0, 0], [cmUnits(seedLengthCm), 0]], + segments: [{ id: 'seed', cm: 0 }], + }], +}); + const activeDraft = { id: 'active', points: [[0.05, 0.05], [0.1, 0.05], [0.1, 0.1], [0.2, 0.1]], @@ -140,6 +181,54 @@ test('#461 remote geometry growth does not grow the selected component', () => { assert.deepEqual(selectedEighty.space.partitions.map((item) => item.id), ['near']); }); +test('#461 local and full junction verdicts agree at every limit boundary', () => { + const ray = (id, angleDeg, lengthCm = 40) => { + const angle = angleDeg * Math.PI / 180; + return { + id, a: [0, 0], + b: [cmUnits(lengthCm) * Math.cos(angle), cmUnits(lengthCm) * Math.sin(angle)], + cm: 0, + }; + }; + const valenceRays = (total) => Array.from({ length: total - 1 }, (_, index) => + ray(`ray-${total}-${index}`, (index + 1) * 360 / total)); + const cases = [ + { name: '14 degree junction is rejected', partitions: [ray('angle-14', 14)], + expected: ['angle'] }, + { name: '15 degree junction is accepted', partitions: [ray('angle-15', 15)], + expected: [] }, + { name: '19 cm segment is rejected', partitions: [], seedLengthCm: 19, + expected: ['length'] }, + { name: '20 cm segment is accepted', partitions: [], seedLengthCm: 20, + expected: [] }, + { name: '4 cm node clearance is rejected', partitions: [{ + id: 'distance-4', + a: [cmUnits(4), cmUnits(4)], b: [cmUnits(4), cmUnits(44)], cm: 0, + }], expected: ['distance'] }, + { name: '5 cm node clearance is accepted', partitions: [{ + id: 'distance-5', + a: [cmUnits(5), cmUnits(5)], b: [cmUnits(5), cmUnits(45)], cm: 0, + }], expected: [] }, + { name: 'valence 6 is accepted', partitions: valenceRays(6), expected: [] }, + { name: 'valence 7 is rejected', partitions: valenceRays(7), expected: ['valence'] }, + ]; + + for (const scenario of cases) { + const candidate = paritySpace(scenario.partitions, scenario.seedLengthCm); + const previous = { ...candidate, room_drafts: [] }; + const seed = draftLiveSeed(candidate, 'active'); + const localPrevious = draftLiveCandidateSpace(previous, seed)?.space; + const localCandidate = draftLiveCandidateSpace(candidate, seed)?.space; + assert.ok(seed && localPrevious && localCandidate, scenario.name); + const full = introducedRules(previous, candidate); + const local = introducedRules(localPrevious, localCandidate); + assert.deepEqual(local, full, `${scenario.name}: local verdict must equal full verdict`); + assert.deepEqual(full, scenario.expected, `${scenario.name}: boundary verdict`); + assert.equal(localCandidate.partitions.some((item) => item.id === 'remote'), false, + `${scenario.name}: parity must be proved on a bounded projection`); + } +}); + test('#461 projector closes a collinear run and keeps every ray at its junctions', () => { const space = { id: 'space', cell_cm: 5, rooms: [], walls: [], wall_segments: [],