fix: make card resource recovery observable

Issue: #462
User-Visible: yes
This commit is contained in:
Matysh
2026-09-05 16:04:54 +03:00
parent a4fe939f63
commit fcf0ce80eb
74 changed files with 4739 additions and 450 deletions
+18 -3
View File
@@ -30,8 +30,16 @@ import { pathToFileURL } from 'node:url';
// ленивый чанк (−13.6 КБ, вариант 1) либо русский словарь (вариант 2).
//
// Measured fact at recalibration (dev @ 360, 29.08.2026): 273 697 B gzip.
// 273 697 × 1.10 = 301 067 — потолок 300 000 держится внутри правила ~10%.
export const INITIAL_VIEW_GZIP_BUDGET = 300_000;
// 273 697 × 1.10 = 301 067 — прежний потолок 300 000 держался внутри правила ~10%.
//
// 2026-09-05, #462: общий бюджет поднят 300 000 → 301 066. Контроллер
// согласования frontend/backend версии, безопасный kiosk reload и его banner
// обязаны жить в initial View: механизм нужен до любого lazy import и именно
// лечит вкладку со старым eager bundle. Измеренный шаг 297 503 → 300 090 Б
// gzip (+2 587 Б); presentation/safety при этом вынесены из ядра карточки в
// отдельный adapter. Новый бюджет — целая часть прежней верхней границы 10%
// над фактом #367 и оставляет 976 Б запаса; следующий рост обязан решать #367.
export const INITIAL_VIEW_GZIP_BUDGET = 301_066;
/**
* Порог, ниже которого запас перестаёт быть запасом (#367).
@@ -123,8 +131,15 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* то есть следующий семибайтный шум нарушил обязательный запас >500 Б.
* Новый центр оставляет 997 Б сверху и 1 003 Б снизу; бюджет 300 000 Б и
* предупреждение о малом запасе не меняются.
*
* 2026-09-05, #462: потолок поднят 298 500 → 301 000 вместе с общим бюджетом
* 301 066. Обязательный eager version-recovery занимает initial-граф до
* lazy editor/onboarding и потому не может быть вынесен из него. Факт
* 300 090 Б расположен почти по центру полосы: 910 Б сверху и 1 090 Б снизу.
* Core adapter вынесен в `src/version-recovery-card.ts`; это изменение
* фиксирует цену новой границы восстановления, а не разрешает молчаливый рост.
*/
export const INITIAL_VIEW_GZIP_CEILING = 298_500;
export const INITIAL_VIEW_GZIP_CEILING = 301_000;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
+72
View File
@@ -22,6 +22,8 @@ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const canonicalText = (path) => readFileSync(path, 'utf8').replace(/\r\n?/g, '\n');
const withoutFences = (text) => text.replace(/^```[^\n]*\n[\s\S]*?^```\s*$/gm, '');
const yamlFences = (text) => [...text.matchAll(/^```yaml[^\S\r\n]*\n([\s\S]*?)^```[^\S\r\n]*$/gm)]
.map((match) => match[1].split('\n').map((line) => line.replace(/[\t ]+$/, '')).join('\n').trim());
const slug = (heading) => heading
.trim().toLowerCase()
.replace(/<[^>]+>/g, '')
@@ -94,6 +96,76 @@ for (const relative of PUBLIC_DOCS) {
}
}
// #462: a flat top-level `resources:` block looks plausible but is not a valid
// configuration.yaml fragment. Keep all four public install paths on the same
// versioned Storage/current-YAML/legacy-YAML contract.
const RESOURCE_INSTALL_DOCS = [
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
];
const RESOURCE_URL = '/houseplan_files/houseplan-card.js';
const RESOURCE_SNIPPETS = [
[
'lovelace:',
' resource_mode: yaml',
' resources:',
` - url: ${RESOURCE_URL}`,
' type: module',
].join('\n'),
[
'lovelace:',
' mode: yaml',
' resources:',
` - url: ${RESOURCE_URL}`,
' type: module',
].join('\n'),
];
for (const relative of RESOURCE_INSTALL_DOCS) {
const text = canonicalText(resolve(ROOT, relative));
const blocks = yamlFences(text).filter((block) => block.includes(RESOURCE_URL));
if (blocks.length !== RESOURCE_SNIPPETS.length) {
errors.push(`${relative}: expected exactly two mode-specific House Plan resource snippets`);
}
if (blocks.some((block) => /^resources:\s*$/m.test(block))) {
errors.push(`${relative}: House Plan resources must be nested under lovelace:`);
}
for (const expected of RESOURCE_SNIPPETS) {
const count = blocks.filter((block) => block === expected).length;
if (count !== 1) {
const mode = expected.includes('resource_mode') ? 'HA 2026.2+ resource_mode' : 'legacy mode';
errors.push(`${relative}: expected one exact ${mode} House Plan snippet, found ${count}`);
}
}
if (blocks.length === RESOURCE_SNIPPETS.length
&& blocks.some((block, index) => block !== RESOURCE_SNIPPETS[index])) {
errors.push(`${relative}: current resource_mode snippet must precede the legacy mode snippet`);
}
const russian = relative.endsWith('.ru.md');
const prose = text.replace(/\s+/g, ' ');
const proseContracts = russian ? [
'#### Режим Storage (по умолчанию в Home Assistant)',
'#### YAML-ресурсы в Home Assistant 2026.2+',
'#### Home Assistant 2024.6–2026.1: устаревший режим',
'полностью управляется через YAML',
'Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить ресурс',
'Не переключайте storage-панель в устаревший YAML только ради House Plan',
] : [
'#### Storage mode (Home Assistant default)',
'#### YAML resources mode (Home Assistant 2026.2+)',
'#### Legacy Home Assistant 2024.6–2026.1',
'full-YAML dashboard',
'Settings → Dashboards → menu ⋮ → Resources → Add resource',
'Do not switch a storage dashboard to legacy YAML just for House Plan',
];
for (const fragment of proseContracts) {
if (!prose.includes(fragment)) errors.push(`${relative}: missing resource guidance “${fragment}”`);
}
for (const shortcut of ['Ctrl+F5', 'Cmd+Shift+R']) {
if (!text.includes(shortcut)) errors.push(`${relative}: missing hard reload shortcut ${shortcut}`);
}
}
const sectionMarkers = (relative) => [...canonicalText(resolve(ROOT, relative))
.matchAll(/<!--\s*docs-section:\s*([a-z0-9-]+)\s*-->/g)].map((match) => match[1]);
for (const [en, ru, required] of [
+166 -2
View File
@@ -105,6 +105,170 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'resource-docs-flatten-current-yaml',
guard: 'node scripts/check-docs.mjs',
because: 'the supported HA 2026.2+ resource snippet must stay nested under lovelace; '
+ 'a plausible-looking top-level resources block is the user-facing defect from #462 AC1',
patches: [{
file: 'README.md',
find: 'lovelace:\n resource_mode: yaml\n resources:',
replace: 'resource_mode: yaml\nresources:',
}],
},
{
id: 'frontend-registration-skips-retry',
guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider '
+ '-k "retry_waits_one_second"',
because: 'a temporarily unavailable Lovelace registry must receive the one lifecycle-bound '
+ 'recovery attempt promised by #462 AC3',
patches: [{
file: 'custom_components/houseplan/frontend_registration.py',
find: ' _schedule_retry(hass, entry, state)',
replace: ' pass # mutant: recovery retry removed',
}],
},
{
id: 'frontend-registration-retries-without-delay',
guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider '
+ '-k "retry_waits_one_second"',
because: 'even an already-running HA must retain the fixed cancellable delay instead of '
+ 'repeating a transient registry failure in the same tick (#462 AC3)',
patches: [{
file: 'custom_components/houseplan/frontend_registration.py',
find: ' state._cancel_timer = async_call_later(\n'
+ ' hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay\n'
+ ' )',
replace: ' _after_delay() # mutant: retry runs without the fixed lifecycle delay',
}],
},
{
id: 'frontend-registration-is-not-unload-bound',
guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider '
+ '-k "retry_lifecycle_handles_are_cancelled"',
because: 'the start listener, delay and running retry belong to the config-entry lifecycle; '
+ 'otherwise unload can resurrect frontend side effects (#462 AC3)',
patches: [{
file: 'custom_components/houseplan/frontend_registration.py',
find: ' entry.async_on_unload(state.cancel)',
replace: ' pass # mutant: retry lifecycle detached from config-entry unload',
}],
},
{
id: 'frontend-reload-notice-forgets-persisted-flag',
guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider '
+ '-k "reload_notice_is_localized_and_persisted_once"',
because: 'the first-install hard-reload notice must remain one-shot across retry, reload and '
+ 'future versions rather than reappearing forever (#462 AC4)',
patches: [{
file: 'custom_components/houseplan/frontend_registration.py',
find: ' data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},',
replace: ' data={**entry.data}, # mutant: one-shot flag is not persisted',
}],
},
{
id: 'version-recovery-treats-unknown-as-mismatch',
guard: 'node --test --test-name-pattern="malformed values stay unknown" '
+ 'test/version-recovery.test.mjs',
because: 'a missing or malformed integration_version must clear stale state and stay unknown, '
+ 'not manufacture a reload request (#462 AC6)',
patches: [{
file: 'src/version-recovery.ts',
find: " if (!normalizedFrontend || !normalizedBackend) return { kind: 'unknown' };",
replace: " if (!normalizedFrontend || !normalizedBackend) return { kind: 'mismatch', frontend: normalizedFrontend || 'unknown', backend: normalizedBackend || 'unknown' };",
}],
},
{
id: 'version-recovery-delays-config-capability-adoption',
guard: 'node demo/smoke_version_recovery.mjs',
because: 'a successful config/get must set or clear its runtime capabilities before a sibling '
+ 'layout request or later asset preparation can reject the structural candidate (#462 AC6)',
patches: [{
file: 'src/version-recovery-card.ts',
find: ' (response) => adoptCardConfigCapabilities(host, response),',
replace: ' () => undefined, // mutant: aggregate consumers own capability adoption',
}],
},
{
id: 'version-recovery-auto-reloads-ordinary-view',
guard: 'node --test --test-name-pattern="ordinary mode always" '
+ 'test/version-recovery.test.mjs',
because: 'outside kiosk a version mismatch must never reload without the user pressing the '
+ 'trusted action, even after an arbitrary wait (#462 AC7)',
patches: [{
file: 'src/version-recovery.ts',
find: ' if (!this._input.kiosk) {',
replace: ' if (false && !this._input.kiosk) {',
}],
},
{
id: 'version-recovery-ignores-editor-state',
guard: 'node demo/smoke_version_recovery.mjs',
because: 'a kiosk card in an editor must preserve unsaved work instead of silently reloading '
+ 'when its versions differ (#462 AC8)',
patches: [{
file: 'src/version-recovery-card.ts',
find: " viewOnly: host._config?.kiosk === true && host._mode === 'view' && !host._editing,",
replace: ' viewOnly: true,',
}],
},
{
id: 'version-recovery-ignores-dialog-state',
guard: 'node demo/smoke_version_recovery.mjs',
because: 'a first-class dialog or contextual surface must block the kiosk auto-reload '
+ 'rather than disappear underneath it (#462 AC8)',
patches: [{
file: 'src/version-recovery-card.ts',
find: ' surfacesIdle,',
replace: ' surfacesIdle: true,',
}],
},
{
id: 'version-recovery-ignores-pending-config-write',
guard: 'node demo/smoke_version_recovery.mjs',
because: 'a pending configuration write must block kiosk reload so an accepted edit cannot '
+ 'be lost during version recovery (#462 AC8)',
patches: [{
file: 'src/version-recovery-card.ts',
find: ' configWritesIdle: host._writesPending === 0 && !host._saveConfigDebounced.pending(),',
replace: ' configWritesIdle: true,',
}],
},
{
id: 'version-recovery-ignores-interaction-pause',
guard: 'node demo/smoke_version_recovery.mjs',
because: 'recent pointer, keyboard, touch and native more-info interaction must defer the '
+ 'silent kiosk reload by the same shared pause contract (#462 AC8)',
patches: [{
file: 'src/version-recovery-card.ts',
find: ' interactionPauseElapsed: Date.now() >= host._cyclePausedUntil,',
replace: ' interactionPauseElapsed: true,',
}],
},
{
id: 'version-recovery-marks-target-after-reload',
guard: 'node --test --test-name-pattern="marks the exact target before one reload" '
+ 'test/version-recovery.test.mjs',
because: 'the backend target must be durably claimed before navigation, otherwise the next '
+ 'document can enter an infinite kiosk reload loop (#462 AC8)',
patches: [{
file: 'src/version-recovery.ts',
find: ' storage.setItem(VERSION_RELOAD_ATTEMPT_KEY, target);\n return \'claimed\';',
replace: " return 'claimed'; // mutant: navigation happens before any durable claim",
}],
},
{
id: 'version-recovery-ignores-stored-target',
guard: 'node --test --test-name-pattern="same target is once per tab" '
+ 'test/version-recovery.test.mjs',
because: 'all full cards in one tab must honour the attempted backend target so a stale '
+ 'frontend cannot reload repeatedly (#462 AC9)',
patches: [{
file: 'src/version-recovery.ts',
find: " return storage.getItem(VERSION_RELOAD_ATTEMPT_KEY) === target ? 'attempted' : 'fresh';",
replace: " return 'fresh'; // mutant: forget the tab-wide target guard",
}],
},
{
id: 'space-copy-title-always-reuses-two',
guard: 'node --test --test-name-pattern="first free numbered" test/space-copy.test.mjs',
@@ -1376,8 +1540,8 @@ const MUTANT_DEFINITIONS = [
+ 'forever (#402)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' return html`${body}${this._renderDangerConfirm()}`;',
replace: ' return html`${body}`;',
find: ' return html`${body}${this._renderVersionBanner()}${this._renderDangerConfirm()}`;',
replace: ' return html`${body}${this._renderVersionBanner()}`;',
}],
},
{
+16
View File
@@ -37,6 +37,22 @@ export const SMOKE_LINKS = [
+ 'Undo/Redo identity through the real Walls click handler; those browser effects are '
+ 'outside the pure projector and transaction source contracts',
},
{
symbols: [
'VersionRecoveryController', 'VersionReloadSafetySnapshot',
'isVersionReloadSafe', 'compareRuntimeVersions', 'normalizeRuntimeVersion',
'fetchAuthoritativeConfig', 'getAuthoritativeCardConfig', '_getAuthoritativeConfig',
'adoptCardConfigCapabilities',
'cardVersionReloadSafetySnapshot', '_versionReloadSafetySnapshot',
'renderVersionBanner', '_renderVersionBanner',
],
smokes: ['smoke_version_recovery.mjs'],
because: 'the #462 production-bundle scenario proves the card-level overlay, trusted '
+ 'keyboard/touch reload seam, exact session target, concrete card dialog/write/gesture '
+ 'guards, config capability adoption before sibling failures, more-info pause and the '
+ 'full-card/space-card boundary; pure controller tests '
+ 'cannot observe those DOM and Home Assistant lifecycle effects',
},
{
symbols: [
'LiveEditorState', 'finishRevision', 'hoverProperties',