mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f362248d7 | ||
|
|
90e6323940 | ||
|
|
cebb19a09a | ||
|
|
031148e0e1 | ||
|
|
0164e655c5 |
File diff suppressed because one or more lines are too long
@@ -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
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- Сохранены явные привязки дверей, окон и ворот после удаления отдельного
|
||||
|
||||
@@ -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 сценариях не найдено.
|
||||
@@ -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) — не блокирует, технически формулировка проверена и
|
||||
корректна, оставлена автору на усмотрение с записью в этом документе.
|
||||
@@ -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 сохраняет
|
||||
действующий приоритет.
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user