diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index b7390e7b..6af6f3a5 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -11136,10 +11136,7 @@ export class HouseplanCard extends LitElement {
${isoFrame?.walls ?? nothing}
` : nothing)}
+ preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">` : nothing)}
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
had, restored by the owner. `iconCqw` is `iconPct * iconUnit
diff --git a/src/iso-scene-render.ts b/src/iso-scene-render.ts
index f39a368a..cf61222b 100644
--- a/src/iso-scene-render.ts
+++ b/src/iso-scene-render.ts
@@ -131,7 +131,6 @@ export type IsoOverlayRenderEntry = {
id: string;
kind: IsoRaisedOverlayKind;
placement: IsoOverlayPlacement;
- groundRadius: number;
/** Screen-facing HTML footprint around visualScene, in scene units. */
screenHalfSize: PlanPoint;
/**
@@ -727,7 +726,7 @@ function sameOverlayEntries(
return previous.length === next.length && next.every((entry, index) => {
const before = previous[index];
return before.id === entry.id && before.kind === entry.kind
- && before.placement === entry.placement && before.groundRadius === entry.groundRadius
+ && before.placement === entry.placement
&& before.screenHalfSize[0] === entry.screenHalfSize[0]
&& before.screenHalfSize[1] === entry.screenHalfSize[1];
});
@@ -843,7 +842,6 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
id: device.id,
kind: 'device',
placement,
- groundRadius: Math.max(core * 0.32, 2),
screenHalfSize: halfSize,
layoutHalfSize,
});
@@ -866,7 +864,6 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
id: overlayRoom.id,
kind: 'room-label',
placement,
- groundRadius: Math.max(font * 0.75, 2),
screenHalfSize: halfSize,
});
}
@@ -891,7 +888,6 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
id: String(opening.id),
kind: 'opening-lock',
placement,
- groundRadius: Math.max(size * 0.28, 2),
screenHalfSize: halfSize,
});
}
@@ -1038,8 +1034,6 @@ export interface IsoFramePresentation {
underlay: TemplateResult;
shadows: TemplateResult;
walls: TemplateResult;
- grounds: TemplateResult;
- raised: TemplateResult;
}
/** Resolve every lazy Stage 4 artifact inside the card's single failure boundary. */
@@ -1062,8 +1056,6 @@ export function resolveIsoFramePresentation(input: {
underlay: renderIsoUnderlay(renderLayers, input.scene?.floor, input.cellCm),
shadows: renderIsoShadows(renderLayers, panels, input.scene?.geometry, input.cellCm),
walls: renderIsoWalls(input.projection, renderLayers, input.scene, panels, input.cellCm),
- grounds: renderIsoOverlayGrounds(overlays, renderLayers, input.cellCm),
- raised: renderIsoRaisedOverlays(overlays),
});
try { return render(layers); } catch (error) {
// A material/shadow presentation failure loses only decorative nuance.
@@ -1115,20 +1107,6 @@ function renderIsoDefs(
` as unknown as TemplateResult;
}
-export function renderIsoOverlayGrounds(
- _overlays: IsoOverlayRenderScene | null,
- _layers: IsoDecorationLayers,
- _cellCm: number,
-): TemplateResult {
- return emptySvg();
-}
-
-export function renderIsoRaisedOverlays(
- _overlays: IsoOverlayRenderScene | null,
-): TemplateResult {
- return emptySvg();
-}
-
export function renderIsoUnderlay(
layers: IsoDecorationLayers,
floor: IsoFloorGeometry | undefined,
diff --git a/test/iso-overlay-fixture-types.test.mjs b/test/iso-overlay-fixture-types.test.mjs
new file mode 100644
index 00000000..24182a66
--- /dev/null
+++ b/test/iso-overlay-fixture-types.test.mjs
@@ -0,0 +1,138 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { basename, dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import ts from 'typescript';
+
+// #732 AC2. Фикстуры тестов оверлеев 2.5D передавали поля, которых никто не
+// читает: `view`, `referenceView`, `stageSize`, `layers`, `selectedDeviceId`
+// у входа сцены и `groundRadius` у записи. Тесты оставались зелёными — и
+// «проверяли», что зум, размер сцены или выделение не пересчитывают раскладку,
+// хотя сигнатура их вообще не принимает.
+//
+// Здесь файлы с такими фикстурами проходят через компилятор TypeScript. Тип
+// фикстуры (`OverlaySceneFixture`, `OverlayEntryFixture`) — ключи боевого типа
+// входа с намеренно свободными значениями: фикстура частична, как и нужно
+// тесту, но поле, которого у типа нет, — ошибка «лишнее свойство». Прочие
+// диагностики этих файлов не судятся: сами тесты исполняют `test-build`, и
+// частичность значений — их право.
+
+const ROOT = fileURLToPath(new URL('../', import.meta.url));
+const TEST_DIR = join(ROOT, 'test');
+const FIXTURE_FILES = ['iso-scene-render.test.mjs', 'iso-stage6.test.mjs'].map((name) => join(TEST_DIR, name));
+const PROBE = join(TEST_DIR, '__iso-overlay-fixture-probe.mjs');
+/** «Object literal may only specify known properties…» — с подсказкой и без. */
+const EXCESS_PROPERTY = new Set([2353, 2561]);
+/** Входы сцены и поле записи, которых у боевых типов нет (#714, #724, #732). */
+const DEAD_SCENE_FIELDS = ['view', 'referenceView', 'stageSize', 'layers', 'selectedDeviceId'];
+const DEAD_ENTRY_FIELDS = ['groundRadius'];
+const SCENE_BUILDERS = new Set(['buildIsoOverlayRenderScene', 'overlayScene']);
+const tagOf = (file) => basename(file).replace(/\W/g, '_');
+
+/** Зонд: каждое мёртвое поле — отдельный литерал (TypeScript называет одно лишнее поле на литерал). */
+function probeSource() {
+ const lines = [];
+ const typed = (type, name, body) => lines.push(`/** @type {${type}} */`, `export const ${name} = { ${body} };`);
+ for (const file of FIXTURE_FILES) {
+ const scene = `import('./${basename(file)}').OverlaySceneFixture`;
+ const tag = tagOf(file);
+ typed(scene, `${tag}_known`, 'space: null, structure: null, positionOf: null');
+ DEAD_SCENE_FIELDS.forEach((field, index) => typed(scene, `${tag}_scene${index}`, `${field}: null`));
+ }
+ const entry = "import('./iso-scene-render.test.mjs').OverlayEntryFixture";
+ typed(entry, 'entry_known', 'id: null, placement: null, screenHalfSize: null');
+ DEAD_ENTRY_FIELDS.forEach((field, index) => typed(entry, `entry${index}`, `${field}: null`));
+ return `${lines.join('\n')}\n`;
+}
+
+function fixtureProgram() {
+ const config = ts.readConfigFile(join(ROOT, 'tsconfig.json'), ts.sys.readFile).config;
+ const { options: base } = ts.parseJsonConfigFileContent(config, ts.sys, ROOT);
+ const options = {
+ ...base, allowJs: true, checkJs: true, noEmit: true, incremental: false, types: [],
+ };
+ const host = ts.createCompilerHost(options, true);
+ const probe = probeSource();
+ const getSourceFile = host.getSourceFile.bind(host);
+ host.getSourceFile = (name, languageVersion, ...rest) => (name === PROBE
+ ? ts.createSourceFile(name, probe, languageVersion, true)
+ : getSourceFile(name, languageVersion, ...rest));
+ const fileExists = host.fileExists.bind(host);
+ host.fileExists = (name) => name === PROBE || fileExists(name);
+ // Тесты исполняют `../test-build/*.js` и `node:*`; для типов фикстур нужны
+ // только JSDoc-импорты `../src/*` и файлы фикстур для зонда. Остальное не
+ // разрешается — компилятор не тянет test-build и @types/node.
+ host.resolveModuleNameLiterals = (literals, containing, _redirect, opts) => literals.map((literal) => {
+ const spec = literal.text;
+ if (dirname(containing) === TEST_DIR && !spec.startsWith('../src/') && !spec.startsWith('./')) {
+ return { resolvedModule: undefined };
+ }
+ return ts.resolveModuleName(spec, containing, opts, host);
+ });
+ return ts.createProgram([...FIXTURE_FILES, PROBE], options, host);
+}
+
+const program = fixtureProgram();
+const checker = program.getTypeChecker();
+const where = (diagnostic) => {
+ const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
+ return `${diagnostic.file.fileName.slice(ROOT.length)}:${line + 1}`;
+};
+const message = (diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
+
+test('#732 AC2: фикстуры оверлеев не передают полей, которых нет у боевых типов', () => {
+ const excess = FIXTURE_FILES.flatMap((file) => program.getSemanticDiagnostics(program.getSourceFile(file)))
+ .filter((diagnostic) => EXCESS_PROPERTY.has(diagnostic.code))
+ .map((diagnostic) => `${where(diagnostic)} ${message(diagnostic)}`);
+ assert.deepEqual(excess, [], 'поле, которого никто не читает, фикстура не передаёт');
+});
+
+test('#732 AC2: тип фикстуры — ключи боевого входа; зум, размер сцены, слои, выделение и радиус земли — не входы', () => {
+ const probe = program.getSourceFile(PROBE);
+ const rejected = new Map();
+ for (const diagnostic of program.getSemanticDiagnostics(probe)) {
+ const name = probe.text.slice(0, diagnostic.start).match(/export const (\w+) = [^\n]*$/)?.[1];
+ assert.ok(EXCESS_PROPERTY.has(diagnostic.code), `${where(diagnostic)} ${message(diagnostic)}`);
+ rejected.set(name, message(diagnostic));
+ }
+ for (const file of FIXTURE_FILES) {
+ const tag = tagOf(file);
+ assert.ok(!rejected.has(`${tag}_known`), `${tag}: поля боевого входа принимаются`);
+ DEAD_SCENE_FIELDS.forEach((field, index) => {
+ assert.match(rejected.get(`${tag}_scene${index}`) || '', new RegExp(`'${field}' does not exist in type`),
+ `${file.slice(ROOT.length)}: OverlaySceneFixture отвергает ${field} — тип разрешился в боевой вход, а не в any`);
+ });
+ }
+ assert.ok(!rejected.has('entry_known'), 'поля записи принимаются');
+ DEAD_ENTRY_FIELDS.forEach((field, index) => {
+ assert.match(rejected.get(`entry${index}`) || '', new RegExp(`'${field}' does not exist in type`));
+ });
+});
+
+test('#732 AC2: каждая фикстура сцены доходит до построителя через проверяемый тип', () => {
+ const aliasOf = (type) => type?.aliasSymbol?.name ?? null;
+ const unchecked = [];
+ let calls = 0;
+ for (const file of FIXTURE_FILES) {
+ const source = program.getSourceFile(file);
+ const visit = (node) => {
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && SCENE_BUILDERS.has(node.expression.text)) {
+ calls += 1;
+ const [arg] = node.arguments;
+ // Литерал проверяется типом параметра (`overlayScene`), прочее — своим
+ // объявленным типом; литерал прямо в построитель из test-build не
+ // проверяется ничем.
+ const type = arg && ts.isObjectLiteralExpression(arg)
+ ? checker.getContextualType(arg) : arg && checker.getTypeAtLocation(arg);
+ if (aliasOf(type) !== 'OverlaySceneFixture') {
+ const { line } = source.getLineAndCharacterOfPosition(node.getStart());
+ unchecked.push(`${file.slice(ROOT.length)}:${line + 1} ${node.getText().slice(0, 80)}`);
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(source);
+ }
+ assert.ok(calls >= 20, `нашлись вызовы построителя сцены (${calls})`);
+ assert.deepEqual(unchecked, [], 'фикстура сцены — литерал в overlayScene или объявление типа OverlaySceneFixture');
+});
diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs
index 30029480..17cc3b1a 100644
--- a/test/iso-scene-render.test.mjs
+++ b/test/iso-scene-render.test.mjs
@@ -43,6 +43,21 @@ import {
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]],
@@ -132,19 +147,19 @@ test('overlay bounds use final screen footprint and canonical owner filtering',
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]),
- groundRadius: 1, screenHalfSize: [5, 3] },
- { id: 'two', kind: 'device', placement: placement('r2', [200, 300]),
- groundRadius: 1, screenHalfSize: [10, 8] },
+ { 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', groundRadius: 1, screenHalfSize: [10, 8],
+ id: 'edge', kind: 'device', screenHalfSize: [10, 8],
placement: {
owner: { id: 'room' }, floorScene: [95, 50],
visualScene: [95, 50],
@@ -162,18 +177,18 @@ test('#713 K8: overlay fit is the structure plus visible tiles, no #651 nudge re
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], stageSize, targetView,
+ 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], stageSize, targetView,
+ 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], stageSize,
+ 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 });
@@ -324,13 +339,11 @@ test('opening-lock scene keeps physical host ownership when spatial fallback poi
});
assert.equal(spatialOwner?.id, 'decoy', 'fixture proves point containment would pick the wrong room');
- const scene = buildIsoOverlayRenderScene({
+ const scene = overlayScene({
space,
devices: [],
openings: [opening],
- view: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
- layers: { shadows: true },
structure: structureOf(),
iconPct: 100,
deviceBasePct: 100,
@@ -338,7 +351,6 @@ test('opening-lock scene keeps physical host ownership when spatial fallback poi
cellCm: 5,
kioskIconScale: 1,
kioskFontScale: 1,
- stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 0, y: 0 }),
presentationOf: () => ({ scale: 1 }),
labelPositionOf: () => ({ x: 0, y: 0 }),
@@ -365,13 +377,11 @@ test('opening lock without a canonical host owner never guesses from point conta
partition: { cm: 10 },
},
};
- const scene = buildIsoOverlayRenderScene({
+ const scene = overlayScene({
space,
devices: [],
openings: [opening],
- view: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
- layers: { shadows: true },
structure: structureOf(wallRect(0, 45, 100, 55)),
iconPct: 100,
deviceBasePct: 100,
@@ -379,7 +389,6 @@ test('opening lock without a canonical host owner never guesses from point conta
cellCm: 5,
kioskIconScale: 1,
kioskFontScale: 1,
- stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 0, y: 0 }),
presentationOf: () => ({ scale: 1 }),
labelPositionOf: () => ({ x: 0, y: 0 }),
@@ -402,14 +411,12 @@ test('Stage 4 reuses pure overlay placements, and fit and the live frame share o
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: [],
- view: { x: 0, y: 0, w: 100, h: 100 },
- referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
- layers: { structural: true, shadows: true },
structure,
iconPct: 3.4,
deviceBasePct: 3.4,
@@ -417,7 +424,6 @@ test('Stage 4 reuses pure overlay placements, and fit and the live frame share o
cellCm: 5,
kioskIconScale: 1,
kioskFontScale: 1,
- stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 5, y: 50 }),
presentationOf: () => ({
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
@@ -429,8 +435,8 @@ test('Stage 4 reuses pure overlay placements, and fit and the live frame share o
openingEntityAvailable: () => false,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
};
- const live = buildIsoOverlayRenderScene(input);
- const repeated = buildIsoOverlayRenderScene(input);
+ 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'),
@@ -439,23 +445,13 @@ test('Stage 4 reuses pure overlay placements, and fit and the live frame share o
// 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));
- const zoomedIn = buildIsoOverlayRenderScene({
- ...input, view: { x: 0, y: 0, w: 80, h: 80 },
- });
- assert.strictEqual(zoomedIn, live, 'zooming in reuses the immutable scene placement');
- const resized = buildIsoOverlayRenderScene({ ...input, stageSize: { width: 40, height: 40 } });
- assert.strictEqual(resized, live, '#714: a stage resize is not a layout event either');
-
// #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).
- const fit = buildIsoOverlayRenderScene({ ...input });
+ // 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');
-
- const zoomed = buildIsoOverlayRenderScene({ ...input, view: { x: -10, y: -10, w: 120, h: 120 } });
- assert.strictEqual(zoomed, live,
- 'zooming out and panning reuse the same immutable scene placement');
- assert.strictEqual(zoomed.devices.get('device'), live.devices.get('device'),
- 'live view is absent from the placement signature');
});
test('render scene keeps coincident devices coincident, leaves labels alone and caches permutations', () => {
@@ -470,15 +466,13 @@ test('render scene keeps coincident devices coincident, leaves labels alone and
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: [],
- view: { x: 0, y: 0, w: 400, h: 400 },
display: { showNames: true, cardFontScale: 1 },
- layers: { structural: true, shadows: true },
structure: structureOf(),
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1,
- stageSize: { width: 200, height: 200 },
positionOf: (device) => ({ x: device.marker.x, y: device.marker.y }),
presentationOf: () => ({
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
@@ -490,7 +484,7 @@ test('render scene keeps coincident devices coincident, leaves labels alone and
openingEntityAvailable: () => false,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
};
- const scene = buildIsoOverlayRenderScene(input);
+ 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,
@@ -506,10 +500,10 @@ test('render scene keeps coincident devices coincident, leaves labels alone and
assert.deepEqual(label.placement.visualScene, [200, 200],
'coincident devices do not push the room label');
- const permuted = buildIsoOverlayRenderScene({ ...input, devices: [...devices].reverse() });
+ const permuted = overlayScene({ ...input, devices: [...devices].reverse() });
assert.strictEqual(permuted, scene,
'HA registry permutations reuse the same immutable group layout snapshot');
- const fit = buildIsoOverlayRenderScene({ ...input });
+ const fit = overlayScene({ ...input });
assert.strictEqual(fit.devices.get('a'), scene.devices.get('a'),
'fit probing and the live scene read the same placement');
});
@@ -532,15 +526,13 @@ test('#711 состояние устройства не двигает знач
: null,
pulse: { animated: false, diameterScale: 1 },
});
+ /** @type {OverlaySceneFixture} */
const input = {
space, devices, openings: [],
- view: { x: 0, y: 0, w: 400, h: 400 },
display: { showNames: false, cardFontScale: 1 },
- layers: { structural: true, shadows: true },
structure,
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1,
- stageSize: { width: 200, height: 200 },
positionOf: (device) => ({ x: device.marker.x, y: device.marker.y }),
presentationOf,
labelPositionOf: () => ({ x: 0, y: 0 }),
@@ -548,9 +540,9 @@ test('#711 состояние устройства не двигает знач
openingEntityAvailable: () => false,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
};
- const off = buildIsoOverlayRenderScene(input);
+ const off = overlayScene(input);
lampOn = true;
- const on = buildIsoOverlayRenderScene(input);
+ const on = overlayScene(input);
for (const id of ['lamp', 'plug', 'sensor']) {
assert.strictEqual(on.devices.get(id), off.devices.get(id), `${id}: включение лампы не пересчитывает раскладку`);
}
@@ -560,7 +552,7 @@ test('#711 состояние устройства не двигает знач
assert.deepEqual(lampOnEntry.layoutHalfSize, lampOff.layoutHalfSize, 'раскладка видит плитку без состояния');
assert.ok(isoOverlaySceneBounds(on).w >= isoOverlaySceneBounds(off).w, 'границы сцены учитывают бейдж');
lampOn = false;
- const offAgain = buildIsoOverlayRenderScene(input);
+ const offAgain = overlayScene(input);
for (const id of ['lamp', 'plug', 'sensor']) {
assert.deepEqual(offAgain.devices.get(id).visualScene, off.devices.get(id).visualScene, `${id}: выключение возвращает то же место`);
}
@@ -831,18 +823,15 @@ const perfFixture = () => {
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: [],
- view: { x: 0, y: 0, w: 100, h: 100 },
- referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
- layers: { structural: true, shadows: true },
structure,
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1,
- stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 5, y: 50 }),
presentationOf: () => ({
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
@@ -857,22 +846,16 @@ const perfFixture = () => {
return { input, structure };
};
-test('#570 supersedes #473 W1: selection reuses the cue-free low placement', () => {
- const { input } = perfFixture();
- const plain = buildIsoOverlayRenderScene(input).devices.get('device');
- const selected = buildIsoOverlayRenderScene({ ...input, selectedDeviceId: 'device' })
- .devices.get('device');
- // Stage 4 removed the selected/hover tether and ground cue. Selection no
- // longer changes collision geometry and therefore must not churn the cache.
- assert.strictEqual(selected, plain, 'selection is presentation-only after cue removal');
- const again = buildIsoOverlayRenderScene(input).devices.get('device');
- assert.strictEqual(again, selected, 'clearing selection keeps the same immutable placement');
-});
+// #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 = buildIsoOverlayRenderScene(input).devices.get('device');
- const noWalls = buildIsoOverlayRenderScene({ ...input, structure: structureOf() }).devices.get('device');
+ 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');
@@ -884,14 +867,16 @@ test('#473 W2 after #724: walls never move a tile, the cache follows the structu
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 }],
- view: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1,
- stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 50, y: 50 }),
presentationOf: () => ({
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
@@ -908,25 +893,28 @@ const overlayInput = (space, structure, 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) => {
+ 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: { x: 0, y: 0, w: 100, h: 100 },
+ 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 = buildIsoOverlayRenderScene(overlayInput(thin.space, thin.scene.geometry));
+ 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, 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 zoomed = buildIsoOverlayRenderScene(overlayInput(again.space, again.scene.geometry, {
- view: { x: 20, y: 20, w: 40, h: 40 }, stageSize: { width: 320, height: 180 },
- }));
- assert.strictEqual(zoomed, live, 'zoom and resize reuse the render scene');
- const lit = buildIsoOverlayRenderScene(overlayInput(again.space, again.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' },
@@ -940,7 +928,7 @@ test('#724 AC2: the overlay scene survives zoom, resize and HA state, and is reb
const thick = structural(30);
assert.notEqual(thick.scene.key, thin.scene.key);
assert.notStrictEqual(thick.scene.geometry, thin.scene.geometry);
- const rebuilt = buildIsoOverlayRenderScene(overlayInput(thick.space, thick.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');
@@ -964,36 +952,33 @@ test('#724 AC2: a room edit that changes the owner is a new structure — no own
};
const at = { positionOf: () => ({ x: 90, y: 50 }) };
const before = structural(100);
- const west = buildIsoOverlayRenderScene(overlayInput(before.space, before.scene.geometry, at));
+ 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 = buildIsoOverlayRenderScene(overlayInput(after.space, after.scene.geometry, at));
+ 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: live zoom never recomputes placement and every tile gets one straight-up shift', () => {
+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 at = { ...input, positionOf: () => position };
- const live = buildIsoOverlayRenderScene(at).devices.get('device');
+ 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');
- for (const view of [{ x: 0, y: 0, w: 80, h: 80 }, { x: -20, y: -10, w: 140, h: 140 }]) {
- const zoomed = buildIsoOverlayRenderScene({ ...at, view }).devices.get('device');
- assert.strictEqual(zoomed, live, 'zoom and pan are not layout events');
- }
}
});
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 = buildIsoOverlayRenderScene({
+ const scene = overlayScene({
...input,
space: { ...input.space, rooms: [owner] },
display: { showNames: true, cardFontScale: 1 },
diff --git a/test/iso-stage6.test.mjs b/test/iso-stage6.test.mjs
index b4810eec..267cf122 100644
--- a/test/iso-stage6.test.mjs
+++ b/test/iso-stage6.test.mjs
@@ -186,6 +186,12 @@ test('#649 п.2 beams: same exterior windows as Flat, parallelogram along the su
assert.equal(Math.round(faced.a[1]), 390);
});
+/**
+ * #732: an overlay fixture names only fields of the production input type;
+ * test/iso-overlay-fixture-types.test.mjs typechecks this file.
+ * @typedef {{ [K in keyof import('../src/iso-scene-render.js').IsoOverlaySceneInput]?: unknown }} OverlaySceneFixture
+ */
+
test('#649 AC3 layout and collisions see the 2.5D tile size (× ISO_ICON_SCALE)', async () => {
const { buildIsoOverlayRenderScene, isoRaisedOverlayHalfSize } = await import('../test-build/iso-scene-render.js');
const { iconUnit } = await import('../test-build/space-geometry.js');
@@ -195,15 +201,17 @@ test('#649 AC3 layout and collisions see the 2.5D tile size (× ISO_ICON_SCALE)'
rooms: [{ id: 'r', poly: [[0, 0], [1000, 0], [1000, 1000], [0, 1000]] }],
wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
- const scene = buildIsoOverlayRenderScene({
- space, devices: [{ id: 'd1' }], openings: [], view: { x: 0, y: 0, w: 1000, h: 1000 },
- display: { showNames: false, cardFontScale: 1 }, layers: { shadows: true },
+ /** @type {OverlaySceneFixture} */
+ const input = {
+ space, devices: [{ id: 'd1' }], openings: [],
+ display: { showNames: false, cardFontScale: 1 },
structure: { topPath: '', topFaces: [], sides: [], contactPath: '', edgeCount: 0 },
iconPct: 3, deviceBasePct: 3, showLqi: false, cellCm: 5, kioskIconScale: 1, kioskFontScale: 1,
- stageSize: { width: 1000, height: 1000 }, positionOf: () => ({ x: 500, y: 500 }),
+ positionOf: () => ({ x: 500, y: 500 }),
presentationOf: () => presentation, labelPositionOf: () => ({ x: 0, y: 0 }), labelScaleOf: () => 1,
openingEntityAvailable: () => true, openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
- });
+ };
+ const scene = buildIsoOverlayRenderScene(input);
const footprint = scene.devices.get('d1').footprint;
const half = (Math.max(...footprint.map((p) => p[0])) - Math.min(...footprint.map((p) => p[0]))) / 2;
const base = 3 * iconUnit(space) / 100;
diff --git a/test/isometric-contract.test.mjs b/test/isometric-contract.test.mjs
index 49817e91..76a2cb50 100644
--- a/test/isometric-contract.test.mjs
+++ b/test/isometric-contract.test.mjs
@@ -124,16 +124,15 @@ test('Stage 4 composes ordered inert SVG surfaces below screen-facing HTML', ()
assert.match(styles, /\.iso-walls-svg\s*\{[\s\S]*?z-index:\s*4/);
assert.match(styles, /\.iso-overlays-svg\s*\{\s*z-index:\s*5/);
assert.match(styles, /\.devlayer\s*\{[\s\S]*?z-index:\s*6/);
- const shadowSurface = section(card, '