diff --git a/demo/golden/baselines/baselines-index.json b/demo/golden/baselines/baselines-index.json index c6103099..95fcec0f 100644 --- a/demo/golden/baselines/baselines-index.json +++ b/demo/golden/baselines/baselines-index.json @@ -1,11 +1,12 @@ { "schema": 1, "matrixVersion": 54, - "acceptedAt": "2026-09-04T13:10:56.492Z", - "sourceFingerprint": "7acb1b9d3bb384bc1a0c70fb01347386c157ae88b12b00ccd876b7658fc84a1c", + "acceptedAt": "2026-09-05T06:21:56.683Z", + "sourceFingerprint": "814851e1e157fd126309de061c720fb1203ddd6d63824cd37289542baefca406", "chromium": "151.0.7922.34", + "platform": "linux", "witnesses": { - "count": 127, + "count": 134, "floor": 10 }, "scenarios": { @@ -152,7 +153,7 @@ "support-relay-error-light-en": "e9a9ad906e106eedfa6e84c592325f1604eb64c89c51135c076ac87a6a0766b2", "support-tablet-preview-dark-ru": "1264e005cd764408edb9b285a5dfbe92ceb90cb925f624b8f65cd47edb96c0c9", "device-ripple-color-popover-mobile-ru": "d9ebf83ad0d2995b46f6174daf6e21ca5404952afa308ddd3a66ecefcee565d0", - "space-room-color-popover-desktop-ru": "9bbbcf9c5ea6ff5490b500be8cb3086af9d368d14ca50c304f42a697e296ece1", + "space-room-color-popover-desktop-ru": "04bf325ddb449001f6c0b459761dcb84eda6432dca2fbd10ce5a129484d0756c", "backup-full-preview-desktop-en": "0fbd23a92121668001faf5c9ed555a31107704f3074d1791709c8436517d3d1f", "backup-plan-only-export-desktop-en": "ce66fb57edbc87d56355b0d73a2d4203a67c275d5927d400ef1758ac0b607b34", "backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad", diff --git a/demo/golden/baselines/space-room-color-popover-desktop-ru.png b/demo/golden/baselines/space-room-color-popover-desktop-ru.png index b1711b60..df337c55 100644 Binary files a/demo/golden/baselines/space-room-color-popover-desktop-ru.png and b/demo/golden/baselines/space-room-color-popover-desktop-ru.png differ diff --git a/demo/smoke_space_copy.mjs b/demo/smoke_space_copy.mjs new file mode 100644 index 00000000..0b62a761 --- /dev/null +++ b/demo/smoke_space_copy.mjs @@ -0,0 +1,141 @@ +// #456: Copy is a real two-write workflow when the plan needs Optimize and a +// one-write workflow when it does not. Exercise the production bundle through +// the visible dialogs while delegating every request to the demo backend. +import { launch, checkAll, finish } from './serve.mjs'; + +const { page, browser } = await launch({ width: 820, height: 780 }); +const out = await page.evaluate(async () => { + const result = {}; + const card = window.__card; + const root = () => card.shadowRoot || card.renderRoot; + const clone = (value) => structuredClone(value); + const settle = async () => { + await card.updateComplete; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; + const waitFor = async (predicate, label) => { + for (let attempt = 0; attempt < 120; attempt++) { + await settle(); + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`space-copy smoke timed out: ${label}`); + }; + const currentDialog = () => root().querySelector('hp-dialog'); + const buttonWithIcon = (dialog, icon) => [...(dialog?.querySelectorAll('button') || [])] + .find((button) => button.querySelector(`ha-icon[icon="${icon}"]`)); + const openCopy = async () => { + card._openSpaceDialog('edit', 'f1'); + await settle(); + const settingsDialog = currentDialog(); + const button = buttonWithIcon(settingsDialog, 'mdi:content-copy'); + const outsideDanger = !!button && !button.closest('.dialog-action-danger'); + button?.click(); + await waitFor(() => !!card._spaceDialog?.copy, 'copy name dialog'); + const copyDialog = currentDialog(); + return { outsideDanger, copyDialog }; + }; + const nameCopy = async (dialog, title) => { + const input = dialog?.querySelector('#space-copy-name'); + if (input) { + input.value = title; + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + } + await settle(); + return input; + }; + const submitCopy = (dialog) => buttonWithIcon(dialog, 'mdi:content-copy')?.click(); + const confirmUi = () => { + const component = root().querySelector('hp-confirm'); + const dialog = component?.querySelector('hp-dialog'); + return { + component, + dialog, + buttons: [...(dialog?.querySelectorAll('.danger-confirm-footer button') || [])], + }; + }; + + const baseCall = card.hass.callWS.bind(card.hass); + const calls = []; + card.hass = { + ...card.hass, + callWS: async (message) => { + calls.push(clone(message)); + return baseCall(message); + }, + }; + await settle(); + + // The demo's original f1 needs Optimize. Cancel must leave the name dialog + // intact and must not make either durable write. + const cancelStart = calls.length; + const cancel = await openCopy(); + const proposedName = cancel.copyDialog?.querySelector('#space-copy-name')?.value || ''; + await nameCopy(cancel.copyDialog, 'Cancelled copy'); + submitCopy(cancel.copyDialog); + await waitFor(() => confirmUi().buttons.length === 2, 'Optimize warning for Cancel'); + const cancelWarning = confirmUi(); + const warningText = cancelWarning.dialog?.textContent || ''; + cancelWarning.buttons[0].click(); + await waitFor(() => !confirmUi().component && card._spaceDialog?.copy?.busy === false, + 'Cancel returns to name'); + const cancelWrites = calls.slice(cancelStart) + .filter((call) => call.type === 'houseplan/plan/optimize' + || call.type === 'houseplan/config/set'); + result.copyButtonIsNeutralAndDefaultNameIsNumbered = cancel.outsideDanger + && proposedName.endsWith(' (2)'); + result.optimizeWarningNamesTheWholePlan = warningText.includes('entire plan') + && warningText.includes('including other spaces'); + result.optimizeCancelMakesZeroWritesAndKeepsName = cancelWrites.length === 0 + && card._spaceDialog?.copy?.title === 'Cancelled copy'; + card._spaceDialog = null; + await settle(); + + // Accept uses the real demo backend in the promised order and enters the + // roomless copy after both writes succeed. + const acceptStart = calls.length; + const accepted = await openCopy(); + await nameCopy(accepted.copyDialog, 'Optimized copy'); + submitCopy(accepted.copyDialog); + await waitFor(() => confirmUi().buttons.length === 2, 'Optimize warning for Accept'); + confirmUi().buttons[1].click(); + await waitFor(() => card._spaceDialog === null && card._space !== 'f1', + 'accepted copy transition'); + const acceptedCalls = calls.slice(acceptStart) + .filter((call) => call.type === 'houseplan/plan/optimize' + || call.type === 'houseplan/config/set'); + const optimizedCopy = card._serverCfg.spaces.find((space) => space.title === 'Optimized copy'); + result.optimizeAcceptWritesInOrder = acceptedCalls.length === 2 + && acceptedCalls[0].type === 'houseplan/plan/optimize' + && acceptedCalls[1].type === 'houseplan/config/set'; + result.acceptCreatesAndEntersRoomlessCopy = !!optimizedCopy + && optimizedCopy.id === card._space + && optimizedCopy.rooms?.length === 0 + && optimizedCopy.wall_segments?.length === 0 + && optimizedCopy.partitions?.length > 0 + && card._mode === 'plan' && card._tool === 'draw'; + + // The accepted config is now optimized. Submitting a second name is itself + // the only confirmation and produces exactly one config write. + card._commitSpace('f1', true); + await settle(); + const cleanStart = calls.length; + const clean = await openCopy(); + await nameCopy(clean.copyDialog, 'Clean copy'); + submitCopy(clean.copyDialog); + await waitFor(() => card._spaceDialog === null && card._space !== 'f1', + 'clean copy transition'); + const cleanCalls = calls.slice(cleanStart) + .filter((call) => call.type === 'houseplan/plan/optimize' + || call.type === 'houseplan/config/set'); + const cleanCopy = card._serverCfg.spaces.find((space) => space.title === 'Clean copy'); + result.cleanCopyNeedsNoExtraConfirmation = !confirmUi().component + && cleanCalls.length === 1 && cleanCalls[0].type === 'houseplan/config/set'; + result.cleanCopyIsInsertedAfterSourceAndSelected = !!cleanCopy + && card._serverCfg.spaces[1]?.id === cleanCopy.id + && card._space === cleanCopy.id; + return result; +}); + +checkAll(out); +await finish(browser, out);