mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
163 lines
7.5 KiB
JavaScript
163 lines
7.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
createSpaceCopyCandidate, newSpaceCopySeed, nextSpaceCopyTitle, SpaceCopyError,
|
|
} from '../test-build/space-copy.js';
|
|
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
|
import { makeLargeHouseFixture } from '../demo/fixtures/large-house.mjs';
|
|
|
|
const fixture = () => ({
|
|
model_version: 9,
|
|
spaces: [
|
|
{ id: 'before', title: 'Before', rooms: [], wall_segments: [], view_box: [0, 0, 1, 1] },
|
|
{
|
|
id: 'source', title: 'First floor', view_box: [0.1, 0.2, 0.7, 0.6], cell_cm: 1,
|
|
rooms: [{ id: 'room', name: 'Kitchen', poly: [[0, 0], [1, 0], [1, 1]] }],
|
|
wall_segments: [
|
|
{ id: 'wall-a', a: [0, 0], b: [1, 0], cm: 20, owners: ['room'] },
|
|
{ id: 'wall-b', a: [1, 0], b: [1, 1], cm: 0, owners: ['room'] },
|
|
],
|
|
partitions: [{ id: 'part-a', a: [0.5, 0], b: [0.5, 1], cm: 10, style: { keep: true } }],
|
|
openings: [
|
|
{
|
|
id: 'door', type: 'door', x: 0.25, y: 0, angle: 0, length: 0.2,
|
|
host: { kind: 'wall', id: 'wall-a', t: 0.25 },
|
|
contact: 'binary_sensor.door', lock: 'lock.door', invert: true,
|
|
},
|
|
{
|
|
id: 'passage', type: 'passage', x: 0.5, y: 0.6, angle: 90, length: 0.3,
|
|
host: { kind: 'partition', id: 'part-a', t: 0.6 }, flip_h: true,
|
|
},
|
|
],
|
|
decor: [{ id: 'decor-a', kind: 'rect', x: 0.2, y: 0.3, style: { color: '#123456' } }],
|
|
wall_columns: [{ id: 'column-a', x: 0.7, y: 0.8, cm: 20, shape: 'square' }],
|
|
room_drafts: [{ id: 'draft-a', path: [[0, 0], [0.2, 0.2]] }],
|
|
open_spans: [{ a: [0, 0], b: [0.1, 0] }],
|
|
walls: [{ key: 'legacy', cm: 15 }],
|
|
settings: { show_names: false, nested: { keep: true } },
|
|
zero_wall_style: 'solid',
|
|
plan_url: '/api/houseplan/plan/source.svg', plan_aspect: 1.5,
|
|
plan_x: 0.1, plan_y: 0.2, plan_scale: 1.2,
|
|
plan_scale_x: -1, plan_scale_y: 0.9, plan_angle: 12,
|
|
future_room_bound_field: { must_not_copy: true },
|
|
},
|
|
{ id: 'after', title: 'After', rooms: [], wall_segments: [], view_box: [0, 0, 1, 1] },
|
|
],
|
|
markers: [{ id: 'device', space: 'source', room_id: 'room', vacuum: { map_routes: [{ space: 'source' }] } }],
|
|
settings: {
|
|
global: true,
|
|
summary_panel: { version: 1, title: 'Summary', show_on_mobile: true, blocks: [] },
|
|
future_namespace: { sentinel: 'kept' },
|
|
},
|
|
});
|
|
|
|
test('#456 chooses the first free numbered copy title', () => {
|
|
const spaces = [{ title: 'Floor' }, { title: 'Floor (2)' }, { title: 'Floor (4)' }];
|
|
assert.equal(nextSpaceCopyTitle(' Floor ', spaces, 'Space'), 'Floor (3)');
|
|
assert.equal(nextSpaceCopyTitle('', [{ title: 'Space (2)' }], 'Space'), 'Space (3)');
|
|
});
|
|
|
|
test('#456 copies the complete allowed physical surface without room or device ownership', () => {
|
|
const input = fixture();
|
|
const before = structuredClone(input);
|
|
const result = createSpaceCopyCandidate(input, 'source', ' My copy ', 'seed');
|
|
const copy = result.space;
|
|
|
|
assert.deepEqual(input, before, 'preview must be immutable');
|
|
assert.deepEqual(result.config.spaces.map((space) => space.id), ['before', 'source', 'sseed', 'after']);
|
|
assert.equal(copy.title, 'My copy');
|
|
assert.deepEqual(copy.rooms, []);
|
|
assert.deepEqual(copy.wall_segments, []);
|
|
assert.deepEqual(copy.partitions.map(({ id, a, b, cm }) => ({ id, a, b, cm })), [
|
|
{ id: 'cwseed0', a: [0, 0], b: [1, 0], cm: 20 },
|
|
{ id: 'cwseed1', a: [1, 0], b: [1, 1], cm: 0 },
|
|
{ id: 'cpseed2', a: [0.5, 0], b: [0.5, 1], cm: 10 },
|
|
]);
|
|
assert.equal(Object.hasOwn(copy.partitions[2], 'style'), false, 'unknown wall metadata is not copied');
|
|
assert.deepEqual(copy.openings.map((opening) => ({
|
|
id: opening.id, host: opening.host, contact: opening.contact, lock: opening.lock,
|
|
})), [
|
|
{ id: 'coseed3', host: { kind: 'partition', id: 'cwseed0', t: 0.25 }, contact: undefined, lock: undefined },
|
|
{ id: 'coseed4', host: { kind: 'partition', id: 'cpseed2', t: 0.6 }, contact: undefined, lock: undefined },
|
|
]);
|
|
assert.equal(copy.openings[0].invert, true);
|
|
assert.equal(copy.openings[1].flip_h, true);
|
|
assert.equal(copy.decor[0].id, 'cdseed5');
|
|
assert.equal(copy.wall_columns[0].id, 'ccseed6');
|
|
for (const key of [
|
|
'settings', 'zero_wall_style', 'cell_cm', 'view_box', 'plan_url', 'plan_aspect',
|
|
'plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle',
|
|
]) assert.deepEqual(copy[key], before.spaces[1][key], key);
|
|
for (const key of ['room_drafts', 'open_spans', 'walls', 'future_room_bound_field']) {
|
|
assert.equal(Object.hasOwn(copy, key), false, key);
|
|
}
|
|
assert.deepEqual(result.config.markers, before.markers, 'markers and vacuum routes stay untouched');
|
|
assert.deepEqual(result.config.settings, before.settings, 'global settings stay untouched');
|
|
|
|
copy.settings.nested.keep = false;
|
|
copy.decor[0].style.color = '#ffffff';
|
|
assert.equal(input.spaces[1].settings.nested.keep, true, 'settings are deep-copied');
|
|
assert.equal(input.spaces[1].decor[0].style.color, '#123456', 'decor is deep-copied');
|
|
});
|
|
|
|
test('#456 rejects missing and unknown opening hosts instead of losing an opening', () => {
|
|
const missing = fixture();
|
|
delete missing.spaces[1].openings[0].host;
|
|
assert.throws(
|
|
() => createSpaceCopyCandidate(missing, 'source', 'Copy', 'seed'),
|
|
(error) => error instanceof SpaceCopyError && error.code === 'opening_host_missing',
|
|
);
|
|
|
|
const unknown = fixture();
|
|
unknown.spaces[1].openings[0].host.id = 'gone';
|
|
assert.throws(
|
|
() => createSpaceCopyCandidate(unknown, 'source', 'Copy', 'seed'),
|
|
(error) => error instanceof SpaceCopyError && error.code === 'opening_host_unknown',
|
|
);
|
|
});
|
|
|
|
test('#456 enforces backend collection boundaries before producing a candidate', () => {
|
|
const atSpaceLimit = fixture();
|
|
atSpaceLimit.spaces.push(...Array.from({ length: 47 }, (_, index) => ({
|
|
id: `extra-${index}`, title: `Extra ${index}`, rooms: [], wall_segments: [], view_box: [0, 0, 1, 1],
|
|
})));
|
|
assert.throws(
|
|
() => createSpaceCopyCandidate(atSpaceLimit, 'source', 'Copy', 'seed'),
|
|
(error) => error instanceof SpaceCopyError && error.code === 'spaces_limit',
|
|
);
|
|
|
|
const tooManyWalls = fixture();
|
|
tooManyWalls.spaces[1].wall_segments = Array.from({ length: 2001 }, (_, index) => ({
|
|
id: `w-${index}`, a: [index / 3000, 0], b: [index / 3000, 1], cm: 10,
|
|
}));
|
|
tooManyWalls.spaces[1].openings = [];
|
|
tooManyWalls.spaces[1].partitions = [];
|
|
assert.throws(
|
|
() => createSpaceCopyCandidate(tooManyWalls, 'source', 'Copy', 'seed'),
|
|
(error) => error instanceof SpaceCopyError && error.code === 'partitions_limit',
|
|
);
|
|
});
|
|
|
|
test('#456 generated seeds are bounded, deterministic at the test seam and title-free', () => {
|
|
assert.equal(newSpaceCopySeed(123456789, 0.5), '21i3v94zsow');
|
|
assert.match(newSpaceCopySeed(), /^[a-z0-9]+$/);
|
|
});
|
|
|
|
test('#456 the large-house floor keeps every wall and opening after Optimize', () => {
|
|
const fixture = makeLargeHouseFixture();
|
|
const optimized = optimizePlans(fixture.config, fixture.layout);
|
|
const source = optimized.config.spaces[0];
|
|
const result = createSpaceCopyCandidate(
|
|
optimized.config, source.id, `${source.title} copy`, 'large',
|
|
);
|
|
assert.equal(result.space.rooms.length, 0);
|
|
assert.equal(result.space.wall_segments.length, 0);
|
|
assert.equal(
|
|
result.space.partitions.length,
|
|
source.wall_segments.length + (source.partitions?.length || 0),
|
|
);
|
|
assert.equal(result.space.openings.length, source.openings.length);
|
|
assert.ok(result.space.openings.every((opening) => opening.host.kind === 'partition'));
|
|
});
|