mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
Since #713 the overlay fit envelope reserves no nudge budget, and since #725 _isoScene passes `stageSize: null` while resolveIsoOverlayFitEnvelope never reads the field. The room focus still built a { width, height } object from the stage for nothing. The optional field is removed from IsoOverlayFitEnvelopeInput together with both call-site arguments. The #725 AC3 unit compared bounds with stageSize null and 1000x500, which is now meaningless; it checks instead that the fit bounds follow only scene.frame and the tiles: the same bounds for every stage aspect, a moved frame moves them, an enclosing frame is returned as is. Issue: #741 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
1033 lines
46 KiB
JavaScript
1033 lines
46 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import {
|
||
ISO_RAISED_FOOTPRINT,
|
||
buildIsoWallDepthQueue,
|
||
buildIsoOverlayRenderScene,
|
||
createIsoStructuralSource,
|
||
isoOpeningLockPlacement,
|
||
isoFixedLightTransform,
|
||
isoOverlaySceneBounds,
|
||
isoOverlayRooms,
|
||
isoRaisedOverlayHalfSize,
|
||
isoSourceOpenings,
|
||
isoStructuralOpeningHost,
|
||
isoStructuralRoomGeometry,
|
||
resolveIsoDecorationLayers,
|
||
resolveIsoFramePresentation,
|
||
resolveIsoOverlayFitEnvelope,
|
||
resolveIsoScene,
|
||
} from '../test-build/iso-scene-render.js';
|
||
import {
|
||
ISO_OPENING_GEOMETRY_POLICY,
|
||
buildIsoOpeningBasis,
|
||
projectIsoOpening,
|
||
projectIsoOpeningStructure,
|
||
} from '../test-build/iso-openings.js';
|
||
import { buildIsoWallGeometry } from '../test-build/iso-walls.js';
|
||
import { fitView } from '../test-build/logic.js';
|
||
import { wallKey } from '../test-build/wall-thickness.js';
|
||
import {
|
||
buildIsoFootprintPolygon,
|
||
resolveIsoOverlayOwner,
|
||
} from '../test-build/iso-overlays.js';
|
||
import {
|
||
ISO_WALL_HEIGHT,
|
||
projectPlanPoint,
|
||
} from '../test-build/iso-projection.js';
|
||
|
||
/**
|
||
* #724: the overlay caches are keyed by the structural wall geometry. Every
|
||
* fixture gets its own object, as a structural scene of its own would.
|
||
*/
|
||
const structureOf = (walls = []) => buildIsoWallGeometry(walls);
|
||
const wallRect = (x0, y0, x1, y1) => [[[[x0, y0], [x1, y0], [x1, y1], [x0, y1]]]];
|
||
|
||
/**
|
||
* #732: an overlay fixture names only fields of the production input type; the
|
||
* values stay as partial as each test needs. test/iso-overlay-fixture-types.test.mjs
|
||
* typechecks this file: a field nothing reads — a zoom view, a stage size,
|
||
* decoration layers, a selection, a ground radius — fails there instead of
|
||
* pretending to be an input. Every scene fixture reaches the builder through
|
||
* `overlayScene` or a declaration of this type.
|
||
*
|
||
* @typedef {{ [K in keyof import('../src/iso-scene-render.js').IsoOverlaySceneInput]?: unknown }} OverlaySceneFixture
|
||
* @typedef {{ [K in keyof import('../src/iso-scene-render.js').IsoOverlayRenderEntry]?: unknown }} OverlayEntryFixture
|
||
*/
|
||
|
||
/** @param {OverlaySceneFixture} input */
|
||
const overlayScene = (input) => buildIsoOverlayRenderScene(input);
|
||
|
||
const room = (id, x0, y0, x1, y1) => ({
|
||
id,
|
||
poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
|
||
});
|
||
|
||
test('every Stage 4 shadow plane uses the same scale-aware fixed-light vector', () => {
|
||
assert.equal(isoFixedLightTransform(5), 'translate(4 8)');
|
||
assert.equal(isoFixedLightTransform(1), 'translate(20 40)');
|
||
});
|
||
|
||
test('device footprint contains value capsule, bottom badge and displaced LQI row', () => {
|
||
const core = 100;
|
||
const presentation = {
|
||
valueText: '12345678',
|
||
valueFullText: '12345678',
|
||
valueBadge: {
|
||
configured: true,
|
||
enabled: true,
|
||
source: null,
|
||
sourceLabel: 'Energy',
|
||
text: '123.4 kWh',
|
||
fullText: '123.4 kWh',
|
||
position: 'bottom',
|
||
availability: 'available',
|
||
isLqi: false,
|
||
tone: 'default',
|
||
failure: null,
|
||
},
|
||
tempText: null,
|
||
humText: null,
|
||
lqiText: '255',
|
||
pulse: {
|
||
kind: 'none', reason: 'none', generation: 1, expiresAt: null,
|
||
color: null, diameterScale: 1.5, animated: false, reducedMotionIndicator: 'none',
|
||
},
|
||
};
|
||
const halfSize = isoRaisedOverlayHalfSize({ kind: 'device', core, presentation });
|
||
const lqiBottom = (ISO_RAISED_FOOTPRINT.deviceLqiBelowBottomBadgeTop
|
||
+ ISO_RAISED_FOOTPRINT.deviceLqiFontSize
|
||
+ ISO_RAISED_FOOTPRINT.devicePadding) * core;
|
||
assert.equal(halfSize[1], lqiBottom,
|
||
'bottom LQI position, line box and conservative footprint padding are all included');
|
||
assert.ok(halfSize[0] > core * 1.6,
|
||
'the expanding value core is not reduced to the shell diameter');
|
||
});
|
||
|
||
test('room footprint contains a long name and the complete four-metric row', () => {
|
||
const font = 20;
|
||
const labelRoom = {
|
||
...room('metrics', 0, 0, 100, 100),
|
||
name: 'Long engineering and utility room',
|
||
area: 'utility',
|
||
settings: { name_scale: 1.25, label_scale: 1.5 },
|
||
};
|
||
const halfSize = isoRaisedOverlayHalfSize({
|
||
kind: 'room-label',
|
||
font,
|
||
room: labelRoom,
|
||
display: { labelTemp: true, labelHum: true, labelLqi: true, labelLight: true },
|
||
});
|
||
const oldSymmetricMetricHalfWidth = font * 11.8 / 2;
|
||
const metricsBottom = (ISO_RAISED_FOOTPRINT.roomNameLineHeight * 1.25 / 2
|
||
+ ISO_RAISED_FOOTPRINT.roomMetricsTopGap
|
||
+ ISO_RAISED_FOOTPRINT.roomMetricFontSize * 1.5
|
||
* ISO_RAISED_FOOTPRINT.roomMetricLineHeight
|
||
+ ISO_RAISED_FOOTPRINT.roomPadding) * font;
|
||
assert.ok(halfSize[0] > oldSymmetricMetricHalfWidth * 2,
|
||
'four metric values, their icons and inter-item gaps exceed the legacy 11em guess');
|
||
assert.equal(halfSize[1], metricsBottom,
|
||
'absolute metrics below the centred name are included instead of halving total height');
|
||
});
|
||
|
||
test('room footprint treats wide ASCII glyphs conservatively', () => {
|
||
const footprint = (name) => isoRaisedOverlayHalfSize({
|
||
kind: 'room-label', font: 20,
|
||
room: { ...room('wide', 0, 0, 100, 100), name, settings: {} },
|
||
display: { labelTemp: false, labelHum: false, labelLqi: false, labelLight: false },
|
||
});
|
||
assert.ok(footprint('WWWWWW')[0] > footprint('iiiiii')[0] * 1.25,
|
||
'bold W/M labels must not escape a Latin-average fit estimate');
|
||
});
|
||
|
||
test('overlay bounds use final screen footprint and canonical owner filtering', () => {
|
||
const placement = (owner, center) => ({
|
||
owner: { id: owner }, floorScene: [center[0] - 5, center[1]],
|
||
visualScene: center,
|
||
footprint: [[center[0] - 2, center[1] - 1], [center[0] + 2, center[1] - 1],
|
||
[center[0] + 2, center[1] + 1], [center[0] - 2, center[1] + 1]],
|
||
});
|
||
/** @type {{ entries: OverlayEntryFixture[] }} */
|
||
const scene = { entries: [
|
||
{ id: 'one', kind: 'device', placement: placement('r1', [20, 30]), screenHalfSize: [5, 3] },
|
||
{ id: 'two', kind: 'device', placement: placement('r2', [200, 300]), screenHalfSize: [10, 8] },
|
||
] };
|
||
assert.deepEqual(isoOverlaySceneBounds(scene, 'r1'), { x: 15, y: 27, w: 10, h: 6 });
|
||
assert.deepEqual(isoOverlaySceneBounds(scene), { x: 15, y: 27, w: 195, h: 281 });
|
||
});
|
||
|
||
test('#713 K8: overlay fit is the structure plus visible tiles, no #651 nudge reserve', () => {
|
||
/** @type {OverlayEntryFixture} */
|
||
const entry = {
|
||
id: 'edge', kind: 'device', screenHalfSize: [10, 8],
|
||
placement: {
|
||
owner: { id: 'room' }, floorScene: [95, 50],
|
||
visualScene: [95, 50],
|
||
footprint: [[90, 46], [100, 46], [100, 54], [90, 54]],
|
||
},
|
||
};
|
||
const stageSize = { width: 320, height: 180 };
|
||
const targetView = (bounds) => {
|
||
const aspect = stageSize.width / stageSize.height;
|
||
if (bounds.w / bounds.h > aspect) {
|
||
const h = bounds.w / aspect;
|
||
return { x: bounds.x, y: bounds.y - (h - bounds.h) / 2, w: bounds.w, h };
|
||
}
|
||
const w = bounds.h * aspect;
|
||
return { x: bounds.x - (w - bounds.w) / 2, y: bounds.y, w, h: bounds.h };
|
||
};
|
||
const fitted = resolveIsoOverlayFitEnvelope({
|
||
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], targetView,
|
||
});
|
||
assert.ok(fitted);
|
||
assert.deepEqual(fitted.bounds, { x: 0, y: 0, w: 105, h: 100 },
|
||
'the tile edge at x=105 is the only growth: no 48 CSS px reserve around it');
|
||
assert.deepEqual(fitted.view, targetView(fitted.bounds));
|
||
const repeated = resolveIsoOverlayFitEnvelope({
|
||
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], targetView,
|
||
});
|
||
assert.deepEqual(repeated, fitted, 'the canonical envelope is deterministic');
|
||
const otherRoom = resolveIsoOverlayFitEnvelope({
|
||
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry],
|
||
targetView, ownerId: 'other-room',
|
||
});
|
||
assert.deepEqual(otherRoom.bounds, { x: 0, y: 0, w: 100, h: 100 });
|
||
});
|
||
|
||
test('#725 AC3/#741: the overlay fit bounds depend only on scene.frame and the tiles, never on the stage', () => {
|
||
/** @type {OverlayEntryFixture[]} */
|
||
const entries = [
|
||
{
|
||
id: 'edge', kind: 'device', screenHalfSize: [10, 8],
|
||
placement: {
|
||
owner: { id: 'room' }, floorScene: [95, 50], visualScene: [95, 50],
|
||
footprint: [[90, 46], [100, 46], [100, 54], [90, 54]],
|
||
},
|
||
},
|
||
{
|
||
id: 'low', kind: 'device', screenHalfSize: [6, 6],
|
||
placement: {
|
||
owner: { id: 'room' }, floorScene: [40, 104], visualScene: [40, 104],
|
||
footprint: [[36, 100], [44, 100], [44, 108], [36, 108]],
|
||
},
|
||
},
|
||
];
|
||
const frame = { x: 0, y: 0, w: 100, h: 100 };
|
||
// The card's targetView: a contain-fit of the frame at the stage aspect.
|
||
const fitAt = (aspect) => (bounds) => fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect);
|
||
const fit = (baseBounds, aspect) => resolveIsoOverlayFitEnvelope({ baseBounds, entries, targetView: fitAt(aspect) });
|
||
// Portrait, square, a 1000×500 and a 320×180 stage, the frame's own aspect (`_isoScene`).
|
||
const aspects = [0.5, 1, 1000 / 500, 320 / 180, frame.w / frame.h];
|
||
const results = aspects.map((aspect) => {
|
||
const fitted = fit(frame, aspect);
|
||
assert.ok(fitted, `aspect ${aspect}: an envelope`);
|
||
assert.deepEqual(fitted.view, fitAt(aspect)(fitted.bounds), `aspect ${aspect}: the view fits the bounds`);
|
||
return fitted;
|
||
});
|
||
assert.deepEqual(results[0].bounds, { x: 0, y: 0, w: 105, h: 110 }, 'the structure plus both tiles');
|
||
for (const fitted of results) assert.deepEqual(fitted.bounds, results[0].bounds);
|
||
assert.notDeepEqual(results[0].view, results[2].view, 'only the view follows the aspect');
|
||
// The bounds follow scene.frame: a moved frame moves them, an enclosing frame is the answer as is.
|
||
assert.deepEqual(fit({ x: 50, y: 0, w: 100, h: 100 }, 2).bounds, { x: 34, y: 0, w: 116, h: 110 });
|
||
const enclosing = { x: -50, y: -50, w: 300, h: 300 };
|
||
for (const aspect of aspects) assert.deepEqual(fit(enclosing, aspect).bounds, enclosing);
|
||
});
|
||
|
||
test('one painter queue paints a nearer wall after an unrelated rear opening', () => {
|
||
const geometry = buildIsoWallGeometry([[[
|
||
[0, 100], [100, 100], [100, 200], [0, 200], [0, 100],
|
||
]]]);
|
||
const nearWall = geometry.topFaces[0];
|
||
const rearOpening = {
|
||
id: 'rear-door', sourceIndex: 4, type: 'door', leaf: 0,
|
||
kind: 'leaf-front', material: 'matte-leaf', d: 'M 0 0 L 1 0 L 1 1 Z',
|
||
depth: nearWall.depth - 10, cameraDepth: nearWall.depth - 10,
|
||
};
|
||
const queue = buildIsoWallDepthQueue(geometry, [rearOpening]);
|
||
const rearIndex = queue.findIndex((entry) => entry.layer === 'opening');
|
||
const nearIndex = queue.findIndex((entry) => entry.layer === 'wall-top'
|
||
&& entry.face === nearWall);
|
||
assert.ok(rearIndex >= 0 && nearIndex > rearIndex,
|
||
'later SVG paint order must let the near wall occlude the rear opening');
|
||
assert.deepEqual(buildIsoWallDepthQueue(geometry, [rearOpening]), queue,
|
||
'the combined wall/opening order is deterministic');
|
||
});
|
||
|
||
test('shared painter queue puts elevated window glass over its rear sill only in window slots', () => {
|
||
const geometry = buildIsoWallGeometry([[[
|
||
[0, 100], [100, 100], [100, 200], [0, 200], [0, 100],
|
||
]]]);
|
||
const basis = buildIsoOpeningBasis({
|
||
id: 'window-overlap', sourceIndex: 2, type: 'window', x: 50, y: 100,
|
||
angle: 0, length: 60, flipH: false, flipV: false,
|
||
face: { ox: 0, oy: -5, cm: 20, side: -1 },
|
||
});
|
||
const surfaces = [
|
||
...projectIsoOpeningStructure(basis),
|
||
...projectIsoOpening(basis, 0).flatMap((panel) => panel.surfaces),
|
||
].map((surface, index) => ({
|
||
...surface, id: basis.id, sourceIndex: basis.sourceIndex,
|
||
type: 'window', leaf: index,
|
||
}));
|
||
const queue = buildIsoWallDepthQueue(geometry, surfaces);
|
||
const windowEntries = queue.filter((entry) => entry.layer === 'opening');
|
||
assert.deepEqual(windowEntries.map((entry) => entry.surface.cameraDepth),
|
||
windowEntries.map((entry) => entry.surface.cameraDepth).toSorted((a, b) => a - b),
|
||
'one window reuses its shared queue slots in physical camera-depth order');
|
||
const sillIndex = queue.findIndex((entry) => entry.layer === 'opening'
|
||
&& entry.surface.kind === 'window-sill');
|
||
const glassIndices = queue.flatMap((entry, index) => entry.layer === 'opening'
|
||
&& entry.surface.material.startsWith('glass') ? [index] : []);
|
||
assert.ok(sillIndex >= 0 && glassIndices.length === 6
|
||
&& glassIndices.some((index) => index > sillIndex),
|
||
'elevated glass must paint after the rear sill projection');
|
||
const wallSlots = queue.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]);
|
||
const reversed = buildIsoWallDepthQueue(geometry, [...surfaces].reverse());
|
||
assert.deepEqual(reversed.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]),
|
||
wallSlots, 'window-local occlusion cannot move unrelated wall slots');
|
||
});
|
||
|
||
test('shared painter queue keeps rotating door prism faces in physical camera order', () => {
|
||
const geometry = buildIsoWallGeometry([[[
|
||
[0, 100], [100, 100], [100, 200], [0, 200], [0, 100],
|
||
]]]);
|
||
const basis = buildIsoOpeningBasis({
|
||
id: 'door-depth', sourceIndex: 3, type: 'door', x: 50, y: 100,
|
||
angle: 0, length: 45, flipH: false, flipV: false,
|
||
face: { ox: 0, oy: -5, cm: 20, side: -1 },
|
||
});
|
||
const surfaces = [
|
||
...projectIsoOpeningStructure(basis),
|
||
...projectIsoOpening(basis, 0.5).flatMap((panel) => panel.surfaces),
|
||
].map((surface, index) => ({
|
||
...surface, id: basis.id, sourceIndex: basis.sourceIndex,
|
||
type: 'door', leaf: index,
|
||
}));
|
||
const queue = buildIsoWallDepthQueue(geometry, [...surfaces].reverse());
|
||
const doorEntries = queue.filter((entry) => entry.layer === 'opening');
|
||
assert.deepEqual(doorEntries.map((entry) => entry.surface.cameraDepth),
|
||
doorEntries.map((entry) => entry.surface.cameraDepth).toSorted((a, b) => a - b),
|
||
'all faces of one live door reuse its queue slots in physical camera-depth order');
|
||
const wallSlots = queue.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]);
|
||
const natural = buildIsoWallDepthQueue(geometry, surfaces);
|
||
assert.deepEqual(natural.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]),
|
||
wallSlots, 'door-local ordering cannot move unrelated wall slots');
|
||
});
|
||
|
||
test('production overlay rooms preserve direct island holes once per room snapshot', () => {
|
||
const outer = room('outer', 0, 0, 100, 100);
|
||
const island = room('island', 40, 40, 60, 60);
|
||
const space = { id: 'floor', rooms: [outer, island] };
|
||
const rows = isoOverlayRooms(space);
|
||
assert.strictEqual(isoOverlayRooms(space), rows, 'one immutable room snapshot is prepared once');
|
||
assert.deepEqual(rows[0].overlayRoom.holes, [island.poly]);
|
||
const owner = resolveIsoOverlayOwner({
|
||
kind: 'device', floorAnchor: [50, 50], rooms: rows.map((row) => row.overlayRoom),
|
||
});
|
||
assert.equal(owner?.id, 'island', 'a marker in the island belongs to the island, not its parent');
|
||
});
|
||
|
||
test('opening-lock placement inherits the selected physical host side', () => {
|
||
const index = {
|
||
adjacencyEps: 0.1,
|
||
edges: [
|
||
{
|
||
roomId: 'north', a: [-50, 0], b: [50, 0], inward: [0, 1],
|
||
cm: 40, half: 20, area: 1000, key: 'north-wall',
|
||
},
|
||
{
|
||
roomId: 'south', a: [-50, 0], b: [50, 0], inward: [0, -1],
|
||
cm: 40, half: 20, area: 1000, key: 'south-wall',
|
||
},
|
||
],
|
||
};
|
||
const opening = {
|
||
id: 'door', type: 'door', rx: 0, ry: 0, rlen: 40, angle: 0,
|
||
flip_h: false, flip_v: false,
|
||
};
|
||
const positive = isoOpeningLockPlacement(opening, index, 5);
|
||
const negative = isoOpeningLockPlacement({ ...opening, flip_v: true }, index, 5);
|
||
assert.equal(positive.preferredRoomId, 'north');
|
||
assert.equal(negative.preferredRoomId, 'south');
|
||
assert.ok(positive.floorAnchor[1] > 0 && negative.floorAnchor[1] < 0);
|
||
});
|
||
|
||
test('opening-lock scene keeps physical host ownership when spatial fallback points elsewhere', () => {
|
||
const host = room('host', 0, 0, 100, 100);
|
||
const decoy = room('decoy', 45, 60, 55, 75);
|
||
const space = {
|
||
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
|
||
rooms: [host, decoy], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
|
||
};
|
||
const openingWallIndex = {
|
||
adjacencyEps: 0.1,
|
||
edges: [{
|
||
roomId: 'host', a: [0, 50], b: [100, 50], inward: [0, 1],
|
||
cm: 40, half: 20, area: 10_000, key: 'host-wall',
|
||
}],
|
||
};
|
||
const opening = {
|
||
id: 'door', type: 'door', rx: 50, ry: 50, rlen: 40, angle: 0,
|
||
flip_h: false, flip_v: false, lock: 'lock.door',
|
||
};
|
||
const roomRows = isoOverlayRooms(space);
|
||
const spatialOwner = resolveIsoOverlayOwner({
|
||
kind: 'device',
|
||
floorAnchor: isoOpeningLockPlacement(opening, openingWallIndex, 5).floorAnchor,
|
||
rooms: roomRows.map((row) => row.overlayRoom),
|
||
});
|
||
assert.equal(spatialOwner?.id, 'decoy', 'fixture proves point containment would pick the wrong room');
|
||
|
||
const scene = overlayScene({
|
||
space,
|
||
devices: [],
|
||
openings: [opening],
|
||
display: { showNames: false, cardFontScale: 1 },
|
||
structure: structureOf(),
|
||
iconPct: 100,
|
||
deviceBasePct: 100,
|
||
showLqi: false,
|
||
cellCm: 5,
|
||
kioskIconScale: 1,
|
||
kioskFontScale: 1,
|
||
positionOf: () => ({ x: 0, y: 0 }),
|
||
presentationOf: () => ({ scale: 1 }),
|
||
labelPositionOf: () => ({ x: 0, y: 0 }),
|
||
labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => true,
|
||
openingWallIndex: () => openingWallIndex,
|
||
});
|
||
assert.equal(scene.locks.get('door')?.owner?.id, 'host',
|
||
'render-scene lock ownership comes from the selected physical wall side');
|
||
});
|
||
|
||
test('opening lock without a canonical host owner never guesses from point containment', () => {
|
||
const containing = room('containing', 0, 0, 100, 100);
|
||
const space = {
|
||
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
|
||
rooms: [containing], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
|
||
};
|
||
const opening = {
|
||
id: 'partition-door', type: 'door', rx: 50, ry: 50, rlen: 40, angle: 0,
|
||
flip_h: false, flip_v: false, lock: 'lock.door',
|
||
partitionHost: {
|
||
depth: 10,
|
||
axis: { ux: 1, uy: 0 },
|
||
partition: { cm: 10 },
|
||
},
|
||
};
|
||
const scene = overlayScene({
|
||
space,
|
||
devices: [],
|
||
openings: [opening],
|
||
display: { showNames: false, cardFontScale: 1 },
|
||
structure: structureOf(wallRect(0, 45, 100, 55)),
|
||
iconPct: 100,
|
||
deviceBasePct: 100,
|
||
showLqi: false,
|
||
cellCm: 5,
|
||
kioskIconScale: 1,
|
||
kioskFontScale: 1,
|
||
positionOf: () => ({ x: 0, y: 0 }),
|
||
presentationOf: () => ({ scale: 1 }),
|
||
labelPositionOf: () => ({ x: 0, y: 0 }),
|
||
labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => true,
|
||
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
|
||
});
|
||
const placement = scene.locks.get('partition-door');
|
||
assert.equal(placement?.owner, null);
|
||
// #713: no placement search runs, so a missing owner is no search failure;
|
||
// the badge simply stands on the wall-top plane above its anchor.
|
||
assert.equal(placement?.plane, 'raised');
|
||
assert.deepEqual(placement?.visualScene, projectPlanPoint(placement.floorAnchor, ISO_WALL_HEIGHT));
|
||
});
|
||
|
||
test('Stage 4 reuses pure overlay placements, and fit and the live frame share one snapshot', () => {
|
||
const owner = room('owner', 0, 0, 100, 100);
|
||
const space = {
|
||
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
|
||
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
|
||
};
|
||
const structure = structureOf(wallRect(-4, -10, 4, 110));
|
||
/** @type {OverlaySceneFixture} */
|
||
const input = {
|
||
space,
|
||
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }],
|
||
openings: [],
|
||
display: { showNames: false, cardFontScale: 1 },
|
||
structure,
|
||
iconPct: 3.4,
|
||
deviceBasePct: 3.4,
|
||
showLqi: false,
|
||
cellCm: 5,
|
||
kioskIconScale: 1,
|
||
kioskFontScale: 1,
|
||
positionOf: () => ({ x: 5, y: 50 }),
|
||
presentationOf: () => ({
|
||
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
|
||
tempText: null, humText: null, lqiText: null,
|
||
pulse: { animated: false, diameterScale: 1 },
|
||
}),
|
||
labelPositionOf: () => ({ x: 0, y: 0 }),
|
||
labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => false,
|
||
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
|
||
};
|
||
const live = overlayScene(input);
|
||
const repeated = overlayScene(input);
|
||
assert.strictEqual(repeated, live,
|
||
'an unchanged frame reuses the exact render-scene snapshot for Lit guards');
|
||
assert.strictEqual(repeated.devices.get('device'), live.devices.get('device'),
|
||
'unchanged HA/render passes reuse the exact pure placement result');
|
||
// #713: the tile stands on the wall-top plane right above its anchor even
|
||
// next to a wall; no wall test and no nudge in the live scene.
|
||
assert.deepEqual(live.devices.get('device')?.visualScene, projectPlanPoint([5, 50], ISO_WALL_HEIGHT));
|
||
|
||
// #724: the fit envelope and the live frame ask with the same inputs and read
|
||
// one snapshot — there is no search for them to differ by (#713). Zoom, pan
|
||
// and a stage resize are no input of the overlay scene at all (#714, #732):
|
||
// the fixture typecheck rejects such a field, and the production-path zoom is
|
||
// the #724 AC2 test below.
|
||
const fit = overlayScene({ ...input });
|
||
assert.strictEqual(fit, live, 'fit and live share one render-scene snapshot');
|
||
});
|
||
|
||
test('render scene keeps coincident devices coincident, leaves labels alone and caches permutations', () => {
|
||
const owner = {
|
||
...room('owner', 0, 0, 400, 400),
|
||
name: 'Owner label', area: 'living', settings: {},
|
||
};
|
||
const space = {
|
||
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null,
|
||
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
|
||
};
|
||
const devices = ['b', 'a', 'c'].map((id) => ({
|
||
id, space: 'floor', marker: { room_id: 'owner', x: 200, y: 200 },
|
||
}));
|
||
/** @type {OverlaySceneFixture} */
|
||
const input = {
|
||
space, devices, openings: [],
|
||
display: { showNames: true, cardFontScale: 1 },
|
||
structure: structureOf(),
|
||
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
|
||
kioskIconScale: 1, kioskFontScale: 1,
|
||
positionOf: (device) => ({ x: device.marker.x, y: device.marker.y }),
|
||
presentationOf: () => ({
|
||
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
|
||
tempText: null, humText: null, lqiText: null,
|
||
pulse: { animated: false, diameterScale: 1 },
|
||
}),
|
||
labelPositionOf: () => ({ x: 200, y: 200 }),
|
||
labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => false,
|
||
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
|
||
};
|
||
const scene = overlayScene(input);
|
||
const deviceEntries = scene.entries.filter((entry) => entry.kind === 'device');
|
||
for (let index = 0; index < deviceEntries.length; index++) {
|
||
assert.deepEqual(deviceEntries[index].placement.visualScene,
|
||
projectPlanPoint([200, 200], ISO_WALL_HEIGHT), 'every device only rises to the wall top');
|
||
for (let other = 0; other < index; other++) {
|
||
const a = deviceEntries[index], b = deviceEntries[other];
|
||
assert.deepEqual(a.placement.visualScene, b.placement.visualScene,
|
||
`device roots ${a.id}/${b.id} keep their canonical coincident relationship`);
|
||
}
|
||
}
|
||
const label = scene.entries.find((entry) => entry.kind === 'room-label');
|
||
assert.ok(label, 'fixture includes a room label at the same floor anchor');
|
||
assert.deepEqual(label.placement.visualScene, [200, 200],
|
||
'coincident devices do not push the room label');
|
||
|
||
const permuted = overlayScene({ ...input, devices: [...devices].reverse() });
|
||
assert.strictEqual(permuted, scene,
|
||
'HA registry permutations reuse the same immutable group layout snapshot');
|
||
const fit = overlayScene({ ...input });
|
||
assert.strictEqual(fit.devices.get('a'), scene.devices.get('a'),
|
||
'fit probing and the live scene read the same placement');
|
||
});
|
||
|
||
test('#711 состояние устройства не двигает значки: раскладка не пересчитывается, границы видят бейдж', () => {
|
||
const owner = { ...room('owner', 0, 0, 400, 400), name: '', settings: {} };
|
||
const space = {
|
||
id: 'floor-710', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null,
|
||
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
|
||
};
|
||
const structure = structureOf(wallRect(146, 140, 154, 260));
|
||
const devices = ['lamp', 'plug', 'sensor'].map((id, index) => ({
|
||
id, space: 'floor-710', marker: { room_id: 'owner', x: 160 + index * 4, y: 200 },
|
||
}));
|
||
let lampOn = false;
|
||
const presentationOf = (device) => ({
|
||
scale: 1, valueText: null, valueFullText: '', tempText: null, humText: null, lqiText: null,
|
||
valueBadge: device.id === 'lamp' && lampOn
|
||
? { configured: true, enabled: true, text: '100 %', fullText: '100 %', position: 'right', tone: 'default' }
|
||
: null,
|
||
pulse: { animated: false, diameterScale: 1 },
|
||
});
|
||
/** @type {OverlaySceneFixture} */
|
||
const input = {
|
||
space, devices, openings: [],
|
||
display: { showNames: false, cardFontScale: 1 },
|
||
structure,
|
||
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
|
||
kioskIconScale: 1, kioskFontScale: 1,
|
||
positionOf: (device) => ({ x: device.marker.x, y: device.marker.y }),
|
||
presentationOf,
|
||
labelPositionOf: () => ({ x: 0, y: 0 }),
|
||
labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => false,
|
||
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
|
||
};
|
||
const off = overlayScene(input);
|
||
lampOn = true;
|
||
const on = overlayScene(input);
|
||
for (const id of ['lamp', 'plug', 'sensor']) {
|
||
assert.strictEqual(on.devices.get(id), off.devices.get(id), `${id}: включение лампы не пересчитывает раскладку`);
|
||
}
|
||
const lampOff = off.entries.find((entry) => entry.id === 'lamp');
|
||
const lampOnEntry = on.entries.find((entry) => entry.id === 'lamp');
|
||
assert.ok(lampOnEntry.screenHalfSize[0] > lampOff.screenHalfSize[0], 'видимая ширина с бейджем больше');
|
||
assert.deepEqual(lampOnEntry.layoutHalfSize, lampOff.layoutHalfSize, 'раскладка видит плитку без состояния');
|
||
assert.ok(isoOverlaySceneBounds(on).w >= isoOverlaySceneBounds(off).w, 'границы сцены учитывают бейдж');
|
||
lampOn = false;
|
||
const offAgain = overlayScene(input);
|
||
for (const id of ['lamp', 'plug', 'sensor']) {
|
||
assert.deepEqual(offAgain.devices.get(id).visualScene, off.devices.get(id).visualScene, `${id}: выключение возвращает то же место`);
|
||
}
|
||
});
|
||
|
||
test('orphan hosted openings never become phantom Stage 4 volumes', () => {
|
||
const base = {
|
||
type: 'door', rx: 20, ry: 30, rlen: 40, angle: 0,
|
||
flip_h: false, flip_v: false,
|
||
};
|
||
const result = isoSourceOpenings([
|
||
{ ...base, id: 'orphan', orphanReason: 'missing-partition' },
|
||
{ ...base, id: 'valid' },
|
||
], 1000);
|
||
assert.deepEqual(result.map((opening) => [opening.id, opening.sourceIndex]), [['valid', 1]]);
|
||
});
|
||
|
||
const cacheRoom = (overrides = {}) => ({
|
||
id: 'cache-room',
|
||
name: 'Structural cache room',
|
||
area: 'living_room',
|
||
poly: [[0, 0], [100, 0], [100, 100], [0, 100]],
|
||
wall_ids: ['north', 'east', 'south', 'west'],
|
||
settings: { fill_mode: 'temp', glow: true, name_scale: 1.25, label_scale: 1.1 },
|
||
...overrides,
|
||
});
|
||
|
||
const structuralInput = ({
|
||
room = cacheRoom(), walls = [], openings = [], onBuild = () => {},
|
||
coordinateScale = 1000, wallKeyPitch = 1,
|
||
} = {}) => ({
|
||
space: {
|
||
id: 'cache-space', title: 'Cache space', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
|
||
rooms: [room], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
|
||
},
|
||
walls, openCuts: [], openings,
|
||
partitionCuts: () => [], roomOpenings: () => [],
|
||
cellCm: 5, gridPitch: 5, wallKeyPitch, coordinateScale, onBuild,
|
||
});
|
||
|
||
test('presentation-only room changes reuse the structural scene but geometry changes rebuild it', () => {
|
||
assert.deepEqual(isoStructuralRoomGeometry(cacheRoom()), {
|
||
id: 'cache-room', x: undefined, y: undefined, w: undefined, h: undefined,
|
||
poly: [[0, 0], [100, 0], [100, 100], [0, 100]],
|
||
wall_ids: ['north', 'east', 'south', 'west'],
|
||
});
|
||
let builds = 0;
|
||
const cache = new Map();
|
||
const resolve = (room) => {
|
||
const source = createIsoStructuralSource(structuralInput({
|
||
room, onBuild: () => { builds += 1; },
|
||
}));
|
||
return { source, scene: resolveIsoScene({
|
||
source, cache, cellCm: 5, liveFrame: { x: 0, y: 0, w: 100, h: 100 },
|
||
}) };
|
||
};
|
||
const original = resolve(cacheRoom());
|
||
const presentation = resolve(cacheRoom({
|
||
name: 'Renamed room', area: 'private_area',
|
||
settings: { fill_mode: 'custom', custom_fill: { c: '#123456', a: 0.4 }, glow: false,
|
||
temp_source: 'sensor.private', hum_source: 'sensor.private_humidity',
|
||
name_scale: 2, label_scale: 0.75 },
|
||
}));
|
||
assert.equal(presentation.source.key, original.source.key);
|
||
assert.strictEqual(presentation.scene.geometry, original.scene.geometry);
|
||
assert.equal(builds, 1, 'presentation changes must hit the existing structural LRU entry');
|
||
|
||
const geometry = resolve(cacheRoom({
|
||
poly: [[0, 0], [120, 0], [100, 100], [0, 100]],
|
||
}));
|
||
assert.notEqual(geometry.source.key, original.source.key);
|
||
assert.notStrictEqual(geometry.scene.geometry, original.scene.geometry);
|
||
assert.equal(builds, 2, 'room geometry must invalidate and rebuild the structural scene');
|
||
});
|
||
|
||
test('structural scene cache refreshes a hot hit before evicting the least-recently-used entry', () => {
|
||
const cache = new Map();
|
||
const builds = new Map();
|
||
const resolve = (index) => {
|
||
const id = `lru-room-${index}`;
|
||
const source = createIsoStructuralSource(structuralInput({
|
||
room: cacheRoom({ id, poly: [[0, 0], [100 + index, 0], [100, 100], [0, 100]] }),
|
||
onBuild: () => builds.set(id, (builds.get(id) || 0) + 1),
|
||
}));
|
||
const scene = resolveIsoScene({
|
||
source, cache, cellCm: 5, liveFrame: { x: 0, y: 0, w: 100, h: 100 },
|
||
});
|
||
return { id, key: source.key, scene };
|
||
};
|
||
|
||
const initial = Array.from({ length: 8 }, (_, index) => resolve(index));
|
||
const hot = resolve(0);
|
||
assert.strictEqual(hot.scene.geometry, initial[0].scene.geometry);
|
||
const added = resolve(8);
|
||
|
||
assert.equal(cache.size, 8);
|
||
assert.equal(cache.has(initial[0].key), true, 'the refreshed hot entry must survive');
|
||
assert.equal(cache.has(initial[1].key), false, 'the coldest entry must be evicted');
|
||
assert.equal(cache.has(added.key), true);
|
||
assert.equal(builds.get(initial[0].id), 1, 'a hot hit must not rebuild structural geometry');
|
||
resolve(1);
|
||
assert.equal(builds.get(initial[1].id), 2, 'the evicted cold entry must rebuild on its next use');
|
||
});
|
||
|
||
test('throwing decoration capability probes keep Iso structural geometry on the solid path', () => {
|
||
const previousCss = globalThis.CSS;
|
||
const previousMatchMedia = globalThis.matchMedia;
|
||
try {
|
||
globalThis.CSS = { supports: () => { throw new Error('capability probe failure'); } };
|
||
globalThis.matchMedia = () => { throw new Error('forced-colors probe failure'); };
|
||
const layers = resolveIsoDecorationLayers({ showBorders: true, hideOpenings: false });
|
||
assert.deepEqual(layers, {
|
||
structural: true, panels: true, shadows: false, materialNuance: false,
|
||
floorSymbols: false,
|
||
});
|
||
} finally {
|
||
if (previousCss === undefined) delete globalThis.CSS;
|
||
else globalThis.CSS = previousCss;
|
||
if (previousMatchMedia === undefined) delete globalThis.matchMedia;
|
||
else globalThis.matchMedia = previousMatchMedia;
|
||
}
|
||
});
|
||
|
||
test('removed contact shadows are never read while ambient shadow capability remains enabled', () => {
|
||
const previousCss = globalThis.CSS;
|
||
const previousMatchMedia = globalThis.matchMedia;
|
||
try {
|
||
globalThis.CSS = { supports: () => true };
|
||
globalThis.matchMedia = () => ({ matches: false });
|
||
let contactReads = 0;
|
||
const geometry = {
|
||
topPath: '', topFaces: [], sides: [], edgeCount: 0,
|
||
get contactPath() {
|
||
contactReads += 1;
|
||
throw new Error('decorative shadow failure');
|
||
},
|
||
};
|
||
const frame = resolveIsoFramePresentation({
|
||
projection: 'iso',
|
||
display: { showBorders: true, hideOpenings: false },
|
||
scene: {
|
||
key: 'solid-retry', geometry,
|
||
floor: { footprintPath: '', sides: [] },
|
||
openings: [], openingSurfaces: [], frame: { x: 0, y: 0, w: 100, h: 100 },
|
||
},
|
||
openings: [], amountOf: () => 0, overlays: () => null, cellCm: 5,
|
||
});
|
||
assert.equal(contactReads, 0,
|
||
'the deprecated contact path must not be touched by presentation rendering');
|
||
assert.equal(frame.layers.structural, true);
|
||
assert.equal(frame.layers.panels, true);
|
||
assert.equal(frame.layers.shadows, true,
|
||
'the remaining building ambient shadow still follows filter capability');
|
||
assert.equal(frame.layers.materialNuance, true);
|
||
assert.equal(frame.overlays, null);
|
||
} finally {
|
||
if (previousCss === undefined) delete globalThis.CSS;
|
||
else globalThis.CSS = previousCss;
|
||
if (previousMatchMedia === undefined) delete globalThis.matchMedia;
|
||
else globalThis.matchMedia = previousMatchMedia;
|
||
}
|
||
});
|
||
|
||
const partitionOpening = (overrides = {}) => {
|
||
const resolved = {
|
||
opening: {},
|
||
host: { kind: 'partition', id: 'partition-a', t: 0.5 },
|
||
partition: { id: 'partition-a', a: [0, 0], b: [100, 0], cm: 15 },
|
||
center: [50, 0], angle: 0, length: 20, depth: 15, t: 0.5,
|
||
axis: { a: [0, 0], b: [100, 0], ux: 1, uy: 0, length: 100 },
|
||
...overrides,
|
||
};
|
||
return {
|
||
id: 'hosted-door', type: 'door', x: 0.5, y: 0, length: 0.2,
|
||
rx: 50, ry: 0, rlen: 20, angle: 0, flip_h: false, flip_v: false,
|
||
partitionHost: resolved,
|
||
};
|
||
};
|
||
|
||
test('gate flips move structural host face with the reviewed inverse convention', () => {
|
||
const rendered = partitionOpening();
|
||
const source = (type, flipV) => ({
|
||
id: `${type}-${flipV}`, sourceIndex: 0, type,
|
||
x: 0.5, y: 0, length: 0.2, angle: 0, flipH: false, flipV,
|
||
});
|
||
const doorNormal = isoStructuralOpeningHost(rendered, source('door', false));
|
||
const doorFlipped = isoStructuralOpeningHost(rendered, source('door', true));
|
||
const gateNormal = isoStructuralOpeningHost(rendered, source('gate', false));
|
||
const gateFlipped = isoStructuralOpeningHost(rendered, source('gate', true));
|
||
|
||
assert.ok(doorNormal && doorFlipped && gateNormal && gateFlipped);
|
||
assert.deepEqual(gateNormal.face, doorFlipped.face,
|
||
'an unflipped gate selects the opposite structural face from an unflipped door');
|
||
assert.deepEqual(gateFlipped.face, doorNormal.face,
|
||
'flipping a gate restores the door-normal structural face');
|
||
assert.notDeepEqual(gateNormal.face, gateFlipped.face,
|
||
'the saved gate flip must change the structural host face');
|
||
});
|
||
|
||
test('gate flips move unhosted structural face with the reviewed inverse convention', () => {
|
||
const square = cacheRoom({ wall_ids: [] });
|
||
const walls = square.poly.map((a, index) => ({
|
||
key: wallKey(a, square.poly[(index + 1) % square.poly.length], 1),
|
||
cm: 20,
|
||
}));
|
||
const gate = (flip_v) => ({
|
||
id: `unhosted-gate-${flip_v}`, type: 'gate',
|
||
rx: 50, ry: 0, rlen: 20, angle: 0, flip_h: false, flip_v,
|
||
});
|
||
const basis = (flip_v) => createIsoStructuralSource(structuralInput({
|
||
room: square, walls, openings: [gate(flip_v)], coordinateScale: 1,
|
||
})).build().openings[0];
|
||
|
||
const normal = basis(false);
|
||
const flipped = basis(true);
|
||
assert.equal(normal.face.side, -1,
|
||
'an unflipped gate selects the inverse wall face used by its saved swing convention');
|
||
assert.equal(flipped.face.side, 1,
|
||
'flipping the gate selects the opposite structural wall face');
|
||
assert.deepEqual(normal.face.selectedStart, [40, -10]);
|
||
assert.deepEqual(flipped.face.selectedStart, [40, 10]);
|
||
});
|
||
|
||
test('partition host identity, placement, depth and selected face invalidate opening volumes', () => {
|
||
const keyFor = (opening) => createIsoStructuralSource(structuralInput({
|
||
openings: [opening],
|
||
})).key;
|
||
const original = partitionOpening();
|
||
const base = keyFor(original);
|
||
const mutations = [
|
||
partitionOpening({ host: { kind: 'partition', id: 'partition-b', t: 0.5 } }),
|
||
partitionOpening({ t: 0.75 }),
|
||
partitionOpening({ depth: 25 }),
|
||
partitionOpening({
|
||
axis: { ...original.partitionHost.axis, ux: 0, uy: 1 },
|
||
}),
|
||
partitionOpening({
|
||
partition: { ...original.partitionHost.partition, cm: 25 },
|
||
}),
|
||
];
|
||
for (const mutation of mutations) assert.notEqual(keyFor(mutation), base);
|
||
});
|
||
|
||
test('opening geometry policy is both fingerprinted and consumed by the structural build', () => {
|
||
const input = structuralInput({ openings: [partitionOpening()] });
|
||
const original = createIsoStructuralSource(input);
|
||
const policy = {
|
||
...ISO_OPENING_GEOMETRY_POLICY,
|
||
revision: ISO_OPENING_GEOMETRY_POLICY.revision + 1,
|
||
leafThicknessRatio: ISO_OPENING_GEOMETRY_POLICY.leafThicknessRatio * 2,
|
||
};
|
||
const changed = createIsoStructuralSource(input, policy);
|
||
assert.notEqual(changed.key, original.key);
|
||
const basis = changed.build().openings[0];
|
||
assert.equal(basis.leafThickness, basis.wallHeight * policy.leafThicknessRatio);
|
||
});
|
||
|
||
// #473. Перф-дельта #160 (f1b9bbf3..b3ca0ca2) ввела кэш размещений, кэш по
|
||
// идентичности силуэтов, повторное использование при зуме внутрь и
|
||
// AABB-отсечение — и ушла в бету по §11.4 без ревью и без единого свидетеля.
|
||
// Двенадцать мутантов stage3-w* защищают картинку, ни один — эти механизмы.
|
||
// Ошибка любого из них даёт УСТАРЕВШЕЕ размещение без единого падения.
|
||
|
||
const perfFixture = () => {
|
||
const owner = room('owner', 0, 0, 100, 100);
|
||
const space = {
|
||
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
|
||
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
|
||
};
|
||
const structure = structureOf(wallRect(-4, -10, 4, 110));
|
||
/** @type {OverlaySceneFixture} */
|
||
const input = {
|
||
space,
|
||
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }],
|
||
openings: [],
|
||
display: { showNames: false, cardFontScale: 1 },
|
||
structure,
|
||
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
|
||
kioskIconScale: 1, kioskFontScale: 1,
|
||
positionOf: () => ({ x: 5, y: 50 }),
|
||
presentationOf: () => ({
|
||
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
|
||
tempText: null, humText: null, lqiText: null,
|
||
pulse: { animated: false, diameterScale: 1 },
|
||
}),
|
||
labelPositionOf: () => ({ x: 0, y: 0 }),
|
||
labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => false,
|
||
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
|
||
};
|
||
return { input, structure };
|
||
};
|
||
|
||
// #570 superseded #473 W1: selection is presentation-only since Stage 4
|
||
// removed the selected/hover tether and ground cue. It is no input of the
|
||
// overlay scene at all — the fixture typecheck rejects `selectedDeviceId`
|
||
// (test/iso-overlay-fixture-types.test.mjs, #732); a test passing it proved
|
||
// nothing the signature does not.
|
||
|
||
test('#473 W2 after #724: walls never move a tile, the cache follows the structural wall geometry', () => {
|
||
const { input } = perfFixture();
|
||
const withWall = overlayScene(input).devices.get('device');
|
||
const noWalls = overlayScene({ ...input, structure: structureOf() }).devices.get('device');
|
||
assert.notStrictEqual(noWalls, withWall, 'a new wall geometry is a new cache slot');
|
||
assert.deepEqual(noWalls.visualScene, withWall.visualScene,
|
||
'the tile position does not depend on nearby walls');
|
||
});
|
||
|
||
// #724 AC2: the overlay caches follow the wall geometry of the structural scene
|
||
// on the production path — createIsoStructuralSource → resolveIsoScene (the
|
||
// LRU) → buildIsoOverlayRenderScene({ structure: scene.geometry }).
|
||
const unhostedWalls = (poly, cm) => poly.map((a, index) => ({
|
||
key: wallKey(a, poly[(index + 1) % poly.length], 1), cm,
|
||
}));
|
||
/**
|
||
* @param {OverlaySceneFixture} [overrides]
|
||
* @returns {OverlaySceneFixture}
|
||
*/
|
||
const overlayInput = (space, structure, overrides = {}) => ({
|
||
space, structure, openings: [],
|
||
devices: [{ id: 'device', space: space.id }],
|
||
display: { showNames: false, cardFontScale: 1 },
|
||
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
|
||
kioskIconScale: 1, kioskFontScale: 1,
|
||
positionOf: () => ({ x: 50, y: 50 }),
|
||
presentationOf: () => ({
|
||
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
|
||
tempText: null, humText: null, lqiText: null,
|
||
pulse: { animated: false, diameterScale: 1 },
|
||
}),
|
||
labelPositionOf: () => ({ x: 0, y: 0 }),
|
||
labelScaleOf: () => 1,
|
||
openingEntityAvailable: () => false,
|
||
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
|
||
...overrides,
|
||
});
|
||
|
||
test('#724 AC2: the overlay scene survives zoom, resize and HA state, and is rebuilt when a wall changes', () => {
|
||
const square = cacheRoom({ wall_ids: [] });
|
||
const cache = new Map();
|
||
const structural = (cm, liveFrame = { x: 0, y: 0, w: 100, h: 100 }) => {
|
||
const input = structuralInput({ room: square, walls: unhostedWalls(square.poly, cm), coordinateScale: 1 });
|
||
return { space: input.space, scene: resolveIsoScene({
|
||
source: createIsoStructuralSource(input), cache, cellCm: 5, liveFrame,
|
||
}) };
|
||
};
|
||
const thin = structural(20);
|
||
assert.ok(thin.scene.geometry.sides.length > 0, 'fixture has real wall bodies');
|
||
const live = overlayScene(overlayInput(thin.space, thin.scene.geometry));
|
||
|
||
// Same walls: the structural LRU hands out the same geometry, and neither
|
||
// zoom, a stage resize nor an HA state change is a layout event. Zoom and
|
||
// resize reach 2.5D only as the live frame of the structural scene (#732:
|
||
// the overlay scene has no view or stage size input).
|
||
const zoomedIn = structural(20, { x: 20, y: 20, w: 40, h: 40 });
|
||
assert.strictEqual(zoomedIn.scene.geometry, thin.scene.geometry);
|
||
assert.notDeepEqual(zoomedIn.scene.frame, thin.scene.frame, 'the fixture really changes the live frame');
|
||
const zoomed = overlayScene(overlayInput(zoomedIn.space, zoomedIn.scene.geometry));
|
||
assert.strictEqual(zoomed, live, 'zoom and resize reuse the render scene');
|
||
const again = structural(20);
|
||
assert.strictEqual(again.scene.geometry, thin.scene.geometry);
|
||
const lit = overlayScene(overlayInput(again.space, again.scene.geometry, {
|
||
presentationOf: () => ({
|
||
scale: 1, valueText: null, valueFullText: '', tempText: null, humText: null, lqiText: null,
|
||
valueBadge: { configured: true, enabled: true, text: '100 %', fullText: '100 %', position: 'right', tone: 'default' },
|
||
pulse: { animated: false, diameterScale: 1 },
|
||
}),
|
||
}));
|
||
assert.strictEqual(lit.devices.get('device'), live.devices.get('device'), 'HA state keeps the placement');
|
||
|
||
// A thicker wall with the same room: a new structure, so a new scene. A cache
|
||
// keyed without the walls would serve the placement of the old plan.
|
||
const thick = structural(30);
|
||
assert.notEqual(thick.scene.key, thin.scene.key);
|
||
assert.notStrictEqual(thick.scene.geometry, thin.scene.geometry);
|
||
const rebuilt = overlayScene(overlayInput(thick.space, thick.scene.geometry));
|
||
assert.notStrictEqual(rebuilt, live, 'a wall edit rebuilds the overlay scene');
|
||
assert.notStrictEqual(rebuilt.devices.get('device'), live.devices.get('device'),
|
||
'and its placements');
|
||
assert.deepEqual(rebuilt.devices.get('device').visualScene, live.devices.get('device').visualScene,
|
||
'the tile itself stays where it was: walls never move it');
|
||
});
|
||
|
||
test('#724 AC2: a room edit that changes the owner is a new structure — no owner of a plan that is gone', () => {
|
||
const cache = new Map();
|
||
const structural = (split) => {
|
||
const base = structuralInput();
|
||
const rooms = [
|
||
cacheRoom({ id: 'west', poly: [[0, 0], [split, 0], [split, 100], [0, 100]], wall_ids: [] }),
|
||
cacheRoom({ id: 'east', poly: [[split, 0], [200, 0], [200, 100], [split, 100]], wall_ids: [] }),
|
||
];
|
||
const space = { ...base.space, vb: [0, 0, 200, 100], rooms };
|
||
return { space, scene: resolveIsoScene({
|
||
source: createIsoStructuralSource({ ...base, space }), cache, cellCm: 5,
|
||
liveFrame: { x: 0, y: 0, w: 200, h: 100 },
|
||
}) };
|
||
};
|
||
const at = { positionOf: () => ({ x: 90, y: 50 }) };
|
||
const before = structural(100);
|
||
const west = overlayScene(overlayInput(before.space, before.scene.geometry, at));
|
||
assert.equal(west.devices.get('device').owner?.id, 'west');
|
||
const after = structural(80);
|
||
assert.notEqual(after.scene.key, before.scene.key, 'room geometry is structural');
|
||
const east = overlayScene(overlayInput(after.space, after.scene.geometry, at));
|
||
assert.equal(east.devices.get('device').owner?.id, 'east',
|
||
'the tile at the same point now belongs to the room that grew over it');
|
||
});
|
||
|
||
test('#713 AC3: every tile gets one straight-up shift from its Flat anchor', () => {
|
||
// Live zoom is no overlay input (#732): the fixture typecheck rejects a view,
|
||
// and the #724 AC2 test zooms the structural scene on the production path.
|
||
const { input } = perfFixture();
|
||
const lift = projectPlanPoint([0, 0], 0)[1] - projectPlanPoint([0, 0], ISO_WALL_HEIGHT)[1];
|
||
for (const position of [{ x: 60, y: 20 }, { x: 5, y: 50 }]) {
|
||
const live = overlayScene({ ...input, positionOf: () => position }).devices.get('device');
|
||
assert.deepEqual(live.floorScene, [position.x, position.y], 'the floor anchor is the Flat point');
|
||
assert.equal(live.visualScene[0], position.x, 'no horizontal displacement');
|
||
assert.ok(Math.abs(live.floorScene[1] - live.visualScene[1] - lift) < 1e-9,
|
||
'the vertical displacement is the wall-top rise for every tile');
|
||
}
|
||
});
|
||
|
||
test('#713 K5: room names stay on the Flat floor point while devices share the wall-top rise', () => {
|
||
const { input } = perfFixture();
|
||
const owner = { ...input.space.rooms[0], name: 'Owner' };
|
||
const scene = overlayScene({
|
||
...input,
|
||
space: { ...input.space, rooms: [owner] },
|
||
display: { showNames: true, cardFontScale: 1 },
|
||
labelPositionOf: () => ({ x: 30, y: 40 }),
|
||
});
|
||
const label = scene.rooms.get(owner);
|
||
assert.ok(label, 'the room label is placed');
|
||
assert.deepEqual(label.visualScene, [30, 40], 'the name keeps its Flat position');
|
||
const device = scene.devices.get('device');
|
||
assert.deepEqual(device.visualScene, projectPlanPoint([5, 50], ISO_WALL_HEIGHT));
|
||
});
|