mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: canonicalize near-grid coordinates exactly
Issue: #223 User-Visible: yes
This commit is contained in:
committed by
claude[bot]
parent
603bb3647f
commit
d486c64576
@@ -38,14 +38,61 @@ const detunedLayout = () => ({
|
||||
junk: { s: 'f1' }, // no coordinates: skipped
|
||||
});
|
||||
|
||||
test('snapN is idempotent and leaves a node bit-identical', () => {
|
||||
test('snapN returns the exact nearest node and is idempotent', () => {
|
||||
const node = 12 / GRID_N;
|
||||
assert.equal(snapN(node), node); // untouched, not "re-rounded"
|
||||
assert.equal(snapN(node), node);
|
||||
const canonical = 112 * S;
|
||||
const noisy = 0.46666666666666673;
|
||||
assert.notEqual(noisy, canonical);
|
||||
assert.equal(snapN(noisy), canonical, 'a one-ULP tail must not survive Optimize');
|
||||
const off = node + S / 3;
|
||||
assert.ok(Math.abs(snapN(off) - node) < 1e-12);
|
||||
assert.equal(snapN(snapN(off)), snapN(off));
|
||||
});
|
||||
|
||||
test('near-node report counts only coordinate values actually written to the candidate', () => {
|
||||
const canonical = 112 * S;
|
||||
const noisy = 0.46666666666666673;
|
||||
const spaces = [{
|
||||
id: 'f1', rooms: [
|
||||
{ id: 'poly', poly: [[noisy, 0.2], [0.6, 0.2], [0.6, 0.4], [0.2, 0.4]] },
|
||||
{ id: 'rect', x: 0, y: 0.5, w: noisy, h: 0.2 },
|
||||
],
|
||||
partitions: [
|
||||
{ id: 'accepted', a: [noisy, 0.2], b: [0.6, 0.2], cm: 15 },
|
||||
{ id: 'rejected', a: [noisy, 0.4], b: [0.6, 0.4], cm: 15 },
|
||||
],
|
||||
openings: [{
|
||||
id: 'outside-host', type: 'door', x: 0.5, y: 0.4, angle: 0, length: 0.05,
|
||||
host: { kind: 'partition', id: 'rejected', t: 2 },
|
||||
}],
|
||||
wall_columns: [{ id: 'column', shape: 'circle', center: [noisy, 0.5], cm: 20 }],
|
||||
decor: [
|
||||
{ id: 'line', kind: 'line', x1: noisy, y1: 0.7, x2: 0.6, y2: 0.7 },
|
||||
{ id: 'box', kind: 'rect', x: 0, y: 0.8, w: noisy, h: 0.1 },
|
||||
{ id: 'text', kind: 'text', x: noisy, y: 0.9, text: 'x' },
|
||||
],
|
||||
}];
|
||||
const result = alignAllToGrid(spaces, { marker: { s: 'f1', x: noisy, y: 0.5 } });
|
||||
|
||||
assert.equal(result.report.moved, 0, 'ULP cleanup is not a visible move');
|
||||
assert.equal(result.report.maxShift, 0);
|
||||
assert.equal(result.report.maxShiftCm, 0);
|
||||
assert.equal(result.report.coordsCanonicalized, 8);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.spaces[0].partitions[0].a[0], canonical);
|
||||
assert.equal(result.spaces[0].partitions[1].a[0], noisy,
|
||||
'hostedFit=false keeps the rejected endpoints and must not count them');
|
||||
assert.equal(result.spaces[0].wall_columns[0].center[0], canonical);
|
||||
assert.equal(result.layout.marker.x, canonical);
|
||||
});
|
||||
|
||||
test('ordinary off-grid movement is not duplicated in the near-node counter', () => {
|
||||
const result = alignAllToGrid([], { marker: { x: 0.2 + S / 3, y: 0.2 } });
|
||||
assert.equal(result.report.moved, 1);
|
||||
assert.equal(result.report.coordsCanonicalized, 0);
|
||||
});
|
||||
|
||||
test('alignAllToGrid puts every grid-bound element on a node', () => {
|
||||
const { spaces, layout, report } = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
const sp = spaces[0];
|
||||
@@ -111,6 +158,7 @@ test('idempotent: a second run moves nothing and changes nothing', () => {
|
||||
const first = alignAllToGrid(detuned().spaces, detunedLayout());
|
||||
const second = alignAllToGrid(first.spaces, first.layout);
|
||||
assert.equal(second.report.moved, 0);
|
||||
assert.equal(second.report.coordsCanonicalized, 0);
|
||||
assert.equal(second.changed, false);
|
||||
assert.deepEqual(second.spaces, first.spaces);
|
||||
assert.deepEqual(second.layout, first.layout);
|
||||
@@ -122,6 +170,7 @@ test('an already-aligned plan reports nothing to do', () => {
|
||||
};
|
||||
const r = alignAllToGrid(clean.spaces, { d1: { s: 'f1', x: 0.25, y: 0.5 } });
|
||||
assert.equal(r.report.moved, 0);
|
||||
assert.equal(r.report.coordsCanonicalized, 0);
|
||||
assert.equal(r.changed, false);
|
||||
assert.ok(r.report.total >= 2); // it still LOOKED at everything
|
||||
});
|
||||
|
||||
@@ -27,6 +27,19 @@ test('i18n: placeholders match between languages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
|
||||
assert.equal(
|
||||
en['gs.optimize_changes'],
|
||||
'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}.',
|
||||
);
|
||||
assert.equal(
|
||||
ru['gs.optimize_changes'],
|
||||
'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}.',
|
||||
);
|
||||
assert.match(cardSource, /p: String\(r\.coordsCanonicalized\)/);
|
||||
assert.match(cardSource, /d\.report\.coordsCanonicalized \+ d\.report\.wallsMerged/);
|
||||
});
|
||||
|
||||
test('i18n: every literal help call has body and full aria keys in both languages', () => {
|
||||
const allCalls = cardSource.match(/this\._help\(/g) || [];
|
||||
const helpKeys = [...cardSource.matchAll(/this\._help\('([^']+\.help)'\)/g)].map((match) => match[1]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
collapseIsolatedWallThicknessIslands, optimizePlans, PLAN_MODEL_VERSION,
|
||||
} from '../test-build/plan-optimizer.js';
|
||||
import { unionBodies } from '../test-build/physical-geometry.js';
|
||||
import { GRID_PITCH, GRID_STEP_N as S, NORM_W } from '../test-build/space-geometry.js';
|
||||
import { wallKey } from '../test-build/wall-thickness.js';
|
||||
|
||||
@@ -17,6 +18,20 @@ const room = (id, x0, x1, openTo) => ({
|
||||
|
||||
const exactWall = (a, b, cm) => ({ key: wallKey(a, b, S), a, b, cm });
|
||||
|
||||
// Privacy-minimised six-room topology from #218/#223. The relevant stored ULP
|
||||
// tails stay literal so this fixture proves that Optimize repairs the source,
|
||||
// not merely that render-time boolean normalisation remains resilient.
|
||||
const noisySixRoomFloor = [
|
||||
[[0.46666666666666673, 0.7083333333333334], [0.6125, 0.9],
|
||||
[0.4666666666666667, 1], [0.46666666666666673, 0.9]],
|
||||
[[0.1625, 0.3], [0.3458333333333333, 0],
|
||||
[0.46666666666666673, 1], [0.3458333333333333, 1]],
|
||||
[[0.7, 0], [0.8, 0], [0.8, 0.7083333333333334], [0.7, 0.7083333333333334]],
|
||||
[[0.7, 0.7083333333333335], [0.8, 0.7083333333333335], [0.8, 1], [0.7, 1]],
|
||||
[[0.85, 0], [0.9, 0], [0.9, 0.4], [0.85, 0.4]],
|
||||
[[0.85, 0.5], [0.9, 0.5], [0.9, 1], [0.85, 1]],
|
||||
];
|
||||
|
||||
const microIntervalFixture = (length = S / 3, middleCm = 15, rightCm = 22) => {
|
||||
const x0 = 0.2, split = 0.5, x1 = 0.8, y = 0.2;
|
||||
return {
|
||||
@@ -57,6 +72,41 @@ test('Optimize collapses one isolated thickness micro-interval and is idempotent
|
||||
assert.deepEqual(second.config, first.config);
|
||||
});
|
||||
|
||||
test('Optimize canonicalizes the six-room ULP source without claiming a visible move', () => {
|
||||
const config = {
|
||||
model_version: PLAN_MODEL_VERSION,
|
||||
spaces: [{
|
||||
id: 'noisy', title: 'Noisy', view_box: [0, 0, 1, 1], cell_cm: 5,
|
||||
rooms: noisySixRoomFloor.map((poly, index) => ({ id: `room-${index}`, poly })),
|
||||
}],
|
||||
markers: [], settings: {}, future: { kept: true },
|
||||
};
|
||||
const before = structuredClone(config);
|
||||
const first = optimizePlans(config, {});
|
||||
|
||||
assert.deepEqual(config, before, 'preview never mutates the noisy source');
|
||||
assert.equal(first.changed, true);
|
||||
assert.equal(first.report.moved, 0);
|
||||
assert.equal(first.report.maxShift, 0);
|
||||
assert.equal(first.report.maxShiftCm, 0);
|
||||
assert.ok(first.report.coordsCanonicalized > 0);
|
||||
assert.deepEqual(first.config.future, { kept: true });
|
||||
for (const item of first.config.spaces[0].rooms) {
|
||||
for (const [x, y] of item.poly) {
|
||||
assert.equal(x, Math.round(x / S) * S);
|
||||
assert.equal(y, Math.round(y / S) * S);
|
||||
}
|
||||
}
|
||||
assert.ok(unionBodies(first.config.spaces[0].rooms.map((item) => item.poly)),
|
||||
'downstream boolean geometry accepts the exact candidate');
|
||||
|
||||
const second = optimizePlans(first.config, first.layout);
|
||||
assert.equal(second.changed, false);
|
||||
assert.equal(second.report.coordsCanonicalized, 0);
|
||||
assert.deepEqual(second.config, first.config);
|
||||
assert.deepEqual(second.layout, first.layout);
|
||||
});
|
||||
|
||||
test('micro-interval cleanup has a strict half-step boundary at both coordinate scales', () => {
|
||||
for (const length of [S / 3, S / 2, S / 2 + S / 100]) {
|
||||
const fixture = microIntervalFixture(length);
|
||||
|
||||
Reference in New Issue
Block a user