Files
houseplan-card/test/iso-stage6.test.mjs
T
Claudeandclaude[bot] 715735d753 refactor(iso): remove the empty overlay renderers and dead fixture fields (#732)
After #714 and #724 the 2.5D overlays still carried stubs:

- renderIsoOverlayGrounds and renderIsoRaisedOverlays returned an empty SVG
  on every frame. They go with IsoFramePresentation.grounds/raised and the two
  bindings in the card. The iso-overlays-svg element itself stays, now empty:
  it is the inert camera-viewBox layer the contract and live-touch smokes
  measure screen-facing HTML against, so the 2.5D DOM keeps its elements.
- IsoOverlayRenderEntry.groundRadius was computed for every device, room label
  and lock and read only by the snapshot comparison that compared it.

The overlay test fixtures passed view, referenceView, stageSize and layers
(and one test selectedDeviceId), which IsoOverlaySceneInput does not have, and
asserted that changing them keeps the placement - a claim the signature makes
by itself. Those fields are gone from every fixture. The zoom/resize asserts of
"Stage 4 reuses pure overlay placements" and "#713 AC3" (renamed to what it
still checks) and the "#570 supersedes #473 W1" selection test go; the #724
AC2 test now zooms the way production does, through the live frame of
resolveIsoScene, and checks that the structural geometry and so the overlay
scene are reused. The #713 K8 fixture no longer passes stageSize, which
resolveIsoOverlayFitEnvelope does not read.

test/iso-overlay-fixture-types.test.mjs typechecks the overlay test files with
the TypeScript compiler: their fixture types (OverlaySceneFixture,
OverlayEntryFixture) are the keys of the production types with deliberately
loose values, so a partial fixture is fine and a field the type lacks is an
excess-property error. Three checks: no excess property in the fixture files;
a probe shows the fixture types resolve to the real inputs and reject view,
referenceView, stageSize, layers, selectedDeviceId and groundRadius; every
call of the scene builder gets its argument through a checked type (a literal
in overlayScene or a declaration of the fixture type). Each check is red when
a dead field is put back into a declared fixture, an override literal or an
entry, when a literal goes straight into the builder, when a fixture loses its
annotation, and when groundRadius returns to the entry type.

isometric-contract now asserts that nothing renders into the overlay surface
and that the removed renderers and groundRadius stay gone. No mutant is
anchored on the removed code; mutation-gate --check is unchanged (3 warnings).
The 19 2.5D golden scenes pass in capture on the accepted baselines.

Issue: #732
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 23:26:46 +00:00

253 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #649 (2.5D stage 6): the pure parts — wall colours from the user's data,
// light-floor luma, tile edge/shadow numbers from the designer lab, soft window
// light geometry. Browser behaviour is proven by demo/smoke_iso_*.mjs.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
colorLuma, compositeFloor, isLightFloor, isoLightFloorRooms, memoIsoLightFloorRooms,
isoWallMaterial, isoWallMaterialVars,
parseCssColor, parseHexColor,
} from '../test-build/iso-materials.js';
import { IsoFirstFrameState, isoPaperContext } from '../test-build/iso-first-frame.js';
import {
ISO_ICON_SCALE, ISO_STATE_BODIES, ISO_TILE, isoEdgeColor, isoTileShadow, isoTileStateCss,
} from '../test-build/iso-tiles.js';
import { computeIsoSunBeams, isoSunDepth, ISO_SUN_STOPS_DARK_FLOOR, ISO_SUN_STOPS_LIGHT_FLOOR } from '../test-build/iso-sun.js';
import { volumetricViewOf } from '../test-build/logic.js';
test('#649 п.4 volumetric_view: only an explicit true enables 2.5D', () => {
assert.equal(volumetricViewOf({ volumetric_view: true }), true);
for (const s of [undefined, null, {}, { volumetric_view: false }, { volumetric_view: 'true' }, { volumetric_view: 1 }]) {
assert.equal(volumetricViewOf(s), false, JSON.stringify(s));
}
});
test('#649 3b walls: top = user colour, sides darker per channel; default white = lab matte', () => {
assert.deepEqual(isoWallMaterial('#ffffff'), {
topHi: '#ffffff', topLo: '#ededed', sideHi: '#c4c4c4', sideMid: '#adadad', sideLo: '#999999',
});
const user = isoWallMaterial('#d9c8b4');
assert.equal(user.topHi, '#d9c8b4');
assert.equal(user.sideLo, '#82786c');
assert.equal(isoWallMaterial('nonsense').topHi, '#ffffff', 'a malformed colour falls back to white');
assert.match(isoWallMaterialVars('#d9c8b4'), /^--iso-top-hi:#d9c8b4;--iso-top-lo:#[0-9a-f]{6};--iso-side-hi:/);
assert.doesNotMatch(isoWallMaterialVars('#d9c8b4'), /dark|theme/);
});
test('#649 light floor: lab luma > 0.55 on the fill composited over the paper', () => {
assert.deepEqual(parseHexColor('#abc'), [170, 187, 204]);
assert.deepEqual(parseCssColor('rgb(238, 232, 222)'), [238, 232, 222]);
assert.equal(isLightFloor([0xee, 0xe8, 0xde]), true, 'lab warm floor');
assert.equal(isLightFloor([0x73, 0x77, 0x77]), false, 'lab dark floor');
assert.equal(Math.round(colorLuma([255, 255, 255]) * 100), 100);
assert.deepEqual(compositeFloor(null, 1, [1, 2, 3]), [1, 2, 3], 'no fill = paper');
assert.deepEqual(compositeFloor([0, 0, 0], 0.5, [200, 200, 200]), [100, 100, 100]);
const rooms = isoLightFloorRooms(new Map([
['light', null], ['dark', { color: '#0d1b2a', opacity: 0.5 }], ['half', { color: '#486a8f', opacity: 0.42 }],
]), [255, 255, 255]);
assert.deepEqual([...rooms].sort(), ['half', 'light']);
});
test('#654 light-floor memo reuses unchanged inputs and invalidates every visible input', () => {
const base = new Map([
['light', null],
['tinted', { color: '#486a8f', opacity: 0.42 }],
]);
const first = memoIsoLightFloorRooms(null, base, [255, 255, 255]);
const same = memoIsoLightFloorRooms(first, new Map(base), [255, 255, 255]);
assert.equal(same, first, 'equivalent paper/fills/rooms reuse the memo object');
assert.equal(same.rooms, first.rooms, 'the hot render path reuses the Set too');
const paperChanged = memoIsoLightFloorRooms(first, base, [20, 20, 20]);
assert.notEqual(paperChanged, first, 'paper RGB participates in the key');
assert.deepEqual([...paperChanged.rooms], [], 'dark paper changes an unfilled room');
const fillChanged = memoIsoLightFloorRooms(first, new Map([
['light', { color: '#000000', opacity: 1 }],
['tinted', { color: '#486a8f', opacity: 0.42 }],
]), [255, 255, 255]);
assert.notEqual(fillChanged, first, 'resolved fill participates in the key');
assert.equal(fillChanged.rooms.has('light'), false);
const roomsChanged = memoIsoLightFloorRooms(first, new Map([
...base,
['new-room', null],
]), [255, 255, 255]);
assert.notEqual(roomsChanged, first, 'room membership participates in the key');
assert.equal(roomsChanged.rooms.has('new-room'), true);
});
test('#654 first-frame state resolves paper after commit and releases failure to Flat', () => {
const state = new IsoFirstFrameState();
const light = isoPaperContext('floor', 'view', true, { darkMode: false, theme: 'light' });
state.prepare('iso', light);
assert.equal(state.pending('iso', false), true, 'runtime and image-paper are both cold');
let resolves = 0;
assert.equal(state.sync('iso', light, () => { resolves += 1; return [250, 250, 250]; }), true);
assert.equal(state.sync('iso', light, () => { resolves += 1; return [0, 0, 0]; }), false);
assert.equal(resolves, 1, 'unchanged HA updates do not resolve computed colour again');
state.runtimeReady();
assert.equal(state.readiness('iso', 'iso', true), 'ready');
const dark = isoPaperContext('floor', 'view', true, { darkMode: true, theme: 'dark' });
state.prepare('iso', dark);
assert.equal(state.pending('iso', true), true, 'a theme change hides the stale classification');
state.sync('iso', dark, () => { resolves += 1; return [17, 17, 17]; });
assert.equal(resolves, 2);
state.runtimeFailure();
assert.equal(state.pending('iso', false), false, 'a failed chunk cannot leave the veil stuck');
assert.equal(state.readiness('iso', 'flat', false), 'fallback');
assert.equal(state.readiness('flat', 'flat', false), null, 'Flat never inherits 2.5D waiting');
});
test('#654 a drawn plan has deterministic white paper before its first render', () => {
const state = new IsoFirstFrameState();
state.prepare('iso', isoPaperContext('floor', 'view', false));
assert.equal(state.pending('iso', true), false);
assert.equal(state.sync('iso', isoPaperContext('floor', 'view', false), () => {
assert.fail('drawn paper must not ask the DOM for its colour');
}), false);
});
test('#649 п.1 tile numbers are the lab units / 80 and the scale 1.12', () => {
assert.equal(ISO_ICON_SCALE, 1.12);
assert.deepEqual({ ...ISO_TILE }, {
depth: 0.1, radius: 0.275, radiusOfHeight: 0.3, lift: 0.075, badgeGap: 0.075,
shadowInset: 0.0375, frameWidth: 0.0625, frameOutset: 0.075,
});
});
test('#649 п.1 edge colours: brightness/saturate of the body, dark bodies fixed, light floor lighter', () => {
assert.equal(isoEdgeColor('#ffffff', 'light', false), '#b3b3b3');
assert.equal(isoEdgeColor('#ffffff', 'light', true), '#d1d1d1');
assert.equal(isoEdgeColor('#F0A00C', 'light', false), '#a07119');
assert.equal(isoEdgeColor('#000000', 'light', false), '#5b5e5a', 'black body (luma < 70)');
assert.equal(isoEdgeColor('#252525', 'dark', false), '#4a4a4a', 'dark core');
assert.equal(isoEdgeColor('#252525', 'dark', true), '#4a4a4a', 'the floor does not lighten a dark edge');
const css = isoTileStateCss();
for (const [state, body] of Object.entries(ISO_STATE_BODIES)) {
assert.match(css, new RegExp(`\\.dev\\.${state} \\{ --iso-body: ${body}; [^}]*--iso-edge: ${isoEdgeColor(body, 'light', false)};`));
assert.match(css, new RegExp(`\\.dev\\.${state}\\.iso-floor-light \\{ --iso-edge: ${isoEdgeColor(body, 'light', true)}; \\}`));
// CODE-REVIEW-649-r1 L3: the dark theme computes its own state edge.
assert.match(css, new RegExp(`\\.dev\\.theme-dark\\.${state} \\{ [^}]*--iso-edge: ${isoEdgeColor(body, 'dark', false)}; \\}`));
assert.match(css, new RegExp(`\\.dev\\.theme-dark\\.${state}\\.iso-floor-light \\{ --iso-edge: ${isoEdgeColor(body, 'dark', true)}; \\}`));
}
});
test('#649 п.1 shadow table: all four theme × floor combinations (SPEC-REVIEW-649-r1 M1)', () => {
const row = (t, f) => { const s = isoTileShadow(t, f); return [s.dy * 80, s.sigma * 80, s.opacityWhite, s.opacityTinted]; };
assert.deepEqual(row('light', false), [30, 22, 0.34, 0.5]);
assert.deepEqual(row('light', true), [34, 11, 0.3, 0.42]);
assert.deepEqual(row('dark', false), [30, 22, 0.4, 0.4]);
assert.deepEqual(row('dark', true), [34, 11, 0.4, 0.4]);
const css = isoTileStateCss();
const dark = css.indexOf('.iso-tile-shadow.theme-dark.theme-dark {');
const floorTinted = css.indexOf('.iso-tile-shadow.iso-floor-light:is(');
assert.ok(floorTinted > 0 && dark > floorTinted, 'the dark-theme opacity rule comes after the floor rules');
});
test('#649 п.2 light depth: lab length curve in wall heights, low sun = long', () => {
const H = 84;
assert.ok(Math.abs(isoSunDepth(90, H) - 408 * 0.55 / 218.8 * H) < 1e-9, 'noon: the lab base × 0.55');
assert.ok(isoSunDepth(90, 1) >= 215 / 218.8 - 1e-12, 'never below the lab minimum');
assert.ok(isoSunDepth(10, H) > isoSunDepth(40, H), 'low sun reaches further');
assert.ok(isoSunDepth(0, H) <= 910 / 218.8 * H + 1e-9);
const e = 30;
const lab = Math.min(910, Math.max(215, 408 * (0.55 + 1.4 * Math.pow(1 - e / 90, 1.6))));
assert.ok(Math.abs(isoSunDepth(e, H) - lab / 218.8 * H) < 1e-9);
assert.equal(ISO_SUN_STOPS_DARK_FLOOR[0][1], '#ffe9b4');
assert.equal(ISO_SUN_STOPS_LIGHT_FLOOR[0][1], '#e2b95e');
});
test('#649 п.2 beams: same exterior windows as Flat, parallelogram along the sun, room clip, light floor', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [400, 0], [400, 400], [0, 400]] }, { id: 'n', poly: [[400, 0], [800, 0], [800, 400], [400, 400]] }];
// A south window (bottom wall, y = 400) and an interior window between the rooms.
const windows = [
{ id: 'south', x: 200, y: 400, angle: 0, length: 80 },
{ id: 'interior', x: 400, y: 200, angle: 90, length: 80 },
];
const base = { rooms, windows, azimuth: 200, elevation: 30, northDeg: 0, wallHeight: 84 };
const beams = computeIsoSunBeams(base);
assert.deepEqual(beams.map((b) => b.openingId), ['south'], 'interior windows never light');
const [beam] = beams;
assert.deepEqual(beam.normal.map((v) => Math.round(v)), [0, -1], 'light enters the room');
assert.ok(Math.abs(beam.depth - isoSunDepth(30, 84)) < 1e-9);
const lateral = beam.shift[0] / -beam.shift[1];
assert.ok(Math.abs(lateral - Math.tan(20 * Math.PI / 180)) < 1e-6, 'oblique along the real sun, not the normal');
assert.ok(beam.polys.every((poly) => poly.every(([x, y]) => x >= -1e-6 && x <= 400 + 1e-6 && y >= -1e-6 && y <= 400 + 1e-6)),
'clipped to the room');
assert.equal(beam.lightFloor, false);
assert.equal(computeIsoSunBeams({ ...base, lightFloorRooms: new Set(['r']) })[0].lightFloor, true);
assert.deepEqual(computeIsoSunBeams({ ...base, elevation: -1 }), [], 'night');
assert.deepEqual(computeIsoSunBeams({ ...base, azimuth: 20 }), [], 'the sun behind the wall');
// A wall face moves the base to the inner face (half the wall depth inward).
const faced = computeIsoSunBeams({ ...base, wallDepthByOpening: { south: 20 } })[0];
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');
const presentation = { scale: 1, pulse: { animated: false }, classes: [], supplemental: [] };
const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 1000, 1000], bg: null,
rooms: [{ id: 'r', poly: [[0, 0], [1000, 0], [1000, 1000], [0, 1000]] }],
wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
/** @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,
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;
const scaled = isoRaisedOverlayHalfSize({ kind: 'device', core: base * ISO_ICON_SCALE, presentation })[0];
const flat = isoRaisedOverlayHalfSize({ kind: 'device', core: base, presentation })[0];
assert.ok(Math.abs(half - scaled) < 1e-6, `footprint half ${half} = the 1.12 tile ${scaled}`);
assert.ok(half > flat * 1.1, 'the Flat size would let 2.5D tiles overlap');
});
test('#649 AC7 the 2.5D beam is cut by the same physical bodies as Flat, on the far side of the body', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [400, 0], [400, 400], [0, 400]] }];
const windows = [{ id: 'south', x: 200, y: 400, angle: 0, length: 80 }];
// Sun due south: the light goes straight north (−y) from the south window.
const base = { rooms, windows, azimuth: 180, elevation: 30, northDeg: 0, wallHeight: 84 };
const inside = (p, poly) => {
let hit = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i]; const [xj, yj] = poly[j];
if ((yi > p[1]) !== (yj > p[1]) && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
};
const lit = (beam, p) => beam.polys.some((poly) => inside(p, poly));
const area = (beam) => beam.polys.reduce((sum, poly) => sum + Math.abs(poly.reduce((s, p, i) => {
const q = poly[(i + 1) % poly.length];
return s + p[0] * q[1] - q[0] * p[1];
}, 0)) / 2, 0);
const [open] = computeIsoSunBeams(base);
assert.ok(open.depth > 150, 'the beam reaches past the body');
for (const p of [[200, 360], [200, 250], [170, 250]]) assert.ok(lit(open, p), `lit without a body at ${p}`);
// A column in the middle of the beam, 80–100 units into the room.
const column = [[190, 300], [210, 300], [210, 320], [190, 320]];
const [cut] = computeIsoSunBeams({ ...base, occluders: [column] });
assert.ok(lit(cut, [200, 360]), 'between the window and the body the floor stays lit');
assert.ok(!lit(cut, [200, 250]), 'behind the body, away from the sun, the floor is in shadow');
assert.ok(lit(cut, [170, 250]), 'beside the shadow the beam goes on');
assert.ok(area(cut) < area(open) - 20 * 50, 'the shadow runs along the whole remaining beam');
});