mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
576 lines
28 KiB
JavaScript
576 lines
28 KiB
JavaScript
// #122/#160/#570 Stage 4 + #649: settings lifecycle, visual handoff and flat editor boundary.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launch({ width: 1000, height: 850 });
|
|
const out = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const root = () => card.renderRoot;
|
|
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
const isoRuntimeRequests = () => [...performance.getEntriesByType('resource')]
|
|
.filter((entry) => /\/iso-scene-render-[^/]+\.js(?:$|\?)/.test(entry.name)).length;
|
|
const ensureIsoRuntime = async () => {
|
|
if (typeof card._ensureIsoSceneRuntime !== 'function'
|
|
|| !(await card._ensureIsoSceneRuntime())) {
|
|
throw new Error('isometric smoke runtime did not load');
|
|
}
|
|
await card.updateComplete;
|
|
await frame();
|
|
};
|
|
const center = (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
|
|
};
|
|
const inside = (outer, element, tolerance = 1) => {
|
|
const a = outer.getBoundingClientRect(), b = element.getBoundingClientRect();
|
|
return b.left >= a.left - tolerance && b.top >= a.top - tolerance
|
|
&& b.right <= a.right + tolerance && b.bottom <= a.bottom + tolerance;
|
|
};
|
|
const sceneToClient = (point) => {
|
|
const svg = root().querySelector('.iso-overlays-svg');
|
|
if (!svg) return [NaN, NaN];
|
|
const rect = svg.getBoundingClientRect(), vb = svg.viewBox.baseVal;
|
|
const scale = Math.min(rect.width / vb.width, rect.height / vb.height);
|
|
return [rect.left + (rect.width - vb.width * scale) / 2 + (point[0] - vb.x) * scale,
|
|
rect.top + (rect.height - vb.height * scale) / 2 + (point[1] - vb.y) * scale];
|
|
};
|
|
const owns44 = (element) => {
|
|
if (!element) return false;
|
|
const rect = element.getBoundingClientRect(), c = center(element);
|
|
const pseudo = getComputedStyle(element, '::before');
|
|
const width = Math.max(rect.width, Number.parseFloat(pseudo.width) || 0);
|
|
const height = Math.max(rect.height, Number.parseFloat(pseudo.height) || 0);
|
|
if (width < 44 || height < 44) return false;
|
|
return [[0, 0], [-21, 0], [21, 0], [0, -21], [0, 21]].every(([dx, dy]) => {
|
|
const hit = root().elementFromPoint(c[0] + dx, c[1] + dy);
|
|
return hit === element || !!hit && element.contains(hit);
|
|
});
|
|
};
|
|
// #649: 2.5D is the installation-wide General settings switch.
|
|
// The switch arrives as a server config push: re-bind the space the smoke edits.
|
|
const rebind = () => { configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1'); };
|
|
const enable = async () => {
|
|
await window.__hpTest.setVolumetricView(true);
|
|
rebind();
|
|
await ensureIsoRuntime();
|
|
await frame();
|
|
};
|
|
const result = {};
|
|
result.cleanProfileStartsOff = window.__hpAlpha === false
|
|
&& JSON.stringify(window.__hpLabs) === '[]'
|
|
&& !root().querySelector('[data-hp="projection-toggle"]')
|
|
&& ![...root().querySelector('.stage').classList]
|
|
.some((name) => name.startsWith('projection-'));
|
|
result.cleanAlphaOffSkipsIsoRuntimeRequest = isoRuntimeRequests() === 0;
|
|
let configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
|
|
configSpace.settings = {
|
|
...(configSpace.settings || {}), show_borders: true, show_names: true,
|
|
};
|
|
configSpace.partitions = [{
|
|
id: 'iso-smoke-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
|
|
}];
|
|
configSpace.openings = [
|
|
{ id: 'iso-centred-door', type: 'door', x: 0.325, y: 0.12, angle: 0, length: 0.08,
|
|
lock: 'lock.front_door',
|
|
host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.25 } },
|
|
{ id: 'iso-flipped-window', type: 'window', x: 0.5, y: 0.12, angle: 0, length: 0.08,
|
|
flip_v: true, host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.5 } },
|
|
{ id: 'iso-centred-gate', type: 'gate', x: 0.605, y: 0.12, angle: 0, length: 0.08,
|
|
flip_v: false, host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.65 } },
|
|
{ id: 'iso-flipped-gate', type: 'gate', x: 0.745, y: 0.12, angle: 0, length: 0.08,
|
|
flip_v: true, host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.85 } },
|
|
];
|
|
card._cfgEpoch++;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
// #649 AC11: the alpha switch no longer enables 2.5D and loads nothing.
|
|
history.replaceState(null, '', '#space=f1&hp_alpha=1');
|
|
dispatchEvent(new HashChangeEvent('hashchange'));
|
|
await card.updateComplete;
|
|
await frame();
|
|
result.alphaDoesNotEnableIso = window.__hpAlpha === true && JSON.stringify(window.__hpLabs) === '[]'
|
|
&& !root().querySelector('[data-hp="iso-walls"]') && isoRuntimeRequests() === 0;
|
|
history.replaceState(null, '', '#space=f1&hp_alpha=0');
|
|
dispatchEvent(new HashChangeEvent('hashchange'));
|
|
await card.updateComplete;
|
|
await enable();
|
|
result.settingLoadsIsoRuntimeOnce = isoRuntimeRequests() === 1;
|
|
const openingBases = card._isoSource()?.build().openings || [];
|
|
const basis = (id) => openingBases.find((opening) => opening.id === id);
|
|
const centredDoor = basis('iso-centred-door');
|
|
const flippedWindow = basis('iso-flipped-window');
|
|
const centredGate = basis('iso-centred-gate');
|
|
const flippedGate = basis('iso-flipped-gate');
|
|
const wallAxisY = 0.12 * 1000;
|
|
const samePoint = (a, b) => !!a && !!b
|
|
&& Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
|
|
const leavesUseSelectedFace = (opening) => !!opening
|
|
&& opening.leaves.every((leaf) => samePoint(
|
|
leaf.hinge,
|
|
leaf.leaf === 0 ? opening.face.selectedStart : opening.face.selectedEnd,
|
|
));
|
|
result.isoDoorUsesSelectedHostFace = centredDoor?.leaves.length === 1
|
|
&& leavesUseSelectedFace(centredDoor)
|
|
&& Math.abs(centredDoor.leaves[0].hinge[1] - wallAxisY) > 1e-6;
|
|
result.isoWindowFlipStaysCentred = flippedWindow?.leaves.length === 2
|
|
&& flippedWindow.leaves.every(
|
|
(leaf) => Math.abs(leaf.hinge[1] - wallAxisY) < 1e-6,
|
|
);
|
|
result.isoGateFlipUsesSelectedHostFace = centredGate?.leaves.length === 2
|
|
&& leavesUseSelectedFace(centredGate)
|
|
&& flippedGate?.leaves.length === 2 && leavesUseSelectedFace(flippedGate)
|
|
&& Math.sign(centredGate.face.offset[1]) === -Math.sign(flippedGate.face.offset[1])
|
|
&& centredGate.leaves.every((leaf) => Math.abs(leaf.turnDeg) === 10)
|
|
&& flippedGate.leaves.every((leaf) => Math.abs(leaf.turnDeg) === 10);
|
|
result.isoGateFlipReversesTurn = centredGate?.leaves[0]?.turnDeg
|
|
=== -flippedGate?.leaves[0]?.turnDeg;
|
|
result.settingStored = card._serverCfg.settings?.volumetric_view === true;
|
|
result.noCardToggle = !root().querySelector('[data-hp="projection-toggle"]');
|
|
const device = root().querySelector('[data-hp="device"]');
|
|
const roomLabel = root().querySelector('[data-hp="room-label"]');
|
|
const openingLock = root().querySelector('.oplock');
|
|
const stage = root().querySelector('.stage');
|
|
result.isoRendered = !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
|
|
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
|
|
result.stage4RevisionAdvertised = stage?.getAttribute('data-hp-iso-stage') === '4';
|
|
result.sharedProjectionSnapshot = [
|
|
'.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg', '.iso-overlays-svg',
|
|
]
|
|
.map((selector) => root().querySelector(selector)?.getAttribute('viewBox'))
|
|
.every((value, _index, values) => !!value && value === values[0]);
|
|
const materialDefs = [...root().querySelectorAll('[data-hp-iso-material-def]')];
|
|
const requiredDefs = [
|
|
'hp-iso-wall-side', 'hp-iso-wall-top', 'hp-iso-wall-texture',
|
|
'hp-iso-floor-texture', 'hp-iso-ambient-shadow',
|
|
];
|
|
result.stage4DefinitionsBounded = materialDefs.length >= requiredDefs.length
|
|
&& materialDefs.length <= 16
|
|
&& new Set(materialDefs.map((node) => node.id)).size === materialDefs.length
|
|
&& requiredDefs.every((id) => materialDefs.some((node) => node.id === id));
|
|
const shadowNodes = [...root().querySelectorAll('.iso-ambient-shadow')];
|
|
const fixedLightTransform = card._isoSceneRuntime.isoFixedLightTransform(card._cellCm);
|
|
result.fixedLightVectorShared = shadowNodes.length === 1
|
|
&& shadowNodes[0].getAttribute('transform') === fixedLightTransform
|
|
&& !root().querySelector('.iso-contact-shadow, .iso-leaf-shadow,'
|
|
+ ' #hp-iso-contact-shadow, #hp-iso-leaf-shadow');
|
|
const hasOpeningSurface = (id, surface, material) => !!root().querySelector(
|
|
`[data-hp="iso-openings"] [data-id="${id}"][data-surface="${surface}"].iso-material-${material}`,
|
|
);
|
|
result.stage4OpeningMaterials = [
|
|
['iso-centred-door', 'jamb-reveal', 'reveal'],
|
|
['iso-centred-door', 'leaf-front', 'matte-leaf'],
|
|
['iso-centred-door', 'leaf-back', 'matte-leaf'],
|
|
['iso-centred-door', 'leaf-edge', 'matte-leaf'],
|
|
['iso-centred-door', 'leaf-top', 'matte-leaf'],
|
|
['iso-flipped-window', 'window-frame-side', 'light-frame'],
|
|
['iso-flipped-window', 'window-frame-top', 'light-frame'],
|
|
['iso-flipped-window', 'window-sill', 'light-sill'],
|
|
['iso-flipped-window', 'window-sash-side', 'light-frame'],
|
|
['iso-flipped-window', 'window-sash-bottom', 'light-frame'],
|
|
['iso-flipped-window', 'window-sash-top', 'light-frame'],
|
|
['iso-flipped-window', 'window-glass', 'glass-side'],
|
|
['iso-flipped-window', 'window-glass-top', 'glass-top'],
|
|
['iso-centred-gate', 'jamb-reveal', 'reveal'],
|
|
['iso-centred-gate', 'leaf-front', 'matte-leaf'],
|
|
].every(([id, surface, material]) => hasOpeningSurface(id, surface, material))
|
|
&& !root().querySelector('[data-hp="iso-openings"] .iso-material-dark-glass');
|
|
const raisedRoot = (node, kind) => node?.getAttribute('data-hp-iso-overlay-kind') === kind
|
|
&& node.getAttribute('data-hp-iso-raised') === 'true'
|
|
&& node.getAttribute('data-hp-iso-floor')
|
|
&& node.getAttribute('data-hp-iso-visual')
|
|
&& node.getAttribute('data-hp-iso-floor') !== node.getAttribute('data-hp-iso-visual');
|
|
result.stage4UsesExactLowInteractiveRoots = raisedRoot(device, 'device')
|
|
&& raisedRoot(roomLabel, 'room-label')
|
|
&& raisedRoot(openingLock, 'opening-lock')
|
|
&& !root().querySelector('.iso-overlay-tether, .iso-overlay-ground, #hp-iso-overlay-ground');
|
|
result.raisedFootprintsStayInvisible = !root().querySelector(
|
|
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
|
|
);
|
|
result.noDevicePreference = localStorage.getItem('houseplan_card_view_v1') === null;
|
|
result.anchorsFinite = [device, roomLabel, openingLock].every((node) => node
|
|
&& center(node).every((value) => Number.isFinite(value)));
|
|
card._fitAll();
|
|
for (let guard = 0; card._cameraTransition.active && guard < 90; guard++) await frame();
|
|
const raisedNodes = [...root().querySelectorAll('[data-hp-iso-raised="true"]')]
|
|
.filter((node) => node.closest('.devlayer'));
|
|
const raisedGeometryMatches = (node) => {
|
|
const visual = (node.getAttribute('data-hp-iso-visual') || '').split(',').map(Number);
|
|
const floorPoint = (node.getAttribute('data-hp-iso-floor') || '').split(',').map(Number);
|
|
if (visual.length !== 2 || floorPoint.length !== 2) return false;
|
|
const expected = sceneToClient(visual), actual = center(node);
|
|
return Math.hypot(actual[0] - expected[0], actual[1] - expected[1]) <= 1
|
|
&& Math.hypot(floorPoint[0] - visual[0], floorPoint[1] - visual[1]) > 0;
|
|
};
|
|
result.globalFitContainsRaisedFootprints = raisedNodes.length >= 3
|
|
&& raisedNodes.every((node) => inside(root().querySelector('.stage'), node));
|
|
result.raisedGeometryTracksHtml = raisedNodes.every(raisedGeometryMatches);
|
|
result.raisedTargetsOwn44Pixels = ['device', 'room-label', 'opening-lock'].every((kind) =>
|
|
raisedNodes.filter((node) => node.getAttribute('data-hp-iso-overlay-kind') === kind).some(owns44));
|
|
const overlaySceneSnapshot = () => [...root().querySelectorAll(
|
|
'[data-hp-iso-raised="true"][data-hp-iso-visual]',
|
|
)].map((node) => [
|
|
node.getAttribute('data-hp-iso-overlay-kind'),
|
|
node.getAttribute('data-hp-iso-floor'),
|
|
node.getAttribute('data-hp-iso-visual'),
|
|
].join('|')).sort();
|
|
const overlaysBeforeZoom = overlaySceneSnapshot();
|
|
card._stepZoom(1);
|
|
for (let guard = 0; card._cameraTransition.active && guard < 90; guard++) await frame();
|
|
const overlaysAfterZoomIn = overlaySceneSnapshot();
|
|
card._stepZoom(-1);
|
|
for (let guard = 0; card._cameraTransition.active && guard < 90; guard++) await frame();
|
|
result.desktopZoomKeepsOverlayScene = overlaysBeforeZoom.length >= 3
|
|
&& JSON.stringify(overlaysAfterZoomIn) === JSON.stringify(overlaysBeforeZoom)
|
|
&& JSON.stringify(overlaySceneSnapshot()) === JSON.stringify(overlaysBeforeZoom);
|
|
const deviceBefore = device ? center(device) : null;
|
|
const fingerprintBeforeLive = root().querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
|
|
const geometryBeforeLive = card._isoGeometryCache.get(fingerprintBeforeLive)?.geometry;
|
|
const buildsBeforeLive = Number(stage?.getAttribute('data-hp-iso-structural-builds'));
|
|
const sourceMethod = card._isoSource;
|
|
const sceneMethod = card._isoScene;
|
|
let sourceCalls = 0;
|
|
let sceneCalls = 0;
|
|
card._isoSource = (...args) => { sourceCalls++; return sourceMethod.apply(card, args); };
|
|
card._isoScene = (...args) => { sceneCalls++; return sceneMethod.apply(card, args); };
|
|
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
result.oneStructuralResolvePerFrame = sourceCalls === 1 && sceneCalls === 1
|
|
&& Number(root().querySelector('.stage')?.getAttribute('data-hp-iso-structural-builds'))
|
|
=== buildsBeforeLive;
|
|
card._isoSource = sourceMethod;
|
|
card._isoScene = sceneMethod;
|
|
const deviceAfter = root().querySelector('[data-hp="device"]');
|
|
result.haUpdateKeepsGeometry = root().querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint
|
|
&& card._isoGeometryCache.size === 1;
|
|
result.haUpdateKeepsAnchor = !!deviceBefore && !!deviceAfter
|
|
&& Math.hypot(...center(deviceAfter).map((value, index) => value - deviceBefore[index])) <= 1;
|
|
const room = card._spaceModel('f1')?.rooms?.[0];
|
|
card._hoverRoom = room ? { space: 'f1', room } : null;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
const fingerprintAfterHover = root().querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
|
|
const buildsAfterHover = Number(root().querySelector('.stage')
|
|
?.getAttribute('data-hp-iso-structural-builds'));
|
|
result.haAndHoverReuseStructuralScene = !!fingerprintBeforeLive
|
|
&& fingerprintAfterHover === fingerprintBeforeLive
|
|
&& Number.isFinite(buildsBeforeLive) && buildsAfterHover === buildsBeforeLive
|
|
&& card._isoGeometryCache.get(fingerprintBeforeLive)?.geometry === geometryBeforeLive
|
|
&& card._isoGeometryCache.size === 1;
|
|
card._hoverRoom = null;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
|
|
const originalRoomConfig = configSpace.rooms[0];
|
|
const roomSourceBeforePresentation = card._isoSource();
|
|
const roomGeometryBeforePresentation = card._isoGeometryCache
|
|
.get(roomSourceBeforePresentation.key)?.geometry;
|
|
const buildsBeforeRoomPresentation = Number(root().querySelector('.stage')
|
|
?.getAttribute('data-hp-iso-structural-builds'));
|
|
const presentationRoomConfig = {
|
|
...originalRoomConfig,
|
|
name: `${originalRoomConfig.name} presentation only`,
|
|
area: 'presentation_only_area',
|
|
settings: {
|
|
...(originalRoomConfig.settings || {}),
|
|
fill_mode: 'custom', custom_fill: { c: '#123456', a: 0.4 },
|
|
glow: false, name_scale: 1.7, label_scale: 0.8,
|
|
},
|
|
};
|
|
configSpace.rooms[0] = presentationRoomConfig;
|
|
card._cfgEpoch++;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
const roomSourceAfterPresentation = card._isoSource();
|
|
const buildsAfterRoomPresentation = Number(root().querySelector('.stage')
|
|
?.getAttribute('data-hp-iso-structural-builds'));
|
|
result.presentationRoomUpdateKeepsStructuralCache =
|
|
roomSourceAfterPresentation.key === roomSourceBeforePresentation.key
|
|
&& buildsAfterRoomPresentation === buildsBeforeRoomPresentation
|
|
&& card._isoGeometryCache.get(roomSourceAfterPresentation.key)?.geometry
|
|
=== roomGeometryBeforePresentation;
|
|
|
|
const geometryRoomConfig = {
|
|
...presentationRoomConfig,
|
|
poly: presentationRoomConfig.poly.map((point, index) =>
|
|
index === 1 ? [point[0] + 0.01, point[1]] : [...point]),
|
|
};
|
|
configSpace.rooms[0] = geometryRoomConfig;
|
|
card._cfgEpoch++;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
const roomSourceAfterGeometry = card._isoSource();
|
|
const buildsAfterRoomGeometry = Number(root().querySelector('.stage')
|
|
?.getAttribute('data-hp-iso-structural-builds'));
|
|
result.roomGeometryUpdateInvalidatesStructuralCache =
|
|
roomSourceAfterGeometry.key !== roomSourceAfterPresentation.key
|
|
&& buildsAfterRoomGeometry === buildsAfterRoomPresentation + 1
|
|
&& card._isoGeometryCache.get(roomSourceAfterGeometry.key)?.geometry
|
|
!== roomGeometryBeforePresentation;
|
|
configSpace.rooms[0] = originalRoomConfig;
|
|
card._cfgEpoch++;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
|
|
const roomFitLabel = root().querySelector('.roomlabel[data-id="r1"]');
|
|
roomFitLabel?.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'Enter', bubbles: true, composed: true,
|
|
}));
|
|
for (let guard = 0; card._cameraTransition.active && guard < 90; guard++) await frame();
|
|
await card.updateComplete;
|
|
const ownedEntries = (card._renderIsoScene?.overlayFitEntries || [])
|
|
.filter((entry) => entry.placement.owner?.id === 'r1');
|
|
const ownedRoots = ownedEntries.map((entry) => entry.kind === 'device'
|
|
? root().querySelector(`.dev[data-id="${CSS.escape(entry.id)}"]`)
|
|
: entry.kind === 'room-label'
|
|
? root().querySelector(`.roomlabel[data-id="${CSS.escape(entry.id)}"]`)
|
|
: root().querySelector('.oplock')).filter(Boolean);
|
|
result.roomFitContainsOwnedRaisedFootprints = ownedEntries.length >= 2
|
|
&& ownedRoots.length === ownedEntries.length
|
|
&& ownedRoots.every((node) => inside(root().querySelector('.stage'), node));
|
|
card._fitAll();
|
|
for (let guard = 0; card._cameraTransition.active && guard < 90; guard++) await frame();
|
|
const originalMoreInfo = card._openMoreInfo;
|
|
let moreInfoCalls = 0;
|
|
card._openMoreInfo = () => { moreInfoCalls++; };
|
|
const sensorDevice = root().querySelector('.dev[data-id="d_temp"]');
|
|
sensorDevice?.dispatchEvent(new MouseEvent('contextmenu', {
|
|
bubbles: true, composed: true, button: 2,
|
|
}));
|
|
sensorDevice?.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'Enter', bubbles: true, composed: true,
|
|
}));
|
|
const keyboardOpenedDeviceCard = card._infoCard?.id === 'd_temp';
|
|
card._closeInfoCard();
|
|
card._openMoreInfo = originalMoreInfo;
|
|
const areaLink = root().querySelector('.roomlabel[data-id="r1"] .rlgo');
|
|
areaLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
|
const areaPath = location.pathname;
|
|
history.replaceState(null, '', '/#space=f1');
|
|
const lockRoot = root().querySelector('.oplock');
|
|
lockRoot?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
|
result.raisedActionsRemainOwned = moreInfoCalls === 1 && keyboardOpenedDeviceCard
|
|
&& areaPath === '/config/areas/area/living_room'
|
|
&& card._openingInfo?.id === 'iso-centred-door';
|
|
card._openingInfo = null;
|
|
|
|
card._setMode('plan');
|
|
await card.updateComplete;
|
|
while (card._modeTransitionBusy) await frame();
|
|
result.editorIsFlat = card._mode === 'plan'
|
|
&& !root().querySelector('[data-hp="projection-toggle"]')
|
|
&& !root().querySelector('[data-hp="iso-walls"]')
|
|
&& ![...root().querySelector('.stage').classList]
|
|
.some((name) => name.startsWith('projection-'));
|
|
card._setMode('view');
|
|
await card.updateComplete;
|
|
while (card._modeTransitionBusy) await frame();
|
|
result.viewRestoresIso = !!root().querySelector('.stage.projection-iso')
|
|
&& !!root().querySelector('[data-hp="iso-walls"]');
|
|
|
|
const nativeCssSupports = CSS.supports;
|
|
const fallbackCountBeforeCapabilityProbe = card._isoFallback.size;
|
|
CSS.supports = () => { throw new Error('injected decoration capability failure'); };
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
result.throwingCapabilityProbeStaysSolidIso = card._effectiveProjection() === 'iso'
|
|
&& card._isoFallback.size === fallbackCountBeforeCapabilityProbe
|
|
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top')
|
|
&& !!root().querySelector('[data-hp="iso-openings"] .iso-opening-panel')
|
|
&& !root().querySelector('.iso-overlay-plate, .iso-overlay-plate-texture')
|
|
&& !root().querySelector('.iso-overlay-tether, .iso-overlay-ground, #hp-iso-overlay-ground')
|
|
&& !root().querySelector('[data-hp-iso-material-def]')
|
|
&& !root().querySelector('.iso-ambient-shadow');
|
|
CSS.supports = nativeCssSupports;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
result.capabilityProbeRecoveryRestoresNuance = card._effectiveProjection() === 'iso'
|
|
&& !!root().querySelector('[data-hp-iso-material-def]')
|
|
&& card._isoFallback.size === fallbackCountBeforeCapabilityProbe;
|
|
|
|
const healthySource = card._isoSource;
|
|
let invalidAttempts = 0;
|
|
card._view = { x: 123456, y: 234567, w: 3456, h: 4567 };
|
|
card._viewModeSnap = { mode: 'view', viewBox: { ...card._view }, zoom: 1 };
|
|
card._isoSource = () => {
|
|
invalidAttempts++;
|
|
throw new Error('invalid source sentinel sensor.private_identifier https://secret.invalid');
|
|
};
|
|
const firstInvalidKey = card._isoInvalidKey();
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
|
await card.updateComplete;
|
|
const flatView = (root().querySelector('[data-hp-live-viewbox="floor"]')
|
|
?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
|
result.invalidSourceFallbackLatchedAndRefit = invalidAttempts === 1
|
|
&& card._isoFallback.has(firstInvalidKey) && card._viewModeSnap === null
|
|
&& flatView.length === 4 && flatView.every((value) => Number.isFinite(value) && Math.abs(value) < 10000);
|
|
const fallbackFlatCamera = { x: 80, y: 90, w: 700, h: 620 };
|
|
card._view = { ...fallbackFlatCamera };
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
result.latchedFallbackKeepsFlatCamera = JSON.stringify(card._view) === JSON.stringify(fallbackFlatCamera);
|
|
card._isoSource = healthySource;
|
|
await window.__hpTest.setVolumetricView(false);
|
|
await window.__hpTest.setVolumetricView(true);
|
|
rebind();
|
|
result.invalidSourceExplicitRetryRestoresIso = !card._isoFallback.has(firstInvalidKey)
|
|
&& !!root().querySelector('[data-hp="iso-walls"]');
|
|
|
|
const sourceKeyBeforeEpochRecovery = card._isoSource()?.key;
|
|
const originalSmokeWall = configSpace.partitions[0];
|
|
card._isoSource = () => {
|
|
invalidAttempts++;
|
|
throw new Error('second invalid source must recover after geometry changes');
|
|
};
|
|
const epochInvalidKey = card._isoInvalidKey();
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
|
await card.updateComplete;
|
|
card._isoSource = healthySource;
|
|
configSpace.partitions[0] = { ...originalSmokeWall, cm: originalSmokeWall.cm + 1 };
|
|
card._cfgEpoch++;
|
|
const nextEpochInvalidKey = card._isoInvalidKey();
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
const sourceKeyAfterEpochRecovery = card._isoSource()?.key;
|
|
result.invalidSourceNewFingerprintRestoresIso = invalidAttempts === 2
|
|
&& sourceKeyAfterEpochRecovery !== sourceKeyBeforeEpochRecovery
|
|
&& !card._isoFallback.has(epochInvalidKey)
|
|
&& !card._isoFallback.has(nextEpochInvalidKey)
|
|
&& card._effectiveProjection() === 'iso'
|
|
&& !!root().querySelector('[data-hp="iso-walls"]');
|
|
configSpace.partitions[0] = originalSmokeWall;
|
|
card._cfgEpoch++;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
|
|
const source = card._isoSource;
|
|
let attempts = 0;
|
|
let shouldFail = true;
|
|
const fallbackKey = 'f1|injected-render-failure';
|
|
card._isoGeometryCache.clear();
|
|
card._isoSource = () => ({
|
|
key: fallbackKey,
|
|
build: () => {
|
|
attempts++;
|
|
if (shouldFail) throw new Error('injected isometric failure');
|
|
return { walls: [], floor: [], openings: [], openingSurfaces: [] };
|
|
},
|
|
});
|
|
card._view = { x: 123456, y: 234567, w: 3456, h: 4567 };
|
|
card._viewModeSnap = { mode: 'view', viewBox: { ...card._view }, zoom: 1 };
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
|
await card.updateComplete;
|
|
result.fallbackLatched = attempts === 1
|
|
&& card._viewModeSnap === null
|
|
&& !root().querySelector('.stage.projection-iso')
|
|
&& !root().querySelector('[data-hp="iso-walls"]')
|
|
&& card._serverCfg.settings?.volumetric_view === true;
|
|
shouldFail = false;
|
|
await window.__hpTest.setVolumetricView(false);
|
|
await window.__hpTest.setVolumetricView(true);
|
|
rebind();
|
|
result.explicitRetryRestoresIso = attempts >= 2
|
|
&& !!root().querySelector('[data-hp="iso-walls"]')
|
|
&& !!root().querySelector('.stage.projection-iso');
|
|
card._isoSource = source;
|
|
card._isoGeometryCache.delete(fallbackKey);
|
|
card._isoFallback.delete(fallbackKey);
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
|
|
const runtime = card._isoSceneRuntime;
|
|
const warning = console.warn;
|
|
let lateAttempts = 0;
|
|
const fallbackWarnings = [];
|
|
const isoDiagnostics = [];
|
|
const diagnosticText = (args) => args.map((value) => {
|
|
if (value instanceof Error) return `${value.name}:${value.message}:${value.stack}`;
|
|
if (value && typeof value === 'object') return JSON.stringify(value, (_key, item) =>
|
|
item instanceof Error ? { name: item.name, message: item.message, stack: item.stack } : item);
|
|
return String(value);
|
|
}).join(' ');
|
|
console.warn = (...args) => {
|
|
if (/HOUSEPLAN ISO FALLBACK|hidden isometric runtime diagnostic/.test(String(args[0]))) {
|
|
const text = diagnosticText(args);
|
|
isoDiagnostics.push(text);
|
|
if (String(args[0]).includes('HOUSEPLAN ISO FALLBACK')) fallbackWarnings.push(text);
|
|
}
|
|
else warning(...args);
|
|
};
|
|
const privateSpace = 'space.private_identifier';
|
|
const privateAsset = 'https://private.invalid/iso.js?token=private-query';
|
|
const currentSpace = card._space;
|
|
card._space = privateSpace;
|
|
const privateKey = `${privateSpace}|${privateAsset}`;
|
|
card._latchIsoFallback(privateKey, new Error(`sensor.private_identifier ${privateAsset}`));
|
|
card._space = currentSpace;
|
|
card._isoFallback.delete(privateKey);
|
|
card._isoSceneRuntimeLoader.options.failed(
|
|
new Error(`${privateAsset}\nprivate stack sensor.private_identifier`), { terminal: false },
|
|
);
|
|
await card.updateComplete;
|
|
result.isoDiagnosticsAreRedacted = isoDiagnostics.length === 2
|
|
&& isoDiagnostics.every((text) => /"kind":|kind=/.test(text)
|
|
&& /"fingerprint":|fingerprint=/.test(text) && /"terminal":|terminal=/.test(text))
|
|
&& !isoDiagnostics.some((text) => /private_identifier|private\.invalid|sensor\.|token=|stack/i.test(text));
|
|
isoDiagnostics.length = 0;
|
|
fallbackWarnings.length = 0;
|
|
card._stepZoom(1);
|
|
const lateTransitionStarted = card._cameraTransition.active;
|
|
card._viewModeSnap = { mode: 'view', viewBox: { ...card._view }, zoom: card._zoom };
|
|
card._isoSceneRuntime = {
|
|
...runtime,
|
|
buildIsoOverlayRenderScene: () => {
|
|
lateAttempts++;
|
|
throw new Error('entity sensor.private_identifier must not reach diagnostics');
|
|
},
|
|
};
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
|
await card.updateComplete;
|
|
result.lateFailureIsRedactedAndLatched = lateTransitionStarted && !card._cameraTransition.active
|
|
&& card._viewModeSnap === null && lateAttempts === 1 && fallbackWarnings.length === 1
|
|
&& /"kind":"structural"/.test(fallbackWarnings[0])
|
|
&& /"fingerprint":"[a-z0-9]+"/.test(fallbackWarnings[0])
|
|
&& /"terminal":true/.test(fallbackWarnings[0])
|
|
&& !/(private_identifier|sensor\.|https?:|token=|stack)/i.test(fallbackWarnings[0])
|
|
&& !root().querySelector('[data-hp="iso-walls"]')
|
|
&& card._serverCfg.settings?.volumetric_view === true;
|
|
card._isoSceneRuntime = runtime;
|
|
await window.__hpTest.setVolumetricView(false);
|
|
await window.__hpTest.setVolumetricView(true);
|
|
rebind();
|
|
|
|
result.lateFailureExplicitRetryRestoresIso = !!root().querySelector('[data-hp="iso-walls"]');
|
|
console.warn = warning;
|
|
|
|
await window.__hpTest.setVolumetricView(false);
|
|
await frame();
|
|
result.removalIsImmediateFlat = card._serverCfg.settings?.volumetric_view === false
|
|
&& !root().querySelector('[data-hp="projection-toggle"]')
|
|
&& !root().querySelector('[data-hp="iso-walls"]')
|
|
&& !root().querySelector('[id^="hp-iso-"]')
|
|
&& ![...root().querySelector('.stage').classList]
|
|
.some((name) => name.startsWith('projection-'));
|
|
await enable();
|
|
result.reenableRestoresIso = !!root().querySelector('.stage.projection-iso')
|
|
&& !!root().querySelector('[data-hp="iso-walls"]');
|
|
return result;
|
|
});
|
|
|
|
checkAll(out);
|
|
await finish(browser, out);
|