mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
235 lines
12 KiB
JavaScript
235 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { buildPdfPage } from '../test-build/pdf/pdf-scene.js';
|
|
import { stableDimensionEdges } from '../test-build/pdf/pdf-dimensions.js';
|
|
import { makeLargeHouseFixture } from '../demo/fixtures/large-house.mjs';
|
|
import { fixtureWallKey } from '../demo/fixtures/wall-key.mjs';
|
|
import { physicalBodyParts } from '../test-build/physical-geometry.js';
|
|
import {
|
|
geometryOpenings, geometryPartitionOpeningCuts, geometryRoomOpeningInputs,
|
|
} from '../test-build/plan-geometry-preflight.js';
|
|
import { spaceModels, GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
|
|
import { innerContourForRoom, wallBodiesGeometry } from '../test-build/wall-thickness.js';
|
|
import { resolveZeroWalls } from '../test-build/zero-walls.js';
|
|
|
|
const rawSpace = {
|
|
id: 'ground', title: 'Первый этаж', cell_cm: 5, view_box: [0, 0, 1, 1],
|
|
rooms: [{ id: 'room', name: 'Кухня', area: null,
|
|
poly: [[0.1, 0.1], [0.8, 0.1], [0.8, 0.7], [0.1, 0.7]],
|
|
wall_ids: ['top', 'right', 'bottom', 'left'] }],
|
|
walls: [],
|
|
wall_segments: [
|
|
{ id: 'top', a: [0.1, 0.1], b: [0.8, 0.1], cm: 0 },
|
|
{ id: 'right', a: [0.8, 0.1], b: [0.8, 0.7], cm: 0 },
|
|
{ id: 'bottom', a: [0.8, 0.7], b: [0.1, 0.7], cm: 0 },
|
|
{ id: 'left', a: [0.1, 0.7], b: [0.1, 0.1], cm: 0 },
|
|
],
|
|
partitions: [], wall_columns: [], decor: [], settings: {},
|
|
openings: [{ id: 'door', type: 'door', x: 0.45, y: 0.1, angle: 0, length: 0.12 }],
|
|
};
|
|
const config = { model_version: 9, spaces: [rawSpace], markers: [], settings: {} };
|
|
const space = spaceModels(config)[0];
|
|
const t = (key, vars) => key === 'pdf.scale' ? `Scale 1:${vars.n}` : key;
|
|
|
|
const page = (options) => buildPdfPage({
|
|
config, rawSpace, space, layout: {}, options, imperial: false,
|
|
cardTitle: 'House', version: 'test', now: new Date('2026-09-07T00:00:00Z'), t,
|
|
});
|
|
|
|
const sharedRaw = (() => {
|
|
const segments = [
|
|
{ id: 'tl', a: [0.1, 0.1], b: [0.5, 0.1], cm: 15 },
|
|
{ id: 'mid', a: [0.5, 0.1], b: [0.5, 0.8], cm: 15 },
|
|
{ id: 'bl', a: [0.5, 0.8], b: [0.1, 0.8], cm: 15 },
|
|
{ id: 'left', a: [0.1, 0.8], b: [0.1, 0.1], cm: 15 },
|
|
{ id: 'tr', a: [0.5, 0.1], b: [0.9, 0.1], cm: 15 },
|
|
{ id: 'right', a: [0.9, 0.1], b: [0.9, 0.8], cm: 15 },
|
|
{ id: 'br', a: [0.9, 0.8], b: [0.5, 0.8], cm: 15 },
|
|
];
|
|
return {
|
|
id: 'shared', title: 'Shared wall', cell_cm: 5, view_box: [0, 0, 1, 1],
|
|
rooms: [
|
|
{ id: 'left-room', name: 'Left', area: null,
|
|
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.8], [0.1, 0.8]],
|
|
wall_ids: ['tl', 'mid', 'bl', 'left'] },
|
|
{ id: 'right-room', name: 'Right', area: null,
|
|
poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.8], [0.5, 0.8]],
|
|
wall_ids: ['tr', 'right', 'br', 'mid'] },
|
|
],
|
|
wall_segments: segments,
|
|
walls: segments.map((segment) => ({
|
|
key: fixtureWallKey(segment.a, segment.b), cm: segment.cm,
|
|
a: segment.a, b: segment.b,
|
|
})),
|
|
openings: [], partitions: [], wall_columns: [], decor: [], settings: {},
|
|
};
|
|
})();
|
|
|
|
const sharedPage = (options, extra = {}) => {
|
|
const sharedConfig = {
|
|
model_version: 10, spaces: [sharedRaw],
|
|
markers: [{ id: 'secret-device-marker', binding: 'entity:light.secret' }], settings: {},
|
|
};
|
|
return buildPdfPage({
|
|
config: sharedConfig, rawSpace: sharedRaw, space: spaceModels(sharedConfig)[0], layout: {},
|
|
options, imperial: false, cardTitle: 'House', version: 'test',
|
|
now: new Date('2026-09-07T00:00:00Z'), t, ...extra,
|
|
});
|
|
};
|
|
|
|
test('PDF scene includes architecture and respects names/dimensions switches', () => {
|
|
const full = page({ dimensions: true, roomNames: true, decor: false, backdrop: false });
|
|
const minimal = page({ dimensions: false, roomNames: false, decor: false, backdrop: false });
|
|
const fullText = full.commands.filter((command) => command.kind === 'text').map((command) => command.text);
|
|
const minimalText = minimal.commands.filter((command) => command.kind === 'text').map((command) => command.text);
|
|
assert.ok(fullText.includes('Кухня'));
|
|
assert.ok(fullText.some((value) => /m²/.test(value)));
|
|
assert.ok(!minimalText.includes('Кухня'));
|
|
assert.ok(!minimalText.some((value) => /m²/.test(value)));
|
|
assert.ok(!minimalText.some((value) => value !== '1 m'
|
|
&& /^[-+]?\d+(?:[.,]\d+)?\s(?:m|cm|ft|in)$/.test(value)));
|
|
assert.ok(full.commands.some((command) => command.kind === 'line' && command.dash),
|
|
'zero-thickness walls stay dashed');
|
|
assert.ok(full.commands.some((command) => command.kind === 'line' && !command.dash),
|
|
'door leaf/arc is represented by vector lines');
|
|
});
|
|
|
|
test('shared wall architecture is emitted once and devices never enter the PDF scene', () => {
|
|
const output = sharedPage({ dimensions: false, roomNames: false, decor: false, backdrop: false });
|
|
const architecture = output.commands.filter((command) => command.kind === 'path');
|
|
assert.equal(architecture.length, 1);
|
|
assert.ok(!output.commands.some((command) => command.kind === 'text'
|
|
&& command.text.includes('secret-device-marker')));
|
|
});
|
|
|
|
test('dimensions switch removes the external chain from physical walls', () => {
|
|
const texts = (dimensions) => sharedPage({
|
|
dimensions, roomNames: false, decor: false, backdrop: false,
|
|
}).commands.filter((command) => command.kind === 'text').map((command) => command.text)
|
|
.filter((value) => value !== '1 m' && /^[-+]?\d+(?:[.,]\d+)?\s(?:m|cm|ft|in)$/.test(value));
|
|
assert.ok(texts(true).length > 0, 'the thick outer wall has a dimension chain');
|
|
assert.deepEqual(texts(false), [], 'the option removes every dimension value');
|
|
});
|
|
|
|
test('backdrop is below physical architecture and disappears with its option', () => {
|
|
const raster = {
|
|
id: 'backdrop', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
|
|
width: 1, height: 1, x: 0, y: 0, drawWidth: 1000, drawHeight: 1000,
|
|
opacity: 0.6,
|
|
};
|
|
const visible = sharedPage(
|
|
{ dimensions: false, roomNames: false, decor: false, backdrop: true },
|
|
{ rasters: [raster] },
|
|
);
|
|
const imageIndex = visible.commands.findIndex((command) => command.kind === 'image');
|
|
const wallIndex = visible.commands.findIndex((command) => command.kind === 'path');
|
|
assert.ok(imageIndex >= 0 && wallIndex > imageIndex);
|
|
assert.ok(!sharedPage({ dimensions: false, roomNames: false, decor: false, backdrop: false })
|
|
.commands.some((command) => command.kind === 'image'));
|
|
});
|
|
|
|
test('PDF scene chooses one A4 orientation and a standard scale', () => {
|
|
const output = page({ dimensions: false, roomNames: false, decor: false, backdrop: false });
|
|
assert.ok(output.width === 595.2755905511812 || output.height === 595.2755905511812);
|
|
assert.ok([20, 25, 50, 75, 100, 150, 200, 250, 500].includes(output.scale));
|
|
});
|
|
|
|
test('dense non-rectangular rooms keep mandatory dimensions in stable callouts', () => {
|
|
const denseRaw = structuredClone(rawSpace);
|
|
denseRaw.id = 'dense';
|
|
denseRaw.title = 'Dense';
|
|
denseRaw.rooms[0].id = 'dense-room';
|
|
denseRaw.rooms[0].name = 'Комната с выступом';
|
|
denseRaw.rooms[0].poly = [
|
|
[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.55, 0.9],
|
|
[0.55, 0.84], [0.45, 0.84], [0.45, 0.9], [0.1, 0.9],
|
|
];
|
|
denseRaw.wall_segments = denseRaw.rooms[0].poly.map((a, index, ring) => ({
|
|
id: `dense-${index}`, a, b: ring[(index + 1) % ring.length], cm: 0,
|
|
}));
|
|
denseRaw.rooms[0].wall_ids = denseRaw.wall_segments.map((wall) => wall.id);
|
|
denseRaw.openings = [];
|
|
const denseConfig = { model_version: 9, spaces: [denseRaw], markers: [], settings: {} };
|
|
const output = buildPdfPage({
|
|
config: denseConfig, rawSpace: denseRaw, space: spaceModels(denseConfig)[0], layout: {},
|
|
options: { dimensions: true, roomNames: true, decor: false, backdrop: false },
|
|
imperial: false, cardTitle: 'House', version: 'test',
|
|
now: new Date('2026-09-07T00:00:00Z'), t,
|
|
});
|
|
const texts = output.commands.filter((command) => command.kind === 'text')
|
|
.map((command) => command.text);
|
|
assert.ok(texts.includes('pdf.internal_dimensions'));
|
|
assert.ok(texts.some((value) => /^R\d+$/.test(value)));
|
|
assert.ok(texts.some((value) => /^R\d+ .+: .+/.test(value)));
|
|
const expectedValues = stableDimensionEdges(
|
|
denseRaw.rooms[0].poly.map(([x, y]) => [x * NORM_W, y * NORM_W]), 5 / GRID_PITCH, false,
|
|
).filter((edge) => !edge.short).length;
|
|
assert.equal(texts.filter((value) => value !== '1 m' && /(?:^|:\s)\d+(?:[.,]\d+)?\sm$/.test(value)).length,
|
|
expectedValues, 'every non-short clean-contour edge keeps one reconstructable value');
|
|
});
|
|
|
|
test('decor toggle uses the canonical designer furniture vector path', () => {
|
|
const decorated = structuredClone(rawSpace);
|
|
decorated.decor = [{
|
|
id: 'sofa', kind: 'furniture', symbol: 'sofa',
|
|
x: 0.2, y: 0.3, w: 0.3, h: 0.15, angle: 30, width_cm: 2,
|
|
}];
|
|
const decoratedConfig = { model_version: 9, spaces: [decorated], markers: [], settings: {} };
|
|
const make = (decor) => buildPdfPage({
|
|
config: decoratedConfig, rawSpace: decorated, space: spaceModels(decoratedConfig)[0], layout: {},
|
|
options: { dimensions: false, roomNames: false, decor, backdrop: false },
|
|
imperial: false, cardTitle: 'House', version: 'test',
|
|
now: new Date('2026-09-07T00:00:00Z'), t,
|
|
});
|
|
assert.ok(make(true).commands.some((command) => command.kind === 'vector'
|
|
&& command.ops.some((operation) => operation.op === 'C')));
|
|
assert.ok(!make(false).commands.some((command) => command.kind === 'vector'));
|
|
});
|
|
|
|
test('current 20-room large-house space builds from the visible geometry cache under 200 ms', () => {
|
|
const fixture = makeLargeHouseFixture();
|
|
const largeConfig = { ...fixture.config, model_version: 9 };
|
|
const largeRaw = largeConfig.spaces[0];
|
|
const largeSpace = spaceModels(largeConfig)[0];
|
|
assert.equal(largeSpace.rooms.length, 20);
|
|
const cellCm = largeRaw.cell_cm;
|
|
const zero = resolveZeroWalls(largeRaw, largeSpace, NORM_W, GRID_PITCH * 0.02);
|
|
const openings = geometryOpenings(largeRaw, largeSpace, cellCm, GRID_PITCH, NORM_W);
|
|
const cuts = geometryPartitionOpeningCuts(openings);
|
|
const extras = physicalBodyParts(
|
|
largeSpace, cellCm, GRID_PITCH, GRID_PITCH * 0.0002, cuts,
|
|
).all;
|
|
const roomOpenings = geometryRoomOpeningInputs(
|
|
openings, largeSpace, largeRaw.walls, zero.contour,
|
|
GRID_STEP_N, cellCm, GRID_PITCH, NORM_W,
|
|
);
|
|
const sharedWallGeometry = wallBodiesGeometry(
|
|
largeSpace.rooms, largeRaw.walls, zero.contour, roomOpenings,
|
|
GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
|
|
);
|
|
const innerContours = new Map(largeSpace.rooms.map((room) => [room.id, innerContourForRoom(
|
|
largeSpace.rooms, room.id, largeRaw.walls, zero.contour, GRID_STEP_N,
|
|
cellCm, GRID_PITCH, NORM_W, sharedWallGeometry.roomGeom, sharedWallGeometry.multiWallNodes,
|
|
)]));
|
|
// The full suite runs test files concurrently on CI. Measuring wall time here would
|
|
// count periods when this worker is descheduled, so use the CPU consumed by the
|
|
// scene build itself while keeping the agreed 200 ms product budget unchanged.
|
|
const started = process.cpuUsage();
|
|
let contourCacheReads = 0;
|
|
buildPdfPage({
|
|
config: largeConfig, rawSpace: largeRaw, space: largeSpace, layout: fixture.layout,
|
|
sharedWallGeometry,
|
|
resolveInnerContour: (roomId) => {
|
|
contourCacheReads++;
|
|
return innerContours.get(roomId);
|
|
},
|
|
options: { dimensions: true, roomNames: true, decor: true, backdrop: false },
|
|
imperial: false, cardTitle: 'House', version: 'test',
|
|
now: new Date('2026-09-07T00:00:00Z'), t,
|
|
});
|
|
const elapsed = process.cpuUsage(started);
|
|
const elapsedMs = (elapsed.user + elapsed.system) / 1000;
|
|
assert.equal(contourCacheReads, largeSpace.rooms.length);
|
|
assert.ok(elapsedMs < 200, `PDF scene build used ${elapsedMs.toFixed(1)} ms of CPU`);
|
|
});
|