mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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
355 lines
25 KiB
JavaScript
355 lines
25 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { readAllStylesSource } from './styles-source.mjs';
|
|
|
|
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
|
const labs = readFileSync(new URL('../src/labs.ts', import.meta.url), 'utf8');
|
|
const styles = readAllStylesSource();
|
|
const openings = readFileSync(new URL('../src/iso-openings.ts', import.meta.url), 'utf8');
|
|
const overlays = readFileSync(new URL('../src/iso-overlays.ts', import.meta.url), 'utf8');
|
|
const projection = readFileSync(new URL('../src/iso-projection.ts', import.meta.url), 'utf8');
|
|
const sceneRender = readFileSync(new URL('../src/iso-scene-render.ts', import.meta.url), 'utf8');
|
|
const editorRuntime = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
|
|
const packageJson = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
|
|
const spaceRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
|
|
const goldenHarness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
|
|
const contractSmoke = readFileSync(new URL('../demo/smoke_isometric_contract.mjs', import.meta.url), 'utf8');
|
|
const touchSmoke = readFileSync(new URL('../demo/smoke_isometric_live_touch.mjs', import.meta.url), 'utf8');
|
|
const serveHarness = readFileSync(new URL('../demo/serve.mjs', import.meta.url), 'utf8');
|
|
const isoRuntimeCompat = readFileSync(new URL('../demo/iso-runtime-compat.mjs', import.meta.url), 'utf8');
|
|
|
|
const section = (source, start, end) =>
|
|
source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
|
|
|
|
const styleBodiesFor = (...selectorParts) => [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
|
.filter((match) => selectorParts.every((part) => match[1].includes(part)))
|
|
.map((match) => match[2])
|
|
.join('\n');
|
|
|
|
test('#649 2.5D is the General settings switch, presentation-only and absent from the space card', () => {
|
|
assert.match(card, /private get _isoEnabled\(\): boolean \{ return volumetricViewOf\(this\._settings\); \}/);
|
|
assert.match(card, /return this\._mode === 'view' && this\._isoEnabled \? 'iso' : 'flat';/);
|
|
assert.doesNotMatch(card, /data-hp="projection-toggle"|_viewPreference|houseplan_card_view_v1|_labsIso/,
|
|
'no per-device choice, no header button, no alpha gate');
|
|
assert.doesNotMatch(labs, /id: 'iso'/, 'the alpha registry no longer ships 2.5D');
|
|
assert.match(card, /iso \? `projection-iso \$\{deviceThemeClass\(this\._renderPlanHass\)\}` : ''/);
|
|
assert.doesNotMatch(card, /projection-\$\{projection\}/,
|
|
'Flat/editor DOM must not gain a projection marker');
|
|
assert.doesNotMatch(spaceCard, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
|
|
assert.doesNotMatch(spaceRender, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
|
|
assert.match(labs, /houseplan_card_alpha_v1/);
|
|
assert.match(labs, /params\.getAll\('hp_alpha'\)/);
|
|
assert.doesNotMatch(labs, /CARD_VERSION|houseplan_card_labs_v1|params\.getAll\('hp-labs'\)/);
|
|
});
|
|
|
|
test('Stage 4 code is loaded only through the hidden alpha runtime boundary', () => {
|
|
assert.equal([...card.matchAll(/await import\('\.\/iso-scene-render'\)/g)].length, 1);
|
|
for (const module of ['iso-scene-render', 'iso-walls', 'iso-openings', 'iso-overlays']) {
|
|
const valueImport = new RegExp(`import\\s+(?!type\\b)[^;]+from './${module}';`, 's');
|
|
assert.doesNotMatch(card, valueImport, `${module} leaked into the eager card graph`);
|
|
assert.doesNotMatch(editorRuntime, valueImport, `${module} leaked into the lazy editor graph`);
|
|
}
|
|
for (const module of ['iso-walls', 'iso-openings', 'iso-overlays'])
|
|
assert.match(sceneRender, new RegExp(`from './${module}'`));
|
|
assert.match(card, /const ISO_RETRY_ASSET = '__HOUSEPLAN_ISO_RETRY_ASSET__'/);
|
|
assert.match(sceneRender, /ISO_SCENE_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__'/);
|
|
assert.match(card, /if \(this\._isoEnabled\) void this\._ensureIsoSceneRuntime\(\)/);
|
|
});
|
|
|
|
test('Stage 4 browser harnesses await the lazy runtime and cold alpha-off skips its chunk', () => {
|
|
for (const [label, source] of [
|
|
['contract smoke', contractSmoke],
|
|
['touch smoke', touchSmoke],
|
|
['golden harness', goldenHarness],
|
|
]) {
|
|
assert.match(source, /await card\._ensureIsoSceneRuntime\(\)/,
|
|
`${label} does not await the Stage 4 runtime`);
|
|
}
|
|
assert.match(contractSmoke,
|
|
/cleanAlphaOffSkipsIsoRuntimeRequest = isoRuntimeRequests\(\) === 0/);
|
|
assert.ok((contractSmoke.match(/startsWith\('projection-'\)/g) || []).length >= 3,
|
|
'alpha-off, editor and removal witnesses must preserve the legacy Flat stage classes');
|
|
assert.match(contractSmoke,
|
|
/\/iso-scene-render-\[\^\/\]\+\\\.js/,
|
|
'the cold-alpha guard must identify the generated lazy chunk, not generic requests');
|
|
});
|
|
|
|
test('every browser smoke that explicitly selects Iso waits for its lazy runtime', () => {
|
|
const demo = new URL('../demo/', import.meta.url);
|
|
const consumers = [];
|
|
for (const name of readdirSync(demo).filter((item) => /^smoke_.*\.mjs$/.test(item))) {
|
|
const source = readFileSync(new URL(name, demo), 'utf8');
|
|
// #649: 2.5D is selected through the shared harness helper or the facade.
|
|
if (!/__hpHarnessProjection\(\w+, 'iso'\)|setVolumetricView\(true\)/.test(source)) continue;
|
|
consumers.push(name);
|
|
assert.doesNotMatch(source, /_setProjection\(/, `${name} still selects 2.5D the pre-#649 way`);
|
|
// The facade waits for `.stage.projection-iso`, which the card sets only
|
|
// once the lazy runtime is installed (_effectiveProjection).
|
|
assert.match(source, /await (?:window\.)?(?:__hpHarnessProjection|__hpEnsureHarnessIsoRuntime|ensureIsoRuntime|(?:__hpTest|\w+)\.setVolumetricView)\(/,
|
|
`${name} selects Iso without waiting for the lazy runtime`);
|
|
}
|
|
assert.ok(consumers.length >= 14, 'the guard unexpectedly lost known Iso smoke consumers');
|
|
assert.match(isoRuntimeCompat, /window\.__hpHarnessProjection = async[\s\S]*?await window\.__hpEnsureHarnessIsoRuntime\(card\)/,
|
|
'the shared projection helper must await the lazy runtime');
|
|
assert.match(serveHarness,
|
|
/installHarnessIsoRuntimeHelper\(page\)/,
|
|
'the shared browser helper is not installed by the smoke launcher');
|
|
assert.match(isoRuntimeCompat,
|
|
/await ensure\.call\(card\)[\s\S]*?await card\.updateComplete[\s\S]*?requestAnimationFrame/,
|
|
'the shared helper must await load and a painted settled frame');
|
|
});
|
|
|
|
test('Stage 4 composes ordered inert SVG surfaces below screen-facing HTML', () => {
|
|
assert.match(card, /<svg class="iso-underlay-svg"/);
|
|
assert.match(card, /<svg class="iso-shadows-svg"/);
|
|
assert.match(card, /<svg class="iso-walls-svg"/);
|
|
assert.match(card, /<svg class="iso-overlays-svg"/);
|
|
assert.match(sceneRender, /class="iso-wall-top"/);
|
|
assert.match(sceneRender, /class="iso-wall-side"/);
|
|
assert.match(sceneRender, /class="iso-opening-panel iso-\$\{surface\.type\} iso-opening-\$\{surface\.kind\} iso-material-\$\{surface\.material\}"/);
|
|
assert.doesNotMatch(sceneRender, /iso-overlay-plate|hp-iso-overlay-texture/,
|
|
'the invisible safety footprint must never become painted SVG geometry');
|
|
assert.doesNotMatch(sceneRender, /class="iso-overlay-tether"|class="iso-overlay-ground"/,
|
|
'the reviewed near-floor treatment has no debug tethers or ground dots');
|
|
assert.match(sceneRender, /const depthQueue = buildIsoWallDepthQueue\(scene\.geometry, openingSurfaces\)/);
|
|
assert.match(sceneRender, /depthQueue\.map\(\(entry\) =>/);
|
|
assert.match(sceneRender, /entry\.layer === 'wall-top'[\s\S]*?fill-rule="evenodd"/,
|
|
'each independently sorted wall component must retain evenodd hole semantics');
|
|
assert.match(sceneRender, /aria-hidden="true" pointer-events="none"/);
|
|
assert.match(styles, /\.iso-underlay-svg\s*\{\s*z-index:\s*0/);
|
|
assert.match(styles, /\.plan-svg\s*\{\s*z-index:\s*1/);
|
|
assert.match(styles, /\.iso-shadows-svg\s*\{\s*z-index:\s*3/);
|
|
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/);
|
|
// #732: the overlay surface stays as the inert camera-viewBox layer the
|
|
// screen-facing HTML is measured against; nothing renders into it, and the
|
|
// two renderers that returned an empty SVG are gone with the ground radius.
|
|
const overlaySurface = section(card, '<svg class="iso-overlays-svg"', '</svg>');
|
|
assert.match(overlaySurface, /pointer-events="none">$/);
|
|
assert.doesNotMatch(overlaySurface, /\$\{isoFrame/);
|
|
const frameResolver = section(sceneRender, 'export function resolveIsoFramePresentation', 'const emptySvg');
|
|
assert.doesNotMatch(frameResolver, /grounds|raised/);
|
|
assert.doesNotMatch(sceneRender, /renderIsoOverlayGrounds|renderIsoRaisedOverlays|groundRadius/);
|
|
assert.doesNotMatch(styles, /perspective\s*:|preserve-3d|rotateX\(|rotateZ\(/);
|
|
// #649: the 2.5D window light is its own module re-exported through this lazy
|
|
// graph; the structural renderer itself still paints no light.
|
|
assert.doesNotMatch(sceneRender, /window-light|iso-window-light|iso-glow/);
|
|
assert.equal([...sceneRender.matchAll(/iso-sun/g)].length, 1, 'only the lazy re-export mentions iso-sun');
|
|
});
|
|
|
|
test('Stage 4 keeps device, room and lock roots on one corrected low visual plane', () => {
|
|
for (const renderer of [
|
|
'_renderDecorLayer(undefined, view)', '_renderRoomHoverFill(roomHover)', '_renderGlowLayer(space, disp, view)',
|
|
'_renderSunRays(space)', '_renderOpenings(disp)',
|
|
'_renderVacuums(this._renderVacuumDevices, view, space.id)',
|
|
]) assert.ok(card.includes(renderer), `missing ${renderer}`);
|
|
// #713: the 2.5D floor is the Flat plane, so a floor anchor is its own scene point.
|
|
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? \[pos\.x, pos\.y\]/);
|
|
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? \[p\.x, p\.y\]/);
|
|
assert.match(card, /const point = isoPlacement\?\.visualScene \?\? floorAnchor/);
|
|
assert.doesNotMatch(card, /_scenePoint/);
|
|
assert.doesNotMatch(readFileSync(new URL('../src/live-editor.ts', import.meta.url), 'utf8'), /_scenePoint/,
|
|
'the live editor paints a dragged marker at its Flat point too');
|
|
const vacuum = section(card, 'private _renderVacuums(', 'private _renderDevice(');
|
|
assert.match(vacuum, /const point = \[cx, cy\]/);
|
|
assert.doesNotMatch(vacuum, /visualScene|raisedScene|resolveIsoOverlayPlacement/);
|
|
for (const kind of ['device', 'room-label', 'opening-lock'])
|
|
assert.match(card, new RegExp(`data-hp-iso-overlay-kind=\\$\\{isoPlacement\\?\\.plane === 'raised' \\? '${kind}'`));
|
|
});
|
|
|
|
test('Stage 4 structural cache fingerprints geometry/camera/heights and excludes live paint', () => {
|
|
assert.match(sceneRender, /flipH: !!opening\.flip_h/);
|
|
assert.match(sceneRender, /flipV: !!opening\.flip_v/);
|
|
assert.match(sceneRender, /const floorEdgeHeight = gridVisualUnits\(ISO_FLOOR_EDGE_HEIGHT, input\.cellCm\)/);
|
|
assert.match(sceneRender, /const raisedHeight = gridVisualUnits\(ISO_RAISED_OVERLAY_HEIGHT, input\.cellCm\)/);
|
|
assert.match(sceneRender, /camera: ISO_CAMERA,[\s\S]*?wallHeight,[\s\S]*?raisedHeight,[\s\S]*?floorEdgeHeight,[\s\S]*?algorithm: 6/);
|
|
const source = section(sceneRender, 'export function createIsoStructuralSource', 'export interface ResolveIsoSceneInput');
|
|
const roomProjection = section(sceneRender,
|
|
'export function isoStructuralRoomGeometry', 'export type IsoStructuralOpeningHost');
|
|
for (const field of ['id', 'x', 'y', 'w', 'h', 'poly', 'wall_ids'])
|
|
assert.match(roomProjection, new RegExp(`${field}: room\\.${field}`));
|
|
assert.doesNotMatch(roomProjection, /room\.(?:name|area|settings|open_to)/,
|
|
'presentation and compatibility-only room fields must not invalidate structure');
|
|
assert.match(source, /rooms: input\.space\.rooms\.map\(isoStructuralRoomGeometry\)/);
|
|
assert.doesNotMatch(source, /rooms: input\.space\.rooms,/);
|
|
assert.match(source, /openingHosts,[\s\S]*?openingGeometryPolicy,/);
|
|
const hostProjection = section(sceneRender,
|
|
'export function isoStructuralOpeningHost', 'export interface IsoStructuralSourceInput');
|
|
for (const field of ['hostId: resolved.host.id', 't: resolved.t', 'depth: resolved.depth',
|
|
'face: partitionOpeningFace']) assert.ok(hostProjection.includes(field), `missing ${field}`);
|
|
assert.match(openings, /ISO_OPENING_GEOMETRY_POLICY[\s\S]*?revision: 3/);
|
|
assert.match(openings,
|
|
/input\.type === 'door' \|\| input\.type === 'gate'[\s\S]*?\? input\.face[\s\S]*?: openingSymbolOffset/,
|
|
'door/gate volume uses the selected physical face while Flat/window geometry stays canonical');
|
|
assert.match(source,
|
|
/buildIsoOpeningBasis\(\{ \.\.\.opening, face \}, wallHeight, openingGeometryPolicy\)/);
|
|
assert.match(openings, /policy\.gateTurnDeg[\s\S]*?policy\.gateTopRatio/);
|
|
assert.match(openings, /policy\.windowSashBottomRatio[\s\S]*?policy\.windowSashTopRatio/);
|
|
assert.match(openings, /policy\.windowGlassBottomRatio[\s\S]*?policy\.windowGlassTopRatio/);
|
|
assert.match(openings, /policy\.doorTurnDeg/);
|
|
assert.match(openings, /policy\.windowTurnDeg/);
|
|
assert.match(openings, /policy\.doorTopRatio/);
|
|
assert.match(openings, /policy\.leafThicknessRatio[\s\S]*?policy\.frameThicknessRatio/);
|
|
assert.match(contractSmoke,
|
|
/presentationRoomUpdateKeepsStructuralCache[\s\S]*?roomSourceAfterPresentation\.key === roomSourceBeforePresentation\.key[\s\S]*?buildsAfterRoomPresentation === buildsBeforeRoomPresentation/);
|
|
assert.match(contractSmoke,
|
|
/roomGeometryUpdateInvalidatesStructuralCache[\s\S]*?roomSourceAfterGeometry\.key !== roomSourceAfterPresentation\.key[\s\S]*?buildsAfterRoomGeometry === buildsAfterRoomPresentation \+ 1/);
|
|
assert.doesNotMatch(source, /_openingAmt|openingAmount|\.hass|matchMedia|CSS\.supports|theme|hover|focus|selected|sunlight/);
|
|
assert.match(card, /onBuild: \(\) => \{ this\._isoStructuralBuildCount \+= 1; \}/);
|
|
assert.match(source, /projectIsoOpeningStructure\(basis\)/);
|
|
assert.match(sceneRender, /projectIsoOpening\(basis, amountOf\(opening\)\)/);
|
|
assert.match(sceneRender, /lruWrite\(input\.cache, input\.source\.key, value, 8\)/);
|
|
assert.match(card, /data-hp-iso-structural-builds=\$\{iso \? this\._isoStructuralBuildCount : nothing\}/);
|
|
// #724: the overlay caches are keyed by the structural wall geometry itself —
|
|
// no silhouette array is built only to be that key.
|
|
assert.match(sceneRender, /geometry: buildIsoWallGeometry\(structural\.walls, ISO_CAMERA, wallHeight\)/);
|
|
assert.match(sceneRender, /structure: IsoWallGeometry;/);
|
|
assert.match(sceneRender, /let placements = isoOverlayPlacementCache\.get\(input\.structure\)/);
|
|
assert.match(sceneRender, /const previous = isoOverlayRenderSceneCache\.get\(input\.structure\)/);
|
|
assert.doesNotMatch(sceneRender, /wallSilhouettes|isoWallSilhouettesOf|resolveCollisions/);
|
|
});
|
|
|
|
test('show_borders:false keeps the Flat floor plane and removes every volume cue', () => {
|
|
assert.match(card, /isoLayers && !isoLayers\.floorSymbols/);
|
|
assert.match(card, /<svg class="plan-svg"[\s\S]*?data-hp-live-viewbox=\$\{iso \? 'camera' : 'floor'\}/);
|
|
// #713: the floor group carries no transform in either branch.
|
|
assert.match(card, /<g class=\$\{iso \? 'iso-floor-scene' : nothing\}>/);
|
|
assert.doesNotMatch(card, /isoFloorMatrixCss|unprojectFloorPoint/);
|
|
assert.match(card,
|
|
/\$\{litCache\(iso && isoLayers\?\.structural \? svg`<svg class="iso-shadows-svg"/);
|
|
assert.match(card, /if \(!runtime \|\| !layers\?\.structural \|\| !structural\) return null/);
|
|
const baseView = section(card, 'private _baseVb(', '/** How many objects');
|
|
assert.match(baseView, /if \(!this\._spaceDisplayForRender\(\)\.showBorders\)[\s\S]*?projectedFrame\(\{[\s\S]*?wallHeight:\s*0/);
|
|
const effective = section(card, 'private _effectiveProjection()', 'private _floorView');
|
|
assert.ok(effective.indexOf("if (!this._spaceDisplayForRender().showBorders)")
|
|
< effective.indexOf('const source = this._isoSource()'));
|
|
assert.doesNotMatch(baseView, /ISO_WALL_HEIGHT|raisedHeight|floorDepth/);
|
|
assert.doesNotMatch(baseView, /accepted Stage 1|Stage 2 structure is absent/);
|
|
});
|
|
|
|
test('one frame resolves one structural source and latches late topology/projection failures', () => {
|
|
const effective = section(card, 'private _effectiveProjection()', 'private _floorView');
|
|
assert.equal([...effective.matchAll(/this\._isoSource\(\)/g)].length, 1);
|
|
assert.match(effective, /this\._isoScene\(source\)/);
|
|
assert.match(effective, /this\._isoFallback\.has\(key\)[\s\S]*?try \{/,
|
|
'the invalid source/key sentinel must stop a second failing attempt');
|
|
assert.match(card, /return `\$\{this\._space\}\|invalid\|\$\{this\._cfgEpoch\}`/,
|
|
'an invalid source latch must be scoped to the current structural config revision');
|
|
assert.match(card, /this\._clearIsoInvalidFallback\(\)/,
|
|
'an explicit Iso retry must release invalid source sentinels');
|
|
assert.doesNotMatch(card, /`\$\{this\._space\}\|invalid`/,
|
|
'a space-wide invalid latch would block recovery after a new geometry fingerprint');
|
|
const diagnostic = section(card, 'private _latchIsoFallback(', 'private _effectiveProjection');
|
|
assert.match(diagnostic, /key\.includes\('\|invalid\|'\) \? 'invalid'/);
|
|
assert.match(diagnostic, /safeRuntimeDiagnostic\('structural', fingerprint, true\)/);
|
|
assert.doesNotMatch(diagnostic, /this\._space|\$\{key\}|_error/,
|
|
'structural diagnostics must never serialize identifiers or thrown errors');
|
|
assert.match(card, /failed: \(_ignored, info\) => \{[\s\S]*?safeRuntimeDiagnostic\('runtime-load', 'unverified', info\.terminal\)/);
|
|
assert.doesNotMatch(card, /unable to load hidden isometric runtime[\s\S]{0,160}\b(?:error|message|stack|url)\b/i);
|
|
assert.match(effective, /const fallback = \(\) => \{[\s\S]*?_cancelCameraTransition\(false\); this\._view = null;[\s\S]*?this\._viewModeSnap = null;[\s\S]*?catch \(error\)[\s\S]*?return fallback\(\)/,
|
|
'a structural failure may not reuse an Iso camera as a Flat viewBox');
|
|
assert.match(effective, /fallback = \(\) => \{ if \(this\._renderProjection === 'iso'\)/,
|
|
'the persistent latch must not erase later Flat pan/zoom frames');
|
|
const renderBody = section(card, 'private _renderBody()', 'private _renderSpaceDialog');
|
|
assert.equal([...renderBody.matchAll(/resolveIsoFramePresentation\(/g)].length, 1);
|
|
assert.doesNotMatch(renderBody, /this\._isoScene\(/);
|
|
assert.match(renderBody, /catch \(error\)[\s\S]*?_latchIsoFallback[\s\S]*?_cancelCameraTransition\(false\)[\s\S]*?projection = 'flat'; iso = false/);
|
|
assert.match(renderBody, /this\._view = null;[\s\S]*?_baseVb\('flat', null\)/);
|
|
const resolver = section(sceneRender, 'export function resolveIsoFramePresentation', 'const emptySvg');
|
|
for (const step of ['resolveIsoDecorationLayers', 'resolveIsoOpeningPanels', 'renderIsoUnderlay',
|
|
'renderIsoShadows', 'renderIsoWalls'])
|
|
assert.ok(resolver.includes(step), `late Stage 4 step escaped the frame boundary: ${step}`);
|
|
});
|
|
|
|
test('raised footprints participate in global and room fit without entering the structural LRU', () => {
|
|
assert.match(sceneRender, /screenHalfSize: PlanPoint/);
|
|
assert.match(sceneRender, /export function isoOverlaySceneBounds/);
|
|
assert.match(sceneRender, /export function resolveIsoOverlayFitEnvelope/);
|
|
// #714: without a runtime nudge the fit and the final bounds read one point set.
|
|
assert.match(sceneRender, /const center = placement\.visualScene;/);
|
|
assert.match(sceneRender, /placement\.floorScene,\s*\.\.\.placement\.footprint,/);
|
|
assert.match(sceneRender, /const \[halfX, halfY\] = entry\.screenHalfSize/);
|
|
const scene = section(card, 'private _isoScene(', 'private _latchIsoFallback');
|
|
assert.match(scene, /resolveIsoScene\([\s\S]*?buildIsoOverlayRenderScene|resolveIsoScene\([\s\S]*?_isoOverlayScene/);
|
|
assert.match(scene, /resolveIsoOverlayFitEnvelope\([\s\S]*?overlayFitEntries/);
|
|
assert.ok(scene.indexOf('resolveIsoScene') < scene.indexOf('resolveIsoOverlayFitEnvelope'));
|
|
const roomFit = section(card, 'private _fitRoom(', '/** «Вписать всё»');
|
|
assert.match(roomFit, /overlayFitEntries/);
|
|
assert.match(roomFit, /ownerId: room\.id/);
|
|
assert.match(roomFit, /resolveIsoOverlayFitEnvelope/);
|
|
const structural = section(sceneRender, 'export function resolveIsoScene', 'const unionRect');
|
|
assert.doesNotMatch(structural, /overlayFitEntries|resolveIsoOverlayFitEnvelope|buildIsoOverlayRenderScene/);
|
|
});
|
|
|
|
test('low-plane DOM roots expose one floor/visual identity and preserve existing actions', () => {
|
|
assert.match(card, /data-hp-iso-stage=\$\{iso \? '4' : nothing\}/);
|
|
for (const token of [
|
|
'data-hp-iso-overlay-kind', 'data-hp-iso-raised', 'data-hp-iso-nudged',
|
|
'data-hp-iso-floor', 'data-hp-iso-visual',
|
|
]) assert.ok(card.includes(token), `missing ${token}`);
|
|
assert.match(card, /data-hp="device"[\s\S]*?@click=\$\{\(e: MouseEvent\) => this\._clickDevice\(e, d\)\}/);
|
|
assert.match(card, /data-hp="room-label"[\s\S]*?@keydown=\$\{this\._mode === 'view'/);
|
|
assert.match(card, /class="oplock[\s\S]*?@click=\$\{\(e: MouseEvent\) =>/);
|
|
const deviceHit = styleBodiesFor('.dev::before');
|
|
assert.match(deviceHit, /width:\s*max\(44px,/);
|
|
assert.match(deviceHit, /height:\s*max\(44px,/);
|
|
for (const selector of ['.roomlabel', '.oplock']) {
|
|
const raisedHit = styleBodiesFor('projection-iso', selector);
|
|
assert.match(raisedHit, /(?:min-)?width:\s*(?:44px|max\(44px,)/, selector);
|
|
assert.match(raisedHit, /(?:min-)?height:\s*(?:44px|max\(44px,)/, selector);
|
|
}
|
|
assert.match(styles, /\.iso-overlays-svg,[\s\S]*?pointer-events:\s*none/);
|
|
});
|
|
|
|
test('Stage 4 materials are bounded and capability-safe; #649 walls follow the user colour, not the theme', () => {
|
|
const materialIds = [...sceneRender.matchAll(/id="(hp-iso-[^"]+)" data-hp-iso-material-def/g)]
|
|
.map((match) => match[1]);
|
|
assert.ok(materialIds.length >= 5 && materialIds.length <= 12,
|
|
`expected a bounded shared definition set, got ${materialIds.length}`);
|
|
assert.equal(new Set(materialIds).size, materialIds.length, 'material ids must be unique');
|
|
// #649 3b: no theme rule for walls, openings, floor edge or wall texture.
|
|
assert.doesNotMatch(styles, /\.stage(?:\.theme-dark|:not\(\.theme-light\)) \.iso-(?:side|top|wall|floor-side|opening|texture)/);
|
|
assert.match(styles, /\.iso-top-hi\s*\{\s*stop-color:\s*var\(--iso-top-hi/);
|
|
assert.match(styles, /\.iso-side-mid\s*\{\s*stop-color:\s*var\(--iso-side-mid/);
|
|
assert.match(card, /\$\{iso \? `;\$\{isoWallMaterialVars\(this\._fillColors\.wall_fill\.c\)\}` : ''\}/);
|
|
assert.doesNotMatch(styles, /iso-overlay-plate|hp-iso-overlay-texture/,
|
|
'themes and forced colors must not recreate a painted footprint');
|
|
assert.doesNotMatch(styles, /iso-overlay-ground|iso-overlay-tether/,
|
|
'themes and capability fallbacks must not recreate removed debug cues');
|
|
assert.match(styles, /iso-material-glass-side[\s\S]*?fill:\s*#c9e4f3/);
|
|
assert.match(styles, /iso-material-glass-top[\s\S]*?fill:\s*#e3f2fa/);
|
|
const rendering = section(sceneRender, 'function renderIsoDefs(', 'export function resolveIsoDecorationLayers');
|
|
assert.match(sceneRender, /return `translate\(\$\{gridVisualUnits\(4, cellCm\)\} \$\{gridVisualUnits\(8, cellCm\)\}\)`/);
|
|
assert.equal([...sceneRender.matchAll(/transform=\$\{isoFixedLightTransform\(cellCm\)\}/g)].length, 1,
|
|
'only the approved building ambient shadow uses the fixed-light vector');
|
|
assert.doesNotMatch(sceneRender, /hp-iso-contact-shadow|hp-iso-leaf-shadow|iso-contact-shadow|iso-leaf-shadow/);
|
|
assert.doesNotMatch(styles, /\.iso-contact-shadow|\.iso-leaf-shadow/);
|
|
assert.match(styles, /\.iso-opening-panel\.iso-material-matte-leaf\s*\{[\s\S]*?stroke:\s*none/);
|
|
assert.match(styles, /iso-material-glass-side[\s\S]*?stroke:\s*#8fb4c7/,
|
|
'window glass keeps its thin border');
|
|
assert.match(styles,
|
|
/\.stage\.projection-iso\.mode-view \.dev,[\s\S]*?\.stage\.projection-iso\.mode-view \.oplock\s*\{\s*z-index:\s*2/,
|
|
'interactive overlays remain above room labels');
|
|
assert.doesNotMatch(rendering, /sunState|_renderSun|Date\.now|Math\.random/);
|
|
});
|
|
|
|
test('#649 room labels keep the configured colour in both themes; 2.5D only drops stroke and shadow', () => {
|
|
const label = styleBodiesFor('projection-iso', 'mode-view', '.roomlabel');
|
|
assert.match(label, /text-shadow:\s*none/);
|
|
assert.match(label, /filter:\s*none/);
|
|
assert.match(label, /-webkit-text-stroke:\s*0 transparent/);
|
|
assert.doesNotMatch(label, /(?:^|[;\s])color:/, 'the inline Flat colour must win in 2.5D');
|
|
assert.equal(styleBodiesFor('projection-iso', 'theme-dark', '.roomlabel'), '');
|
|
});
|
|
|
|
test('Stage 4 adds no schema, dependency, storage, network or HA action surface', () => {
|
|
for (const source of [openings, overlays, projection, sceneRender]) {
|
|
assert.doesNotMatch(source, /localStorage\s*\.|fetch\(|XMLHttpRequest|WebSocket|callService\(|Math\.random/);
|
|
}
|
|
assert.doesNotMatch(packageJson, /three|babylon|webgl/i);
|
|
assert.doesNotMatch(card, /iso2|isometric_stage|stage2_enabled|stage3_enabled|hp_alpha_stage/);
|
|
});
|