mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Treat explicit empty route lists as authoritative, preserve them through single-space export, group deleted-space routes, and render vacuums from the immutable vacuum-only snapshot subset. Issue: #443 User-Visible: yes
199 lines
8.4 KiB
JavaScript
199 lines
8.4 KiB
JavaScript
// #441: adding a second vacuum map is a UI draft, never an invalid route with
|
|
// `space: ''`. The same smoke covers both add entry points and rejected saves.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
const { page, browser } = await launch();
|
|
|
|
const out = await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
const o = {};
|
|
const sr = () => c.shadowRoot || c.renderRoot;
|
|
const waitFor = async (read, timeout = 2000) => {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeout) {
|
|
const value = read();
|
|
if (value) return value;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
return null;
|
|
};
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
c._saveConfigDebounced?.flush?.();
|
|
await c._writeChain;
|
|
|
|
const originalCallWS = c.hass.callWS;
|
|
c.hass = {
|
|
...c.hass,
|
|
entities: {
|
|
...c.hass.entities,
|
|
'camera.mower_map': {
|
|
entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo', disabled_by: null,
|
|
},
|
|
'camera.mower_upstairs': {
|
|
entity_id: 'camera.mower_upstairs', device_id: 'd_mower', platform: 'demo', disabled_by: null,
|
|
},
|
|
},
|
|
states: {
|
|
...c.hass.states,
|
|
'camera.mower_map': {
|
|
state: 'idle', attributes: {
|
|
friendly_name: 'Main map', map_name: 'm1', vacuum_position: { x: 1, y: 2 },
|
|
},
|
|
},
|
|
'camera.mower_upstairs': {
|
|
state: 'idle', attributes: {
|
|
friendly_name: 'Upstairs map', map_name: 'm2', vacuum_position: { x: 1, y: 2 },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
c._serverCfg.markers = [{
|
|
id: 'd_mower', binding: 'device:d_mower', space: 'garden', area: 'garden',
|
|
vacuum: { source: 'camera.mower_map', map_routes: [] },
|
|
}];
|
|
c._regSignature = '';
|
|
c._maybeRebuildDevices();
|
|
c._setMode('devices');
|
|
await c.updateComplete;
|
|
c._openMarkerDialog(c._devices.find((device) => device.id === 'd_mower'));
|
|
await c.updateComplete;
|
|
|
|
const routes = () => c._serverCfg.markers.find((marker) => marker.id === 'd_mower')
|
|
?.vacuum?.map_routes || [];
|
|
const addCurrent = () => sr().querySelector('.vacroute-add-current');
|
|
const pending = () => sr().querySelector('.vacroute.pending');
|
|
|
|
// The first route is a draft too, but its only safe default is the dock floor.
|
|
addCurrent()?.click();
|
|
await c.updateComplete;
|
|
o.firstRoutePreselectsDock = pending()?.querySelector('.vacroute-draft-space')?.value === 'garden';
|
|
o.firstDraftDoesNotWrite = routes().length === 0;
|
|
pending()?.querySelector('button.ghostbtn')?.click();
|
|
await c.updateComplete;
|
|
|
|
// Start with one accepted route, then exercise the separate-source entry.
|
|
c._serverCfg.markers[0].vacuum.map_routes = [{
|
|
id: 'r1', source: 'camera.mower_map', map_id: 'm1', space: 'garden', calibration: null,
|
|
}];
|
|
c._regSignature = '';
|
|
c._maybeRebuildDevices();
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
const sourceDetails = sr().querySelector('details.vacroute-sources');
|
|
if (sourceDetails) sourceDetails.open = true;
|
|
await c.updateComplete;
|
|
[...(sourceDetails?.querySelectorAll('button.vacsource') || [])]
|
|
.find((button) => button.textContent.includes('camera.mower_upstairs'))?.click();
|
|
await c.updateComplete;
|
|
o.separateSourceStartsBlank = pending()?.querySelector('.vacroute-draft-space')?.value === '';
|
|
o.blankDraftCannotSave = pending()?.querySelector('.vacroute-draft-save')?.disabled === true;
|
|
o.separateSourceDoesNotWrite = routes().length === 1;
|
|
pending()?.querySelector('button.ghostbtn')?.click();
|
|
await c.updateComplete;
|
|
o.cancelLeavesAcceptedRoutes = !pending() && routes().length === 1;
|
|
|
|
// The same source reports another map. Confirm once against a rejecting
|
|
// backend, then retry successfully without reopening the dialog.
|
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
|
'camera.mower_map': {
|
|
state: 'idle', attributes: {
|
|
friendly_name: 'Main map', map_name: 'm2', vacuum_position: { x: 1, y: 2 },
|
|
},
|
|
},
|
|
} };
|
|
c._regSignature = '';
|
|
c._maybeRebuildDevices();
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
addCurrent()?.click();
|
|
await c.updateComplete;
|
|
o.secondCurrentStartsBlank = pending()?.querySelector('.vacroute-draft-space')?.value === '';
|
|
const select = pending()?.querySelector('.vacroute-draft-space');
|
|
if (select) {
|
|
select.value = 'f1';
|
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
await c.updateComplete;
|
|
o.validFloorEnablesSave = pending()?.querySelector('.vacroute-draft-save')?.disabled === false;
|
|
|
|
const writes = [];
|
|
let rejectNext = true;
|
|
c.hass = { ...c.hass, callWS: async (message) => {
|
|
if (message.type === 'houseplan/config/set') {
|
|
writes.push(message);
|
|
if (rejectNext) {
|
|
rejectNext = false;
|
|
throw new Error('synthetic route rejection');
|
|
}
|
|
}
|
|
return originalCallWS(message);
|
|
} };
|
|
pending()?.querySelector('.vacroute-draft-save')?.click();
|
|
await waitFor(() => writes.length === 1 && pending()?.querySelector('.vacroute-draft-save')?.disabled === false);
|
|
o.rejectionRestoresAcceptedMarker = routes().length === 1 && routes()[0].id === 'r1';
|
|
o.rejectionKeepsEditorUsable = !!pending()
|
|
&& pending().querySelector('.vacroute-draft-space')?.value === 'f1';
|
|
o.rejectedPayloadWasValid = writes[0]?.config?.markers
|
|
?.find((marker) => marker.id === 'd_mower')?.vacuum?.map_routes
|
|
?.every((route) => !!route.space) === true;
|
|
|
|
pending()?.querySelector('.vacroute-draft-save')?.click();
|
|
await waitFor(() => routes().length === 2 && !pending());
|
|
const added = routes().find((route) => route.map_id === 'm2');
|
|
o.retryAddsExactlyOneRoute = routes().length === 2;
|
|
o.retryKeepsExactIdentity = added?.source === 'camera.mower_map'
|
|
&& added?.space === 'f1' && added?.calibration === null;
|
|
o.noEmptySpaceEverSubmitted = writes.every((write) => write.config.markers
|
|
.find((marker) => marker.id === 'd_mower').vacuum.map_routes
|
|
.every((route) => !!route.space));
|
|
|
|
// #443: valid routes keep the space order, while every dead-space route is
|
|
// collected under one explicit, operable and deterministic group.
|
|
c._config = { ...c._config, language: 'en' };
|
|
c._serverCfg.markers[0].vacuum.map_routes = [
|
|
{ id: 'missing-z', source: 'camera.z', map_id: 'zeta', space: 'gone-z', calibration: null },
|
|
{ id: 'valid-f1', source: 'camera.valid', map_id: 'valid', space: 'f1', calibration: null },
|
|
{ id: 'missing-b', source: 'camera.b', map_id: 'alpha', space: 'gone-b', calibration: null },
|
|
{ id: 'missing-a', source: 'camera.a', map_id: 'alpha', space: 'gone-a', calibration: null },
|
|
];
|
|
c._regSignature = '';
|
|
c._maybeRebuildDevices();
|
|
c._openMarkerDialog(c._devices.find((device) => device.id === 'd_mower'));
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
const missingGroups = [...sr().querySelectorAll('[data-hp="vacuum-route-missing-group"]')];
|
|
const missingGroup = missingGroups[0];
|
|
const missingIds = [...(missingGroup?.querySelectorAll('.vacroute') || [])]
|
|
.map((row) => row.dataset.routeId);
|
|
const validIds = [...sr().querySelectorAll('.vacroute')]
|
|
.filter((row) => !row.closest('.vacroute-missing-group'))
|
|
.map((row) => row.dataset.routeId);
|
|
o.oneExplicitMissingSpaceGroup = missingGroups.length === 1;
|
|
o.missingSpaceGroupIsLabelled = missingGroup?.querySelector('.vacroute-group-title')
|
|
?.textContent.trim() === 'Deleted space';
|
|
o.validRowsStayBeforeMissingGroup = validIds.join(',') === 'valid-f1';
|
|
o.missingRowsHaveStableIdentityOrder = missingIds.join(',')
|
|
=== 'missing-a,missing-b,missing-z';
|
|
o.missingRowsRemainOperable = [...(missingGroup?.querySelectorAll('.vacroute') || [])]
|
|
.every((row) => row.querySelector('select') && row.querySelectorAll('button').length === 2);
|
|
o.missingSpaceWarningRemains = !!sr().querySelector('.warn');
|
|
const groupThemeStates = [];
|
|
for (const darkMode of [false, true]) {
|
|
c.hass = { ...c.hass, themes: { ...(c.hass.themes || {}), darkMode } };
|
|
await c.updateComplete;
|
|
const themedGroup = sr().querySelector('[data-hp="vacuum-route-missing-group"]');
|
|
const themedTitle = themedGroup?.querySelector('.vacroute-group-title');
|
|
groupThemeStates.push(!!themedGroup && !!themedTitle
|
|
&& getComputedStyle(themedGroup).display !== 'none'
|
|
&& getComputedStyle(themedTitle).visibility === 'visible'
|
|
&& themedTitle.textContent.trim() === 'Deleted space');
|
|
}
|
|
o.missingSpaceGroupReadableInLightAndDark = groupThemeStates.every(Boolean);
|
|
|
|
c.hass = { ...c.hass, callWS: originalCallWS };
|
|
return o;
|
|
});
|
|
|
|
checkAll(out);
|
|
await finish(browser, out);
|