mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
- ru/de/fr трёх словарей пространств — девять ленивых чанков (по одному на пару «пространство × язык»), английский статически как слой отката; рантайм — тот же LanguageRuntime (две попытки, отпечаток сборки, fallback, общий тост отказа), src/i18n/namespace-language.ts. - Загрузчики рантаймов онбординга и редактора отдают поверхность в рендер только после того, как словарь текущего языка осел; гейт хоста учитывает словари загруженной на нём поверхности (смена языка на лету — ветка warm). Оверлей Zigbee ждёт topology до первой отрисовки. - bundle-manifest: граф lazyNamespaceLocaleFiles, девять retry-токенов со строгим счётом «ровно один». bundle-budget: потолок онбординга 28 500 (замер 27 470), редактор 245 400 → 228 900 (замер 227 869), проверки ленивости и владения по содержимому. - Смок smoke_lazy_admin_locale (AC4–AC7); регресс-смоки, переключавшие язык синхронно, ждут гейт вместо ослабления проверок; пять мутантов. - monolith-baseline bundleBytes 2 500 387 → 2 508 676: +10 чанков (rollup intro и export на каждый) и записи манифеста; gzip каждой поверхности при этом уменьшился. Issue: #627 User-Visible: no
596 lines
24 KiB
JavaScript
596 lines
24 KiB
JavaScript
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launch({ width: 960, height: 640 });
|
|
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
const root = card?.shadowRoot || card?.renderRoot;
|
|
return !!root?.querySelector('.summary-control');
|
|
});
|
|
|
|
const initial = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const root = () => card.shadowRoot || card.renderRoot;
|
|
const stageBefore = root().querySelector('.stage').getBoundingClientRect();
|
|
const buttons = root().querySelectorAll('.summary-control button');
|
|
buttons[1].click();
|
|
await card.updateComplete;
|
|
// Static containment is measured after entry. The focused #505 smoke
|
|
// separately samples opacity/translation and retained DOM during motion.
|
|
await new Promise((resolve) => setTimeout(resolve, 320));
|
|
const overlay = root().querySelector('.summary-overlay');
|
|
const stageAfter = root().querySelector('.stage').getBoundingClientRect();
|
|
const box = overlay?.getBoundingClientRect();
|
|
return {
|
|
splitControl: buttons.length === 2,
|
|
togglePersistsIntent: buttons[1].getAttribute('aria-pressed') === 'true',
|
|
rightOnWideStage: overlay?.classList.contains('right') === true,
|
|
overlayDoesNotResizeStage: Math.abs(stageBefore.width - stageAfter.width) < 0.5
|
|
&& Math.abs(stageBefore.height - stageAfter.height) < 0.5,
|
|
overlayInsideStage: !!box && box.left >= stageAfter.left - 1 && box.right <= stageAfter.right + 1
|
|
&& box.top >= stageAfter.top - 1 && box.bottom <= stageAfter.bottom + 1,
|
|
readOnlySurface: !!overlay && overlay.querySelectorAll('button, input, select, textarea').length === 0,
|
|
hasDefaultRows: (overlay?.querySelectorAll('.summary-value').length || 0) === 3,
|
|
};
|
|
});
|
|
|
|
// The stage respects the viewport's available height; an inline 900px height
|
|
// cannot force it beyond the viewport. Keep the viewport landscape while
|
|
// giving the deliberately narrower stage enough real height to be portrait.
|
|
await page.setViewportSize({ width: 1200, height: 1000 });
|
|
await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
document.querySelector('#host').style.width = '560px';
|
|
root.querySelector('.stage').style.height = '900px';
|
|
card._summary.resized();
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
const root = card?.shadowRoot || card?.renderRoot;
|
|
return root?.querySelector('.summary-overlay')?.classList.contains('bottom') === true;
|
|
});
|
|
const bottomOnTallStage = await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const stage = root.querySelector('.stage').getBoundingClientRect();
|
|
const overlay = root.querySelector('.summary-overlay').getBoundingClientRect();
|
|
return Math.abs((overlay.left + overlay.right) / 2 - (stage.left + stage.right) / 2) < 2;
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
document.querySelector('#host').style.width = '270px';
|
|
root.querySelector('.stage').style.height = '500px';
|
|
card._summary.resized();
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
const root = card?.shadowRoot || card?.renderRoot;
|
|
return !root?.querySelector('.summary-overlay')
|
|
&& root?.querySelector('.summary-control button:last-child')?.getAttribute('aria-pressed') === 'true';
|
|
});
|
|
const smallCardKeepsLocalIntent = await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const toggle = root.querySelector('.summary-control button:last-child');
|
|
return toggle?.title === card._summary?.['t']?.('summary.hidden_small')
|
|
|| /space|мест|Platz|espace/i.test(toggle?.title || '');
|
|
});
|
|
|
|
await page.setViewportSize({ width: 960, height: 640 });
|
|
await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
document.querySelector('#host').style.width = '780px';
|
|
root.querySelector('.stage').style.height = '640px';
|
|
card._summary.resized();
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
const root = card?.shadowRoot || card?.renderRoot;
|
|
return root?.querySelector('.summary-overlay.right');
|
|
});
|
|
|
|
const pickerWitnessSetup = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const states = {};
|
|
for (let index = 0; index < 10_000; index++) {
|
|
const id = `sensor.summary_${String(index).padStart(5, '0')}`;
|
|
states[id] = {
|
|
entity_id: id, state: String(index),
|
|
attributes: { friendly_name: `Summary reading ${index}` },
|
|
};
|
|
}
|
|
const blocks = Array.from({ length: 10 }, (_, blockIndex) => ({
|
|
id: `block-${blockIndex}`, title: `Block ${blockIndex}`, visible: true,
|
|
scope: { type: 'all' },
|
|
values: Array.from({ length: 20 }, (_, valueIndex) => {
|
|
const index = blockIndex * 20 + valueIndex;
|
|
return {
|
|
id: `value-${index}`, label: `Value ${index}`,
|
|
source: { type: 'entity', entity_id: `sensor.summary_${String(index).padStart(5, '0')}` },
|
|
};
|
|
}),
|
|
}));
|
|
card._serverCfg = {
|
|
...card._serverCfg,
|
|
settings: { ...(card._serverCfg.settings || {}), summary_panel: {
|
|
version: 1, title: 'Picker witness', show_on_mobile: true, blocks,
|
|
} },
|
|
};
|
|
card._settings = card._serverCfg.settings;
|
|
card.hass = { ...card.hass, states };
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
card._haSummaryPanelApi = 1;
|
|
await card._summary.openDialog();
|
|
await card.updateComplete;
|
|
card._summary.dialog = null;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
const extras = [];
|
|
for (let index = 0; index < 2; index++) {
|
|
const extra = document.createElement('houseplan-card');
|
|
extra.style.display = 'none';
|
|
extra.hass = card.hass;
|
|
extra.setConfig({ ...(card._config || {}), type: 'custom:houseplan-card' });
|
|
document.body.append(extra);
|
|
// #506: the warm summary runtime now exists synchronously; the readiness
|
|
// this witness needs is the server config load, which stays asynchronous.
|
|
for (let attempt = 0; attempt < 400 && !(extra._summary && extra._loadOk); attempt++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
extra._serverCfg = structuredClone(card._serverCfg);
|
|
extra._settings = extra._serverCfg.settings;
|
|
extra._haSummaryPanelApi = 1;
|
|
extra._serverCanWrite = true;
|
|
await extra._summary.openDialog();
|
|
extra._summary.openSource('block-0', 'value-0');
|
|
await extra.updateComplete;
|
|
extras.push(extra);
|
|
}
|
|
window.__summaryWitnessExtras = extras;
|
|
const cards = [card, ...extras];
|
|
return {
|
|
cards: cards.length,
|
|
rows: blocks.reduce((sum, block) => sum + block.values.length, 0),
|
|
states: Object.keys(states).length,
|
|
everyCardHasFullIndex: cards.every((entry) => entry._summary.entityIndex?.entries.length === 10_000),
|
|
extraCardsBoundOnePicker: extras.every((entry) => {
|
|
const root = entry.shadowRoot || entry.renderRoot;
|
|
return root.querySelectorAll('.summary-source-picker').length === 1
|
|
&& root.querySelectorAll('.summary-source-results button').length <= 104;
|
|
}),
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
// The standalone harness has no backend capability handshake. Backend
|
|
// validation is covered separately; advertise the supported API here so
|
|
// this browser witness exercises the complete shared-settings form.
|
|
card._haSummaryPanelApi = 1;
|
|
root.querySelector('.summary-control button:first-child').click();
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
const root = card?.shadowRoot || card?.renderRoot;
|
|
return !!root?.querySelector('hp-dialog .summary-editor');
|
|
});
|
|
const settings = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const root = () => card.shadowRoot || card.renderRoot;
|
|
const editor = root().querySelector('hp-dialog .summary-editor');
|
|
const actionTargets = [...editor.querySelectorAll(
|
|
"button, input:not([type='checkbox']), select, .summary-drag",
|
|
)];
|
|
const switchTargets = [...editor.querySelectorAll('.summary-switch')];
|
|
const source = editor.querySelector('.summary-source');
|
|
const closedHasNoEntityOptions = editor.querySelectorAll('option[value^="entity:"]').length === 0;
|
|
source.click();
|
|
await card.updateComplete;
|
|
const picker = root().querySelector('.summary-source-picker');
|
|
const search = picker.querySelector('[data-summary-picker-search]');
|
|
const inputSamples = [];
|
|
for (let index = 0; index < 23; index++) {
|
|
const started = performance.now();
|
|
search.value = `Summary reading ${index % 10}`;
|
|
search.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
await card.updateComplete;
|
|
if (index >= 3) inputSamples.push(performance.now() - started);
|
|
}
|
|
inputSamples.sort((a, b) => a - b);
|
|
const inputP95 = inputSamples[Math.ceil(inputSamples.length * .95) - 1];
|
|
search.value = 'sensor.summary_09999';
|
|
search.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
await card.updateComplete;
|
|
const refreshedPicker = root().querySelector('.summary-source-picker');
|
|
const oneActivePicker = root().querySelectorAll('.summary-source-picker').length === 1;
|
|
const exact = [...refreshedPicker.querySelectorAll('.summary-source-results button')]
|
|
.find((button) => button.textContent.includes('sensor.summary_09999'));
|
|
const boundedPicker = refreshedPicker.querySelectorAll('.summary-source-results button').length <= 104;
|
|
exact?.click();
|
|
await card.updateComplete;
|
|
const cancel = [...root().querySelectorAll('hp-dialog [slot="footer"] button')]
|
|
.find((button) => button.textContent.trim() === card._t('btn.cancel'));
|
|
const samples = [];
|
|
for (let index = 0; index < 23; index++) {
|
|
card._summary.dialog = null;
|
|
const started = performance.now();
|
|
await card._summary.openDialog();
|
|
await card.updateComplete;
|
|
if (index >= 3) samples.push(performance.now() - started);
|
|
}
|
|
samples.sort((a, b) => a - b);
|
|
const openP95 = samples[Math.ceil(samples.length * .95) - 1];
|
|
const result = {
|
|
editorLoadedOnDemand: !!editor,
|
|
sharedAndLocalControls: !!editor?.querySelector('#summary-panel-title')
|
|
&& !!editor.querySelector('input[data-summary-local-show]')
|
|
&& !!editor.querySelector('input[data-summary-mobile-show]'),
|
|
obsoleteSizeControlsAbsent: editor.querySelectorAll(
|
|
'input[type="range"], .summary-local-sizes, .summary-sizes-title, .summary-size-reset',
|
|
).length === 0,
|
|
closedRowsHaveNoEntityOptions: closedHasNoEntityOptions,
|
|
oneActivePicker,
|
|
pickerClosesAfterSelection: root().querySelectorAll('.summary-source-picker').length === 0,
|
|
boundedPicker,
|
|
fullIndexReachableByExactSearch: !!exact,
|
|
loadedFormOpenP95Under250ms: openP95 <= 250,
|
|
pickerInputP95Under50ms: inputP95 <= 50,
|
|
metrics: { openP95Ms: openP95, inputP95Ms: inputP95, samples: inputSamples.length },
|
|
editorTouchTargets: [...actionTargets, ...switchTargets].every((target) => {
|
|
const box = target.getBoundingClientRect();
|
|
return box.width >= 44 && box.height >= 44;
|
|
}),
|
|
};
|
|
cancel?.click();
|
|
return result;
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
for (const extra of window.__summaryWitnessExtras || []) extra.remove();
|
|
window.__summaryWitnessExtras = [];
|
|
});
|
|
|
|
const indexInvalidation = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const runtime = card._summary;
|
|
await runtime.openDialog();
|
|
await card.updateComplete;
|
|
const initial = runtime.entityIndex.rebuilds;
|
|
card.hass = { ...card.hass, states: {
|
|
...card.hass.states,
|
|
'sensor.summary_00000': { ...card.hass.states['sensor.summary_00000'], state: 'changed' },
|
|
} };
|
|
await card.updateComplete;
|
|
const afterValue = runtime.entityIndex.rebuilds;
|
|
card.hass = { ...card.hass, states: {
|
|
...card.hass.states,
|
|
'sensor.summary_added': {
|
|
entity_id: 'sensor.summary_added', state: '1', attributes: { friendly_name: 'Added' },
|
|
},
|
|
} };
|
|
await card.updateComplete;
|
|
const afterAdd = runtime.entityIndex.rebuilds;
|
|
card.hass = { ...card.hass, states: {
|
|
...card.hass.states,
|
|
'sensor.summary_00001': {
|
|
...card.hass.states['sensor.summary_00001'], attributes: { friendly_name: 'Renamed' },
|
|
},
|
|
} };
|
|
await card.updateComplete;
|
|
const afterRename = runtime.entityIndex.rebuilds;
|
|
runtime.dialog = null;
|
|
card.requestUpdate();
|
|
return {
|
|
stateValueDoesNotRebuildIndex: afterValue === initial,
|
|
addRebuildsIndexOnce: afterAdd === initial + 1,
|
|
renameRebuildsIndexOnce: afterRename === afterAdd + 1,
|
|
counts: { initial, afterValue, afterAdd, afterRename },
|
|
};
|
|
});
|
|
|
|
const responsiveForm = async ({ width, language, dark, canWrite, kiosk }) => {
|
|
await page.setViewportSize({ width, height: 760 });
|
|
return page.evaluate(async ({ language, dark, canWrite, kiosk }) => {
|
|
const card = window.__card;
|
|
document.documentElement.style.fontSize = '200%';
|
|
document.documentElement.toggleAttribute('dark', dark);
|
|
card._config = { ...card._config, language, kiosk };
|
|
card._serverCanWrite = canWrite;
|
|
// #627: this harness preloads the editor runtime, so a live language
|
|
// switch also waits for its lazy dictionaries (inert + aria-busy).
|
|
card.requestUpdate(); await card.updateComplete;
|
|
for (let i = 0; i < 250 && card.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
|
|
card._summary.updated();
|
|
await card._summary.openDialog();
|
|
await card.updateComplete;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const editor = root.querySelector('hp-dialog .summary-editor');
|
|
const footer = root.querySelector('hp-dialog [slot="footer"]');
|
|
const editorBox = editor.getBoundingClientRect();
|
|
const controls = [...editor.querySelectorAll(
|
|
'button, input:not([type="checkbox"]), select, .summary-switch, .summary-drag',
|
|
), ...root.querySelectorAll('hp-dialog [slot="footer"] button')];
|
|
const result = {
|
|
noHorizontalOverflow: editor.scrollWidth <= editor.clientWidth + 1,
|
|
controlsInsideViewport: controls.length >= 3 && controls.every((control) => {
|
|
const box = control.getBoundingClientRect();
|
|
return box.left >= -1 && box.right <= innerWidth + 1 && box.width >= 44 && box.height >= 44;
|
|
}),
|
|
footerReachable: !!footer && footer.getBoundingClientRect().left >= -1
|
|
&& footer.getBoundingClientRect().right <= innerWidth + 1,
|
|
editorInsideViewport: editorBox.left >= -1 && editorBox.right <= innerWidth + 1,
|
|
localOnlyMatchesRole: canWrite && !kiosk
|
|
? !editor.querySelector('.summary-local-hint') : !!editor.querySelector('.summary-local-hint'),
|
|
bounds: { left: editorBox.left, right: editorBox.right, viewport: innerWidth },
|
|
};
|
|
card._summary.dialog = null;
|
|
card.requestUpdate();
|
|
return result;
|
|
}, { language, dark, canWrite, kiosk });
|
|
};
|
|
|
|
const responsiveAdmin = await responsiveForm({
|
|
width: 320, language: 'ru', dark: true, canWrite: true, kiosk: false,
|
|
});
|
|
const responsiveHousehold = await responsiveForm({
|
|
width: 390, language: 'en', dark: false, canWrite: false, kiosk: false,
|
|
});
|
|
const responsiveKiosk = await responsiveForm({
|
|
width: 320, language: 'ru', dark: true, canWrite: true, kiosk: true,
|
|
});
|
|
await page.setViewportSize({ width: 960, height: 640 });
|
|
await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
document.documentElement.style.fontSize = '';
|
|
document.documentElement.removeAttribute('dark');
|
|
card._config = { ...card._config, language: 'en', kiosk: false };
|
|
card._serverCanWrite = true;
|
|
card._summary.updated();
|
|
await card.updateComplete;
|
|
});
|
|
|
|
const liveSetup = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const panel = {
|
|
version: 1, title: 'Live summary', show_on_mobile: true,
|
|
blocks: [{ id: 'live', title: 'Sensors', visible: true, scope: { type: 'all' }, values: [
|
|
{ id: 'summary-only', label: 'Summary only',
|
|
source: { type: 'entity', entity_id: 'sensor.summary_only' } },
|
|
] }],
|
|
};
|
|
card._serverCfg = {
|
|
...card._serverCfg,
|
|
settings: { ...(card._serverCfg.settings || {}), summary_panel: panel },
|
|
};
|
|
card.hass = {
|
|
...card.hass,
|
|
states: {
|
|
...card.hass.states,
|
|
'sensor.summary_only': {
|
|
entity_id: 'sensor.summary_only', state: '111', attributes: { unit_of_measurement: 'ppm' },
|
|
},
|
|
},
|
|
};
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
return card._renderDeviceSnapshot?.entityIds.includes('sensor.summary_only') === true;
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
return [...root.querySelectorAll('.summary-value strong')]
|
|
.some((node) => node.textContent.trim() === '111 ppm');
|
|
});
|
|
|
|
const liveState = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const root = () => card.shadowRoot || card.renderRoot;
|
|
const value = () => root().querySelector('.summary-overlay .summary-value strong')
|
|
?.textContent.trim() || '';
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
await card.updateComplete;
|
|
const originalUpdated = card.updated.bind(card);
|
|
let updateCount = 0;
|
|
card.updated = (changed) => { updateCount++; return originalUpdated(changed); };
|
|
const modelBefore = card._model;
|
|
const epochBefore = card._cfgEpoch;
|
|
const layoutBefore = card._layoutRev;
|
|
const beforeUnrelated = updateCount;
|
|
|
|
card.hass = {
|
|
...card.hass,
|
|
states: {
|
|
...card.hass.states,
|
|
'sensor.unrelated_490': { entity_id: 'sensor.unrelated_490', state: 'changed', attributes: {} },
|
|
},
|
|
};
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const unrelatedRenders = updateCount - beforeUnrelated;
|
|
const beforeRelevant = updateCount;
|
|
|
|
card.hass = {
|
|
...card.hass,
|
|
states: {
|
|
...card.hass.states,
|
|
'sensor.summary_only': {
|
|
...card.hass.states['sensor.summary_only'], state: '222',
|
|
},
|
|
},
|
|
};
|
|
await card.updateComplete;
|
|
const changed = value();
|
|
const relevantRenders = updateCount - beforeRelevant;
|
|
|
|
card.hass = {
|
|
...card.hass,
|
|
states: {
|
|
...card.hass.states,
|
|
'sensor.summary_only': {
|
|
...card.hass.states['sensor.summary_only'], state: 'unavailable', attributes: {},
|
|
},
|
|
},
|
|
};
|
|
await card.updateComplete;
|
|
const unavailable = value();
|
|
|
|
const missingStates = { ...card.hass.states };
|
|
delete missingStates['sensor.summary_only'];
|
|
card.hass = { ...card.hass, states: missingStates };
|
|
await card.updateComplete;
|
|
const missing = value();
|
|
|
|
card.hass = {
|
|
...card.hass,
|
|
states: {
|
|
...card.hass.states,
|
|
'sensor.summary_only': {
|
|
entity_id: 'sensor.summary_only', state: '333', attributes: { unit_of_measurement: 'ppm' },
|
|
},
|
|
},
|
|
};
|
|
await card.updateComplete;
|
|
const recovered = value();
|
|
card.updated = originalUpdated;
|
|
return {
|
|
unrelatedTickSkipped: unrelatedRenders === 0,
|
|
relevantTickRenderedOnce: relevantRenders === 1,
|
|
valueUpdated: changed === '222 ppm',
|
|
unavailableUpdated: unavailable === 'unavailable',
|
|
missingUpdated: missing === card._summary.t('summary.unavailable'),
|
|
recoveredUpdated: recovered === '333 ppm',
|
|
stateTicksKeepGeometry: card._model === modelBefore && card._cfgEpoch === epochBefore
|
|
&& card._layoutRev === layoutBefore,
|
|
};
|
|
});
|
|
|
|
const recovery = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const runtime = card._summary;
|
|
const originalHass = card.hass;
|
|
const originalCallWS = originalHass.callWS;
|
|
const originalPrepareImage = card._signer.prepareImage.bind(card._signer);
|
|
// #500: adoption lives in the identity owner; observe it there.
|
|
const adoption = card._adoption;
|
|
const originalAdoptResponses = adoption.adoptResponses.bind(adoption);
|
|
const recoveryOrder = [];
|
|
const concurrentBackdrop = 'media-source://image/summary-recovery-490';
|
|
card._signer.prepareImage = async (_hass, href) => {
|
|
recoveryOrder.push(`prepare:${href}`);
|
|
return true;
|
|
};
|
|
adoption.adoptResponses = (...args) => {
|
|
recoveryOrder.push('adopt');
|
|
return originalAdoptResponses(...args);
|
|
};
|
|
const writes = [];
|
|
let serverConfig = structuredClone(card._serverCfg);
|
|
let serverRev = card._cfgRev;
|
|
let firstWrite = true;
|
|
let rejectConflict = false;
|
|
card.hass = {
|
|
...originalHass,
|
|
callWS: async (message) => {
|
|
if (message.type === 'houseplan/config/set') {
|
|
writes.push(structuredClone(message));
|
|
if (rejectConflict) throw Object.assign(new Error('synthetic conflict'), { code: 'conflict' });
|
|
if (firstWrite) {
|
|
firstWrite = false;
|
|
serverConfig = structuredClone(message.config);
|
|
serverConfig.spaces[0] = {
|
|
...serverConfig.spaces[0],
|
|
title: 'Concurrent title kept',
|
|
plan_url: concurrentBackdrop,
|
|
};
|
|
serverConfig.settings = { ...serverConfig.settings, concurrent_guard_490: 'kept' };
|
|
serverRev = message.expected_rev + 2;
|
|
throw new Error('synthetic lost ACK');
|
|
}
|
|
serverConfig = structuredClone(message.config);
|
|
serverRev++;
|
|
return { rev: serverRev };
|
|
}
|
|
if (message.type === 'houseplan/config/get') return {
|
|
config: structuredClone(serverConfig), rev: serverRev,
|
|
can_write: true, summary_panel_api: 1,
|
|
};
|
|
return originalCallWS(message);
|
|
},
|
|
};
|
|
await card.updateComplete;
|
|
|
|
await runtime.openDialog();
|
|
runtime.dialog.draft.title = 'Saved despite lost ACK';
|
|
await runtime.saveDialog();
|
|
const recoveredConfig = card._serverCfg;
|
|
const recoveredRev = card._cfgRev;
|
|
const firstClosed = runtime.dialog === null;
|
|
|
|
await runtime.openDialog();
|
|
runtime.dialog.draft.title = 'Second summary write';
|
|
await runtime.saveDialog();
|
|
const secondWrite = writes[1];
|
|
|
|
rejectConflict = true;
|
|
await runtime.openDialog();
|
|
runtime.dialog.draft.title = 'Must remain a conflict';
|
|
await runtime.saveDialog();
|
|
const trueConflictStaysOpen = runtime.dialog?.conflict === true
|
|
&& !!runtime.dialog.error
|
|
&& card._serverCfg.settings.summary_panel.title === 'Second summary write';
|
|
runtime.dialog = null;
|
|
card.hass = { ...card.hass, callWS: originalCallWS };
|
|
card._signer.prepareImage = originalPrepareImage;
|
|
delete adoption.adoptResponses;
|
|
await card.updateComplete;
|
|
return {
|
|
lostAckClosesAsSuccess: firstClosed,
|
|
recoveryAdoptsWholeConfig: recoveredConfig.spaces[0].title === 'Concurrent title kept'
|
|
&& recoveredConfig.spaces[0].plan_url === concurrentBackdrop
|
|
&& recoveredConfig.settings.concurrent_guard_490 === 'kept',
|
|
recoveryPreparesBackdropBeforeAdoption:
|
|
recoveryOrder.indexOf(`prepare:${concurrentBackdrop}`) >= 0
|
|
&& recoveryOrder.indexOf('adopt') > recoveryOrder.indexOf(`prepare:${concurrentBackdrop}`),
|
|
recoveryAdoptsRevision: recoveredRev === writes[0].expected_rev + 2,
|
|
nextWriteUsesRecoveredRevision: secondWrite?.expected_rev === recoveredRev,
|
|
nextWritePreservesConcurrentChange: secondWrite?.config?.spaces?.[0]?.title === 'Concurrent title kept'
|
|
&& secondWrite?.config?.spaces?.[0]?.plan_url === concurrentBackdrop
|
|
&& secondWrite?.config?.settings?.concurrent_guard_490 === 'kept',
|
|
trueConflictStaysOpen,
|
|
};
|
|
});
|
|
|
|
const { metrics: pickerMetrics, ...settingsChecks } = settings;
|
|
const witness = {
|
|
...initial,
|
|
bottomOnTallStage,
|
|
smallCardKeepsLocalIntent,
|
|
...settingsChecks,
|
|
pickerWitnessHas200RowsAnd10000States:
|
|
pickerWitnessSetup.cards === 3 && pickerWitnessSetup.rows === 200
|
|
&& pickerWitnessSetup.states === 10_000 && pickerWitnessSetup.everyCardHasFullIndex
|
|
&& pickerWitnessSetup.extraCardsBoundOnePicker,
|
|
stateValueDoesNotRebuildIndex: indexInvalidation.stateValueDoesNotRebuildIndex,
|
|
addRebuildsIndexOnce: indexInvalidation.addRebuildsIndexOnce,
|
|
renameRebuildsIndexOnce: indexInvalidation.renameRebuildsIndexOnce,
|
|
responsiveAdmin: Object.entries(responsiveAdmin).filter(([key]) => key !== 'bounds').every(([, value]) => value),
|
|
responsiveHousehold: Object.entries(responsiveHousehold).filter(([key]) => key !== 'bounds').every(([, value]) => value),
|
|
responsiveKiosk: Object.entries(responsiveKiosk).filter(([key]) => key !== 'bounds').every(([, value]) => value),
|
|
summaryDependencyCaptured: liveSetup,
|
|
...liveState,
|
|
...recovery,
|
|
};
|
|
checkAll(witness);
|
|
await finish(browser, {
|
|
...witness,
|
|
pickerTimings: pickerMetrics,
|
|
pickerFixture: pickerWitnessSetup,
|
|
indexRebuilds: indexInvalidation,
|
|
responsive: { admin: responsiveAdmin, household: responsiveHousehold, kiosk: responsiveKiosk },
|
|
});
|