// #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);