mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
642 lines
26 KiB
JavaScript
642 lines
26 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import {
|
|
collapseIsolatedWallThicknessIslands, optimizePlans, PLAN_MODEL_VERSION,
|
|
} from '../test-build/plan-optimizer.js';
|
|
import {
|
|
canonicalizeConfigGeometry, canonicalizeLayoutGeometry, canonicalizeNumber,
|
|
} from '../test-build/coordinate-canonicalization.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';
|
|
|
|
const room = (id, x0, x1, openTo) => ({
|
|
id,
|
|
name: id,
|
|
area: null,
|
|
poly: [[x0, 0], [x1, 0], [x1, 1], [x0, 1]],
|
|
...(openTo ? { open_to: [openTo] } : {}),
|
|
});
|
|
|
|
const exactWall = (a, b, cm) => ({ key: wallKey(a, b, S), a, b, cm });
|
|
|
|
const coordinateFixture = JSON.parse(readFileSync(
|
|
new URL('./fixtures/coordinate-canonicalization.json', import.meta.url),
|
|
'utf8',
|
|
));
|
|
const storageRoundtripFixture = JSON.parse(readFileSync(
|
|
new URL('./fixtures/optimize-storage-roundtrip.json', import.meta.url),
|
|
'utf8',
|
|
));
|
|
const wallKeyRoundtripFixture = JSON.parse(readFileSync(
|
|
new URL('./fixtures/258-wall-key-roundtrip.json', import.meta.url),
|
|
'utf8',
|
|
));
|
|
|
|
const assertNoPersistedChanges = (result) => {
|
|
assert.equal(result.changed, false);
|
|
for (const field of [
|
|
'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated',
|
|
'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged',
|
|
'spansMerged', 'partitionsMerged', 'spaceRefsRemapped', 'roomRefsRemapped',
|
|
'positionsRemapped', 'markersDetached', 'orphanRoomLabelsRemoved',
|
|
'orphanDevicePositionsRemoved', 'orphanGroupPositionsRemoved',
|
|
'liveMissingPositionsRemoved',
|
|
]) assert.equal(result.report[field], 0, `${field} must describe the persisted delta`);
|
|
assert.equal(result.report.maxShift, 0);
|
|
assert.equal(result.report.maxShiftCm, 0);
|
|
assert.equal(result.report.maxSpace, '');
|
|
};
|
|
|
|
// 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 {
|
|
rooms: [{ id: 'r1', poly: [[x0, y], [x1, y], [x1, 0.8], [x0, 0.8]] }],
|
|
walls: [
|
|
exactWall([x0, y], [split, y], 22),
|
|
exactWall([split, y], [split + length, y], middleCm),
|
|
exactWall([split + length, y], [x1, y], rightCm),
|
|
],
|
|
};
|
|
};
|
|
|
|
test('Optimize collapses one isolated thickness micro-interval and is idempotent', () => {
|
|
const fixture = microIntervalFixture();
|
|
const config = {
|
|
model_version: PLAN_MODEL_VERSION,
|
|
spaces: [{
|
|
id: 'micro', title: 'Micro', view_box: [0, 0, 1, 1], cell_cm: 5,
|
|
rooms: fixture.rooms, walls: fixture.walls,
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
const before = JSON.parse(JSON.stringify(config));
|
|
const first = optimizePlans(config, {});
|
|
assert.deepEqual(config, before, 'preview must not mutate its input');
|
|
assert.equal(first.changed, true);
|
|
assert.equal(first.report.canonicalized, 1);
|
|
assert.equal(first.report.wallsMerged, 2);
|
|
assert.equal(first.config.spaces[0].walls.length, 1);
|
|
assert.equal(first.config.spaces[0].walls[0].cm, 22);
|
|
assert.deepEqual(first.config.spaces[0].walls[0].a, [0.2, 0.2]);
|
|
assert.deepEqual(first.config.spaces[0].walls[0].b, [0.8, 0.2]);
|
|
|
|
const second = optimizePlans(first.config, first.layout);
|
|
assert.equal(second.changed, false);
|
|
assert.equal(second.report.canonicalized, 0);
|
|
assert.equal(second.report.wallsMerged, 0);
|
|
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, canonicalizeNumber(Math.round(x / S) * S));
|
|
assert.equal(y, canonicalizeNumber(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('issue 248 Optimize stays a no-op across the nine-decimal storage round-trip', () => {
|
|
const inputBefore = structuredClone(storageRoundtripFixture.input);
|
|
const first = optimizePlans(
|
|
storageRoundtripFixture.input.config,
|
|
storageRoundtripFixture.input.layout,
|
|
);
|
|
|
|
assert.deepEqual(storageRoundtripFixture.input, inputBefore, 'preview must keep fixture input');
|
|
assert.equal(first.changed, true);
|
|
assert.ok(first.report.coordsCanonicalized > 0);
|
|
assert.deepEqual(first.config, storageRoundtripFixture.expected.config);
|
|
assert.deepEqual(first.layout, storageRoundtripFixture.expected.layout);
|
|
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config);
|
|
assert.deepEqual(canonicalizeLayoutGeometry(first.layout), first.layout);
|
|
|
|
const inMemorySecond = optimizePlans(first.config, first.layout);
|
|
assertNoPersistedChanges(inMemorySecond);
|
|
assert.deepEqual(inMemorySecond.config, first.config);
|
|
assert.deepEqual(inMemorySecond.layout, first.layout);
|
|
|
|
const backendEcho = optimizePlans(
|
|
canonicalizeConfigGeometry(first.config),
|
|
canonicalizeLayoutGeometry(first.layout),
|
|
);
|
|
assertNoPersistedChanges(backendEcho);
|
|
assert.deepEqual(backendEcho.config, first.config);
|
|
assert.deepEqual(backendEcho.layout, first.layout);
|
|
});
|
|
|
|
test('issue 258 Optimize canonicalizes an affected wall key across storage round-trip', () => {
|
|
const space = structuredClone(wallKeyRoundtripFixture.space);
|
|
space.walls[0].key = wallKeyRoundtripFixture.affected_key;
|
|
const config = {
|
|
model_version: PLAN_MODEL_VERSION,
|
|
spaces: [space],
|
|
markers: [], settings: {},
|
|
};
|
|
const before = structuredClone(config);
|
|
const first = optimizePlans(config, {});
|
|
assert.deepEqual(config, before, 'preview must not mutate the affected source');
|
|
assert.equal(first.changed, true);
|
|
assert.equal(first.report.canonicalized, 1);
|
|
const repaired = first.config.spaces[0].walls.find((wall) => wall.cm === 29);
|
|
assert.ok(repaired);
|
|
assert.equal(repaired.cm, 29);
|
|
assert.deepEqual(repaired.a, wallKeyRoundtripFixture.space.walls[0].a);
|
|
assert.deepEqual(repaired.b, wallKeyRoundtripFixture.space.walls[0].b);
|
|
assert.equal(repaired.key, wallKeyRoundtripFixture.canonical_key);
|
|
assert.equal(repaired.key, wallKey(repaired.a, repaired.b, S));
|
|
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config);
|
|
|
|
const inMemorySecond = optimizePlans(first.config, first.layout);
|
|
assertNoPersistedChanges(inMemorySecond);
|
|
assert.deepEqual(inMemorySecond.config, first.config);
|
|
const backendEcho = optimizePlans(canonicalizeConfigGeometry(first.config), first.layout);
|
|
assertNoPersistedChanges(backendEcho);
|
|
assert.deepEqual(backendEcho.config, first.config);
|
|
});
|
|
|
|
test('issue 248 every persisted geometry surface converges at every supported scale', () => {
|
|
for (const cellCm of [1, 3, 5, 1000]) {
|
|
const config = structuredClone(coordinateFixture.configInput);
|
|
config.spaces[0].cell_cm = cellCm;
|
|
const first = optimizePlans(config, coordinateFixture.layoutInput);
|
|
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config, `config ${cellCm}`);
|
|
assert.deepEqual(canonicalizeLayoutGeometry(first.layout), first.layout, `layout ${cellCm}`);
|
|
|
|
const second = optimizePlans(first.config, first.layout);
|
|
assertNoPersistedChanges(second);
|
|
assert.deepEqual(second.config, first.config, `config round-trip ${cellCm}`);
|
|
assert.deepEqual(second.layout, first.layout, `layout round-trip ${cellCm}`);
|
|
}
|
|
});
|
|
|
|
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);
|
|
const normalized = collapseIsolatedWallThicknessIslands(
|
|
fixture.rooms, fixture.walls, [], S, 5, S, 1,
|
|
);
|
|
const normalizedMiddle = normalized.find((wall) => wall.a?.[0] === 0.5);
|
|
assert.equal(normalizedMiddle?.cm, length < S / 2 ? 22 : 15, `normalized length=${length}`);
|
|
|
|
const renderRooms = fixture.rooms.map((item) => ({
|
|
...item, poly: item.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
|
|
}));
|
|
const rendered = collapseIsolatedWallThicknessIslands(
|
|
renderRooms, fixture.walls, [], S, 5, GRID_PITCH, NORM_W,
|
|
);
|
|
const renderMiddle = rendered.find((wall) => wall.a?.[0] === 0.5);
|
|
assert.equal(renderMiddle?.cm, length < S / 2 ? 22 : 15, `render length=${length}`);
|
|
}
|
|
});
|
|
|
|
test('micro-interval cleanup preserves ambiguous and topological boundaries', () => {
|
|
const unequal = microIntervalFixture(S / 3, 15, 20);
|
|
assert.deepEqual(
|
|
collapseIsolatedWallThicknessIslands(unequal.rooms, unequal.walls, [], S, 5, S),
|
|
unequal.walls,
|
|
'different neighbour thicknesses have no unambiguous replacement',
|
|
);
|
|
|
|
const overlapping = microIntervalFixture();
|
|
overlapping.walls.splice(2, 0, { ...overlapping.walls[1], cm: 18 });
|
|
assert.deepEqual(
|
|
collapseIsolatedWallThicknessIslands(overlapping.rooms, overlapping.walls, [], S, 5, S),
|
|
overlapping.walls,
|
|
'conflicting exact owners of the same micro-interval are ambiguous',
|
|
);
|
|
|
|
const atVertex = microIntervalFixture();
|
|
atVertex.rooms[0].poly.splice(1, 0, [0.5, 0.2]);
|
|
assert.deepEqual(
|
|
collapseIsolatedWallThicknessIslands(atVertex.rooms, atVertex.walls, [], S, 5, S),
|
|
atVertex.walls,
|
|
'a room vertex at the island endpoint is a topology boundary',
|
|
);
|
|
|
|
const atOpening = microIntervalFixture();
|
|
const cut = [[0.45, 0.2, 0.5, 0.2]];
|
|
assert.deepEqual(
|
|
collapseIsolatedWallThicknessIslands(atOpening.rooms, atOpening.walls, cut, S, 5, S),
|
|
atOpening.walls,
|
|
'an open-cut endpoint at the island boundary blocks cleanup',
|
|
);
|
|
|
|
const chain = microIntervalFixture(S / 3, 15, 22);
|
|
const y = 0.2, firstEnd = 0.5 + S / 3, secondEnd = firstEnd + S / 3;
|
|
chain.walls = [
|
|
exactWall([0.2, y], [0.5, y], 22),
|
|
exactWall([0.5, y], [firstEnd, y], 15),
|
|
exactWall([firstEnd, y], [secondEnd, y], 16),
|
|
exactWall([secondEnd, y], [0.8, y], 22),
|
|
];
|
|
assert.deepEqual(
|
|
collapseIsolatedWallThicknessIslands(chain.rooms, chain.walls, [], S, 5, S),
|
|
chain.walls,
|
|
'a chain of different micro-intervals must not collapse cumulatively',
|
|
);
|
|
});
|
|
|
|
test('micro-interval cleanup is endpoint, input-order and room-order invariant without mutation', () => {
|
|
const baselineFixture = microIntervalFixture();
|
|
const fixture = microIntervalFixture();
|
|
const detached = {
|
|
id: 'r2', poly: [[0.85, 0.85], [0.95, 0.85], [0.95, 0.95], [0.85, 0.95]],
|
|
};
|
|
const semantic = (walls) => walls.map((wall) => {
|
|
const ends = [wall.a, wall.b]
|
|
.map((point) => point.map((value) => Number(value.toFixed(12))).join(','))
|
|
.sort();
|
|
return `${ends[0]}|${ends[1]}|${wall.cm}`;
|
|
}).sort();
|
|
|
|
const baseline = collapseIsolatedWallThicknessIslands(
|
|
[baselineFixture.rooms[0], structuredClone(detached)], baselineFixture.walls, [], S, 5, S,
|
|
);
|
|
const reversedMiddle = {
|
|
...fixture.walls[1],
|
|
a: [...fixture.walls[1].b],
|
|
b: [...fixture.walls[1].a],
|
|
};
|
|
const rooms = [detached, {
|
|
...fixture.rooms[0], poly: [...fixture.rooms[0].poly].reverse().map((point) => [...point]),
|
|
}];
|
|
const walls = [fixture.walls[2], reversedMiddle, fixture.walls[0]]
|
|
.map((wall) => ({ ...wall, a: [...wall.a], b: [...wall.b] }));
|
|
const beforeRooms = structuredClone(rooms);
|
|
const beforeWalls = structuredClone(walls);
|
|
|
|
const result = collapseIsolatedWallThicknessIslands(rooms, walls, [], S, 5, S);
|
|
assert.deepEqual(rooms, beforeRooms, 'helper must not mutate rooms or their winding');
|
|
assert.deepEqual(walls, beforeWalls, 'helper must not mutate the walls array or entries');
|
|
assert.deepEqual(semantic(result), semantic(baseline));
|
|
assert.equal(result.find((wall) => wall.a?.[0] === 0.5 || wall.b?.[0] === 0.5)?.cm, 22);
|
|
});
|
|
|
|
test('optimizePlans migrates, aligns and canonicalises idempotently', () => {
|
|
const config = {
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], cell_cm: 5,
|
|
plan_scale: 0.75,
|
|
segments: [[0, 0, 1, 0]],
|
|
rooms: [room('a', 0, 0.5, 'b'), room('b', 0.5, 1, 'a')],
|
|
walls: [
|
|
{ key: wallKey([0, 0], [0.25, 0], S), a: [0, 0], b: [0.25, 0], cm: 20 },
|
|
{ key: wallKey([0.25, 0], [0.5, 0], S), a: [0.25, 0], b: [0.5, 0], cm: 20 },
|
|
],
|
|
decor: [{
|
|
id: 't1', kind: 'text', x: S / 3, y: S / 3,
|
|
text: 'Now {}', entity: 'sensor.temp', attr: 'state', size: 'm',
|
|
}, {
|
|
id: 'l1', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 3,
|
|
}],
|
|
}],
|
|
markers: [{
|
|
id: 'm1', binding: 'virtual', display: 'ripple',
|
|
vacuum: { trail: false },
|
|
}],
|
|
settings: {},
|
|
};
|
|
const layout = { m1: { s: 'f1', x: S / 3, y: S / 3 } };
|
|
|
|
const first = optimizePlans(config, layout);
|
|
assert.equal(first.changed, true);
|
|
assert.equal(first.config.model_version, PLAN_MODEL_VERSION);
|
|
assert.equal('segments' in first.config.spaces[0], false);
|
|
assert.equal(first.config.markers[0].display, 'icon_ripple');
|
|
assert.deepEqual(first.config.markers[0].vacuum, { trail_mode: 'never' });
|
|
assert.equal(first.config.spaces[0].decor[0].text, 'Now {sensor.temp}');
|
|
assert.equal(first.config.spaces[0].decor[0].size_cm, 24);
|
|
assert.equal('scale' in first.config.spaces[0].decor[0], false);
|
|
assert.equal(first.config.spaces[0].decor[1].width_cm, 3.6);
|
|
assert.equal('width' in first.config.spaces[0].decor[1], false);
|
|
assert.equal(first.config.spaces[0].plan_scale_x, 0.75);
|
|
assert.equal(first.config.spaces[0].plan_scale_y, 0.75);
|
|
assert.equal('plan_scale' in first.config.spaces[0], false);
|
|
assert.equal('entity' in first.config.spaces[0].decor[0], false);
|
|
assert.equal(first.config.spaces[0].open_spans.length, 1);
|
|
assert.equal(first.config.spaces[0].walls.length, 1);
|
|
assert.equal(first.layout.m1.x % S, 0);
|
|
|
|
const second = optimizePlans(first.config, first.layout);
|
|
assert.equal(second.changed, false);
|
|
assert.deepEqual(second.config, first.config);
|
|
assert.deepEqual(second.layout, first.layout);
|
|
});
|
|
|
|
test('legacy physical decor fields are clamped to the persisted schema', () => {
|
|
const result = optimizePlans({
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], cell_cm: 1000,
|
|
rooms: [],
|
|
decor: [
|
|
{ id: 'wide', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 300 },
|
|
{ id: 'thin', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 0 },
|
|
{ id: 'huge', kind: 'text', x: 0, y: 0, text: 'X', scale: 20 },
|
|
],
|
|
}],
|
|
markers: [], settings: {},
|
|
}, {});
|
|
const [wide, thin, huge] = result.config.spaces[0].decor;
|
|
assert.equal(wide.width_cm, 100);
|
|
assert.equal(thin.width_cm, 0.1);
|
|
assert.equal(huge.size_cm, 2000);
|
|
});
|
|
|
|
test('invalid legacy plan_scale is preserved instead of being counted as migrated', () => {
|
|
const result = optimizePlans({
|
|
model_version: PLAN_MODEL_VERSION,
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1],
|
|
plan_scale: 'broken', rooms: [],
|
|
}],
|
|
markers: [], settings: {},
|
|
}, {});
|
|
assert.equal(result.config.spaces[0].plan_scale, 'broken');
|
|
assert.equal(result.report.migrated, 0);
|
|
});
|
|
|
|
test('model version bookkeeping does not claim a data migration', () => {
|
|
const result = optimizePlans({
|
|
model_version: PLAN_MODEL_VERSION - 1,
|
|
spaces: [], markers: [], settings: {},
|
|
}, {});
|
|
assert.equal(result.changed, false);
|
|
assert.equal(result.config.model_version, PLAN_MODEL_VERSION - 1);
|
|
assert.equal(result.report.modelTo, PLAN_MODEL_VERSION - 1);
|
|
assert.equal(result.report.migrated, 0);
|
|
});
|
|
|
|
test('issue 252 Optimize detaches a live marker without silently deleting its old position', () => {
|
|
const input = {
|
|
model_version: PLAN_MODEL_VERSION - 1,
|
|
spaces: [{ id: 'home', title: 'Home', view_box: [0, 0, 1, 1], rooms: [] }],
|
|
markers: [{ id: 'm', binding: 'virtual', space: 'gone', icon: 'mdi:sofa' }],
|
|
settings: {},
|
|
};
|
|
const result = optimizePlans(input, { m: { s: 'gone', x: 0.25, y: 0.5 } });
|
|
assert.equal(result.changed, true);
|
|
assert.equal(result.config.model_version, PLAN_MODEL_VERSION);
|
|
assert.equal(result.config.markers[0].space, undefined);
|
|
assert.equal(result.config.markers[0].icon, 'mdi:sofa');
|
|
assert.deepEqual(result.layout.m, { s: 'gone', x: 0.25, y: 0.5 });
|
|
assert.equal(result.report.markersDetached, 1);
|
|
assert.equal(result.report.positionsUnresolved, 1);
|
|
assert.equal(result.report.liveMissingPositions.length, 1);
|
|
assert.equal(result.report.migrated, 0, 'reference counters stay separate from migration');
|
|
});
|
|
|
|
test('optimizer never downgrades a model from a newer client', () => {
|
|
const future = PLAN_MODEL_VERSION + 1;
|
|
const result = optimizePlans({
|
|
model_version: future,
|
|
spaces: [], markers: [], settings: {},
|
|
}, {});
|
|
assert.equal(result.changed, false);
|
|
assert.equal(result.config.model_version, future);
|
|
assert.equal(result.report.modelTo, future);
|
|
});
|
|
|
|
test('legacy filled shapes receive the canonical fill fields', () => {
|
|
const result = optimizePlans({
|
|
model_version: PLAN_MODEL_VERSION,
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], rooms: [],
|
|
decor: [{
|
|
id: 'r1', kind: 'rect', x: 0, y: 0, w: 0.2, h: 0.2,
|
|
color: '#123456', fill: true,
|
|
}],
|
|
}],
|
|
markers: [], settings: {},
|
|
}, {});
|
|
assert.equal(result.config.spaces[0].decor[0].fill_color, '#123456');
|
|
assert.equal(result.config.spaces[0].decor[0].fill_opacity, 0.25);
|
|
});
|
|
|
|
test('optimizer removes legacy self-control without inventing a light source', () => {
|
|
const result = optimizePlans({
|
|
model_version: PLAN_MODEL_VERSION - 1,
|
|
spaces: [],
|
|
markers: [{
|
|
id: 'hood', binding: 'entity:switch.hood',
|
|
controls: ['switch.hood', 'input_boolean.legacy', 'light.mirror', 'light.mirror'],
|
|
}],
|
|
settings: {},
|
|
}, {});
|
|
assert.deepEqual(result.config.markers[0].controls, [
|
|
'input_boolean.legacy', 'light.mirror', 'light.mirror',
|
|
]);
|
|
assert.equal(result.config.markers[0].is_light, undefined);
|
|
assert.equal(result.config.model_version, PLAN_MODEL_VERSION);
|
|
assert.equal(result.report.migrated, 1);
|
|
});
|
|
|
|
test('optimizer repairs legacy cell_cm values into the server schema range', () => {
|
|
const result = optimizePlans({
|
|
spaces: [
|
|
{ id: 'high', title: 'High', view_box: [0, 0, 1, 1], cell_cm: 5000, rooms: [] },
|
|
{ id: 'low', title: 'Low', view_box: [0, 0, 1, 1], cell_cm: 0.01, rooms: [] },
|
|
{ id: 'bad', title: 'Bad', view_box: [0, 0, 1, 1], cell_cm: 'NaN', rooms: [] },
|
|
{ id: 'zero', title: 'Zero', view_box: [0, 0, 1, 1], cell_cm: 0, rooms: [] },
|
|
{ id: 'null', title: 'Null', view_box: [0, 0, 1, 1], cell_cm: null, rooms: [] },
|
|
{ id: 'negative', title: 'Negative', view_box: [0, 0, 1, 1], cell_cm: -2, rooms: [] },
|
|
],
|
|
markers: [], settings: {},
|
|
}, {});
|
|
assert.deepEqual(result.config.spaces.map((space) => space.cell_cm), [1000, 0.1, 5, 5, 5, 5]);
|
|
assert.equal(result.report.migrated, 6);
|
|
});
|
|
|
|
test('optimizer separates legacy Glow from data fill without losing explicit booleans', () => {
|
|
const result = optimizePlans({
|
|
spaces: [{
|
|
id: 'legacy', title: 'Legacy', view_box: [0, 0, 1, 1],
|
|
settings: { fill_mode: 'glow', glow_enabled: false, future: 'kept' },
|
|
rooms: [
|
|
{ ...room('a', 0, 0.5), settings: { fill_mode: 'glow', future: 1 } },
|
|
{ ...room('b', 0.5, 1), settings: { fill_mode: 'glow', glow: false } },
|
|
],
|
|
}],
|
|
markers: [], settings: {},
|
|
}, {});
|
|
const space = result.config.spaces[0];
|
|
assert.equal(space.settings.fill_mode, 'none');
|
|
assert.equal(space.settings.glow_enabled, false);
|
|
assert.equal(space.settings.future, 'kept');
|
|
assert.deepEqual(space.rooms[0].settings, { future: 1, glow: true });
|
|
assert.deepEqual(space.rooms[1].settings, { glow: false });
|
|
assert.equal(result.report.migrated, 3);
|
|
assert.equal(result.report.glowSpacesMigrated, 1);
|
|
assert.equal(result.report.glowRoomsMigrated, 2);
|
|
const again = optimizePlans(result.config, result.layout);
|
|
assert.equal(again.changed, false);
|
|
});
|
|
|
|
test('optimizer canonicalises only an explicitly stored square-column angle', () => {
|
|
const result = optimizePlans({
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], rooms: [],
|
|
wall_columns: [
|
|
{ id: 'implicit', shape: 'square', center: [0.2, 0.2], cm: 30 },
|
|
{ id: 'explicit', shape: 'square', center: [0.4, 0.4], cm: 30, angle: 95 },
|
|
],
|
|
}],
|
|
markers: [], settings: {},
|
|
}, {});
|
|
const [implicit, explicit] = result.config.spaces[0].wall_columns;
|
|
assert.equal('angle' in implicit, false);
|
|
assert.equal(explicit.angle, 5);
|
|
});
|
|
|
|
// --- issue #229: сращивание независимых стен в «Оптимизировать планы» --------
|
|
|
|
const partition = (id, ax, ay, bx, by, cm = 15) => ({ id, a: [ax, ay], b: [bx, by], cm });
|
|
|
|
test('issue 229 Optimize collapses the seams an older plan has piled up', () => {
|
|
const config = {
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1], rooms: [],
|
|
partitions: [
|
|
partition('p1', 0.1, 0.1, 0.3, 0.1),
|
|
partition('p2', 0.3, 0.1, 0.5, 0.1),
|
|
partition('p3', 0.5, 0.1, 0.7, 0.1),
|
|
// a lone wall elsewhere: nothing to merge with, must survive untouched
|
|
partition('lonely', 0.1, 0.6, 0.4, 0.6),
|
|
],
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
const result = optimizePlans(config, {});
|
|
const space = result.config.spaces[0];
|
|
assert.equal(result.report.partitionsMerged, 2, 'three collinear pieces are one wall');
|
|
assert.equal(space.partitions.length, 2);
|
|
const merged = space.partitions.find((p) => p.id !== 'lonely');
|
|
assert.deepEqual([merged.a, merged.b], [[0.1, 0.1], [0.7, 0.1]]);
|
|
assert.ok(space.partitions.some((p) => p.id === 'lonely'), 'an unrelated wall is left alone');
|
|
|
|
// Idempotent: the sweep has nothing left to do on its own output.
|
|
const again = optimizePlans(result.config, result.layout);
|
|
assert.equal(again.report.partitionsMerged, 0);
|
|
assert.equal(again.config.spaces[0].partitions.length, 2);
|
|
});
|
|
|
|
test('issue 229 Optimize keeps a door where it was when its host is merged', () => {
|
|
const config = {
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1], rooms: [],
|
|
partitions: [
|
|
partition('p1', 0.1, 0.1, 0.3, 0.1),
|
|
partition('p2', 0.3, 0.1, 0.5, 0.1),
|
|
],
|
|
// door in the middle of p2 → absolute x = 0.4
|
|
openings: [{
|
|
id: 'door', type: 'door', x: 0.4, y: 0.1, angle: 0, length: 0.05,
|
|
host: { kind: 'partition', id: 'p2', t: 0.5 },
|
|
}],
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
const result = optimizePlans(config, {});
|
|
const space = result.config.spaces[0];
|
|
assert.equal(result.report.partitionsMerged, 1);
|
|
const [wall] = space.partitions;
|
|
const [door] = space.openings;
|
|
assert.equal(door.host.id, wall.id, 'the host that vanished is not referenced any more');
|
|
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
|
|
assert.ok(Math.abs(x - 0.4) < 1e-9, `door moved to ${x}`);
|
|
// …and the legacy projection follows, because that is all an older reader sees
|
|
assert.ok(Math.abs(door.x - 0.4) < 1e-9, `stale projection: ${door.x}`);
|
|
});
|
|
|
|
test('issue 229 Optimize rewrites the legacy projection when the merged wall turns around', () => {
|
|
// Both pieces are stored right-to-left; the survivor is canonicalised
|
|
// left-to-right, so the angle an older reader draws from must be rewritten.
|
|
const config = {
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1], rooms: [],
|
|
partitions: [
|
|
partition('p1', 0.5, 0.1, 0.3, 0.1),
|
|
partition('p2', 0.3, 0.1, 0.1, 0.1),
|
|
],
|
|
openings: [{
|
|
id: 'door', type: 'door', x: 0.4, y: 0.1, angle: 180, length: 0.05,
|
|
host: { kind: 'partition', id: 'p1', t: 0.5 },
|
|
}],
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
const result = optimizePlans(config, {});
|
|
const space = result.config.spaces[0];
|
|
const [wall] = space.partitions;
|
|
const [door] = space.openings;
|
|
assert.equal(result.report.partitionsMerged, 1);
|
|
assert.deepEqual([wall.a, wall.b], [[0.1, 0.1], [0.5, 0.1]], 'survivor points one way');
|
|
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
|
|
assert.ok(Math.abs(x - 0.4) < 1e-9, `door moved to ${x}`);
|
|
assert.ok(Math.abs(door.x - 0.4) < 1e-9, `stale projection x: ${door.x}`);
|
|
assert.equal(door.angle, 0, 'stale projection angle');
|
|
});
|
|
|
|
test('issue 229 a node on the side of a room survives the sweep', () => {
|
|
// The junction sits in the middle of the room's bottom side, not on a
|
|
// corner — an ordinary T-junction, and the reason the node exists.
|
|
// Regression for the rescaled room polygons of CODE-REVIEW-229-r1 High-1.
|
|
const config = {
|
|
spaces: [{
|
|
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1],
|
|
rooms: [{ id: 'r1', x: 0.1, y: 0.1, w: 0.4, h: 0.4 }],
|
|
partitions: [
|
|
partition('p1', 0.1, 0.5, 0.3, 0.5),
|
|
partition('p2', 0.3, 0.5, 0.5, 0.5),
|
|
],
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
const result = optimizePlans(config, {});
|
|
assert.equal(result.report.partitionsMerged, 0, 'the room side holds the node');
|
|
assert.equal(result.config.spaces[0].partitions.length, 2);
|
|
});
|