Compare commits

...
Author SHA1 Message Date
Sergey Matyunin 2f362248d7 fix: preserve space during revalidation
Validate / provenance (push) Successful in 33s
Validate / hacs (push) Failing after 11s
Validate / process-gate (push) Failing after 50s
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 6m27s
Validate / backend (push) Failing after 9m36s
Validate / golden (push) Failing after 9m25s
Validate / performance_smoke (push) Failing after 13m28s
Validate / smoke (push) Failing after 34m51s
Issue: #131
User-Visible: no
2026-08-14 02:31:16 +03:00
claude[bot] 90e6323940 docs: review document for #131
Issue: #131
User-Visible: no
2026-08-13 23:28:17 +00:00
Sergey Matyunin cebb19a09a fix: complete read-only cold start
Issue: #131
User-Visible: yes
2026-08-14 02:18:11 +03:00
claude[bot] 031148e0e1 docs: spec review document for #131 (r1, green)
Issue: #131
User-Visible: no
2026-08-13 23:01:55 +00:00
Sergey Matyunin 0164e655c5 docs: specify readonly cold start behavior
Issue: #131
User-Visible: no
2026-08-14 01:53:51 +03:00
15 changed files with 1311 additions and 74 deletions
File diff suppressed because one or more lines are too long
+187
View File
@@ -0,0 +1,187 @@
// #131: a read-only HA session may read the House Plan snapshot while HA
// rejects event subscriptions. The initial frame must still select one exact
// space and render every raw-space layer before any user click.
import { launch, checkAll, finish } from './serve.mjs';
import { makeVisualMatrixFixture } from './fixtures/visual-matrix.mjs';
const fixture = makeVisualMatrixFixture();
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async (rawFixture) => {
const out = {};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const HP = customElements.get('houseplan-card');
const host = document.getElementById('host');
window.__card.remove();
HP._warmBootReset?.();
for (const key of Object.keys(localStorage)) {
if (key.startsWith('houseplan_card_')) localStorage.removeItem(key);
}
history.replaceState(null, '', '/demo.html');
const fixture = structuredClone(rawFixture);
const lighting = fixture.config.spaces.find((space) => space.id === 'golden-lighting');
const geometry = fixture.config.spaces.find((space) => space.id === 'golden-geometry');
lighting.id = 'home';
lighting.title = 'Home';
lighting.settings = {
...lighting.settings,
fill_mode: 'custom',
custom_fill: { c: '#cdbb96', a: 0.42 },
glow_enabled: true,
show_borders: true,
};
lighting.decor = [{
id: 'readonly-furniture', kind: 'rect', x: 0.16, y: 0.18, w: 0.12, h: 0.08,
color: '#8d6e63', opacity: 1, width_cm: 2, fill: true,
fill_color: '#bcaaa4', fill_opacity: 1,
}];
geometry.id = 'upstairs';
geometry.title = 'Upstairs';
fixture.config.spaces = [lighting, geometry];
fixture.config.markers.push({
id: 'golden-light-one', binding: 'device:golden-light-one', display: 'value',
});
fixture.layout = Object.fromEntries(Object.entries(fixture.layout).map(([id, pos]) => [
id,
{ ...pos, s: pos.s === 'golden-lighting' ? 'home' : pos.s === 'golden-geometry' ? 'upstairs' : pos.s },
]));
const base = window.__mkHass();
const makeHass = (subscriptionMode = 'partial') => {
const calls = { configGets: 0, events: {}, unsubscribed: [] };
const connection = {
subscribeEvents: async (callback, event) => {
calls.events[event] = (calls.events[event] || 0) + 1;
if (String(event).startsWith('houseplan_')
&& (subscriptionMode === 'reject-all'
|| event === 'houseplan_config_updated' || event === 'houseplan_trail_updated')) {
throw new Error('unauthorized');
}
return () => { calls.unsubscribed.push(event); };
},
subscribeMessage: async () => () => {},
};
const hass = {
...base,
user: { id: 'readonly', name: 'Readonly', is_admin: false },
devices: fixture.devices,
entities: fixture.entities,
areas: fixture.areas,
states: fixture.states,
connection,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
calls.configGets += 1;
return { config: structuredClone(fixture.config), rev: 131, can_write: false };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(fixture.layout), rev: 131 };
}
if (message.type === 'houseplan/trail/get') return { trails: {} };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get') {
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden' }];
}
if (message.type === 'manifest/list') {
return [{ domain: 'houseplan_golden', name: 'Golden' }];
}
return { ok: true };
},
};
return { hass, calls };
};
const layerSnapshot = (card) => {
const root = card.shadowRoot || card.renderRoot;
return {
exactSpace: card._curSpaceCfg?.id || null,
activeSpace: root.querySelector('[data-hp="space-tab"].active')?.getAttribute('data-id') || null,
rooms: root.querySelectorAll('[data-hp="room"]').length,
decor: root.querySelectorAll('[data-hp="decor"]').length,
walls: root.querySelectorAll('.wallbodies .wallbody-fill').length,
glow: root.querySelectorAll('.glow-spot').length,
devices: root.querySelectorAll('[data-hp="device"]').length,
values: root.querySelectorAll('[data-hp="device"] .valtext').length,
};
};
const complete = (snapshot) => snapshot.exactSpace === 'home'
&& snapshot.activeSpace === 'home' && snapshot.rooms > 0 && snapshot.decor > 0
&& snapshot.walls > 0 && snapshot.glow > 0 && snapshot.devices > 0 && snapshot.values > 0;
const waitForCard = async (card, calls, kiosk = false) => {
const until = performance.now() + 9000;
while (performance.now() < until) {
const snapshot = layerSnapshot(card);
const allSubscriptionsTried = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => calls.events[event] === 1);
if (card._loadOk && !card._loading && card._booting === false
&& allSubscriptionsTried && complete({
...snapshot,
activeSpace: kiosk ? 'home' : snapshot.activeSpace,
})) return snapshot;
await sleep(25);
}
throw new Error(`readonly card did not settle: ${JSON.stringify({
state: layerSnapshot(card), calls, space: card._space, loadOk: card._loadOk,
})}`);
};
const mount = async (title, options = {}) => {
const runtime = makeHass(options.subscriptionMode);
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title, kiosk: !!options.kiosk });
host.appendChild(card);
card.hass = runtime.hass;
const snapshot = await waitForCard(card, runtime.calls, !!options.kiosk);
return { card, snapshot, ...runtime };
};
const cold = await mount('Readonly cold start');
out.coldSelectsExactSpace = cold.card._space === 'home'
&& cold.snapshot.exactSpace === 'home' && cold.snapshot.activeSpace === 'home';
out.coldRendersAllSpatialLayers = complete(cold.snapshot);
out.readOnlyStaysReadOnly = cold.card._canEdit === false
&& !(cold.card.shadowRoot || cold.card.renderRoot).querySelector('.modetab');
out.allSubscriptionsAttempted = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => cold.calls.events[event] === 1);
out.allowedSubscriptionAdopted = typeof cold.card._unsubLayout === 'function'
&& !cold.card._unsubCfg && !cold.card._unsubTrail;
out.cacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
const beforeClick = layerSnapshot(cold.card);
cold.card._pickSpace('home');
await cold.card.updateComplete;
out.activeTabClickIsNoop = JSON.stringify(layerSnapshot(cold.card)) === JSON.stringify(beforeClick);
await sleep(700);
out.optionalFailureNoFullRetry = cold.calls.configGets === 1;
cold.card.remove();
await sleep(20);
out.successfulSubscriptionCleanedUp = cold.calls.unsubscribed.includes('houseplan_layout_updated');
const warm = await mount('Readonly cold start');
out.warmRemountKeepsCompleteSpace = complete(warm.snapshot) && warm.card._space === 'home';
warm.card.remove();
await sleep(20);
HP._warmBootReset?.();
const reload = await mount('Readonly simulated reload');
out.cachedReloadKeepsCompleteSpace = complete(reload.snapshot) && reload.card._space === 'home';
reload.card.remove();
await sleep(20);
HP._warmBootReset?.();
localStorage.removeItem('houseplan_card_cfg_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const kiosk = await mount('Readonly cold kiosk', { kiosk: true, subscriptionMode: 'reject-all' });
const kioskRoot = kiosk.card.shadowRoot || kiosk.card.renderRoot;
out.coldKioskCompleteWithoutHeaderAction = kiosk.card._space === 'home'
&& kiosk.snapshot.exactSpace === 'home' && kiosk.snapshot.rooms > 0
&& kiosk.snapshot.decor > 0 && kiosk.snapshot.walls > 0 && kiosk.snapshot.glow > 0
&& kiosk.snapshot.devices > 0 && getComputedStyle(kioskRoot.querySelector('.hdr')).display === 'none';
out.kioskCacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
kiosk.card.remove();
return out;
}, fixture);
checkAll(result);
await finish(browser, result);
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+20
View File
@@ -676,6 +676,26 @@ localized opaque recovery overlay, after a 150 ms delay. The overlay never
steals initial focus; while visible it alone is interactive and the scene is
`inert`.
**The initial snapshot does not depend on live-sync subscriptions** (#131).
`houseplan-card` first accepts config and layout, builds the model, chooses one
exact space, caches the accepted snapshot and restores its viewport. Only then
is the mandatory load complete. Config, trail and layout event subscriptions
start together as independent best-effort enrichments: one rejected channel
does not prevent the others from subscribing, does not erase the usable
snapshot and does not schedule a full-load retry solely for that rejection.
Missing channels get another attempt on the next normal load or reconnect.
`src/initial-load.ts` is the shared authority for the exact space used by a
cached snapshot, a live snapshot and its protected-backdrop candidate. A cold
load considers only valid ids in this order: URL hash, saved navigation,
`default_floor`, first live space. Once an initial URL hash has been consumed,
a valid same-route current selection is preserved instead of repeatedly
snapping back to that hash. Every later revalidation of an already complete
snapshot likewise keeps its valid exact current space; cold precedence is only
for an unsettled initial snapshot. The legacy field initializer is never a
cold-start choice by itself. A plan with no spaces keeps `null` authority and
does not invent an id.
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
card memoizes it on `hass` identity, which Home Assistant replaces on every
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Read-only View and kiosk cards now paint a complete first frame even when
Home Assistant refuses live-sync event subscriptions. The selected space,
room fills, decor, walls, Glow and device values no longer require clicking
the already active space tab ([#131](https://github.com/Matysh/houseplan-card/issues/131)).
## v1.63.0 — 2026-08-13
- Preserved explicit door, window and gate bindings when their standalone
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Read-only карточки в режимах View и киоска теперь сразу показывают полный
первый кадр, даже если Home Assistant запрещает подписки на события live-sync.
Выбранное пространство, заливки комнат, декор, стены, Glow и значения
устройств больше не требуют нажатия на уже активную вкладку пространства
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
## v1.63.0 — 2026-08-13
- Сохранены явные привязки дверей, окон и ворот после удаления отдельного
+231
View File
@@ -0,0 +1,231 @@
# CODE-REVIEW-131-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/131
- **Диапазон:** `origin/dev..HEAD` (`0164e65` спека, `031148e` спек-ревью,
`cebb19a` реализация)
- **Роль:** ревьюер кода (не автор), этап `S7-code-review`
- **Трек:** обычный, цикл r1/4
- **ТЗ:** `docs/specs/131-readonly-cold-start.md`, spec-review
`docs/reviews/SPEC-REVIEW-131-r1.md` (зелёный, r1)
## Скоуп ревью
Проверялось соответствие реализации контракту §7 ТЗ (инвариант выбранного
пространства, приоритет, обязательная/необязательная части загрузки,
деградация live-sync, reload/cache/warm remount) и AC1–AC12 (§11), а также:
- `docs/SCOPE.md` — задача чинит регрессию внутри уже закрытых J1/J6 и
гарантированного View/touch/kiosk-контракта, не расширяет скоуп;
- `AGENTS.md` — классы файлов, трейлеры, синхронность трёх копий бандла;
- `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` —
правки в срезе одного коммита `cebb19a`;
- фактический код `src/houseplan-card.ts` и новый `src/initial-load.ts` —
построчно, включая места, не упомянутые в diff явно (порядок вызовов в
`updated`/`willUpdate`/`connectedCallback`/`disconnectedCallback`,
`_onConnReady`/`_onConnLost`, `_reloadConfigOnly`).
## Как проверялось
### Гейты, которые я прогнал
1. `npx tsc --noEmit` — green, без вывода.
2. `npm test` — **771/771 green** (`node --test`, `duration_ms 4267`). Автор
сообщал 770/771 с известным Windows-only падением
`test/process-gate.test.mjs`; в этом (Linux) окружении все 771 зелёные —
согласуется с тем, что тот дефект специфичен для Windows-путей и здесь не
воспроизводится.
3. `npm run build` — green. Три копии бандла после build побайтно совпадают
друг с другом и с git-версией (никаких незакоммиченных изменений):
`sha256 00f526a2231487fabd36387731c83a483bc8387798c9b51dccd440ba5ae6a5ca`,
идентично значению из комментария автора «Implementation handoff». `git
status --porcelain` после build — пусто.
4. `node --check demo/smoke_readonly_cold_start.mjs` — синтаксис ок.
5. **`node demo/smoke_readonly_cold_start.mjs` — запущен полностью (не
только синтаксическая проверка).** Результат: все 13 внутренних проверок
`true`, `OK`. Он назван в AC3/AC4/AC6/AC8 и напрямую покрывает тронутую
поверхность — цикл реализации по процессу его не обязан был гонять, но
ревью обязано ответить «оно вообще работает», и это самый прямой способ.
6. **Дисциплина «тест умеет падать» — проверена активно, не только чтением.**
Временно подменил `src/houseplan-card.ts` на версию `origin/dev` (без
`src/initial-load.ts`), пересобрал бандл и прогнал тот же smoke:
он **упал** с точной сигнатурой дефекта из issue —
`"space":"f1"`, `"exactSpace":null`, `"activeSpace":null`, `rooms:2` но
`decor:0`, `walls:1`, `glow:0` — и дополнительно показал `"configGets":9`
(непрерывный full-load retry от отказа необязательной подписки — ровно
риск §14.2 ТЗ и находка аналитики S2). После проверки восстановил
`src/houseplan-card.ts` и `src/initial-load.ts` из HEAD, пересобрал,
сверил SHA-256 всех трёх копий бандла — снова
`00f526a2231487fabd36387731c83a483bc8387798c9b51dccd440ba5ae6a5ca`, `git
status --porcelain` пуст. Тест не тавтологичен: он реально различает
старое и новое поведение.
7. Дополнительно прогнал 4 существующих browser-smoke, которые дёргают тот
же переставленный код (`_warmVpArmed`/`_restoreZoom`/порядок в
`_loadFromServer`/`connectedCallback`/`disconnectedCallback`), потому что
это «поверхности, тронутые диффом», а не только названные в AC:
`demo/smoke_warm_remount.mjs`, `demo/smoke_kiosk.mjs`,
`demo/smoke_warm_owners.mjs`, `demo/smoke_warm_dialogs.mjs` — все green,
регрессий в warm-continuity/kiosk-контракте не найдено.
8. Прочитан весь diff `src/houseplan-card.ts` и весь новый `src/initial-load.ts`
построчно (не только заголовки функций), плюс: `test/initial-load.test.mjs`
(проверено, что unit-тесты покрывают именно матрицу AC1/AC5 — валидный/устаревший
hash × `LS_NAV` × `default_floor` × 0/1/N пространств, включая
`legacy 'f1'` и «hash применён один раз, дальше не мешает навигации»),
`demo/smoke_readonly_cold_start.mjs` целиком (не только последний вывод),
`docs/ARCHITECTURE.md`/`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md`/
`tsconfig.test.json` диффы, `git log` трейлеры трёх коммитов.
### Гейты, которые я НЕ прогнал, и почему
- **`npm run golden:verify`** — не прогонял. ТЗ §12 обоснованно (и spec-review
это подтвердил) заявляет, что нового намеренного визуала нет: конечный
вид совпадает с уже существующим состоянием после клика по вкладке,
которое уже покрыто действующими golden-baseline. Правка не меняет ни один
рендер-путь (`_curSpaceCfg`, слои пола/декора/стен/Glow остаются теми же
функциями) — меняется только момент, когда `_space` становится точным ID,
а не логика самой отрисовки после этого момента. Дополнительно проверил
это чтением: ни одна из четырёх прогнанных смоук-проверок (warm_remount,
kiosk, warm_owners, warm_dialogs), которые визуально чувствительны к
той же цепочке кода, не показала расхождений. Решение — сознательный
пропуск, не молчаливый.
- **Полный набор из 127 `demo/smoke_*.mjs`** — не прогонял. Диф не касается
геометрии, редакторов, экспорта/импорта, правил иконок и прочих
поверхностей, не связанных с cold start/space selection/subscriptions;
гонять все 127 было бы гейтом уровня пре-релиза, а не этого код-ревью.
Прогнал 5 смоуков (сам новый плюс 4 связанных по коду) — соразмерно диффу.
- **`npm run performance_smoke` / perf-профили** — не прогонял. AC11 явно
требует «нормализация не в hot path»; проверил это чтением (см. ниже,
раздел «Что проверено и корректно») — все три вызова `_adoptInitialSpace`
и оба вызова `_candidateBackdrop` находятся строго в load/reload путях
(`setConfig` из cache, `_loadFromServer`, `_reloadConfigOnly`), ни один не
вызывается из `willUpdate`/`updated`, которые исполняются на каждый
hass-тик. Новый per-tick код не добавлен — perf-гейт не относится к этому
диффу по своему собственному критерию (AC11), а не потому что его дорого
гонять.
- **`python -m pytest tests_backend -q`** — не прогонял. `custom_components/**/*.py`
в диффе не тронут (`git diff --stat` подтверждает: только
`src/**`, `test/**`, `demo/**`, `docs/**`, `tsconfig.test.json`,
`dist/**`/frontend copies).
- **Ручной просмотр в браузере (не Playwright)** — не делал; данных
достаточно от исполняемых unit/smoke прогонов с реальным shadow DOM
(`demo/smoke_readonly_cold_start.mjs` читает `root.querySelectorAll(...)`,
а не только внутреннее состояние `_model`).
## Находки
Находок уровня **High** и **Medium** нет.
Замечаний уровня **Low** нет: единственная Low-находка предыдущего этапа
(SPEC-REVIEW Low-1, плотная формулировка §7.2 про hash/warm-viewport) —
находка ТЗ, не кода; в реализации проверенное поведение (`_hashApplied`,
`_warmVpArmed`, `preserveCurrent`) соответствует и коду, и намерению ТЗ, и не
требует правки текста ради корректности кода.
## Что проверено и корректно
- **Единый resolver (§8 п.2 ТЗ).** `resolveInitialSpace()` в
`src/initial-load.ts` — чистая функция без побочных эффектов; все три места
выбора пространства (`setConfig` при чтении `LS_CFG`, `_loadFromServer`,
`_reloadConfigOnly`) идут через один и тот же `_initialSpaceSelection()` →
`_adoptInitialSpace()`. Разошедшихся копий правил приоритета нет — ровно то,
что требует архитектурный контракт.
- **Порядок «нормализация до первого live-sync await» (§8 п.1, AC2).** В
`_loadFromServer()` (`src/houseplan-card.ts:3246-3271`) `_adoptInitialSpace`,
`_resumePendingNavMode`, `_cacheSnapshot` и восстановление viewport
выполняются **до** `this._loadOk = true` и до вызова
`_ensureLiveSyncSubscriptions()`; сама подписка на события вынесена в
отдельный метод, вызываемый уже после этой точки. Раньше (в `origin/dev`)
было наоборот: три `await subscribeEvents(...)` шли до выбора пространства,
это и была причина дефекта — воспроизвёл её напрямую (см. «Как проверялось»
п.6).
- **Изоляция rejection необязательных подписок (§8 п.4, AC7, AC9).**
`_ensureLiveSyncSubscriptions()` строит массив `attempts` и передаёт их в
`settleBestEffort()` (`Promise.allSettled` под капотом,
`src/initial-load.ts:44-49`) — отклонение одной подписки не бросает
исключение наружу и не может попасть во внешний `catch` `_loadFromServer`,
который по-прежнему реагирует только на отказ обязательных
`config/get`/`layout/get`/asset (`src/houseplan-card.ts:3272-3282`,
не изменено диффом кроме сдвига `_loadOk`). Юнит-тест
`best-effort optional work starts every attempt and contains rejections`
(`test/initial-load.test.mjs:53-61`) подтверждает это на уровне чистой
функции с управляемыми fulfilled/rejected промисами.
- **Идемпотентность и отсутствие retry storm (§8 п.5–6, AC7, AC11).**
Каждая из трёх подписок оборачивается общим хелпером `subscribe()`
(`:3319-3335`), который проверяет `current()` до попытки и повторно после
резолва (`generation === this._liveSyncGeneration && this.isConnected &&
this.hass?.connection === connection && !current()`) — гонка
disconnect/reconnect посреди подписки корректно ликвидирует «зомби»-
подписку вызовом её же `unsubscribe()`. `disconnectedCallback` теперь чистит
и `_unsubTrail` (`:1947-1950`), чего не было на `origin/dev` — там подписка
на trail никогда не отписывалась при отключении карточки, и её poле
оставалось «занятым» стейл-функцией, из-за чего `_ensureLiveSyncSubscriptions`
ошибочно посчитала бы канал уже подписанным после реконнекта. Эта правка не
упомянута в ТЗ явно, но необходима для корректности той самой
idempotency-гарантии, которую ТЗ требует (§8 п.6) — это не выход за скоуп, а
предусловие для него.
- **Ретрай отклонённой подписки происходит на «следующей обычной
загрузке/реконнекте» (§7.4), а не в hot path.** Единственные вызовы
`_ensureLiveSyncSubscriptions()` — `connectedCallback` (переподключение/
ремонт карточки, `:1862`) и конец успешного `_loadFromServer` (`:3271`),
который сам вызывается из `_onConnReady` (`ready`-событие сокета,
`:3729`) и из `willUpdate` только пока `!this._loadOk` (`:3070`). Ни
`willUpdate`, ни `updated()` не вызывают его напрямую на каждый
hass-тик — подтверждает AC11 «нормализация не в render hot path».
- **Отсутствие права записи не расширяется (AC10).** `_serverCanWrite`/
`_canEdit` не тронуты диффом; смоук `readOnlyStaysReadOnly` (карточка
остаётся `_canEdit === false`, нет `.modetab` в DOM) подтверждён реальным
прогоном, не только чтением.
- **Приоритет и hash-once-then-navigate (§7.2, AC1, AC5).** `_initialSpaceSelection`
передаёt `acceptHash: !this._hashApplied` и `preserveCurrent: this._hashApplied
|| this._navApplied || this._warmVpArmed` — воспроизводит именно то
взаимодействие, которое подтвердил spec-review построчным чтением
`_warmAdoptViewport`/`_pickSpace` до реализации. Юнит-тесты
(`test/initial-load.test.mjs:23-40`) покрывают «explicit hash побеждает
один раз, затем принятая навигация сохраняется» и «legacy `f1` никогда не
трактуется как cold-start источник».
- **Reload/warm remount/kiosk (AC6, AC8).** Прогнанный
`demo/smoke_readonly_cold_start.mjs` подтверждает: cold start без cache,
warm remount без cache, «reload» (повторный маунт с валидным `LS_CFG`) и
kiosk с `reject-all` подписками — во всех случаях `_space`, exact raw
space, все геометрические слои и (где применимо) активная вкладка на
месте без клика; клик по уже активной вкладке — no-op
(`activeTabClickIsNoop: true`).
- **Trailers и class-структура (AGENTS.md).** `cebb19a`: `Issue: #131`,
`User-Visible: yes`, оба changelog правлены в этом же коммите. Файлы —
класс A (`src/houseplan-card.ts`, новый `src/initial-load.ts`), класс B
(`test/initial-load.test.mjs`, `demo/smoke_readonly_cold_start.mjs`,
`tsconfig.test.json`), класс C (`docs/ARCHITECTURE.md`, оба changelog),
класс D (три копии бандла) — синхронны и побайтно идентичны после
самостоятельной пересборки.
- **Соответствие `docs/SCOPE.md`.** Правка — регрессия внутри уже закрытых
J1/J6 и гарантированного View/kiosk-контракта (`docs/TOUCH-SUPPORT.md`);
не открывает новую функциональность, не трогает lock-инвариант, не
удаляет файлы пользователя.
## Чего не проверял
- `python -m pytest tests_backend` — backend не тронут диффом, гейт не
относится к задаче.
- `npm run golden:verify` и `npm run performance_smoke` — см. обоснование
выше в разделе «Гейты… не прогнал».
- Полный набор всех 127 browser-smoke — прогнаны только 5 релевантных
(см. выше); остальные 122 не относятся к тронутой поверхности и остаются
на пре-релизный гейт.
- Реальный HA-сервер с настоящим read-only пользователем — не поднимал;
весь прогон идёт через `demo/serve.mjs` с фейковым `hass`, как и
предполагает штатная smoke-инфраструктура проекта (`AGENTS.md`).
- Поведение `houseplan-space-card` (статическая карточка) — вне скоупа ТЗ
§6 и диффа: ни один изменённый файл к ней не относится.
- Гонку двух параллельных вызовов `_loadFromServer()` для одной и той же
карточки (например, `_onConnReady` и `willUpdate` почти одновременно) не
воспроизводил вручную; прочитал защиту (`this._loading` гейт в начале
`_loadFromServer`, не изменённую диффом) и она выглядит согласованной, но
это разобрано чтением, а не отдельным сценарием — риск не нулевой, но не
новый (существовал и на `origin/dev` в той же форме).
## Вердикт
Зелёный. High: 0, Medium: 0. Реализация покрывает контракт §7 ТЗ и все 12 AC;
переставленный порядок mandatory/optional подтверждён построчным чтением и
воспроизведением дефекта на до-фиксной версии кода тем же smoke-сценарием,
который на исправленной версии проходит полностью. Регрессий в смежных
warm-continuity/kiosk сценариях не найдено.
+226
View File
@@ -0,0 +1,226 @@
# SPEC-REVIEW-131-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/131
- **ТЗ под ревью:** `docs/specs/131-readonly-cold-start.md` (коммит `0164e65`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — владелец явно принял D1/D2
(оценка сложности 4/10, но затрагивает async boot, cache/navigation
precedence, reload и warm continuity; лёгкий трек корректно не применён)
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа,
сохранение lock-инварианта и правила «никогда не удалять файл по догадке»
(задача файлов не касается);
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §12 (запреты);
- `AGENTS.md` — классы файлов (класс A: `src/houseplan-card.ts`; класс B:
тесты/demo; класс C: документация/changelog), ветка `issue/131-readonly-cold-start`,
трейлеры;
- `docs/TOUCH-SUPPORT.md` — View/kiosk как гарантированные touch-поверхности;
- `docs/USER-GUIDE.ru.md` — терминология «пространство», «вкладка», «киоск»;
- фактическому состоянию кода `src/houseplan-card.ts` — на предмет того, что
технические утверждения ТЗ (диагноз причины, поведение hash/`LS_NAV`/warm
viewport, no-op клика активной вкладки) не являются непроверенной догадкой,
а описывают код, который действительно существует.
## Как проверялось
1. Прочитан весь тред issue #131: исходный отчёт с двумя скриншотами,
аналитика S2 (таблица диагностики на `dev` SHA `0e69c4a18337`, два
пространства `home`/`upstairs`), решение владельца о defaults D1–D3
(https://github.com/Matysh/houseplan-card/issues/131#issuecomment-5287280361),
комментарий «Взял» и комментарий «ТЗ готово».
2. Сверены обязательные разделы ТЗ (§7.1 `PROCESS.md`) построчно — таблица ниже.
3. Прочитан `setConfig()` (`src/houseplan-card.ts:2258-2321`): подтверждено, что
`default_floor` и cache-приоритет (`hash → LS_NAV → default_floor → model[0]`)
применяются здесь только когда есть валидный `LS_CFG`; без кэша `_space`
остаётся дефолтным `'f1'` до серверного ответа — совпадает с §3 ТЗ.
4. Прочитан `_loadFromServer()` (`src/houseplan-card.ts:3193-3316`): подтверждено
дословно то, что описывает §3/§8 ТЗ —
- `_adoptStructuralResponses()` (принятие config/layout/`can_write`) вызывается
**до** трёх последовательных `await this.hass.connection.subscribeEvents(...)`
(`houseplan_config_updated`, `houseplan_trail_updated`, `houseplan_layout_updated`);
- выбор `_space` по hash/`LS_NAV`/`_norm`-fallback и `_cacheSnapshot()`
находятся **после** этих трёх `await`, внутри того же `try`;
- отклонённый `subscribeEvents` уходит во внешний `catch`, который при уже
установленном `_serverCfg` вызывает `_scheduleLoadRetry(true)` — то есть
ошибка необязательной подписки трактуется как отказ всей загрузки и
провоцирует непрерывный retry. Это ровно то, что ТЗ называет в §3 п.4 и
в риске §14.2.
5. Прочитан `_warmAdoptViewport()` (`src/houseplan-card.ts:2451-2499`) и
окружающие флаги `_hashApplied`/`_navApplied` (`:1521-1522`, `:2296-2299`,
`:2466`, `:3266-3277`): подтверждено, что explicit `#space=` hash уже сегодня
выигрывает у warm-viewport (`if (this._hashApplied || …) { this._warmVp = null; return; }`),
а принятый warm viewport не сбрасывается менее точным `LS_NAV`/`default_floor`
(`_navApplied = true` защищает ветку в `_loadFromServer`). Формулировка §7.2
ТЗ об этом взаимодействии технически точна, а не придумана заново.
6. Прочитан `_pickSpace()` (`src/houseplan-card.ts:1087-1097`): `if (id === this._space) return;`
— клик по уже активной вкладке уже сегодня no-op. AC4 ТЗ («после исправления
клик активной вкладки — no-op») не вводит новое поведение клика, а лишь
требует, чтобы `_space` корректно совпадал с реальным выбором к моменту клика.
7. Прочитана генерация id пространства (`src/houseplan-card.ts:12067`,
`spaceId = 's' + Date.now().toString(36)`): реальные id никогда не выглядят
как `f1`/`f2`, то есть легаси-дефолт `_space = 'f1'` (`:611`) — синтетический
сентинел, а не потенциально валидный id; диагностическая таблица ТЗ с
`home`/`upstairs` репрезентативна, коллизии не подразумевается.
8. Проверено `docs/CONFIG-COMPATIBILITY.md` — ни `LS_CFG`, ни server-config
schema там не упомянуты в связи с этим изменением; заявление ТЗ §9 «миграция
не нужна» не противоречит канону.
9. Проверено существующее browser-smoke покрытие: `demo/smoke_warm_remount.mjs`,
`demo/smoke_kiosk.mjs`, `demo/smoke_warm_owners.mjs`, `demo/smoke_warm_dialogs.mjs`
реально существуют — предположение §17 п.5 («smoke может расширить
существующий WS/warm lifecycle сценарий») опирается на реальную
инфраструктуру, а не на вымышленный файл.
10. Проверена запись в `docs/specs/README.md` — строка на #131 добавлена в том
же коммите `0164e65`, ссылка issue ↔ ТЗ двусторонняя.
11. Сверена терминология с `docs/USER-GUIDE.ru.md` (§7 «Пространства», §17
«Киоск-режим», обычный просмотр «не отдельная вкладка») — ТЗ использует
«пространство», «вкладка», «киоск» ровно в этом значении, ничего не
изобретает.
12. Сверено с `docs/TOUCH-SUPPORT.md` — «View is fully supported/must be
convenient and reliable» и «kiosk is the primary supported environment»:
формулировка ТЗ §10 «View на touch блокирующий… в киоске требование ещё
строже» дословно соответствует канону, а не собственная эскалация автора.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после (без терминов реализации) | ✅ | §2, одной фразой каждое состояние |
| Проблема | ✅ | §3, с подтверждённой диагностикой и таблицей на конкретном SHA |
| Скоуп / не-скоуп | ✅ | §5 / §6 |
| Контракт поведения | ✅ | §7 (инвариант, приоритет, mandatory/optional, деградация, reload/cache) |
| UX / i18n / accessibility / touch | ✅ | §10 |
| Модель данных и миграция | ✅ | §9 |
| AC1…ACn с доказательством | ✅ | §11, 12 штук, каждый с типом (`unit`/`smoke`/«ревью кода»/`build`) |
| План автотестов | ✅ | §12 |
| Риски | ✅ | §14, 5 пунктов, каждый со ссылкой на закрывающий AC |
| Откат | ✅ | §16 |
| Release-артефакты | ✅ | §15 |
Все обязательные разделы присутствуют и содержательны. Дополнительно есть
раздел §8 «Архитектурный контракт реализации» и §17 «Принятые технические
предположения» — оба корректно отделены от продуктового контракта.
## Находки
Находок уровня **High** и **Medium** нет.
### Low-1 — плотная формулировка §7.2 про hash/warm-viewport взаимодействие
**Файл:** `docs/specs/131-readonly-cold-start.md:146-160`
Абзац описывает две ветки (без принятого warm viewport / с ним) одним плотным
текстом: «применяется существующий порядок… Explicit hash по-прежнему
выигрывает. Уже принятый валидный same-route warm viewport остаётся
существующим continuity-исключением… и не сбрасывается менее точным
saved/default значением». При беглом чтении можно ошибочно понять, что
`explicit hash` всегда переопределяет уже принятый warm viewport — в
реальности (проверено чтением `_warmAdoptViewport`, `src/houseplan-card.ts:2466`)
hash блокирует **принятие** нового warm viewport, но не выбивает уже
принятый в текущем цикле рендера; вторая фраза говорит о независимой ветке
«when warm viewport already accepted», а не о конкуренции с первой веткой в
рамках одного и того же кадра. Технически формулировка точна (я сверил её с
кодом и противоречия не нашёл), но растянута до состояния, что имплементатор
может перечитать её как конфликт правил.
**Почему не блокирует:** AC1/AC5/AC6 explicitly не расширяют матрицу на
конкуренцию «hash vs уже принятый warm viewport» — этот случай прямо в
не-скоупе (§6: «изменение выбора пространства… deep link… не входит»), то
есть задача обязана лишь не сломать существующее поведение, а не
переописывать его заново. Формулировка описывает уже существующий, а не
новый контракт, и не создаёт риска неверной реализации, потому что §8 п.2
и риск §14.1 уже требуют «одного resolver» и защищают его тем же AC1/AC5/AC6.
**Решение ревьюера:** Low, не блокирует. На усмотрение автора — можно
перефразировать двумя явными пунктами («без warm viewport: …», «warm viewport
уже принят: …») при следующей правке или оставить как есть.
## Что проверено и корректно
- Соответствие `docs/SCOPE.md`: задача закрывает J1 («план должен быть читаемым
и полным сразу при открытии») и J6-грань «reload/reconnect/техническое
перемонтирование не должны менять состав видимого плана» — регрессия внутри
уже закрытых Core user jobs, не новая функциональность и не расширение
скоупа; ни один пункт «Out of scope» не задет.
- Гарантированный View/touch/kiosk-контракт (`docs/TOUCH-SUPPORT.md`) учтён
верно и процитирован дословно, не переизобретён.
- Владелец лично принял defaults D1–D3 и приоритет P1 (комментарий
2026-08-14) — открытых продуктовых вопросов в финальной редакции ТЗ нет, и
это корректно: вопросы (триггер cold start vs прав, поведение при
нескольких пространствах, переживание reload) были заданы и закрыты на
этапе аналитики диагностическим прогоном, а не додуманы автором ТЗ.
Раздел §17 «Принятые технические предположения» отделяет свободно
изменяемые технические решения (имя resolver'а, форма orchestration
подписок, механизм retry, файл browser smoke) от продуктовых решений D1–D3,
которые пересмотру не подлежат — ни одна догадка не выдана за факт без
пометки.
- Технический диагноз причины (порядок `_adoptStructuralResponses` → три
`await subscribeEvents` → выбор пространства/`_cacheSnapshot`, отказ
подписки уходит во внешний `catch` и триггерит `_scheduleLoadRetry(true)`)
подтверждён построчным чтением `_loadFromServer()` — не голословное
утверждение автора, а точное описание существующего кода.
- Поведение hash/`LS_NAV`/warm-viewport и no-op клика активной вкладки
(AC4) подтверждено чтением `setConfig`, `_warmAdoptViewport`, `_pickSpace`
— расхождений с ТЗ не найдено (см. Low-1 только по ясности формулировки,
не по фактической корректности).
- AC1–AC12 однозначны, у каждого указан тип доказательства из допустимого по
DoR перечня (`unit`/`smoke`/«ревью кода»/`build`); план автотестов (§12)
даёт конкретный маршрут, включая явное требование покрыть матрицу
valid/stale hash × `LS_NAV` × `default_floor` × 0/1/N пространств (AC1) и
проверять «умеет падать» через фиксированный HA snapshot до/после no-op
клика (AC4).
- Не-скоуп (§6) корректно отсекает смежные соблазны: не менять backend
permissions/websocket API, не добавлять новый toast/recovery overlay, не
трогать порядок вкладок/deep-link/формат cache, не менять
`houseplan-space-card`, не переписывать весь boot lifecycle — типичные
места, где скоуп мог бы незаметно расшириться из-за близости к async boot.
- Раздел §13 (Производительность и security) обоснованно закрывает вопрос
«не ослабляет ли исправление read-only-границу»: подписка на запрещённые
события не эмулируется, write API не вызывается — прямо отражает
требование AC10.
- Release-артефакты (§15) перечисляют реальные файлы:
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` (существуют), `docs/ARCHITECTURE.md`
(существует), `test/*.test.mjs` и `demo/smoke_*.mjs` (существующая
инфраструктура, см. «Как проверялось» п.9), три bundle snapshot
(существующая практика синхронизации по `AGENTS.md`).
- Реестр `docs/specs/README.md` обновлён тем же коммитом, ссылка issue ↔ ТЗ
двусторонняя (`PROCESS.md` §7.1).
- Трейлеры коммита `0164e65` (`Issue: #131`, `User-Visible: no`) корректны:
изменение — только документация ТЗ, продуктовый код не тронут, что явно
подтверждено и в тексте самого ТЗ, и в комментарии «ТЗ готово».
## Чего не проверял
- Не проверял, что предложенная в §8 архитектурная декомпозиция (единый pure
resolver cold precedence) реализуема без побочных эффектов на смежные поля
`_hashApplied`/`_navApplied`/`_warmVpArmed` — по правилам ТЗ (§17 п.1–2) это
свободно изменяемое техническое предположение автора кода, предмет
код-ревью, а не ревью ТЗ.
- Не запускал никаких автотестов и не собирал бандл — на этапе `spec` это не
требуется; существование упомянутых тестовых файлов и smoke-инфраструктуры
проверено чтением файловой системы, не исполнением.
- Не проверял golden/скриншоты — ТЗ §12 явно и обоснованно заявляет их
ненужность (новый визуал совпадает с уже принятым состоянием после клика);
это будущий предмет пре-релизного гейта, не ревью ТЗ.
- Не проверял поведение `houseplan-space-card` (статическая карточка) —
явно вынесено в не-скоуп §6, и это верно: она получает пространство
отдельным обязательным параметром, а не через выбор, который чинит эта
задача.
- Не проверял, действительно ли трёх-подписочный orchestration в текущем
коде уже идемпотентен при повторном вызове `_loadFromServer` после отказа
(авторитет `_unsubCfg`/`_unsubTrail`/`_unsubLayout`, AC7/AC11) —
прочитал структуру `if (!this._unsubX) { this._unsubX = await … }`
(`src/houseplan-card.ts:3232-3263`) и она выглядит согласованной с
требованием, но полное покрытие гонок (двойной параллельный вызов
`_loadFromServer`) — предмет код-ревью на реализованном коде, не ревью ТЗ.
## Вердикт
Зелёный. High: 0, Medium: 0. Одна находка Low (плотность формулировки §7.2
про hash/warm-viewport) — не блокирует, технически формулировка проверена и
корректна, оставлена автору на усмотрение с записью в этом документе.
+397
View File
@@ -0,0 +1,397 @@
# Issue #131 — полный первый кадр View у read-only-пользователя
- **Issue:** https://github.com/Matysh/houseplan-card/issues/131
- **Редакция:** первая редакция для независимого ревью; статус задачи определяется
только метками issue
- **Тип / приоритет:** bug / P1
- **Оценка:** пользовательская ценность 9/10; ценность для разработки 7/10;
сложность и риск 4/10
- **Область:** холодная загрузка `houseplan-card`, выбор пространства,
read-only View/киоск, локальный config-cache, reload и warm remount
- **Модель данных:** без изменений и миграции
- **Связано:** #73, #93, `docs/SCOPE.md`, `docs/TOUCH-SUPPORT.md`,
`docs/UX-MODES.md`, `docs/USER-GUIDE.ru.md`, `docs/ARCHITECTURE.md`
## 1. Сценарий и продуктовый контекст
**Персона:** домочадец без права редактирования плана либо пользователь
настенной панели/киоска. Для него View — основной продуктовый режим, а не
предпросмотр перед редактором.
**Поверхность и момент:** пользователь впервые открывает Lovelace-страницу после
чистой установки, очистки локального состояния или на новом браузере. Сервер
разрешает прочитать House Plan, но HA-сессия не разрешает одну или несколько
подписок на служебные события интеграции.
Задача поддерживает:
- **J1:** план должен быть читаемым и полным сразу при открытии;
- **J6:** reload, reconnect и техническое перемонтирование не должны менять
смысл или состав видимого плана;
- гарантированный View/touch/kiosk-контракт: телефон, планшет и настенная панель
являются целевыми устройствами просмотра и управления.
## 2. Что человек увидит до и после
**До:** при первом открытии видна только часть плана, ни одно пространство не
выделено, а нажатие его вкладки неожиданно «дорисовывает» пол, мебель, стены,
свет и подписи состояний.
**После:** первый кадр уже совпадает с нормальным видом после нажатия вкладки;
пространство выбрано, все его слои и состояния на месте, а нажатие активной
вкладки ничего не исправляет.
## 3. Проблема и подтверждённая причина
`setConfig()` начинает без server-cache с legacy-значения `_space = 'f1'`.
После ответов `houseplan/config/get` и `houseplan/layout/get` метод
`_loadFromServer()` принимает конфигурацию через `_adoptStructuralResponses()`,
но до выбора реального пространства последовательно ожидает три live-sync
подписки.
Отказ первой `subscribeEvents()` попадает во внешний `catch` и пропускает весь
оставшийся хвост успешной инициализации:
1. hash/saved/default/first precedence не применяется;
2. `_cacheSnapshot()` не вызывается;
3. zoom/viewport не восстанавливается по принятому пространству;
4. ошибка ошибочно считается сбоем всей загрузки и запускает полный retry.
При этом `_loadOk` уже установлен и server config уже принят. `_spaceModel()`
скрывает часть ошибки, подставляя первый model space вместо несуществующего
`f1`, поэтому устройства и часть геометрии видны. Но `_curSpaceCfg` ищет точное
совпадение и остаётся `undefined`; header также сравнивает точные ID. Из-за этого
неактивна вкладка и отсутствуют зависящие от raw-space слои.
Диагностика на `dev` SHA `0e69c4a18337` с двумя пространствами `home` и
`upstairs`:
| Сценарий | `_space` | model fallback | exact space | active tab |
|---|---|---|---|---|
| Cold start, подписка запрещена | `f1` | `home` | — | — |
| После клика `home` | `home` | `home` | `home` | `home` |
| Технический remount без config-cache | `f1` | `home` | — | — |
| Новый экземпляр с валидным `LS_NAV`, без config-cache | `f1` | `home` | — | — |
| Reload с валидным config-cache | `home` | `home` | `home` | `home` |
`can_write: false` без отказа подписок загружается правильно. Значит, условие
дефекта — не запрет редактирования сам по себе, а ошибка необязательной подписки
в обязательной последовательности cold start.
## 4. Решения владельца
Владелец принял defaults D1–D3 14.08.2026. Каноническая запись:
https://github.com/Matysh/houseplan-card/issues/131#issuecomment-5287280361
1. Задача имеет приоритет P1 и идёт полным маршрутом без `small`/`trivial`.
2. После принятия серверной конфигурации карточка выбирает ровно одно
существующее пространство по действующему приоритету до пространственного
рендера.
3. Полнота initial snapshot не зависит от права записи, результата
необязательных live-sync-подписок, числа пространств, наличия cache, reload
или технического remount. Отказ подписки может отключить только последующую
live-синхронизацию.
## 5. Скоуп
В задачу входят:
1. обязательное завершение server snapshot после успешных `config/get` и
`layout/get`, даже если любая houseplan event subscription отклонена;
2. выбор валидного пространства до первого пространственного кадра;
3. cold start без `LS_CFG`, включая валидный и stale `LS_NAV`;
4. одно и несколько пространств, валидные и stale `#space`/`default_floor`;
5. сохранение принятого server snapshot в `LS_CFG` после успешной загрузки;
6. reload и same-route warm remount;
7. View и киоск при `can_write: false`; обычная admin-сессия остаётся той же;
8. независимая best-effort установка config, trail и layout subscriptions;
9. отсутствие полного load-retry только из-за отказа необязательной подписки;
10. unit/browser regression coverage, архитектурная документация и два
changelog.
## 6. Не входит в задачу
- выдача read-only-пользователю права редактировать план или подписываться на
запрещённые HA events;
- изменение backend permissions, websocket API или Home Assistant auth;
- гарантированная live-синхронизация после явно запрещённой подписки;
- новый warning, toast, recovery overlay или индикатор ограниченных прав;
- изменение порядка вкладок, названий пространств, `default_floor`, deep link
или формата локального cache/navigation;
- изменение выбора пространства в `houseplan-space-card`, где пространство
задаётся отдельным обязательным параметром;
- изменение рендера пола, мебели, стен, Glow, устройств либо их состояний после
того, как raw space уже выбран правильно;
- изменение reconnect/asset-failure контракта #73 для обязательных данных;
- schema/config migration, import/export и новые compatibility-поля;
- отдельная оптимизация производительности или переработка всего boot lifecycle.
## 7. Контракт поведения
### 7.1. Инвариант выбранного пространства
Если принятая серверная конфигурация содержит хотя бы одно пространство, перед
публикацией spatial candidate одновременно выполняются условия:
- `_space` равен ID существующего пространства;
- model space и exact raw space описывают один и тот же ID;
- ровно одна вкладка full card имеет active-состояние, кроме киоска, где header
намеренно не рендерится;
- все raw-space consumers получают один и тот же объект пространства;
- несуществующее legacy/stale значение не остаётся скрытым за model fallback.
Если пространств нет, карточка сохраняет существующий empty/onboarding-контракт:
она не обязана выдумывать ID и не падает.
### 7.2. Приоритет выбора
Для cold/reload без уже принятого валидного same-route warm viewport применяется
существующий порядок, причём каждый кандидат обязан присутствовать в live model:
1. валидный `#space=<id>`;
2. валидное сохранённое пространство `LS_NAV`;
3. валидный `default_floor` карточки;
4. первое пространство live config.
Невалидный кандидат пропускается, а не сохраняется как `_space`. Explicit hash
по-прежнему выигрывает. Уже принятый валидный same-route warm viewport остаётся
существующим continuity-исключением #73/#93 и не сбрасывается менее точным
saved/default значением; при отсутствии его пространства в live config порядок
выше применяется заново.
### 7.3. Обязательная и необязательная части загрузки
Успешный initial snapshot состоит из обязательных шагов:
1. получить config и layout;
2. при структурном изменении подготовить обязательный backdrop;
3. принять config/layout, revisions и `can_write`;
4. выбрать валидное пространство;
5. записать локальный snapshot;
6. восстановить применимый viewport/zoom;
7. опубликовать полный candidate frame и построить устройства.
Config/trail/layout event subscriptions являются best-effort live-sync. Ни одна
из них не может отменить или задержать перечисленные гарантии после принятия
server data.
### 7.4. Деградация live-sync
- Каждая подписка устанавливается независимо; отказ одной не запрещает попытки
установить остальные.
- Успешная подписка сохраняет текущий idempotent one-subscription-per-card
контракт и штатно очищается при disconnect.
- Неуспешная подписка остаётся доступной для повторной попытки при следующем
обычном load/reconnect, но не запускает tight loop и не перечитывает весь
snapshot только ради подписки.
- Отказ подписки не показывает новый toast и не подменяет принятый config
fallback-данными.
- Ошибка обязательных config/layout calls, обязательного asset или последующей
config reload сохраняет нынешний stale-while-revalidate/recovery-контракт и
не маскируется как успех.
### 7.5. Reload, cache и warm remount
- После успешного server snapshot `LS_CFG` записывается независимо от результата
подписок.
- Сохранённый `LS_NAV` применяется после появления live model, даже если до
server response не было config-cache, в котором можно было проверить ID.
- Технический same-route remount сохраняет валидное пространство по текущему
warm/navigation contract; отсутствие cache не возвращает `_space` к `f1`.
- Валидный старый cache может дать мгновенный полный кадр, но не является
условием корректности.
- После исправления клик уже активной вкладки является no-op и не меняет состав
сцены.
## 8. Архитектурный контракт реализации
Конкретные helper names являются техническим выбором автора, но обязательны
следующие границы:
1. Нормализация пространства является частью принятия structural snapshot и
выполняется до первого `await`, который относится только к live-sync.
2. Один resolver владеет проверкой существования и cold precedence; setConfig,
live load и warm path не должны получать расходящиеся копии правил.
3. Model fallback не считается доказательством валидного exact space. После
принятия непустого config invariant проверяется по точному ID.
4. Подписки изолируют rejection по отдельности; rejected Promise не достигает
outer catch обязательной загрузки и не создаёт unhandled rejection.
5. Ошибка optional subscription не устанавливает recovery/error state полного
кадра и не запускает `_scheduleLoadRetry(true)` сама по себе.
6. Существующие `_unsubCfg`, `_unsubTrail`, `_unsubLayout` остаются authority
идемпотентности: успешную подписку нельзя дублировать.
7. Cache записывает только уже принятые server config/layout и revisions;
optional subscription state в persistent data не добавляется.
8. Device rebuild и continuity candidate читают уже нормализованное пространство;
отдельной исправленной ветки рендера для read-only быть не должно.
Предполагаемые файлы реализации:
- `src/houseplan-card.ts`;
- при полезном выделении pure resolver — небольшой frontend module;
- соответствующий `test/*.test.mjs`;
- `demo/smoke_readonly_cold_start.mjs` либо эквивалентный browser scenario;
- `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
## 9. Модель данных, compatibility и миграция
Форматы `CardConfig`, server config, layout, `LS_CFG`, `LS_NAV` и warm memo не
меняются. Новых полей, schema version, backend validation и compatibility aliases
нет.
Сохранённые планы не переписываются. Исправление вычисляется на каждом старте;
конфигурации прежней версии остаются читаемыми новой и старой версиями. Прямая
и обратная миграция не нужны.
## 10. UX, i18n, accessibility и touch
Новых controls, текстов, фокуса, клавиатурных команд или motion нет. Новые
i18n-ключи en/ru не требуются.
Активная вкладка сохраняет существующую семантику кнопки и focus order.
Исправление не должно программно переводить фокус и не должно объявлять
служебную ошибку подписки через live region.
View на touch блокирующий: первый кадр телефона/планшета обязан быть полным без
тапа по вкладке. В киоске требование ещё строже, потому что header скрыт и обход
недоступен. Touch editor остаётся вне скоупа; право редактирования не меняется.
## 11. Критерии приёмки
- **AC1 (`unit`):** resolver cold selection покрывает матрицу valid/stale
hash, `LS_NAV`, `default_floor`, legacy current ID, одного/нескольких/нулевого
числа пространств и всегда возвращает существующий ID при непустом model.
- **AC2 (`unit` + `ревью кода`):** после принятия config/layout exact space
нормализован и cache/viewport finalization выполнены до optional subscription;
rejection любой подписки не может пропустить эти шаги.
- **AC3 (`smoke`):** read-only cold start без `LS_CFG`, с пространствами
`home/upstairs`, `can_write: false` и rejected houseplan subscriptions сразу
имеет `_space = home`, exact raw space `home`, одну active-вкладку и полный
набор floor/decor/wall/Glow/device-state слоёв без клика и page error.
- **AC4 (`smoke`):** fixed HA snapshot до клика активной вкладки и после её
no-op клика имеет одинаковые проверяемые spatial layers; сцена не
«дорисовывается».
- **AC5 (`unit` + `smoke`):** valid/stale saved/default/hash precedence на
нескольких пространствах выбирает ожидаемый live ID; stale `f1` никогда не
остаётся exact selection, если такого пространства нет.
- **AC6 (`smoke`):** после cold start с rejected subscriptions `LS_CFG`
существует; reload и same-route warm remount без предварительного admin-cache
сохраняют полный кадр и валидное пространство.
- **AC7 (`unit` + `ревью кода`):** rejection config, trail или layout
subscription изолирован: остальные подписки всё равно предпринимаются,
успешные не дублируются, unhandled rejection и full-load retry storm нет.
- **AC8 (`smoke`):** тот же cold сценарий с `kiosk: true` рендерит полный план
при скрытом header; никакого пользовательского действия для восстановления
не требуется.
- **AC9 (`unit` + `ревью кода`):** обязательный `config/get`, `layout/get` или
asset failure продолжает действующий stale-while-revalidate/recovery путь;
optional и mandatory errors не смешиваются.
- **AC10 (`ревью кода`):** исправление не выдаёт право записи, не вызывает
write/service API, не показывает редакторы при `can_write: false` и не меняет
backend/security boundary.
- **AC11 (`unit` + `ревью кода`):** на успешную карточку остаётся не более одной
подписки каждого типа; нормализация не добавляется в HA state render hot path.
- **AC12 (`build` + `ревью кода`):** оба changelog и архитектурный документ
описывают исправление, пользовательское руководство остаётся правдивым без
нового контракта, а три bundle snapshot после build побайтно совпадают.
## 12. План автотестов и проверок
### Unit
1. Выделить или напрямую покрыть pure resolution matrix из AC1/AC5.
2. Покрыть mandatory/optional orchestration контролируемыми fulfilled/rejected
Promises: порядок finalization, независимость трёх подписок, idempotency и
отсутствие retry от optional failure.
3. Сохранить регрессию обязательного fetch/asset failure из AC9.
### Browser smoke
Новый узкий сценарий создаёт отдельную full card с пустым House Plan
localStorage, non-admin hass, `can_write: false`, двумя пространствами с ID, не
равными `f1`, и управляемыми subscription outcomes. Он проверяет AC3/AC4/AC6/AC8
по внутреннему exact state и реальному shadow DOM, а не только по `_model`
fallback.
По действующему решению владельца в цикле реализации запускаются только
`typecheck`, unit и build. Smoke добавляется вместе с кодом, а его штатный
прогон входит в pre-beta browser-smoke gate; независимый код-ревьюер вправе
выполнить целевой сценарий для проверки AC.
### Golden и ручные изображения
Нового намеренного визуала нет: правильный результат уже совпадает с состоянием
после клика и существующими View/golden. Новые baseline и их принятие не нужны.
Скриншоты до/после уже приложены к issue и используются как диагностическая
ссылка, не как новый эталон.
## 13. Производительность и security
Изменение выполняется один раз на structural load, а не на HA state tick.
Дополнительного boolean geometry, DOM-слоя, polling или per-frame resolver нет.
Количество успешных подписок не растёт. Performance benchmark и новый budget
не нужны; штатный performance gate остаётся pre-beta проверкой.
Security boundary не ослабляется: read-only-пользователь только использует уже
разрешённые read calls. Запрещённая подписка не эмулируется, backend permission
не обходится, write API не вызывается. Отдельный security artifact не нужен.
## 14. Риски
1. **Регрессия precedence:** повторная нормализация может затереть valid hash или
same-route warm viewport. Закрывается AC1/AC5/AC6.
2. **Дубликаты подписок:** независимый retry может создать два listener после
позднего успеха. Закрывается authority через `_unsub*` и AC7/AC11.
3. **Ложный успех mandatory load:** слишком широкий `catch` может скрыть отказ
config/layout/asset. Закрывается явной границей phases и AC9.
4. **Partial live-sync:** read-only-сессия может не получать последующие внешние
изменения. Это допустимая деградация запрещённой подписки, но initial snapshot
обязан оставаться полным.
5. **Cache leakage между browser tests:** сценарий должен изолировать и очищать
House Plan keys, иначе валидный admin-cache маскирует регрессию.
## 15. Release-артефакты
Поскольку исправление пользовательски видимо, реализационный коммит содержит:
- `docs/CHANGELOG.md` — EN bug-fix bulletin со ссылкой на #131;
- `docs/CHANGELOG.ru.md` — эквивалентный RU bulletin со ссылкой на #131;
- `docs/ARCHITECTURE.md` — граница mandatory initial snapshot и best-effort
live-sync subscriptions, плюс invariant exact space;
- user guide — **без изменения**: он уже обещает правильный first space,
read-only View и полноценный kiosk/touch; задача приводит код к этому тексту;
- новый unit и browser regression scenario;
- build-синхронизация `dist/houseplan-card.js`,
`custom_components/houseplan/frontend/houseplan-card.js` и
`demo/srv/assets/houseplan-card.js`.
Golden baseline, screenshots, migration, backend, performance и security
артефакты не создаются по причинам из разделов 9, 12 и 13. Перед бетой идут
штатные golden verify, полный browser smoke и performance gate по release runbook.
## 16. Откат
Откат — обычный revert frontend-изменения и синхронных документации/changelog/
тестов/bundle snapshot. Данные и localStorage форматы не меняются, поэтому
чистить cache или восстанавливать конфигурацию не требуется.
После отката у затронутой read-only-сессии вернётся прежний неполный cold frame;
пользовательским временным обходом остаётся выбор пространства там, где header
доступен. Feature flag и обратная миграция не нужны.
## 17. Принятые технические предположения — можно менять без пересмотра продукта
1. Предпочтительно выделить один pure resolver cold precedence, но точное имя и
файл не являются продуктовым решением.
2. Подписки можно устанавливать после mandatory finalization последовательно
с локальными `try/catch` или общей best-effort orchestration; наблюдаемый
контракт и AC важнее формы.
3. Повторная попытка rejected subscription использует следующий штатный
load/reconnect, без отдельного polling timer.
4. Отказ optional subscription остаётся тихим: новый UX ограниченных live
updates потребовал бы отдельного продуктового решения.
5. Browser smoke может расширить существующий WS/warm lifecycle scenario вместо
нового файла, если сохраняет изоляцию cache и все проверки AC.
6. Valid same-route warm viewport трактуется как уже принятое пространство, а
не как новый пятый cold persistence source; explicit valid hash сохраняет
действующий приоритет.
+1
View File
@@ -45,6 +45,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Опциональный объёмный 2.5D/изометрический вид | [089-isometric-view.md](089-isometric-view.md) |
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) |
| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) |
| [#131](https://github.com/Matysh/houseplan-card/issues/131) Полный первый кадр View у read-only-пользователя | [131-readonly-cold-start.md](131-readonly-cold-start.md) |
## P2
+117 -64
View File
@@ -74,6 +74,9 @@ import {
type OpenSpanEntry, type BoundaryTarget,
} from './open-spans';
import { ContentSigner } from './signing';
import {
resolveInitialSpace, settleBestEffort, type InitialSpaceSelection,
} from './initial-load';
import { mdiHomeCityOutline } from '@mdi/js';
import {
Affine, applyAffine, readVacTelemetry,
@@ -621,6 +624,8 @@ class HouseplanCard extends LitElement {
private _cfgContentFingerprint = '';
private _unsubCfg: (() => void) | null = null;
private _unsubLayout: (() => void) | null = null;
private _liveSyncAttempt: Promise<void> | null = null;
private _liveSyncGeneration = 0;
private _layoutRev = 0;
private _layoutContentFingerprint = '';
/** One-deep server snapshot; invalidated by the first later plan edit. */
@@ -1854,6 +1859,7 @@ class HouseplanCard extends LitElement {
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
// AUD-159B1-01: the placement is only knowable once we are IN the DOM.
if (!this._warmSlot && this._config) this._warmAdopt();
if (this._loadOk) this._ensureLiveSyncSubscriptions();
// DEV-B703-03: one task later the element Lovelace replaced has detached
// — only then is its open dialog ours to take over.
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
@@ -1938,6 +1944,12 @@ class HouseplanCard extends LitElement {
this._unsubLayout();
this._unsubLayout = null;
}
if (this._unsubTrail) {
this._unsubTrail();
this._unsubTrail = undefined;
}
this._liveSyncGeneration++;
this._liveSyncAttempt = null;
clearTimeout(this._layoutSyncTimer);
clearTimeout(this._duplicateColumnTimer);
for (const timer of this._glowFadeTimers.values()) clearTimeout(timer);
@@ -2291,12 +2303,7 @@ class HouseplanCard extends LitElement {
this._layoutRev = c.layout_rev || 0;
this._layoutContentFingerprint = c.layout_fingerprint || contentFingerprint(this._layout);
this._serverStorage = true;
const hs = this._hashSpace();
const nav = this._savedNav();
if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; }
else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; }
else if (config.default_floor) this._space = config.default_floor;
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
this._adoptInitialSpace(this._model);
}
} catch {
/* ignore */
@@ -2731,13 +2738,33 @@ class HouseplanCard extends LitElement {
return !space?.bg?.href || this._signer.isReady(this.hass, space.bg.href);
}
private _initialSpaceSelection(models: SpaceModel[]): InitialSpaceSelection {
return resolveInitialSpace({
spaceIds: models.map((space) => space.id),
hashSpace: this._hashSpace(),
acceptHash: !this._hashApplied,
currentSpace: this._space,
preserveCurrent: this._loadOk
|| this._hashApplied || this._navApplied || this._warmVpArmed,
savedSpace: this._savedNav()?.space,
defaultSpace: this._config?.default_floor,
});
}
/** Install one exact raw-space authority before any spatial candidate paints. */
private _adoptInitialSpace(models: SpaceModel[]): InitialSpaceSelection {
const selection = this._initialSpaceSelection(models);
if (!selection.id) return selection;
this._space = selection.id;
if (selection.source === 'hash') this._hashApplied = true;
if (selection.source === 'saved') this._navApplied = true;
return selection;
}
private _candidateBackdrop(config: ServerConfig | null, spaceId = this._space): string {
const models = spaceModels(config);
const hashSpace = this._hashSpace();
const savedSpace = this._savedNav()?.space || '';
const preferred = !this._hashApplied && models.some((space) => space.id === hashSpace) ? hashSpace
: !this._hashApplied && !this._navApplied && models.some((space) => space.id === savedSpace) ? savedSpace
: models.some((space) => space.id === spaceId) ? spaceId : models[0]?.id;
const preferred = this._initialSpaceSelection(models).id
|| (models.some((space) => space.id === spaceId) ? spaceId : models[0]?.id);
return models.find((space) => space.id === preferred)?.bg?.href || '';
}
@@ -3220,61 +3247,14 @@ class HouseplanCard extends LitElement {
&& this._continuity.state === 'steady') {
this._beginContinuityCandidate('structural-response', true);
}
this._loadOk = true;
this._connectionWasLost = false;
this._serverStorage = true;
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
this._resumePendingNavMode();
this._adoptStructuralResponses(cfgResp, layResp);
// live sync: the config was changed in another window → re-read it
if (!this._unsubCfg) {
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
// Flush a pending local edit BEFORE adopting a remote revision:
// otherwise the debounced write reads a config that this reload has
// already replaced, and the user's edit vanishes (audit L2).
const observedRev = Number(ev?.data?.rev ?? -1);
if (observedRev !== this._cfgRev) this._reloadConfigOnly(false, observedRev);
}, 'houseplan_config_updated');
}
// server-side trails are additive: an older backend without the WS
// command just leaves the map empty and the card shows live-only trails
this.hass.callWS({ type: 'houseplan/trail/get' })
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
.catch(() => undefined);
if (!this._unsubTrail) {
this._unsubTrail = await this.hass.connection.subscribeEvents(async () => {
try {
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
this._vacSrvTrails = r?.trails || {};
this.requestUpdate();
} catch { /* transient WS hiccup — the next event retries */ }
}, 'houseplan_trail_updated');
}
if (!this._unsubLayout) {
// Positions are separate state. The static card learned to follow them
// in v1.46.0 and the full one did not, so two full cards side by side
// stayed out of sync until a reload (HP-1460-03).
this._unsubLayout = await this.hass.connection.subscribeEvents(
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
'houseplan_layout_updated',
);
}
const hs = this._hashSpace();
const nav = this._savedNav();
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
this._space = hs;
this._hashApplied = true;
} else if (nav?.space && !this._navApplied && !this._hashApplied
&& this._model.find((s) => s.id === nav.space)) {
// the cached config might have been stale (no such space) — retry once
// the live config is in
this._space = nav.space;
this._navApplied = true;
} else if (this._norm && !this._model.find((s) => s.id === this._space)) {
this._space = this._model[0]?.id || this._space;
}
this._adoptInitialSpace(this._model);
this._resumePendingNavMode();
this._cacheSnapshot();
// DEV-B703-03: a warm re-mount already holds the exact viewport of the
// instance that was thrown away; the centred restore here IS the
@@ -3282,6 +3262,14 @@ class HouseplanCard extends LitElement {
// another space) still needs it.
if (this._warmVpArmed && this._space === this._warmVp?.space) this._warmVpArmed = false;
else if (!hadViewport || this._space !== visibleSpace) this._restoreZoom();
this._loadOk = true;
// Trails and event subscriptions enrich an already complete snapshot.
// A read-only HA session may reject these; that must never roll the
// accepted config back into the mandatory load catch.
void this.hass.callWS({ type: 'houseplan/trail/get' })
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
.catch(() => undefined);
this._ensureLiveSyncSubscriptions();
} catch (e) {
if (this._serverCfg) {
// DEV-B703-02: this instance already RENDERS a valid config (the LS
@@ -3315,6 +3303,67 @@ class HouseplanCard extends LitElement {
}
}
/** Best-effort live sync starts only after the initial snapshot is usable. */
private _ensureLiveSyncSubscriptions(): void {
const connection = this.hass?.connection;
if (!connection || this._liveSyncAttempt) return;
const generation = this._liveSyncGeneration;
const attempts: Array<() => Promise<void>> = [];
const subscribe = (
current: () => (() => void) | null | undefined,
adopt: (unsubscribe: () => void) => void,
event: string,
callback: (event: any) => void | Promise<void>,
): void => {
if (current()) return;
attempts.push(async () => {
const unsubscribe = await connection.subscribeEvents(callback, event);
const valid = generation === this._liveSyncGeneration
&& this.isConnected && this.hass?.connection === connection && !current();
if (valid) adopt(unsubscribe);
else unsubscribe?.();
});
};
subscribe(
() => this._unsubCfg,
(unsubscribe) => { this._unsubCfg = unsubscribe; },
'houseplan_config_updated',
(ev: any) => {
// Flush a pending local edit BEFORE adopting a remote revision:
// otherwise the debounced write reads a config that this reload has
// already replaced, and the user's edit vanishes (audit L2).
const observedRev = Number(ev?.data?.rev ?? -1);
if (observedRev !== this._cfgRev) void this._reloadConfigOnly(false, observedRev);
},
);
subscribe(
() => this._unsubTrail,
(unsubscribe) => { this._unsubTrail = unsubscribe; },
'houseplan_trail_updated',
async () => {
try {
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
this._vacSrvTrails = r?.trails || {};
this.requestUpdate();
} catch { /* transient WS hiccup — the next event retries */ }
},
);
subscribe(
() => this._unsubLayout,
(unsubscribe) => { this._unsubLayout = unsubscribe; },
'houseplan_layout_updated',
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
);
if (!attempts.length) return;
const task = settleBestEffort(attempts).then(() => undefined);
this._liveSyncAttempt = task;
void task.finally(() => {
if (this._liveSyncAttempt === task) this._liveSyncAttempt = null;
});
}
/**
* Adopt the server config. Any pending local write is flushed first and, if a
* write is still in flight, the reload is deferred — adopting a revision on
@@ -3351,9 +3400,12 @@ class HouseplanCard extends LitElement {
this._scheduleLoadRetry(true);
return;
}
const visibleSpace = this._space;
this._adoptStructuralResponses(resp);
this._adoptInitialSpace(this._model);
this._resumePendingNavMode();
this._cacheSnapshot();
if (this._space !== visibleSpace) this._restoreZoom();
this._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
@@ -3674,10 +3726,11 @@ class HouseplanCard extends LitElement {
this._continuityPaintToken = -1;
}
if (this._loading) return;
// a subscribe lost mid-load leaves _loadOk=true without _unsubCfg — the
// full load path repairs both (every subscribe in it is guarded)
// Re-read config and layout as one candidate. `_loadFromServer` now adopts
// each side by revision+fingerprint and preserves equal references.
// Re-read config and layout as one mandatory candidate. Optional live-sync
// subscriptions are then retried independently for whichever channels are
// still missing; their rejection cannot invalidate this snapshot.
// `_loadFromServer` adopts each side by revision+fingerprint and preserves
// equal references.
this._loadFromServer();
};
private _onConnLost = (): void => {
+49
View File
@@ -0,0 +1,49 @@
export type InitialSpaceSource = 'hash' | 'current' | 'saved' | 'default' | 'first' | 'none';
export interface InitialSpaceSelectionInput {
spaceIds: readonly string[];
hashSpace?: string | null;
acceptHash?: boolean;
currentSpace?: string | null;
preserveCurrent?: boolean;
savedSpace?: string | null;
defaultSpace?: string | null;
}
export interface InitialSpaceSelection {
id: string | null;
source: InitialSpaceSource;
}
/**
* Resolve the one exact space that may back a spatial frame.
*
* A same-route warm/hash/nav selection has already recorded newer intent and
* may be preserved by the caller. Otherwise the public cold-start precedence
* is hash -> saved -> default -> first. Every candidate is checked against the
* live model; the legacy `f1` field is never accepted merely because it is the
* class initializer.
*/
export function resolveInitialSpace(input: InitialSpaceSelectionInput): InitialSpaceSelection {
const ids = new Set(input.spaceIds.filter((id) => !!id));
if (!ids.size) return { id: null, source: 'none' };
const candidates: Array<[InitialSpaceSource, string | null | undefined]> = [
['hash', input.acceptHash === false ? null : input.hashSpace],
['current', input.preserveCurrent ? input.currentSpace : null],
['saved', input.savedSpace],
['default', input.defaultSpace],
['first', input.spaceIds[0]],
];
for (const [source, id] of candidates) {
if (id && ids.has(id)) return { id, source };
}
return { id: null, source: 'none' };
}
/** Start every optional operation and wait only for their individual result. */
export function settleBestEffort<T>(
attempts: ReadonlyArray<() => Promise<T>>,
): Promise<PromiseSettledResult<T>[]> {
return Promise.allSettled(attempts.map((attempt) => Promise.resolve().then(attempt)));
}
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveInitialSpace, settleBestEffort } from '../test-build/initial-load.js';
test('initial space follows hash, saved, default and first live precedence', () => {
const base = { spaceIds: ['home', 'upstairs'] };
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'upstairs', savedSpace: 'home', defaultSpace: 'home',
}), { id: 'upstairs', source: 'hash' });
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'stale', savedSpace: 'upstairs', defaultSpace: 'home',
}), { id: 'upstairs', source: 'saved' });
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'stale', savedSpace: 'gone', defaultSpace: 'upstairs',
}), { id: 'upstairs', source: 'default' });
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'stale', savedSpace: 'gone', defaultSpace: 'missing',
}), { id: 'home', source: 'first' });
});
test('legacy current id is not a cold-start persistence source', () => {
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'f1'], currentSpace: 'f1', preserveCurrent: false,
}), { id: 'home', source: 'first' });
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], currentSpace: 'f1', preserveCurrent: true,
}), { id: 'home', source: 'first' });
});
test('a new explicit hash wins once, then adopted navigation is preserved', () => {
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], currentSpace: 'upstairs', preserveCurrent: true,
savedSpace: 'home', defaultSpace: 'home',
}), { id: 'upstairs', source: 'current' });
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], hashSpace: 'home', currentSpace: 'upstairs',
preserveCurrent: true, savedSpace: 'upstairs',
}), { id: 'home', source: 'hash' });
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], hashSpace: 'home', acceptHash: false,
currentSpace: 'upstairs', preserveCurrent: true, savedSpace: 'home',
}), { id: 'upstairs', source: 'current' });
});
test('empty model has no invented selection', () => {
assert.deepEqual(resolveInitialSpace({
spaceIds: [], hashSpace: 'home', savedSpace: 'home', defaultSpace: 'home',
}), { id: null, source: 'none' });
});
test('best-effort optional work starts every attempt and contains rejections', async () => {
const calls = [];
const results = await settleBestEffort([
async () => { calls.push('config'); throw new Error('unauthorized'); },
() => { calls.push('trail'); throw new Error('sync rejection'); },
async () => { calls.push('layout'); return 'subscribed'; },
]);
assert.deepEqual(calls, ['config', 'trail', 'layout']);
assert.deepEqual(results.map((result) => result.status), ['rejected', 'rejected', 'fulfilled']);
assert.equal(results[2].status === 'fulfilled' ? results[2].value : null, 'subscribed');
});
+1 -1
View File
@@ -19,7 +19,7 @@
"src/devices.ts",
"src/types.ts",
"src/space-geometry.ts",
"src/signing.ts",
"src/signing.ts", "src/initial-load.ts",
"src/visual-continuity.ts", "src/mode-transition.ts",
"src/render-device-snapshot.ts",
"src/command-stack.ts",