Compare commits

...
Author SHA1 Message Date
claude[bot] 8ba87b3ff7 docs: review document for #525
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m47s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 2m51s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 1m23s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 1m44s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 2m14s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m45s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m4s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #525
User-Visible: no
2026-09-11 02:00:05 +00:00
Claudeandclaude[bot] ab296302d1 test: register the space-switch witness for the two renderers (#525)
`smoke-select` answered НЕОПРЕДЕЛЁННОСТЬ on this diff: the smoke does not
name `_renderDevice` or `_renderOpenings` anywhere — it switches spaces the
ordinary way and reads running transitions off the nodes those renderers
produced. That is exactly the case `scripts/smoke-links.mjs` exists for, and
without the record the next edit to either renderer selects no witness at all.

Issue: #525
User-Visible: no
2026-09-11 01:42:46 +00:00
Claudeandclaude[bot] 8d5bade656 docs: refresh the screenshot source fingerprint (#525)
The fix touches `src/houseplan-card.ts` and `src/styles/plan.styles.ts`, and
the documentation fingerprint covers the whole `src/**` corpus.

Accepted with `npm run docs:accept -- --identical` (#512): all 11 frames were
re-captured locally and matched the committed ones byte for byte, so only the
fingerprint moves, `7a90ca04 → cd8e9598`. No PNG changes. Environment: Linux,
Chromium 152 — the canon platform of #455.

Issue: #525
User-Visible: no
2026-09-11 01:42:46 +00:00
Claudeandclaude[bot] 9edef041a9 fix: openings and markers keep their identity across a space switch (#525)
Lit reuses list nodes by position. The opening list and the device markers had
no keys, so on a space switch the leaf that held a slot kept its DOM node and
only changed values — and `.op-leaf` (transform) and `.op-arc`
(stroke-dashoffset) carry a 0.6 s transition, so the browser animated a door
that never moved: the new floor's leaf drove in from the previous floor's
opening angle. Measured on two spaces with a door in the same place and
opposite contact states: the node is reused, transform goes
`rotate(-90deg) → rotate(0deg)`, dash offset `0 → 125.66`, both transitions
`running`. The marker shell adds two more with its `box-shadow`.

Both lists are now rendered through `repeat(…, (item) => item.id, …)`, the
same lesson `glow-scene.ts` already learned for the Glow spots. The trap is
written where it starts — above the two transitions in `plan.styles.ts` —
because that is the file someone edits when adding the next animated property.
`houseplan-card.ts` is at its line ceiling, and the note would have cost the
budget a dozen lines for nothing: the swap itself is line-for-line.

The witness walks the shadow tree per element. `document.getAnimations()` is
empty here EVEN ON THE BROKEN CODE — the card lives in a shadow root and the
document-level call does not reach into it, and the issue proposed exactly
that call. The smoke also builds its own fixture: the demo home has no
openings at all, so two doors in two spaces are prepared in the smoke, and it
asserts the other half of the contract as well — a real contact change inside
one space still animates the leaf.

On `origin/dev` the smoke fails on five facts, naming the offenders:
`op-arc:stroke-dashoffset`, `op-leaf:transform`, `device-shell-frame:box-shadow`
twice. Mutants `openings-rendered-without-keys` and
`device-markers-rendered-without-keys` put each `map` back.

Perf, 7 samples against `19e421b3`: spaceSwitchMs 524.8 (limit 769.35, base
512.9), switchCycleMs 1293.9 (1696.28, 1256.5), firstStableRenderMs 2533.8
(3000, 2529.2), modelReadyMs 732.7 (944.97, 726.9), longTask.maxSingleMs 663
(910, 660) — `benchmark:compare` green in full.

The initial View graph grows 241 B gzip: `repeat` enters it for the first
time. The #438 ceiling is recentred 300 400 → 300 700 with the usual dated
note; measured 300 059 B keeps 641 B above and 1 359 B below the band. The
301 066 B budget is untouched, but only 366 B now separate the ceiling from
it — the #367 headroom debt has stopped being theoretical.

Issue: #525
User-Visible: yes
2026-09-11 01:42:46 +00:00
claude[bot] 95175a7616 docs: review document for #525
Issue: #525
User-Visible: no
2026-09-11 01:20:43 +00:00
Claude 19e421b3cf Refresh the v1.74.0-beta.1 candidate on the merged line
The candidate was assembled before #520 and #521 landed. Version fields are
already at 1.74.0-beta.1 in all seven sources and the bundles rebuild
byte-identical, so this commit only moves what the beta says about itself:
the #521 changelog entry leaves Unreleased for the beta.1 section in both
languages, the release notes gain its bullet on both sides (four and four,
the grouped small-fixes bullet stays last), and STATUS records the two
issues merged since — the alignment guides that follow the gesture again
(#521, a regression shipped in v1.72.0 and v1.73.0) and the cold-start
adoption fix behind it (#520, internal).

No product change: `npm run build` plus `bundle-sync` leave `dist/**` and
the two mirrored trees untouched, `node scripts/release-contract.mjs
v1.74.0-beta.1` is green on all seven version sources, `npm test` 2515/0.

The `Release:` trailer is what asks CI for the heavy gates — smokes, golden
and the full performance comparison — on this exact SHA (#479).

Issue: #521
Issue: #520
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 23:59:13 +03:00
claude[bot] 3f3583ac58 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 20:48:53 +00:00
Claude 9a3af808d9 fix: hide the settled guides copy for the whole gesture, and prove it (#521)
Code review r1 was right that AC5's evidence was empty: the smoke never
forced a settled render during a gesture, so the settled copy of
`.hp-editor-only-layer` was empty at every point it looked, and
`groups() === 1` held whether the copy was hidden or not.

Measuring the case the reviewer named turned up more than a weak assertion.
Ownership of the layer alternates on its own — every settled render ends in
`updated()` → `_commitLiveEditor()`, which empties the live root — so a
settled render mid-gesture takes the guides back and draws them itself, from
the same live `_alignPoint`. That much needs no suppression. But the copy it
leaves behind stays in the settled scene, and the NEXT live paint adds a
second one: measured two `.alignline` on one alignment, the settled one a
grid step behind the marker. So the suppression stays, and now it stays with
a witness.

The smoke counts what is visible, not what is in the DOM: the hidden copy is
still a node, and counting nodes is how this check could have looked green
while showing the user two lines. Its device scenario now drives the whole
handover — force an unrelated settled render mid-drag (`_hdrH`, the same
header-height observer that masked the defect in the S2 measurements), assert
the render actually happened, that the layer went back to the settled scene
with the live point on it, and that one real move later the live painter owns
it again — exactly one visible guide at every step.

Mutant `live-editor-keeps-the-settled-guides-visible` puts the suppression
back under the plan branch, as it was before this issue, and the smoke goes
red on `nextMoveTakesTheLayerBack`.

Issue: #521
User-Visible: no
2026-09-10 23:25:46 +03:00
claude[bot] deebcfb095 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 20:05:44 +00:00
Claude 0715321fdc docs: refresh the screenshot source fingerprint (#521)
The fix touches `src/live-editor.ts` and `src/houseplan-card.ts`, and the
documentation fingerprint covers the whole `src/**` corpus, so `check-docs`
called the committed screenshot index stale — the same step #520 needed.

Accepted with `npm run docs:accept -- --identical` (#512): all 11 frames were
re-captured locally and matched the committed ones byte for byte, so only the
fingerprint moves, `c615d580 → 7a90ca04`. No PNG changes, nothing here needs
the owner's visual acceptance. Environment: Linux, Chromium 152 — the canon
platform of #455.

Issue: #521
User-Visible: no
2026-09-10 22:24:27 +03:00
Claude 53585b451b fix: alignment guides follow the live gesture again (#521)
#451 moved every editor gesture onto the live painter, and the guides stayed
behind in the settled scene. While a gesture runs, the settled scene is not
re-rendered at all, so the guides did not follow the marker in the device
editor, the shape in the backdrop editor, or the cursor while a contour is
drawn in the plan editor. Measured with real pointer events on the demo stand
against `origin/dev`, after waiting for the editor chrome to settle: three
gestures, each exactly on another object's axis, 0 settled render cycles,
`.alignline` 0 and no `.alignguides` group in all three.

The report called it two breaks. It is one — the layer — plus one thing that
would have broken the repair: `_alignPoint` read `_pos`, which during a live
gesture answers from the snapshot of the last settled render. Over one drag:
live 254.17 → 220.83 while `_pos` stayed at 254.17, eight grid steps behind,
so a restored layer would have drawn the guide at the marker's old place.

The live template now paints the guides in all three modes (the device editor
had no template at all — `paintDevice` only moves the marker element), and
`_alignPoint` takes the live position. The settled copy of
`.hp-editor-only-layer` is made transparent for the duration of any editor
gesture, not only in plan mode: two guides, one of them stale, is what the
user would otherwise see when an unrelated settled render lands mid-gesture.
`_renderAlignGuides` on the card becomes soft — a gesture that starts while
the editor runtime is still loading must cost nothing, and an exception inside
a `requestAnimationFrame` paint would take the whole gesture with it.

The witness is rewritten around the defect that hid this for two stable
releases: the old smoke assigned `_deviceDrag`/`_decorDraft` wholesale, and an
assignment with `oldValue == null` does not route to the live path — it
verified a state a real gesture never reaches. Every scenario now drives real
`PointerEvent`s, waits for silence first (the `_hdrH` settling window right
after entering a mode hands out settled frames that make even the broken code
draw a guide), and asserts zero settled cycles during the movements plus
exactly one `.alignguides` group. #400's exclusion is checked without touching
the drag state: the dragged marker must simply be absent from the candidates.
On `origin/dev` the smoke fails on nine of its facts; a witness that stays
green before the fix was the actual bug here.

Mutants: `live-editor-devices-drops-align-guides`,
`live-editor-decor-drops-align-guides`, `live-editor-plan-drops-align-guides`,
`align-point-reads-frozen-snapshot` — one per AC, all guarded by the smoke.
`test/smoke-harness-contract.test.mjs` pins that the smoke cannot go back to
fabricating gesture state.

Issue: #521
User-Visible: yes
2026-09-10 22:20:18 +03:00
claude[bot] fa7ac02c0f docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 19:05:32 +00:00
claude[bot] 548e7a8563 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 18:57:55 +00:00
claude[bot] 9592092b8e docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 18:49:42 +00:00
claude[bot] c50bc725c8 docs: review document for #522
Issue: #522
User-Visible: no
2026-09-10 18:40:54 +00:00
claude[bot] b2346eaac3 docs: review document for #520
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 2m16s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m55s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 52s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #520
User-Visible: no
2026-09-10 18:26:40 +00:00
Claude a325294d7a docs: refresh the screenshot source fingerprint (#520)
The fix touches `src/houseplan-card.ts` and `src/config-adoption.ts`, and the
documentation fingerprint is computed over the whole `src/**` corpus, so
`check-docs` called the committed screenshot index stale — the Medium of code
review r2.

Accepted through `npm run docs:accept -- --identical` (#512): all 11 frames
were re-captured locally and compared byte-for-byte with the committed ones —
«Все 11 кадров попиксельно совпали с закоммиченными». Only the fingerprint
moves, `ccbbfb4e → c615d580`; not a single PNG changes, so nothing here needs
the owner's visual acceptance. Environment: Linux, Chromium 152 (the canon
platform of #455; capture refuses elsewhere).

`node scripts/check-docs.mjs` — «Documentation checks passed (7 files, 12
external links)».

Issue: #520
User-Visible: no
2026-09-10 21:15:42 +03:00
claude[bot] 3066fec17e docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 17:47:45 +00:00
Claude 462b56453c fix: the authoritative adoption keeps its own task (#520)
The r1 diagnosis was wrong, and the measurement in the code review proved
it: removing the two declarations from `static properties` left the cold
start at 19 update cycles, 4 model builds and 4 config epochs, exactly the
numbers of the bug. Lit's forced first-update change does mark `_serverCfg`
changed, but at that moment the body and `_cfgEpochPreservedConfig` are both
null, `preserveGeometry` is true and the epoch does not move. The comment
above `static properties` now says that; the declaration still stays out,
because two owners of one reactivity is what #500 removed.

The real cause is the `await`. Before #500 everything from
`_adoptStructuralResponses` to the end of the load ran in one task: the
adopted bodies, `_adoptInitialSpace`, the viewport restore, `_loadOk`, and
the device seeding — whose `_syncNewDevices`/`_seedHiddenDevices` write the
config back — all landed in a single Lit update. #500 made the adoption an
async sequence, so the caller resumes one microtask later, after Lit has
already painted the adopted config; the seeding writes then arrive as a
second config epoch, a second model build and a second paint of a 60-room
house.

`GatedAdoptionInput` gains `afterAdopt`, the mirror of `beforeAdopt`: it
runs synchronously at the end of the sequence, before the promise resolves.
`_loadFromServer` moves the viewport restore, `_loadOk` and the device
rebuild into it — `_syncNewDevices` refuses to write before `_loadOk`, so
the order inside the hook matters — and the load tail now rebuilds devices
only when nothing was adopted. `_reloadConfigOnly` takes the same route.

Measured with the project's own runner, 7 samples per profile, base
`a44fbd37` against this tree (Chromium 152, sandbox):

  interaction  modelReadyMs 761.3 ≤ 950.56 (base 731.2)
               firstStableRenderMs 2567.2 ≤ 3000 (base 2542.7)
               longTask.maxSingleMs 690 ≤ 921 · cache.entries.cleanFloor 100
  isometric    modelReadyMs 1252.9 ≤ 1499.76 (base 1249.8)
               firstStableRenderMs 1378 ≤ 1610.16 (base 1341.8)

Boot diagnostics on both trees: 18 update cycles, 3 model builds, 3 config
epochs, with the same epoch trace — the candidate is no longer
distinguishable from the base.

Witnesses. `config-adoption.test.mjs` queues a microtask at the start of
the adoption and pins that `afterAdopt` runs before it — the probe fails the
moment the hook crosses an await; `config-adoption-ownership.test.mjs` pins
the wiring in the card and the hook's place in the sequence. Mutants
`adoption-tail-defers-caller-hook` (defers the hook by one microtask) and
`authoritative-load-seeds-devices-after-the-await` (drops the rebuild from
the hook) redden them.

The initial View graph grows 40 B gzip, so the #438 ceiling is recentred
300 300 → 300 400 with the usual dated note; measured 299 812 B keeps 588 B
above and 1 412 B below the band. The overall 301 066 B budget and the #367
headroom debt are untouched.

Issue: #520
User-Visible: no
2026-09-10 20:12:13 +03:00
claude[bot] 49a02637ca docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 16:27:39 +00:00
Claude a625871538 test: a mutant for the only surviving wake-up of a replaced config body (#520)
With the bodies no longer declared as Lit properties, `onBodyReplaced` is the
single path that turns a replaced config reference into an update — AC2 of
this issue rests on it, and until now only a hand-run mutation stood behind
that column. The mutant drops the notification from `setConfig`; the existing
unit in `test/config-adoption.test.mjs` reddens on it.

Verified: `node scripts/mutation-gate.mjs --id=adoption-notifies-no-host-on-config-replacement`
— «поймано 1 из 1».

Issue: #520
User-Visible: no
2026-09-10 19:03:21 +03:00
Claude e181b08f2b fix: the adoption bodies are no longer declared Lit properties (#520)
#500 gave `_serverCfg` and `_layout` prototype accessors but left them in
`static properties`. Lit marks such a property `wrapped` and, on the FIRST
update, force-writes it into `changedProperties` with an `undefined` old
value even though nobody assigned anything (`reactive-element.js:249-252`
and `:880-886`). `willUpdate` reads that as a config replacement, raises
`_cfgEpoch`, the memoized model key changes, and a 60-room house builds and
paints its model a second time: measured 19 update cycles, 4 builds and 4
epochs against 18 / 3 / 3 before #500, worth ~550 ms of `modelReadyMs` and
the same on `firstStableRenderMs` (3355 against a 3000 ceiling).

The declaration goes; the bodies stay reactive through the owner —
`_adoption` → `onBodyReplaced` → `requestUpdate(field, previous)` — which
needs no declaration: `getPropertyOptions` falls back to the default and
`changed.has('_serverCfg')` works as before. `noAccessor: true` would not
help, `wrapped` is set before that flag is read. The trap is written above
`static properties`, where someone would put the declaration back.

`cache.entries.cleanFloor` returns to 100 in both interaction budgets: the
120 entries were the extra epoch re-keying the per-room cache, not a
property of the design — the reasoning in 914e8402 was wrong.

Witness: test/config-adoption-ownership.test.mjs pins that neither body is
declared; the mutant `adoption-bodies-declared-reactive` puts the
declaration back and reddens it.

The boot diagnostics of the previous three commits touch four private
members, so they are declared in the performance contract: `_buildModel` and
`_cfgEpoch` outright (both exist in every supported comparison base), and the
adoption entry point as a current/legacy pair — #500 turned the private
`_adoptStructuralResponses` into the public `_adoptAuthoritative`, and an
undeclared rename would have the counter report zero adoptions instead of
failing.

The same commits carried a `node_modules` symlink: `.gitignore` had the
pattern with a trailing slash, which does not cover a symbolic link, and
`git add -A` in a sandbox worktree committed it. The link is removed and the
pattern loses the slash; a mutant run on this branch failed with `EEXIST` on
it.

Issue: #520
User-Visible: no
2026-09-10 18:58:24 +03:00
claude[bot] 50fa7f9be2 docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:38:40 +00:00
claude[bot] b49721a0bd docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:30:21 +00:00
claude[bot] ad2f9afd2b docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:20:07 +00:00
Claude 27ea23c1ec test(perf): trace every config-epoch bump with the frame that made it (#520)
The comparison already names the mechanism: base does 18 update cycles, 3
model builds and ends at epoch 3, the candidate does 19, 4 and epoch 4, and
the extra build is the whole ~550 ms. What is still missing is the caller.
The diagnostic now installs an instance-level setter over `_cfgEpoch` and
records `from->to` with the top stack frames, so the extra bump names itself.

Issue: #520
User-Visible: no
2026-09-10 17:57:27 +03:00
Claude 3d43980568 test(perf): the boot diagnostics travel out of the page into the run log (#520)
The first attempt printed them with console.log inside page.evaluate, and
nothing forwards the page console to Node — the numbers went nowhere. The
sample now returns `bootDiag`, the runner prints it and strips it before the
row is recorded, so the budgeted record keeps its shape.

Issue: #520
User-Visible: no
2026-09-10 17:47:18 +03:00
Claude 37ef970dfc test(perf): count update cycles, model builds and adoptions during boot (#520)
The full comparison says model readiness grew by ~500 ms inside #500 and
that the growth sits in one long task, but neither contentFingerprint
(2.8 ms on this fixture) nor spaceModels (0.1 ms) can account for it. The
benchmark now prints, per sample, how many Lit update cycles ran before the
first stable frame, how long they took together, how many models were built,
how many adoptions happened and the config epoch. Diagnostics only: printed
to the log, never part of the budgeted record, and the same harness runs
against the comparison bundle, so candidate and base are counted alike.

Issue: #520
User-Visible: no
2026-09-10 17:38:36 +03:00
Claude 914e84028d perf: the clean-floor cache holds one entry per room per config epoch (#509)
Проверка (CI) / Классификация изменённых файлов (push) Successful in 52s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m32s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 43s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 41s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m11s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Failing after 4m28s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Failing after 4m36s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Failing after 4m43s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Failing after 4m38s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Failing after 3m58s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Failing after 4m7s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m49s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m2s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The pre-release perf gate of the v1.74.0-beta.1 candidate reported
cache.entries.cleanFloor 120 against a ceiling of 100 (run 34480302982,
large-house-interaction-v1). The ceiling was calibrated when only the visible
space populated `_cleanFloorCache`; since #509 the summary panel computes the
clean-floor total in per-room slices through the same cache, so the sixty
fixture rooms are cached under both config epochs the interaction profile
creates — 60 × 2 = 120, exactly what the run measured.

The ceiling moves to 180 in the smoke and in the full interaction profile:
one entry per room per epoch with room for a third epoch, far below the LRU
cap of 600 and far below anything a per-frame or per-marker regression would
produce. The leak detector is untouched: cacheGrowth.cleanFloor stays 0.

Issue: #509
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 16:29:31 +03:00
Claude d5575f6393 Prepare v1.74.0-beta.1
Version fields and the generated bundles move to 1.74.0-beta.1 and open the
line after the stable v1.73.0. The changelog entries of #509 and #508 leave
Unreleased for the beta.1 section; release notes and STATUS describe them and
name the internal work of this beta. No product change beyond what is already
reviewed and merged.

Issue: #509
Issue: #508
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 16:01:59 +03:00
Claude 1950df608f docs: refresh the screenshots fingerprint before the v1.74.0-beta.1 candidate
The #500 adoption work changed src/** without touching any of the eleven
documented frames. The owner re-captured them in WSL — the canonical Linux
environment, because DirectWrite on native Windows never reproduces an
accepted frame — and `npm run docs:accept -- --identical` (#512) found zero
differing pixels, so only the source fingerprint moves:
48f770cf → ccbbfb4e. Frame bytes, their hashes and the capture-script guard
stay exactly as accepted.

Issue: #500
User-Visible: no
2026-09-10 16:01:58 +03:00
claude[bot] 94ea4694f4 docs: review document for #500
Проверка (CI) / Классификация изменённых файлов (push) Successful in 47s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m24s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m14s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #500
User-Visible: no
2026-09-10 12:32:21 +00:00
Codex be6d57e96f test(adoption): the post-write smoke resets its own pending writes
Review r2 M1. The smoke's reset() left the previous scenario's debounced
config/layout write pending; _deleteSpace flushes whatever is pending
before it writes, the fake socket answers without a rev, and the
documented rev+1 fallback then moved the revision on a body from another
scenario. The refused branch looked as if it had adopted:
onboardingDeleteRefusedAdoptsNothing was red on every run. The scenarios
are supposed to be independent, so reset() now cancels both debounced
writers, as smoke_danger_confirmation already does.

37/37 green, three runs in a row; with the cancel removed the same
single check goes red again.

The refused-tail fix from r1 had no witness in CI at all: no mutant
named this smoke as its guard, so the review gate never ran it and a red
witness survived a whole round. A witness that never runs is not a
witness, so the early return in _undoPlanOptimization now has a mutant
that names the smoke.

Issue: #500
User-Visible: no
2026-09-10 15:16:12 +03:00
claude[bot] 5acf04fa33 docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 12:08:21 +00:00
claude[bot] 93c6c7551b fix(adoption): post-write callers skip their tail on a refused gate (#500 r1 M1)
`space/delete` (both runtimes), Optimize Undo and Import apply now treat
`asset-wait` like every reload path: nothing was adopted, so no toast, no
space switch, no history/undo reset — the dialog is released and the
scheduled reload owns the rest. Unit and smoke cover the refused branch for
all four paths; the spec's reactivity risk row states the real mechanism.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] bac63dbcaf docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 31c2583e46 fix(adoption): a replaced body stays a reactive host event (#500)
`willUpdate` keys the geometry epoch and render-lifecycle invalidation on
`changed.has('_serverCfg')`. Before #500 every body replacement went through
Lit's accessor and produced that event; the owner wrote its field directly
and the epoch stopped moving on adoption, staging and rollback — the safe
Resize smoke then measured against a stale model (Validate on c360bcc9).
`MutableConfigAdoption` now reports each replaced reference through
`onBodyReplaced`, which the card wires to `requestUpdate(field, previous)`;
echoes and identity-only changes stay silent, exactly as an unchanged
reference never fired the accessor.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 2f41c0b29e test(adoption): ownership lint, post-write smoke, harness seam and docs (#500)
`test/config-adoption-ownership.test.mjs` pins identity writes to the owner
and ratchets body staging (AC1/AC2). `demo/smoke_post_write_adoption.mjs`
drives space/delete (both runtimes), Optimize Undo and Import apply with a
concurrent backdrop change between the write and the re-read (AC4). Smokes
that seed revisions from outside the card keep working through the
`seedIdentity` harness seam behind the card's delegate setters. The initial
View ceiling is re-centred with the measured fact; ARCHITECTURE.md gets the
boundary paragraph.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] a3e7ac1fc6 test(adoption): revision guard witness for optimistic rollback (#500 AC6)
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 7194fb2df0 feat(adoption): one owner for config/layout identity and the adoption sequence
`src/config-adoption.ts` owns config/layout with revision and fingerprint;
the host keeps `_serverCfg`/`_cfgRev`/`_layout`/`_layoutRev` as delegates.
All seven authoritative adoptions go through `adoptAuthoritativeGated`
(backdrop readiness → continuity → adopt → profile tail); the post-write
paths (space/delete ×2, optimize_undo, import/apply) gain the gate and take
revisions from the re-read bodies. `rollbackOptimistic` moves to the owner;
plan-optimize, space copy and the vacuum writers stop assigning identity.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] a44fbd373d docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] c21e9de32b docs(spec): #500 r3 — post-write profile keeps caller tails untouched
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 9bbba66d11 docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] b4a2d5b952 docs(spec): #500 r2 — seven adoption callers, two profiles, vacuum writers
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] b6a66a1bac docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 88eb382403 docs(spec): #500 one ownership boundary for config adoption
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
Codex f6eac1d971 docs: the review gate charges a round only for what the round changed
Issue: #518
User-Visible: no
2026-09-10 14:31:06 +03:00
Codex 67c780a742 test: the review gate's own job names follow the six shards
Issue: #518
User-Visible: no
2026-09-10 14:20:34 +03:00
Codex 2783ceff94 perf(ci): judge a witness by its anchor's neighbourhood, not the whole file
The review gate re-ran almost every selected witness on every round even
when the executor's fix was twelve lines: the ledger fingerprint and the
diff selection both worked on whole files, and the card hosts are
thirteen thousand lines each. On #500 those twelve lines in
houseplan-editor-runtime.ts pulled 53 of the 75 witnesses the third
round ran, and the gate cost 140 job-minutes and an hour of the
reviewer's wall clock across three rounds.

The patch side is now judged by the anchor's neighbourhood — the anchor
lines plus ANCHOR_RADIUS_LINES on each side — in both the ledger
fingerprint and the diff selection, which now reads hunk ranges from
git diff --unified=0. The guard side keeps whole-file granularity: a
guard has no anchor and changes as a whole. An anchor that is not found
exactly once falls back to the whole file, and so does a file whose
hunks were not read: not knowing is not proof. Same class of
approximation as the existing diff selection, with the nightly full
gate (#513) as the floor.

Two more cuts to the wall clock of a review round. The shard plan is now
computed before the environment is installed — restore the ledger,
select, split, and only then pay for npm ci, Python and Chromium; the
job still runs, so the review gate's proof (#510) is unaffected. And the
matrix goes from three shards to six: the same job-minutes, half the
wall time.

On the #500 round the selection drops 60 → 7. Four witnesses guard the
new logic, including the two unsafe defaults (ambiguous anchor, missing
hunks).

Issue: #518
User-Visible: no
2026-09-10 14:17:36 +03:00
claude[bot] 0c74828240 docs: review document for #509
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 51s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 37s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 55s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m29s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 1m3s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 8m21s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m42s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #509
User-Visible: no
2026-09-10 09:12:51 +00:00
Codex 9b00002a5b test: allow the indivisible per-space masonry slice in the first-paint budget
The residual 150–200 ms tasks are one space's wallBodiesGeometry — a
single polyclip union that cannot be split — not the aggregate the issue
is about. The threshold now names them and leaves room for a slower CI
runner; the mutant that computes everything in one task still produces
~1.5 s and fails.

Issue: #509
User-Visible: no
2026-09-10 11:48:30 +03:00
Codex 19b14d3976 test: the first-paint smoke watches only the panel's own long tasks
On the CI runner the smoke went red without any mutant: the observer was
started before the 60-room plan had finished drawing, and that render —
a long task of its own, unrelated to this issue — landed in the window.
The smoke now waits for a quiet main thread before it starts watching,
prints what it measured, and allows up to 450 ms per task: the residual
150–200 ms slices are one space's masonry union, which polyclip cannot
split, while the mutant that puts the whole aggregate back into one task
still produces ~1.5 s.

Issue: #509
User-Visible: no
2026-09-10 11:47:25 +03:00
Codex 172d9d5a00 docs: refresh the screenshots fingerprint after the summary-panel fix
All 11 frames are pixel-identical (docs:accept --identical, #512); only
the source fingerprint moved with src/**.

Issue: #509
User-Visible: no
2026-09-10 11:35:26 +03:00
Codex 588ef42546 fix: the clean-floor total is computed in per-room slices, not one long task
Moving the aggregate out of render fixed the first frame, but the work
itself was still one uninterrupted ~1.5 s task on the large-house
fixture — the interface stayed frozen, just a moment later, which is the
same symptom the issue reports. cleanFloorAreaSteps yields after every
room; the runtime advances it with an 8 ms budget per frame and
reschedules until it finishes, so no slice outlives a frame and the
skeletons stay until the number is ready.

The smoke now watches longtask entries for the whole show, not only the
first frame: a single long task while the values are computed fails it.

Issue: #509
User-Visible: no
2026-09-10 11:31:37 +03:00
Codex e2bb1e5390 test: the clean-floor total is timed on a real floor, not just counted
The injected-counter test proves "one geometry pass per space" but not
that its result reaches innerContourForRoom — without the shared
arguments that function rebuilds the masonry per room, and the only
observable difference is time (176 ms per room, S2). One large-house
floor: ~0.6 s with the shared pass, ~3.7 s without, so a 2.5 s threshold
is coarse enough not to flake.

Issue: #509
User-Visible: no
2026-09-10 11:26:29 +03:00
Codex 398281a93f fix: the summary panel's first paint no longer freezes or claims the sources are gone
Two halves of the same first paint. The panel showed «Source unavailable»
in every row until the lazy metrics chunk arrived, because value() could
not tell "not loaded yet" from "source is dead"; and metrics() ran inside
render, walking the HA registry and unioning the clean floor of every
space synchronously — 11 s on the large-house fixture.

- totalCleanFloorAreaM2 computes the space's masonry and junction
  topology once per SPACE and hands them to innerContourForRoom, which
  otherwise unions the whole space again for every room: 11 045 → 1 488 ms
  on that fixture, same 306.3 m². The card has always done this through
  its own _innerContour cache; the panel now does the same.
- Aggregates leave the render path: the first frame paints skeletons and
  the work starts right after the frame is shown (timeout → rAF →
  timeout, never requestIdleCallback, which under load would leave the
  skeleton up for seconds). Stale memo keeps the previous number on
  screen instead of flashing back to a skeleton.
- valueState() separates pending from unavailable; a pending row keeps
  the same plate, grid and height and carries a pulsing rectangle the
  height of the line, replaced by the value with a short fade. Reduced
  motion keeps the rectangle and drops the pulse.
- Panel enter/exit animation (#505, 190 ms) is now actually visible —
  the main thread is free — and the smoke witnesses it.

Mutants: summary-first-paint-shows-unavailable, summary-metrics-block-first-frame,
summary-area-recomputes-walls-per-room, summary-stale-metric-falls-back-to-skeleton.

Issue: #509
User-Visible: yes
2026-09-10 11:24:10 +03:00
claude[bot] 9d375dcb40 docs: review document for #509
Issue: #509
User-Visible: no
2026-09-10 08:14:22 +00:00
claude[bot] 57f38fedba docs: review document for #509
Issue: #509
User-Visible: no
2026-09-10 08:05:30 +00:00
claude[bot] d204bf5d77 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 07:36:17 +00:00
Codex 156835c048 ci: specs live in the issue body — body digest in review anchors, gate without a spec file
The spec file solved exactly one problem — proving that a review verdict
was passed on a given text — and created two: docs/specs/README.md
conflicted between parallel tasks and served as a second, stale status
dictionary, and every spec edit cost a commit, a push and a label. The
proof moves into the pipeline.

- review-doc-guard: normalizeIssueBody / issueBodyDigest (CRLF, trailing
  whitespace, trailing newlines), the anchor line `Тело issue: <sha256>`,
  anchorIssueBodyFrom, and issueBodyChanged — the finding "the spec
  changed after a green spec review", judged against the pipeline's own
  record in the last green SPEC-REVIEW, never against prose.
- reusableGreenVerdict takes the current digest: reuse (#499) skips the
  model entirely, so without this a spec edit between rounds would pass
  unseen. Documents without the record (the whole backlog) keep judging
  by tree.
- process.yml: the material step reads the body with `gh issue view` in
  the same run that fixes the material — the event snapshot describes a
  text the reviewer may never see; the digest goes into the anchors, into
  reuse and, when it differs, into the reviewer's prompt.
- process-gate: rule 3 judges the text (a `## ТЗ` heading or an AC1) with
  the archived file still accepted; adding a new file under docs/specs/
  warns — the directory is frozen.
- task-packet reads AC from the body first, the archived file second.
- PROCESS.md §2.3/§5/§7.1/§7.3/§10.5, AGENTS.md and docs/specs/README.md
  say so; the index table is gone with the long-standing §7.3 debt.

Mutants: review-anchor-drops-issue-body, review-ignores-changed-spec-body,
reuse-ignores-changed-issue-body, process-gate-requires-spec-file.

Issue: #517
User-Visible: no
2026-09-10 10:18:17 +03:00
claude[bot] e20560afa8 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 07:08:05 +00:00
claude[bot] 6b31e94501 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 06:54:51 +00:00
claude[bot] a824acc1b1 docs: review document for #502
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 58s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 40s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m11s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #502
User-Visible: no
2026-09-10 06:34:45 +00:00
Sergey Matyuninandclaude[bot] 441d3cc3de test(i18n-dead-keys): narrow dynamic consumers to key-shaped joins (#502)
Issue: #502
User-Visible: no
2026-09-10 06:27:59 +00:00
claude[bot] 9293109ba1 docs: review document for #516
Проверка (CI) / Классификация изменённых файлов (push) Successful in 35s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 53s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 22s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 22s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m1s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m54s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m28s
Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
claude[bot] 96bfd94313 docs: review document for #516
Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
Codexandclaude[bot] 3ad5d0baea ci: merge-candidate compares patch-ids without the review documents
The candidate is the branch tip, which already carries the round's
CODE-REVIEW-N-rK.md; the material the reviewer read does not. With
docs/reviews in the diff the two patch-ids never matched once dev had
moved, so every green candidate went back to review whenever another
task published its own document in the meantime — #514 looped twice on
09.09 and #508 only merged when dev happened to stand still. The
patch-id now excludes docs/reviews, exactly like `reviewedFresh` next to
it; a real change of the patch under rebase still returns the task.

Mutant: merge-rereviews-own-review-doc.

Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
claude[bot] 440460255b docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:37:26 +00:00
Codexandclaude[bot] 0ce5e4eed3 ci: the E2E gate fails loudly when the release list cannot be read
Code review r3 (M1): realOps.releases() swallowed a failing `gh release
list` into an empty list, so a fine-grained token scoped to houseplan-e2e
alone would have dispatched with upgrade_from=stable — the tag onto
itself — and the red run would look like the bug 4143f998 already fixed.
The call now throws like dispatch() and lands in the same catch: result
`error` with the token hint, which now names both repositories. L4:
PROCESS.md says who dispatches and who waits.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] ed6d1d76db docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] a8a9b7d99e docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] 39571e20d6 docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 0796a01571 ci: the E2E gate recognises its run by the suites that install the tag
Two findings from the live run on v1.73.0 (houseplan-e2e run
34393136097): the upgrade job carries the previous stable's tag in its
name, so "any job with HP <tag>" let a gate for v1.72.0 adopt the
v1.73.0 run — recognition now keys on `journeys`/`first-run`; and the
first poll after a dispatch sees only the matrix-planning job, which
marked the run as foreign forever — a run without any `· HP … ·` job is
undecided and polled again. Live: v1.73.0 → green with the run link,
v1.72.0 → no run of its own.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 57b19f9914 ci: the E2E gate upgrades from the previous stable, not from the tag under test
Live run on v1.73.0 (houseplan-e2e run 34392391382): at `release:
published` the new tag is already the newest stable, so
`upgrade_from=stable` made the upgrade suite update v1.73.0 onto itself
and fail with `Expected: not "1.73.0"`. The gate now resolves the newest
non-prerelease, non-draft release other than the tag from `gh release
list` and passes it as `upgrade_from`; the first stable ever falls back
to `stable`. Spec §4/§6 record the change and the matrix-planning job in
houseplan-e2e (a job-level `if` cannot read `matrix.*`).

Mutant: release-upgrades-stable-onto-itself.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] c50458a098 ci: a stable release waits for a green E2E run on a real Home Assistant
The stable gate proved Validate and Full Performance on the exact SHA but
never ran the release in Home Assistant itself. houseplan-e2e installs
the release's houseplan.zip — the bytes HACS ships — into HA in docker
and walks the sidebar page, dashboards, roles, PDF, restart and the
stable→tag upgrade. release.yml now dispatches e2e.yml on the tag for
`!prerelease` releases and waits for it (scripts/e2e-gate.mjs, modelled
on validate-gate.mjs): the gate recognises its own run by `HP <tag>` in
the job names, ignores foreign dispatches, and reports red / missing /
cancelled / token error with the run link. Betas are untouched.

Mutants: release-ships-on-red-e2e, release-trusts-foreign-e2e-run.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] d20ef60037 docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 8f037a74dc docs: spec for #514 — E2E on a real Home Assistant as the stable release gate
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] ad2858a80c docs: review document for #515
Issue: #515
User-Visible: no
2026-09-09 21:13:52 +00:00
Codex 5a1cddeaf0 ci: review anchors are taken after the pipeline's rebase, not before
The material anchors (commit, tree, spec blobs) written into every
review document came from the checkout step, before "Привести ветку к
dev". Whenever dev had moved — since 09.09 every review-document publish
moves it — the pipeline rebased and force-pushed the branch, orphaning
the pre-rebase commit and its tree. A fresh clone in the next run could
not resolve that tree: reuse (#499) always reported false and the
model reviewed the same code again, and the #413 post-step refused the
green round because neither the cited SHA nor the tree anchor was
reachable — #508 took three identical green rounds this way.

The `material` step, which already fixes the reviewed SHA after the
rebase, now also records the tree and spec blobs, and the publish step
reads all three from it. The contract test pins the order and forbids
reading anchors from the checkout step.

Issue: #515
User-Visible: no
2026-09-10 00:04:41 +03:00
claude[bot] 3c1b4ebee2 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 20:55:09 +00:00
claude[bot] be93291065 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 20:02:08 +00:00
claude[bot] 6ac513caa0 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:49:18 +00:00
Codexandclaude[bot] e38beed796 fix: the summary panel settings dialog scrolls in Home Assistant
In HA hp-dialog renders ha-dialog, whose own `.body` is the scroller and
is not a flex container; `.summary-editor` (overflow:auto,
overscroll-behavior:contain, min-height:0) therefore grew to its content
and became a scroll container that never scrolls — Chromium stops wheel
and touch scroll chaining at such a child, so nothing moved (reproduced
on ha.jbstudio.pro, HA 2026.9.1, and in an isolated Playwright page).
hp-dialog gains an opt-in `flex-content` attribute forwarded as
ha-dialog's `flexcontent`, which lays the body out as a flex column: the
editor is height-bound again and scrolls itself, header and footer stay,
exactly as the native branch already did. Only the summary dialog opts in.

Smoke demo/smoke_summary_dialog_scroll.mjs stubs ha-dialog after the HA
2026.9 contract: wheel on desktop, touch swipe on a phone, dialog within
the viewport, and a witness that the stub reproduces the bug without
flexcontent. Docs screenshots: 11/11 pixel-identical (docs:accept
--identical, #512), fingerprint refreshed.

Mutants: summary-dialog-drops-flex-content, hp-dialog-ignores-flex-content.

Issue: #508
User-Visible: yes
2026-09-09 19:49:18 +00:00
claude[bot] 6b592c13c0 docs: review document for #502
Issue: #502
User-Visible: no
2026-09-09 19:35:30 +00:00
claude[bot] 72764cff90 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:21:54 +00:00
claude[bot] f02af9330b docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:01:45 +00:00
claude[bot] fa01aa00bf docs: review document for #513
Issue: #513
User-Visible: no
2026-09-09 18:09:24 +00:00
Codex 0b9acd6382 docs: the local pre-push gate no longer calls the full mutation registry a pre-release gate
Code review r1 (M1): scripts/pre-push-gate.mjs — in its comment and in
the text every developer sees before a push — still named the full
mutation registry a pre-release gate; the same phrase lived in the
header of test/mutation-gate.test.mjs. Both now point at the nightly
schedule (#513); golden/smokes/HA harness are named as the heavy
Validate set on the candidate.

Issue: #513
User-Visible: no
2026-09-09 21:00:54 +03:00
claude[bot] f5fb7a0f8a docs: review document for #513
Issue: #513
User-Visible: no
2026-09-09 17:58:17 +00:00
Codex 61905bacdc ci: full mutation gate runs nightly, outside the development and release cycle
The full registry run proves that tests can fail, not that the product
works; 4 of its 5 runs since 02.09 were manual dispatches tied to
releases. Owner decision 09.09: a daily schedule (01:00 UTC, before the
02:30 nightly Validate), failures reported as an issue by the existing
#472 job, no place in the development or release flow. Docs and the
workflow comments say so; the test pins the daily cron.

Issue: #513
User-Visible: no
2026-09-09 20:49:08 +03:00
claude[bot] 9b65d4bcf7 docs: review document for #512
Проверка (CI) / Классификация изменённых файлов (push) Successful in 36s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m0s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 41s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 19s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m10s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m6s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m0s
Issue: #512
User-Visible: no
2026-09-09 16:15:24 +00:00
Codex ffe2ec51c0 test: golden baselines show 0.0.0-golden instead of the card version
Re-acceptance after the version seam (#512 §7) from the full Validate
dispatch on 605991ef (run 34366858855): seven frames carried the version
text — three version-mismatch banners, three PDF footers, the support
preview — and now read `0.0.0-golden`; six of them were within threshold
and still showed stale betas (beta.4, beta.8). The other 162 frames are
kept as reviewed; 128 environment witnesses matched byte for byte.
Sub-threshold drift in nine unrelated frames (tray, resize handles,
compass, junction) is not accepted.

Issue: #512
User-Visible: no
Release: v1.74.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34366858855
2026-09-09 18:53:46 +03:00
Codex b7d8b9e6ee test: displayed version through a seam; docs:accept --identical for pixel-identical screenshots
Golden frames carried the card version text (about, version banner,
support/export previews), so every beta bump re-accepted up to 20
baselines that had not visually changed. The version now reaches the DOM
and stand requests through displayVersion() (src/card-version.ts); the
golden harness pins window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'
before the card is created. CARD_VERSION literals stay where the release
contract reads them; cache-busting and the console banner keep the literal.

Docs screenshots: `npm run docs:accept -- --identical` re-captures
locally, compares decoded RGBA pixels with the committed frames inside
Chromium (scripts/png-identical.mjs) and, only when every frame is
identical, refreshes the manifest fingerprints and captureScriptSha256;
bytes stay, any difference refuses with a per-frame count. First run on
this tree: 11/11 identical, manifest refreshed.

Mutants: version-seam-ignores-override, docs-identical-accepts-any-frame.

Issue: #512
User-Visible: no
2026-09-09 18:53:38 +03:00
claude[bot]andCodex e5a62f7ffd docs: review document for #512
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
Codex 7674b78c33 docs: spec #512 r2 — capture.mjs untouched, --identical refreshes the capture-script guard
Spec review r1 (H1): the `--out` flag would have changed the only file
guarded by `captureScriptSha256` and reddened check-docs on its own. The
identical-accept mode now leaves demo/docs/capture.mjs as is (frames are
backed up and restored around the standard capture) and takes
`captureScriptSha256` from the candidate manifest together with the
source fingerprint; the docs re-acceptance for this issue is the first
local --identical run in the same branch.

Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
claude[bot]andCodex 3f402a3481 docs: review document for #512
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
Codex 278d9257eb docs: spec for #512 — version seam outside golden frames, docs:accept --identical
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
claude[bot] 33cb131b3a docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:52:11 +00:00
Codex 615181050b test: the real waitValidate is exercised against a cancelled dispatch
Code review r2 (M1): the cancelled-run filter in merge-candidate's
waitValidate had no test or mutant — every test replaced ops.waitValidate
with a fake. realOps now takes an injectable `exec` (default: the same
spawnSync wrapper) so the real implementation runs against scripted
`gh run list` answers: a cancelled dispatch is skipped and its
replacement followed; a lone cancelled run ends in `missing`, never red.

Mutant: merge-trusts-cancelled-dispatch.

Issue: #510
User-Visible: no
2026-09-09 18:45:44 +03:00
claude[bot] 8acbd333d8 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:43:09 +00:00
Codex 00130e08aa ci: a cancelled Validate dispatch proves nothing to the review gate
Code review r1 (M1): validate-gate.mjs and merge-candidate's waitValidate
read a `cancelled` dispatch run on the material as red, so a dispatch
replaced by the next one in the `validate-dispatch-<ref>` concurrency
group would have returned the task S7→S6 for nothing — the same class
#511 fixed in release-gate.mjs. Cancelled runs are now ignored: the gate
follows the replacement dispatch, or starts its own when there is none.

Mutant: review-returns-task-on-cancelled-dispatch.

Issue: #510
User-Visible: no
2026-09-09 18:33:24 +03:00
claude[bot] f6e64ba985 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:29:18 +00:00
Codexandclaude[bot] cbece6324f test: re-anchor the review-starts-on-red-validate mutant after the r1 verdict split
The mutant registry pointed at the pre-r1 verdict helper that no longer
exists; the anchor test caught it in CI. The patch now removes the red
branch of the completed-run check.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 97dfa457a4 ci: diff mutants only on request; the review pipeline proves them on the material before reviewing
Validate ran the three "Мутанты по диффу" shards on every push of every
branch: 48 of 56 job-hours on 08–09.09, most of them cancelled by the
next push. Mutants now run when asked — pull requests, the nightly
schedule, a push carrying a `Release:` trailer, or a dispatch with
`mutants=true` (classify-changes.mjs → `mutants_requested`); an ordinary
push runs the light checks only.

The proof moves to where it is consumed. process.yml gets a gate after
the #499 reuse step: on the code stage it looks for a dispatch Validate
run on the exact material SHA whose mutant jobs executed and passed
(scripts/validate-gate.mjs); none → it dispatches one and waits; red or
missing → the task goes back S7→S6 with the run link and the review
cycle is not spent. Spec stage and the reuse fast-path skip the gate
(`proceed=true`); all later steps branch on `proceed` in place of the
old conflict conjunct only. merge-candidate.mjs dispatches Validate on
the pushed candidate and waits for that dispatch run.

PROCESS.md/AGENTS.md: review does not start on red code; one handoff —
one push.

Mutants: mutants-run-on-every-push, review-starts-on-red-validate,
review-trusts-push-run-without-mutants, merge-waits-push-run-without-mutants.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 34c5c2d134 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 73d6e2eeeb docs: spec #510 r4 — proceed replaces only the conflict conjunct
Spec review r3: `proceed` is true on the reuse fast-path too, so it must
replace the `rebase.conflict != 'true'` conjunct alone; the existing
`reuse != 'true'` (#499), stage and decide conjuncts stay on every step
that has them. The "single variable" claim is narrowed accordingly.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 24cd39fca5 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 65526a6a7f docs: spec #510 r3 — one branching variable for the gate outcome
Spec review r2: §5.2 named the gate outcome three different ways; the
skip branch (spec stage, reuse fast-path) would have matched a literal
`result != 'green'` and produced a spurious S7→S6 return. All step
conditions now branch on `proceed` only (true = green or skipped, false =
red/missing); `result` feeds the comment text alone.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] b593489041 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] a91ba16a5c docs: spec #510 r2 — gate after reuse, proof requires executed mutant jobs
Spec review r1: place the gate after the #499 reuse step so its output is
defined; a green dispatch counts only when the "Мутанты по диффу" jobs ran
and passed (a foreign dispatch with mutants=false leaves them skipped);
therefore the Validate job runs whenever mutants are requested, even on an
empty selection. Explicit User-Visible/UX/i18n N/A statement added.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 1063156f12 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 2b8b7b2aa7 docs: spec for #510 — mutants on the review candidate, review waits for a green Validate, handoff rules
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] eed7239aa3 docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:45:45 +00:00
Codex 9d8f89d260 ci: release gate judges the latest non-cancelled Validate run of the SHA
The gate used to require every Validate run on the tag SHA to be green:
a cancelled duplicate or a red flake that a later re-run had fixed kept
the stable release blocked (v1.73.0, 09.09 — released by hand). Now the
verdict comes from the newest run that was not cancelled: not completed →
wait, success → pass, anything else → fail, no run → wait. The same rule
is documented for the perf workflow and the release runbook.

Mutants: release-gate-counts-cancelled-runs, release-gate-oldest-run-wins.

Issue: #511
User-Visible: no
2026-09-09 17:38:48 +03:00
claude[bot] 590b3a7e67 docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:27:52 +00:00
claude[bot] 6842b69eed docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:20:48 +00:00
Codex ceab345215 perf: give the Stage 3 dense isometric budget the same long-task count allowance (#507)
The #160 contract test keeps the dense profile's longTasks block equal to
the historical isometric one, and both profiles boot through the same lazy
iso-scene-render chunk; the accepted split applies to both. Validate
34356856702 caught the divergence.

Issue: #507
User-Visible: no
Release: v1.73.0
2026-09-09 16:30:18 +03:00
Codex d72cb704f3 perf: accept the lazy isometric chunk boot-task split in the isometric long-task count budget (#507)
Full Performance of the v1.73.0 stable candidate against the v1.72.0 product
(run 34354409872) was red on one check of the isometric profile:
longTask.countP95 16 → 20 against max(16×1.2, 16+3) = 19.2, with every
timing, longTask.totalP95Ms and longTask.maxSingleMs green. The trace behind
#506 shows why: since v1.73.0-beta.1 the isometric renderer is the lazy
iso-scene-render chunk (#160 Stage 3), so the single v1.72.0 boot task is
split in two around that import — the same work, +2 tasks.

Owner decision 2026-09-09: accept the split. countNoiseAllowance 3 → 5 for
large-house-isometric-v1 only; the ratio, the hard ceiling, total and
maximum single task keep gating real growth. The downloaded CI artefact
re-evaluated with this budget passes (limit 21, actual 20, no failures).
Documented in demo/performance/README.md; the budget test pins the
allowance and the untouched profiles.

Issue: #507
User-Visible: no
Release: v1.73.0
2026-09-09 16:24:15 +03:00
Codex 913e3187f1 Release v1.73.0
Promote the nine published v1.73.0 betas without new product behaviour:
stable version fields, synchronized generated bundles, the aggregated
bilingual release notes from v1.72.0 and status metadata only. beta.9
carried the startup-regression fix (#506) that Full Performance caught on
the first promotion attempt; the stable body keeps it out as an in-line
regression per the #328 curation rules.

Issue: #506
User-Visible: no
Release: v1.73.0
2026-09-09 15:22:01 +03:00
Codex 43809178ee test: poll smoke_preloader's reduced-motion phase from Playwright, with navigation diagnostics
The beta.9 candidate failed the same phase twice in CI with "Resulting
promise was garbage collected" (runs 34346813552, 34347910231) while it
passes locally; a timer guard did not help, which points at a destroyed
context rather than a starved animation-frame chain. The wait now runs as
page.waitForFunction with raf polling, and the smoke logs frame navigations
and page crashes as `diagnostic …` lines so the next failure names its
cause. Verdict unchanged (animationName of the boot house, or 'missed').
Pre-release gate repair per PROCESS §11.4; locally OK ×2.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 15:05:46 +03:00
Codex 581d165bda test: keep smoke_preloader's reduced-motion wait alive without animation frames
Validate 34346813552 on the beta.9 candidate failed only in browser smoke
shard 1: smoke_preloader phase 3 died with "Resulting promise was garbage
collected" — its rAF-only wait for the boot house had no other reference
while the runner withheld animation frames. A timer now bounds the wait
and keeps the promise reachable; the verdict is unchanged (animationName
of the house, or 'missed'). Pre-release gate repair per PROCESS §11.4:
locally `node demo/smoke_preloader.mjs` → OK ×2; all other heavy gates of
that run (golden, perf-smoke, backend, shards 2–3) were green.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 14:53:45 +03:00
Codex a9d0cab2dd Prepare v1.73.0-beta.9
Version fields and generated bundles move to 1.73.0-beta.9; the #506
changelog entry leaves Unreleased for the beta.9 section; release notes and
STATUS describe the startup-regression fix that unblocks the stable
promotion. No product change beyond #506, already reviewed and merged.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 14:40:28 +03:00
217 changed files with 18600 additions and 2271 deletions
+12 -10
View File
@@ -5,11 +5,12 @@ name: Мутационный гейт
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
# test/mutation-gate.test.mjs.
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
# Мутанты, задетые диффом, конвейер ревью гоняет отдельно на кандидате (#510).
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
# обычными юнитами: test/mutation-gate.test.mjs.
#
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
@@ -25,14 +26,15 @@ on:
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
- cron: '0 1 * * *'
permissions:
contents: read
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
# запуск (отладка гейта) отменял идущий по расписанию — так 24.08 погиб
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
@@ -111,8 +113,8 @@ jobs:
if-no-files-found: warn
retention-days: 30
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
# для отладки самого гейта, его результат смотрят в прогоне — issue на
# каждый такой отказ был бы шумом, который снова перестанут читать.
#
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
+162 -32
View File
@@ -321,20 +321,10 @@ jobs:
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
# ветку, и спрашивать его поздно.
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
# Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ
# ребейза (#515): снятые здесь, они после force-push приведённой
# ветки указывали на осиротевший коммит, и ни reuse (#499), ни
# страховка #414 не находили дерева в свежем клоне.
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
@@ -413,9 +403,46 @@ jobs:
- name: Зафиксировать SHA материала ревью
id: material
if: steps.rebase.outputs.conflict != 'true'
env:
NUM: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "материал ревью: $(git rev-parse --short HEAD)"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая
# копия равна тому, что ревьюер прочтёт, и коммит с этим деревом
# уже запушен в ветку — следующий прогон найдёт его в свежем клоне.
# В шаге публикации дерево уже сброшено на целевую ветку, и
# спрашивать его поздно.
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
# Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это
# единственный якорь, доказывающий «вердикт вынесен на этом тексте».
# Читается здесь, а не из github.event.issue.body: между событием
# метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание
# Validate), и снимок события описывал бы не тот текст.
body=$(mktemp)
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
digest=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { readFileSync } = await import("node:fs");
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
});
' "$body")
echo "issue_body=$digest" >> "$GITHUB_OUTPUT"
echo "тело issue: ${digest:0:12}"
else
echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт"
fi
echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')"
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
@@ -431,8 +458,13 @@ jobs:
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
# Правка ТЗ между раундами обязана отменять повторное применение
# зелёного вердикта: иначе вызов модели пропускается и находку
# «ТЗ менялось» некому напечатать (#517 AC6).
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
run: |
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD)
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
--issue-body="${ISSUE_BODY}")
printf '%s\n' "$out"
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
@@ -474,6 +506,64 @@ jobs:
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
# запускает Validate с мутантами на материале и ждёт его. Красный или
# пропавший прогон возвращает задачу автору без ревью — цикл не
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
# слияние само дожидается Validate на кандидате.
- name: Validate с мутантами на материале
id: gate
if: steps.rebase.outputs.conflict != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
STAGE: ${{ needs.guard.outputs.stage }}
REUSE: ${{ steps.reuse.outputs.reuse }}
BRANCH: ${{ steps.branch.outputs.name }}
SHA: ${{ steps.material.outputs.sha }}
run: |
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
exit 0
fi
if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then
echo 'proceed=true' >> "$GITHUB_OUTPUT"
else
echo 'proceed=false' >> "$GITHUB_OUTPUT"
fi
- name: Validate красный — вернуть автору без ревью
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
SHA: ${{ steps.material.outputs.sha }}
RESULT: ${{ steps.gate.outputs.result }}
NOTE: ${{ steps.gate.outputs.note }}
URL: ${{ steps.gate.outputs.url }}
run: |
short=$(git rev-parse --short "$SHA")
cat > /tmp/gate.md <<EOF
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
Задача переведена в \`S6-in-progress\`. Осталось:
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
2. дождаться зелёного дешёвого Validate на пуше;
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
# ревью тратится на повторение CI вместо чтения кода.
@@ -483,7 +573,7 @@ jobs:
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
- name: Зелёные гейты на этом SHA
id: validated
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
@@ -512,21 +602,21 @@ jobs:
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
@@ -542,7 +632,7 @@ jobs:
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
- name: Установить Claude Code детерминированно
id: claude_bin
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
run: |
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
@@ -557,9 +647,43 @@ jobs:
"$bin" --version
echo "path=$bin" >> "$GITHUB_OUTPUT"
# ТЗ живёт в теле issue (#517), а GitHub хранит его правки без diff и без
# доступной чтением истории. Единственное, что можно доказать, — «текст
# изменился после того, как ревью ТЗ признало его зелёным». Это не отказ:
# автор вправе уточнять ТЗ. Это находка, доставляемая ревьюеру кода в
# промпт и владельцу в комментарий вердикта: разбор AC в таком заходе
# обязан быть полным, а не по дельте.
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
id: spec_body
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
DIGEST: ${{ steps.material.outputs.issue_body }}
run: |
if [ -z "$DIGEST" ]; then
echo "хеша тела нет — сравнивать не с чем"
exit 0
fi
out=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { execFileSync } = await import("node:child_process");
const [num, digest] = process.argv.slice(1);
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n")
.filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name));
const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) }));
const changed = m.issueBodyChanged(docs, digest);
if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`);
else process.stdout.write("changed=false\n");
});
' "$NUM" "$DIGEST")
printf '%s\n' "$out"
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
- name: Review
id: review
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
uses: anthropics/claude-code-action@v1
env:
# Вне рабочей копии: восстановление дерева ревьюером не должно
@@ -586,6 +710,8 @@ jobs:
${{ steps.rebase.outputs.note }}
${{ steps.spec_body.outputs.changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', steps.spec_body.outputs.doc, steps.spec_body.outputs.recorded) || '' }}
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
всех раундов, и повторный цикл заново выводил продуктовую рамку и
@@ -631,8 +757,9 @@ jobs:
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
есть у старой задачи, он и есть материал, новые не создаются.
Проверь обязательные разделы §7.1, однозначность каждого AC и
указание способа доказательства. Отдельно проверь, что автор не
выдал догадку за решение: утверждение о поведении, которого нет ни
@@ -783,7 +910,7 @@ jobs:
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
@@ -791,9 +918,11 @@ jobs:
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
# После ребейза конвейером — якоря приведённого материала (#515).
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
# Вердикт из structured_output попадает в блок якорей (#499): по нему
# следующий заход решает, можно ли применить зелёный вердикт повторно.
OUT: ${{ steps.review.outputs.structured_output }}
@@ -855,6 +984,7 @@ jobs:
node scripts/review-doc-guard.mjs --anchor="$doc" \
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
--issue-body="$MATERIAL_ISSUE_BODY" \
--verdict="$verdict" --high="$high"
else
echo "::warning::$SOURCE не найден — документа для публикации нет"
@@ -945,7 +1075,7 @@ jobs:
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
# пользы нет — он достанет только то, что есть на origin.
- name: "Материал раунда воспроизводим (#413)"
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
env:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
@@ -962,7 +1092,7 @@ jobs:
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
- name: Решение по вердикту
id: decide
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true'
env:
OUT: ${{ steps.review.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
@@ -1017,7 +1147,7 @@ jobs:
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
# случится (#364).
- name: dev ушёл вперёд, пока шло ревью
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
if: steps.gate.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
@@ -1063,7 +1193,7 @@ jobs:
--issue="$NUM" --repo="${{ github.repository }}"
- name: Переставить метку
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true'
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
+14
View File
@@ -50,6 +50,20 @@ jobs:
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
# #514: the only check on a real Home Assistant. houseplan-e2e installs
# the release's houseplan.zip — the bytes HACS ships — into HA in docker
# and walks the sidebar page, dashboards, roles, PDF, restart and the
# stable→tag upgrade. A red, missing or cancelled run withholds the
# assets exactly like Full Performance. Cross-repository dispatch needs a
# token with Actions: write on houseplan-e2e; HP_PROCESS_TOKEN (classic,
# repo scope) has it, E2E_DISPATCH_TOKEN is the fallback for a
# fine-grained token.
- name: Require green E2E on a real Home Assistant for a stable release
if: ${{ !github.event.release.prerelease }}
env:
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: node scripts/e2e-gate.mjs --tag="$TAG"
build:
name: Сборка бандла и загрузка ассетов
needs: gate
+66 -25
View File
@@ -18,9 +18,15 @@ on:
workflow_dispatch:
inputs:
full:
description: 'Полный набор: смоки, golden, performance_smoke'
description: 'Полный набор: смоки, golden, performance_smoke, мутанты по диффу'
type: boolean
default: true
# Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние
# кандидата запускают `-f full=false -f mutants=true` на материале.
mutants:
description: 'Мутанты по диффу на этом SHA (кандидат ревью)'
type: boolean
default: false
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
@@ -218,10 +224,10 @@ jobs:
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
heavy: ${{ steps.heavy.outputs.heavy }}
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого судить
# диапазон на dev (#388): общее имя — чужая база у потребителя.
# `base` — до какого коммита классифицировать файлы ветки (#387),
# `range_base` — от какого судить диапазон на dev (#388).
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
@@ -241,6 +247,7 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
MUTANTS_INPUT: ${{ inputs.mutants }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
@@ -476,14 +483,21 @@ jobs:
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
# детерминированных непересекающихся шарда вместо ослабления проверки.
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
# материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на
# промежуточных пушах она стоила 48 из 56 часов job-минут и в основном
# отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
# её исход по job, и skipped был бы неотличим от «не запрашивали».
changed_mutants:
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
needs: changes
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
if: needs.changes.outputs.mutants_requested == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5, 6]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -494,26 +508,13 @@ jobs:
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (файлы патча и гарда, без строки версии) повторно не гоняется.
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
# пропуск станет известен только после установки окружения.
- name: Журнал свидетелей (последний по шарду)
uses: actions/cache/restore@v6
with:
@@ -524,7 +525,13 @@ jobs:
restore-keys: |
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
mutation-ledger-${{ matrix.shard }}-
- name: Затронутые мутанты ловятся
# #518: план шарда считается до установки окружения. npm ci, Python с
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
# их платили даже тогда, когда гонять было нечего: правка задела чужой
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
# git и node из образа.
- name: План шарда
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
@@ -546,11 +553,45 @@ jobs:
fi
if [ -z "$base" ]; then
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "диапазон: $base..$HEAD_SHA"
echo "base=$base" >> "$GITHUB_OUTPUT"
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
echo "к прогону в этом шарде: ${count:-0}"
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
- if: steps.plan.outputs.count != '0'
run: npm ci
- if: steps.plan.outputs.count != '0'
uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
if: steps.plan.outputs.count != '0'
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
if: steps.plan.outputs.count != '0'
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Затронутые мутанты ловятся
if: steps.plan.outputs.count != '0'
env:
BASE: ${{ steps.plan.outputs.base }}
HEAD_SHA: ${{ github.sha }}
SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
+3 -1
View File
@@ -1,4 +1,6 @@
node_modules/
# Без слэша: с ним шаблон не покрывает СИМВОЛИЧЕСКУЮ ССЫЛКУ с этим именем,
# и `git add -A` в чужом worktree затягивает её в коммит (#520, прежде #429).
node_modules
tsout/
test-build/
*.log
+32 -6
View File
@@ -305,10 +305,14 @@ does not meet.
## Specs
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
on which surface, at what moment; and what the person sees before and after, in one
sentence without implementation terms.
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two
product ones: which persona meets this, on which surface, at what moment; and what
the person sees before and after, in one sentence without implementation terms.
Proof that a verdict was passed on a given text is the pipeline's job: it writes
the `sha256` of the normalised body into the review document's anchor block, and
an edit made after a green spec review reaches the code reviewer as a finding.
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
fact is the worst kind of defect: it passes review because it looks like a decision.
@@ -387,6 +391,18 @@ demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
whole review round that a local run would have caught immediately.
**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues`
locally with `gh` available before pushing (without `gh` the hook cannot check the
issue status and stays silent). After `S7-code-review` do not push to the branch
until the verdict or the return arrives: a push on top of a running review cancels
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
with the diff mutants on the material itself and returns a red one to `S6` without
spending a review cycle. Mutants by diff no longer run on ordinary pushes — only
on the review candidate, the merge candidate, the beta candidate, PRs and the
nightly run — so a routine push costs ~3 minutes; 08–09.09 they cost 48 of 56
Validate job-hours and were mostly cancelled by the next push.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
@@ -409,7 +425,14 @@ simply the only one we have). The deliberate override is
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
manifest. Baselines are still accepted only via
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
index records the platform next to the Chromium build.
index records the platform next to the Chromium build. The one local
shortcut is `npm run docs:accept -- --identical` (#512): it re-captures on this
machine, compares decoded pixels with the committed frames and, only when every
frame is identical, refreshes the manifest fingerprints — frames that differ go
through the artefact as before. The displayed card version reaches the DOM
through `displayVersion()` (`src/card-version.ts`); the global
`__HP_VERSION_OVERRIDE__` behind it is for harnesses only and the product never
sets it.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -445,7 +468,10 @@ an unfinished Validate for the same branch. Gate jobs, matching the actual
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge.
went red after the #113 merge. A stable release additionally waits for Full
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
dispatched on the tag by `release.yml`, #514); betas and the development cycle
never run E2E.
**"Verified" without a named command and its result is not evidence.**
+64 -22
View File
@@ -122,9 +122,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
### 2.3 ТЗ в работе — написание ТЗ
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
- **Артефакт:** **тело issue**, раздел `## ТЗ` (решение владельца 2026-09-10,
#517). Файл в `docs/specs/` не создаётся ни на одном треке: каталог — архив
ТЗ до этой даты, и задачи, у которых файл уже есть, доживают по старой схеме.
Доказуемость («вердикт вынесен на этом тексте») держит конвейер: в блок якорей
документа ревью пишется `sha256` нормализованного тела, и правка ТЗ после
зелёного ревью ТЗ приходит ревьюеру кода находкой, а не тишиной.
- **Выход:** полная первая редакция по §7.
### 2.4 ТЗ на ревью
@@ -155,8 +158,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
- влияние на производительность и бюджеты названо (или явно «нет»);
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
документация, golden/скриншоты, performance/security);
- release-артефакты названы: changelog RU+EN, документация, golden/скриншоты,
performance/security — либо явное «нет»;
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
- открытых продуктовых вопросов нет; риски перечислены.
@@ -428,8 +431,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
**Что упрощается:**
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
доказательством · откат. Файл в `docs/specs/` не создаётся;
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
(в теле issue, как и на полном треке с 2026-09-10);
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
- лимит ревью ТЗ — 2 цикла.
@@ -488,7 +491,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
| Роль | Делает | Не имеет права |
|---|---|---|
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
@@ -520,7 +523,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
```
issue #NN
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
↔ ветка issue/NN-slug
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
@@ -593,10 +596,9 @@ issue #NN
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
таблицу «issue ↔ ТЗ».
1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт
в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён
вместе с самой таблицей. Статус задачи — только метка `S*`.
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
@@ -653,6 +655,13 @@ python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:c
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
декодирует оба набора в Chromium и при нуле отличающихся пикселей во всех
кадрах обновляет только отпечаток исходников в `screenshots.json`; байты
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
@@ -694,7 +703,9 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA; статусов issue не касается.
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на теге и ждёт его
зелёного (#514); статусов issue не касается.
---
@@ -811,9 +822,11 @@ Performance зелёные на точном SHA; статусов issue не к
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
2. имя ветки `issue/NN-slug` соответствует трейлерам;
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
3. у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1` в теле issue —
либо архивный `docs/specs/NN-*.md` у задачи до 2026-09-10. Офлайн тела нет,
и проверка молчит; с `--issues` — предупреждение (настоящий рубеж — ревью ТЗ).
Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог
заморожен (#517);
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
`Baseline-Reviewed: <ссылка на прогон CI>`;
@@ -897,6 +910,34 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
только по запросу: на кандидате ревью, кандидате слияния (#492), кандидате беты,
в ночном прогоне и на PR; обычный push обходится дешёвыми гейтами (~3 минуты).
За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов
job-минут Validate и в основном отменялись следующим пушем.
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
диффу, который читает ханки `git diff --unified=0`. Сторона гарда осталась
файловой: у гарда якоря нет. Неоднозначный якорь и непрочитанные ханки дают
прежний широкий ответ — незнание не доказательство. Приближение того же класса,
что и сам отбор по диффу; нижняя граница — ночной полный гейт (#513). Шард
считает свой план до установки окружения и при пустом плане не платит за
npm ci, Python и Chromium, оставаясь исполненной job: доказательство гейта
требует успешной job, а не пропущенной.
**Один хендофф — один пуш.** Перед пушем — локальный `node scripts/process-gate.mjs
--issues` при доступном `gh` (хук без `gh` статус issue не проверяет и молчит);
после `S7-code-review` в ветку не пушить, пока не пришёл вердикт или возврат: пуш
поверх идущего ревью отменяет его и стоит 10–20 минут раннера, а после фиксации
материала — ещё и слияние (#312). `S7` ставится один раз на заход, не после
каждого фикса CI: красный Validate конвейер вернёт сам.
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
@@ -945,8 +986,9 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, ожидание
зелёного Validate **на этом SHA** и только затем push в `dev` с lease на ту
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
— новая попытка; после третьей — `S6-in-progress` с комментарием;
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
@@ -1083,9 +1125,9 @@ Golden, браузерные смоки, performance и полный HA-харн
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
одна `S*`-метка, инварианты чистые.
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
код, которого уже нет.
2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/`
стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых
документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет.
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
+1 -1
View File
@@ -57,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.73.0"
VERSION = "1.74.0-beta.1"
DECOR_ASSETS_API_VERSION = 1
SUMMARY_PANEL_API_VERSION = 1
@@ -1,309 +1,309 @@
{
"schema": 1,
"fingerprint": "95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74",
"fingerprint": "7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 298891,
"initialViewGzipBytes": 300059,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-card.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301193,
"initialPanelGzipBytes": 302365,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2302,
"initialPanelOnlyGzipBytes": 2306,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/fr-T10CV6-a.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js",
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"houseplan-assets/pdf-export-DpHlViMV.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/de-HksnN-9C.js",
"houseplan-assets/editor-CSFqqHwT.js",
"houseplan-assets/fr-lBEFwYGK.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
"houseplan-assets/pdf-export-CsCJxU0E.js",
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
],
"lazyGzipBytes": 417329,
"lazyGzipBytes": 418179,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/editor-CSFqqHwT.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
],
"lazyEditorGzipBytes": 205633,
"lazyEditorGzipBytes": 205726,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js"
],
"lazyOnboardingGzipBytes": 14148,
"lazyOnboardingGzipBytes": 14187,
"lazyLocaleFiles": [
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/fr-T10CV6-a.js"
"houseplan-assets/de-HksnN-9C.js",
"houseplan-assets/fr-lBEFwYGK.js"
],
"lazyLocaleGzipBytes": 54843,
"lazyLocaleGzipBytes": 54854,
"lazyIsometricFiles": [
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js"
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/iso-scene-render-RhSO-S9j.js"
],
"lazyIsometricGzipBytes": 13452,
"lazyIsometricGzipBytes": 13460,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js"
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js"
],
"lazyFurnitureArtGzipBytes": 10253,
"lazyFurnitureArtGzipBytes": 10255,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/pdf-export-DpHlViMV.js"
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/pdf-export-CsCJxU0E.js"
],
"lazyPdfGzipBytes": 120367,
"lazyPdfGzipBytes": 120372,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"sha256": "a4abe8807d8c404a7ce9f65b0a72ca3d8f8d4d5bf38fa8220915818f9238c0db",
"path": "houseplan-assets/backdrop-pick-BvtFVd_V.js",
"sha256": "e76da9cb2c5c8a32b2798c6f8785819bd7ff6ee624987feb8ff2c89522f87fef",
"rawBytes": 21134,
"gzipBytes": 7144,
"gzipBytes": 7148,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-IBWzSVOj.js",
"sha256": "cf031fb380b4b056c116e14a8cb26729ae4d0b275991b07989917f884f8841ff",
"path": "houseplan-assets/de-HksnN-9C.js",
"sha256": "3a1ada3b04ef3eb7e9c6856c700d589e90e962a6753087b59f89b481c68a9499",
"rawBytes": 91441,
"gzipBytes": 27705,
"gzipBytes": 27710,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DKd1NGbT.js",
"sha256": "e62e388db3b39eaa5f0e8f9b9676b7a6e30c453f84d22d530740f9be093512d1",
"rawBytes": 3837,
"path": "houseplan-assets/editor-CSFqqHwT.js",
"sha256": "a5adebb3a438356deb0e5a35fa218bcb4ab6a8be23d6e535b6cd350170775926",
"rawBytes": 3832,
"gzipBytes": 1585,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-T10CV6-a.js",
"sha256": "b602e1137a8395f963be744ed43d2054e472093502f687e40e6bd3e7a25a8794",
"path": "houseplan-assets/fr-lBEFwYGK.js",
"sha256": "de104eb96b8e4fbb2895ab861300878eb3f27dc616e4737bc2e9e818286c9d88",
"rawBytes": 93719,
"gzipBytes": 27138,
"gzipBytes": 27144,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"sha256": "1e26cad8d04b459f4b1140e705a02a1d380b8c49a84f92b8ea2ce3e684544800",
"path": "houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"sha256": "9b63fd1d6479a45b433b323468a56b46ab0e0e3d2a5bfb82b6349d475e59eee8",
"rawBytes": 33655,
"gzipBytes": 10253,
"gzipBytes": 10255,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/guard-7S6iRB0N.js",
"sha256": "67f95421a21516a66157d8fdc0129c2d1280cee31a3621a81039c88835d1c0f8",
"path": "houseplan-assets/guard-Bdo2w6Sq.js",
"sha256": "b98da3eb22fde2449b43c37851d7e2cbbcb14103b20b7c6b417fa4bab2502416",
"rawBytes": 518,
"gzipBytes": 368,
"gzipBytes": 371,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Bj6mVEeM.js",
"sha256": "dea85ab6dfeebcb263bd672270041e34473d40b9db24e69259e55d42e5ff44d4",
"rawBytes": 1076387,
"gzipBytes": 298088,
"path": "houseplan-assets/houseplan-card-DkiI2BOK.js",
"sha256": "e7bdf9f5d1b699c27b57f25c369b137eceae0cf6f3ec7f2d364ef6c2bd552887",
"rawBytes": 1079369,
"gzipBytes": 299254,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/fr-T10CV6-a.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js",
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"houseplan-assets/pdf-export-DpHlViMV.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
"houseplan-assets/de-HksnN-9C.js",
"houseplan-assets/editor-CSFqqHwT.js",
"houseplan-assets/fr-lBEFwYGK.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
"houseplan-assets/pdf-export-CsCJxU0E.js",
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"sha256": "bc5163222924f68e6279b412b16a53f28a76b5b4d80d4a03e7fe28c4cce77a39",
"rawBytes": 671972,
"gzipBytes": 178859,
"path": "houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"sha256": "00be6a1cdb443cd875e736984baf86b77e63d96474ec72d4d66bf2baa9f1a2ba",
"rawBytes": 672339,
"gzipBytes": 178936,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"sha256": "31ea22267928606938b34c47f1ac8ecc2041048bc8c045a411d7108d353de849",
"rawBytes": 28237,
"gzipBytes": 7004,
"path": "houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
"sha256": "f81b58bde08ff74bab903803cc709e18449900dc91a3f64106db737bbabd55e3",
"rawBytes": 28287,
"gzipBytes": 7039,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"sha256": "0e18deb183cb473c2a96fb689c91c68a3729b8ad56fb8907b01c30810585dbec",
"path": "houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
"sha256": "968345cc4eaaf6cc46accbdc30b4fff83e3fc40a5a7b7a0f611229f0e53ec6b9",
"rawBytes": 11034,
"gzipBytes": 3731,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-miMf0jvt.js",
"sha256": "00fd101b4e5469f6d289b77eab3056bedf98082520af9139a1afff5823fcb025",
"path": "houseplan-assets/iso-scene-render-RhSO-S9j.js",
"sha256": "b5c630c05c0c2648c96006ab51cf271ae2ca585742df4bd9ad456ce9dac86646",
"rawBytes": 38562,
"gzipBytes": 13084,
"gzipBytes": 13089,
"isEntry": false,
"imports": [
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"sha256": "631b277aae71d9977fabb3f330c4a9cdf38e47ec51259584540b747d9c9444df",
"path": "houseplan-assets/live-interaction-runtime-C_klxsXE.js",
"sha256": "319a2a94cf85acd20b27522ae71b81e25ee1f2d69f1688256c5eeed4dddec3d5",
"rawBytes": 5196,
"gzipBytes": 2124,
"gzipBytes": 2129,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-DpHlViMV.js",
"sha256": "48b07b9ecfbbf2c1a8de53edce8bc32b1217e2c2f20eb5ceb24af75999870d8a",
"path": "houseplan-assets/pdf-export-CsCJxU0E.js",
"sha256": "eccac69e0d54ceeff8fceb8db69a0f20f5339443cb7961f8916f9a850bb1ae6c",
"rawBytes": 237254,
"gzipBytes": 110114,
"gzipBytes": 110117,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-DOpNDtK_.js",
"sha256": "4ed28cc81b7f0ccac46e0fb6ac37856ddbdf7813040585447089bf4a0a61805f",
"rawBytes": 14811,
"gzipBytes": 3390,
"path": "houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
"sha256": "5c7fd338b94152ae5e1194f07c6353863a9e22c4f2e99424f5795484da989a64",
"rawBytes": 14824,
"gzipBytes": 3401,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-DXF83oAy.js",
"sha256": "51953d004b76561a842f67b9455985cceac2663c52499d1e1acc3a3cdfdaded9",
"rawBytes": 2195,
"gzipBytes": 1121,
"path": "houseplan-assets/summary-panel-metrics-C2M32Xkf.js",
"sha256": "bca21f0d66af64c7c1ef501a8cd38e04e2a74ee7196e004f6f60397d9e9a72f1",
"rawBytes": 2610,
"gzipBytes": 1308,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
"sha256": "7598bcc5d26d8d2f9a15cc154c32a0b836ba0ac53b7b5f9b65f3c2c2bc0287d1",
"rawBytes": 69530,
"gzipBytes": 19034,
"path": "houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
"sha256": "54c791fdd19e77c24ac142794c4207345eaec03d2ab15dfdf3b991afa32bb812",
"rawBytes": 72272,
"gzipBytes": 19729,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-DOpNDtK_.js",
"houseplan-assets/summary-panel-metrics-DXF83oAy.js"
"houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
"houseplan-assets/summary-panel-metrics-C2M32Xkf.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"sha256": "0176688590ae76a8766ee735de892e37f29211a042c27f5a2992eda0cc8ef8d9",
"path": "houseplan-assets/zigbee-topology-LI-FVdpo.js",
"sha256": "d01ec49d20c5f3dc028a59107f1f9060634ab93d6d112e03a7c2b85792159440",
"rawBytes": 18961,
"gzipBytes": 7424,
"gzipBytes": 7431,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js",
"sha256": "6b43a567d3686249519d43ef6f04bb9596529d99d04f745944c0e561c0e3034e",
"path": "houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js",
"sha256": "3a0fad7d479bbeb161434e498a3c8ac7f88da62ef37c173b66f95057098bcc97",
"rawBytes": 3941,
"gzipBytes": 1762,
"gzipBytes": 1765,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "b8095427f52e7413e84d3202953d15765fbd43d4536226beff01ba7ff692ae2e",
"sha256": "21da7b3390ea9d4992e33bf4d6fdf684145b53f407e253a599e70a459585298d",
"rawBytes": 1205,
"gzipBytes": 803,
"gzipBytes": 805,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "f0a1709a58213029c08e73da2ed81b217827c8f5cdbf96e082385cfb608eabcc",
"sha256": "a664721695cf5c85903eea440d05a295cb6d6d2cd1e3669fd5c65c9752534492",
"rawBytes": 6124,
"gzipBytes": 2302,
"gzipBytes": 2306,
"isEntry": true,
"imports": [
"houseplan-card.js"
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-DkiI2BOK.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{t as e,l as o,x as t,e4 as s,A as a,e5 as i,e6 as l,e7 as n,dV as r,b as c}from"./houseplan-card-Bj6mVEeM.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{dT as r,dU as e,dV as t}from"./houseplan-card-Bj6mVEeM.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{dS as r,dT as t,dU as e}from"./houseplan-card-DkiI2BOK.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{b as a,A as e}from"./houseplan-card-Bj6mVEeM.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-2X-IAkWu.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{b as a,A as e}from"./houseplan-card-DkiI2BOK.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-DQS834xA.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
@@ -6,7 +6,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c46
?disabled=${d.busy} aria-label=${b("summary.show_local")}
aria-describedby="summary-local-show-hint"
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
.title=${b("summary.settings")} wide
.title=${b("summary.settings")} wide flex-content
dismiss-on-scrim aria-busy=${String(d.busy)}
@hp-close=${k}>
<div class="body summary-editor" @click=${()=>m.closeSource()}>
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{e9 as e,L as t,a2 as n,J as i,an as o,N as r,bo as s,bn as a,a1 as c,a0 as l,ax as d,ea as u,eb as f,bq as m,ec as g}from"./houseplan-card-DkiI2BOK.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=d(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,l,d=_){try{let u=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=d(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,o,r,g.roomGeom,g.multiWallNodes)||a;v=y(v,s(c,m.physicalBodies)),yield}const _=m.cellCm/o;u+=a(v)*_*_/1e4}return u}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=u(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{az as e,ea as t,eb as n,bs as i,ec as r,P as a,a5 as s,M as o,aq as c,N as l,bq as d,bp as u,ed as f}from"./houseplan-card-Bj6mVEeM.js";function g(t){if(!t.registry.authoritative)return null;const n=new Set,i=e(t.markers);for(const e of Object.values(t.registry.devices||{}))e?.id&&"service"!==e.entry_type&&!i.devices.has(e.id)&&e.area_id&&t.areaToSpace[e.area_id]&&n.add(e.id);for(const e of t.markers||[]){if(e.removed||"virtual"===e.binding)continue;const r=String(e.binding||"").indexOf(":");if(r<1)continue;const a=e.binding.slice(0,r),s=e.binding.slice(r+1),o="device"===a?t.registry.devices?.[s]:null,c="entity"===a?t.registry.entities?.[s]:null,l="device"===a?s:c?.device_id,d="device"===a?o?.area_id:c?.area_id||l&&t.registry.devices?.[l]?.area_id,u="string"==typeof e.room_id&&e.room_id.length>0&&null===e.area?"":e.area||d||"",f=u&&t.areaToSpace[u]||e.space||t.firstSpaceId;l&&t.registry.devices?.[l]&&t.spaceIds.has(f)&&(!i.devices.has(l)||"entity"===a&&i.liveEntities.has(s))&&n.add(l)}return n}function m(e,t){return e?.length?t?.length?f(e,t):e:t}function v(e,t){try{let n=0;for(const i of t){const t=e.spaces.find(e=>String(e?.id)===i.id);if(!t)continue;const f=r(t,i);let g=null;for(const e of i.rooms){if(!e.id)continue;const t=a(e);if(!t)continue;const n=s(i.rooms,e.id,f.walls,f.openCuts,o,f.cellCm,c,l)||t;g=m(g,d(n,f.physicalBodies))}const v=f.cellCm/c;n+=u(g)*v*v/1e4}return n}catch{return null}}function y(e,t,n,r){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:i(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(r||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function _(e,i){const r=e?.states?.[i];if(!r)return null;const a=t(e,i);return a?n(a,String(r.attributes?.unit_of_measurement||"")):null}export{g as representedHaDeviceIds,_ as summaryEntityValue,y as summarySystemValue,v as totalCleanFloorAreaM2};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{n as t,b as n}from"./zigbee-topology-DOkBU6gZ.js";import{F as e}from"./houseplan-card-Bj6mVEeM.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{n as t,b as n}from"./zigbee-topology-LI-FVdpo.js";import{B as e}from"./houseplan-card-DkiI2BOK.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";try{await import("./houseplan-assets/houseplan-card-Bj6mVEeM.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";try{await import("./houseplan-assets/houseplan-card-DkiI2BOK.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -20,5 +20,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.73.0"
"version": "1.74.0-beta.1"
}
+54 -1
View File
@@ -294,6 +294,41 @@ try {
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
// #520 diagnostics: where the extra half second of model readiness goes.
// Counts Lit update cycles and model builds; printed, never budgeted.
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
// what rebuilds the model a second time (#520).
let diagEpoch = 0;
Object.defineProperty(card, '_cfgEpoch', {
configurable: true,
get: () => diagEpoch,
set: (next) => {
if (next !== diagEpoch) {
const frames = (new Error().stack || '').split('\n').slice(1, 4)
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
}
diagEpoch = next;
},
});
const diagPerform = card.performUpdate.bind(card);
card.performUpdate = function () {
const started = performance.now();
const result = diagPerform();
card.__diag.updates += 1;
card.__diag.updateMs += performance.now() - started;
return result;
};
const diagBuild = card._buildModel.bind(card);
card._buildModel = function () { card.__diag.models += 1; return diagBuild(); };
if (typeof card._adoptStructuralResponses === 'function') {
const legacy = card._adoptStructuralResponses.bind(card);
card._adoptStructuralResponses = function (...args) { card.__diag.adopts += 1; return legacy(...args); };
} else if (typeof card._adoptAuthoritative === 'function') {
const gated = card._adoptAuthoritative.bind(card);
card._adoptAuthoritative = function (...args) { card.__diag.adopts += 1; return gated(...args); };
}
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
@@ -363,6 +398,14 @@ try {
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const bootDiag = {
updates: card.__diag.updates,
updateMs: Number(card.__diag.updateMs.toFixed(1)),
models: card.__diag.models,
adopts: card.__diag.adopts,
cfgEpoch: card._cfgEpoch,
epochs: card.__diag.epochs.slice(0, 8),
};
const initialProjection = typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null;
if (requiresIsometric && initialProjection !== 'iso')
@@ -1096,6 +1139,7 @@ try {
const result = {
sample,
bootDiag,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
@@ -1165,7 +1209,16 @@ try {
interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, profile,
});
if (measuredSample >= 0) rows.push(row);
// #520: диагностика печатается в лог прогона и в запись не попадает.
const { bootDiag, ...measured } = row;
if (bootDiag) {
console.log(`#520 diag sample ${row.sample}: updates=${bootDiag.updates}`
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
}
if (measuredSample >= 0) rows.push(measured);
}
} finally {
await browser.close();
+11
View File
@@ -43,6 +43,17 @@ radial spokes visible instead of hiding them under a translucent room fill.
CI turns green"; the second stops an empty or clipped frame from becoming the
contract unseen (#350).
## Displayed version (#512)
Frames never show the real `CARD_VERSION`. The harness sets the test seam
`window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before any card is created and
feeds the same constant wherever it plays the backend (`integration_version`,
support facts), so the about dialog, the version-recovery banner and the support
and backup previews print `0.0.0-golden` in every baseline. A beta bump therefore
changes no golden frame; the version-mismatch scenarios keep their own
`0.0.0-golden-backend` and still exercise the frontend≠backend relation. The
product never sets the seam (`src/card-version.ts`).
## Workflow
Build and copy the exact current source first:
+10 -10
View File
@@ -1,21 +1,21 @@
{
"schema": 1,
"matrixVersion": 61,
"acceptedAt": "2026-09-09T07:34:37.356Z",
"sourceFingerprint": "4c289ff2fbf0a2df9cd9ca8f6f3c286d9d2c67019a1ced4be717a7d101800575",
"acceptedAt": "2026-09-09T15:06:57.368Z",
"sourceFingerprint": "b6d96e4120acb757fb31f8003e38b4de3eba014f652ec08da7890d611e50bacf",
"chromium": "151.0.7922.34",
"platform": "linux",
"witnesses": {
"count": 112,
"count": 128,
"floor": 10
},
"scenarios": {
"panel-wide-view-light-en": "2c93497871bc40f6401f6cb7e65e73bf98d0064bd2755ca2026135975bd2d9f3",
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
"version-mismatch-desktop-dark-en": "8a693b1b676276ceef8c30dbd8dbccb375a4ce856690e1f2beb182242503743c",
"version-mismatch-touch-light-ru": "6aa0d115da2ec6ba9081a57eb739b7531f860a4ad7a9716ff270bce7e82e803b",
"version-mismatch-kiosk-attempted-dark-de": "848b05c62360b532e3105212ea76730bd02320672d341e2cef90875bac7d87fd",
"version-mismatch-desktop-dark-en": "b8059b768284208f605329618799d13491655e488e9d739a40b85cdc0eba5888",
"version-mismatch-touch-light-ru": "df42d0158025371b82eabd522e0dd7d0b9121a9ba685970db3d7839d97fce50f",
"version-mismatch-kiosk-attempted-dark-de": "2e3eaf2fb13d1cca77abe97938374b3e25235fc7db87bb6f7a2e72f1cafe4a7e",
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
"coincident-partition-before-dark": "32d6d7e32db1bb579b33c783833ce986d10a81589e0783c5e7fae4b8c1e2755e",
@@ -40,9 +40,9 @@
"isometric-stage3-no-filter-dark": "b2eeda001fa1d72c74aa0a06a996df205a7398dfc9f729406939a49a4c009a21",
"geometry-view-dark-fit": "454da1248f1fe13b8f60c589ae9ebee5e852995e9bc5175d3c5692c445f7cb94",
"geometry-view-light-fit": "de5f6a41552447ca909d20471ed6665f558fb6e111c1f333eeba55866517d5a5",
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
"pdf-export-geometry-light": "03765f043e4dffd0c46d2a63059c580cb20bfdddd3df49ab58cac626b4d0f91a",
"pdf-export-polish-light": "30c2cb38ec2cec45aba5721a8d210ba982b51ea30a840fff9d76973c1e1f2724",
"pdf-export-stepped-dimensions-light": "5d5d16194fba0ae86b84607eabc4dbda9450162eb6441ad38520e5aaef84b86d",
"vacuum-trail-smoothing-dark": "f8eecc726a3e8bdaba414584ad6afd48908ee9d96ea969a1e37758f4e76843e2",
"room-label-parity-view-dark": "a188d4db84533d605cb6fe822d20c34d164e3bfc8458a55beaad564854eef169",
"room-label-parity-plan-dark": "07e896d4f6dad384bd52dc60f786f042e1cfcd26ddb1f7b12b72d40bb4824324",
@@ -162,7 +162,7 @@
"general-color-popover-desktop-en": "8dd1aac5beb37d942325c566dd1fedd6303a74d97882202cca92516fc4bd7982",
"settings-help-zoom-200-en-light": "d4c0ca1d0a6807707bc5f3366f4da8d8bba13297d09339d36917fd77f9fbe923",
"settings-help-zoom-200-ru-dark": "5128090ce7ed3196b3cf81e95634c11c9a42160959cac682792920353f8405c7",
"support-desktop-empty-light-en": "a16ebd9246343bdfe363eb1546fab0dfee220cdb746575afb920ffad946648d1",
"support-desktop-empty-light-en": "ec60104cb98b646db22089c93f70f78c3a13cdb7001782b2981ce19800ce3cee",
"support-desktop-preview-dark-en": "37707b251c117eb381f7ddd6ad13a235be82c139a4d92dc07789a76cc425e946",
"support-phone-validation-light-ru": "1699dde634aa583ce6aaa30252f6cc01c9d822ff2718d0d77b179c7c38216ea2",
"support-phone-success-dark-en": "21146b3a221010cdc45e1a83c299395772a9f214550a296b78c5ecb72ddab332",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+9 -3
View File
@@ -32,9 +32,13 @@ const spanOverDoorFixture = JSON.parse(readFileSync(
const wallUnionIsolationFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const cardVersion = JSON.parse(readFileSync(
new URL('../../package.json', import.meta.url), 'utf8',
)).version;
// The version the frames show (#512). The card's own CARD_VERSION changes with
// every beta and used to invalidate every frame that prints it (about, the
// version-recovery banner, support and backup previews) although nothing but
// the digits changed. The harness pins the displayed version through the
// `__HP_VERSION_OVERRIDE__` seam and feeds the same constant wherever it plays
// the backend, so a version bump is invisible to golden by construction.
const cardVersion = '0.0.0-golden';
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
const fixtureFor = (scenario) => scenario.fixture === 'large'
@@ -857,6 +861,8 @@ export async function prepareGoldenScenario(page, scenario) {
const fixture = prepareGoldenFixture(scenario);
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
// #512: the displayed version is pinned before any card is created.
window.__HP_VERSION_OVERRIDE__ = cardVersion;
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
+14 -1
View File
@@ -121,7 +121,9 @@ one runner. Its raw reports and comparison are uploaded as
`full-performance-<profile>`, and the table is written to that GitHub job
summary. Stable release assets require both exact-SHA
`Validate` and exact-SHA `Full Performance`; prereleases require only
`Validate`.
`Validate`. The gate (`scripts/release-gate.mjs`, #511) judges by the latest
non-cancelled run on the SHA: a re-run or a manual comparison against another
baseline refreshes the verdict, and a cancelled twin is invisible.
This base-vs-candidate design intentionally does not compare timings captured
on different machines or different Chromium builds. A runtime/profile mismatch
@@ -218,6 +220,17 @@ rationale in the change. Do not loosen a threshold merely to make a single red
run pass. A new fixture profile gets a new profile id instead of silently
changing the meaning of `large-house-v1`.
`large-house-isometric-v1` and its Stage 3 dense twin carry
`countNoiseAllowance: 5` (the other profiles keep 3). Owner decision 2026-09-09, #507: since v1.73.0-beta.1 the
isometric renderer lives in the lazy `iso-scene-render` chunk (#160 Stage 3),
so the single v1.72.0 boot task is split into two around that import. The
load-phase work is unchanged — timings, `longTask.totalP95Ms` and
`longTask.maxSingleMs` keep their unchanged ratios and still catch real
growth — but `longTask.countP95` counts the split as +2 tasks and, with
runner jitter, sat at 16 → 20 against a 19.2 limit on the v1.73.0 stable
comparison. The allowance widens the count check alone to 16 → 21 for that
baseline; it is not a licence for more work per task.
The `cleanFloor` entry ceiling is 100: the reviewed fixture warms exactly 100
deterministic room/physical-body entries. An extra 20 means that one complete
floor was invalidated and rebuilt, so fixture extensions must recalibrate this
@@ -21,7 +21,7 @@
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.2,
"countNoiseAllowance": 3,
"countNoiseAllowance": 5,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.2,
"noiseAllowanceMs": 150
@@ -19,7 +19,7 @@
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.2,
"countNoiseAllowance": 3,
"countNoiseAllowance": 5,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.2,
"noiseAllowanceMs": 150
+9
View File
@@ -18,6 +18,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
methods: Object.freeze([
'_baseVb',
'_bindingStatus',
'_buildModel',
'_cancelDecorGesture',
'_checkSpacePhysicalGeometry',
'_decorBoxOf',
@@ -40,6 +41,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_booting',
'_bootSoft',
'_cameraTransition',
'_cfgEpoch',
...CACHE_FIELDS,
'_continuity',
'_cursorPt',
@@ -72,13 +74,20 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
// bundles moved it into ResizeController. A comparison target must expose
// one of the two explicit shapes; the current member retains its object
// type check and is also verified against current production source.
// #500 replaced the private structural adoption with a gated public entry
// point. The boot diagnostics (#520) count adoptions through whichever of
// the two the measured bundle owns; an undeclared rename would silently
// report zero adoptions instead of failing.
fieldAlternatives: Object.freeze([
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
Object.freeze({ current: '_adoptAuthoritative', legacy: '_adoptStructuralResponses' }),
]),
fieldTypes: Object.freeze({
_adoptAuthoritative: 'function',
_booting: 'boolean',
_bootSoft: 'boolean',
_cameraTransition: 'object',
_cfgEpoch: 'number',
_cleanFloorCache: 'map',
_devices: 'array',
_continuity: 'object',
+316 -69
View File
@@ -1,80 +1,327 @@
// Направляющие выравнивания — свидетель на НАСТОЯЩЕМ жесте (#521).
//
// Прежняя редакция этого смока была зелёной на сломанном коде и потому сама
// оказалась частью дефекта: она присваивала `_deviceDrag`/`_decorDraft`
// целиком и звала `requestUpdate()`. Присвоение с `oldValue == null`
// маршрутизацию живого пути не включает, поэтому проверялась обычная осевая
// отрисовка — состояние, которого при настоящем жесте не бывает. С #451 всё,
// что двигается пальцем, идёт в живого художника, а слой направляющих жил
// только в осевшей сцене; на пользователе это выглядело как «направляющих
// больше нет» в трёх редакторах сразу.
//
// Отсюда протокол каждого сценария:
// 1. дождаться тишины — сразу после входа в режим `_hdrH` сходится
// ступеньками ~200 мс, и каждая ступенька даёт осевой кадр «в подарок»,
// на котором сломанный код тоже показывает направляющую;
// 2. вести жест настоящими `PointerEvent`/`click` по элементу;
// 3. считать осевые циклы за движения — их обязано быть НОЛЬ, иначе смок
// снова проверяет не тот путь;
// 4. требовать ровно одну группу `.alignguides`: осевая копия слоя на время
// жеста подавлена, две линии (одна из них устаревшая) — дефект.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const g = c._gridPitch;
const guides = () => sr().querySelectorAll('.alignline').length;
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
const v = poly[0];
c._path = [[v[0] + g * 10, v[1] + g * 10]];
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
c.requestUpdate(); await c.updateComplete;
out.drawGuide = guides() >= 1;
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
// нет выравнивания → нет гидов
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
out.noGuideOffAxis = guides() === 0;
// 2) бейдж угла: 45° красит
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
const ml = sr().querySelector('.measurelabel');
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
const ml2 = sr().querySelector('.measurelabel');
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
c._path = []; c._cursorPt = null;
// 3) редактор устройств: drag значка в линию с другим значком
c._setMode('devices'); await c.updateComplete;
const devs = c._devices.filter((d) => d.space === c._space);
const a = devs[0], b = devs[1];
const pa = c._pos(a);
// поставим b на тот же Y, начнём drag
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
const pb = c._pos(b);
c._deviceDrag = {
id: b.id, spaceId: b.space, displayName: b.name, pointerId: 74,
source: null, sx: 0, sy: 0, ox: pb.x, oy: pb.y, moved: true,
before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 },
start: c._devicePlacementForCanvas(b, pb.x, pb.y),
// Считается ВИДИМОЕ. Осевая копия слоя на время жеста гасится прозрачностью
// и остаётся в DOM — если считать узлы, две направляющие подряд (живая и
// осевая, отставшая на шаг) выглядят как одна.
const visible = (selector) => [...sr().querySelectorAll(selector)].filter((node) => {
const layer = node.closest('.hp-editor-only-layer');
return !layer || layer.style.opacity !== '0';
});
const lines = () => visible('.alignline').length;
const dots = () => visible('.aligndot').length;
const groups = () => visible('.alignguides').length;
/** Кто сейчас рисует видимую направляющую: живой художник или осевшая сцена. */
const guideOwner = () => {
const group = visible('.alignguides')[0];
if (!group) return 'none';
return group.closest('[data-hp-live-editor]') ? 'live' : 'settled';
};
const keptDrag = c._deviceDrag;
c.requestUpdate(); await c.updateComplete;
out.devGuide = guides() >= 1;
// #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя.
// Прежняя проверка `guides() >= 1` этого не различала: точка всегда
// совпадает сама с собой в пределах допуска, поэтому исключение
// перетаскиваемого маркера могло не работать вовсе — и не работало,
// потому что читало `_drag`, который в этом режиме всегда null.
out.devGuideComesFromAnotherMarker = (() => {
const key = (list) => list.map((point) => point.join(','));
const withDrag = key(c._editorRuntime._alignCandidates());
c._deviceDrag = null;
const withoutDrag = key(c._editorRuntime._alignCandidates());
c._deviceDrag = keptDrag;
// Исключение снимает РОВНО один кандидат — перетаскиваемый маркер.
// Сравнение по разнице списков, а не по заранее посчитанной точке:
// позиция значка живёт вместе с перетаскиванием и к моменту проверки
// уже другая.
const removed = withoutDrag.filter((point) => !withDrag.includes(point));
return withDrag.length === withoutDrag.length - 1 && removed.length === 1;
const guideLineX = () => {
const line = visible('.alignline')[0];
return line ? Number(line.getAttribute('x1')) : NaN;
};
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
/** Осевые кадры оседания редактора должны закончиться ДО жеста. */
const quiet = async () => {
for (let i = 0; i < 8; i++) await frame();
await new Promise((resolve) => setTimeout(resolve, 350));
};
const pe = (type, id, extra) => new PointerEvent(type, {
pointerId: id, isPrimary: true, bubbles: true, composed: true, cancelable: true, ...extra,
});
let settled = 0;
const settledWillUpdate = c.willUpdate.bind(c);
c.willUpdate = function (changed) { settled += 1; return settledWillUpdate(changed); };
const g = c._gridPitch;
const stage = () => c._stageEl || sr().querySelector('.stage');
const toClient = (x, y) => {
const rect = stage().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return {
x: rect.left + ((x - v.x) / v.w) * rect.width,
y: rect.top + ((y - v.y) / v.h) * rect.height,
};
};
const pxPerUnit = () => {
const rect = stage().getBoundingClientRect();
return rect.width / c._viewOr(c._baseVb()).w;
};
// ---- 1) редактор устройств: настоящее перетаскивание значка -------------
c._setMode('devices');
await c.updateComplete;
const devs = c._devices.filter((d) => d.space === c._space && !d.virtual
&& d.bindingStatus?.kind !== 'ha_disabled');
const anchor = devs[0];
const dragged = devs[1];
// Обе позиции — на узлах сетки, иначе выравнивание жестом недостижимо.
// Цель ведёт значок и по X (на ось якоря), и по Y — тогда конец
// направляющей отличает живую точку от замороженного снимка.
c._layout = {
...c._layout,
[anchor.id]: { s: c._space, x: (53 * g) / 1000, y: (53 * g) / 1000 },
[dragged.id]: { s: c._space, x: (61 * g) / 1000, y: (69 * g) / 1000 },
};
c.requestUpdate();
await c.updateComplete;
const anchorPos = c._pos(anchor);
const marker = [...sr().querySelectorAll('[data-hp="device"]')]
.find((element) => element.dataset.id === dragged.id);
out.markerIsOnTheStage = !!marker;
const live = () => c._livePos(c._devices.find((d) => d.id === dragged.id));
await quiet();
const box = marker.getBoundingClientRect();
const sx = box.x + box.width / 2;
const sy = box.y + box.height / 2;
marker.dispatchEvent(pe('pointerdown', 77, { clientX: sx, clientY: sy }));
await c.updateComplete;
out.pointerDownStartsTheDrag = !!c._deviceDrag && c._deviceDrag.moved === false;
const settledAtDown = settled;
let clientX = sx;
let clientY = sy;
let oneLayerEveryStep = true;
const targetY = anchorPos.y + g * 8;
for (let step = 0; step < 10; step++) {
const at = live();
const dx = anchorPos.x - at.x;
const dy = targetY - at.y;
if (Math.abs(dx) < g * 0.01 && Math.abs(dy) < g * 0.01) break;
const share = step < 4 ? 1 / (4 - step) : 1;
clientX += dx * share * pxPerUnit();
clientY += dy * share * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
await frame();
await frame();
if (groups() > 1) oneLayerEveryStep = false;
}
const atGuide = live();
out.dragReachedTheAnchorAxis = Math.abs(atGuide.x - anchorPos.x) < 1e-6;
out.devGuideFollowsARealDrag = lines() >= 1 && dots() >= 1;
out.devSingleGuideLayer = groups() === 1 && oneLayerEveryStep;
out.devZeroSettledRendersDuringDrag = settled - settledAtDown === 0;
// Конец направляющей считается от точки жеста: `pt[1] ± 1.5 шага`. Живая
// точка и замороженный снимок здесь заведомо разные — это и есть проверка,
// что направляющую нарисовали от маркера, а не от его прошлого места.
const frozen = c._pos(c._devices.find((d) => d.id === dragged.id));
const over = g * 1.5;
const endFrom = (point) => point.y + Math.sign(point.y - anchorPos.y) * over;
const guideLine = sr().querySelector('.alignline');
const y2 = guideLine ? Number(guideLine.getAttribute('y2')) : NaN;
out.snapshotIsStaleDuringTheDrag = Math.abs(endFrom(frozen) - endFrom(atGuide)) > 1;
out.devGuideIsMeasuredFromTheLivePosition = Math.abs(y2 - endFrom(atGuide)) < 0.01;
out.alignPointIsTheLivePosition = Math.abs(c._alignPoint[0] - atGuide.x) < 1e-6
&& Math.abs(c._alignPoint[1] - atGuide.y) < 1e-6;
out.guideIsPaintedByTheLivePainter = guideOwner() === 'live';
// #400: направляющая обязана идти ОТ ДРУГОГО значка. Проверяется без
// подмены состояния жеста: перетаскиваемого маркера нет среди кандидатов.
const candidates = c._editorRuntime._alignCandidates();
const key = (x, y) => `${Math.round(x * 1000)}:${Math.round(y * 1000)}`;
const candidateKeys = candidates.map((point) => key(point[0], point[1]));
out.devCandidatesExcludeTheDraggedMarker = !candidateKeys.includes(key(atGuide.x, atGuide.y))
&& candidates.length === devs.length - 1;
out.devGuideAnchorIsTheOtherMarker = candidateKeys.includes(key(anchorPos.x, anchorPos.y));
// AC6: серия движений внутри одного кадра — один расчёт кандидатов.
const runtime = c._editorRuntime;
const realCandidates = runtime._alignCandidates.bind(runtime);
let candidateCalls = 0;
runtime._alignCandidates = function () { candidateCalls += 1; return realCandidates(); };
const paintsBefore = c._liveEditorPaintCount;
for (let i = 1; i <= 5; i++) {
marker.dispatchEvent(pe('pointermove', 77, { clientX: clientX + i, clientY }));
}
await frame();
await frame();
const paints = c._liveEditorPaintCount - paintsBefore;
out.candidatesAreComputedOncePerFrame = paints >= 1 && candidateCalls >= 1
&& candidateCalls <= paints;
runtime._alignCandidates = realCandidates;
// Передача слоя посреди жеста. Осевой кадр приходит не от жеста — у
// владельца это делает наблюдатель высоты шапки (`_hdrH` сходится
// ступеньками при любом изменении раскладки), и он же маскировал дефект на
// замерах S2. Каждый осевой рендер заканчивается `_commitLiveEditor()`,
// который гасит живой слой и возвращает владение сцене: двух групп не
// бывает, но точка обязана остаться живой — иначе осевая копия нарисует
// направляющую там, где маркер стоял до жеста.
// Серия AC6 увела маркер с оси на пару шагов — возвращаем его, иначе
// проверять передачу слоя не на чем: без совпадения направляющей нет.
for (let step = 0; step < 8; step++) {
const at = live();
if (Math.abs(at.x - anchorPos.x) < g * 0.01) break;
clientX += (anchorPos.x - at.x) * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
await frame();
await frame();
}
const atHandover = live();
out.markerIsBackOnTheAxis = Math.abs(atHandover.x - anchorPos.x) < 1e-6 && lines() === 1;
const settledBeforeHandover = settled;
c._hdrH = (c._hdrH || 0) + 0.01;
await c.updateComplete;
await frame();
out.handoverHappened = settled - settledBeforeHandover >= 1;
out.handoverGivesTheLayerBackToTheSettledScene = guideOwner() === 'settled'
&& sr().querySelector('[data-hp-live-editor]').childElementCount === 0;
out.handoverKeepsExactlyOneGuide = groups() === 1 && lines() === 1;
out.handoverGuideStaysOnTheLivePoint = Math.abs(guideLineX() - atHandover.x) < 1e-6;
// Настоящее движение — и слой снова у живого художника. Шаг вниз по той же
// оси: выравнивание сохраняется, а позиция меняется, иначе живой отрисовки
// не будет вовсе — менять нечего, и это правильно.
clientY += g * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
for (let i = 0; i < 10 && guideOwner() !== 'live'; i++) await frame();
out.nextMoveTakesTheLayerBack = guideOwner() === 'live' && groups() === 1 && lines() === 1;
// Съехали с оси — направляющей нет. Смещение выбирается так, чтобы не
// попасть на ось ни одного из кандидатов: их девять, и «на глаз» выбранный
// отступ однажды уже сел ровно на чужую ось.
const away = (() => {
const points = c._editorRuntime._alignCandidates();
const here = live();
for (let step = 3; step <= 24; step++) {
const x = here.x + g * step;
const y = here.y + g * step;
const clear = points.every((point) => Math.abs(point[0] - x) > g * 0.5
&& Math.abs(point[1] - y) > g * 0.5);
if (clear) return step;
}
return 0;
})();
c._deviceDrag = null; c.requestUpdate(); await c.updateComplete;
out.devGuideGone = guides() === 0;
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
c._setMode('decor'); await c.updateComplete;
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
const W = 1000, H = 1000; // square canvas
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
c.requestUpdate(); await c.updateComplete;
out.decorGuide = guides() >= 1;
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
// 5) в Просмотре гидов не бывает
c._setMode('view'); await c.updateComplete;
out.noneInView = guides() === 0;
out.foundAPlaceOffEveryAxis = away > 0;
clientX += g * away * pxPerUnit();
clientY += g * away * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
await frame();
await frame();
out.devNoGuideOffAxis = lines() === 0;
marker.dispatchEvent(pe('pointerup', 77, { clientX, clientY }));
await c.updateComplete;
out.devGuideGoneAfterTheDrop = lines() === 0 && groups() === 0;
// ---- 2) подложка: настоящее рисование прямоугольника -------------------
c._setMode('decor');
await c.updateComplete;
c._curSpaceCfg.decor = [{
id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3,
}];
c._decorTool = 'rect';
c._decorStyle = { color: '#00ff00', width: 3, fill: false };
c.requestUpdate();
await c.updateComplete;
await quiet();
{
const start = toClient(500, 500);
const element = stage();
const settledAtStart = settled;
element.dispatchEvent(pe('pointerdown', 91, { clientX: start.x, clientY: start.y }));
await c.updateComplete;
out.decorGestureStartsADraft = !!c._decorDraft;
let x = start.x;
const y = toClient(0, 600).y;
let decorOneLayer = true;
for (let step = 0; step < 10; step++) {
const b = c._decorDraft?.b;
if (!b) break;
const remaining = 200 - b[0]; // левый край d1
if (Math.abs(remaining) < 0.05) break;
x += remaining * pxPerUnit() * (step < 3 ? 0.5 : 1);
element.dispatchEvent(pe('pointermove', 91, { clientX: x, clientY: y }));
await frame();
await frame();
if (groups() > 1) decorOneLayer = false;
}
out.decorDraftReachedTheShapeCorner = Math.abs((c._decorDraft?.b?.[0] ?? -1) - 200) < 0.05;
out.decorGuideFollowsARealGesture = lines() >= 1 && dots() >= 1;
out.decorSingleGuideLayer = groups() === 1 && decorOneLayer;
out.decorZeroSettledRendersDuringGesture = settled - settledAtStart <= 1;
element.dispatchEvent(pe('pointerup', 91, { clientX: x, clientY: y }));
await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select';
c.requestUpdate();
await c.updateComplete;
}
// ---- 3) план: направляющая идёт за курсором БЕЗ клика ------------------
c._setMode('plan');
c._tool = 'draw';
await c.updateComplete;
await quiet();
{
const room = c._spaceModel().rooms[0];
const poly = room.poly || [[room.x, room.y], [room.x + room.w, room.y],
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h]];
const vertex = poly[0];
const element = stage();
const first = toClient(vertex[0] + g * 10, vertex[1] + g * 10);
element.dispatchEvent(new MouseEvent('click', {
clientX: first.x, clientY: first.y, bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.planClickAddsTheFirstVertex = c._path.length === 1;
await quiet();
const settledAtHover = settled;
let planOneLayer = true;
for (let step = 1; step <= 4; step++) {
const x = vertex[0] + g * 10 - (g * 10 * step) / 4;
const point = toClient(x, vertex[1] + g * 20);
element.dispatchEvent(pe('pointermove', 93, { clientX: point.x, clientY: point.y }));
await frame();
await frame();
if (groups() > 1) planOneLayer = false;
}
out.planHoverReachedTheVertexAxis = Math.abs((c._cursorPt?.[0] ?? -1) - vertex[0]) < 1e-6;
out.planGuideFollowsTheCursorWithoutAClick = lines() >= 1 && dots() >= 1;
out.planSingleGuideLayer = groups() === 1 && planOneLayer;
out.planZeroSettledRendersDuringHover = settled - settledAtHover === 0;
// Бейдж угла живёт тем же жестом: 45° красит, иначе — обычный градус.
const at45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 5);
element.dispatchEvent(pe('pointermove', 93, { clientX: at45.x, clientY: at45.y }));
await frame();
await frame();
const label45 = sr().querySelector('.measurelabel');
out.badge45 = !!label45 && label45.classList.contains('on45') && label45.textContent.includes('45');
const off45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 2);
element.dispatchEvent(pe('pointermove', 93, { clientX: off45.x, clientY: off45.y }));
await frame();
await frame();
const labelOff = sr().querySelector('.measurelabel');
out.badgeOff45 = !!labelOff && !labelOff.classList.contains('on45')
&& labelOff.textContent.includes('°');
c._path = [];
c._cursorPt = null;
c.requestUpdate();
await c.updateComplete;
}
// ---- 4) в Просмотре направляющих не бывает ----------------------------
c._setMode('view');
await c.updateComplete;
out.noneInView = lines() === 0 && groups() === 0;
return out;
});
checkAll(res);
+2 -1
View File
@@ -196,7 +196,8 @@ const out = await page.evaluate(async () => {
card._saveConfigDebounced.cancel();
card._persistLayout.cancel();
card._writeChain = Promise.resolve();
card._adoptStructuralResponses = () => ({ configChanged: false, layoutChanged: false });
// #500: the delete path re-reads and adopts through the one gated entry; keep it inert here.
card._adoptAuthoritative = async () => ({ status: 'adopted', spaceChanged: false });
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
card._space = 'another-space';
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
+2 -2
View File
@@ -37,7 +37,7 @@ const res = await page.evaluate(async () => {
if (message.type === 'houseplan/config/get') {
// #397 AC3: the reconnect path reads BOTH answers, and a config that
// differs clears the history for its own reason (`configChanged` in
// `_adoptStructuralResponses`). To isolate the layout question the
// `adoptStructuralResponses`, #500). To isolate the layout question the
// fake server echoes the config the card already holds.
return { config: structuredClone(c._serverCfg), rev: c._cfgRev };
}
@@ -294,7 +294,7 @@ const res = await page.evaluate(async () => {
placement: structuredClone(rawProbe) },
});
// #397 AC3: the reconnect path (`_loadFromServer` →
// `_adoptStructuralResponses`) is the OTHER reader of the same value: it
// `adoptAuthoritativeGated`, #500) is the OTHER reader of the same value: it
// compares the stored `_layoutContentFingerprint` with the server answer
// instead of recomputing it. It must be exercised right after a write,
// before any reload has had a chance to align the two sides — otherwise
+193
View File
@@ -0,0 +1,193 @@
// #500 AC4: the four post-write adoptions — space/delete from both runtimes,
// Optimize Undo and Import apply — pass the same backdrop readiness gate as a
// reload before the structure is replaced, and take config/layout revisions
// from the re-read `config/get`/`layout/get`, never from the write reply.
// A second client changes `plan_url` between our write and the re-read; the
// card must prepare THAT backdrop first and end on the re-read revision.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const card = window.__card;
await card._ensureOnboardingRuntime();
const editor = card._editorRuntime;
const onboarding = card._onboardingRuntime;
const adoption = card._adoption;
const result = {};
const space = (id, title, planUrl) => ({
id, title, plan_url: planUrl, view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], partitions: [],
});
const freshConfig = () => ({
model_version: 9,
spaces: [space('alpha', 'Alpha', '/local/alpha.png'), space('beta', 'Beta', '/local/beta.png')],
markers: [],
settings: {},
});
const order = [];
const toasts = [];
let retries = 0;
let assetReady = true;
const originalPrepareImage = card._signer.prepareImage.bind(card._signer);
card._signer.prepareImage = async (_hass, href) => { order.push(`prepare:${href}`); return assetReady; };
card._scheduleLoadRetry = () => { retries += 1; };
const originalAdoptResponses = adoption.adoptResponses.bind(adoption);
adoption.adoptResponses = (...args) => { order.push('adopt'); return originalAdoptResponses(...args); };
card._confirmDanger = async () => true;
card._showToast = (message) => { toasts.push(message); };
// Fake server: our write bumps both revisions; a concurrent client then
// swaps the backdrop and bumps the config revision again before the re-read.
let server;
let concurrentHref = '';
const concurrentWrite = () => {
for (const s of server.config.spaces) s.plan_url = concurrentHref;
server.cfgRev += 1;
};
const originalHass = card.hass;
card.hass = {
...originalHass,
callWS: async (message) => {
switch (message.type) {
case 'houseplan/space/delete': {
server.config.spaces = server.config.spaces.filter((s) => s.id !== message.space_id);
server.cfgRev += 1; server.layRev += 1;
const reply = { config_rev: server.cfgRev, layout_rev: server.layRev };
concurrentWrite();
return reply;
}
case 'houseplan/plan/optimize_undo': {
server.cfgRev += 1; server.layRev += 1;
const reply = { config_rev: server.cfgRev, layout_rev: server.layRev };
concurrentWrite();
return reply;
}
case 'houseplan/import/apply': {
server.cfgRev += 1; server.layRev += 1;
const reply = { kind: 'full', config_rev: server.cfgRev, layout_rev: server.layRev, counts: { spaces: 2, rooms: 0, markers: 0 } };
concurrentWrite();
return reply;
}
case 'houseplan/config/get':
return { config: structuredClone(server.config), rev: server.cfgRev, can_write: true };
case 'houseplan/layout/get':
return { layout: structuredClone(server.layout), rev: server.layRev };
default:
return {};
}
},
};
const reset = async (scenario) => {
// Ревью r2 M1: отложенная запись предыдущего сценария иначе доживает до
// следующего и флашится безусловным `if (pending) flush()` внутри
// `_deleteSpace`; мок отвечает `{}` без `rev`, запасной путь «rev + 1»
// двигает ревизию по ЧУЖОМУ телу, и отказная ветка выглядит принявшей.
// Сценарии обязаны быть независимы — так же чистится smoke_danger_confirmation.
card._saveConfigDebounced.cancel();
card._persistLayout.cancel();
server = { config: freshConfig(), layout: {}, cfgRev: 10, layRev: 20 };
concurrentHref = `media-source://image/${scenario}-500`;
adoption.restoreCached({ config: structuredClone(server.config), rev: 10, layout: {}, layout_rev: 20 });
card._space = 'beta';
card._spaceDialog = null;
card._backupImportDialog = null;
order.length = 0;
toasts.length = 0;
retries = 0;
await card.updateComplete;
};
// Review r1 M1: a refused backdrop gate adopts nothing and skips the caller's
// tail (no toast, no space switch, dialog released), exactly like every
// reload path; the scheduled retry owns the rest.
const refusedVerdict = (scenario, extra = {}) => {
result[`${scenario}RefusedAdoptsNothing`] = card._cfgRev === 10 && card._layoutRev === 20
&& card._serverCfg.spaces.length === 2 && card._serverCfg.spaces.every((s) => s.plan_url !== concurrentHref);
result[`${scenario}RefusedSchedulesRetry`] = retries === 1 && !order.includes('adopt');
result[`${scenario}RefusedSkipsTail`] = toasts.length === 0 && card._space === 'beta';
Object.assign(result, extra);
};
const verdict = (scenario, extra = {}) => {
const prepared = order.indexOf(`prepare:${concurrentHref}`);
const adopted = order.indexOf('adopt');
result[`${scenario}PreparesConcurrentBackdropBeforeAdoption`] = prepared >= 0 && adopted > prepared;
result[`${scenario}RevisionsFromReRead`] = card._cfgRev === server.cfgRev && card._layoutRev === server.layRev;
result[`${scenario}AdoptsConcurrentBackdrop`] = card._serverCfg.spaces.every((s) => s.plan_url === concurrentHref);
Object.assign(result, extra);
};
const spaceDialog = (spaceId) => ({
mode: 'edit', spaceId, title: 'Beta', planUrl: null, planFile: null, source: 'draw',
showBorders: true, showNames: true, zeroWallStyle: 'dashed', displayTouched: true,
hideDecor: false, hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
busy: false, saved: [],
});
for (const [name, runtime] of [['onboardingDelete', onboarding], ['editorDelete', editor]]) {
await reset(name);
card._spaceDialog = spaceDialog('beta');
await runtime._deleteSpace();
// the write reply carried cfgRev 11; the concurrent client moved it to 12
verdict(name, {
[`${name}RevisionNotFromDeleteReply`]: card._cfgRev === 12 && server.cfgRev === 12,
[`${name}LeavesDeletedSpace`]: card._space === 'alpha' && card._serverCfg.spaces.length === 1,
[`${name}ClosesDialog`]: card._spaceDialog === null,
});
}
await reset('optimizeUndo');
card._canOptimizeUndo = true;
card._undoKind = 'optimize';
await editor._undoPlanOptimization();
verdict('optimizeUndo', { optimizeUndoClearsUndo: card._canOptimizeUndo === false && card._undoKind === null });
await reset('importApply');
card._backupImportDialog = {
filename: 'plan.json', size: 1, token: 'token-500', preview: { confirmation_required: false, counts: {} },
expectedConfigRev: card._cfgRev, expectedLayoutRev: card._layoutRev,
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
};
const fixedFloor = !!card._hasFixedFloor;
await editor._applyBackupImport();
verdict('importApply', {
importApplyClosesDialog: card._backupImportDialog === null,
// today's rule, not its absence: a fixed-floor card re-selects through
// _adoptInitialSpace, otherwise the previous space is kept
importApplyKeepsSpaceRule: fixedFloor ? typeof card._space === 'string' && card._space.length > 0 : card._space === 'beta',
});
assetReady = false;
for (const [name, runtime] of [['onboardingDelete', onboarding], ['editorDelete', editor]]) {
await reset(`${name}-refused`);
card._spaceDialog = spaceDialog('beta');
await runtime._deleteSpace();
refusedVerdict(name, { [`${name}RefusedReleasesDialog`]: card._spaceDialog?.busy === false });
}
await reset('optimizeUndo-refused');
card._canOptimizeUndo = true;
card._undoKind = 'optimize';
await editor._undoPlanOptimization();
refusedVerdict('optimizeUndo', { optimizeUndoRefusedKeepsUndoFlag: card._canOptimizeUndo === true && card._optimizeUndoBusy === false });
await reset('importApply-refused');
card._backupImportDialog = {
filename: 'plan.json', size: 1, token: 'token-500', preview: { confirmation_required: false, counts: {} },
expectedConfigRev: card._cfgRev, expectedLayoutRev: card._layoutRev,
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
};
await editor._applyBackupImport();
refusedVerdict('importApply', { importApplyRefusedReleasesDialog: card._backupImportDialog?.busy === false });
assetReady = true;
card.hass = originalHass;
card._signer.prepareImage = originalPrepareImage;
delete card._scheduleLoadRetry;
delete adoption.adoptResponses;
await card.updateComplete;
return result;
});
checkAll(out);
await finish(browser, out);
+17 -15
View File
@@ -118,29 +118,31 @@ check('kioskNoVeil', kiosk.kioskNoVeil);
check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
// The wait lives on the Playwright side: an in-page promise that only a
// requestAnimationFrame chain keeps alive was reported as "garbage collected"
// on a CI runner (#506 beta.9 candidate) while the same phase passed locally.
// Polling from outside cannot be collected, and a destroyed context or a
// navigation surfaces as its own error instead of a vanished promise.
page.on('framenavigated', (frame) => console.log('diagnostic navigation:', frame.url()));
page.on('crash', () => console.log('diagnostic page crash'));
await page.emulateMedia({ reducedMotion: 'reduce' });
const reduced = await page.evaluate(async () => {
await page.evaluate(() => {
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let anim = 'missed';
await new Promise((done) => {
const tick = () => {
const house = sr().querySelector('.bootveil .boothouse');
if (house) { anim = getComputedStyle(house).animationName; done(); return; }
if (performance.now() - t0 < 500) requestAnimationFrame(tick);
else done();
};
requestAnimationFrame(tick);
});
c.remove();
return { anim };
window.__preloaderReduced = { card: c, started: performance.now() };
});
const reduced = await page.waitForFunction(() => {
const probe = window.__preloaderReduced;
const root = probe.card.shadowRoot || probe.card.renderRoot;
const house = root?.querySelector('.bootveil .boothouse');
if (house) return { anim: getComputedStyle(house).animationName };
return performance.now() - probe.started > 500 ? { anim: 'missed' } : null;
}, null, { polling: 'raf', timeout: 5000 }).then((handle) => handle.jsonValue());
await page.evaluate(() => { window.__preloaderReduced.card.remove(); delete window.__preloaderReduced; });
check('reducedMotionStaticHouse', reduced.anim, 'none');
await finish(browser, { ...res, ...kiosk, ...reduced });
+139
View File
@@ -0,0 +1,139 @@
// Переключение пространств не доигрывает чужие переходы (#525).
//
// Lit переиспользует узлы списка по позиции. Пока у створки двери
// (`.op-leaf`, `transform`) и у дуги (`.op-arc`, `stroke-dashoffset`) есть
// переход в 0.6 с, позиционное переиспользование превращает смену
// пространства в анимацию: створка нового этажа доезжает из положения двери,
// которая занимала этот слот раньше. То же у оболочки маркера
// (`.device-shell-frame`, `box-shadow`).
//
// Две ловушки, обе стоили бы свидетелю правдивости:
// 1. `document.getAnimations()` здесь пуст ДАЖЕ НА СЛОМАННОМ КОДЕ — карточка
// живёт в shadow root, и документный вызов туда не заходит. Считаем
// поэлементно, обходя вложенные теневые деревья.
// 2. В демо-доме проёмов нет вовсе (`space.openings` пуст у обоих
// пространств), поэтому дверь в двух пространствах готовит сам смок.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
const settle = async (frames = 10, ms = 500) => {
for (let i = 0; i < frames; i++) await frame();
await new Promise((resolve) => setTimeout(resolve, ms));
};
/** Все бегущие анимации внутри карточки, включая вложенные shadow root. */
const runningAnimations = () => {
const found = [];
const walk = (node) => {
for (const element of node.querySelectorAll('*')) {
const list = element.getAnimations ? element.getAnimations() : [];
for (const animation of list) {
if (animation.playState !== 'running') continue;
found.push({
cls: element.getAttribute?.('class') || element.tagName,
property: animation.transitionProperty || animation.animationName || '?',
});
}
if (element.shadowRoot) walk(element.shadowRoot);
}
};
walk(root());
return found;
};
// Вкладка подсвечивается, слайд едет, значения сводной панели проявляются —
// это и есть переключение. Всё остальное, что бежит сразу после него,
// доигрывает чужие данные.
const EXPECTED = (entry) => /(^|\s)tab(\s|$)|zoomwrap/.test(entry.cls)
|| /^summary-/.test(entry.property);
// ---- фикстура: дверь на одном месте в двух пространствах ---------------
const ids = c._serverCfg.spaces.map((space) => space.id).slice(0, 2);
out.twoSpacesExist = ids.length === 2;
const physicalize = (space) => {
for (const room of [...(space.rooms || [])]) {
const rendered = c._spaceModelById(space.id)?.rooms?.find((item) => item.id === room.id);
if (!rendered?.poly?.length) continue;
c._wallDialog = {
a: rendered.poly[0], b: rendered.poly[1], value: '15', roomId: room.id,
source: { kind: 'room' }, sx: 0, sy: 0,
};
c._wallThickApply(true);
}
};
for (const id of ids) physicalize(c._serverCfg.spaces.find((space) => space.id === id));
c._geometryHistory.length = 0;
const contactOpen = 'binary_sensor.probe_door_open';
const contactShut = 'binary_sensor.probe_door_shut';
const withContacts = (openState) => ({
...c.hass,
states: {
...c.hass.states,
[contactOpen]: { entity_id: contactOpen, state: openState, attributes: { device_class: 'door' } },
[contactShut]: { entity_id: contactShut, state: 'off', attributes: { device_class: 'door' } },
},
entities: {
...(c.hass.entities || {}),
[contactOpen]: { entity_id: contactOpen },
[contactShut]: { entity_id: contactShut },
},
});
c.hass = withContacts('on');
const door = (id, contact) => ({
id, type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08, contact,
});
c._serverCfg.spaces.find((space) => space.id === ids[0]).openings = [door('opA', contactOpen)];
c._serverCfg.spaces.find((space) => space.id === ids[1]).openings = [door('opB', contactShut)];
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
// ---- 1) штатное переключение не анимирует чужую створку ----------------
c._pickSpace(ids[0]);
await c.updateComplete;
await settle();
const leafBefore = root().querySelector('.op-leaf');
out.doorIsDrawnInTheFirstSpace = !!leafBefore;
out.quietBeforeTheSwitch = runningAnimations().filter((entry) => !EXPECTED(entry)).length === 0;
// поворот створки живёт инлайновым стилем, не атрибутом
const leafTransform = (leaf) => leaf?.style?.transform || leaf?.getAttribute('transform') || null;
const transformBefore = leafTransform(leafBefore);
c._pickSpace(ids[1]);
await c.updateComplete;
await frame();
const afterSwitch = runningAnimations();
const leafAfter = root().querySelector('.op-leaf');
out.doorIsDrawnInTheSecondSpace = !!leafAfter;
out.theTwoDoorsDifferInState = !!transformBefore && transformBefore !== leafTransform(leafAfter);
out.noOpeningTransitionOnSwitch = afterSwitch
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
out.noMarkerTransitionOnSwitch = afterSwitch
.filter((entry) => /device-shell-frame/.test(entry.cls)).length === 0;
const unexpected = afterSwitch.filter((entry) => !EXPECTED(entry));
out.onlyTheSwitchItselfAnimates = unexpected.length === 0;
out.unexpectedAnimations = unexpected.map((entry) => `${entry.cls}:${entry.property}`);
// ещё кадр — переход мог стартовать с задержкой в один тик
await frame();
await frame();
const later = runningAnimations().filter((entry) => !EXPECTED(entry));
out.stillQuietOneFrameLater = later.length === 0;
// ---- 2) настоящая смена состояния двери по-прежнему анимируется --------
await settle();
out.quietBeforeTheRealChange = runningAnimations()
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
c.hass = { ...c.hass, states: { ...c.hass.states,
[contactShut]: { entity_id: contactShut, state: 'on', attributes: { device_class: 'door' } } } };
await c.updateComplete;
await frame();
const onRealChange = runningAnimations();
out.aRealDoorOpeningStillAnimates = onRealChange
.filter((entry) => /op-leaf/.test(entry.cls)).length >= 1;
return out;
});
checkAll(res, { unexpectedAnimations: [] });
await finish(browser, res);
+155
View File
@@ -0,0 +1,155 @@
// #508: the summary-panel settings dialog scrolls in Home Assistant — wheel and
// touch. In HA `hp-dialog` renders `ha-dialog`, whose own `.body` is the
// scroller; `.summary-editor` (overflow:auto + overscroll-behavior:contain) was
// not height-bound there, and a scroll container that never scrolls stops
// Chromium's scroll chaining for wheel and touch alike. `flex-content` binds it.
//
// The stub below reproduces the HA 2026.9 ha-dialog contract that matters:
// a fixed, height-capped `dialog` laid out as a flex column, a `.body` with
// overflow:auto, `:host([flexcontent]) .body` as a flex column, and the
// full-screen media query for phones. It is registered BEFORE the dialog is
// opened, so hp-dialog takes its HA branch. Witness (c) proves the stub
// reproduces the bug: without `flexcontent` the editor does not scroll.
import { launch, check, finish } from './serve.mjs';
const STUB = String.raw`
class HaDialogStub extends HTMLElement {
static get observedAttributes() { return ['open']; }
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = '<style>'
+ ':host{display:block}'
+ 'dialog{position:fixed;inset:0;margin:auto;width:min(var(--ha-dialog-width-md,580px),95vw);'
+ 'max-height:calc(100vh - 80px);padding:0;border:0;display:flex;flex-direction:column;overflow:hidden}'
+ '.header{flex:none;min-height:56px;display:flex;align-items:center;padding:0 16px}'
+ '.content-wrapper{position:relative;flex:1;display:flex;flex-direction:column;min-height:0}'
+ '.body{position:relative;overflow:auto;flex-grow:1}'
+ ':host([flexcontent]) .body{max-width:100%;flex:1;display:flex;flex-direction:column}'
+ '.footer{flex:none;display:flex;padding:12px 16px 16px}'
+ '@media all and (max-width:450px){dialog{width:100vw;max-height:100dvh;min-height:100dvh}}'
+ '</style><dialog><div class="header"><slot name="headerTitle"></slot></div>'
+ '<div class="content-wrapper"><div class="body"><slot></slot></div></div>'
+ '<div class="footer"><slot name="footer"></slot></div></dialog>';
}
set open(value) { this._open = !!value; this._sync(); }
get open() { return !!this._open; }
connectedCallback() { this._sync(); queueMicrotask(() => this.dispatchEvent(new Event('opened'))); }
_sync() {
const dialog = this.shadowRoot.querySelector('dialog');
if (!dialog || !this.isConnected) return;
if (this._open && !dialog.open) dialog.showModal();
if (!this._open && dialog.open) dialog.close();
}
}
customElements.define('ha-dialog', HaDialogStub);
`;
async function scenario(viewport, { flexContent = true } = {}) {
const { page, browser } = await launch(viewport, 1, [], { hasTouch: viewport.width < 500 });
await page.waitForFunction(() => !!window.__card?._summary);
const opened = await page.evaluate(async ({ stub, flexContent }) => {
// eslint-disable-next-line no-new-func
new Function(stub)();
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const settle = async () => { await card.updateComplete; await frame(); await card.updateComplete; };
card._config = { ...card._config, language: 'en', kiosk: false };
card._serverCanWrite = true;
card._haSummaryPanelApi = 1;
card.narrow = innerWidth < 500;
const config = structuredClone(card._serverCfg);
const blocks = [];
for (let index = 0; index < 6; index += 1) {
blocks.push({ id: `block-${index}`, title: `Block ${index}`, visible: true, scope: { type: 'all' }, values: [
{ id: `devices-${index}`, label: 'Devices', source: { type: 'system', key: 'device_count' } },
{ id: `area-${index}`, label: 'Floor area', source: { type: 'system', key: 'total_area' } },
{ id: `clock-${index}`, label: 'Date and time', source: { type: 'system', key: 'datetime' } },
] });
}
config.settings = { ...config.settings, summary_panel: { version: 1, title: 'Summary', show_on_mobile: true, blocks } };
card._serverCfg = config;
card._settings = config.settings;
card._sendConfigCandidate = async () => {};
card._summary.updated();
card.requestUpdate();
await settle();
root().querySelector('.summary-control')?.querySelector('button:first-child')?.click();
for (let i = 0; i < 80 && !root().querySelector('hp-dialog[data-kind="summary"]'); i += 1) { await frame(); }
const dialog = root().querySelector('hp-dialog[data-kind="summary"]');
if (!dialog) return { dialog: false };
if (!flexContent) { dialog.flexContent = false; dialog.removeAttribute('flex-content'); }
await dialog.updateComplete;
await settle();
await new Promise((resolve) => setTimeout(resolve, 150));
const editor = dialog.querySelector('.summary-editor');
const ha = dialog.shadowRoot.querySelector('ha-dialog');
const body = ha?.shadowRoot.querySelector('.body');
const native = ha?.shadowRoot.querySelector('dialog');
const rect = (node) => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; };
const footer = dialog.querySelector('[slot="footer"]');
window.__508 = { dialog, editor, body };
return {
dialog: true, haBranch: !!ha, flexContent: ha?.hasAttribute('flexcontent') === true,
editor: rect(editor), editorScroll: [editor.scrollHeight, editor.clientHeight],
body: rect(body), bodyScroll: [body.scrollHeight, body.clientHeight],
dialogRect: rect(native), footer: footer ? rect(footer) : null, inner: [innerWidth, innerHeight],
};
}, { stub: STUB, flexContent });
const readScroll = () => page.evaluate(() => ({ editor: window.__508.editor.scrollTop, body: window.__508.body.scrollTop }));
return { page, browser, opened, readScroll };
}
// (a) desktop: the wheel over the content scrolls .summary-editor; header/footer stay
{
const { page, browser, opened, readScroll } = await scenario({ width: 1280, height: 720 });
check('desktop.haBranch', opened.haBranch);
check('desktop.flexContent', opened.flexContent);
check('desktop.editorIsTheScroller', opened.editorScroll[0] > opened.editorScroll[1] + 50);
check('desktop.bodyDoesNotScroll', opened.bodyScroll[0] <= opened.bodyScroll[1] + 1);
const footerBefore = opened.footer;
await page.mouse.move(opened.editor.x + opened.editor.w / 2, opened.editor.y + Math.min(opened.editor.h / 2, 160));
await page.mouse.wheel(0, 240);
await page.waitForTimeout(250);
const after = await readScroll();
check('desktop.wheelScrollsEditor', after.editor >= 100);
const footerAfter = await page.evaluate(() => { const r = window.__508.dialog.querySelector('[slot="footer"]').getBoundingClientRect(); return { y: r.y, h: r.height }; });
check('desktop.footerStays', Math.abs(footerAfter.y - footerBefore.y) < 1 && footerAfter.y + footerAfter.h <= opened.inner[1] + 1);
await browser.close();
}
// (b) phone: a touch swipe scrolls the editor; the dialog is not taller than the viewport
{
const { page, browser, opened, readScroll } = await scenario({ width: 390, height: 844 });
check('phone.haBranch', opened.haBranch);
check('phone.dialogFitsViewport', opened.dialogRect.h <= opened.inner[1] + 1);
check('phone.footerInViewport', !!opened.footer && opened.footer.y + opened.footer.h <= opened.inner[1] + 1);
check('phone.editorIsTheScroller', opened.editorScroll[0] > opened.editorScroll[1] + 50);
const cdp = await page.context().newCDPSession(page);
const x = opened.editor.x + opened.editor.w / 2;
const y0 = opened.editor.y + Math.min(opened.editor.h - 40, 300);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: y0 }] });
for (let step = 1; step <= 8; step += 1) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y0 - step * 30 }] });
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(400);
const after = await readScroll();
check('phone.touchScrollsEditor', after.editor >= 80);
await browser.close();
}
// (c) the stub reproduces the bug: without flexcontent the editor is unbounded and nothing scrolls
{
const { page, browser, opened, readScroll } = await scenario({ width: 1280, height: 720 }, { flexContent: false });
check('witness.noFlexContent', opened.flexContent === false);
check('witness.editorUnbounded', opened.editorScroll[0] === opened.editorScroll[1]);
await page.mouse.move(opened.editor.x + opened.editor.w / 2, opened.editor.y + 160);
await page.mouse.wheel(0, 240);
await page.waitForTimeout(250);
const after = await readScroll();
check('witness.wheelBlocked', after.editor === 0 && after.body === 0);
await browser.close();
}
finish();
+192
View File
@@ -0,0 +1,192 @@
// #509: первый показ сводной панели на большом плане.
//
// Что здесь доказывается и почему смоком, а не юнитом. Обе половины дефекта
// видны только в живом кадре: «Source unavailable» вместо значений рисовался
// в первом же рендере панели, а подвисание возникало оттого, что агрегаты
// считались синхронно внутри render — юнит на чистой функции ни того, ни
// другого не замечает. План берётся тот же, на котором мерили в S2
// (demo/fixtures/large-house.mjs: 3 пространства, 60 комнат): на демо-доме
// расчёт дёшев и дефект не воспроизводится.
import { launch, check, finish } from './serve.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
const fixture = makeLargeHouseFixture();
const BLOCK = {
id: 'first-paint', title: 'General', visible: true, scope: { type: 'all' },
values: [
{ id: 'devices', label: 'Devices', source: { type: 'system', key: 'device_count' } },
{ id: 'area', label: 'Floor area', source: { type: 'system', key: 'total_area' } },
{ id: 'clock', label: 'Date and time', source: { type: 'system', key: 'datetime' } },
],
};
/** Панель на большом плане: возвращает страницу и хелперы. */
async function openLargePlan(page, { reducedMotion = false } = {}) {
if (reducedMotion) await page.emulateMedia({ reducedMotion: 'reduce' });
await page.waitForFunction(() => !!window.__card?._summary);
return page.evaluate(async ({ config, block }) => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const settle = async () => { await card.updateComplete; await frame(); await card.updateComplete; };
card._config = { ...card._config, language: 'en', kiosk: false };
card._serverCanWrite = true;
card._haSummaryPanelApi = 1;
card.narrow = false;
const next = structuredClone(config);
next.settings = {
...next.settings,
summary_panel: { version: 1, title: 'Summary', show_on_mobile: true, blocks: [block] },
};
card._serverCfg = next;
card._settings = next.settings;
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
card._sendConfigCandidate = async () => {};
card._summary.updated();
card.requestUpdate();
await settle();
window.__firstPaint = {
card, root, frame, settle,
overlay: () => root().querySelector('.summary-overlay'),
rows: () => [...(root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value') || [])],
texts: () => [...(root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value strong') || [])]
.map((node) => node.textContent.trim()),
pending: () => (root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value-pending') || []).length,
};
return { ready: !!card._summary, spaces: card._serverCfg.spaces.length };
}, { config: fixture.config, block: BLOCK });
}
// --- (а) первый показ: скелеты вместо текста ошибки, кадр не заблокирован ---
{
const { page, browser } = await launch({ width: 1280, height: 900 });
const opened = await openLargePlan(page);
check('largePlanReady', opened.ready && opened.spaces === 3);
const first = await page.evaluate(async () => {
const f = window.__firstPaint;
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
// Долгие задачи главного потока за всё время показа: подвисание может
// случиться и не в первом кадре, а сразу после прихода ленивого чанка,
// и человек увидит ровно тот же фриз (#509 AC4).
//
// Считаются только задачи ПОСЛЕ того, как большой план дорисован: сама
// отрисовка 60 комнат — тоже длинная задача, но она не про эту задачу и
// на CI-раннере попадала в окно наблюдения (#509, первый прогон CI).
await new Promise((resolve) => {
let quiet = null;
const settleObserver = (() => {
try {
const observer = new PerformanceObserver(() => {
clearTimeout(quiet);
quiet = setTimeout(() => { observer.disconnect(); resolve(); }, 400);
});
observer.observe({ entryTypes: ['longtask'] });
quiet = setTimeout(() => { observer.disconnect(); resolve(); }, 400);
return observer;
} catch { resolve(); return null; }
})();
if (!settleObserver) resolve();
});
window.__longTasks = [];
try {
window.__longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) window.__longTasks.push(Math.round(entry.duration));
});
window.__longTaskObserver.observe({ entryTypes: ['longtask'] });
} catch { window.__longTaskObserver = null; }
const started = performance.now();
toggle?.click();
// Ровно один кадр: именно его и видел человек как «зависание».
await f.card.updateComplete;
await f.frame();
const blockedMs = performance.now() - started;
return {
blockedMs, rows: f.rows().length, pending: f.pending(), texts: f.texts(),
hasOverlay: !!f.overlay(),
};
});
check('overlayShownOnFirstFrame', first.hasOverlay && first.rows === 3);
// AC1: до готовности — скелеты; ни одна строка не врёт про недоступный источник.
check('skeletonsInsteadOfError', first.pending >= 2);
check('noUnavailableOnFirstFrame', !first.texts.some((text) => /unavailable/i.test(text)));
// AC4: первый кадр не съеден расчётом. Порог с большим запасом от измеренного
// (S2: до правки один только расчёт площади занимал секунды на этом плане).
check('firstFrameNotBlocked', first.blockedMs < 400);
// AC8: скелет живёт ограниченное время — значения приходят следом.
const settled = await page.evaluate(async () => {
const f = window.__firstPaint;
const started = performance.now();
const deadline = started + 4000;
while (performance.now() < deadline && f.pending() > 0) {
await new Promise((resolve) => setTimeout(resolve, 25));
await f.card.updateComplete;
}
window.__longTaskObserver?.takeRecords?.().forEach((entry) => window.__longTasks.push(Math.round(entry.duration)));
window.__longTaskObserver?.disconnect?.();
return {
waitedMs: performance.now() - started, pending: f.pending(), texts: f.texts(),
longest: window.__longTasks.length ? Math.max(...window.__longTasks) : 0,
tasks: window.__longTasks.slice(0, 8),
observed: !!window.__longTaskObserver,
};
});
check('skeletonReplacedByValues', settled.pending === 0);
check('valuesAreReal', settled.texts.every((text) => text && !/unavailable/i.test(text)));
check('replacedWithinBudget', settled.waitedMs < 6000);
// AC4: работа идёт порциями, а не одним куском. Остаточные 150–200 мс — это
// подготовка геометрии ОДНОГО пространства (`wallBodiesGeometry`, единый
// polyclip-union, неделимый): три такие задачи на дом вместо одной на 1,5 с.
// Порог с запасом к раннеру CI; мутант «расчёт целиком» даёт ~1,5 с и падает.
console.log(` диагностика: долгих задач ${settled.tasks.length ? settled.tasks.join(', ') : 'нет'};`
+ ` значения через ${Math.round(settled.waitedMs)} мс`);
check('noLongTaskWhileComputing', !settled.observed || settled.longest < 450);
// AC5: появление панели анимируется — кадры анимации реально существуют.
const motion = await page.evaluate(async () => {
const f = window.__firstPaint;
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
toggle?.click(); // скрыть
await f.card.updateComplete;
const exiting = (f.overlay()?.getAnimations?.() || []).map((a) => a.effect?.getTiming?.().duration);
await new Promise((resolve) => setTimeout(resolve, 320));
toggle?.click(); // показать снова
await f.card.updateComplete;
const entering = (f.overlay()?.getAnimations?.() || []).map((a) => a.effect?.getTiming?.().duration);
return { exiting, entering };
});
check('exitAnimated', motion.exiting.includes(190));
check('enterAnimated', motion.entering.includes(190));
await browser.close();
}
// --- (б) reduced motion: прямоугольник есть, моторики нет ---
{
const { page, browser } = await launch({ width: 1280, height: 900 });
await openLargePlan(page, { reducedMotion: true });
const calm = await page.evaluate(async () => {
const f = window.__firstPaint;
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
toggle?.click();
await f.card.updateComplete;
await f.frame();
const skeleton = f.overlay()?.querySelector('.summary-value-pending i');
const style = skeleton ? getComputedStyle(skeleton) : null;
const overlayAnimations = (f.overlay()?.getAnimations?.() || []).length;
return {
hasSkeleton: !!skeleton,
animationName: style?.animationName || '',
opacity: Number(style?.opacity || '0'),
overlayAnimations,
};
});
// AC6: прямоугольник остаётся видимым, пульсация выключена.
check('skeletonStillDrawn', calm.hasSkeleton && calm.opacity > 0.05);
check('skeletonDoesNotPulse', calm.animationName === 'none');
// AC5 (вторая половина): при reduced motion панель не анимируется вовсе.
check('panelNotAnimatedWhenReduced', calm.overlayAnimations === 0);
await browser.close();
}
finish();
+6 -4
View File
@@ -471,16 +471,18 @@ const recovery = await page.evaluate(async () => {
const originalHass = card.hass;
const originalCallWS = originalHass.callWS;
const originalPrepareImage = card._signer.prepareImage.bind(card._signer);
const originalAdoptStructuralResponses = card._adoptStructuralResponses.bind(card);
// #500: adoption lives in the identity owner; observe it there.
const adoption = card._adoption;
const originalAdoptResponses = adoption.adoptResponses.bind(adoption);
const recoveryOrder = [];
const concurrentBackdrop = 'media-source://image/summary-recovery-490';
card._signer.prepareImage = async (_hass, href) => {
recoveryOrder.push(`prepare:${href}`);
return true;
};
card._adoptStructuralResponses = (...args) => {
adoption.adoptResponses = (...args) => {
recoveryOrder.push('adopt');
return originalAdoptStructuralResponses(...args);
return originalAdoptResponses(...args);
};
const writes = [];
let serverConfig = structuredClone(card._serverCfg);
@@ -540,7 +542,7 @@ const recovery = await page.evaluate(async () => {
runtime.dialog = null;
card.hass = { ...card.hass, callWS: originalCallWS };
card._signer.prepareImage = originalPrepareImage;
card._adoptStructuralResponses = originalAdoptStructuralResponses;
delete adoption.adoptResponses;
await card.updateComplete;
return {
lostAckClosesAsSuccess: firstClosed,
+146 -146
View File
@@ -1,309 +1,309 @@
{
"schema": 1,
"fingerprint": "95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74",
"fingerprint": "7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 298891,
"initialViewGzipBytes": 300059,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-card.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301193,
"initialPanelGzipBytes": 302365,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2302,
"initialPanelOnlyGzipBytes": 2306,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/fr-T10CV6-a.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js",
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"houseplan-assets/pdf-export-DpHlViMV.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/de-HksnN-9C.js",
"houseplan-assets/editor-CSFqqHwT.js",
"houseplan-assets/fr-lBEFwYGK.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
"houseplan-assets/pdf-export-CsCJxU0E.js",
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
],
"lazyGzipBytes": 417329,
"lazyGzipBytes": 418179,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/editor-CSFqqHwT.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
],
"lazyEditorGzipBytes": 205633,
"lazyEditorGzipBytes": 205726,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js"
],
"lazyOnboardingGzipBytes": 14148,
"lazyOnboardingGzipBytes": 14187,
"lazyLocaleFiles": [
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/fr-T10CV6-a.js"
"houseplan-assets/de-HksnN-9C.js",
"houseplan-assets/fr-lBEFwYGK.js"
],
"lazyLocaleGzipBytes": 54843,
"lazyLocaleGzipBytes": 54854,
"lazyIsometricFiles": [
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js"
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/iso-scene-render-RhSO-S9j.js"
],
"lazyIsometricGzipBytes": 13452,
"lazyIsometricGzipBytes": 13460,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js"
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js"
],
"lazyFurnitureArtGzipBytes": 10253,
"lazyFurnitureArtGzipBytes": 10255,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/pdf-export-DpHlViMV.js"
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/pdf-export-CsCJxU0E.js"
],
"lazyPdfGzipBytes": 120367,
"lazyPdfGzipBytes": 120372,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"sha256": "a4abe8807d8c404a7ce9f65b0a72ca3d8f8d4d5bf38fa8220915818f9238c0db",
"path": "houseplan-assets/backdrop-pick-BvtFVd_V.js",
"sha256": "e76da9cb2c5c8a32b2798c6f8785819bd7ff6ee624987feb8ff2c89522f87fef",
"rawBytes": 21134,
"gzipBytes": 7144,
"gzipBytes": 7148,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-IBWzSVOj.js",
"sha256": "cf031fb380b4b056c116e14a8cb26729ae4d0b275991b07989917f884f8841ff",
"path": "houseplan-assets/de-HksnN-9C.js",
"sha256": "3a1ada3b04ef3eb7e9c6856c700d589e90e962a6753087b59f89b481c68a9499",
"rawBytes": 91441,
"gzipBytes": 27705,
"gzipBytes": 27710,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DKd1NGbT.js",
"sha256": "e62e388db3b39eaa5f0e8f9b9676b7a6e30c453f84d22d530740f9be093512d1",
"rawBytes": 3837,
"path": "houseplan-assets/editor-CSFqqHwT.js",
"sha256": "a5adebb3a438356deb0e5a35fa218bcb4ab6a8be23d6e535b6cd350170775926",
"rawBytes": 3832,
"gzipBytes": 1585,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-T10CV6-a.js",
"sha256": "b602e1137a8395f963be744ed43d2054e472093502f687e40e6bd3e7a25a8794",
"path": "houseplan-assets/fr-lBEFwYGK.js",
"sha256": "de104eb96b8e4fbb2895ab861300878eb3f27dc616e4737bc2e9e818286c9d88",
"rawBytes": 93719,
"gzipBytes": 27138,
"gzipBytes": 27144,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"sha256": "1e26cad8d04b459f4b1140e705a02a1d380b8c49a84f92b8ea2ce3e684544800",
"path": "houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"sha256": "9b63fd1d6479a45b433b323468a56b46ab0e0e3d2a5bfb82b6349d475e59eee8",
"rawBytes": 33655,
"gzipBytes": 10253,
"gzipBytes": 10255,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/guard-7S6iRB0N.js",
"sha256": "67f95421a21516a66157d8fdc0129c2d1280cee31a3621a81039c88835d1c0f8",
"path": "houseplan-assets/guard-Bdo2w6Sq.js",
"sha256": "b98da3eb22fde2449b43c37851d7e2cbbcb14103b20b7c6b417fa4bab2502416",
"rawBytes": 518,
"gzipBytes": 368,
"gzipBytes": 371,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Bj6mVEeM.js",
"sha256": "dea85ab6dfeebcb263bd672270041e34473d40b9db24e69259e55d42e5ff44d4",
"rawBytes": 1076387,
"gzipBytes": 298088,
"path": "houseplan-assets/houseplan-card-DkiI2BOK.js",
"sha256": "e7bdf9f5d1b699c27b57f25c369b137eceae0cf6f3ec7f2d364ef6c2bd552887",
"rawBytes": 1079369,
"gzipBytes": 299254,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/fr-T10CV6-a.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js",
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"houseplan-assets/pdf-export-DpHlViMV.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
"houseplan-assets/de-HksnN-9C.js",
"houseplan-assets/editor-CSFqqHwT.js",
"houseplan-assets/fr-lBEFwYGK.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
"houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
"houseplan-assets/iso-scene-render-RhSO-S9j.js",
"houseplan-assets/live-interaction-runtime-C_klxsXE.js",
"houseplan-assets/pdf-export-CsCJxU0E.js",
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"sha256": "bc5163222924f68e6279b412b16a53f28a76b5b4d80d4a03e7fe28c4cce77a39",
"rawBytes": 671972,
"gzipBytes": 178859,
"path": "houseplan-assets/houseplan-editor-runtime-uP6vjZmA.js",
"sha256": "00be6a1cdb443cd875e736984baf86b77e63d96474ec72d4d66bf2baa9f1a2ba",
"rawBytes": 672339,
"gzipBytes": 178936,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"sha256": "31ea22267928606938b34c47f1ac8ecc2041048bc8c045a411d7108d353de849",
"rawBytes": 28237,
"gzipBytes": 7004,
"path": "houseplan-assets/houseplan-onboarding-runtime-BdClDAcS.js",
"sha256": "f81b58bde08ff74bab903803cc709e18449900dc91a3f64106db737bbabd55e3",
"rawBytes": 28287,
"gzipBytes": 7039,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/backdrop-pick-BvtFVd_V.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"sha256": "0e18deb183cb473c2a96fb689c91c68a3729b8ad56fb8907b01c30810585dbec",
"path": "houseplan-assets/hp-zigbee-topology-overlay-DRpau2VG.js",
"sha256": "968345cc4eaaf6cc46accbdc30b4fff83e3fc40a5a7b7a0f611229f0e53ec6b9",
"rawBytes": 11034,
"gzipBytes": 3731,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js",
"houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-miMf0jvt.js",
"sha256": "00fd101b4e5469f6d289b77eab3056bedf98082520af9139a1afff5823fcb025",
"path": "houseplan-assets/iso-scene-render-RhSO-S9j.js",
"sha256": "b5c630c05c0c2648c96006ab51cf271ae2ca585742df4bd9ad456ce9dac86646",
"rawBytes": 38562,
"gzipBytes": 13084,
"gzipBytes": 13089,
"isEntry": false,
"imports": [
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/guard-Bdo2w6Sq.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"sha256": "631b277aae71d9977fabb3f330c4a9cdf38e47ec51259584540b747d9c9444df",
"path": "houseplan-assets/live-interaction-runtime-C_klxsXE.js",
"sha256": "319a2a94cf85acd20b27522ae71b81e25ee1f2d69f1688256c5eeed4dddec3d5",
"rawBytes": 5196,
"gzipBytes": 2124,
"gzipBytes": 2129,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-DpHlViMV.js",
"sha256": "48b07b9ecfbbf2c1a8de53edce8bc32b1217e2c2f20eb5ceb24af75999870d8a",
"path": "houseplan-assets/pdf-export-CsCJxU0E.js",
"sha256": "eccac69e0d54ceeff8fceb8db69a0f20f5339443cb7961f8916f9a850bb1ae6c",
"rawBytes": 237254,
"gzipBytes": 110114,
"gzipBytes": 110117,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/furniture-plan-art.generated-B3wZVvff.js",
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-DOpNDtK_.js",
"sha256": "4ed28cc81b7f0ccac46e0fb6ac37856ddbdf7813040585447089bf4a0a61805f",
"rawBytes": 14811,
"gzipBytes": 3390,
"path": "houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
"sha256": "5c7fd338b94152ae5e1194f07c6353863a9e22c4f2e99424f5795484da989a64",
"rawBytes": 14824,
"gzipBytes": 3401,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-DXF83oAy.js",
"sha256": "51953d004b76561a842f67b9455985cceac2663c52499d1e1acc3a3cdfdaded9",
"rawBytes": 2195,
"gzipBytes": 1121,
"path": "houseplan-assets/summary-panel-metrics-C2M32Xkf.js",
"sha256": "bca21f0d66af64c7c1ef501a8cd38e04e2a74ee7196e004f6f60397d9e9a72f1",
"rawBytes": 2610,
"gzipBytes": 1308,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
"sha256": "7598bcc5d26d8d2f9a15cc154c32a0b836ba0ac53b7b5f9b65f3c2c2bc0287d1",
"rawBytes": 69530,
"gzipBytes": 19034,
"path": "houseplan-assets/summary-panel-runtime-loaded-DQS834xA.js",
"sha256": "54c791fdd19e77c24ac142794c4207345eaec03d2ab15dfdf3b991afa32bb812",
"rawBytes": 72272,
"gzipBytes": 19729,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-DOpNDtK_.js",
"houseplan-assets/summary-panel-metrics-DXF83oAy.js"
"houseplan-assets/summary-panel-editor-BC_vG9Hn.js",
"houseplan-assets/summary-panel-metrics-C2M32Xkf.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"sha256": "0176688590ae76a8766ee735de892e37f29211a042c27f5a2992eda0cc8ef8d9",
"path": "houseplan-assets/zigbee-topology-LI-FVdpo.js",
"sha256": "d01ec49d20c5f3dc028a59107f1f9060634ab93d6d112e03a7c2b85792159440",
"rawBytes": 18961,
"gzipBytes": 7424,
"gzipBytes": 7431,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js",
"sha256": "6b43a567d3686249519d43ef6f04bb9596529d99d04f745944c0e561c0e3034e",
"path": "houseplan-assets/zigbee-topology-runtime-DTi-kj9C.js",
"sha256": "3a0fad7d479bbeb161434e498a3c8ac7f88da62ef37c173b66f95057098bcc97",
"rawBytes": 3941,
"gzipBytes": 1762,
"gzipBytes": 1765,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js",
"houseplan-assets/zigbee-topology-LI-FVdpo.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "b8095427f52e7413e84d3202953d15765fbd43d4536226beff01ba7ff692ae2e",
"sha256": "21da7b3390ea9d4992e33bf4d6fdf684145b53f407e253a599e70a459585298d",
"rawBytes": 1205,
"gzipBytes": 803,
"gzipBytes": 805,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-DkiI2BOK.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "f0a1709a58213029c08e73da2ed81b217827c8f5cdbf96e082385cfb608eabcc",
"sha256": "a664721695cf5c85903eea440d05a295cb6d6d2cd1e3669fd5c65c9752534492",
"rawBytes": 6124,
"gzipBytes": 2302,
"gzipBytes": 2306,
"isEntry": true,
"imports": [
"houseplan-card.js"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-DkiI2BOK.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
-14
View File
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{t as e,l as o,x as t,e4 as s,A as a,e5 as i,e6 as l,e7 as n,dV as r,b as c}from"./houseplan-card-Bj6mVEeM.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{dT as r,dU as e,dV as t}from"./houseplan-card-Bj6mVEeM.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
+1
View File
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{dS as r,dT as t,dU as e}from"./houseplan-card-DkiI2BOK.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{b as a,A as e}from"./houseplan-card-Bj6mVEeM.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-2X-IAkWu.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{b as a,A as e}from"./houseplan-card-DkiI2BOK.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-DQS834xA.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
@@ -6,7 +6,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c46
?disabled=${d.busy} aria-label=${b("summary.show_local")}
aria-describedby="summary-local-show-hint"
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
.title=${b("summary.settings")} wide
.title=${b("summary.settings")} wide flex-content
dismiss-on-scrim aria-busy=${String(d.busy)}
@hp-close=${k}>
<div class="body summary-editor" @click=${()=>m.closeSource()}>
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{e9 as e,L as t,a2 as n,J as i,an as o,N as r,bo as s,bn as a,a1 as c,a0 as l,ax as d,ea as u,eb as f,bq as m,ec as g}from"./houseplan-card-DkiI2BOK.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=d(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,l,d=_){try{let u=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=d(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,o,r,g.roomGeom,g.multiWallNodes)||a;v=y(v,s(c,m.physicalBodies)),yield}const _=m.cellCm/o;u+=a(v)*_*_/1e4}return u}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=u(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{az as e,ea as t,eb as n,bs as i,ec as r,P as a,a5 as s,M as o,aq as c,N as l,bq as d,bp as u,ed as f}from"./houseplan-card-Bj6mVEeM.js";function g(t){if(!t.registry.authoritative)return null;const n=new Set,i=e(t.markers);for(const e of Object.values(t.registry.devices||{}))e?.id&&"service"!==e.entry_type&&!i.devices.has(e.id)&&e.area_id&&t.areaToSpace[e.area_id]&&n.add(e.id);for(const e of t.markers||[]){if(e.removed||"virtual"===e.binding)continue;const r=String(e.binding||"").indexOf(":");if(r<1)continue;const a=e.binding.slice(0,r),s=e.binding.slice(r+1),o="device"===a?t.registry.devices?.[s]:null,c="entity"===a?t.registry.entities?.[s]:null,l="device"===a?s:c?.device_id,d="device"===a?o?.area_id:c?.area_id||l&&t.registry.devices?.[l]?.area_id,u="string"==typeof e.room_id&&e.room_id.length>0&&null===e.area?"":e.area||d||"",f=u&&t.areaToSpace[u]||e.space||t.firstSpaceId;l&&t.registry.devices?.[l]&&t.spaceIds.has(f)&&(!i.devices.has(l)||"entity"===a&&i.liveEntities.has(s))&&n.add(l)}return n}function m(e,t){return e?.length?t?.length?f(e,t):e:t}function v(e,t){try{let n=0;for(const i of t){const t=e.spaces.find(e=>String(e?.id)===i.id);if(!t)continue;const f=r(t,i);let g=null;for(const e of i.rooms){if(!e.id)continue;const t=a(e);if(!t)continue;const n=s(i.rooms,e.id,f.walls,f.openCuts,o,f.cellCm,c,l)||t;g=m(g,d(n,f.physicalBodies))}const v=f.cellCm/c;n+=u(g)*v*v/1e4}return n}catch{return null}}function y(e,t,n,r){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:i(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(r||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function _(e,i){const r=e?.states?.[i];if(!r)return null;const a=t(e,i);return a?n(a,String(r.attributes?.unit_of_measurement||"")):null}export{g as representedHaDeviceIds,_ as summaryEntityValue,y as summarySystemValue,v as totalCleanFloorAreaM2};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{n as t,b as n}from"./zigbee-topology-DOkBU6gZ.js";import{F as e}from"./houseplan-card-Bj6mVEeM.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";import{n as t,b as n}from"./zigbee-topology-LI-FVdpo.js";import{B as e}from"./houseplan-card-DkiI2BOK.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";try{await import("./houseplan-assets/houseplan-card-Bj6mVEeM.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7d8c26b95986499fd35534164d8e2bc2e897f0fbe9d846bccffa49f45116a618";try{await import("./houseplan-assets/houseplan-card-DkiI2BOK.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
+1 -1
View File
File diff suppressed because one or more lines are too long
+26
View File
@@ -188,6 +188,12 @@ the same profiler available between stable promotions.
initial focus, Escape close event and restore-focus session. Focus sessions
are scoped to a card shadow root so nested dialogs return to their parent
trigger and dialog replacement still returns to the original outside opener.
`flex-content` forwards ha-dialog's `flexcontent`, making HA's `.body` a
flex column so a consumer that is itself a scroll container (`min-height: 0`,
`overflow: auto`, `overscroll-behavior: contain`) is height-bound and scrolls
by itself; without it Chromium stops wheel and touch scroll chaining at the
never-scrolling child (#508). The summary-panel settings dialog uses it; the
native branch already bounds the surface with its own flex column.
Its footer wrapper is a full-width slot item: HA lays the footer slot out as
flex, so flattening that wrapper would shrink action rows to their content.
The wrapper opts HA's title-height custom property into content sizing so
@@ -1359,6 +1365,26 @@ snapshot for every affected space, clears its gestures and geometry history,
then best-effort reloads authoritative config. Thus a newer edit made while the
rejected request was in flight cannot survive on an unaccepted base (#314).
**Config/layout identity has one owner** (#500). `src/config-adoption.ts`
holds the server config and the device layout together with their revision
and content fingerprint; the card exposes `_serverCfg`, `_cfgRev`, `_layout`
and `_layoutRev` only as read delegates. The identity changes in exactly
three ways — adopting an authoritative response, accepting the reply to our
own write (`acceptConfigWrite`, `acceptPairWrite`), restoring the warm cache —
and a revision is never taken apart from the body it describes: after
`space/delete`, Optimize Undo or Import the revisions come from the re-read
`config/get`/`layout/get`, not from the write reply. Every authoritative
adoption goes through `adoptAuthoritativeGated`: compare by fingerprint →
backdrop readiness (`ContentSigner.prepareImage`) → continuity candidate →
adopt → tail. The `reload` profile (initial load, `config_updated`, summary
lost-ACK recovery) runs the shared tail (decor assets, initial space, pending
nav mode, cache snapshot); the `post-write` profile (the four re-reads after a
paired write) ends at adoption and leaves each caller its own tail. Bodies may
still be staged locally before a write — that is how the editors work — but
only in the files pinned by `test/config-adoption-ownership.test.mjs`, whose
counts ratchet down. Feature runtimes see one host method,
`_adoptAuthoritative`, instead of the eight steps it replaces.
**Persisted coordinates have one lattice-aware write boundary** (#291).
`canonicalizeConfigGeometry()` / `canonicalizeLayoutGeometry()` /
`canonicalizePosition()` own the frontend candidate; mirrored Python functions
+31 -5
View File
@@ -2,11 +2,29 @@
## Unreleased
- Reopening the plan on a page that has already loaded the summary panel no
longer triggers a late header re-measurement and an extra round of heavy
redraws right after the first frame: the panel's controls are part of the
first frame, so the first device/glow update lands on a stable stage
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
- Switching between spaces no longer replays a neighbouring door's leaf
movement, in either direction. The animation now belongs to a real sensor
change and nothing else
([#525](https://github.com/Matysh/houseplan-card/issues/525)).
## v1.74.0-beta.1 — 2026-09-10
- Alignment guides follow the object again while you drag it: the marker in the
device editor, the shape and the cursor in the backdrop and plan editors. The
dashed line and its anchor dot appear the moment the object lines up with
another one, instead of after the gesture is over
([#521](https://github.com/Matysh/houseplan-card/issues/521)).
- The summary panel no longer freezes the first frame on a large plan, and it
no longer says «Source unavailable» while its values are still being
computed: rows show a pulsing placeholder that is replaced by the numbers as
they arrive, and the panel fades in and out again
([#509](https://github.com/Matysh/houseplan-card/issues/509)).
- The summary panel settings dialog scrolls again in Home Assistant — with the
mouse wheel on desktop and with a swipe on phones; the header and the action
buttons stay in place
([#508](https://github.com/Matysh/houseplan-card/issues/508)).
## v1.73.0 — 2026-09-09
@@ -54,6 +72,14 @@
card-mod rules, without depending on private CSS classes
([#489](https://github.com/Matysh/houseplan-card/issues/489)).
## v1.73.0-beta.9 — 2026-09-09
- Reopening the plan on a page that has already loaded the summary panel no
longer triggers a late header re-measurement and an extra round of heavy
redraws right after the first frame: the panel's controls are part of the
first frame, so the first device/glow update lands on a stable stage
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
## v1.73.0-beta.8 — 2026-09-09
- Attachment uploads no longer count their own in-flight file twice against
+30 -5
View File
@@ -8,11 +8,28 @@
## Не выпущено
- Повторное открытие плана на странице, где сводная панель уже загружена,
больше не вызывает позднего перемера шапки и лишнего круга тяжёлых
перерисовок сразу после первого кадра: элементы панели входят в первый кадр,
и первое обновление устройств/свечения ложится на стабильную сцену
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
- При переключении между пространствами двери и окна больше не доигрывают
движение створки соседней двери — ни в одну сторону, ни в другую. Анимация
остаётся только за настоящей сменой состояния датчика
([#525](https://github.com/Matysh/houseplan-card/issues/525)).
## v1.74.0-beta.1 — 2026-09-10
- Направляющие выравнивания снова идут за объектом во время жеста: за значком
в редакторе устройств, за фигурой и курсором в подложке и в плане. Пунктирная
линия и точка-якорь появляются в тот момент, когда объект встаёт на одну ось
с другим, а не после того, как жест закончился
([#521](https://github.com/Matysh/houseplan-card/issues/521)).
- Сводная панель больше не подвешивает первый кадр на большом плане и не пишет
«Source unavailable», пока значения ещё считаются: в строках появляются
пульсирующие плашки, которые плавно сменяются числами, а сама панель снова
появляется и исчезает плавно
([#509](https://github.com/Matysh/houseplan-card/issues/509)).
- Диалог настроек сводной панели в Home Assistant снова прокручивается —
колесом мыши на десктопе и свайпом на телефоне; шапка и кнопки остаются на
месте ([#508](https://github.com/Matysh/houseplan-card/issues/508)).
## v1.73.0 — 2026-09-09
@@ -61,6 +78,14 @@
card-mod, без привязки к приватным CSS-классам
([#489](https://github.com/Matysh/houseplan-card/issues/489)).
## v1.73.0-beta.9 — 2026-09-09
- Повторное открытие плана на странице, где сводная панель уже загружена,
больше не вызывает позднего перемера шапки и лишнего круга тяжёлых
перерисовок сразу после первого кадра: элементы панели входят в первый кадр,
и первое обновление устройств/свечения ложится на стабильную сцену
([#506](https://github.com/Matysh/houseplan-card/issues/506)).
## v1.73.0-beta.8 — 2026-09-09
- Загрузка вложений больше не считает собственный ещё не сохранённый файл
+20 -3
View File
@@ -166,8 +166,18 @@ npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=art
npm run golden:capture
npm run golden:verify
npm run golden:accept -- --reviewed
# Docs screenshots whose pixels did not change (a version bump, a refactor):
# re-capture locally, compare decoded RGBA against the committed frames and,
# if every frame is identical, refresh only the manifest fingerprints (#512).
npm run docs:accept -- --identical
```
Golden frames never show the real card version: the harness sets the test-only
seam `window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before the card is
created, so a version bump alone changes no baseline (#512, see
`demo/golden/README.md`).
The config audit performs no network requests and does not rewrite the input.
Its registry and lifecycle rules are documented in `CONFIG-COMPATIBILITY.md`.
The blocking performance workflow runs its independent profile pairs in
@@ -355,9 +365,16 @@ fallback. They still gate assets on the exact tagged SHA, so adopting the new
path does not weaken releases created through the old path.
Tag `vX.Y.Z` + GitHub Release → `.github/workflows/release.yml` resolves that
tag to its exact commit, waits for every Validate run of the SHA to complete
successfully, then builds and attaches `houseplan-card.js`. A missing, failed,
cancelled or one-hour-timed-out Validate withholds the asset. Bump the version
tag to its exact commit, waits for the latest non-cancelled Validate run of the
SHA to complete successfully (#511: a cancelled run is not a verdict, a later
re-run or another-baseline comparison refreshes an older result), then builds
and attaches `houseplan-card.js`. A missing, failed or one-hour-timed-out latest
Validate withholds the asset; stable releases additionally need the same for
Full Performance and a green E2E run on a real Home Assistant: `release.yml`
dispatches `e2e.yml` in `Matysh/houseplan-e2e` with the tag (the suite installs
the release's `houseplan.zip` into HA in docker) and waits for it (#514). A red
E2E withholds the assets — open the linked run, the Playwright traces and
screenshots are in its artifacts; fix, then cut a new tag. Bump the version
everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION), `package.json`,
`custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`.
+9 -9
View File
@@ -1,20 +1,20 @@
<!-- release: v1.73.0 -->
<!-- release: v1.74.0-beta.1 -->
## Основное
- House Plan теперь открывается отдельной страницей из бокового меню Home Assistant; новая настраиваемая сводная панель показывает состояния и итоги по дому ([#486](https://github.com/Matysh/houseplan-card/issues/486), [#437](https://github.com/Matysh/houseplan-card/issues/437)).
- План можно выгрузить в PDF A4; отрезки цепочек стен сохраняются сразу, а сохранение и восстановление планов стали надёжнее ([#53](https://github.com/Matysh/houseplan-card/issues/53), [#478](https://github.com/Matysh/houseplan-card/issues/478), [#491](https://github.com/Matysh/houseplan-card/issues/491)).
- Радары присутствия получили настройку источников, калибровку и живое отображение на плане — первый этап поддержки; границы комфортной температуры настраиваются отдельно для каждой комнаты ([#485](https://github.com/Matysh/houseplan-card/issues/485), [#487](https://github.com/Matysh/houseplan-card/issues/487)).
- Сводная панель больше не подвешивает первый кадр на большом плане и не показывает «Source unavailable», пока значения ещё считаются ([#509](https://github.com/Matysh/houseplan-card/issues/509)).
- Диалог настроек сводной панели снова прокручивается — колесом мыши на десктопе и свайпом на телефоне ([#508](https://github.com/Matysh/houseplan-card/issues/508)).
- Направляющие выравнивания снова идут за значком, фигурой и курсором во время жеста в трёх редакторах ([#521](https://github.com/Matysh/houseplan-card/issues/521)).
- Мелкие исправления и улучшения.
## Highlights
- Open House Plan as a standalone page from the Home Assistant sidebar; a new configurable summary panel shows household states and totals ([#486](https://github.com/Matysh/houseplan-card/issues/486), [#437](https://github.com/Matysh/houseplan-card/issues/437)).
- Export your plan to an A4 PDF; wall-chain segments save immediately, and plan saving and recovery are more reliable ([#53](https://github.com/Matysh/houseplan-card/issues/53), [#478](https://github.com/Matysh/houseplan-card/issues/478), [#491](https://github.com/Matysh/houseplan-card/issues/491)).
- Presence radars gain source setup, calibration and live on-plan observations in this first stage of support; each room can have its own comfort-temperature limits ([#485](https://github.com/Matysh/houseplan-card/issues/485), [#487](https://github.com/Matysh/houseplan-card/issues/487)).
- The summary panel no longer freezes the first frame on a large plan and no longer says "Source unavailable" while its values are still being computed ([#509](https://github.com/Matysh/houseplan-card/issues/509)).
- The summary panel settings dialog scrolls again — with the mouse wheel on desktop and with a swipe on phones ([#508](https://github.com/Matysh/houseplan-card/issues/508)).
- Alignment guides follow the marker, the shape and the cursor again while you drag, in all three editors ([#521](https://github.com/Matysh/houseplan-card/issues/521)).
- Small fixes and improvements.
Сообщество / Community: [Telegram](https://t.me/ha_houseplan)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.73.0/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.73.0/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.74.0-beta.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.74.0-beta.1/docs/CHANGELOG.md)
+3 -3
View File
@@ -17,12 +17,12 @@ change must pass through a published beta/RC before stable. Stable release
commits are promotion-only (versions, generated bundles and release/changelog
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Snapshot (2026-09-09)
## Snapshot (2026-09-10)
| Item | State |
|---|---|
| Version | **v1.73.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Current local cycle | Stable promotion of the eight published v1.73.0 betas, through v1.73.0-beta.8. Highlights: the native HA sidebar page (#486), configurable designer-aligned summary panel (#437/#505), PDF export (#53), presence-radar Stage 1 (#485), room comfort limits (#487), wall-chain simplification (#478/#477) and reliability improvements. No new product behaviour is added during promotion. The main-only workflow mirrors were merged without changing the beta.8 tree. Publication requires the complete local gate, reviewed golden frames where version text changes, and green Validate plus Full Performance on the exact release SHA; stable assets also require the compositor-continuity check. |
| Version | **v1.74.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION in the card and the editor runtime) |
| Current local cycle | v1.74.0-beta.1 opens the line after the stable v1.73.0. User-visible: the summary panel no longer freezes the first frame on a large plan and no longer shows "Source unavailable" while its values are still being computed (#509), its settings dialog scrolls again with the wheel and with a swipe (#508), and the alignment guides follow the gesture again in all three editors — the marker, the shape and the drawing cursor (#521, a regression that shipped in v1.72.0 and v1.73.0). Everything else in this beta is internal: one owner for config/layout identity and the adoption sequence (#500) plus the cold-start regression it left behind — the authoritative adoption now keeps its own task and the first stable frame of a 60-room house is back inside its budget (#520) — and the process/CI work: review waits for a green Validate with diff mutants on the exact material (#510), merge-candidate publishes and validates the merge candidate (#492), the release gate judges the latest non-cancelled run (#511), the version text left the golden frames behind a seam with a local `docs:accept --identical` path (#512), the full mutation registry moved to a nightly schedule (#513), specs live in the issue body (#517), and the Windows gate is green with CI-pinned toolchain (#496). Publication requires green Validate on the exact candidate SHA; `main` stays at v1.73.0. |
| Hidden Alpha Stage | #89 Stage 1 ships in v1.63.0-beta.1, #122 Stage 2 in v1.64.0 and #160 Stage 3 in v1.73.0-beta.1. The same hidden `iso` view uses the fixed 4° camera, raised/tethered device-room-lock overlays, deeper openings and bounded theme materials; #471 removes the overlay plates from paint while retaining their safety geometry. Since #448 the experiment is enabled only through the single indefinite browser-local `hp_alpha` gate; it is not expiring and has no per-stage key. Flat remains default; editors, `houseplan-space-card`, floor effects, stored coordinates and HA actions remain unchanged. Stage 3 stays internal and is absent from public changelog/user documentation. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+33 -4
View File
@@ -30,7 +30,9 @@
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда,
`--shard=i/4`), перед стабильным релизом и по понедельникам. Дешёвая половина
`--shard=i/4`) каждую ночь по расписанию (01:00 UTC); в цикле разработки и в
релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам
заводит issue с отчётом (#472, #513). Дешёвая половина
идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи —
`node scripts/mutation-gate.mjs --changed origin/dev..HEAD`: гоняются только
мутанты, чьи patch-файлы или **входы гарда** задеты диффом (#332, #475, #492).
@@ -46,7 +48,15 @@ GUARD_INPUTS` (умолчание `backend-test-guard.mjs` —
Бандл собирается только мутантам с браузерным гвардом; компиляция тестов в
worktree стартует с тёплого `test-build/` основного дерева.
В CI `changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных
В CI `changed_mutants` бежит не на каждом пуше, а по запросу (#510):
`workflow_dispatch validate.yml -f mutants=true` (его делают ревью-конвейер на
материале ревью и слияние на кандидате), `full=true` (ночь, кнопка), PR и кандидат
беты (трейлер `Release:`). Обычный push в ветку задачи обходится дешёвыми гейтами:
за 08–09.09 мутанты на промежуточных пушах стоили 48 из 56 часов job-минут и в
основном отменялись следующим пушем. Доказательство мутантов для ревью — именно
dispatch-прогон на точном SHA; зелёный push-прогон им не является.
`changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных
свидетелей (#481): после каждого пойманного мутанта в файл пишется отпечаток
его входов (файлы патча, все входы гарда по замыканию выше, объявление мутанта;
строка версии продукта нормализована), и мутант с тем же отпечатком в следующем
@@ -55,6 +65,24 @@ worktree стартует с тёплого `test-build/` основного д
что отменённый пуш или таймаут не пропадают даром. Полный прогон и `--id`
журнал не читают; `--ledger` без `--changed` — ошибка.
## E2E на реальном Home Assistant (#514)
Репозиторий `Matysh/houseplan-e2e`: настоящий HA в docker, House Plan из
`houseplan.zip` релиза, 13 сценариев Playwright (боковая панель, дашборды,
роли, телефон, PDF, рестарт HA, первый запуск, обновление). Ночью — по
расписанию на последней бете; для **стабильного** релиза `release.yml`
запускает его на теге и ждёт зелёного (`scripts/e2e-gate.mjs`): красный —
ассеты не публикуются. В цикле разработки и на бетах не участвует.
## Версия в кадрах и попиксельная приёмка (#512)
Golden-кадры и скриншоты документации не должны меняться от bump версии. Для
golden отображаемая версия идёт через seam `displayVersion()` (`src/card-version.ts`,
глобальная `__HP_VERSION_OVERRIDE__` — только для харнесов; продукт её не задаёт),
и харнес фиксирует `0.0.0-golden`. Для docs-скриншотов есть локальная приёмка
`npm run docs:accept -- --identical`: кадры сравниваются по декодированным
пикселям, и при полном совпадении обновляется только отпечаток исходников.
## Manifest входов: какие job запускать и что хешировать (#492)
Один модуль, `scripts/check-inputs.mjs`, объявляет каждую проверку Validate
@@ -1036,8 +1064,9 @@ node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1
- **громко перечисляет, чего не проверял** — молчаливый пропуск дважды стоил
проекту дня (#171, #207), а «Verified» без названной команды и её результата
доказательством не является;
- **не претендует на полноту.** Golden, полная матрица смоков, HA-харнесс и весь
мутационный реестр — предрелизный гейт, а не этот набор.
- **не претендует на полноту.** Golden, полная матрица смоков, HA-харнесс —
heavy-набор Validate на кандидате; весь мутационный реестр — ночное
расписание (#513), а не этот набор.
Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а
свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока
+15 -13
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceFingerprint": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"captureScriptSha256": "9e4b0ae533407fe003a129078aec020f2c83d67cdb6fc6da696ce3d829beaeb5",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "76c35a87810e3dc6890bda7b43da133f57d8860031204a62cda7115c15950090"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "795585acb09488236ebb8028a7fcbd918404c5fe01560bb9b4559de13d441625"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "9f0970de09772674c3156b9fc5b2d49de0ceb22be11ecf8b136047d37d100a84"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "0c354a79f9c3660788b6c5e889a27b6bd78b1f209dc0eef6bb43e2c1344028c4"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "64169c543291bc4df806dcf8ff484b42c8ea55eb343323d945344e0e28f2ce97"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "3c8703175605fcf0387d654cd30783ecfd33e21743e70b45190a5ae1a8d355c2"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "5d6193202102f5e6d85567f1111fa105895b933979fbf369c6ba0b1029deb823"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "375900a29aa62632883ede6727b7dbb8b48cabe9d7d0e16425f9779c837f3dd9"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "3d85424676c823ddca3fa7dab3cce089b6030e48c05d16d2d48d922c66c0cbd2"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "ac962820f8e1c5cb5b042d39b18dc95905fa840cb2d4749060d991be4f6977c2"
},
"pdf-export": {
@@ -125,7 +125,7 @@
},
"theme": "light",
"language": "en",
"sourceSha256": "4c472e043fa69a664ba9778200ff67b223e2f23b4eeb29c84688c14d93956de7",
"sourceSha256": "cd8e9598aa2451b5ebfb05af8cbe752e33f08efe21e7fe7363772e8d9ed83822",
"imageSha256": "4537692fd57a3ed95528eddd748eee5cb6e456ffab7cc49169853d62459252cd"
}
},
@@ -138,6 +138,8 @@
"pdf-export"
],
"witnesses": 7,
"floor": 2
"floor": 2,
"lastWriteWasFingerprintOnly": true,
"identicalPixels": true
}
}
+248
View File
@@ -0,0 +1,248 @@
# CODE-REVIEW-500-r1
- **Issue:** #500 — Архитектура: выделить одну границу владения config/adoption
- **Материал:** `0905f67ec0d8fb830065e909988f442c884320c2` (ветка `issue/500-config-adoption-boundary`, ребейз на `origin/dev` = `0c748282`)
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 10 коммитов (4 ТЗ/докс-ревью + 1 реализация модуля + делегаты + 7 вызывающих + 1 тесты/lint/docs + 1 фикс реактивности)
- **ТЗ:** тело `docs/specs/500-config-adoption-boundary.md`, зелёное ревью r3 (`docs/reviews/SPEC-REVIEW-500-r3.md`)
- **Заход:** r1 · блокирующих циклов израсходовано 0/4 (это первый заход)
## Скоуп
Извлечение владения идентичностью `{config, rev, fingerprint}` × `{layout, rev,
fingerprint}` в новый модуль `src/config-adoption.ts`; единая гейт-
последовательность `adoptAuthoritativeGated` (compare → prepareImage →
continuity → adopt → хвост профиля) для семи вызывающих в двух профилях
(`reload`/`post-write`); сужение `SummaryPanelHost` и host-контрактов
editor-/onboarding-runtime; перенос `rollbackOptimistic` в модуль; единственное
намеренное изменение поведения — гейт готовности фона на четырёх post-write
путях, ревизии `space/delete` берутся из `config/get`/`layout/get`, а не из
ответа `delete`. `User-Visible: no` на всех коммитах, changelog не тронут.
## Как проверялось
**Дешёвые гейты** — не гонял заново: Validate на точном материале ревью
(`0905f67e`) зелёный целиком —
https://github.com/Matysh/houseplan-card/actions/runs/34462124328 (`headSha`
подтверждён `gh run view` перед использованием результата). Это покрывает
`tsc --noEmit`, `npm test` (2494+ юнитов), `npm run build` + сверку бандл-
деревьев, `no-new-any`, `process-gate`, `check-docs` (WARN о протухшем
скриншот-отпечатке — это предупреждение обычного push, не гейт кандидата,
блокирующим не является).
**Мутанты по диффу** — проверил логи всех трёх шардов `Мутанты по диффу
(N/3)` этого же прогона (`gh run view --job <id> --log`), а не только факт
green: все четыре новых мутанта пойманы на материале ревью —
| Мутант | Файл лога (шард) | Результат |
|---|---|---|
| `config-adoption-echo-clears-history` | 2/3 | `тест покраснел, как обязан` |
| `config-adoption-rev-from-foreign-response` | 3/3 | `тест покраснел, как обязан` |
| `config-adoption-rollback-ignores-rev` | 1/3 | `тест покраснел, как обязан` |
| `post-write-skips-asset-gate` | 3/3 | `тест покраснел, как обязан` |
Плюс чистые (немутированные) прогоны в тех же логах подтверждают отсутствие
регрессии по соседним защитам на этом материале: `node --test
test/config-adoption.test.mjs`, `node demo/smoke_room_resize.mjs` (сам фикс
реактивности `0905f67e` — эту же регрессию поймал предыдущий красный Validate
на `c360bcc9`), `node demo/smoke_summary_panel.mjs`, `node demo/
smoke_marker_write_rollback.mjs`, `node demo/smoke_device_position_history.mjs`,
`node demo/smoke_summary_panel_polish.mjs` — все чистые.
**Браузерные смоки, не покрытые CI на этом SHA.** Тяжёлые job'ы (`smoke`,
`golden`, `performance_smoke`) в этом прогоне `skipped` — обычный push без
`Release:`, не кандидат (см. `PROCESS.md` §8/AGENTS.md, ожидаемо). Отдельно
проверил: ни `demo/smoke_post_write_adoption.mjs` (новый смок, единственное
прямое свидетельство AC4), ни `demo/smoke_danger_confirmation.mjs` не
зарегистрированы guard'ом ни одного мутанта в `scripts/mutation-gate.mjs` —
значит job «Мутанты по диффу» их тоже не запускал ни разу на этом материале
(грепом по логам всех трёх шардов подтверждено отсутствие). Автор сам об этом
написал в хендоффе («в моей среде нет Chromium»). Прогнал сам:
```
npm run build && npm run bundle:sync
node demo/smoke_post_write_adoption.mjs → OK, все 20 проверок true
node demo/smoke_danger_confirmation.mjs → OK, все 18 проверок true
```
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` даёт 47 прямых
совпадений (порог «широкого» — больше 47, эта задача чуть ниже порога, но
широка по природе диффа: тронуты повсеместно читаемые `_cfgRev`/`_layoutRev`/
`_model`/`_serverCanWrite`). Полный прогон всех 47 непропорционален объёму
находок и является предрелизным гейтом (§8); выбрал те, что прямо касаются
изменённых путей (adoption/rollback/reactivity) и либо подтвердил зелёными
через логи CI-мутантов, либо прогнал сам. Остальные 45 не прогонял — по
диффу они читают эти поля так же, как раньше (делегаты не меняют семантику
чтения), а не изменённое поведение.
**Инварианты модели** — диффом задета идентичность, не геометрия рёбер/
толщины/ссылок; `npm run invariants` не запускал (не требуется правилом:
геометрия и её ссылки не менялись, только владелец ревизии/тела).
**pytest/golden/perf** — не задеты диффом (только `src/**`, backend не
тронут; визуал не меняется, ТЗ §7 и §14 это явно фиксируют); не гонял.
## AC → доказательство (проверено чтением и/или прогоном)
| AC | Чем доказан | Проверено | Чем краснеет |
|---|---|---|---|
| AC1 | `test/config-adoption-ownership.test.mjs` | чтением: пересчитал оба регекса вручную по `src/**` — `_serverCfg =` 5(editor)+3(card), `_layout =` 5(editor)+9(card), совпадает с allowlist дословно; `IDENTITY_WRITE` не находит присваиваний вне модуля | вернуть присваивание в любом модуле — красный (мутация не нужна, тест — прямой grep) |
| AC2 | `test/config-adoption-ownership.test.mjs` + чтение | подтверждено: `_adoptStructuralResponses` как host-метод не существует нигде вне `config-adoption.ts`; `SummaryPanelHost`/`HouseplanEditorHostPort` сужены точно по §6.4 | оставить старый метод в любом файле — красный |
| AC3 | `test/config-adoption.test.mjs` (17 кейсов) + существующие смоки/юниты | таблица переходов (эхо/смена/только layout/virtual lights/gated-последовательность/reactive-контракт) прочитана целиком, поведенчески эквивалентна старому `_adoptStructuralResponses`; `post-write` подтверждён без пост-шагов тестом на строке 215 | мутант `config-adoption-echo-clears-history` пойман на материале (см. таблицу выше) |
| AC4 | `demo/smoke_post_write_adoption.mjs` + I2-юнит | **прогнал сам** — 20/20 true; частично не покрыт: ветка отказа гейта (`asset-wait`) ни одним из четырёх вызывающих не тестируется (см. находку M-1 ниже) | мутант `post-write-skips-asset-gate` пойман на материале |
| AC5 | `test/config-adoption.test.mjs` round-trip | прочитано, ключи `LS_CFG` побайтово совпадают со старым форматом | мутант не заведён (чистый юнит, признание правила §2.7 достаточно) |
| AC6 | `test/config-adoption.test.mjs` (перенесённые кейсы #439/#442) | прочитано, идентично прежней логике `rollbackOptimistic` | мутант `config-adoption-rollback-ignores-rev` пойман на материале |
| AC7 | `test/core-file-budget.test.mjs`, `bundle:budget`, `test/bundle-assets.test.mjs` | `wc -l src/houseplan-card.ts` = 13605 < потолка 13650; `INITIAL_VIEW_GZIP_CEILING` 300 300 с датированной записью в `scripts/bundle-budget.mjs`; общий бюджет 301 066 не тронут | поднять любой из потолков — красный по правилу теста (не прогонял мутацию, чтение констант и тест-файла достаточно) |
| AC8 | ревью кода | `docs/ARCHITECTURE.md` содержит абзац о границе (владелец, три способа смены ревизии, единая последовательность, оба профиля) — соответствует факту кода | — |
## Находки
### Medium-1 (в скоупе задачи). Post-write гейт: отказ (`asset-wait`) не останавливает хвост вызывающего ни в одном из четырёх путей — единственная защита AC4 не проверена для своей же отказной ветки и частично не работает
**Где:** `src/houseplan-editor-runtime.ts:8693` (`_deleteSpace`, editor),
`src/houseplan-editor-runtime.ts:9575` (`_undoPlanOptimization`),
`src/houseplan-editor-runtime.ts:9734` (`_applyBackupImport`),
`src/houseplan-onboarding-runtime.ts:477` (`_deleteSpace`, onboarding).
**Что происходит.** У трёх reload-вызывающих (`_loadFromServer`,
`_reloadConfigOnly`, summary recovery) при `adopted.status !== 'adopted'`
метод полностью прерывается (`return`/`throw`) — хвост не выполняется вовсе,
структура и на глаз, и в идентичности остаётся прежней, а
`_scheduleLoadRetry(true)` (вызванный внутри `adoptAuthoritativeGated`) сам
подтянет данные позже. Ни один из четырёх **post-write** вызывающих так не
делает:
- `_undoPlanOptimization` (`:9575`) вообще не читает `adopted.status` —
`_geometryHistory.clear()`, `_devicePositionHistory.clear()`,
`_canOptimizeUndo = false`, `_undoKind = null`, `_cfgEpoch++`,
`_maybeRebuildDevices()`, `_cacheSnapshot()` и тост
`gs.optimize_undone`/`backup.import_undone` выполняются безусловно.
- `_deleteSpace` (оба входа, `:8693` и onboarding `:477`) и
`_applyBackupImport` (`:9734`) читают `adopted.status` **только** для
одного шага — выбора видимого пространства (`_commitSpace`/
`_adoptInitialSpace`). Закрытие диалога, `_regSignature = ''`,
`_maybeRebuildDevices()`, `_cacheSnapshot()` (у Import) и финальный тост
выполняются безусловно, тем же порядком, что и при успешном adoption.
**Почему это дефект, а не стиль.** До #500 у post-write путей вообще не было
асинхронного гейта — `_adoptStructuralResponses` было синхронным и всегда
адоптировало то, что пришло; ветки «не адоптировано» просто не существовало.
Она появляется **именно этой задачей** (тем самым единственным намеренным
изменением поведения из §6.3 ТЗ) — и именно поэтому её последствия для
вызывающих — предмет этого ревью, а не наследие. Сценарий реалистичен и
прямо описан в самом ТЗ (§6.3): «Undo/Import могут вернуть другой фон» — то
есть конкурентная смена `plan_url` между записью и повторным чтением, из-за
которой `prepareImage` не готов вовремя, — ровно тот случай, который смок
`demo/smoke_post_write_adoption.mjs` воспроизводит для успешного пути
(`assetReady` всегда `true` в фикстуре). При отказе `prepareImage`
(например, временная сетевая ошибка на media-source) сервер уже необратимо
выполнил операцию (space удалено / undo применён / импорт применён), а
карточка: (а) для Undo — показывает тост «отменено» и гасит аффорданс Undo,
хотя видимая модель ещё старая (пост-оптимизационная, не отменённая) —
несовпадение тоста с фактическим состоянием экрана; (б) для всех четырёх —
пересобирает устройства/кэширует снимок по **старой** (неадоптированной)
идентичности, хотя гейт был явно введён, чтобы не показывать переходное/
рассинхронизированное состояние. Ретрай внутри `adoptAuthoritativeGated`
рано или поздно подтянет верные данные, поэтому дефект самоисцеляющийся и не
портит серверные данные — отсюда Medium, а не High.
**Чем красит/красится.** Ни `test/config-adoption.test.mjs`, ни
`demo/smoke_post_write_adoption.mjs` не подают `assetReady: false` ни в один
из четырёх post-write сценариев — ветка `status !== 'adopted'` для
post-write профиля ни разу не проходится ни юнитом, ни смоком. Убедиться
легко: временная правка `card._signer.prepareImage = async () => false;`
перед любым из четырёх вызовов в `demo/smoke_post_write_adoption.mjs`
сегодня не роняет ни одну из существующих проверок смока (они не смотрят на
это состояние) — то есть отсутствие проверки подтверждено, а не
предположено.
**Требуется:** привести хвосты `_deleteSpace` (оба входа),
`_undoPlanOptimization`, `_applyBackupImport` к тому же правилу, что уже
работает в reload-профиле — полностью пропускать хвост (закрытие диалога,
очистки, тост, `_cacheSnapshot`, выбор пространства) при
`adopted.status !== 'adopted'`, либо явно обосновать в ТЗ/ревью, почему
post-write хвост обязан выполняться даже без структурного adoption — и
покрыть выбранное поведение тестом/смоком, который умеет упасть на снятой
защите. Находка в скоупе задачи (это ровно те четыре пути, которые ТЗ §6.3
называет объектом гейта) — чинится в этом же issue, второй ревью-цикл не по
лимиту §4, а по итогу вердикта.
### Low-1 (снимается записью, не требует правки). Риск-таблица ТЗ §12 содержит неверный факт о `static properties`, но код всё равно корректен
ТЗ (§12) утверждает: «`_serverCfg`, `_layout`, `_cfgRev` и fingerprint не
входят в `static properties` хоста (проверено на `a824acc1`)». Проверка
чтением `git show a824acc1:src/houseplan-card.ts` показывает, что `_layout`
и `_serverCfg` **были** объявлены `{ state: true }` уже на этом коммите (до
#500) — то есть до задачи они были обычными реактивными Lit-полями без
собственных аксессоров, и замена их на кастомные getter/setter действительно
меняла контракт реактивности (Lit пропускает генерацию своего аксессора,
когда `prototype.hasOwnProperty(name)` истинно). Утверждение спеки было
неверным в момент зелёного ревью ТЗ r1–r3 — но реальный код это не сломало
благодаря отдельному фикс-коммиту `0905f67e` (`onBodyReplaced` →
`requestUpdate`), написанному именно потому, что red Validate на `c360bcc9`
поймал регрессию `smoke_room_resize` от этого же факта. Итог: код корректен и
подтверждён смоком/юнитом, найденная неточность — в тексте уже принятого ТЗ,
которое не переоткрывается этим ревью. Фиксирую как Low без блокировки.
## Что проверено и корректно
- Модуль `src/config-adoption.ts`: ссылочная идентичность (I4), приём
ревизии только с телом (I2), эхо не трогает историю/эпоху (I3) — все три
инварианта подтверждены и чтением, и тестами/мутантами.
- Реактивный контракт `onBodyReplaced` → `requestUpdate(field, previous)`
корректно воспроизводит поведение снятого Lit-аксессора; `willUpdate`
(`houseplan-card.ts:4111`) по-прежнему держит инвариант эпохи геометрии.
- Сужение `SummaryPanelHost`/`HouseplanEditorHostPort` до одного метода —
соответствует §6.4 дословно (проверено построчно).
- `plan-optimize-write.ts`, `space-copy-runtime.ts`, `vacuum-calibration-write.ts`,
`editors/vacuum-maps-section.ts` — переведены на `acceptPairWrite`/
`beginOptimistic`/`stageLocalConfig`/`host._rollbackOptimistic`, прямых
записей идентичности не осталось (подтверждено AC1-тестом и ручным grep).
- `serialized-write-queue.ts` лишился `rollbackOptimistic`; единственный
вызывающий отката — `houseplan-card.ts` (`_rollbackOptimistic`), как того
требует AC2-тест.
- Бюджеты AC7: факт (13605 строк, 299 771 Б) внутри новых потолков с
датированной записью причины пересчёта.
- Трейлеры всех десяти коммитов: `Issue: #500` + `User-Visible: no`,
changelog не тронут — согласовано с `User-Visible: no`.
- `docs/ARCHITECTURE.md` (AC8) описывает границу фактически точно.
## Чего не проверял и почему
- Полный набор из 47 «прямых совпадений» `smoke-select` — прогнал только те,
что напрямую относятся к изменённому поведению (adoption/rollback/
reactivity), остальные читают делегируемые поля так же, как раньше;
полный набор — предрелизный гейт (§8).
- `npm run invariants` — диффом не задета геометрия/толщина/ссылки, только
владение ревизией и телом.
- `python -m pytest tests_backend` — backend не тронут диффом.
- `golden:verify`/`performance_smoke` — визуал и перф-профили не в AC и не
меняются по диффу (подтверждено ТЗ §7/§14 и составом diff: только `src/**`
frontend, никаких изменений в рендер-путях).
- Собственный повторный прогон `tsc`/`npm test`/`npm run build` — не
дублировал: зелёный Validate подтверждён на точном материале ревью через
`gh run view` (headSha сверен).
## Материал раунда
- SHA материала: `0905f67ec0d8fb830065e909988f442c884320c2`
- Дерево: рабочая копия уже на этом SHA (`git rev-parse HEAD` совпадает)
- ТЗ: `docs/specs/500-config-adoption-boundary.md`, зелёное ревью
`docs/reviews/SPEC-REVIEW-500-r3.md` на коммите `622470ff`
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/500-config-adoption-boundary`, коммит `0905f67ec0d8` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `5c8ab48044237c58112ba21b6d38a8de22a371c1`
```
git log --all --format='%H %T' | grep 5c8ab4804423
```
- Тело issue: `d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5`
- ТЗ `docs/specs/500-config-adoption-boundary.md`, блоб `892240ddf10b28b483d865aa21e7132a1c0b7aaa`
```
git log --all --find-object=892240ddf10b28b483d865aa21e7132a1c0b7aaa -- docs/specs/500-config-adoption-boundary.md
```
- Вердикт конвейера: `yellow` · High 0
+228
View File
@@ -0,0 +1,228 @@
# CODE-REVIEW-500-r2
- **Issue:** #500 — Архитектура: выделить одну границу владения config/adoption
- **Материал:** `93c6c7551b3a7f3df1901e69888611b1a7375ac4` (ветка `issue/500-config-adoption-boundary`); рабочая копия была на этом SHA на момент старта ревью
- **Заход:** r2 · блокирующих циклов израсходовано 1/4 до этого раунда
## Почему разбор полный, а не по дельте
Конвейер привёл ветку к `dev` перед ревью: поверх материала r1 (`0905f67e` / его копия после переноса истории — `da9a58bf`) легло три коммита `dev`
(`f6eac1d9`, `67c780a7`, `2783ceff` — перестройка джоба «Мутанты по диффу» на 6 шардов с предварительным планированием, #518). После ребейза это
другой код (PROCESS.md §7.2), поэтому разбор в этом раунде — полный, не ограниченный дельтой r1→r2.
Фактическая проверка: `git diff da9a58bf 93c6c755` (вне `dist/**` и бандл-деревьев) показывает, что содержимое самой задачи №500
(`src/**`, `test/**`, `docs/specs/**`) **не изменилось** — ребейз принёс исключительно инфраструктурные правки `.github/workflows/validate.yml`,
`scripts/mutation-gate.mjs`, `PROCESS.md`, `test/mutation-gate.test.mjs`, `test/validate-*.test.mjs` (#518, не связано с #500). Правки самой
задачи r1→r2 — единственный коммит `93c6c755` (тот же, что `da9a58bf`, с иным родителем), закрывающий Medium-1 r1. Несмотря на это, ниже
разобран весь диапазон `origin/dev...HEAD` (34 нестандартных файла, ~2864/-396 строк), а не только коммит M1-фикса — так требует правило
полного разбора при ребейзе, и по факту в ходе разбора это окупилось (см. находку M1-2 ниже, обнаруженную независимым прогоном, а не чтением диффа).
## Скоуп
Извлечение владения идентичностью `{config, rev, fingerprint} × {layout, rev, fingerprint}` в `src/config-adoption.ts`; единая
гейт-последовательность `adoptAuthoritativeGated` (compare → prepareImage → continuity → adopt → хвост профиля) для семи вызывающих в
двух профилях (`reload`/`post-write`); сужение `SummaryPanelHost` и host-контрактов editor-/onboarding-runtime; перенос `rollbackOptimistic`
в модуль; единственное намеренное изменение поведения — гейт готовности фона на четырёх post-write путях, ревизии `space/delete` берутся из
`config/get`/`layout/get`, а не из ответа `delete`. `User-Visible: no` на всех двенадцати коммитах диапазона, changelog не тронут (подтверждено
диффом — ни один файл `docs/CHANGELOG*.md` не изменён).
M1-фикс этого раунда (`93c6c755`): все четыре post-write вызывающих (`_deleteSpace` × 2 рантайма, `_undoPlanOptimization`, `_applyBackupImport`)
теперь при `adopted.status !== 'adopted'` полностью пропускают хвост (тост, выбор пространства, очистки, `_cacheSnapshot`), освобождают `busy`
своего диалога и выходят — как это уже делали три reload-пути.
## Как проверялось
**Дешёвые гейты — не гонял заново отдельно, но перепроверил напрямую.** Validate на точном материале (`93c6c755`) зелёный целиком:
https://github.com/Matysh/houseplan-card/actions/runs/34472616328 (`headSha` подтверждён). Из осторожности (полный разбор) всё равно
прогнал сам и получил тот же результат: `npx tsc -p tsconfig.test.json` + `npm run build` (tsc --noEmit + rollup) — чисто; `npm test` —
**2508 pass / 0 fail / 1 skip**; `npm run bundle:sync` — дерево `dist`/`custom_components/houseplan/frontend`/`demo/srv/assets` совпадает
(рабочая копия чистая после сборки, `git status --short` пуст); `npm run bundle:budget` — initial View 299 774 Б (потолок 300 300 ±2000,
запас budget 1292 Б, известное предупреждение низкого запаса #367/#474, не блокирует); `node scripts/no-new-any.mjs` — 0 новых `any` на
596 добавленных строках `src/**`. `node --test test/core-file-budget.test.mjs` — 7/7, `houseplan-card.ts` 13605 строк < потолка 13650.
**ВАЖНО, разошлось с записью в хендоффе (Matysh, комментарий #12):** «Мутанты по диффу» и «Смоки в браузере» в прогоне `34472616328`
имеют статус **`skipped`**, не `success` — это обычный push без `Release:`, тяжёлые джобы на нём не запускаются (см. PROCESS.md §8, ожидаемо).
Формулировка «Validate зелёный целиком» в контексте этой задачи верна буквально (все незапущенные джобы = `skipped`, это не красный), но
**не покрывает** ни один из четырёх мутантов-защитников (`config-adoption-echo-clears-history`, `config-adoption-rev-from-foreign-response`,
`config-adoption-rollback-ignores-rev`, `post-write-skips-asset-gate`), ни браузерные смоки. Прогнал их лично:
```
node scripts/mutation-gate.mjs --id=config-adoption-echo-clears-history → поймано 1 из 1
node scripts/mutation-gate.mjs --id=config-adoption-rev-from-foreign-response → поймано 1 из 1
node scripts/mutation-gate.mjs --id=config-adoption-rollback-ignores-rev → поймано 1 из 1
node scripts/mutation-gate.mjs --id=post-write-skips-asset-gate → поймано 1 из 1
```
Гард всех четырёх мутантов — `node --test test/config-adoption.test.mjs` (юнит), не браузерный смок; это подтверждает наблюдение r1-ревью,
что `demo/smoke_post_write_adoption.mjs` не зарегистрирован гардом ни одного мутанта и, значит, ни разу не проверялся автоматически на
материале ревью ни в r1, ни здесь.
**Браузерные смоки — прогнал сам (Chromium доступен в этой среде).** Собрал бандл (`npm run build && npm run bundle:sync`, дерево чистое)
и прогнал по прямым совпадениям diff'а (`node scripts/smoke-select.mjs --base origin/dev --head HEAD` → 48 прямых совпадений, порог
«широкого» символа — 47) те, что относятся к изменённому поведению (adoption/rollback/reactivity/M1), а не ко всем 48 — полный прогон
остаётся предрелизным гейтом (PROCESS.md §8):
| Смок | Результат |
|---|---|
| `demo/smoke_post_write_adoption.mjs` (AC4, единственный прямой свидетель, переписан этим раундом) | **КРАСНЫЙ** — 1 проверка из 36 падает, см. находку ниже |
| `demo/smoke_room_resize.mjs` (свидетель реактивности `onBodyReplaced`, красневший на предыдущем кандидате r1) | OK |
| `demo/smoke_summary_panel.mjs` (lost-ACK/summary recovery через `_adoption`) | OK |
| `demo/smoke_danger_confirmation.mjs` (глушит `_adoptAuthoritative`) | OK |
| `demo/smoke_device_position_history.mjs` (тронут диффом, автор не прогонял — «нет Chromium») | OK |
| `demo/smoke_area_relocation_safety.mjs` (тронут диффом, автор не прогонял) | OK |
Остальные 42 прямых совпадения не прогонял — по диффу они читают делегируемые поля (`_cfgRev`/`_layoutRev`/`_model`/`_serverCanWrite` и т.п.)
так же, как раньше (делегаты не меняют семантику чтения), не изменённое поведение; это тот же критерий отбора, что применил r1.
**Инварианты модели** — не запускал: диффом задета идентичность (revision/fingerprint/owner), не геометрия рёбер/толщины/ссылок; ни
`layout` (структура устройств), ни `rooms`/`wall_segments`/`marker.space` не меняют формат или содержимое, меняется только то, кто и когда
пишет их идентичность.
**pytest/golden/perf** — не задеты диффом (backend не тронут ни одним файлом; визуал не меняется — ни один файл рендера/стилей в диффе);
не гонял, как и в r1.
**Один источник числа** — не применимо: `User-Visible: no`, пользователю ничего не показывается; единственные числа в диффе — внутренние
бюджеты сборки (см. AC7), они не дублируются в интерфейсе.
## Находка
### Medium-1 (в скоупе задачи, новая в r2). Единственный автоматический свидетель AC4/M1 красный на материале ревью — `demo/smoke_post_write_adoption.mjs` падает на `onboardingDeleteRefusedAdoptsNothing`, и ни один гейт CI это не поймал
**Воспроизведение (детерминированно, 3/3 прогонов подряд):**
```
npm run build && npm run bundle:sync
node demo/smoke_post_write_adoption.mjs
# ...
# FAILED (1):
# - onboardingDeleteRefusedAdoptsNothing: expected true, got false
```
**Что происходит.** После M1-фикса смок расширен сценариями с `assetReady=false` (проверка отказной ветки, ровно то, что требовала r1
находка). Для сценария «удаление пространства через onboarding-рантайм при отказе гейта» проверка `card._cfgRev === 10 && card._layoutRev
=== 20 && card._serverCfg.spaces.length === 2 && ...` не проходит: `card._cfgRev` равен **11**, хотя adoption корректно отказал (в `order`
нет `'adopt'`, `retries === 1`, тостов нет, диалог освобождён — то есть сам M1-фикс отработал правильно).
**Причина (доказана чтением и инструментированным прогоном, не догадкой).** `reset()` в смоке (строки 82–93) вызывает
`adoption.restoreCached(...)`, но не отменяет отложенные записи — `_saveConfigDebounced`/`_persistLayout` (существовавший до #500
механизм; другие смоки, например `demo/smoke_danger_confirmation.mjs:196`, явно вызывают `.cancel()` для этой же гигиены). Если к
моменту очередного сценария есть **не связанная с ним** отложенная запись конфига (в этом прогоне она возникает во время предыдущего
успешного `editorDelete`/аналогичного сценария), `_deleteSpace()`/`_applyBackupImport()` в начале своего `try`-блока безусловно вызывают
`this.host._saveConfigDebounced.flush()` (код, не относящийся к #500, не изменён этой задачей) — что немедленно шлёт `houseplan/config/set`
через фейковый `card.hass.callWS`. Смок не обрабатывает этот тип сообщения (`default: return {}`), ответ приходит без `rev`, и
`MutableConfigAdoption.acceptConfigWrite` (одна из трёх санкционированных точек записи ревизии, I1) применяет документированный запасной
вариант «`rev ?? rev + 1`» (§15 п.3 ТЗ) — **к чужому, неотносящемуся к текущему сценарию телу**. Ревизия увеличивается на 1 (10 → 11)
именно в тот момент, когда `onboardingDelete-refused` уже сбросил состояние через `restoreCached`. Поскольку эта конкретная итерация —
refused (гейт отказывает), собственный adoption сценария так и не перезаписывает испорченную ревизию — она доживает до финальной проверки.
Подтверждено инструментированным прогоном (обёртка `acceptConfigWrite`/`restoreCached` во временной копии смока вне репозитория,
рабочая копия репозитория не менялась — `git status --short` пуст на всём протяжении проверки): без правки — `cfgRev: 11` и
трасса показывает лишний `acceptConfigWrite({...}, {})` между двумя `restoreCached`; с добавлением `card._saveConfigDebounced.cancel();
card._persistLayout.cancel();` в `reset()` — трасса чистая (0 лишних вызовов), все четыре refused-сценария дают `cfgRev: 10` корректно.
**Это дефект теста, не продукта.** С реальным backend `houseplan/config/set` всегда возвращает `rev` (см. `custom_components/houseplan/
websocket_api.py:1587-1637`, ответ без `expected_rev`/`rev` — это `conflict`, не успех); запасной вариант `+1` уже задокументирован в ТЗ
§15 п.3 как «с реальным backend недостижимо» и не входит в скоуп этой задачи. Сам M1-фикс (ранний `return`/пропуск хвоста во всех
четырёх вызывающих) при этом подтверждён верным — как прямым чтением диффа (см. таблицу AC ниже), так и тем, что ТРИ из четырёх
refused-сценариев смока (`editorDelete`, `optimizeUndo`, `importApply`) проходят корректно на этом же материале и том же прогоне.
**Почему Medium, а не Low.** Это не стилистическая придирка: единственный автоматический свидетель, который должен доказывать закрытие
r1-находки M1 для конкретно этого пути (`_deleteSpace` в onboarding-рантайме), **красный на материале, поданном на ревью**, и ни один
CI-гейт этого не заметил — «Мутанты по диффу» и «Смоки в браузере» на этом SHA `skipped` (обычный push), а гард всех четырёх мутантов —
юнит-тест, не этот смок. Без ручной проверки (эта проверка — «работает ли оно» и есть мандат ревьюера) дефект теста ушёл бы в `dev`
непойманным, а следующий раунд, который тронет этот файл, унаследовал бы красный тест молча.
**Требуется:** добавить `card._saveConfigDebounced.cancel(); card._persistLayout.cancel();` (или эквивалентную изоляцию) в `reset()`
смока `demo/smoke_post_write_adoption.mjs`, перепрогнать все 36 проверок до зелёного, приложить результат. Правка не продуктового кода —
только `demo/**`, но всё равно проходит ещё один цикл ревью (правило §4: Medium в скоупе чинится и перепроверяется).
## AC → повторно проверено на материале r2
| AC | Статус | Как проверено в этом раунде |
|---|---|---|
| AC1 (идентичность пишет только модуль) | Подтверждено | `node --test test/config-adoption-ownership.test.mjs` (5/5), плюс независимый grep `_cfgRev\s*=`/`_layoutRev\s*=` вне `config-adoption.ts` — ноль совпадений |
| AC2 (семь путей через `adoptAuthoritativeGated`, старый seam не встречается) | Подтверждено | `grep -rl _adoptStructuralResponses src/**/*.ts` → только `config-adoption.ts` (внутренняя функция без подчёркивания); typecheck зелёный |
| AC3 (поведенческая нейтральность, post-write без пост-шагов) | Подтверждено | `node --test test/config-adoption.test.mjs` (25/25); `smoke_summary_panel`, `smoke_danger_confirmation`, `smoke_room_resize` — зелёные, прогнаны лично |
| AC4 (post-write гейт + ревизии из re-read, включая отказную ветку) | **Частично** — код верен (прочитан построчно, все 4 вызывающих симметрично пропускают хвост), но единственный автоматический свидетель красный на материале (Medium-1) | `demo/smoke_post_write_adoption.mjs` — 35/36; unit `post-write profile: a refused gate adopts nothing…` (r1 M1) зелёный |
| AC5 (тёплый старт round-trip) | Подтверждено (унаследовано, не задето диффом раунда) | `test/config-adoption.test.mjs` round-trip кейсы в общем прогоне 25/25 |
| AC6 (optimistic rollback) | Подтверждено (унаследовано) | мутант `config-adoption-rollback-ignores-rev` — поймано 1/1 лично |
| AC7 (бюджеты) | Подтверждено, факт обновлён этим раундом | `npm run bundle:budget` → 299 774 Б, совпадает с записью в `scripts/bundle-budget.mjs`; `core-file-budget` 13605 < 13650 |
| AC8 (документация) | Подтверждено | `docs/ARCHITECTURE.md` абзац о границе присутствует и фактически точен (сверен построчно) |
## Что проверено и корректно
- Сам M1-фикс: все четыре post-write вызывающих (`_deleteSpace` в `houseplan-editor-runtime.ts:8691-8697` и
`houseplan-onboarding-runtime.ts:477-489`, `_undoPlanOptimization:9575-9576`, `_applyBackupImport:9735-9736`) симметрично проверяют
`adopted.status !== 'adopted'` и делают ранний `return` до любого хвоста (тост/выбор пространства/очистки/кэш); для `_undoPlanOptimization`
`finally` по-прежнему снимает `_optimizeUndoBusy` — подтверждено чтением управления потоком, `return` внутри `try` гарантированно
проходит `finally`.
- `adoptAuthoritativeGated` (`src/config-adoption.ts:415-446`) — единая точка, гейт `prepareImage` вызывается только при
`structuralChanged`, на отказе не адоптирует ничего и планирует `_scheduleLoadRetry(true)`; корректно для обоих профилей.
- Три из четырёх refused-сценариев смока (`editorDelete`, `optimizeUndo`, `importApply`) реально доказывают отказную ветку своих путей —
не только код прочитан, но и тест прошёл на живом прогоне.
- Ревизии из delete-ответа больше нигде не читаются (`grep` по `config_rev`/`layout_rev` в теле `space/delete` подтверждает — оба входа
используют только `config/get`/`layout/get`).
- Бюджеты и trailers всех 12 коммитов диапазона (`Issue: #500`, `User-Visible: no`) — в порядке; `docs/CHANGELOG*.md` не тронуты, что
согласовано с `User-Visible: no`.
- Три коммита `dev`, принесённые ребейзом (#518, мутанты по диффу на 6 шардов с предварительным планированием), не пересекаются по файлам
с продуктовым кодом #500 (кроме общего `scripts/mutation-gate.mjs`, куда #500 лишь добавляет свои 4 определения мутантов в конец
массива — конфликта семантики нет, все 4 подтверждены пойманными лично).
## Чего не проверял и почему
- Полный набор 48 прямых совпадений `smoke-select` — прогнал 6, относящихся к изменённому поведению; остальные 42 диффом не меняют
семантику чтения (та же логика отбора, что в r1). Полный набор — предрелизный гейт (PROCESS.md §8).
- `npm run invariants` — диффом не задета геометрия рёбер/толщины/ссылок, только владелец ревизии и тела.
- `python -m pytest tests_backend` — backend не тронут диффом.
- `golden:verify`/`performance_smoke` — визуал и перф-профили не в AC и не меняются по диффу.
- `git diff da9a58bf 93c6c755` за пределами `src/**`/`test/**`/`docs/specs/**` (т.е. сами принесённые ребейзом инфра-файлы `validate.yml`,
`mutation-gate.mjs` §518-часть, `PROCESS.md`) — не проверял по существу: это код `dev`, не материал задачи #500, он уже прошёл свой
собственный процесс в `dev`.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| Medium-1 (все четыре post-write вызывающих не пропускают хвост при `asset-wait`, отказная ветка не тестируется) | Коммит `93c6c755` добавляет ранний `return`/скип хвоста во всех четырёх вызывающих + 1 юнит + 16 новых проверок смока на отказную ветку | `src/houseplan-editor-runtime.ts:8694,9575,9736`, `src/houseplan-onboarding-runtime.ts:480`; `test/config-adoption.test.mjs` тест «post-write profile: a refused gate adopts nothing…»; смок расширен, но красный на 1/36 — см. Medium-1 этого раунда (продуктовый код верен, тест — нет) |
| Low-1 (риск-таблица ТЗ §12 неточна про `static properties`) | Строка риска переписана на фактический механизм (`onBodyReplaced` → `requestUpdate`) | `docs/specs/500-config-adoption-boundary.md`, диф в `93c6c755` |
## Унаследовано из r1 (только то, что дельта r1→r2 не задевает содержательно)
Несмотря на полный разбор кода в этом раунде (обязателен из-за ребейза, см. выше), ниже — фактические выводы r1, которые я **не
пересчитывал заново с нуля**, а верифицировал спот-чеками, совпавшими с r1 буквально (то есть не «доверие на слово», а подтверждение):
- AC1/AC2 точные числа (18 присваиваний `_serverCfg =` в 8 модулях до переноса, allowlist после переноса) — r1 пересчитал вручную по
`src/**`; в этом раунде перепроверено через `test/config-adoption-ownership.test.mjs` (не переписан диффом M1-фикса) и независимый grep,
оба совпадают с выводом r1. Документ r1: `docs/reviews/CODE-REVIEW-500-r1.md`, материал `0905f67ec0d8`.
- AC5 (тёплый старт round-trip) и AC6 (rollback) логика — код `config-adoption.ts` в этих методах не тронут коммитом `93c6c755` (diff
только в `adoptAuthoritativeGated`/асимметрии post-write-вызывающих и bundle-budget-комментарии); r1 проверил их чтением и мутантами,
здесь перепроверено повторным прогоном тех же мутантов на новом SHA — совпадает.
- Сужение `SummaryPanelHost`/`HouseplanEditorHostPort` (§6.4 ТЗ) — не задето диффом M1-фикса; r1 сверил построчно, здесь не
пересматривалось заново, только подтверждено отсутствием изменений в `git diff da9a58bf 93c6c755 -- src/summary-panel-host.ts`.
## Материал раунда
- SHA материала: `93c6c7551b3a7f3df1901e69888611b1a7375ac4`
- Дерево: рабочая копия была на этом SHA на момент начала ревью (`git rev-parse HEAD` совпадал); после сборки/тестов дерево осталось
чистым (`git status --short` пуст)
- ТЗ: `docs/specs/500-config-adoption-boundary.md`, откорректирован в этом же диапазоне коммитом `93c6c755` (риск-таблица §12, допущение
§15 п.8); зелёное спек-ревью `docs/reviews/SPEC-REVIEW-500-r3.md`
- Предыдущий код-ревью: `docs/reviews/CODE-REVIEW-500-r1.md`, материал `0905f67ec0d8`, вердикт `yellow · High 0 · Medium 1`
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/500-config-adoption-boundary`, коммит `93c6c7551b3a` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `4967ea22e3c4465c17aab7e4ed7bc929f13acc38`
```
git log --all --format='%H %T' | grep 4967ea22e3c4
```
- Тело issue: `d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5`
- ТЗ `docs/specs/500-config-adoption-boundary.md`, блоб `b7b4f02c6ca5a57092a360f1f664656b62ed968c`
```
git log --all --find-object=b7b4f02c6ca5a57092a360f1f664656b62ed968c -- docs/specs/500-config-adoption-boundary.md
```
- Вердикт конвейера: `yellow` · High 0
+174
View File
@@ -0,0 +1,174 @@
# CODE-REVIEW-500-r3
- **Issue:** #500 — Архитектура: выделить одну границу владения config/adoption
- **Материал:** `be6d57e96f425ceefa7ce5888785ac522e277b26` (ветка `issue/500-config-adoption-boundary`); рабочая копия уже была на этом SHA
- **Заход:** r3 · блокирующих циклов израсходовано 2/4 до этого раунда
## Почему разбор по дельте, а не заново
Материал и SHA предыдущего раунда объявлены в `docs/reviews/CODE-REVIEW-500-r2.md`: `93c6c7551b3a7f3df1901e69888611b1a7375ac4`. Этот SHA
живой и является прямым предком текущего `HEAD` (`git merge-base --is-ancestor 93c6c755 be6d57e9` → да; цепочка `93c6c755 → 5acf04fa
(docs: review document for #500) → be6d57e9` линейна, без ребейза). Дельта:
```
git diff 93c6c755..be6d57e9 --stat
demo/smoke_post_write_adoption.mjs | 7 ++
docs/reviews/CODE-REVIEW-500-r2.md | 228 +++++++++++++++++++++++++++++++++++++ (документ предыдущего раунда, не код)
scripts/mutation-gate.mjs | 14 +++
```
Один коммит реализации (`be6d57e9`) поверх doc-коммита `5acf04fa`. Продуктовый код (`src/**`) дельта не трогает вообще — только `demo/**`
(смок) и `scripts/**` (реестр мутантов), то есть class B по AGENTS.md. Ребейза на ушедший вперёд `dev` не было, контракт поведения не
менялся, новая подсистема не затронута, объём дельты (21 добавленная строка) на порядки меньше исходной задачи — все условия §2.10 для
разбора по дельте выполнены; полный разбор не требуется.
## Скоуп дельты
Единственная находка r2 (Medium-1: `demo/smoke_post_write_adoption.mjs` детерминированно красный на материале ревью,
`onboardingDeleteRefusedAdoptsNothing: expected true, got false`, причина — чужая отложенная запись `_saveConfigDebounced`/
`_persistLayout` из предыдущего сценария доживает до следующего). Ответ автора («Ответ на код-ревью r2 — заход 3»): `reset()` смока
теперь отменяет обе отложенные записи в начале, плюс добавлен мутант `post-write-tail-runs-on-refused-gate` с гардом на этот смок, чтобы
witness больше не мог оставаться незапущенным CI незамеченным (ровно то, что произошло на r1→r2: witness был красным целый раунд, и ни
один гейт CI его не поймал, потому что ни один мутант не называл его своим гардом).
## Как проверялось
**Дешёвые гейты — подтверждены Validate на точном материале ревью, не перегонял отдельно.**
https://github.com/Matysh/houseplan-card/actions/runs/34476048247, `headSha` = `be6d57e96f425ceefa7ce5888785ac522e277b26` (сверено
`gh run view --json headSha`) — `success`. В этом прогоне джоб «Мутанты по диффу» **не был `skipped`** (в отличие от r2): это ревью-кандидат
(метка `S7-code-review` запускает Validate с мутантами по диффу на самом материале, PROCESS.md §2.7/AGENTS.md), поэтому прогон покрывает не
только `tsc --noEmit`/`npm test`/`npm run build`+bundle-sync, но и мутанты. Прочитал логи всех 6 шардов (`gh run view --job <id> --log`),
не только факт green:
```
Шард 3/6, лог 102867009880:
ok чистый прогон: node demo/smoke_post_write_adoption.mjs
ok post-write-tail-runs-on-refused-gate: тест покраснел, как обязан
```
То есть CI сам подтвердил и чистый прогон смока (зелёный), и что новый мутант, снимающий ранний `return` в `_undoPlanOptimization`, красит
именно этот смок — то есть witness теперь реально запускается конвейером и реально ловит регрессию, чего не было в r1/r2.
Дополнительно к CI-логам прогнал сам (Chromium в этой среде есть, `npm ci` не требовался):
```
npm run build && npm run bundle:sync → дерево чистое (git status --short пуст)
node demo/smoke_post_write_adoption.mjs × 3 подряд → OK, все 36 проверок true (все три прогона идентичны)
```
**Негативная проба (тест умеет падать) — воспроизвёл сам, не полагаясь на слово автора.** Временно убрал ровно те две строки
(`card._saveConfigDebounced.cancel(); card._persistLayout.cancel();`) из `reset()` в рабочей копии, прогнал смок, вернул файл на место:
```
FAILED (1):
- onboardingDeleteRefusedAdoptsNothing: expected true, got false
```
Дословно совпадает с воспроизведением, описанным в находке r2 — причина найдена верно, фикс минимален и точно закрывает названный
механизм, а не маскирует симптом. `git status --short` пуст после отката правки — рабочая копия не оставлена мутированной.
**Мутант `post-write-tail-runs-on-refused-gate` (`scripts/mutation-gate.mjs`)** — цель патча (`src/houseplan-editor-runtime.ts:9578`,
`if (adopted.status !== 'adopted') return; // asset wait: the scheduled reload owns the tail`) уникальна в `src/**`
(`grep -rn "adopted.status !== 'adopted'" src/houseplan-editor-runtime.ts src/houseplan-onboarding-runtime.ts` даёt 4 совпадения, но
искомая строка с комментарием — ровно одна); `find`-строка мутанта совпадает с кодом дословно. Гард — тот же смок, что и находка;
структурная валидность записи (порядок, отсутствие дублей) подтверждена зелёными `test/mutation-gate.test.mjs`/`test/mutation-gate-
report.test.mjs` в том же CI-прогоне.
**Что не прогонял и почему.** `npm run invariants` — дельта не касается геометрии/толщины/ссылок, только тестовую гигиену и реестр
мутантов. `python -m pytest tests_backend` — backend не в дельте. `golden:verify`/`performance_smoke` — визуал не меняется, дельта не
рендер. `node scripts/check-docs.mjs` отдельно не гонял — дельта не трогает `src/**` (только `demo/**`/`scripts/**`), отпечаток
скриншотов от неё не протухает; в Validate-прогоне `S7` docs-job не входит в набор джобов ревью-кандидата (полный `docs`-джоб — часть
предполётных проверок, прошёл в этом же прогоне success). Полный `smoke-select` (48 прямых совпадений по прошлому раунду) не прогонял
заново — дельта меняет один-единственный файл смока и реестр мутантов, остальные 47 совпадений её не касаются: это не изменение
семантики читаемых полей, а изоляция сценариев внутри одного смока.
## Закрытие раунда r2
| Находка r2 | Чем закрыта | Где это видно |
|---|---|---|
| Medium-1 (единственный автоматический свидетель AC4/M1, `demo/smoke_post_write_adoption.mjs`, детерминированно красный на материале ревью из-за не отменённых отложенных записей в `reset()`) | Коммит `be6d57e9` добавляет `card._saveConfigDebounced.cancel(); card._persistLayout.cancel();` в начало `reset()` | `demo/smoke_post_write_adoption.mjs:86-87`; подтверждено CI (`Мутанты по диффу 3/6`: чистый прогон ok) и лично (3/3 прогона OK, негативная проба красная на снятой правке) |
| Сопутствующая дыра (witness не был зарегистрирован ни одним мутантом — CI не гонял его ни разу за весь r1/r2) | Новый мутант `post-write-tail-runs-on-refused-gate` с гардом на этот смок | `scripts/mutation-gate.mjs` (+14 строк); подтверждено CI-логом: `post-write-tail-runs-on-refused-gate: тест покраснел, как обязан` |
## AC → что изменилось в этом раунде
Только AC4 затронут дельтой (единственный незакрытый пункт из r2 — «код верен, свидетель красный»). Остальные AC дельта не задевает
(дельта не трогает `src/**`).
| AC | Статус | Как проверено в этом раунде |
|---|---|---|
| AC4 (post-write гейт + ревизии из re-read, включая отказную ветку) | **Подтверждено полностью** | Единственный автоматический свидетель (`demo/smoke_post_write_adoption.mjs`) теперь зелёный и на материале ревью в CI, и в трёх личных прогонах; негативная проба лично воспроизведена и совпадает с диагнозом r2; новый мутант закрывает дыру «witness никогда не запускался» |
## Унаследовано из r2 (дельта не задевает — продуктовый код `src/**` не менялся)
- AC1 (идентичность пишет только модуль), AC2 (семь путей через `adoptAuthoritativeGated`) — код `src/**` не тронут дельтой r2→r3;
документ r2 перепроверил их чтением и тестом `config-adoption-ownership.test.mjs`. Документ: `docs/reviews/CODE-REVIEW-500-r2.md`,
материал `93c6c7551b3a`.
- AC3 (поведенческая нейтральность, профиль `post-write` без пост-шагов) — не задето, тот же материал r2.
- AC5 (тёплый старт round-trip), AC6 (optimistic rollback) — код `config-adoption.ts` не в дельте; r2 подтвердил мутантами
`config-adoption-rollback-ignores-rev` и повторным прогоном round-trip юнитов; в этом раунде мутант перепроверен CI на новом SHA (лог
шарда, `тест покраснел, как обязан`) без изменения содержания.
- AC7 (бюджеты) — `houseplan-card.ts`/бандл не менялись дельтой (diff-статистика: только `demo/**`+`scripts/**`); факты r2 (13605 строк,
299 774 Б) остаются в силе, бюджет не пересчитывался, так как нечему.
- AC8 (документация) — `docs/ARCHITECTURE.md` не в дельте.
- Ревизии `space/delete` из `config/get`/`layout/get`, а не из ответа `delete` — продуктовая логика не в дельте, r2 подтвердил grep'ом.
- M1-фикс r1 (четыре post-write вызывающих симметрично пропускают хвост при отказе гейта) — продуктовый код не в дельте r2→r3; r2
перепроверил чтением построчно (`houseplan-editor-runtime.ts:8694,9575,9736`, `houseplan-onboarding-runtime.ts:480`), это не
переоткрывается.
- Материал/содержание задачи в целом (одна граница владения, единая гейт-последовательность, `plan-optimize-write`/`space-copy`/vacuum-
писатели, сужение `SummaryPanelHost`/host-портов) — покрыто полным разбором r1 и повторным полным разбором r2 (ребейз на `dev` принёс
инфраструктуру #518, не тронувшую #500), эта дельта их не касается.
## Что проверено и корректно (эта дельта)
- Фикс `reset()` изолирует сценарии смока так же, как уже делает `demo/smoke_danger_confirmation.mjs:196` — согласованный с остальной
кодовой базой паттерн, не разовый хак.
- Единственная затронутая строка продуктового кода — цель нового мутанта, не сам продуктовый код; дельта не меняет поведение карточки.
- Новый мутант зарегистрирован единожды, без дублей, структура записи проходит `test/mutation-gate.test.mjs`/`test/mutation-gate-
report.test.mjs`.
- Trailers коммита `be6d57e9`: `Issue: #500`, `User-Visible: no` — верно (правка только `demo/**`+`scripts/**`, поведение карточки не
меняется); changelog не тронут, согласовано.
## Чего не проверял и почему
- Полный набор `smoke-select` (48 совпадений) — дельта не меняет семантику чтения ни одного поля за пределами `smoke_post_write_
adoption.mjs`; полный набор — предрелизный гейт (§8).
- `npm run invariants`, `python -m pytest tests_backend`, `golden:verify`, `performance_smoke` — дельта не задевает геометрию, backend
или рендер.
- Полный повторный аудит продуктового кода #500 (`config-adoption.ts`, семь вызывающих, host-делегаты) — не задет дельтой, унаследован
из r1/r2 (см. раздел выше), не пересматривался заново.
- Собственный повторный прогон `tsc`/`npm test`/`npm run build` с нуля — не дублировал: Validate на точном материале ревью зелёный
(headSha сверен), включая на этот раз и мутанты по диффу; сверх этого лично прогнал сам смок (3×) и негативную пробу.
## Материал раунда
- SHA материала: `be6d57e96f425ceefa7ce5888785ac522e277b26`
- Дерево: рабочая копия была на этом SHA весь разбор; после build/bundle-sync и временной негативной пробы дерево возвращено в исходное
состояние (`git status --short` пуст)
- Предыдущий код-ревью: `docs/reviews/CODE-REVIEW-500-r2.md`, материал `93c6c7551b3a7f3df1901e69888611b1a7375ac4`, вердикт
`yellow · High 0 · Medium 1`
- ТЗ: `docs/specs/500-config-adoption-boundary.md`, зелёное ревью `docs/reviews/SPEC-REVIEW-500-r3.md` на `622470ff`; дельта r2→r3 файл
ТЗ не трогает
## Вердикт
Единственная находка r2 (Medium-1) закрыта содержательно и подтверждена независимо: CI поймал новый мутант на смоке-свидетеле, я лично
прогнал смок трижды (зелёный) и воспроизвёл красную пробу без фикса (совпадает дословно с диагнозом r2). Новых High/Medium/Low в дельте
не найдено. AC4 — последний ранее не полностью закрытый пункт — подтверждён полностью. Зелёный.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/500-config-adoption-boundary`, коммит `be6d57e96f42` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `b756dee77c425861ccf68d44c06aa57a65336a78`
```
git log --all --format='%H %T' | grep b756dee77c42
```
- Тело issue: `d4a68d8c756302bcc7b8ec4bf3a7f77ffc88848e51f2b054d58736bfda431ec5`
- ТЗ `docs/specs/500-config-adoption-boundary.md`, блоб `b7b4f02c6ca5a57092a360f1f664656b62ed968c`
```
git log --all --find-object=b7b4f02c6ca5a57092a360f1f664656b62ed968c -- docs/specs/500-config-adoption-boundary.md
```
- Вердикт конвейера: `green` · High 0
+74
View File
@@ -0,0 +1,74 @@
# CODE-REVIEW — issue #502, заход r1
**Материал:** `441d3cc3de3dcf76446e4e914b72c16671494f83` (единственный коммит поверх `origin/dev`; ветка приведена к `dev` конвейером — до ребейза было `1d581fb4`, после — другой код, разбор ниже полный, не по дельте, как и предписано при таком переносе).
**Класс изменения:** B (гейты/тестовая инфраструктура) — `test/i18n-dead-keys.test.mjs` (правка) + `test/helpers/i18n-consumers.mjs` (новый файл). Продуктовый код (`src/**`) не тронут. `User-Visible: no`.
## Скоуп
Задача сужает критерий «динамический потребитель i18n-ключа» в гейте `test/i18n-dead-keys.test.mjs`: конкатенация/шаблонная строка признаётся потребителем семьи ключей только если её статическая часть содержит `.` (разделитель `namespace.key`) и хотя бы одну букву. До правки `'r' + Date.now().toString(36)` превращался в `^r.+$` и маскировал реально мёртвые ключи на «r» (обнаружено на `radar.bad_references`, #485). Пользователь ничего не наблюдает — гейт защищает переводчика/владельца от накопления неиспользуемых переводов.
## Как проверялось
- Прочитан диф целиком (`git diff origin/dev...HEAD`, 2 файла, +279/−68) и оба файла построчно.
- Прочитано тело issue #502 целиком (проблема, ТЗ, контракт п.1–5, «принято предположительно», зависимости, таблица AC, откат) и все комментарии (S2 → S4-spec-review → зелёный вердикт спек-ревью r1 → кандидат реализации → красный Validate по внешней причине → возврат в S7 с объяснением).
- Дешёвые гейты **не перегонялись повторно** — Validate на точном материале ревью (`441d3cc3`) зелёный: https://github.com/Matysh/houseplan-card/actions/runs/34445317690 (`headSha` подтверждён `gh run view`, `conclusion: success`). Job «Фронтенд: типы, юниты, мутанты, синхрон бандла» — success, три job'а «Мутанты по диффу» — success (мутант `i18n-dead-key-returns` пойман). HACS/hassfest/backend/smoke/golden/perf — `skipped`, что корректно: диф не затрагивает пути, от которых они зависят.
- Тем не менее прогнан локально сам изменённый тестовый файл — это дешево и напрямую доказывает AC1–AC4:
`node --test test/i18n-dead-keys.test.mjs` → 9/9 green, диагностика: `discarded 821 dynamic patterns; keys covered only by discarded patterns: none`.
- `npx tsc --noEmit -p tsconfig.test.json` — чисто (новый `.mjs`-helper не ломает тестовый typecheck).
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → «Исполняемого frontend-диффа нет (src/**/*.ts не тронут). Browser-smoke этим диффом не выбираются». Подтверждает, что смоки/golden/check-docs здесь не нужны — диф не трогает `src/**`.
- Проверено чтением: `radar.bad_references` действительно используется как строковый литерал в `src/radar-setup.ts:334` (зависимость из раздела «Зависимости» ТЗ уже закрыта, #485 закрыт) — значит на реальном дереве список `DYNAMIC_KEY_FAMILIES` пуст обоснованно, а не по недосмотру.
- Трейлеры коммита проверены: `Issue: #502`, `User-Visible: no` — корректно, changelog не тронут (`User-Visible: no` не требует правки обоих changelog), проверено `git diff --stat` по путям `docs/CHANGELOG*`.
## Проверка контракта и AC по коду
| # | Контракт/AC | Где выполнено | Проверено |
|---|---|---|---|
| п.1/AC1 | Литерал без точки + выражение — не потребитель | `isKeyShapedPattern` (`i18n-consumers.mjs:66-68`) требует `staticText.includes('.')` и `/[A-Za-z]/` | юнит-тест `AC1` зелёный; вручную прослежена трассировка для `'r' + Date.now()...`, `` `${a}b` ``, `'x' + id` — везде `staticText` не содержит `.` |
| п.2 | Паттерн вида `^r.+$` невозможен по построению | требование точки в `isKeyShapedPattern` устраняет этот класс паттернов целиком | прочитано, логически подтверждено: без `.` в статике `isKeyShapedPattern` всегда false |
| AC2 | `` `radar.${code}` ``, `prefix + '.title'`, `` `${ns}.aria` `` остаются потребителями | тест `key-shaped joins keep consuming their families (#502 AC2)` | зелёный, плюс негативные проверки `doesNotMatch('room.name', …)`, `doesNotMatch('x.titles', …)` — паттерн не расползается |
| AC3 | Мёртвый ключ ловится даже когда id-генератор делит первую букву | тест `a dead key is reported even when an id generator shares its first letter (#502 AC3)` на синтетическом AST/словаре | зелёный: `unusedKeys` → `['probe.dead']`, `discarded.length === 1` |
| п.3/AC4 | `DYNAMIC_KEY_FAMILIES` — по записи на семью, пустая причина/непокрытый паттерн — красный тест | `familyProblems` (`i18n-consumers.mjs:150-165`) + тест `every declared dynamic key family...` + `an explicit family rescues a data-driven key... (#502 AC4)` (4 варианта дефектов) | зелёный на всех 4 синтетических дефектах; на реальном дереве `DYNAMIC_KEY_FAMILIES = []` — подтверждено `node --test`: основной тест «every i18n key has a … consumer» зелёный, т.е. 0 непокрытых ключей и семьи не нужны |
| п.4 | Реально мёртвые ключи не маскируются, не подсовываются в семьи ради зелёного гейта | `radar.bad_references` теперь потребляется литералом в `src/radar-setup.ts:334`; список семей пуст, а не дополнен фиктивной записью под этот ключ | подтверждено чтением `src/radar-setup.ts:334` и всех 4 словарей |
| п.5 | Сообщение об ошибке не изменено по форме | `test/i18n-dead-keys.test.mjs`, строка с `Unused i18n keys: ...` — текст идентичен версии на `dev` | сверено дифом — да, не тронуто |
| AC5 | Число отброшенных паттернов и список ключей «только на отброшенном» — записаны | `narrowingReport` печатает через `t.diagnostic`; автор привёл в issue-комментарии реализации точный вывод (`discarded 821 …, none`) | воспроизведено локально: тот же вывод — `discarded 821 dynamic patterns; keys covered only by discarded patterns: none` |
Отдельно проверено «принятое предположительно» (`` `${a}.${b}` ``, `a + '.' + b` — не потребители, т.к. статика — одна точка без буквы, что дало бы `^.+\..+$`, совпадающий с любым ключом): прослежена ручная трассировка построения `staticText` для обоих случаев — действительно даёт `'.'` без буквы, что и требуется отбросить по контракту п.2 (иначе паттерн вида «пустой хвост + точка + пустой хвост» был бы новой версией того же дефекта `^r.+$`, только в другой форме). Решение корректно и в скоупе задачи.
## Что проверено и корректно
- Диф ограничен `test/**`, что и требовал контракт (класс B, один файл + опциональный helper — использован).
- Чистые функции (`expressionPattern`, `isKeyShapedPattern`, `collectConsumers`, `unusedKeys`, `familyProblems`, `narrowingReport`) действительно чистые, без чтения `src/**`/словарей — юнит-тесты гоняют их на синтетическом AST, как и требовало «принято предположительно» ТЗ.
- Мутационный гейт (Validate, «Мутанты по диффу», 3/3 success) подтверждает, что тесты не декоративны: дисциплина «тест должен уметь падать» удовлетворена независимым инструментом, а не только моим прочтением.
- Зависимость на #485 (`radar.bad_references`) действительно разрешена до реализации, в правильном порядке, как и требовал раздел «Зависимости».
- Трейлеры, класс изменения, отсутствие правок в `dist/**`/changelog — всё соответствует `User-Visible: no`.
## Чего не проверял и почему
- **Полный `npm test`, `npx tsc --noEmit` (весь проект), `npm run build` со сверкой бандла** — не перегонял: Validate уже зелёный на точном SHA `441d3cc3` (ссылка выше), и диф не может задеть сборку (тестовый файл не входит в бандл).
- **Смоки, golden, performance, pytest** — не прогонял: `smoke-select.mjs` прямо говорит «Browser-smoke этим диффом не выбираются», диф не трогает `src/**`, `custom_components/**/*.py`, стили или рендер. Golden/check-docs также не нужны по той же причине (нет изменений видимого результата).
- **Инварианты модели (`npm run invariants`)** — не запускал: диф не касается геометрии, `layout`, `marker.space`, `open_spans`, толщины стен.
- **Ручной прогон полного `node --test` по всему `test/**`** — не делал отдельно (кроме изменённого файла): это покрыто job'ом «Фронтенд» в уже зелёном Validate.
## Находки
Нет. High: 0, Medium: 0, Low: 0.
## Вывод
Контракт (п.1–5), все пять AC и «принятое предположительно» выполнены и подтверждены как автотестом (девять юнитов в изменённом файле, включая мутационную проверку в CI), так и чтением продуктового кода в точке, где решается судьба конкретного ранее мёртвого ключа (`src/radar-setup.ts:334`). Диф строго ограничен заявленным классом B, трейлеры корректны, откат тривиален (задача сама это утверждает, и это подтверждается: изменения лежат в двух тестовых файлах без побочных эффектов на продукт).
Вердикт: зелёный.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/502-i18n-dead-keys-narrow`, коммит `441d3cc3de3d` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `5d0db6767239964f0c804f9212d5b038fca0dd3d`
```
git log --all --format='%H %T' | grep 5d0db6767239
```
- Вердикт конвейера: `green` · High 0
+123
View File
@@ -0,0 +1,123 @@
# CODE-REVIEW-508-r1
Issue: #508 — «Сводная панель: в диалоге настроек не работает прокрутка — колесом мыши и touch на мобильных»
Материал: `768e1281f9119b12d018b416372984ff17eae62d` (ветка `issue/508-summary-dialog-scroll`, ребейзнута конвейером на `dev`: `7e5d394a → 768e1281`, +2 коммита dev).
Заход: r1 · блокирующих циклов израсходовано 0 из 2. Ребейз довёл ветку до другого дерева (§7.2) → разбор полный.
## Скоуп
Один коммит, класс A (`src/hp-dialog.ts`, `src/summary-panel-editor.ts`) + класс B (новый смок,
контрактный юнит-тест, два мутанта) + класс C (`docs/ARCHITECTURE.md`, оба CHANGELOG) + класс D
(пересобранный бандл во всех трёх копиях, `docs/images/screenshots.json` — только фингерпринт/`sourceSha256`,
`imageSha256` не изменились, что соответствует заявленному «11/11 попиксельно»).
Правка ровно та, что описана в ТЗ (docs зафиксированы в теле issue, `SPEC-REVIEW-508-r1.md` →
`SPEC-REVIEW-508-r2.md`, вердикт зелёный): `hp-dialog` получает opt-in булев атрибут `flex-content`
(`flexcontent` на `ha-dialog`, обе HA-ветки рендера — с `described-by` и без), диалог настроек сводной
панели его объявляет. Нативная ветка (`<dialog>`) не тронута ни строкой.
**J1** (`docs/SCOPE.md`) — обзорный диалог сводной панели входит в «show the whole home at a glance»;
это чистый баг-фикс регресса после #505, никакой новой функциональности и расширения скоупа нет.
## Как проверялось
Дешёвые гейты на этом SHA уже подтверждены Validate
(https://github.com/Matysh/houseplan-card/actions/runs/34395116095, `headSha=768e1281`, job
«Фронтенд: типы, юниты, мутанты, синхрон бандла» — success). Тем не менее часть из них прогнана
повторно вживую, потому что дёшево и диф трогает `src/**` (общий компонент `hp-dialog`, используемый
девятью другими вызывающими).
| Гейт | Прогнал | Результат |
|---|---|---|
| `npx tsc --noEmit` + `rollup` (`npm run bundle:sync`) | да | сборка чистая; пересобранный `dist/` и обе копии (`custom_components/.../frontend`, `demo/srv/assets`) байт-в-байт совпали с закоммиченными — `git status` после пересборки пуст |
| `npm test` | да | 2437 тестов, 2436 pass, 1 skip (известный `smoke_opening_measure` не запускался тут — не относится к юнитам), 0 fail |
| `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | да | «Новых any нет» (10 добавленных строк в 2 файлах) |
| `node scripts/check-docs.mjs` (диф трогает `src/**`) | да | «7 files, 12 external links» — зелёный, `sourceFingerprint` в `screenshots.json` уже обновлён коммитом |
| `npm run bundle:budget` | да | initial View 298979 B (потолок 299600±2000); предупреждение о запасе <15000 Б — существующий проектный долг (#367/#474), эта задача добавляет один атрибут разметки и не сдвигает бюджет заметно; не находка этой задачи |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | да | «НЕОПРЕДЕЛЁННОСТЬ» — 2 файла, 0 символов на изменённых строках, порог «широкого» не достигнут. Решение ревьюера ниже |
| `node demo/smoke_summary_dialog_scroll.mjs` (AC1/AC2, новый) | да | `OK`, все 14 проверок зелёные (desktop wheel/footer, phone touch/viewport, witness без `flexcontent`) |
| `node demo/smoke_summary_panel_polish.mjs` (AC3, прямое совпадение — единственный смок диалога панели) | да | `OK`; файл смока не менялся (`git diff` пуст) — «без правок его ожиданий» выполнено |
| `node demo/smoke_dialog_modal_recovery.mjs`, `node demo/smoke_esc_dialogs.mjs`, `node demo/smoke_dialog_footer_width.mjs` (зарегистрированная связь: общий `hp-dialog`, фокус/эскейп/футер) | да | все три `OK` |
| `node scripts/mutation-gate.mjs --id=summary-dialog-drops-flex-content` | да | «чистый прогон» → зелёный, мутация → «тест покраснел, как обязан», поймано 1 из 1 |
| `node scripts/mutation-gate.mjs --id=hp-dialog-ignores-flex-content` | да | то же, поймано 1 из 1 |
| in-memory негативная проба AC4 (`test/hp-dialog-contract.test.mjs`, добавление `flex-content` в `hp-confirm.ts` только в памяти интерпретатора, без записи в дерево) | да | ассерт `=== 1` дал бы 2 → тест обязан покраснеть |
**Не прогонял:** полный `golden:verify`, `python -m pytest tests_backend`, полную матрицу смоков (237 шт.),
performance-профили.
- `golden` — диф не меняет ни один существующий рендер: атрибут opt-in, `?flexcontent=` в Lit убирает
атрибут целиком при `false`, поэтому разметка всех прочих `hp-dialog` не меняется ни на байт; golden
снимается в demo-стенде, где вообще используется нативная ветка (`<dialog>`), которую диф не трогает.
golden-сцен с префиксом `summary` в репозитории нет (снято ещё в SPEC-REVIEW r1 M1, AC3 переписан на
единственный смок). Риск нулевой — не прогонял намеренно, а не пропустил.
- `pytest tests_backend` — диф не касается `custom_components/houseplan/**/*.py` вовсе.
- Полная матрица смоков и перф-профили — не названы в AC; `smoke-select` вернул «НЕОПРЕДЕЛЁННОСТЬ», выбор
сузил вручную до прямого совпадения (диалог панели) и зарегистрированных связей (общий `hp-dialog`:
восстановление модального стека, Escape-стек, ширина футера). Остальные 231 смока к этому диффу не
привязаны сильнее «общий компонент» и по правилу прогона не требуют.
## AC → доказательство → чем краснеет
| AC | Доказано | Чем краснеет |
|---|---|---|
| AC1 (HA-ветка: колесо крутит `.summary-editor`, шапка/футер неподвижны) | `demo/smoke_summary_dialog_scroll.mjs` сценарий (a), прогнан лично — `OK` | оба мутанта `summary-dialog-drops-flex-content` и `hp-dialog-ignores-flex-content`, прогнаны лично: «покраснел, как обязан» |
| AC2 (touch 390×844 крутит редактор; диалог ≤ вьюпорта; футер доступен) | сценарий (b), прогнан лично — `OK` | те же два мутанта (общий guard — атрибут) |
| AC3 (нативная ветка не меняется, `smoke_summary_panel_polish` зелёный без правок ожиданий) | прогнан лично — `OK`; `git diff` файла смока пуст | не защитный AC (сравнение факта с ожиданием, не guard) — правило §2.7 не требует красноты |
| AC4 (прочие `hp-dialog` без `flex-content` не меняют раскладку) | `test/hp-dialog-contract.test.mjs` (в составе `npm test`, прогнан лично) + чтение: `?flexcontent=` в Lit опускает атрибут при `false`, значит разметка девяти прочих вызывающих идентична | in-memory негативная проба (см. выше, без записи в дерево): подмена показала бы `optIns.length === 2` → ассерт `=== 1` красный |
| AC5 (оба мутанта пойманы штатным раннером) | сами прогоны мутантов, см. выше | это и есть доказательство; вторичной пробы не нужно |
| AC6 (`User-Visible: yes`, оба changelog, i18n/модель/перф не задеты) | `git diff` коммита: `docs/CHANGELOG.md` + `docs/CHANGELOG.ru.md` в том же коммите; ни один `translations/**`/`i18n` файл не в диффе; в `src/hp-dialog.ts` нет новых обработчиков — только проброс булева атрибута в существующий рендер | не защитный AC (перечисление фактов), доказано чтением диффа |
## Что проверено и корректно
- Обе HA-ветки рендера (`describedBy` и без) получили `?flexcontent=${this.flexContent}` — прочитано в
`src/hp-dialog.ts:515,531`, подтверждено юнит-тестом и мутантом.
- Нативная `<dialog>`-ветка (`_usesHaDialog()` false) не содержит ни одного упоминания
`flexcontent`/CSS-селектора на `:host([flex-content])` — сброс атрибута не имеет побочных эффектов там,
где используется golden/demo.
- Диагноз (scroll chaining обрывается на height-unbound `overflow:auto + overscroll-behavior:contain`)
independently подтверждён автором на живом стенде HA 2026.9.1 (`ha-dialog.flexContent = true` →
`.summary-editor` 477/819, колесо работает) — это сильнее, чем воспроизведение только в стабе; стаб
смока копирует контракт `.body{overflow:auto}` + `:host([flexcontent]) .body{display:flex}`, о котором
говорит и уже существующий комментарий `demo/capture_summary_panel_505.mjs` (#505).
- `docs/TOUCH-SUPPORT.md:48` прямо называет «simple settings form» сводной панели гарантированной View-
touch-поверхностью — включение touch в скоуп задачи не расширение, а обязательное соответствие контракту.
- Терминология changelog («сводной панели», «настройки») совпадает с `docs/USER-GUIDE.ru.md:264`.
- `docs/images/screenshots.json`: только `sourceFingerprint`/`sourceSha256` изменились, все `imageSha256`
идентичны — соответствует заявленному в хендоффе `docs:accept --identical` (11/11 пиксель-в-пиксель, первое
боевое использование #512).
- Один коммит, трейлеры `Issue: #508` и `User-Visible: yes` на месте; `Release:`/`Baseline-Reviewed:` не
требуются — `demo/golden/baselines/**` не тронут.
- Скоуп не расширен: единственный вызывающий (`summary-panel-editor.ts`) объявляет атрибут, остальные восемь
— нет (проверено и тестом, и построчным чтением).
## Находки
Нет. High: 0, Medium: 0, Low: 0.
## Унаследовано из ревью ТЗ (для полноты трассировки)
Спек-ревью прошло два раунда (`SPEC-REVIEW-508-r1.md` → жёлтый, `SPEC-REVIEW-508-r2.md` → зелёный на
SHA `f02af9330b17a37eea6c8086f4335cbbc7265d36`): M1 (AC3 ссылался на несуществующий golden-префикс
`summary-*`) и M2 (не было раздела «откат») закрыты правкой тела issue. Код-ревью это не переоткрывает —
AC3 в теле issue на момент код-ревью уже опирается только на `smoke_summary_panel_polish`, а раздел
«Откат» присутствует и соответствует факту (opt-in атрибут, ревёрт коммита без миграций).
## Вердикт
Зелёный. Все AC доказаны исполнением (кроме AC3/AC6, не заявляющих защиты — доказаны прямым сравнением),
диагноз независимо подтверждён на реальном стенде HA, оба защитных мутанта лично прогнаны и красные без
патча/зелёные с ним, регресс на нативную ветку и остальные восемь `hp-dialog` исключён и тестом, и
построчным чтением кода. Находок нет.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/508-summary-dialog-scroll`, коммит `7e5d394a936a` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `1594677359b610e22fc5886db02f66bc3c943322`
```
git log --all --format='%H %T' | grep 1594677359b6
```
- Вердикт конвейера: `green` · High 0
+193
View File
@@ -0,0 +1,193 @@
# CODE-REVIEW-508-r2
Issue: #508 — «Сводная панель: в диалоге настроек не работает прокрутка — колесом мыши и touch на мобильных»
Материал: `6ac513caa09a8e583869879412aa3a0c67d04488` (рабочая копия уже на нём). Ветка приведена конвейером к
`dev` до ревью: `e23d742c → 6ac513ca`, поверх легло 1 коммит `dev` (несвязанный — `docs: review document
for #502`).
Заход: r2 · блокирующих циклов израсходовано 0 из 4. Ребейз на ушедший вперёд `dev` формально делает
дерево другим (§7.2) → разбор полный, а не по дельте.
## Почему вообще r2, если r1 был зелёным без находок
Код-ревью r1 (`docs/reviews/CODE-REVIEW-508-r1.md`, материал `768e1281`) уже завершилось вердиктом
«зелёный, находок нет» с исчерпывающим личным прогоном гейтов, мутантов, смоков и подтверждением автора
на живом стенде HA. Сразу после публикации вердикта автор сообщил в issue: «Автоматическое ревью не
отработало» — статусная метка не переставилась, конвейер оставил задачу на месте. Затем конвейер
перебазировал ветку на успевший уйти вперёд `dev` (там landing получил несвязанный коммит с ревью-доком
для issue #502) и передал её на повторное ревью уже под новым SHA. Автор с момента r1 в код и ТЗ не
вносил ничего — это не возврат на правки, а техническое повторение ревью из-за сбоя механики метки плюс
ребейз. Поэтому раздела «находка → возврат → правка» в этом раунде по существу нет: проверка ниже —
подтверждение, что ребейз не изменил и не сломал ничего, а не разбор чьих-то правок.
## Скоуп
Не изменился с r1: один функциональный коммит (`e38beed7`, экс-`768e1281`) — класс A (`src/hp-dialog.ts`,
`src/summary-panel-editor.ts`) + класс B (новый смок `demo/smoke_summary_dialog_scroll.mjs`, контрактный
юнит-тест `test/hp-dialog-contract.test.mjs`, два мутанта в `scripts/mutation-gate.mjs`) + класс C
(`docs/ARCHITECTURE.md`, оба CHANGELOG) + класс D (пересобранный бандл во всех копиях,
`docs/images/screenshots.json` — только фингерпринт). `hp-dialog` получает opt-in булев атрибут
`flex-content` (`?flexcontent` на `ha-dialog`, обе HA-ветки рендера); нативная `<dialog>`-ветка не тронута.
Диалог настроек сводной панели его объявляет — единственный из десяти вызывающих `<hp-dialog>`.
J1 (`docs/SCOPE.md`) — обзорная сводная панель входит в «show the whole home at a glance»; чистый
баг-фикс регресса после #505, скоуп не расширен, продукт не меняет договор.
## Как проверялось (это не дельта — это независимая перепроверка «ничего не изменилось»)
Первый шаг — не поверить заявлению «ребейз ничего не тронул», а доказать его:
```
git diff 768e1281 e38beed7 --stat # r1-материал vs текущий фикс-коммит
```
Результат: единственная разница — файл `docs/reviews/SPEC-REVIEW-502-r1.md` (несвязанный коммит `dev` для
issue #502). Все файлы продукта — `src/hp-dialog.ts`, `src/summary-panel-editor.ts`,
`test/hp-dialog-contract.test.mjs`, `docs/ARCHITECTURE.md`, оба CHANGELOG, `scripts/mutation-gate.mjs`,
`demo/smoke_summary_dialog_scroll.mjs`, весь пересобранный бандл — побайтово идентичны материалу r1.
`docs/images/screenshots.json` проверен построчно (`grep imageSha256` на диффе): изменились только
`sourceFingerprint`/`sourceSha256`-контекст, ни один `imageSha256` не сдвинулся.
Это и определило объём: код доказанно тот же, что уже получил зелёный вердикт с личными прогонами гейтов,
мутантов и смоков (не только заявлением автора — заявление в r1 тоже было перепроверено исполнением). Все
выводы AC1–AC6 из r1 остаются в силе автоматически, потому что применяются к тому же байт-в-байт коду. Тем
не менее раунд объявлен «полным» (ребейз, §7.2), поэтому ниже — не просто ссылка на r1, а собственный
прогон части гейтов на текущем SHA `6ac513ca`, чтобы исключить эффекты самого ребейза (окружение сборки,
конфликт слияния бандла и т. п.), а не только идентичность текста диффа.
### Дешёвые гейты
Validate на `6ac513ca` зелёный (дано в задаче ревью, https://github.com/Matysh/houseplan-card/actions/runs/34397342070)
— `tsc`, `npm test` (2436/2436), `npm run build` со сверкой бандла уже подтверждены конвейером на этом
самом SHA, повторно не гонял.
### Прогнано лично на `6ac513ca` (не на материале r1 — на текущем SHA)
| Гейт/смок | Результат |
|---|---|
| `node --test test/hp-dialog-contract.test.mjs` (AC1 текстовая часть, AC4) | 3/3 pass |
| `node scripts/check-docs.mjs` (диф трогает `src/**`) | «Documentation checks passed (7 files, 12 external links)» — совпадает с числом из r1 |
| `node scripts/bundle-sync.mjs` (пересборка + раскладка по трём местам: `dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) | выполнено; `git status --porcelain` после — пусто (отслеживаемые копии не разошлись с коммитом) |
| `node demo/smoke_summary_dialog_scroll.mjs` (AC1/AC2, прямое совпадение) | `OK` |
| `node demo/smoke_summary_panel_polish.mjs` (AC3, прямое совпадение — файл смока не менялся) | `OK` |
| `node demo/smoke_dialog_modal_recovery.mjs` (зарегистрированная связь: общий `hp-dialog`) | `OK` |
| `node demo/smoke_esc_dialogs.mjs` (зарегистрированная связь) | `OK` |
| `node demo/smoke_dialog_footer_width.mjs` (зарегистрированная связь) | `OK` |
| `node scripts/mutation-gate.mjs --id=summary-dialog-drops-flex-content` | чистый прогон OK → мутация: «тест покраснел, как обязан» → поймано 1 из 1 |
| `node scripts/mutation-gate.mjs --id=hp-dialog-ignores-flex-content` | то же → поймано 1 из 1 |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «НЕОПРЕДЕЛЁННОСТЬ» — 2 файла `src/**`, 0 символов на изменённых строках (тот же результат, что в r1); выбор смоков выше сделан вручную: прямое совпадение (диалог панели) + зарегистрированные связи (общий `hp-dialog`) |
| `grep -rn "<hp-dialog" src/` | 10 вызывающих (включая `summary-panel-editor.ts`); ровно список из `test/hp-dialog-contract.test.mjs` AC4, opt-in — один |
Изначально попытка запустить браузерные смоки провалилась ошибкой окружения
(`Failed to fetch dynamically imported module: http://demo.local/...`, `page.waitForFunction` timeout) —
это была не регрессия, а отсутствие локальной копии `demo/srv/assets` (она не хранится в репозитории,
`scripts/bundle-sync.mjs`: «копий две с половиной», рабочая копия стенда собирается заново). Тот же
таймаут воспроизводился на `demo/smoke_summary_panel_polish.mjs`, файл которого этот диф не трогает вовсе
— это подтвердило, что причина в окружении ревью-песочницы, а не в коде. После `node
scripts/bundle-sync.mjs` (пересборка из уже собранного `dist/`) оба смока пошли зелёными.
## Закрытие раунда r1
Код-ревью r1 не имело находок (High 0, Medium 0, Low 0) — блокирующих находок для закрытия нет. Раунд r2
открылся не как возврат на правки, а как техническое повторение из-за несработавшей автоматической
перестановки метки после зелёного вердикта r1, дополненное последующим ребейзом на ушедший вперёд `dev`
(конвейер, не автор). Автор кода/ТЗ с момента r1 не менял — независимо доказано выше диффом
`768e1281..e38beed7`.
| Находка r1 | Статус | Где видно |
|---|---|---|
| (нет находок) | — | `docs/reviews/CODE-REVIEW-508-r1.md`, раздел «Находки»: «Нет. High: 0, Medium: 0, Low: 0» |
## Унаследовано из r1
Без повторного самостоятельного вывода, но с проверкой байт-в-байт идентичности кода (см. «Как
проверялось»), наследую из `docs/reviews/CODE-REVIEW-508-r1.md` (материал `768e1281f9119b12d018b416372984ff17eae62d`,
дерево `1594677359b610e22fc5886db02f66bc3c943322`):
- диагноз (scroll chaining обрывается на height-unbound `.summary-editor` под `ha-dialog`, `overflow:
auto` + `overscroll-behavior: contain`) — независимо подтверждён автором на живом стенде HA 2026.9.1, не
только в стабе смока;
- личные прогоны `tsc`, `npm test` (2437/2436 pass+1 skip), `no-new-any`, `bundle:budget` на материале
r1 — не переигрывал повторно, т. к. Validate CI зелёный на `6ac513ca` покрывает те же проверки на
байт-в-байт идентичном коде;
- вывод AC6 (i18n/модель/перф не задеты, трейлеры `Issue:`/`User-Visible: yes` и оба CHANGELOG в одном
коммите) — перепроверен построчно, см. «AC → доказательство» ниже, поэтому это скорее «переподтверждено»,
чем слепо унаследовано;
- трассировку к спек-ревью (`SPEC-REVIEW-508-r1.md` жёлтый → `SPEC-REVIEW-508-r2.md` зелёный на SHA
`f02af9330b17a37eea6c8086f4335cbbc7265d36`, M1/M2 закрыты правкой тела issue) — код-ревью r1 её не
переоткрывало, r2 тоже не переоткрывает: текст issue с тех пор не менялся.
## AC → доказательство → чем краснеет (переподтверждено лично на `6ac513ca`)
| AC | Доказано | Чем краснеет |
|---|---|---|
| AC1 (HA-ветка: колесо крутит `.summary-editor`, шапка/футер неподвижны) | `demo/smoke_summary_dialog_scroll.mjs` сценарий (a) — `OK`, лично | оба мутанта, лично: «покраснел, как обязан» |
| AC2 (touch 390×844 крутит редактор; диалог ≤ вьюпорта; футер доступен) | сценарий (b) — `OK`, лично | те же два мутанта |
| AC3 (нативная ветка не меняется) | `smoke_summary_panel_polish` — `OK`, лично; файл смока не в диффе | не защитный AC |
| AC4 (прочие 9 `hp-dialog` без `flex-content`) | `test/hp-dialog-contract.test.mjs` — 3/3 pass, лично; `grep` по `src/**` — 10 вызывающих, 1 opt-in | ассерт `=== 1` в тесте (in-memory проба r1 сохраняет силу — код теста не менялся) |
| AC5 (оба мутанта пойманы) | оба мутанта лично прогнаны — «поймано 1 из 1» каждый | это и есть доказательство |
| AC6 (`User-Visible: yes`, оба changelog, i18n/модель/перф не задеты) | `git show --stat e38beed7`: `docs/CHANGELOG.md`+`docs/CHANGELOG.ru.md` в том же коммите; `git log -1` коммита содержит `Issue: #508` и `User-Visible: yes`; ни один `i18n`/`translations` файл не в диффе; `src/hp-dialog.ts` diff — только проброс булева атрибута, новых обработчиков нет | не защитный AC, доказано чтением |
## Одно число — один источник
Диф не вводит и не дублирует ни одну видимую пользователю величину (площадь, значение, подпись): меняется
только внутренняя раскладка скролл-контейнера через булев атрибут. `test/single-source-numbers.test.mjs`
не затрагивается и не требуется для этой правки.
## Что проверено и корректно
- Обе HA-ветки рендера (`describedBy` и без) несут `?flexcontent=${this.flexContent}` — строки
`src/hp-dialog.ts:515,531`, подтверждено и тестом, и мутантами лично.
- Нативная `<dialog>`-ветка не содержит `flexcontent`/связанных селекторов — регресс на неё исключён и
тестом (`smoke_summary_panel_polish`), и построчным чтением.
- `docs/TOUCH-SUPPORT.md:48–49` называет «simple settings form» сводной панели гарантированной View-touch-
поверхностью — включение touch в скоуп не расширение, а обязательное соответствие контракту.
- Терминология CHANGELOG.ru.md («сводной панели», «настроек») совпадает с `docs/USER-GUIDE.ru.md:264`
(«Настройки сводной панели»).
- `docs/images/screenshots.json`: только `sourceFingerprint`/`sourceSha256`; все 11 `imageSha256`
идентичны — докс-скриншоты не изменились по существу.
- Один коммит (`e38beed7`), трейлеры `Issue: #508` и `User-Visible: yes` на месте.
- После `bundle-sync` рабочая копия чиста (`git status --porcelain` пуст) — три копии бандла (`dist`,
`custom_components/houseplan/frontend`, `demo/srv/assets`) согласованы с закоммиченным.
- Ребейз сам по себе не внёс никакого продуктового изменения — доказано диффом, а не заявлено.
## Находки
Нет. High: 0, Medium: 0, Low: 0.
## Чего не проверял и почему
- `npm run golden:verify` — атрибут opt-in, `?flexcontent=` в Lit убирает атрибут целиком при `false`,
разметка прочих `hp-dialog` не меняется ни на байт; golden-сцен диалога сводной панели в репозитории нет
(снято ещё в `SPEC-REVIEW-508-r1.md` M1). Риск нулевой, решение то же, что в r1, переподтверждено тем же
рассуждением на идентичном коде.
- `python -m pytest tests_backend` — диф не касается `custom_components/houseplan/**/*.py`.
- Полная матрица смоков (237 шт.) и перф-профили — не названы в AC; `smoke-select` вернул
«НЕОПРЕДЕЛЁННОСТЬ» (2 файла `src/**`, 0 символов на изменённых строках, порог «широкого» — 47 смоков).
Выбор сужен вручную до прямого совпадения (`smoke_summary_dialog_scroll`, `smoke_summary_panel_polish`) и
зарегистрированных связей на общий `hp-dialog` (`smoke_dialog_modal_recovery`, `smoke_esc_dialogs`,
`smoke_dialog_footer_width`) — все пять прогнаны лично, все зелёные.
- `npm run invariants` — диф не трогает геометрию/рёбра комнат/`layout`/`marker.space`/`open_spans`, гейт не
применим.
- Полный `npm test` (все 2436 юнитов) — не переигрывал повторно вживую: Validate CI уже подтвердил его
зелёным на этом самом SHA `6ac513ca` (ссылка выше), а не на другом; вместо повторного полного прогона
лично выполнен целевой `test/hp-dialog-contract.test.mjs`.
## Вердикт
Зелёный. Разбор — полный (ребейз, §7.2), но не «с нуля»: доказано диффом, что код байт-в-байт идентичен
уже одобренному материалу r1, и это доказательство переподтверждено собственными прогонами гейтов, смоков
и мутантов на текущем SHA `6ac513ca`, а не принятием чужого вердикта на веру. Находок нет. Раунд не
образует цикл (§4) — вердикт зелёный.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/508-summary-dialog-scroll`, коммит `e23d742cd508` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `5fc02b5d1de102ed9770f28fc5eb3a41098cbc11`
```
git log --all --format='%H %T' | grep 5fc02b5d1de1
```
- Вердикт конвейера: `green` · High 0
+206
View File
@@ -0,0 +1,206 @@
# CODE-REVIEW-508-r3
Issue: #508 — «Сводная панель: в диалоге настроек не работает прокрутка — колесом мыши и touch на мобильных»
Материал: `be93291065a54f8152d0f1945bde973c10613d36` (рабочая копия уже на нём).
Заход: r3 · блокирующих циклов израсходовано 0 из 2 (лёгкий трек — лимит код-ревью общий, не «2»
для спека; счётчик циклов см. ниже).
## Почему вообще r3, если r1 и r2 уже были зелёными без находок
`git log --oneline origin/dev..HEAD`:
```
be932910 docs: review document for #508
6ac513ca docs: review document for #508
e38beed7 fix: the summary panel settings dialog scrolls in Home Assistant
```
Ровно три коммита поверх `origin/dev`, ребейза с момента r2 не было (в отличие от r1→r2, где ветку
перебазировали на ушедший вперёд `dev`). Один функциональный коммит — `e38beed7` (уже проверенный
в r1 и переподтверждённый байт-в-байт в r2). Два следующих — `6ac513ca` и `be932910` — не что иное
как публикация конвейером собственных документов предыдущих раундов ревью:
`6ac513ca` добавляет `docs/reviews/CODE-REVIEW-508-r1.md` (123 строки), `be932910` добавляет
`docs/reviews/CODE-REVIEW-508-r2.md` (193 строки). Ни один из них не трогает ни строки продукта.
Материалом r2 был явно назван `6ac513caa09a8e583869879412aa3a0c67d04488`
(`docs/reviews/CODE-REVIEW-508-r2.md:4`). Дельта r2→r3:
```
git diff 6ac513ca..be932910 --stat
docs/reviews/CODE-REVIEW-508-r2.md | 193 +++++++++++++++++++++++++++++++++++++
1 file changed, 193 insertions(+)
```
Это ровно случай, описанный в `AGENTS.md` («Do not merge into `dev` by hand»,
абзац про #499): «When the only difference from the reviewed material is the pipeline's own
review-document commit, the next run re-applies the green verdict without calling the model».
Формально этот раунд не должен был доходить до модели вообще — но, как и после r1 (см. комментарии
issue: «Автоматическое ревью не отработало… Статусная метка не менялась»), автоматическая
перестановка метки/повторное применение вердикта снова не сработало, и модельное ревью запущено
повторно. Это техническое повторение уровня механики конвейера, а не возврат на правки: автор с
момента r1 не менял ни кода, ни ТЗ.
Раздела «находка → возврат → правка» в этом раунде по существу нет — ниже задокументирована
проверка того, что дельта действительно пуста по продукту, а не принята на слово.
## Скоуп
Не изменился с r1/r2: один функциональный коммит `e38beed7` — класс A (`src/hp-dialog.ts`,
`src/summary-panel-editor.ts`) + класс B (`demo/smoke_summary_dialog_scroll.mjs`,
`test/hp-dialog-contract.test.mjs`, два мутанта в `scripts/mutation-gate.mjs`) + класс C
(`docs/ARCHITECTURE.md`, оба CHANGELOG) + класс D (пересобранный бандл во всех копиях,
`docs/images/screenshots.json` — только фингерпринт). `hp-dialog` получает opt-in булев атрибут
`flex-content` (`?flexcontent` на `ha-dialog`, обе HA-ветки рендера); нативная `<dialog>`-ветка не
тронута. Диалог настроек сводной панели — единственный из десяти вызывающих `<hp-dialog>`, который
его объявляет.
J1 (`docs/SCOPE.md`) — обзорная сводная панель входит в «show the whole home at a glance»; чистый
баг-фикс регресса после #505, скоуп не расширен, продуктовый договор не меняется.
## Как проверялось
Первый шаг — не поверить заявлению «дельта r2→r3 это только паблиш-коммит», а доказать его (см. выше
`git diff 6ac513ca..be932910 --stat`), и дополнительно исключить более широкий рассинхрон:
```
git diff e38beed7..HEAD -- . ':!docs/reviews' --stat → пусто
```
То есть от коммита фикса `e38beed7` до текущего HEAD `be932910` **ни один файл вне `docs/reviews/`**
не изменился ни на байт — ни `src/**`, ни тесты, ни бандл, ни changelog, ни `docs/images/screenshots.json`.
Продуктовый код текущего материала побайтово идентичен коду, который уже дважды получил зелёный
вердикт с личными прогонами гейтов, мутантов и смоков (r1 — на `768e1281`, r2 — независимо
переподтверждено исполнением на `6ac513ca` после ребейза). Все выводы AC1–AC6 из r1/r2 остаются в
силе автоматически.
### Дешёвые гейты
Validate на `be932910` — success, подтверждено напрямую:
```
gh run view 34403312576 --repo Matysh/houseplan-card --json headSha,conclusion,status
→ {"conclusion":"success","headSha":"be93291065a54f8152d0f1945bde973c10613d36","status":"completed"}
```
`headSha` совпадает с материалом раунда. `tsc`, `npm test`, `npm run build` со сверкой бандла —
покрыты этим прогоном, повторно не гонял: код с r2 не изменился (доказано диффом выше), а сам прогон
состоялся уже на итоговом SHA этого раунда, не на промежуточном.
### Что не потребовалось прогонять лично
Поскольку дельта r2→r3 не касается ни одного файла продукта, теста, смока или бандла — только двух
markdown-файлов ревью-документов, — новых причин перегонять `check-docs` (диф не трогает `src/**`),
смоки, мутанты, golden, `invariants` или `pytest` в этом раунде нет: они уже прогнаны лично в r1
(на идентичном коде) и переподтверждены исполнением в r2 (на идентичном коде после ребейза). Смотри
раздел «Унаследовано из r2» — там же таблица AC, которую заново выводить не по чему: дельта её не
задевает.
Единственная проверка, которую стоило сделать заново именно для r3, — не «те же ли гейты снова
зелёные», а «действительно ли материал r3 это тот же код» — она выполнена выше диффом и является
самостоятельным доказательством, а не переносом чужого вывода.
## Закрытие раунда r2
Код-ревью r2 не имело находок (High 0, Medium 0, Low 0) — блокирующих находок для закрытия нет.
| Находка r2 | Статус | Где видно |
|---|---|---|
| (нет находок) | — | `docs/reviews/CODE-REVIEW-508-r2.md`, раздел «Находки»: «Нет. High: 0, Medium: 0, Low: 0» |
## Унаследовано из r2
Без повторного самостоятельного вывода, но с проверкой байт-в-байт идентичности продуктового кода
(раздел «Как проверялось» выше — `git diff e38beed7..HEAD -- . ':!docs/reviews'` пуст), наследую из
`docs/reviews/CODE-REVIEW-508-r2.md` (материал `6ac513caa09a8e583869879412aa3a0c67d04488`):
- диагноз (scroll chaining обрывается на height-unbound `.summary-editor` под `ha-dialog`,
`overflow: auto` + `overscroll-behavior: contain`), независимо подтверждённый автором на живом
стенде HA 2026.9.1 — не только в стабе смока;
- полную таблицу «AC → доказательство → чем краснеет» (AC1–AC6) — код и тесты, на которых она
построена, не изменились ни строкой;
- личные прогоны `test/hp-dialog-contract.test.mjs` (3/3), `check-docs` (7 files, 12 links),
`bundle-sync` (три копии согласованы), `demo/smoke_summary_dialog_scroll.mjs` (AC1/AC2 — 14/14),
`demo/smoke_summary_panel_polish.mjs` (AC3), `demo/smoke_dialog_modal_recovery.mjs`,
`demo/smoke_esc_dialogs.mjs`, `demo/smoke_dialog_footer_width.mjs`, оба защитных мутанта
(`summary-dialog-drops-flex-content`, `hp-dialog-ignores-flex-content` — «поймано 1 из 1» каждый) —
выполнены на `6ac513ca`, применимы без изменений к `be932910`, поскольку продуктовый и тестовый код
между ними идентичен;
- решения «не проверял»: `golden:verify` (opt-in атрибут, golden-сцен диалога панели нет),
`pytest tests_backend` (диф не про Python), полная матрица смоков/перф-профили (`smoke-select` —
«НЕОПРЕДЕЛЁННОСТЬ», выбор сужен вручную до прямого совпадения + зарегистрированных связей),
`npm run invariants` (диф не про геометрию/`layout`/`marker.space`);
- трассировку к спек-ревью (`SPEC-REVIEW-508-r1.md` жёлтый → `SPEC-REVIEW-508-r2.md` зелёный на SHA
`f02af9330b17a37eea6c8086f4335cbbc7265d36`) — код-ревью её не переоткрывало ни в r1, ни в r2, не
переоткрывает и сейчас: текст issue с тех пор не менялся.
## AC → доказательство (унаследовано без повторного вывода, см. выше)
| AC | Статус |
|---|---|
| AC1 (HA-ветка: колесо крутит `.summary-editor`, шапка/футер неподвижны) | доказан, `smoke_summary_dialog_scroll` сценарий (a) — код не менялся с r2 |
| AC2 (touch 390×844 крутит редактор; диалог ≤ вьюпорта; футер доступен) | доказан, сценарий (b) — код не менялся с r2 |
| AC3 (нативная ветка не меняется) | доказан, `smoke_summary_panel_polish` — файл смока по-прежнему не в диффе |
| AC4 (прочие 9 `hp-dialog` без `flex-content`) | доказан, `test/hp-dialog-contract.test.mjs` — код теста не менялся |
| AC5 (оба мутанта пойманы) | доказан — мутанты и продукт не менялись |
| AC6 (`User-Visible: yes`, оба changelog, i18n/модель/перф не задеты) | доказан чтением — коммит `e38beed7` тот же самый |
## Одно число — один источник
Диф не вводит и не дублирует ни одну видимую пользователю величину. Ничего не изменилось с r2, где
это уже проверено; дельта r3 (два markdown-файла ревью) не содержит вообще никаких величин,
видимых пользователю.
## Что проверено и корректно
- Дельта r2→r3 состоит ровно из одного файла, `docs/reviews/CODE-REVIEW-508-r2.md` — публикация
ревью-документа предыдущего раунда конвейером, не правка автора.
- Продуктовый код, тесты, бандл, changelog, документация (кроме `docs/reviews/`) между `e38beed7` и
текущим HEAD побайтово идентичны — доказано `git diff --stat` с пустым результатом.
- Validate зелёный на точном SHA материала (`be932910`), headSha совпадает — проверено напрямую
через `gh run view`.
- Ребейза с r2 не было (`origin/dev..HEAD` — ровно три коммита той же ветки), поэтому это
действительно раунд «по дельте» (§2.10), а не «разбор полный» по правилу ребейза.
## Находки
Нет. High: 0, Medium: 0, Low: 0.
## Чего не проверял и почему
- Полный `npm test`, `tsc`, `npm run build` вживую — покрыты Validate на этом самом SHA `be932910`
(ссылка выше, headSha сверен), а не на промежуточном; продуктовый код с r2 не изменился.
- Смоки, мутанты, `check-docs`, `bundle-sync` — не перегонял лично в этом раунде: дельта их не
задевает (ни один из изменившихся файлов не входит в их область), а на идентичном коде они уже
лично прогнаны и зафиксированы в r1/r2 с результатами.
- `golden:verify`, `pytest tests_backend`, полная матрица смоков, перф-профили, `npm run invariants` —
решение то же, что в r1/r2, и по той же причине (opt-in атрибут без влияния на golden-сцены; диф не
про Python; не в AC/`smoke-select`; диф не про геометрию) — не переигрывал повторно, так как
предпосылки решения (состав диффа) не изменились.
## Вердикт
Зелёный. Разбор — по дельте (§2.10), ребейза не было. Дельта r2→r3 — исключительно публикация
конвейером ревью-документа r2, продуктовый код и тесты байт-в-байт идентичны материалу r2, что
доказано диффом, а не принято на слово. Validate зелёный на точном SHA материала. Находок нет.
Раунд не образует цикл (§4) — вердикт зелёный.
Отдельное наблюдение вне находок задачи: этот раунд полностью соответствует условию AGENTS.md
(#499) «единственное отличие от материала — паблиш-коммит ревью-документа конвейера» — по этому
правилу перестановка метки должна была произойти автоматически, без модельного вызова. Она снова
не произошла (как и после r1), и это тот же класс сбоя механики, что уже отмечен в комментариях
issue после r1 и r2. Находкой в рамках code-review #508 это не является (инфраструктура конвейера
вне скоупа задачи), но стоит того, чтобы владелец знал: сбой перестановки метки после зелёного
вердикта повторился третий раз подряд на одном и том же issue.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/508-summary-dialog-scroll`, коммит `be93291065a5` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `7499e3434f083728210a04ff0d1237202bf30bbe`
```
git log --all --format='%H %T' | grep 7499e3434f08
```
- Вердикт конвейера: `green` · High 0
+221
View File
@@ -0,0 +1,221 @@
# CODE-REVIEW-509-r1
Заход r1 · блокирующих циклов израсходовано 0 из 4 (первый прогон дispatch на `172d9d5a` покраснел на собственном смоке автора, до чтения кода ревьюером — цикл не считается, PROCESS.md §2.9/#510).
Материал: диапазон `origin/dev..HEAD`, `HEAD` = `9b00002a5b09f64a4fea0b51bc03ca6be317ec7b` (рабочая копия уже на нём, `git fetch`/`checkout` не выполнялись).
## Скоуп
Одна поверхность — runtime сводной панели (`src/summary-panel-runtime-loaded.ts`,
`src/summary-panel-metrics.ts`, `src/summary-panel-style.ts`) плюс новый смок
`demo/smoke_summary_first_paint.mjs`, четыре записи в `scripts/mutation-gate.mjs`
и оба changelog. Модель данных, конфигурация и i18n не менялись — подтверждено
чтением диффа (`git diff origin/dev...HEAD --stat`): изменений в `src/i18n/**`
или в схеме конфигурации нет.
Контракт задан ТЗ (issue #509, полный трек, зелёный спек-ревью r2,
`docs/reviews/SPEC-REVIEW-509-r2.md`): AC1–AC9 (AC7 — оба changelog).
## Как проверялось
Прочитан весь диф построчно (`git diff origin/dev...HEAD -- src/... test/...
demo/smoke_summary_first_paint.mjs scripts/mutation-gate.mjs docs/CHANGELOG*`),
сверены сигнатуры `wallBodiesGeometry` / `multiWallNodesForGeometry` /
`innerContourForRoom` (`src/wall-thickness.ts:2434,2750,3662`) с новым вызовом
`spaceWallGeometry` — аргументы и их порядок совпадают со схемой, которой уже
пользуется карточка (`_innerContour`).
Прослежена машина состояний `valueState()` / `scheduleMetrics()` /
`advanceMetrics()` / `computeDeviceCount()` вручную по всем ветвям: модуль не
загружен → `pending`; entity-источник резолвится синхронно, минуя агрегаты;
`device_count`/`total_area` без мемо → `pending` + планирование; с устаревшим
мемо → `ready` со старым числом + фоновый пересчёт (AC9); `datetime` не
зависит от агрегатов вовсе. Прослежен `resetLifecycle()`/`current(generation)`
на предмет использования устаревшего таймера/генератора после отключения
панели — `run()` первым делом обнуляет `metricsRefresh`, затем проверяет
`current(generation)` и выходит без побочных эффектов; утечки не нашёл.
Дисциплина «тест умеет падать» проверена не на слово: каждый из четырёх новых
мутантов прогнан ЛОКАЛЬНО в этой рабочей копии командой `node
scripts/mutation-gate.mjs --id=<mutant>` — все четыре поймали свою поломку
(«поймано 1 из 1»):
- `summary-first-paint-shows-unavailable` (AC1, guard — смок)
- `summary-metrics-block-first-frame` (AC4, guard — смок)
- `summary-area-recomputes-walls-per-room` (AC3, guard — юнит)
- `summary-stale-metric-falls-back-to-skeleton` (AC9, guard — юнит)
Дополнительно эти же четыре мутанта независимо подтверждены в CI на этом же
SHA: два — прямым прогоном dispatch-джобов `Мутанты по диффу (1/3)` и `(3/3)`
(runs/34457268966), два — валидным переиспользованием ledger-кеша с прогона
`(2/3)` того же диспатча, отпечаток входов (файл патча + файл гарда) не
менялся между `172d9d5a` и `9b00002a` (единственная правка между ними —
`demo/smoke_summary_first_paint.mjs`, к мутантам шарда 2 отношения не имеющая
— проверено `git diff 172d9d5a..9b00002a --stat`). У самого мутанта
`summary-stale-metric-falls-back-to-skeleton` найден «поймано» ещё и на
исходном красном прогоне `34456100054` (шард 2 там был зелёным сам по себе,
красным был только шард 1/3 из-за флейка в смоке, не в мутанте) — см.
job `102802793874`.
## Что проверено и корректно
- **AC1/AC2** — `valueState()` возвращает `pending` пока `metricsModule` не
загружен (для ЛЮБОГО источника, включая entity), и не путает это с
`unavailable`; для реально отсутствующей сущности — честный `unavailable`.
Юнит `#509 AC1/AC2/AC9` (`test/summary-panel-runtime.test.mjs`) гоняет обе
ветки, включая живую и мёртвую сущность. Локально: `node --test
test/summary-panel-runtime.test.mjs` — 5/5 ok.
- **AC3** — `spaceWallGeometry` считает `wallBodiesGeometry` +
`multiWallNodesForGeometry` один раз на пространство и передаёт в
`innerContourForRoom` как `sharedRoomWallGeometry`/`sharedMultiWallNodes`;
сигнатуры совпадают построчно с уже принятым способом, которым карточка
передаёт `_innerContour`. Юнит со счётчиком вызовов подтверждает «один
вызов на пространство, не на комнату» и что результат не меняется
(`4.32` что со spy, что без); второй юнит подтверждает ускорение на
фикстуре `large-house` (порог 2500 мс, грубый, не флейкует). Оба зелёные
локально.
- **AC4** — тяжёлый агрегат больше не считается внутри `renderPanel()`:
`valueState()` только читает мемо и планирует фоновый пересчёт
(`scheduleMetrics()`), сам расчёт стартует через
`setTimeout(0)→rAF→setTimeout(0)`, как записано в «Принятых технических
предположениях» ТЗ. Мутант, возвращающий синхронный расчёт в путь
рендера (`summary-metrics-block-first-frame`), ловится смоком. Локально
пересобранный бандл + `node demo/smoke_summary_first_paint.mjs`: первый
кадр не заблокирован, 3 строки со скелетами, ни одного «unavailable».
- **AC5** — анимация входа/выхода (190 мс, #505) реально проигрывается,
потому что главный поток свободен к моменту показа; смок читает
`getAnimations()` на обоих переходах. Проверено локально — `enterAnimated`
и `exitAnimated` подтверждены смоком (полный вывод: `OK`).
- **AC6** — `@media (prefers-reduced-motion: reduce)` глушит и пульсацию
скелета, и fade-in значения, прямоугольник остаётся. Смок проверяет
вычисленный `animationName === 'none'` и видимость плашки — оба true в
локальном прогоне.
- **AC8** — расчёт стартует сразу после показанного кадра (не отложен до
простоя), скелет сменяется значением за разумное время: локально
`значения через 2822 мс` (порог смока 6000 мс; машина ревьюера медленнее
CI-раннера автора, где было ~1555 мс, — оба варианта далеко от порога).
- **AC9** — устаревшее мемо не сбрасывается в скелет: `valueState()`
возвращает `ready` со старым числом и планирует пересчёт в фоне, пока
`metricsFresh()` не станет `true`. Юнит меняет `host._cfgEpoch` и
проверяет ровно эту последовательность; мутант, убирающий проверку
свежести, ловится.
- **AC7** — оба changelog правлены в этом же диффе, синхронно по смыслу
(en/ru), со ссылкой на #509; новых i18n-ключей нет (проверено — diff не
трогает `src/i18n/**`); `docs/images/screenshots.json` меняет только
`sourceFingerprint`/`sourceSha256`, все `imageSha256` — те же самые, то
есть кадры документации побитово не изменились (`docs:accept --identical`,
подтверждено логом job `docs` в CI на этом SHA).
- **Одно число — один источник.** `totalCleanFloorAreaM2` остаётся
единственной функцией, вычисляющей площадь для панели; никто другой в
`src/**` её не вызывает (проверено `grep` по собранному бандлу и
исходникам) — карточка и PDF используют отдельный путь через
`_innerContour`, что и раньше, и ТЗ явно не заявляет это в скоуп. Новый
путь (генератор) не создаёт второго источника числа — `totalCleanFloorAreaM2`
остался тонкой синхронной обёрткой над тем же генератором, который
использует и панель.
- Типы: `npx tsc --noEmit` (через `npm run build`) — зелёный локально;
`npm run bundle:sync` пересобрал бандл и синхронизировал три копии без
расхождений.
## Гейты: что прогнал и почему
**Зелёный Validate уже на этом SHA (`9b00002a`), переиспользован без
повторного прогона:**
- push-прогон `34457195661` — предполётные проверки (докс/провенанс/процесс).
- push-прогон на предыдущем коммите `172d9d5a` (`34455999479`, тот же диф по
продукту, следующий коммит менял только смок) — job «Фронтенд: типы,
юниты, мутанты, синхрон бандла» зелёный целиком: `npm run typecheck`,
`no-new-any`, `npm test` (полный набор), `npm run build`, сверка
бандл-деревьев, `bundle:budget`.
- dispatch-прогон с мутантами `34457268966` — все три шарда `Мутанты по
диффу` зелёные на точном `9b00002a`, включая чистый прогон
`node demo/smoke_summary_first_paint.mjs`, `node --test
test/summary-panel.test.mjs`, `node --test test/summary-panel-runtime.test.mjs`.
**Прогнал сам, локально, на этой же рабочей копии** (Validate не покрывает
браузерные смоки для обычного пуша, а «работает ли оно» — моя ответственность):
- `npm run build` — зелёный (typecheck включён).
- `npm run bundle:sync` — зелёный, три копии бандла синхронны.
- `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node
--test test/summary-panel-runtime.test.mjs test/summary-panel.test.mjs` —
25 + 5 тестов, все ok (в т.ч. новые `#509 AC1/AC2/AC9` и `#509 AC3` × 2).
- `node demo/smoke_summary_first_paint.mjs` — `OK` (все 14 проверок).
- `node demo/smoke_summary_panel.mjs` — `OK` (прямое совпадение по
`_config/_layoutRev/_model`, `scripts/smoke-select.mjs` тоже его называет).
- `node demo/smoke_summary_panel_polish.mjs`, `node
demo/smoke_summary_warm_attach.mjs` — `OK` (заявлены автором как
прогнанные; перепроверил сам вместо того, чтобы поверить на слово).
- Каждый из 4 новых мутантов по отдельности: `node scripts/mutation-gate.mjs
--id=<id>` — все 4 «поймано 1 из 1» (список выше).
**Не прогонял и почему:**
- `npm test` целиком у себя — не требуется: уже зелёный в CI на диффе,
который отличается от `HEAD` только смоком (не входит в `npm test`);
прогнал точечно изменённые файлы вместо этого.
- `npm run golden:verify` — в `demo/golden/**` нет ни одного сценария со
сводной панелью (`grep -rl summary demo/golden/` — пусто); диф не может
изменить ни один принятый кадр. AC7 отдельно ожидает «не ожидается»
изменения golden.
- `npm run invariants` — диф не трогает геометрическую модель (рёбра,
записи толщины, `layout`, `marker.space`, `open_spans`): он лишь
переиспользует уже существующие геометрические функции, не меняя, что
они возвращают (доказано юнитом AC3 — результат идентичен). Инвариантов
ребра/толщины это не касается.
- `python -m pytest tests_backend` — диф не трогает
`custom_components/**/*.py`.
- `smoke_summary_dialog_scroll.mjs` (#508) и остальные «прямые совпадения»
из `smoke-select` (`smoke_junction_holes`, `smoke_glow_fail_dark`,
`smoke_wallthick_standalone` и т.п.) — все они совпали по предельно общим
символам (`cellCm`, `NORM_W`, `GRID_PITCH`, `wallBodiesGeometry` как имя
функции, не как поведение) и не имеют отношения к загрузочному состоянию
панели или к переносу расчёта во времени — единственному, что меняет этот
диф в геометрическом коде. Посмотрел список, прогонять не стал.
- performance-профили — не названы в AC; AC4/AC8 доказываются собственным
смоком задачи.
## Находки
**Low-1 (не блокирует, снимаю с записью).** Комментарий в
`scheduleMetrics()` (`src/summary-panel-runtime-loaded.ts:~735`): «Каждая
порция короче кадра, между порциями браузер обрабатывает ввод и рисует» —
неточен для одного случая. Переход генератора `cleanFloorAreaSteps` между
двумя ПРОСТРАНСТВАМИ выполняет `spaceWallGeometry` (единый polyclip-union)
целиком внутри одного вызова `.next()`, без промежуточного `yield` — это
именно тот «неделимый» кусок, из-за которого автор в этом же заходе поднял
порог смока `noLongTaskWhileComputing` с 250 до 450 мс (коммит `9b00002a`,
сам объясняет это в сообщении коммита и в комментарии смока). Итог по
существу верный (AC4/AC8 выполняются, мутант «весь расчёт целиком»
по-прежнему падает — перепроверено локально), но приведённая фраза
обещает больше, чем код гарантирует: разработчик, который прочитает только
её, ожидает, что ни один кусок не превышает ~16 мс, а по факту до трёх
кусков по дому могут занять до ~450 мс каждый. Прошу поправить формулировку
в следующей правке этого файла (не отдельным заходом ради одной фразы) —
не блокирует, продуктовое поведение проверено и корректно.
## Итог
High: 0. Medium: 0 (обе Medium-находки спек-ревью были закрыты AC8/AC9 ещё
на этапе ТЗ и подтверждены здесь тестами, которые умеют падать). Low: 1,
снята с записью выше.
Все девять AC (AC1–AC9) доказаны либо тестом, который я лично проверил на
способность падать (мутационным гейтом или прогоном красной/зелёной ветки),
либо прямым чтением с перепроверкой сигнатур. Оба changelog на месте,
i18n не тронут, модель данных не тронута, «одно число — один источник»
выполняется. Возвращать в скоуп нечего.
**Вердикт: зелёный.**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/509-summary-first-paint`, коммит `9b00002a5b09` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `017ac1f24095d4f7009510c19a82a9672984f656`
```
git log --all --format='%H %T' | grep 017ac1f24095
```
- Тело issue: `6224b491f7bef6e6962692c3c901bf5121432fd1e0244740514a420aff96ba36`
- Вердикт конвейера: `green` · High 0
+191
View File
@@ -0,0 +1,191 @@
# CODE-REVIEW-510-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/510
- **Материал:** `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff` (рабочая копия на нём; `git rev-parse HEAD` сверен непосредственно перед выводом)
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4
- **Ветка:** `issue/510-mutants-on-candidate-and-review-gate`
- **ТЗ:** `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, ревью ТЗ зелёное на r4 (`docs/reviews/SPEC-REVIEW-510-r4.md`)
Ветка приведена к `dev` конвейером до ревью: поверх старого материала `025634cc`
легло 4 коммита `dev` (в том числе #511 `ci: release gate judges the latest
non-cancelled Validate run of the SHA`), и текущий тип — `cbece632` — другой
код по содержимому дерева (§7.2). Разбор ниже полный, не по дельте.
## Скоуп
Полный трек, класс B/C: `.github/workflows/{process,validate}.yml`,
`scripts/{classify-changes,merge-candidate,validate-gate,mutation-gate}.mjs`,
пять тестовых файлов, `PROCESS.md`, `AGENTS.md`, `docs/TESTING.md`,
`docs/specs/README.md`. Ни одного файла класса A — `src/**` не тронут,
`User-Visible: no` на каждом коммите, changelog не требуется.
Предмет: `changed_mutants` в Validate идёт только по запросу (dispatch,
PR, ночь, кандидат беты), ревью-конвейер и слияние кандидата сами
запускают Validate с мутантами на материале и ждут его перед тем, как
читать код или сливать в `dev`; правила хендоффа (один пуш на заход).
## Как проверялось
Зелёного Validate на `cbece632` в момент ревью не найдено — гейты
прогнаны вручную.
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| unit | `npm test` | 2419 тестов, 2418 pass, 1 skip (`issue 281 private exact fixture` — предсуществующий, привязан к отсутствующей приватной фикстуре, не в этом диффе), 0 fail |
| build + бандл | `npm run build` затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | сборка зелёная, копии идентичны |
| check-docs | не прогонялся | `src/**` не тронут — гейт не относится к этому диффу (§8: отпечаток считается по `src/**`, здесь его нет) |
| smoke / golden / pytest / invariants / perf | не прогонялись | ни одна геометрия, ни один визуальный путь, ни один Python-файл, ни один perf-профиль не задеты; AC6 явно фиксирует «`src/**` без изменений» |
| мутанты реестра, заявленные ТЗ §8/AC4 | `node scripts/mutation-gate.mjs --id=<id>` по каждому из пяти | все пять «поймано 1 из 1» (таблица ниже) |
| таргетированные unit-тесты диффа | `node --test test/{review-doc-guard,validate-workflow,validate-gate,merge-candidate,classify-changes}.test.mjs` | 103/103 pass |
### AC → доказательство → чем краснеет
| AC | Доказано | Чем краснеет |
|---|---|---|
| AC1 (обычный push не запускает `changed_mutants`; dispatch/PR/schedule/кандидат беты — запускают) | `test/classify-changes.test.mjs` (`mutantsRequested` таблица) + структурный тест `validate-workflow.test.mjs` на `if: needs.changes.outputs.mutants_requested == 'true'` | мутант `mutants-run-on-every-push` (classify всегда true) — прогнан лично, «поймано 1 из 1» |
| AC2 (гейт находит/запускает/красный/таймаут/несовпадение SHA, ревью не идёт без зелёного) | `test/validate-gate.test.mjs` (9 сценариев: chужой skip-dispatch игнорируется, push не доказательство, красный, таймаут, материал сменился, слежение за tracked-run) + `test/review-doc-guard.test.mjs` (структура `process.yml`: порядок шагов, условия `proceed`, сохранённый конъюнкт `reuse != 'true'`) | мутанты `review-starts-on-red-validate` и `review-trusts-push-run-without-mutants` — прогнаны лично, оба «поймано 1 из 1» |
| AC3 (слияние кандидата ждёт dispatch с мутантами, не push) | `test/merge-candidate.test.mjs` (dispatch после push, до validate; `waitValidate(candidate, {event:'workflow_dispatch'})`) | мутанты `merge-waits-push-run-without-mutants` и `merge-pushes-unvalidated-candidate` (переанкорен коммитом `cbece632`) — прогнаны лично, оба «поймано 1 из 1» |
| AC4 (три мутанта протокола пойманы штатным раннером) | см. строку выше — фактически пять мутантов, не три (`mutants-run-on-every-push`, `review-starts-on-red-validate`, `review-trusts-push-run-without-mutants`, `merge-waits-push-run-without-mutants`, `merge-pushes-unvalidated-candidate`) | все пять — «поймано 1 из 1», лично воспроизведено, не только со слов хендоффа |
| AC5 (доки обновлены) | проверено чтением: `PROCESS.md` (§10.4 «Ревью не начинается на красном коде», «Один хендофф — один пуш»), `AGENTS.md` (тот же блок), `docs/TESTING.md` («Мутанты по диффу»), `docs/specs/README.md` (строка на ТЗ) — построчно сверены с реализацией | не защитный AC (текст/наличие раздела) — свидетель обычное сравнение, третий столбец не нужен (§2.7) |
| AC6 (perf/touch/UX не затронуты, `src/**` не тронут) | `git diff origin/dev...HEAD --stat` — ни одного файла `src/**` | не защитный AC — сравнение diff-stat |
### Демонстрация: тест умеет падать (снятая защита вручную, помимо реестра)
Для `provesMutants` и `isMutantRun` дополнительно прогнал `test/validate-gate.test.mjs`
после ручного снятия проверки `run.conclusion !== 'success'` (без реестра
`mutation-gate.mjs`) — красный, три упавших сценария (red/timeout/missing
перестают различаться). Восстановлено сразу после проверки, в диффе не
осталось.
## Находки
### Medium (в скоупе задачи — чинится в этой же задаче, без отдельного issue)
**M1. Отменённый (`cancelled`) dispatch-прогон читается как красный, а не игнорируется — то же семейство дефектов, которое #511 только что исправил в соседнем скрипте той же ветки dev.**
- Файлы: `scripts/validate-gate.mjs:32` (`if (run.conclusion !== 'success') return { result: 'red', ... }`), тот же паттерн в `scripts/merge-candidate.mjs:147` (`result: run.conclusion === 'success' ? 'green' : 'red'`), которым #510 начал подавать новый вид прогона (`event: 'workflow_dispatch'`) — раньше туда попадал только push.
- **Сценарий отказа:** пока гейт (или слияние кандидата) ждёт свой dispatch-прогон Validate, кто-то с правом записи запускает ещё один `workflow_dispatch` на той же ветке (ручная проверка, вторая попытка после сетевого сбоя, повторный запуск job). Группа concurrency dispatch-прогонов — `validate-dispatch-<ref>` с `cancel-in-progress: true` (сохранена этим же ТЗ §4, `validate.yml:37`) — отменяет прогон гейта. `validate-gate.mjs` находит его по точному SHA, `run.status === 'completed'`, `run.conclusion === 'cancelled'` — не `'success'` — и до проверки job'ов мутантов сразу возвращает `{result:'red', note:'dispatch-прогон завершился: cancelled'}`. Задача уходит в `S6-in-progress` с сообщением «Validate — red», хотя материал никто не отверг: прогон просто заменён.
- **Воспроизведено лично** (не только рассуждением):
```js
import { validateGate } from './scripts/validate-gate.mjs';
const SHA = 'a'.repeat(40);
const ops = {
listRuns: async () => [{ databaseId: 1, status: 'completed', conclusion: 'cancelled',
url: 'https://run/cancelled', event: 'workflow_dispatch', headSha: SHA }],
listRunsOnRef: async () => [], jobs: async () => [],
dispatch: async () => { throw new Error('не должно понадобиться'); },
sleep: async () => {}, now: () => 0,
};
await validateGate({ ref: 'issue/1', sha: SHA, ops, pollMs: 1000 });
// → { result: 'red', url: 'https://run/cancelled', note: 'dispatch-прогон завершился: cancelled' }
```
Ни `test/validate-gate.test.mjs`, ни `test/merge-candidate.test.mjs` не содержат сценария с `conclusion: 'cancelled'` (`grep -i cancel` по обоим файлам и по обоим скриптам — пусто).
- **Почему это в скоупе, а не смежная находка:** `scripts/validate-gate.mjs` — новый файл этой самой задачи, и его прямая цель — не тратить впустую цикл ревью/минуты раннера на прогон, который ничего не доказал. Отменённый прогон — ровно такой случай: он не про красный код, а про замещённый прогон, и должен либо игнорироваться (как уже сделано для «зелёного чужого dispatch без мутантов» — `ignored`/`tracked = null`/`continue`), либo триггерить повторный dispatch, а не отправлять задачу автору с ложным «Validate red». Тот же коммит-предок (`dev`, #511, `scripts/release-gate.mjs`: `latestRelevantRun` — «cancelled run proves nothing either way») решает точно эту проблему для соседнего скрипта той же категории («дождаться зелёного Validate на SHA») буквально в этом же ребейзе — то есть паттерн исправления уже есть в дереве, просто не применён к новому коду.
- **Не блокирует:** направление отказа безопасное (не пропускает недоказанный код в ревью/слияние), это не потеря доказательства, а лишний беспричинный откат к автору — то есть дефект существующий, но не High. Чинится в этой же задаче (Medium в скоупе, §2.7/#202) — добавить `cancelled` в игнорируемые исходы (как для skip-dispatch) в обоих местах и тест на сценарий.
Других Medium/High не найдено.
### Low
Не найдено требующих записи — единственная спорная деталь (M1) достаточно
конкретна и воспроизведена, чтобы быть Medium, а не «наблюдением».
## Что проверено и корректно
- `.github/workflows/process.yml`: новый шаг `gate` стоит после `material` и
`reuse` и до `validated`/установки зависимостей/`Review` — порядок
подтверждён и структурным тестом, и чтением файла. `proceed` подставлен
ровно на место конъюнкта `steps.rebase.outputs.conflict != 'true'` во всех
перечисленных ТЗ шагах; прочие конъюнкты (`reuse != 'true'`, `stage ==
'code'`, `decide.outputs.green == 'true'`) не тронуты — reuse-тест #499
(«модель не вызывается») остаётся зелёным без изменений своей логики.
- `.github/workflows/validate.yml`: вход `mutants` (default `false`),
`mutants_requested` в выходах `changes`, `changed_mutants.if` заменён с
файлового отбора на `mutants_requested == 'true'` — job исполняется даже
на пустом отборе файлов (подтверждено чтением тела job: матрица из трёх
шардов не имеет собственного условия внутри, только job-level `if`), что
и требуется, чтобы `skipped` однозначно означал «не запрашивали».
- `scripts/classify-changes.mjs`: `mutantsRequested` — `pull_request`/
`schedule` → true, `workflow_dispatch` → `full || mutants`, `push` → только
по трейлеру `Release:`. Таблица тестов покрывает все ветки, включая
«кнопка без запроса» (`full=false, mutants=false` → false).
- `scripts/validate-gate.mjs`: `isMutantRun`/`provesMutants` корректно
отличают чужой зелёный dispatch без исполненных job мутантов (`skipped`)
от настоящего доказательства; таймаут и «материал сменился» (dispatch
появился на другом SHA) разведены и оба протестированы.
- `scripts/merge-candidate.mjs`: `dispatchValidate` вызывается один раз
после успешного `pushWithLease` кандидата, `waitValidate` теперь
фильтрует по `event`, что делает возможным ждать именно dispatch, а не
push-прогон на той же SHA — порядок вызовов (push → dispatch → validate)
подтверждён тестом на реальном git (`на настоящем git: чистый ребейз`).
- Пять мутантов реестра (AC4) лично прогнаны через `node
scripts/mutation-gate.mjs --id=<id>` — все дают «поймано 1 из 1», не
только заявлены хендоффом.
- Документация (`PROCESS.md`, `AGENTS.md`, `docs/TESTING.md`,
`docs/specs/README.md`) построчно сверена с реализацией — расхождений
не найдено; `AGENTS.md` и `PROCESS.md` описывают именно то место
мутантов и то же правило хендоффа, которое реализует код.
- Асимметрии между `validate-gate.mjs` (гейт перед ревью) и
`merge-candidate.mjs` (гейт перед слиянием) нет там, где она была бы
находкой: у `merge-candidate.mjs` SHA кандидата всегда свежесозданный
(только что запушен через `--force-with-lease`), поэтому там нет риска
подобрать «чужой» зелёный push-прогон без мутантов, который решает
`provesMutants` в `validate-gate.mjs` — разное решение для разных
сценариев обосновано, не дефект.
## Чего не проверял и почему
- `check-docs.mjs`, golden, смоки, инварианты модели, `pytest
tests_backend`, perf-профили — diff не касается `src/**`,
`custom_components/**/*.py` или геометрии; AC6 явно фиксирует отсутствие
влияния. Прогон был бы тратой времени без цели.
- Зеркалирование `process.yml`/`validate.yml` в `main` (AC5, §5.3
спеки) — по правилу #454 происходит **после** слияния в `dev`; на этом
SHA проверять нечего, это шаг публикации, не код-ревью.
- Реальный прогон `validate-gate.mjs`/`dispatchValidate` против живого GitHub
Actions (`realOps`, `gh` CLI) — не воспроизводил: `gh` в этом окружении
не аутентифицирован на этот репозиторий для целей ревью, а хендофф-
комментарий уже приводит два реальных дешёвых/dispatch-прогона на
материале (`34367209869` зелёный за 1:56, `34368454919` зелёный с тремя
исполненными job «Мутанты по диффу») — доверяю этим ссылкам как факту
внешнего состояния, а не как замене чтения кода; логика `ops`-инъекции
проверена юнитами и мутантами, что и есть предмет код-ревью для скрипта
без сети.
- Полный `npm run gate:small` / `--smokes` не запускал — при отсутствии
`src/**` в диффе браузерные смоки не выбираются `smoke-select.mjs` в
принципе (нет входов), прогон дал бы пустой список.
## Материал раунда
- SHA: `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff` (сверено `git rev-parse HEAD`
непосредственно перед выводом вердикта).
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`.
- Ребейз: старый материал хендоффа `025634cc` не входит в историю `cbece632`
(`git log --oneline 025634cc..cbece632` не показывает общего предка в
выводимом диапазоне — ветка была переиграна поверх ушедшего вперёд `dev`,
включая #511). Полный разбор выполнен по этой причине, не по дельте.
## Вердикт
Жёлтый: одна находка Medium в скоупе (M1), High нет. AC1–AC6 доказаны и
перепроверены лично (тесты + пять мутантов + сборка), гейты класса
code — зелёные без ссылки на CI (typecheck/test/build прогнаны в этом
ревью). Возврат автору для правки M1 в этой же задаче.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`, коммит `025634cc7599` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `b043dcd5e460e5e25561c530355b262c018f4c2c`
```
git log --all --format='%H %T' | grep b043dcd5e460
```
- ТЗ `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, блоб `df99bed3c772e12fcfde5439176d6827fe6d79dd`
```
git log --all --find-object=df99bed3c772e12fcfde5439176d6827fe6d79dd -- docs/specs/510-mutants-on-candidate-and-review-waits-validate.md
```
- Вердикт конвейера: `yellow` · High 0
+245
View File
@@ -0,0 +1,245 @@
# CODE-REVIEW-510-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/510
- **Материал:** `00130e08aa247cd8796a99125deb7f9b5131bfa1` (рабочая копия на нём; `git rev-parse HEAD` сверен непосредственно перед выводом)
- **Заход:** r2 · блокирующих циклов израсходовано (после этого раунда) 2 из 4
- **Ветка:** `issue/510-mutants-on-candidate-and-review-gate`
- **Предыдущий раунд:** CODE-REVIEW-510-r1 (жёлтый, Medium M1), материал `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`
- **ТЗ:** `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md` (r2 добавил одно предложение про `cancelled`), ревью ТЗ зелёное на r4 (`docs/reviews/SPEC-REVIEW-510-r4.md`)
## Скоуп раунда
`git log --oneline cbece632..00130e08`:
```
00130e08 ci: a cancelled Validate dispatch proves nothing to the review gate
f6e64ba9 docs: review document for #510
```
Никакого ребейза между r1 и r2 не было: `00130e08` — прямой потомок `cbece632`
(два коммита сверху), дерево то же самое плюс точечный фикс M1. Это не смена
кода по содержанию (§7.2) — разбор ведётся по дельте.
`git diff cbece632..00130e08 --stat`:
```
docs/reviews/CODE-REVIEW-510-r1.md | 191 +++++++++
docs/specs/510-mutants-on-candidate-and-review-waits-validate.md | 2 +-
scripts/merge-candidate.mjs | 4 +-
scripts/mutation-gate.mjs | 11 ++
scripts/validate-gate.mjs | 10 +-
test/validate-gate.test.mjs | 18 ++
6 files changed, 233 insertions(+), 3 deletions(-)
```
Предмет дельты — ровно закрытие M1 из r1: «отменённый (`cancelled`)
dispatch-прогон читается как красный вместо того, чтобы игнорироваться»,
найденное в двух местах (`scripts/validate-gate.mjs` и
`scripts/merge-candidate.mjs`). Разбор этого раунда сосредоточен на том,
чем и насколько полно M1 закрыт; остальные AC (1, 3, 4, 5, 6) delta не
задевает — унаследованы из r1 без повторной проверки (раздел ниже).
## Как проверялось
Зелёного Validate на `00130e08` не найдено (см. заголовок задачи) — гейты
прогнаны лично.
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| unit (полный набор) | `npm test` | 2421 тестов, 2420 pass, 1 skip (предсуществующий #281, не в этом диффе), 0 fail |
| build + бандл | `npm run build` затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | сборка зелёная, копии идентичны |
| check-docs / golden / смоки / инварианты / pytest / perf | не прогонялись | `src/**` и `custom_components/**/*.py` в дельте не тронуты — те же основания, что в r1 (AC6) |
| новый мутант протокола `review-returns-task-on-cancelled-dispatch` | `node scripts/mutation-gate.mjs --id=review-returns-task-on-cancelled-dispatch` | `поймано 1 из 1` (лично, не со слов хендоффа) |
| пять мутантов, подтверждённых в r1 (AC4) | не перепрогонялись | делта не трогает их патчи/guard-и (`mutation-gate.mjs` только добавил новую запись) — унаследовано |
### Демонстрация: тест умеет падать — и её отсутствие там, где оно важно
Для нового мутанта `review-returns-task-on-cancelled-dispatch` заявленный
раннер сам это доказывает («тест покраснел, как обязан»). Но M1 в r1 назвал
**два** места с одним и тем же паттерном — `scripts/validate-gate.mjs` и
`scripts/merge-candidate.mjs`. Проверил вторую половину лично:
```
cp scripts/merge-candidate.mjs /tmp/merge-candidate.mjs.bak
# вручную снял фильтр отменённых прогонов:
# const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
# → const runs = all.filter((x) => (!event || x.event === event));
node --test test/merge-candidate.test.mjs # 10/10 pass — ни одного упавшего
npm test # 2420/2420 pass — ни одного упавшего
cp /tmp/merge-candidate.mjs.bak scripts/merge-candidate.mjs # восстановлено, git diff пуст
```
Результат: снятие фикса в `merge-candidate.mjs` **не роняет ни одного теста
во всём проекте**. См. находку ниже.
## Находки
### Medium (в скоупе задачи — чинится в этой же задаче, без отдельного issue)
**M1 (новая, r2). Закрытие M1(r1) асимметрично: половина фикса в
`scripts/merge-candidate.mjs` не защищена ни одним тестом или мутантом.**
- Файл: `scripts/merge-candidate.mjs:145` — `waitValidate` внутри `realOps`:
```js
const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
```
Сам код корректен и делает ровно то, что требует обновлённый текст ТЗ
(«то же в `waitValidate` слияния»): отменённый прогон исключается из
списка кандидатов, гейт слияния ждёт замену вместо того, чтобы красить
кандидата.
- **Но:** ни один тест не вызывает эту ветку кода. `test/merge-candidate.test.mjs`
проверяет `mergeCandidate()` только с полностью замоканным `ops.waitValidate`
(строки 83, 207) либо со «настоящим git», где `ops.waitValidate` тоже
подменён фейком (строка 207 того же теста) — реальная реализация
`waitValidate` внутри `realOps()` не вызывается **ни разу** ни в одном
тесте проекта. Соответственно ни один из пяти мутантов, привязанных к
`merge-candidate.mjs` (`merge-pushes-unvalidated-candidate`,
`merge-ignores-lease-rejection`, `merge-waits-push-run-without-mutants` и
др., `scripts/mutation-gate.mjs:3696,3707,8178`), не патчит и не может
поймать регресс именно этой строки — реестр про неё вообще не знает.
- **Воспроизведено лично** (см. блок выше): при полностью снятом фильтре
`x.conclusion !== 'cancelled'` весь набор (`npm test`, 2420 тестов)
остаётся зелёным. Это ровно тот класс дефекта, о котором предупреждает
сама дисциплина ревью этого проекта («зелёный тест без реальной защиты»,
PROCESS.md §2.7, прецеденты #423/#430) — только тут защиты нет вообще,
а не «не та».
- **Почему это Medium, а не Low/наблюдение:** `scripts/validate-gate.mjs`
получил в этом же коммите два прицельных теста и мутант реестра для
ровно того же паттерна; `scripts/merge-candidate.mjs` — тот же паттерн,
та же формулировка ТЗ, но без единого свидетеля. Будущий рефакторинг
(например, унификация двух копий одной и той же логики фильтрации,
которую сам этот диф оставил задублированной) может тихо вернуть
«cancelled = red» именно в путь слияния кандидата — и ничего в CI об
этом не сообщит. Это не гипотетический сценарий, а прямое следствие
того, что issue #510 в целом решает задачу «не тратить впустую циклы
ревью/минуты раннера»: путь слияния (`merge-candidate.mjs`) — как раз
тот, где отменённый прогон при повторной попытке гейта слияния красит
готового к мержу кандидата.
- **Почему в скоупе:** M1 в r1 явно называла оба файла как один и тот же
дефект («тот же паттерн в `scripts/merge-candidate.mjs:147`»); закрытие
найдено только частичным — код исправлен, но не тестом, который умеет
упасть. Чинится добавлением теста, эксплуатирующего `realOps().waitValidate`
напрямую (с фейковым `sh`/`gh` или через существующий git-фикстурный тест
строка ~200, но с подставленным реальным `waitValidate` вместо фейка) плюс
записи в реестр мутантов.
Других Medium/High не найдено.
### Low
Не найдено требующих записи.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** (`cancelled` dispatch читается как `red` вместо игнорирования) — половина 1: `scripts/validate-gate.mjs:32` | Код: `validate-gate.mjs:60-66` — ветка `run.conclusion === 'cancelled'` добавляет прогон в `ignored`, сбрасывает `tracked`, продолжает цикл (ждёт замену либо диспатчит свой). Тест: `test/validate-gate.test.mjs:109-125` — два новых сценария («замена уже есть» / «замены нет»), лично прогнаны, зелёные. Мутант: `scripts/mutation-gate.mjs` id `review-returns-task-on-cancelled-dispatch` — лично прогнан, `поймано 1 из 1` | `git diff cbece632..00130e08 -- scripts/validate-gate.mjs test/validate-gate.test.mjs scripts/mutation-gate.mjs`; вывод `node scripts/mutation-gate.mjs --id=review-returns-task-on-cancelled-dispatch` выше |
| **M1** — половина 2: `scripts/merge-candidate.mjs:147` (было) | Код исправлен: `merge-candidate.mjs:145` — тот же фильтр `x.conclusion !== 'cancelled'` внутри `waitValidate`. Тест/мутант — **отсутствуют**, регрессия не ловится (см. воспроизведение выше) | `git diff cbece632..00130e08 -- scripts/merge-candidate.mjs`; ручной откат фикса + `npm test` (2420/2420 pass) выше — новая находка M1(r2) |
| Low (r1: «не найдено требующих записи») | — | нечего закрывать, r1 не оставил Low |
## Унаследовано из r1
Принято без повторной проверки в этом раунде — дельта (`docs/specs/*.md` +1
строка, `scripts/{merge-candidate,mutation-gate,validate-gate}.mjs`,
`test/validate-gate.test.mjs`) их не задевает. Источник — CODE-REVIEW-510-r1.md
на материале `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`:
- **AC1** (обычный push не запускает `changed_mutants`; dispatch/PR/schedule/кандидат
беты — запускают) — доказано `test/classify-changes.test.mjs` +
`test/validate-workflow.test.mjs`, мутант `mutants-run-on-every-push`
(«поймано 1 из 1» в r1). Файлы дельтой не тронуты.
- **AC2** (гейт находит/запускает/красный/таймаут/несовпадение SHA) — доказано
`test/validate-gate.test.mjs` (исходные 9 сценариев r1) + `review-doc-guard.test.mjs`,
мутанты `review-starts-on-red-validate` / `review-trusts-push-run-without-mutants`.
`.github/workflows/process.yml` дельтой r1→r2 не тронут вовсе.
- **AC3** (слияние ждёт dispatch с мутантами, не push) — доказано
`test/merge-candidate.test.mjs`, мутанты `merge-waits-push-run-without-mutants` /
`merge-pushes-unvalidated-candidate`. Логика диспатча/ожидания, за исключением
фильтра `cancelled` (см. новую находку M1 выше), дельтой не изменена.
- **AC4** (мутанты протокола пойманы штатным раннером) — пять мутантов r1
подтверждены лично в r1; дельта r2 добавляет к ним шестой
(`review-returns-task-on-cancelled-dispatch`, подтверждён в этом раунде).
Остальные пять не перепрогонялись — их патчи/guard-файлы не менялись.
- **AC5** (доки описывают место мутантов и правила хендоффа) —
`PROCESS.md`/`AGENTS.md`/`docs/TESTING.md`/`docs/specs/README.md` сверены
построчно в r1; в дельте r1→r2 из этого списка правок нет (только сам
ТЗ-файл получил одно уточняющее предложение — см. `git diff` выше,
прочитано, соответствует коду).
- **AC6** (`src/**` не тронут, perf/touch/UX не задеты) — верно и для этой
дельты: `git diff cbece632..00130e08 --stat` не содержит ни одного файла
`src/**`.
- Асимметрия дизайна между `validate-gate.mjs` (учитывает чужой push-прогон)
и `merge-candidate.mjs` (SHA кандидата всегда свежий) — признана
обоснованной в r1, дельта её не меняет.
## Что проверено и корректно (в этом раунде)
- `scripts/validate-gate.mjs`: ветка `cancelled` корректно встроена в
существующий цикл `ignored`/`tracked=null`/`continue` — не ломает уже
протестированные сценарии (chужой skip-dispatch, красный, таймаут,
«материал сменился», слежение за tracked-run — все 9 старых тестов плюс
2 новых зелёные).
- `scripts/merge-candidate.mjs`: сама формула фильтра корректна и
синтаксически идентична логике `validate-gate.mjs` — по коду поведение
правильное (проверено чтением и ручным откатом-и-восстановлением,
описанным выше), проблема только в отсутствии автотеста.
- Правка ТЗ (`docs/specs/...md`, одна строка) точно описывает реализованное
поведение обоих мест, включая «то же в `waitValidate` слияния» — текст
не разошёлся с кодом.
- `scripts/mutation-gate.mjs`: новая запись `review-returns-task-on-cancelled-dispatch`
синтаксически корректна (`find`/`replace` совпадают один-в-один со строкой
кода), `guard` запускает верный тестовый файл, лично прогнана — красит
тест при внесении мутации и не красит при чистом дереве.
## Чего не проверял и почему
- `check-docs.mjs`, golden, смоки, инварианты модели, `pytest tests_backend`,
perf-профили — дельта не касается `src/**`, `custom_components/**/*.py`
или геометрии; основание не изменилось с r1 (AC6).
- Пять мутантов протокола, подтверждённых в r1 (`mutants-run-on-every-push`,
`review-starts-on-red-validate`, `review-trusts-push-run-without-mutants`,
`merge-waits-push-run-without-mutants`, `merge-pushes-unvalidated-candidate`) —
не перепрогонял: дельта не меняет ни их `patches`, ни `guard`-файлы
(`git diff` выше показывает только добавление новой записи в реестр).
- Реальный прогон `validate-gate.mjs`/`merge-candidate.mjs` против живого
GitHub Actions — не воспроизводил (та же причина, что в r1: `gh` в этом
окружении не аутентифицирован для целей ревью); логика проверена юнитами,
мутантами и, для найденного пробела, ручным откатом-фикса — это и есть
предмет код-ревью для скрипта без сети.
- Зеркалирование `process.yml`/`validate.yml` в `main` (AC5, §5.3 ТЗ) — шаг
публикации после слияния, не код-ревью.
## Материал раунда
- SHA: `00130e08aa247cd8796a99125deb7f9b5131bfa1` (сверено `git rev-parse HEAD`
перед выводом).
- Предыдущий материал (r1, код): `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`.
- Ребейза между раундами не было — прямые 2 коммита сверху, дельта локальна.
## Вердикт
Жёлтый: одна находка Medium в скоупе (M1(r2) — половина фикса M1(r1) в
`scripts/merge-candidate.mjs` не защищена тестом/мутантом, регресс
воспроизведён лично и не ловится ни одним из 2420 тестов проекта), High
нет. Остальное из r1 (AC1, AC3–AC6, половина M1 в `validate-gate.mjs`)
закрыто полностью и подтверждено. Возврат автору для добавления теста
(и, по возможности, мутанта) на `realOps().waitValidate` в
`scripts/merge-candidate.mjs`.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`, коммит `00130e08aa24` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `e53cf9a26b9c66fb7602e485b8dffd1c7d286212`
```
git log --all --format='%H %T' | grep e53cf9a26b9c
```
- ТЗ `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, блоб `d68ed7818b4c77535305d0aae7da9765a2ad6c61`
```
git log --all --find-object=d68ed7818b4c77535305d0aae7da9765a2ad6c61 -- docs/specs/510-mutants-on-candidate-and-review-waits-validate.md
```
- Вердикт конвейера: `yellow` · High 0
+230
View File
@@ -0,0 +1,230 @@
# CODE-REVIEW-510-r3
- **Issue:** https://github.com/Matysh/houseplan-card/issues/510
- **Материал:** `615181050b9ba4d4568834d56ac5a0ed97ca804d` (рабочая копия на нём; `git rev-parse HEAD` сверен перед выводом)
- **Заход:** r3 · блокирующих циклов израсходовано 2 из 4 (зелёный вердикт цикла не образует, #227)
- **Ветка:** `issue/510-mutants-on-candidate-and-review-gate`
- **Предыдущий раунд:** CODE-REVIEW-510-r2 (жёлтый, Medium M1(r2)), материал `00130e08aa247cd8796a99125deb7f9b5131bfa1`
- **ТЗ:** `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md` (без изменений в этой дельте), ревью ТЗ зелёное на r4 (`docs/reviews/SPEC-REVIEW-510-r4.md`)
## Скоуп раунда
Ребейза между r2 и r3 не было: `git merge-base --is-ancestor 00130e08 HEAD`
подтверждает прямое потомство, `git log --oneline 00130e08..HEAD` даёт ровно
два коммита сверху:
```
61518105 test: the real waitValidate is exercised against a cancelled dispatch
8acbd333 docs: review document for #510
```
`git diff 00130e08..HEAD --stat` (без `docs/reviews/**`):
```
scripts/merge-candidate.mjs | 8 +-
scripts/mutation-gate.mjs | 11 ++
test/merge-candidate.test.mjs | 39 ++++
3 files changed, 55 insertions(+), 3 deletions(-)
```
Предмет дельты — ровно и единственно закрытие **M1(r2)**: половина фикса
M1(r1) в `scripts/merge-candidate.mjs::waitValidate` (отменённый dispatch не
должен читаться как красный) была исправлена в коде, но не имела ни одного
теста или мутанта — реальная реализация `waitValidate` внутри `realOps()`
никогда не вызывалась ни в одном тесте проекта. Разбор этого раунда
сосредоточен на том, закрыт ли этот пробел; AC1–AC6 дельта не задевает и
наследуются из r1/r2 без повторной проверки (раздел ниже).
## Как проверялось
Зелёного Validate на `61518105` нет — проверено лично:
`gh run list --repo Matysh/houseplan-card --workflow validate.yml --commit 61518105 …`
вернул `[]` (прогона на этом SHA не существует вовсе). Гейты прогнаны лично.
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| unit (полный набор) | `npm test` | 2423 тестов, 2422 pass, 1 skip (предсуществующий #281, не в этом диффе), 0 fail |
| build + бандл (2 committed-копии) | `npm run build` затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | сборка зелёная; `cmp` — идентичны; `git status --porcelain` после сборки пуст — рабочая копия и так была на актуальном бандле |
| смок-селектор | `node scripts/smoke-select.mjs --base 00130e08 --head HEAD` | «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут). Browser-smoke этим диффом не выбираются — выбирать нечего. Тронуто файлов: 4» — браузерные смоки не запускались, основание — вывод инструмента, а не тема диффа |
| check-docs / golden / инварианты / pytest / perf | не прогонялись | дельта не трогает `src/**` ни `custom_components/**/*.py` (подтверждено тем же diff --stat и выводом smoke-select) |
| новый мутант `merge-trusts-cancelled-dispatch` | `node scripts/mutation-gate.mjs --id=merge-trusts-cancelled-dispatch` | `поймано 1 из 1` (лично) |
| пять мутантов r1 + один мутант r2 (`review-returns-task-on-cancelled-dispatch`) | не перепрогонялись | делта r2→r3 не трогает их `patches`/`guard` — новая запись реестра добавлена, старые не изменены (`git diff` выше показывает только вставку блока) |
### Демонстрация: тест умеет падать (независимая проверка, не со слов автора)
Ручной откат фикса, тот же приём, что в r2, — на текущем коде:
```
# вручную снял фильтр в scripts/merge-candidate.mjs:
# const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
# → const runs = all.filter((x) => (!event || x.event === event));
node --test test/merge-candidate.test.mjs
```
Результат: **10 pass, 2 fail** — оба новых теста краснеют независимо друг от
друга:
```
not ok 11 - #510 r2 M1: realOps.waitValidate ignores a cancelled dispatch and follows its replacement
expected: { result: 'green', url: 'https://run/3' }
actual: не тот исход (продолжает опрашивать не туда/красит раньше)
not ok 12 - #510 r2 M1: realOps.waitValidate with only a cancelled dispatch reports missing after the appear window, never red
expected: 'missing'
actual: 'red'
```
Файл восстановлен, `git status --porcelain` пуст. Это прямой контраст с r2,
где тот же ручной откат при том же полном `npm test` **не ронял ни одного
теста из 2420** — асимметрия закрыта: обе ветки (`green`-цепочка через
замену и одинокий `cancelled` без замены) теперь имеют падающего свидетеля,
не только мутант из реестра.
Штатный мутант `merge-trusts-cancelled-dispatch`
(`scripts/mutation-gate.mjs:8161`) патчит ровно ту же строку тем же
`find`/`replace`, гвард — `node --test test/merge-candidate.test.mjs`,
прогнан лично: `поймано 1 из 1`.
## Находки
Новых High/Medium/Low не найдено. Дельта узкая, точечная и делает ровно то,
что требовала находка M1(r2) — не больше и не меньше.
Отдельно проверено, что фикс не меняет поведение продакшен-пути: единственный
вызов `realOps({ repo, token })` в `scripts/merge-candidate.mjs:242` не
передаёт `exec`, поэтому в реальном запуске используется тот же `sh`
(`spawnSync`-обёртка), что и до этой правки — инъекция существует только для
теста. Проверено чтением (`grep -rn "realOps(" ...` — единственный
продакшен-вызов без параметра `exec`).
## Закрытие раунда r2
| Находка r2 | Чем закрыта | Где это видно |
|---|---|---|
| **M1(r2)**: половина фикса M1(r1) в `scripts/merge-candidate.mjs::waitValidate` не защищена ни одним тестом/мутантом — реальная реализация не вызывалась ни в одном тесте проекта, ручной откат фильтра оставлял весь `npm test` (2420/2420) зелёным | `realOps()` получил инъекцию `exec` (по умолчанию `sh`, поведение продакшена не изменилось — см. выше); `test/merge-candidate.test.mjs:248-278` — два новых теста гоняют **реальный** `waitValidate` через скриптованные ответы `gh run list`, не подменяя саму функцию; мутант `merge-trusts-cancelled-dispatch` добавлен в `scripts/mutation-gate.mjs:8161-8171` | `git diff 00130e08..HEAD -- scripts/merge-candidate.mjs scripts/mutation-gate.mjs test/merge-candidate.test.mjs`; независимый ручной откат в этом раунде — 2/12 теста падают (было 0/2420 в r2); `node scripts/mutation-gate.mjs --id=merge-trusts-cancelled-dispatch` → «поймано 1 из 1» |
## Унаследовано из r1/r2
Принято без повторной проверки в этом раунде — дельта
(`scripts/merge-candidate.mjs` +4/-4 внутри `realOps`, `scripts/mutation-gate.mjs`
+11, `test/merge-candidate.test.mjs` +39) не задевает ни один из перечисленных
предметов. Источники: CODE-REVIEW-510-r1.md (материал `cbece6324f83b3400364fcd1a8eb1fa3b321a3ff`)
и CODE-REVIEW-510-r2.md (материал `00130e08aa247cd8796a99125deb7f9b5131bfa1`).
- **AC1** (обычный push не запускает `changed_mutants`; dispatch/PR/schedule/кандидат
беты — запускают) — доказано `test/classify-changes.test.mjs` +
`test/validate-workflow.test.mjs`, мутант `mutants-run-on-every-push`
(«поймано 1 из 1» в r1). Файлы дельтой r2→r3 не тронуты.
- **AC2** (гейт находит/запускает/красный/таймаут/несовпадение SHA, включая
`cancelled`) — доказано `test/validate-gate.test.mjs`, мутанты
`review-starts-on-red-validate` / `review-trusts-push-run-without-mutants` /
`review-returns-task-on-cancelled-dispatch` (последний подтверждён в r2).
`scripts/validate-gate.mjs` дельтой r2→r3 не тронут вовсе.
- **AC3** (слияние ждёт dispatch с мутантами, не push) — доказано
`test/merge-candidate.test.mjs`, мутанты `merge-waits-push-run-without-mutants` /
`merge-pushes-unvalidated-candidate`. Логика диспатча и ожидания вне участка
`cancelled`-фильтра дельтой не изменена.
- **AC4** (мутанты протокола пойманы штатным раннером) — шесть мутантов r1/r2
(`mutants-run-on-every-push`, `review-starts-on-red-validate`,
`review-trusts-push-run-without-mutants`, `merge-waits-push-run-without-mutants`,
`merge-pushes-unvalidated-candidate`, `review-returns-task-on-cancelled-dispatch`)
не перепрогонялись — их `patches`/`guard` не менялись (`git diff` показывает
только добавление новой записи `merge-trusts-cancelled-dispatch`). Седьмой
мутант этого раунда прогнан лично и подтверждён выше.
- **AC5** (доки описывают место мутантов и правила хендоффа) — сверены
построчно в r1, дельта r2→r3 не касается `PROCESS.md`/`AGENTS.md`/
`docs/TESTING.md`/`docs/specs/README.md` (только `scripts/**` и `test/**`).
- **AC6** (`src/**` не тронут, perf/touch/UX не задеты) — верно и для этой
дельты: `git diff 00130e08..HEAD --stat` не содержит ни одного файла
`src/**`, подтверждено также выводом `smoke-select.mjs`.
- Асимметрия дизайна между `validate-gate.mjs` и `merge-candidate.mjs` —
признана обоснованной в r1, дельта её не меняет.
- Замечание r2 о риске дублирования одинаковой формулы фильтра в двух файлах
(`validate-gate.mjs` и `merge-candidate.mjs`) — это было наблюдение о
будущем рефакторинге, не находка с блокирующим статусом; r2 не потребовала
унификации, только теста на существующий код. Дельта r3 её не адресует и не
обязана была: предмет M1(r2) — отсутствие свидетеля, а не дублирование кода.
## Что проверено и корректно (в этом раунде)
- `scripts/merge-candidate.mjs`: инъекция `exec = sh` в сигнатуру `realOps`
консервативна — единственный продакшен-вызов (`mergeCandidate` внутри
`main()`, строка 242) не передаёт `exec`, значит в реальном запуске
используется прежний `sh`/`spawnSync`; поведение продакшена не изменилось
(проверено чтением всех вызовов `realOps(` в дереве).
- `test/merge-candidate.test.mjs:239-246` (`scriptedExec`): фейковый `exec`
отвечает только на `gh run list`, на любой другой вызов бросает — новые
тесты не могут случайно замаскировать вызов `git`/`gh workflow run`
реальной командой; поведение проверено запуском (12/12 pass).
Оба теста используют управляемые `sleep`/`now` (счётчик `clock`), поэтому
не спят по-настоящему и не зависят от таймингов среды.
- Тест 1 (`ignores a cancelled dispatch and follows its replacement`)
проверяет **обе** стороны контракта одним прогоном: что `cancelled` не даёт
зелёный/красный результат по первому ответу (иначе `gh.calls()` было бы
1, а не 3) и что итоговый green берётся из `replacement`, а не из
`cancelled`.
- Тест 2 (`reports missing after the appear window, never red`) закрывает
сценарий «замены не будет вовсе» — обязательный по тексту AC2/M1(r1)
(«ждём замену, а не красим») симметрично для пути слияния.
- Новая запись реестра `merge-trusts-cancelled-dispatch`
(`scripts/mutation-gate.mjs:8161-8171`): `find`/`replace` совпадают
один-в-один со строкой кода на момент проверки, `guard` запускает
правильный тестовый файл, лично прогнана — красит при внесении мутации
(`поймано 1 из 1`) и не красит на чистом дереве.
- Двойное независимое воспроизведение (ручной откат + штатный мутант) даёт
одинаковый вывод: регрессия ловится, причём **сильнее**, чем закрывает
минимальный порог §2.7 (для чистых юнитов достаточно ручного прогона со
снятой защитой — здесь есть и он, и зарегистрированный мутант).
## Чего не проверял и почему
- `check-docs.mjs`, golden, браузерные смоки, инварианты модели,
`pytest tests_backend`, perf-профили — дельта не касается `src/**`,
`custom_components/**/*.py` или геометрии; `smoke-select.mjs` подтверждает
это инструментально, а не по теме диффа.
- Шесть мутантов протокола, подтверждённых в r1/r2 — не перепрогонял: дельта
не меняет ни их `patches`, ни `guard`-файлы (диф показывает только вставку
новой записи).
- Реальный прогон `merge-candidate.mjs` против живого GitHub Actions — не
воспроизводил (та же причина, что в r1/r2: цель кода-ревью для скрипта без
сети — юниты, мутанты и ручное воспроизведение регрессии, что и сделано).
- Зеркалирование `process.yml`/`validate.yml` в `main` (AC5, §5.3 ТЗ) — шаг
публикации после слияния, вне код-ревью.
## Материал раунда
- SHA: `615181050b9ba4d4568834d56ac5a0ed97ca804d` (сверено `git rev-parse HEAD`
непосредственно перед выводом).
- Предыдущий материал (r2, код): `00130e08aa247cd8796a99125deb7f9b5131bfa1`.
- Ребейза между раундами не было — прямые 2 коммита сверху
(`git merge-base --is-ancestor 00130e08 HEAD` — успех), дельта локальна.
## Вердикт
Зелёный. M1(r2) закрыта полностью: тестом на реальную реализацию
`waitValidate` (не фейк) и отдельно зарегистрированным мутантом
`merge-trusts-cancelled-dispatch`; независимое воспроизведение регрессии в
этом раунде даёт 2 упавших теста из 12 (в r2 было 0 из 2420 на том же
приёме) — асимметрия «код исправлен, но не защищён» устранена. High и Medium
не найдено. Дешёвые гейты (`typecheck`, `npm test`, `build`+сверка бандла)
зелёные, лично прогнаны за отсутствием зелёного Validate на этом SHA;
`smoke-select.mjs` подтверждает отсутствие исполняемого frontend-диффа —
браузерные проверки не нужны по инструменту, а не по теме. AC1–AC6
наследуются из r1/r2 без повторной проверки — дельта их не задевает.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/510-mutants-on-candidate-and-review-gate`, коммит `615181050b9b` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `1cc8f27b4d5e0648f5133206f0843a91cca54754`
```
git log --all --format='%H %T' | grep 1cc8f27b4d5e
```
- ТЗ `docs/specs/510-mutants-on-candidate-and-review-waits-validate.md`, блоб `d68ed7818b4c77535305d0aae7da9765a2ad6c61`
```
git log --all --find-object=d68ed7818b4c77535305d0aae7da9765a2ad6c61 -- docs/specs/510-mutants-on-candidate-and-review-waits-validate.md
```
- Вердикт конвейера: `green` · High 0

Some files were not shown because too many files have changed in this diff Show More