Compare commits

...
Author SHA1 Message Date
claude[bot] 5fb510290a docs: review document for #103
Validate / docs (push) Successful in 34s
Validate / provenance (push) Successful in 59s
Validate / changes (push) Successful in 52s
Validate / process-gate (push) Failing after 57s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Issue: #103
User-Visible: no
2026-08-19 00:34:45 +00:00
Sergey Matyunin 932773773f Show state in toggle confirmations
Issue: #103
User-Visible: yes
2026-08-19 03:23:58 +03:00
claude[bot]andSergey Matyunin 6c7958c6d0 docs: add spec review r1 for toggle confirmation state
Issue: #103
User-Visible: no
2026-08-19 03:12:56 +03:00
Sergey Matyunin a449edc545 docs: specify toggle confirmation states
Issue: #103
User-Visible: no
2026-08-19 03:12:55 +03:00
claude[bot] e88c23b8ee docs: review document for #113
Validate / provenance (push) Successful in 45s
Validate / changes (push) Successful in 56s
Validate / process-gate (push) Failing after 58s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / smoke (push) Skipped
Validate / backend (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / docs (push) Failing after 34s
Issue: #113
User-Visible: no
2026-08-19 00:11:27 +00:00
Sergey Matyunin acad3b32c1 Refresh documentation source fingerprint
Issue: #113
User-Visible: no
2026-08-19 03:01:22 +03:00
claude[bot] b443a333e0 docs: review document for #113
Issue: #113
User-Visible: no
2026-08-18 23:59:36 +00:00
Sergey Matyunin 66fa8f476c Keep mutation anchor aligned after optional model change
Issue: #113
User-Visible: no
2026-08-19 02:51:50 +03:00
claude[bot]andSergey Matyunin 047363c2d3 docs: code review document for #113
Issue: #113
User-Visible: no
2026-08-19 02:49:39 +03:00
Sergey Matyunin 1e8503bd46 Make empty space model explicit
Issue: #113
User-Visible: no
2026-08-19 02:49:39 +03:00
claude[bot]andSergey Matyunin 135497b272 docs: review document for #113
Issue: #113
User-Visible: no
2026-08-19 02:47:50 +03:00
Sergey Matyunin 0c2a5dedea docs: specify optional space model contract
Issue: #113
User-Visible: no
2026-08-19 02:47:50 +03:00
claude[bot] c1676cf26a docs: review document for #117
Validate / docs (push) Failing after 35s
Validate / provenance (push) Successful in 53s
Validate / process-gate (push) Failing after 55s
Validate / changes (push) Successful in 46s
Validate / smoke (push) Skipped
Validate / backend (push) Skipped
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Issue: #117
User-Visible: no
2026-08-18 23:20:52 +00:00
claude[bot]andSergey Matyunin c65cbcc96c docs: review document for #117
Issue: #117
User-Visible: no
2026-08-19 02:12:37 +03:00
Sergey Matyunin 01fe48de00 fix: support registryless opening entities
Issue: #117
User-Visible: yes
2026-08-19 02:12:37 +03:00
claude[bot]andSergey Matyunin 9baf533c90 docs: review document for #117
Issue: #117
User-Visible: no
2026-08-19 02:11:36 +03:00
Sergey Matyunin 563a850aac docs: specify registryless opening entities
Issue: #117
User-Visible: no
2026-08-19 02:11:35 +03:00
claude[bot] 9e5ff0b8a0 docs: review document for #132
Issue: #132
User-Visible: no
2026-08-18 22:51:35 +00:00
Sergey Matyunin 3fe0f8c443 fix: address partition opening review regressions
Issue: #132
User-Visible: yes
2026-08-19 01:34:51 +03:00
claude[bot] 742b3279a2 docs: code review document for #132
Issue: #132
User-Visible: no
2026-08-18 22:28:09 +00:00
Sergey Matyunin 9f77e3e932 feat: support openings in independent walls
Issue: #132
User-Visible: yes
2026-08-19 01:11:55 +03:00
Sergey Matyunin 083621342a docs: address partition openings spec review
Issue: #132
User-Visible: no
2026-08-19 00:35:59 +03:00
claude[bot] 4f20befd77 docs: review document for #132
Issue: #132
User-Visible: no
2026-08-18 21:33:56 +00:00
Sergey Matyunin 2aaabc48d6 docs: update partition openings spec after unified walls
Issue: #132
User-Visible: no
2026-08-19 00:21:38 +03:00
Sergey Matyunin b9bf210804 docs: specify partition openings
Issue: #132
User-Visible: no
2026-08-19 00:19:01 +03:00
Sergey Matyunin d6007dc444 ci: retry prerelease gate after runner mirror timeout
Issue: #150
Issue: #157
Issue: #170
Issue: #172
Issue: #173
Issue: #174
Issue: #178
User-Visible: no
2026-08-18 22:44:29 +03:00
Sergey Matyunin 54c5ca3840 test: accept v1.65.0-beta.2 golden baselines
Issue: #150
Issue: #172
Issue: #173
Issue: #178
User-Visible: no
Release: v1.65.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32175473418
2026-08-18 22:26:47 +03:00
Sergey Matyunin 57fc434d4f Release v1.65.0-beta.2 candidate
Issue: #150
Issue: #157
Issue: #170
Issue: #172
Issue: #173
Issue: #174
Issue: #178
User-Visible: yes
2026-08-18 22:11:05 +03:00
Sergey Matyunin 9530ee2e5a fix: preserve short wall closure gestures
Issue: #173
User-Visible: yes
2026-08-18 22:05:03 +03:00
108 changed files with 7772 additions and 826 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.65.0-beta.1"
VERSION = "1.65.0-beta.2"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
File diff suppressed because one or more lines are too long
+16 -3
View File
@@ -48,9 +48,10 @@ from .validation import (
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages,
validate_opening_passages, validate_partition_opening_hosts,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
)
FORMAT = "houseplan-export"
@@ -264,7 +265,8 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
_pick_fields(
opening,
("id", "type", "x", "y", "angle", "length")
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v")),
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
+ (("host",) if opening.get("host") else ()),
)
for opening in space.get("openings") or []
]
@@ -840,6 +842,16 @@ def build_space_merge(
for room in space.get("rooms") or []:
if room.get("open_to"):
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
# Opening ownership is part of the same space-local id graph. Remap the
# nested reference together with the partition itself; otherwise the
# invariant validator correctly rejects a copied space whose host.id still
# names the source partition.
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") == "partition":
old_host_id = str(host.get("id"))
if old_host_id in id_map:
host["id"] = id_map[old_host_id]
space["id"] = new_space_id
space["title"] = _unique_title(
str(space.get("title") or old_space_id), current_config.get("spaces") or []
@@ -980,7 +992,8 @@ def build_space_merge(
validate_marker_light_entities(merged_config, current_config)
validate_marker_value_badges(merged_config, current_config)
validate_opening_passages(merged_config, current_config)
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(merged_config, current_config)
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
raise ImportFailure(err.code, str(err)) from err
try:
merged_layout = LAYOUT_SCHEMA(merged_layout)
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.65.0-beta.1"
"version": "1.65.0-beta.2"
}
+66
View File
@@ -46,6 +46,39 @@ class OpeningPassageError(ValueError):
)
class PartitionOpeningHostError(ValueError):
"""A write tried to strip explicit host identity from a surviving opening."""
code = "invalid_partition_opening_host"
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None
) -> None:
"""Prevent an older writer from silently downgrading hosted openings.
Deleting the opening together with its partition remains valid. Only a
surviving record that previously had an explicit host must keep one.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
old_space = old_spaces.get(str(space.get("id", "")))
if not old_space:
continue
old_openings = {
str(opening.get("id")): opening
for opening in old_space.get("openings") or []
}
for opening in space.get("openings") or []:
old = old_openings.get(str(opening.get("id", "")))
if old and old.get("host") is not None and opening.get("host") is None:
raise PartitionOpeningHostError(
f"space={space.get('id', '')}; opening={opening.get('id', '')}; host removed"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
@@ -767,6 +800,15 @@ WALL_COLUMN_SCHEMA = vol.All(
_strict_wall_column,
)
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("partition"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
@@ -784,6 +826,29 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in value.get("openings", []):
host = opening.get("host")
if host is None:
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
length = float(opening["length"])
along = float(host["t"]) * span
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("partition opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = hosted_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
raise vol.Invalid("partition openings must not overlap")
occupied.append((lo, hi))
return value
@@ -849,6 +914,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
+7 -4
View File
@@ -57,8 +57,9 @@ from .virtual_lights import (
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, sanitize_filename,
validate_opening_passages,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
@@ -1256,7 +1257,8 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
validate_opening_passages(msg["config"], data.get("config"))
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
@@ -1367,7 +1369,8 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
validate_opening_passages(msg["config"], config_data.get("config"))
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
connection.send_error(msg["id"], err.code, str(err))
return
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

+34 -31
View File
@@ -1,44 +1,45 @@
{
"schema": 1,
"matrixVersion": 24,
"acceptedAt": "2026-08-17T10:51:28.998Z",
"sourceFingerprint": "b73e5020215967d2d13e2a08ce303916e2bdbcbd45c94c40c6e56336a8097656",
"matrixVersion": 26,
"acceptedAt": "2026-08-18T19:24:52.977Z",
"sourceFingerprint": "5d50c30316c1e2ec9b9102c7c8f14512fedf610a1a5a43eaa065827ca6e60d13",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
"split-zero-divider-taper-dark": "4c63083b1cb9e1079fa56e6991a035124d0ce22527489712a7a9161f1ccf2107",
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
"geometry-plan-editor-dark": "0e7b01e74c82ab5072bffbfaddfaa21b07089662b19f491a7c6783a4152af9fb",
"plan-snap-endpoint-light": "6187b35a780db09626bd9b8a06461dc133e4027722e965908811226f260d327a",
"plan-snap-line-gaps-dark": "5ae0634f63718be4b2c1fcabd2b474ecbc980fe96a3bdd2987ca8356472b1076",
"wall-junctions-plan-preview-light": "a8a04b5c611a880880b6b6d64d10eb50576a5a377c984a5b397075b225739bd5",
"wall-junctions-plan-t-dark": "479ab196fb52b332615182401e2680ccfbd4362f25b0a294b481b32195119d72",
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"opening-placement-door-thick-wall-dark": "4393770bb97cb051d76f5b2aba9d314a8789a20278ccafb31c048300ccb4d8cf",
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
"tray-wide-selection-en": "995477b887a302c25d8a0fe540a1b8ee56369e21100cdb367a3bf37c8dcc99c1",
"tray-wide-tool-ru": "0fa205ba48803255f8e0dc51286b7fc151ccfd319dfb932fd4927fff124d0396",
"tray-medium-group-en": "b6170877b12de6ff7b03e4c3a0099141a7fa010448bac914ce6dafe321594b83",
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
"tray-narrow-tool-ru": "a144a1927ab60a45630f95d36d163c9db1e5ff8714558b0703678be0695ec19e",
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
@@ -57,16 +58,18 @@
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"toggle-entity-dialog-desktop-en": "013e2d01382435d995e8deece19e07e9bcced4fe80952cbab765e642b3d641d6",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
"backup-plan-only-export-desktop-en": "2833ee45acac2936e76546e9ff5d0031932c02fbf66fe904c1be94dc3e33dc05",
"decor-color-popover-mobile-ru": "91837c71c52eb5447d9d1754038b559838af595d41f44e6587ea8f4c40f96ed9",
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 335 KiB

+5 -4
View File
@@ -205,8 +205,7 @@ const res = await page.evaluate(async () => {
await settle();
out.noPreviewAwayFromWalls = !root().querySelector('.opening-preview');
// Real independent bodies are occluders, not room-owned wall intervals.
// They must never become accidental opening-placement targets.
// #132: saved independent wall segments are first-class opening hosts.
const bodyCentre = card._roomCenter(room);
const bodyX = bodyCentre[0] / 1000;
const bodyY = bodyCentre[1] / card._spaceH;
@@ -217,8 +216,10 @@ const res = await page.evaluate(async () => {
card._openingPlacementIntervalsCache = null;
card._cursorPt = bodyCentre;
await settle();
out.partitionIsNotOpeningTarget = !root().querySelector('.opening-preview')
&& card._resolveOpeningPlacement(bodyCentre) == null;
const partitionCandidate = card._resolveOpeningPlacement(bodyCentre);
out.partitionIsOpeningTarget = !!root().querySelector('.opening-preview')
&& partitionCandidate?.host?.kind === 'partition'
&& partitionCandidate.host.id === 'opening-smoke-partition';
card._curSpaceCfg.partitions = [];
card._curSpaceCfg.wall_columns = [{
id: 'opening-smoke-column', shape: 'square', cm: 30, center: [bodyX, bodyY], angle: 0,
+90
View File
@@ -0,0 +1,90 @@
// #113: deleting the final space and receiving an empty plan over WS must be
// a supported lifecycle state, not a synthetic SpaceModel or an exception.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
await card.updateComplete;
await sleep(40);
await card.updateComplete;
};
const original = structuredClone(card._serverCfg);
const first = structuredClone(original.spaces[0]);
// Exercise the real delete command with exactly one remaining space.
card._serverCfg = { ...structuredClone(original), spaces: [first] };
card._space = first.id;
card._cfgEpoch++;
card.requestUpdate();
await settle();
card._openSpaceDialog('edit', first.id);
card._mode = 'plan';
card._path = [[100, 100], [200, 100]];
card._resumeDraftBySpace = { [first.id]: 'stale-draft' };
card._pointers.set(113, { x: 10, y: 10 });
card._drag = { id: 'stale-device', sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
card._saveConfigDebounced();
const confirmBefore = window.confirm;
window.confirm = () => true;
try {
await card._deleteSpace();
} finally {
window.confirm = confirmBefore;
}
await settle();
out.deleteLastRendersEmpty = !!root().querySelector('.empty') && !root().querySelector('.stage');
out.deleteLastClearsSelection = card._space === '' && card._spaceModel() === undefined;
out.deleteLastAbortsEditorState = card._mode === 'view'
&& card._path.length === 0 && card._pointers.size === 0 && card._drag === null
&& Object.keys(card._resumeDraftBySpace).length === 0;
out.deleteLastClosesEditDialog = card._spaceDialog === null;
out.deleteLastCancelsPendingWrite = card._saveConfigDebounced.pending() === false;
// The empty state still owns global recovery flows.
const add = root().querySelector('.empty button.btn.on');
add?.click();
await settle();
out.createFlowSurvivesEmpty = card._spaceDialog?.mode === 'create';
// Recreate a plan, then reproduce the same transition as a config-updated WS
// event. This also arms the once-per-empty cleanup for a second cycle.
card._spaceDialog = null;
card._serverCfg = structuredClone(original);
card._space = original.spaces[0].id;
card._cfgEpoch++;
card.requestUpdate();
await settle();
out.recreateRestoresPlan = !!root().querySelector('.stage') && card._spaceModel()?.id === card._space;
card._mode = 'devices';
card._pointers.set(114, { x: 20, y: 20 });
card._serverCfg = { ...structuredClone(original), spaces: [] };
card._cfgEpoch++;
card.requestUpdate();
await settle();
out.wsEmptyAbortsLiveGesture = card._mode === 'view'
&& card._pointers.size === 0 && card._spaceModel() === undefined;
// Empty plans must remain inert under unrelated HA/render ticks.
card._serverCanWrite = false;
card.hass = {
...card.hass,
themes: { ...(card.hass.themes || {}), darkMode: !card.hass.themes?.darkMode },
};
window.dispatchEvent(new Event('resize'));
await settle();
out.emptySurvivesThemeResizeReadonly = !!root().querySelector('.empty')
&& !root().querySelector('.stage') && card._mode === 'view';
return out;
});
checkAll(result);
await finish(browser, result);
+147
View File
@@ -0,0 +1,147 @@
/** Issues #132/#185: independent-wall host lifecycle and structural wall continuity. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 960, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const pointIn = (point, body) => {
let inside = false;
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
const a = body[i], b = body[j];
if (((a[1] > point[1]) !== (b[1] > point[1]))
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / ((b[1] - a[1]) || 1e-12) + a[0])
inside = !inside;
}
return inside;
};
card._serverCfg = {
spaces: [{
id: 'partition-openings', title: 'Partition openings', cell_cm: 5,
view_box: [0, 0, 1, 1],
rooms: [{
id: 'room', name: 'Room', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}],
partitions: [{ id: 'host', a: [0.25, 0.5], b: [0.75, 0.5], cm: 15 }],
}],
markers: [], settings: {},
};
card._layout = {};
card._space = 'partition-openings';
card._modelCache = null;
card._cfgEpoch++;
card._saveConfig = () => {
card._cfgEpoch++;
card._modelCache = null;
card._physicalBodiesCache = null;
card._planSnapGeometryCache = null;
card.requestUpdate();
};
card._setMode('plan');
card._activateOpeningPlacement('door');
await update();
const centre = [500, 500];
const candidate = card._resolveOpeningPlacement(centre);
result.partitionCandidateCarriesStableHost = candidate?.host?.kind === 'partition'
&& candidate.host.id === 'host' && Math.abs(candidate.host.t - 0.5) < 1e-9;
card._openingClick(centre);
card._saveOpening();
await update();
let space = card._curSpaceCfg;
const saved = space.openings?.[0];
result.saveMaterializesHostAndCompatibilityGeometry = saved?.host?.id === 'host'
&& Math.abs(saved.host.t - 0.5) < 1e-9
&& Math.abs(saved.x - 0.5) < 1e-9
&& Math.abs(saved.y - 0.5) < 1e-9
&& !!root().querySelector(`[data-hp="opening"][data-id="${saved.id}"]`);
card._physicalBodiesCache = null;
const bodies = card._physicalBodiesR();
result.hostBodyHasFullDepthOpeningGap = !bodies.some((body) => pointIn(centre, body));
const base = card._spaceModel().partitions.find((partition) => partition.id === 'host');
card._physicalDrag = {
pid: 132, kind: 'partition', id: 'host', start: [...base.a],
startClient: [0, 0], before: card._geometrySnapshot(), moved: true,
base: JSON.parse(JSON.stringify(base)), delta: [50, 25],
};
card._physicalUp(new PointerEvent('pointerup', { pointerId: 132, bubbles: true }));
await update();
space = card._curSpaceCfg;
const movedOpening = space.openings?.[0];
result.rigidHostMoveKeepsTAndMovesProjectionAtomically = !!movedOpening
&& Math.abs(movedOpening.host.t - 0.5) < 1e-9
&& Math.abs(movedOpening.x - 0.55) < 1e-9
&& Math.abs(movedOpening.y - 0.525) < 1e-9;
card._physicalSel = { kind: 'partition', id: 'host' };
card._deletePhysicalSelection();
await update();
result.deleteRequiresAccessibleListDialog = !!card._partitionDeleteDialog
&& root().querySelectorAll('hp-dialog li').length === 1
&& !!space.partitions?.length && !!space.openings?.length;
card._partitionDeleteDialog = null;
await update();
result.deleteCancelIsMutationFree = !!space.partitions?.length && !!space.openings?.length;
card._physicalSel = { kind: 'partition', id: 'host' };
card._deletePhysicalSelection();
card._confirmPartitionDelete();
await update();
result.deleteConfirmCascadesHostAndOpening = !space.partitions && !space.openings;
card._undoGeometry();
await update();
space = card._curSpaceCfg;
result.deleteUndoRestoresHostAndOpening = space.partitions?.[0]?.id === 'host'
&& space.openings?.[0]?.host?.id === 'host';
card._redoGeometry();
await update();
space = card._curSpaceCfg;
result.deleteRedoCascadesAgain = !space.partitions && !space.openings;
space.partitions = [{ id: 'replacement', a: [0.3, 0.6], b: [0.7, 0.6], cm: 15 }];
space.openings = [{
id: 'orphan', type: 'door', x: 0.5, y: 0.6, angle: 0, length: 0.1,
host: { kind: 'partition', id: 'missing', t: 0.5 },
}];
card._saveConfig();
await update();
card._editOpening(card._openingsR[0]);
card._rebindPartitionOpening();
result.orphanRebindSessionKeepsOriginalIdentity = card._openingRebindId === 'orphan';
card._openingClick([500, 600]);
card._saveOpening();
await update();
result.orphanRebindReplacesHostWithoutDuplicate = space.openings.length === 1
&& space.openings[0].id === 'orphan'
&& space.openings[0].host?.id === 'replacement';
// Production-bundle regression for #185: a legacy room-wall opening does
// not cut the structural segment consumed by #173 room-face detection.
space.rooms = [{
id: 'room', name: 'Room', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}];
delete space.partitions;
space.openings = [{
id: 'legacy-door', type: 'door', x: 0.5, y: 0.1, angle: 0, length: 0.2,
}];
card._saveConfig();
await update();
const structural = card._planStructuralGeometrySnapshot().value;
result.openingKeepsRoomFaceAxisContinuous = structural.segments.some((segment) =>
segment.sourceKind === 'room'
&& Math.min(segment.a[0], segment.b[0]) <= 100 + 1e-6
&& Math.max(segment.a[0], segment.b[0]) >= 900 - 1e-6
&& Math.abs(segment.a[1] - 100) < 1e-6
&& Math.abs(segment.b[1] - 100) < 1e-6);
return result;
});
await finish(browser, checkAll(out));
+163
View File
@@ -0,0 +1,163 @@
// #117: exact YAML contact/lock references without Entity Registry rows must
// survive the immutable render projection without weakening disabled/security rules.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const contactId = 'binary_sensor.hp117_yaml_contact';
const lockId = 'lock.hp117_yaml_lock';
const space = card._serverCfg.spaces.find((item) => item.id === 'f1');
space.openings = [
{
id: 'hp117-window', type: 'window', x: 0.22, y: 0.14,
angle: 0, length: 0.09, contact: contactId,
},
{
id: 'hp117-door', type: 'door', x: 0.40, y: 0.14,
angle: 0, length: 0.09, contact: contactId, lock: lockId,
},
];
card._serverCfg = {
...card._serverCfg,
markers: [
{ id: 'hp117-contact-gone', binding: `entity:${contactId}`, removed: true, hidden: true },
{ id: 'hp117-lock-gone', binding: `entity:${lockId}`, removed: true, hidden: true },
],
};
card._cfgEpoch++;
card._setMode('view');
const serviceCalls = [];
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: {
entity_id: contactId, state: 'off',
attributes: { friendly_name: 'YAML opening contact', device_class: 'window' },
},
[lockId]: {
entity_id: lockId, state: 'locked',
attributes: { friendly_name: 'YAML opening lock' },
},
},
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, entityId: data.entity_id });
},
};
await settle();
const rendered = () => card._openingsR;
const yamlWindow = () => rendered().find((opening) => opening.id === 'hp117-window');
const yamlDoor = () => rendered().find((opening) => opening.id === 'hp117-door');
result.noRegistryRowsExist = card.hass.entities?.[contactId] == null
&& card.hass.entities?.[lockId] == null;
result.pickerOffersExactYamlEntities = card._contactCandidates()
.some((item) => item.value === contactId)
&& card._lockCandidates().some((item) => item.value === lockId);
result.frozenProjectionKeepsYamlStates = card._renderPlanHass.entities?.[contactId] == null
&& card._renderPlanHass.entities?.[lockId] == null
&& card._renderPlanHass.states?.[contactId]?.state === 'off'
&& card._renderPlanHass.states?.[lockId]?.state === 'locked';
result.markerTombstonesDoNotBlockOpening = card._renderOpeningEntityAvailable(contactId)
&& card._renderOpeningEntityAvailable(lockId)
&& !card._planEntityAvailable(contactId)
&& !card._planEntityAvailable(lockId);
result.closedContactControlsPresentation = card._openingAmt(yamlWindow()) === 0
&& card._openingAmt(yamlDoor()) === 0;
result.yamlLockBadgeRendersLocked = root().querySelectorAll('.oplock.locked').length === 1;
const callsBeforePlanTap = serviceCalls.length;
root().querySelector('[data-hp="opening"][data-id="hp117-door"] .op-hit')
?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
root().querySelector('.oplock')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await settle();
result.planOpeningAndBadgeNeverActuate = serviceCalls.length === callsBeforePlanTap;
result.badgeOpensInfoWithBothRows = card._openingInfo?.id === 'hp117-door'
&& root().querySelectorAll('.oprow').length === 2
&& root().querySelectorAll('.lockact').length === 1;
window.confirm = () => true;
card._lockAction(lockId, 'unlock');
result.explicitInfoActionStillWorks = serviceCalls.at(-1)?.domain === 'lock'
&& serviceCalls.at(-1)?.service === 'unlock'
&& serviceCalls.at(-1)?.entityId === lockId;
card._openingInfo = null;
await settle();
card._physicalBodiesR();
const frameBefore = card._renderPlanHass;
const physicalBefore = card._physicalBodiesCache;
const epochBefore = card._cfgEpoch;
const configBefore = JSON.stringify(card._serverCfg);
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: { ...card.hass.states[contactId], state: 'on' },
[lockId]: { ...card.hass.states[lockId], state: 'unlocked' },
},
};
const oldFrameHeldUntilUpdate = card._renderPlanHass === frameBefore
&& card._openingAmt(yamlWindow()) === 0;
await settle();
result.stateTickSwapsOneAtomicFrame = oldFrameHeldUntilUpdate
&& card._renderPlanHass !== frameBefore
&& card._openingAmt(yamlWindow()) === 1
&& card._openingAmt(yamlDoor()) === 1
&& root().querySelectorAll('.oplock.unlocked').length === 1;
result.stateTickDoesNotRebuildGeometryOrConfig = card._physicalBodiesCache === physicalBefore
&& card._cfgEpoch === epochBefore
&& JSON.stringify(card._serverCfg) === configBefore;
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: { ...card.hass.states[contactId], state: 'unknown' },
[lockId]: { ...card.hass.states[lockId], state: 'unknown' },
},
};
await settle();
result.unknownKeepsExistingTypeSemantics = card._renderOpeningEntityAvailable(contactId)
&& card._openingAmt(yamlWindow()) === 0
&& card._openingAmt(yamlDoor()) === 1
&& root().querySelectorAll('.oplock.unknown').length === 1;
space.openings = [
{
id: 'hp117-disabled-window', type: 'window', x: 0.22, y: 0.14,
angle: 0, length: 0.09, contact: 'binary_sensor.window',
},
{
id: 'hp117-disabled-lock', type: 'door', x: 0.40, y: 0.14,
angle: 0, length: 0.09, lock: 'lock.front_door',
},
];
card._cfgEpoch++;
window.__setRegistryDisabled('entity', 'binary_sensor.window', 'user');
window.__setRegistryDisabled('entity', 'lock.front_door', 'user');
await new Promise((resolve) => setTimeout(resolve, 220));
await settle();
const disabledWindow = card._openingsR.find((opening) => opening.id === 'hp117-disabled-window');
result.explicitDisabledRowsRemoveStaleStates = card._renderPlanHass.states?.['binary_sensor.window'] == null
&& card._renderPlanHass.states?.['lock.front_door'] == null
&& !card._renderOpeningEntityAvailable('binary_sensor.window')
&& !card._renderOpeningEntityAvailable('lock.front_door')
&& card._openingAmt(disabledWindow) === 0
&& root().querySelectorAll('.oplock').length === 0;
return result;
});
checkAll(out);
await finish(browser, out);
+4 -2
View File
@@ -33,6 +33,7 @@ const out = await page.evaluate(async () => {
card._draftSegmentCms = [];
card._closingWallCm = null;
card._roomDialog = false;
card._wallFaceBatch = null;
card._roomEditId = null;
card._pendingSplit = null;
card._toast = '';
@@ -120,7 +121,7 @@ const out = await page.evaluate(async () => {
result.sharedWallKeepsNeighbourThickness = !!sharedWall
&& (card._curSpaceCfg.walls || []).filter((wall) => wall.cm === 15).length >= 3;
// A canonical cut splits the room wall, so the same endpoint pair no longer closes.
// #185: an architectural opening cuts masonry, not the structural wall axis.
await reset({ openings: [{
id: 'door', type: 'door', x: 0.5, y: 0.3, angle: 90, length: 0.1,
}] });
@@ -128,7 +129,8 @@ const out = await page.evaluate(async () => {
await click(800, 100);
await click(800, 500);
await click(500, 500);
result.openingCutPreventsAutoClose = !card._roomDialog
result.openingKeepsStructuralAutoClose = card._roomDialog
&& card._wallFaceBatch?.candidates.length === 1
&& card._path.length === 4
&& currentDraft()?.points.length === 4;
+172
View File
@@ -0,0 +1,172 @@
// #103: Toggle confirmation explains the current snapshot and the exact
// nextEffect while execution still resolves the current intent again.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 760 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const calls = [];
const coverState = (state, entityId = 'cover.gate', deviceClass = 'curtain') => ({
entity_id: entityId,
state,
attributes: {
friendly_name: entityId === 'cover.gate'
? 'Very long living-room curtain name that must wrap on a phone'
: 'Other curtain',
device_class: deviceClass,
supported_features: 11,
},
});
const setCover = async (state, extraStates = {}, deviceClass = 'curtain') => {
card.hass = {
...card.hass,
states: { ...card.hass.states, 'cover.gate': coverState(state, 'cover.gate', deviceClass), ...extraStates },
services: {
...card.hass.services,
cover: {
...card.hass.services?.cover,
open_cover: {}, close_cover: {}, stop_cover: {}, toggle: {},
},
},
// The demo fallback returns raw states; #103 must still localize the
// known vocabulary instead of exposing those tokens in confirmation.
formatEntityState: (entity) => entity.state,
callService: async (domain, service, data) => {
calls.push([domain, service, data]);
return {};
},
};
card.requestUpdate();
await card.updateComplete;
};
const setLanguage = async (language) => {
card._config = { ...card._config, language };
card.requestUpdate();
await card.updateComplete;
};
const rebuildGate = async () => {
card._serverCfg = {
...card._serverCfg,
markers: [{
id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover',
tap_confirm: true, display: 'icon_ripple',
}],
};
card._cfgEpoch++;
card._regSignature = '';
card._maybeRebuildDevices();
card._space = 'garden';
card._setMode('view');
card.requestUpdate();
await card.updateComplete;
};
const gate = () => card._devices.find((item) => item.bindingRef === 'd_gate');
const tap = async () => {
card._clickDevice({ stopPropagation() {} }, gate());
await card.updateComplete;
return card._tapConfirm;
};
const renderedLines = () => [...root().querySelectorAll('.tapconfirm-line')]
.map((node) => node.textContent.trim());
await setLanguage('en');
await setCover('closed');
await rebuildGate();
const english = await tap();
result.englishSnapshotIsStructured = english?.kind === 'toggle'
&& english.initialIntent?.nextEffect === 'open'
&& english.deviceId === gate()?.id;
result.englishCurrentExpected = JSON.stringify(renderedLines()) === JSON.stringify([
'Current state: Closed', 'After switching: Open',
]);
const groupLines = card._toggleConfirmationLines({
origin: 'explicit-toggle', kind: 'group', semantics: 'group-power',
targets: [
{ entityId: 'switch.one', name: 'One', state: 'on', via: 'control-entity' },
{ entityId: 'light.two', name: 'Two', state: 'off', via: 'control-entity' },
],
skippedTargets: [{
ref: 'switch.missing', entityId: 'switch.missing', name: 'Missing', reason: 'unavailable',
}],
noneReason: null, nextEffect: 'turn-off',
command: {
domain: 'homeassistant', service: 'turn_off',
data: { entity_id: ['switch.one', 'light.two'] },
},
});
const virtualLines = card._toggleConfirmationLines({
origin: 'explicit-toggle', kind: 'single', semantics: 'power',
targets: [{ entityId: '', name: 'Virtual lamp', state: 'on', via: 'virtual-light' }],
skippedTargets: [], noneReason: null, nextEffect: 'turn-off', command: null,
operation: { kind: 'virtual-light', markerId: 'virtual-lamp' },
});
result.englishGroupAndVirtualCopy = JSON.stringify(groupLines) === JSON.stringify([
'Current state: on 1 of 2', 'After switching: all are off', 'Unavailable: 1',
]) && JSON.stringify(virtualLines) === JSON.stringify([
'Current state: On', 'After switching: Off',
]);
const body = root().querySelector('.tapconfirm-body');
const dialog = root().querySelector('hp-dialog');
const titleText = dialog?.shadowRoot?.querySelector('.title-text');
const footer = root().querySelector('hp-dialog [slot="footer"]');
const buttons = [...(footer?.querySelectorAll('button') || [])];
result.accessibleDomOrder = dialog?.title === english.text
&& body?.children[0]?.getAttribute('data-line') === '0'
&& body?.children[1]?.getAttribute('data-line') === '1'
&& buttons.length === 2;
result.narrowDialogDoesNotScrollHorizontally = !!body
&& body.scrollWidth <= body.clientWidth + 1
&& !!titleText && titleText.scrollWidth <= titleText.clientWidth + 1
&& buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= 0 && rect.right <= innerWidth + 1;
});
// Same target, changed state: execute the newly resolved direction.
calls.length = 0;
await setCover('open');
english.exec();
await new Promise((resolve) => setTimeout(resolve, 10));
result.sameTargetUsesCurrentDirection = calls.length === 1
&& calls[0][0] === 'cover' && calls[0][1] === 'close_cover';
// Different operation target: preserve #94's cancellation/toast contract.
await setCover('closed');
const changed = await tap();
const beforeChange = gate();
const other = coverState('closed', 'cover.other');
card.hass = { ...card.hass, states: { ...card.hass.states, 'cover.other': other } };
card._devices = card._devices.map((item) => item.id === beforeChange.id ? {
...item,
primary: 'cover.other', entities: ['cover.other'], allEntities: ['cover.other'],
bindingKind: 'entity', bindingRef: 'cover.other',
marker: { ...item.marker, binding: 'entity:cover.other' },
} : item);
calls.length = 0;
changed.exec();
await card.updateComplete;
result.changedTargetCancels = calls.length === 0
&& card._toast === card._t('toast.tap_target_changed');
await setLanguage('ru');
await setCover('closed');
await rebuildGate();
await tap();
result.russianCurrentExpected = JSON.stringify(renderedLines()) === JSON.stringify([
'Текущее состояние: Закрыто', 'После переключения: Открыто',
]);
// A secure/no-operation resolver result must not open a confirmation.
card._tapConfirm = null;
await setCover('closed', {}, 'garage');
await tap();
result.noOperationDoesNotOpen = card._tapConfirm == null && calls.length === 0;
return result;
});
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+234 -159
View File
File diff suppressed because one or more lines are too long
+50 -16
View File
@@ -1,6 +1,6 @@
# House Plan architecture
Updated: 2026-08-17 (#157 open-passage model). The repository = a HACS integration (category **Integration**)
Updated: 2026-08-19 (#113 optional space-model lifecycle). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Layout
@@ -15,6 +15,7 @@ houseplan-card/
│ ├─ floating-surface-controller.ts # shared Popover/fallback portal DOM lifecycle
│ ├─ editor-secondary.ts # context tray model, groups, focus/dismiss lifecycle and stable template
│ ├─ editor-secondary.styles.ts # styles owned by the context tray/submenu surface
│ ├─ space-model-selection.ts # active-or-first and exact optional space selectors
│ ├─ render/opening-tunnels.ts # immutable SVG projection of resolved tunnel geometry/fills
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
@@ -137,6 +138,23 @@ the same profiler available between stable promotions.
## Card data model (runtime)
`SpaceModel` is absent when the authoritative configuration has no spaces.
`_spaceModel()` therefore returns `SpaceModel | undefined`: it preserves the
legacy active-or-first selection for rendering and current navigation, but it
never invents a dummy space. Commands carrying a persisted or otherwise stable
space id use the exact `_spaceModelById()` selector; a stale id aborts before
config, layout, file or service side effects instead of mutating the first
space.
The first update that observes an authoritative empty `spaces` array runs one
space-bound lifecycle cleanup. It releases tracked pointer capture, cancels
pan/pinch/drag/resize/vacuum and geometry gestures, clears draft/history and
space dialogs, cancels the debounced config write and returns the card to View.
The global empty-state Create/import flows remain available. Recreating a space
re-arms cleanup so a later WS transition back to empty is handled identically.
Pure render/geometry helpers may return an empty result while the model is
absent; mutation entry points must guard explicitly.
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
(the first resolved state entity for actions requiring one target), temp,
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
@@ -333,8 +351,8 @@ contribute. Three explicitly typed exceptions are stored per space:
independent wall segments and `wall_columns` for square/circular columns.
They do not create a room or HA area and never split a room implicitly. Their
physical bodies are unioned with room walls for rendering and light occlusion,
and subtracted from clean room floor area. Openings (doors, windows and gates)
still belong only to derived room walls and never cut an independent object.
and subtracted from clean room floor area. A finished partition may explicitly
host a door, window, gate or passage; unfinished drafts and columns may not.
Independent linear objects have two deliberate projections. Raw flat-capped
quads preserve source identity for hit/selection/drag/properties/delete/history
@@ -363,8 +381,11 @@ vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
an interior join/nested-room representation, and atomic quads provide a safe
physical interval when an acute child ring cannot be subtracted. Paper and
masonry paths are emitted by that same geometry pass. Computed independent
junction patches enter as extras only after room opening cuts, so an opening
cannot cut a coincident partition and room exterior authority remains intact.
junction patches enter through the same physical union. A partition-hosted
opening is subtracted from its explicit raw body before that union. It also
cuts a derived room wall only when the wall is exactly collinear and covers
the complete hosted interval; crossing or nearby bodies remain opaque and room
exterior authority remains intact.
Before the exterior offset is built, each saved atomic endpoint splits its
containing collinear union edge. Offset changes are explicit butt steps at that
endpoint, including nonzero-to-zero transitions. The topology tolerance starts
@@ -489,16 +510,17 @@ navigation, focus restoration and outside-dismiss consumption), but no current
tools are grouped without a separate product decision. The change is UI-only:
plan/config models and geometry commands are unchanged.
## Doors, windows & gates (v1.23.0+)
## Doors, windows, gates & passages (v1.23.0+)
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
wall, while markers are free points whose positions live in the layout store. Model:
`{id, type: door|window|gate, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
rooms share a wall with opposite edge directions, and without that a drag across segment
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
or deleting rooms never breaks it.
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle,
a length and one wall, while markers are free points whose positions live in
the layout store. Model:
`{id, type: door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, flip_h?, flip_v?}`.
Room-wall openings omit `host` and retain the absolute-coordinate association.
An independent-wall opening stores
`host:{kind:'partition',id,t}`; the stable id and normalized position `t` are
authoritative, while `x/y/angle` are an atomically refreshed compatibility
projection. No explicit host ever falls back to a nearest wall.
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
@@ -515,8 +537,11 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
Contact and lock are exact HA references owned by the opening, not aliases of
standalone markers. Their candidate/action path follows HA binding status while
their render path follows the frozen active-registry projection; neither path
consults marker tombstones. Plan-level consumers keep the tombstone policy
described above.
consults marker tombstones. For that projection, the presence of an exact state
is sufficient: registry-less YAML entities have no row, while explicit
disabled/orphan rows have already been stripped together with their states.
The render helper must never receive raw live hass. Plan-level consumers keep
the tombstone policy described above.
For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall
interval and adjacent room on each side of the centreline. Opening symbols,
@@ -536,6 +561,15 @@ room both halves; shared openings use a local-coordinate hard stop at `y=0`.
Virtual spans, unfinished drafts and zero-thickness walls are ignored; legacy
spans are clipped per atomic body.
The same resolved host drives placement, symbol face, full-depth partition cut,
static/hidden-isometric rendering, Glow and edit operations. Rigid host drag
keeps `t` and updates every materialized projection in one history command.
Deleting a host with openings requires an explicit cascade dialog; an invalid
host fails dark and is visible only as a rebind diagnostic in Plan. Structural
room-face topology deliberately keeps every valid wall axis continuous through
all opening types (#185); only `open_spans` and absent walls are connectivity
gaps.
## Integration WS API
| Command | Parameters | Response |
+9 -6
View File
@@ -550,8 +550,8 @@ pointer-transparent SVG layer exposes the centre axes of completed room walls,
saved inactive outlines and independent partitions. It is painted after their
physical wall bodies, but before interactive editor chrome. Columns, decor,
devices, the active wall chain and its live preview are not candidates. Door,
window, gate and intentionally open-span intervals are cut from room axes; a
cut boundary does not become a new endpoint.
window, gate and intentionally open-span intervals are cut from presentation
axes; a cut boundary does not become a new endpoint.
The layer and hit resolver share one immutable geometry snapshot. Original
segment endpoints are deduplicated and drawn at a physical radius of 5 cm.
@@ -572,9 +572,11 @@ single active candidate and never writes config, layout or storage.
## Planar wall faces
Every completed Walls segment is first persisted in the active `room_drafts`
chain. On the click path only, an immutable planar graph is built from solid
room edges after opening cuts, independent partitions, inactive drafts and the
active chain both before and after the latest segment. Endpoint, T, X and
chain. On the click path only, an immutable planar graph is built from structural
room edges, independent partitions, inactive drafts and the active chain both
before and after the latest segment. Unlike the presentation/snap snapshot, this
face graph ignores door/window/gate/passage cuts but still applies `open_spans`.
Endpoint, T, X and
collinear-overlap junctions atomize that computed graph without rewriting any
saved wall. A deterministic half-edge walk extracts bounded faces; canonical
identity ignores winding, cyclic start and derived collinear subdivision.
@@ -582,7 +584,8 @@ identity ignores winding, cyclic start and derived collinear subdivision.
Only faces added by the latest segment and containing one of its atoms are
offered. They are ordered by area and then canonical key. Existing exact or
partially overlapping rooms are excluded, nested rooms remain legal, and any
physical gap — including an opening cut — remains a gap. A clean divider across
physical gap created by an `open_span` or absent wall remains a gap. A door,
window, gate or passage is a property of a wall and preserves connectivity. A clean divider across
one room reuses the Split contract: the larger side keeps the room identity,
metadata and device binding, and only the smaller side is offered.
+25 -2
View File
@@ -1,7 +1,12 @@
# Changelog
## Unreleased
## v1.65.0-beta.2 — 2026-08-18
- **Toggle state** confirmation now shows the current state and the exact
expected result before acting. Groups show their active/total count and
unavailable targets separately, while confirmation still re-resolves the
live state and cancels if the target set changed
([#103](https://github.com/Matysh/houseplan-card/issues/103)).
- Composite Home Assistant devices with several light/switch entities now let
you choose the exact entity operated by **Toggle state**. The dialog previews
the selected target immediately, preserves missing choices with a warning,
@@ -16,7 +21,19 @@
and Partition tools. An open chain becomes ordinary walls when the tool,
editor or floor changes; closing endpoint/T/X geometry offers every newly
formed room in stable area order, with clean room splits and atomic
Create/Keep/Cancel decisions ([#173](https://github.com/Matysh/houseplan-card/issues/173)).
Create/Keep/Cancel decisions. Ctrl/Cmd+click no longer adds an extra point
while the chain is still too short to close
([#173](https://github.com/Matysh/houseplan-card/issues/173)).
- Doors, windows, gates and open passages can now be placed in finished
independent Walls segments. They cut the full wall depth, move with their
host, keep the same Home Assistant state/actions as room-wall openings and
are removed only through an explicit cascade confirmation. Openings no
longer break the structural wall axis, so drawing a closed contour still
offers the room even when one of its walls already contains an opening.
Space backups preserve the opening-to-wall binding when imported, while the
editor's visual snap guide keeps its physical gap across the opening
([#132](https://github.com/Matysh/houseplan-card/issues/132),
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
- Collinear exterior walls now change thickness exactly at their saved
breakpoint. After splitting a room, a 10 cm wall therefore keeps its full
depth up to the divider without leaking onto an adjacent zero-thickness
@@ -57,6 +74,12 @@
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
- Small fixes and improvements.
- Door, window and gate contacts and locks now keep working when they are live
YAML entities without a `unique_id` and therefore have no Entity Registry
row. The picker, View animation, lock badge and opening info card now follow
the same exact reference, while disabled, orphaned and missing entities
remain unavailable ([#117](https://github.com/Matysh/houseplan-card/issues/117)).
## v1.64.0-beta.3 — 2026-08-14
- Large plans no longer recompute an unused physical-wall union on every floor
+25 -2
View File
@@ -6,8 +6,13 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## Unreleased
## v1.65.0-beta.2 — 2026-08-18
- Подтверждение действия **«Переключить состояние»** теперь показывает текущее
состояние и точный ожидаемый результат. Для группы отдельно видны число
включённых целей и недоступные цели; перед выполнением состояние по-прежнему
вычисляется заново, а смена набора целей отменяет команду
([#103](https://github.com/Matysh/houseplan-card/issues/103)).
- У составных устройств Home Assistant с несколькими сущностями света/реле
теперь можно выбрать точную сущность для действия **«Переключить состояние»**.
Диалог сразу показывает новую цель, сохраняет пропавший выбор с
@@ -24,8 +29,19 @@
«Контура комнаты» и «Перегородки». Незамкнутая цепочка становится обычными
стенами при смене инструмента, редактора или этажа; замыкание через конечные,
T- и X-точки предлагает все новые комнаты по возрастанию площади, а
Create/«Оставить стенами»/Cancel применяются атомарно
Create/«Оставить стенами»/Cancel применяются атомарно. Ctrl/Cmd+клик не
добавляет лишнюю точку, пока для замыкания ещё недостаточно рёбер
([#173](https://github.com/Matysh/houseplan-card/issues/173)).
- Дверь, окно, ворота и открытый проём теперь можно поставить в законченный
независимый отрезок «Стен». Проём прорезает его на всю глубину, движется
вместе со своей стеной, сохраняет обычные состояния и действия Home
Assistant, а при удалении стены удаляется только после отдельного
подтверждения. Проём больше не разрывает структурную ось стены: замкнутый
контур предлагает создать комнату, даже если в одной из стен уже есть проём.
Импорт резервной копии этажа сохраняет привязку проёма к стене, а визуальная
направляющая привязки по-прежнему показывает физический разрыв в месте проёма
([#132](https://github.com/Matysh/houseplan-card/issues/132),
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
- Коллинеарные внешние стены теперь меняют толщину точно в сохранённой точке
перехода. После разделения комнаты стена 10 см сохраняет полную толщину до
перегородки и не продолжается по соседнему фасаду с нулевой толщиной; План,
@@ -69,6 +85,13 @@
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
- Мелкие исправления и улучшения.
- Датчики и замки дверей, окон и ворот теперь работают и для живых
YAML-сущностей без `unique_id`, у которых поэтому нет строки в Entity
Registry. Список выбора, анимация в Просмотре, значок замка и карточка проёма
используют одну точную ссылку; отключённые, потерянные и отсутствующие
сущности по-прежнему недоступны
([#117](https://github.com/Matysh/houseplan-card/issues/117)).
## v1.64.0-beta.3 — 2026-08-14
- Большие планы больше не пересчитывают неиспользуемое объединение физических
+17
View File
@@ -69,6 +69,23 @@ fallback may show or rewrite it as a door. Before a permanent rollback, convert
saved passages deliberately in a current version; automatic conversion is not
performed because it would invent a leaf and binding semantics.
## Independent-wall opening host (#132)
`space.openings[].host` is an optional discriminated object
`{kind:'partition', id:string, t:number}`. Its absence preserves the historical
room-wall association. When present, the referenced partition in the same
space and normalized `t` are authoritative; the legacy `x/y/angle` siblings
remain a materialized compatibility projection for older readers. Full export,
plan-only export, merge and optimization preserve the host object.
Current writes validate the reference, fit and non-overlap. They also reject a
stale writer that keeps an existing opening but silently drops its host; this
prevents a downgrade from converting it into a nearby room-wall opening. Old
frontends may display only the materialized projection, so opening or editing a
hosted opening with an old bundle is unsupported. A missing/invalid host is not
re-associated automatically: current renderers fail dark and Plan offers an
explicit rebind.
## Four-phase background default and transfer (#146)
The schema remains `settings.bg_mode: static | daynight` globally and per
+7
View File
@@ -52,6 +52,13 @@ it never inherits transparency merely by not being a window.
So the light's masonry is cut by passages only and differs on purpose from the
drawn one.
The rule is identical for a passage hosted by a finished independent wall.
Only its explicit partition body (plus an exactly collinear covering room wall)
is cut. Windows remain opaque to indoor Glow, one-sided door/gate/passage cuts
remain opaque, and an invalid host fails dark. A window hosted by an independent
wall never becomes a sunlight source; sunlight still belongs to exterior room
windows.
Source placement follows the same geometry, fail-dark. If the source centre is
inside an opaque wall body, a window tunnel, or an exterior door/gate opening,
the source produces no Glow at all. It does not light the indoor half of the
+7 -9
View File
@@ -1,18 +1,16 @@
<!-- release: v1.65.0-beta.1 -->
<!-- release: v1.65.0-beta.2 -->
## Основное
- Экспорт пространства получил режим «Только планировка»: геометрия, декор, подложка и подписи переносятся без устройств и привязок Home Assistant.
- Составная техника, включая стиральные машины, получает жёлтый статус во время подтверждённого активного цикла.
- Оконные солнечные лучи теперь правильно учитывают реальное направление стрелки севера на плане.
- Редактор Плана получил единый инструмент «Стены», открытые проёмы и исправленную геометрию стыков и переходов толщины.
- Управление составными устройствами стало точнее: выбор сущности для переключения, связанные виртуальные источники и назначения комнат без HA-зон работают согласованно.
- Мелкие исправления и улучшения.
## Highlights
- Space export now has a Plan only mode that transfers geometry, decor, backdrop and labels without devices or Home Assistant bindings.
- Composite appliances, including washing machines, now show the yellow working state during a confirmed active cycle.
- Window sunlight now correctly follows the real direction of the plan's north arrow.
- The Plan editor now has one Walls tool, open passages and corrected junction and thickness-transition geometry.
- Composite-device control is more precise: exact toggle targets, linked virtual sources and room assignments without HA Areas now stay consistent.
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.65.0-beta.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.65.0-beta.1/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.65.0-beta.2/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.65.0-beta.2/docs/CHANGELOG.md)
+2 -2
View File
@@ -21,8 +21,8 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.65.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate for the current `S8-merged` queue |
| Current local cycle | v1.65.0-beta.1 carries #164, #166 and #167: confirmed appliance cycles receive the working marker, rotated real north produces physically correct window sunlight, and current-space export can create a plan-only template without devices or Home Assistant bindings. It also carries the #165 first-push process-gate correction. #173 is in its issue branch and unshipped: the Plan editor is moving to one continuous Walls tool with deterministic planar-face room creation. Publication waits for the exact-SHA Validate and fail-closed prerelease preflight; stable v1.64.0 remains unchanged. |
| Version | **v1.65.0-beta.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate for the current `S8-merged` queue |
| Current local cycle | v1.65.0-beta.2 candidate carries #150, #157, #170, #172, #173, #174 and #178: the Plan editor has one continuous Walls tool plus open passages and corrected wall-thickness junctions; room bindings without HA Areas, linked virtual sources and exact toggle targets now retain their intended ownership. Publication waits for a green exact-SHA Validate and fail-closed prerelease preflight; stable v1.64.0 remains unchanged. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| 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): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; 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) |
+2 -1
View File
@@ -179,7 +179,8 @@ Boolean, global + per-space (null = inherit), default OFF.
For every opening of type «window» sitting on an EXTERIOR wall — a
wall stretch with no other room on its outer side, decided by probing
the existing room geometry just off both sides of the window; windows
on interior walls do not participate, and open (virtual) boundaries
on interior walls do not participate, windows explicitly hosted by independent
partitions do not participate, and open (virtual) boundaries
never qualify because both sides are rooms — the card draws a wedge
when BOTH hold:
+71
View File
@@ -32,6 +32,43 @@
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
## Empty-space lifecycle (#113)
- [ ] Active selection keeps active-or-first compatibility, while an empty
model returns `undefined`; exact lookup of a stale saved id never falls
back to another space [unit: `space-model-selection.test.mjs`].
- [ ] There are no unguarded `_spaceModel().…` dereferences, explicit-id calls
use `_spaceModelById()`, and marker persistence validates its target
before config/file/WS side effects
[unit: `optional-space-model-contract.test.mjs`].
- [ ] Delete the last space while an editor gesture and debounced write are
active: the empty card renders, View is restored, pointer/draft/dialog
state is cleared, the pending write is cancelled and Add space still
opens Create. Recreate a plan, then receive an empty WS config and repeat
under a theme/resize/read-only tick [auto: `smoke_optional_space_model`].
- [ ] Removing the authoritative empty-state cleanup makes that smoke red
[mutation: `empty-space-cleanup-disabled`].
## Toggle confirmation state (#103)
- [ ] Every executable `ToggleNextEffect` formats current and expected lines
without deriving direction from the state label; `toggle` names Home
Assistant as the authority and a no-operation intent produces no lines
[unit: `device-toggle.test.mjs`].
- [ ] All-off/mixed/partial groups use only executable targets for their
active/total count and show skipped targets as a separate line
[unit: `device-toggle.test.mjs`].
- [ ] EN/RU confirmation renders prompt → current → expected → skipped before
the buttons, wraps a long name at 390 px and has no horizontal scroll
[auto: `smoke_toggle_confirmation.mjs`].
- [ ] A state race with the same target executes the newly resolved direction;
a changed target set makes zero service calls and shows the existing
retry toast [auto: `smoke_toggle_confirmation.mjs`].
- [ ] Cover, virtual-light, HA-control and Run confirmations keep their
existing actuation/cancel contracts [auto: `smoke_cover_tap`,
`smoke_virtual_light_toggle`, `smoke_ha_controls`, `smoke_controls`,
`smoke_tap_run`].
## Open passage (#157)
@@ -52,6 +89,29 @@
- [ ] Пять passage-мутантов из `scripts/mutation-gate.mjs` пойманы своими
guards до передачи в review. [auto: mutation-gate]
## Independent-wall openings and structural axes (#132, #185)
- [ ] Door/window/gate/passage placement on a finished independent wall stores
`host.kind/id/t`; a coincident room wall chooses that explicit host, while
crossing or duplicate-host ties are rejected. [auto: opening-placement,
partition-openings]
- [ ] Every hosted type cuts only its host full-depth in Plan/View/Static/Iso;
exact composite room masonry is also cut, nearby bodies remain intact,
and malformed hosts fail dark. [auto: physical-geometry,
smoke_partition_openings]
- [ ] Rigid host drag preserves `t` and updates projections atomically; delete
lists hosted openings, Cancel changes nothing, Confirm cascades in one
Undo/Redo command. [auto: partition-openings, smoke_partition_openings]
- [ ] Contact/lock actions keep existing security rules; passage stays inert;
windows and exterior passages stay opaque to Glow, and partition windows
produce no sun wedge. [auto: runtime contracts, smoke_glow]
- [ ] Door/window/gate/passage presentation gaps do not split the structural
axis used by the Walls face graph; real `open_spans` still do. [auto:
plan-snap-overlay, smoke_room_autoclose, smoke_partition_openings]
- [ ] Backend rejects missing host references, out-of-range `t`, non-fitting or
overlapping hosted openings and stale host stripping; exports round-trip
the host. [auto: test_validation, test_ha_import_export]
## Device value badge (#90)
@@ -322,6 +382,7 @@ separately promised workflows:
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
[auto: smoke_optional_space_model]
## Spaces ★
@@ -331,6 +392,9 @@ separately promised workflows:
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual]
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual]
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
- [ ] Delete the last space → empty state without console errors; active editor
gestures and drafts are aborted, and creating the first space remains available
[auto: smoke_optional_space_model]
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual]
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual]
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room
@@ -1072,6 +1136,13 @@ separately promised workflows:
locked / Lock when unlocked; button calls the lock service; disabled while
locking/unlocking; hidden when unavailable; plan-icon tap still never
toggles a lock [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] Registry-less opening binding (#117): a live YAML contact/lock without
`unique_id` is offered by the picker and drives the frozen View frame,
badge and info card; marker tombstones do not block it, an HA tick swaps
the frame without rebuilding geometry/config, and explicit disabled rows
with stale states remain hidden. Only the confirmed info-card lock action
may call a service [auto: ha-binding-status, render-device-snapshot,
smoke_registryless_opening, mutation-gate]
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
dot top-right of its icon (all clients); opening its editor clears it
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto: smoke_new_device]
+8
View File
@@ -348,6 +348,14 @@ aggregates until the same ID becomes active again.
| Toggle state | Toggles the exact binding, supported device function or configured light-source group | Locks, alarm panels and protective garage/door/gate targets are no-op; confirmation is optional |
| Run | Runs an automation, script or scene | Explicit target; confirmation is optional |
When confirmation is enabled for **Toggle state**, the dialog shows the current
state and the exact expected result (`On`, `Off`, `Open`, `Closed` or `Stopped`).
A group shows the active/total count and lists unavailable targets separately;
the result describes only the targets that will receive the command. The text
is a snapshot, but Confirm re-resolves the live state and direction. If the
target set changed while the dialog was open, House Plan cancels the action and
asks you to try again.
A light defaults to Toggle; other devices default to the House Plan card. An
unsupported Toggle remains a visible no-op and is never changed into another
action behind the user's back.
+35 -10
View File
@@ -56,7 +56,7 @@ House Plan — локальная интеграция и две Lovelace-кар
| Пространство | Этаж, двор, гараж, отдельное строение | Подложку, масштаб сетки, комнаты, стены, проёмы, декор и настройки отображения |
| Комната | Только замкнутый контур | Название, необязательную HA-зону, источники температуры/влажности и локальную заливку |
| Стена | Сторона контура комнаты | Необязательную толщину и виртуальные участки общей границы |
| Проём | Дверь, окно, открытый проём или ворота на стене | Размер и, где применимо, ориентацию, контактный датчик и замок |
| Проём | Дверь, окно, открытый проём или ворота на стене комнаты либо законченном независимом отрезке | Размер и, где применимо, ориентацию, контактный датчик и замок |
| Маркер | Значок устройства на плане | Привязку к HA, комнату, действие, внешний вид, свет, описание и файлы |
| Декор | Элемент подложки | Линию, фигуру, текст, мебель или положение изображения плана |
@@ -287,7 +287,8 @@ desktop: для точного рисования, Resize, модификато
независимыми стенами; отдельной кнопки завершения нет.
5. Когда новый сегмент замкнёт область собственными стенами, существующими
стенами либо вычисляемой конечной, T- или X-точкой, откроется диалог комнаты.
Разрыв проёма стеной не считается. Если возникло несколько областей, диалоги
Проём не разрывает структурную ось стены и не мешает замыканию; виртуальный
участок остаётся настоящим разрывом. Если возникло несколько областей, диалоги
идут от меньшей площади к большей.
6. Для каждой области задайте название и свободную HA-зону и нажмите
«Сохранить», либо выберите **Оставить стенами**. Cancel/Esc отменяет всю
@@ -299,7 +300,7 @@ desktop: для точного рисования, Resize, модификато
нарисованных стен видны тонкие осевые линии и точки их концов. Увеличенная
точка показывает точное место следующего соединения: конец стены имеет
приоритет, а увеличенная точка посреди линии создаёт T-соединение, не разрезая
существующую стену. В проёмах соединительной линии нет. Для точного наведения и
существующую стену. Осевая линия остаётся непрерывной и в месте проёма. Для точного наведения и
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
тоже выполняет привязку, но отдельный hover до касания не показывается.
@@ -327,7 +328,7 @@ T-соединение входит в проходящую стену без в
| Стены | Непрерывную цепочку стен; при замыкании предлагает комнаты, при выходе сохраняет независимые стены | Площадь появляется только у подтверждённой комнаты | Физические сегменты блокируют свет; проёмы пропускают его | Частичное перекрытие комнат запрещено; отдельного инструмента «Перегородка» нет |
| Колонна | Квадратную или круглую опору | Не меняет площадь комнаты | Блокирует свет внутри своей формы | Имеет одну форму/размер/поворот, но не является стеной или комнатой |
| Граница | Виртуальный участок общей стены | Не меняет геометрию и площадь | Свет проходит, физическая стенка не рисуется | Работает только на общей границе соседних комнат |
| Проём | Дверь, окно, открытый проём или ворота на стене | Не меняет площадь | Дверь/ворота пропускают свет по состоянию, открытый проём — всегда между комнатами; окно может давать солнечный луч | Должен целиком помещаться на подходящем отрезке стены |
| Проём | Дверь, окно, открытый проём или ворота на стене комнаты либо законченном независимом отрезке | Не меняет площадь | Дверь/ворота пропускают свет между полами, открытый проём — всегда; только наружное окно комнаты может давать солнечный луч | Должен целиком помещаться на одном подходящем отрезке стены |
Остальные операции меняют уже созданную геометрию:
@@ -376,7 +377,8 @@ Resize показывает живые длины и чистую площадь
или круглая форма и внешний размер 1–150 см; у квадрата доступен поворот, у
круга размер означает диаметр. Они вычитаются из чистой площади, перекрывают
Glow и солнечные лучи, но при Resize комнаты остаются на месте. За пределами
комнат эти объекты не создают пол или фон.
комнат эти объекты не создают пол или фон. Проём в независимой стене движется
вместе с выбранным отрезком и остаётся в том же относительном месте.
### Толщина стены
@@ -418,7 +420,8 @@ Glow и солнечные лучи, но при Resize комнаты оста
## 9. Двери, окна, открытые проёмы, ворота и замки
Проём всегда привязан к стене. Дверь и открытый проём по умолчанию создаются
Проём всегда привязан к одному отрезку стены комнаты или к одному законченному
независимому отрезку «Стен». Дверь и открытый проём по умолчанию создаются
шириной 90 см, окно — 120 см, ворота — 300 см. Допустимый размер в интерфейсе:
20–600 см с шагом 5 см.
@@ -432,8 +435,18 @@ Glow и солнечные лучи, но при Resize комнаты оста
Наводить указатель перед кликом необязательно: клик по телу толстой стены сам
находит её ось и открывает тот же диалог. Виртуальная стена не принимает проёмы.
Если несколько стен сходятся в одной точке, House Plan выбирает одну физическую
секцию детерминированно; при необходимости поставьте проём чуть дальше от стыка.
Если независимая стена точно совпадает со стеной комнаты, House Plan использует
её как явную привязку и прорезает единое составное тело. Не совпадающая
геометрически неоднозначность у стыка не угадывается: поставьте проём чуть
дальше от узла.
Проём можно перетаскивать только вдоль того же независимого отрезка. При его
удалении House Plan показывает отдельное подтверждение со всеми привязанными
проёмами; отмена ничего не меняет, подтверждение удаляет стену и перечисленные
проёмы одним шагом Undo. Если привязанный отрезок пропал из старого или
повреждённого конфига, в Редакторе плана проём помечается как потерявший стену
и предлагает перепривязку; в Просмотре он не рисуется и не создаёт ложный
проход для света.
Ворота по данным и поведению равны двери: могут иметь датчик открытия и замок, вырезают стену и пропускают Glow. Отличается только символ: две половинные створки без большой дуги, приоткрытые наружу на 10°. С датчиком угол меняется от 0° до 10°; без датчика показаны статически открытыми на 10°. Выбор распашных/откатных и других типов пока не предусмотрен.
@@ -459,7 +472,10 @@ Glow и солнечные лучи, но при Resize комнаты оста
датчика анимирует створку/полотно. Инверсия нужна, если интеграция отдаёт
противоположную логику. Контактный датчик и замок — самостоятельные точные
привязки проёма: удаление отдельного маркера той же сущности с плана не убирает
её из этих списков и не выключает уже сохранённую связь.
её из этих списков и не выключает уже сохранённую связь. Можно выбрать живую
YAML-сущность без `unique_id` и строки в Entity Registry: пока Home Assistant
передаёт её точное состояние, она так же управляет проёмом и значком замка в
Просмотре. Явно отключённая, потерянная или отсутствующая сущность не работает.
### Поведение в разных режимах
@@ -493,7 +509,8 @@ Glow и солнечные лучи, но при Resize комнаты оста
продолжает текущую заливку/Glow-base через тоннель. Отображение уже существующих
дверей, окон и ворот в Static в рамках этой функции намеренно не менялось.
Солнце проходит только через наружные окна. Внутренние окна солнечных лучей не создают.
Солнце проходит только через наружные окна комнаты. Внутренние окна и окна в
независимых стенах солнечных лучей не создают.
<!-- docs-section: devices -->
@@ -645,6 +662,14 @@ HA. «Показать» заблокировано до активации. П
| Переключить состояние | Переключает точную привязанную сущность, функциональную сущность устройства либо явно настроенную группу `controls`. Для штор/жалюзи и клапанов автоматически выбирает open/close/stop | Замки, охранные панели и защитные `garage`/`door`/`gate` остаются безопасным no-op; можно включить подтверждение |
| Run | `automation.trigger`, `script.turn_on` или `scene.turn_on` | Цель выбирается явно; можно включить подтверждение |
Если для **«Переключить состояние»** включено подтверждение, диалог показывает
текущее состояние и точный ожидаемый результат: «Включено», «Выключено»,
«Открыто», «Закрыто» или «Остановлено». У группы отдельно показаны число
включённых целей и недоступные цели; результат относится только к получателям
будущей команды. Текст является снимком на момент открытия, но при подтверждении
House Plan заново определяет состояние и направление. Если набор целей успел
измениться, действие отменяется с предложением повторить попытку.
У устройства, чья основная функция — `light.*`, действием по умолчанию уже является **Переключить состояние**. У остальных устройств по умолчанию открывается внутренняя карточка.
Если у маркера с привязкой к устройству есть две или больше собственных
+7
View File
@@ -98,6 +98,10 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
partitions. Closing one or more planar faces opens the room queue; its
decisions are buffered and applied as one Undo/Redo transaction. Re-selecting
Walls, Reset, pan, pinch and pointer cancellation are not finish actions.
- Opening places every existing opening type on a room wall or a finished
independent Walls segment. A hosted opening moves with that segment; deleting
the segment requires an explicit cascade confirmation. Its physical gap does
not break the structural wall axis used to recognize closed rooms (#185).
- Boundary is contextual: two points on one solid shared wall make the selected
stretch virtual; one click on an existing dashed stretch restores it whole.
Outer walls and room boundaries hidden under independent masonry are not
@@ -175,6 +179,9 @@ layer you cannot see is a layer you cannot edit.
places a square column whose side is the current Thickness value. Neither an
independent wall nor a column creates a room or HA area by itself. A closed
independent-wall ring subtracts only its wall body from room floor.
- Door, window, gate and passage may be hosted by one finished independent wall
segment. Drafts and columns are never opening targets. Missing hosts fail
dark and expose a rebind action only in Plan.
- **Select** is the only mode in which these objects intercept input. It offers
rigid grid-bound drag, double-click/tap properties, Delete, and a rotate
handle for square columns (5° steps; Shift is free). Draft Delete removes the
+8
View File
@@ -200,6 +200,14 @@ fully occluded instead of leaking around its own masonry. The same fail-dark
placement rule applies to window tunnels and exterior door/gate openings;
interior passages remain valid source positions (#92).
An opening with explicit `host:{kind:'partition',id,t}` is resolved from that
partition alone and subtracted full-depth from its raw body before the joined
presentation union. A precisely collinear room wall covering the same interval
is cut as a composite; a crossing/nearby body is not. Host move keeps `t`,
delete requires cascade confirmation, and malformed/orphan hosts remain opaque.
Opening cuts change physical masonry, not the structural wall axes used for
room-face detection (#185).
`physicalBodySet()` separates raw draft/partition/column bodies from computed
junction patches and their joined geometry. Raw bodies remain authoritative for
hit testing, selection, drag, properties, deletion, history and furniture
Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 286 KiB

+13 -13
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceFingerprint": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "5ca7f24642926072ccbc5bf48341c5310329918631b147c116f9c29e20e4927e"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "164b79fd9075a1425cffe3c85c88b689dfda90b79856bb4ed6655507b166b3e0"
},
"device-editor": {
@@ -68,8 +68,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -79,8 +79,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ecfdf1afb82688d4ee3ee423ae74614f49f2eba3b980e4125680f86e89c21e26",
"sourceSha256": "83b741498294df63de36d332512a375d48dbd85d9795bf51a51a7f4c04f85453",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
}
}
+161
View File
@@ -0,0 +1,161 @@
# Код-ревью issue #103 · цикл r1/4
Toggle confirmation: показывать текущее и ожидаемое состояние.
- **Issue:** https://github.com/Matysh/houseplan-card/issues/103
- **ТЗ:** `docs/specs/103-toggle-confirmation-state.md`, принято зелёным
`docs/reviews/SPEC-REVIEW-103-r1.md`
- **Диапазон:** `origin/dev..HEAD`, 3 коммита
(`a449edc` ТЗ, `6c7958c` ревью ТЗ, `9327737`/`9327737...cf` реализация)
- **Ревьюер:** Claude, свежая сессия без контекста реализации
## Скоуп
Диапазон изменил:
- `src/device-toggle.ts` — новый pure `formatToggleConfirmation()` и тип
`ToggleConfirmationFormatter`; `resolveToggleIntent`/резолвер не менялись;
- `src/houseplan-card.ts` — `_tapConfirm` расширен до discriminated union
`{kind:'toggle', lines, initialIntent, deviceId, exec}` /
`{kind:'run', text, exec}`; новый `_toggleConfirmationLines()` и
`_toggleConfirmationStateText()`; шаблон диалога рендерит `lines` для toggle
и прежний `<p>` для run;
- `src/styles.ts` — `.tapconfirm-body`/`.tapconfirm-line` (перенос длинных
строк, нет горизонтального скролла);
- `src/i18n/en.json` + `src/i18n/ru.json` — 15 новых ключей `confirm.*` в обоих
файлах;
- `test/device-toggle.test.mjs` — юнит-тесты форматтера;
- `demo/smoke_toggle_confirmation.mjs` — новый браузерный смок;
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`, `docs/USER-GUIDE(.ru).md`,
`docs/TESTING.md`, `docs/specs/README.md` — документация и changelog в
том же коммите, что и поведение;
- три копии бандла (`dist/`, `custom_components/.../frontend/`,
`demo/srv/assets/`) и `docs/images/screenshots.json` (обновление
`sourceFingerprint`/`sourceSha256`, `imageSha256` каждого сценария не
изменился — визуальный контент прежний).
Соответствует `docs/SCOPE.md` J3 («tap-to-toggle for safe domains») и не
задевает lock invariant: secure-цели (`lock`/`alarm_control_panel`/guarded
`cover`) резолвер отфильтровывает раньше формирования intent, `toggleOperation`
для них `null`, confirmation не открывается — новая ветка кода это условие не
меняет.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | green |
| Unit | `npm test` | 880/880 green |
| Build + сверка бандлов | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | green, три копии идентичны |
| Целевой browser smoke (AC1,3,6,7,8,9) | `node demo/smoke_toggle_confirmation.mjs` | green, все 9 подпроверок true |
| Регрессия смежных confirmation-путей | `node demo/smoke_cover_tap.mjs`, `node demo/smoke_virtual_light_toggle.mjs`, `node demo/smoke_tap_run.mjs`, `node demo/smoke_ha_controls.mjs`, `node demo/smoke_controls.mjs` | все green |
| Golden | `npm run golden:verify` (свежий бандл) | 100% `passed`, 0 failed/error — новых сценариев dialog нет, стили аддитивны и не задели существующие сцены |
| i18n-паритет | `node -e "..."` сравнение ключей `en.json`/`ru.json` | равны, расхождений нет |
| Process gate | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» (без `--issues`, офлайн-часть) |
| Backend | не прогонялся | `custom_components/**/*.py` в диффе нет — гейт не применим |
| Performance-профили | не прогонялись | не названы в AC, изменение — только текст/CSS диалога |
**Дисциплина «тест умеет падать» — проверено для прогнанных тестов:**
- временно нейтрализовал ветку `isGroup && nextEffect==='turn-on'` в
`formatToggleConfirmation` → `npm test` дал `not ok 159` (тест группы), после
отката снова 880/880;
- временно подменил `closed → confirm.state_open` в
`_toggleConfirmationLines` → `node demo/smoke_toggle_confirmation.mjs` дал
`englishCurrentExpected: false` и `russianCurrentExpected: false`, после
отката снова все 9 true и бандлы пересобраны/сверены заново.
## AC — разбор
| AC | Доказательство | Вывод |
|---|---|---|
| 1. current/expected lines для исполняемой цели | unit `toggle confirmation formats every next effect...`; smoke `englishCurrentExpected`/`russianCurrentExpected` | подтверждено |
| 2. expected строго по `nextEffect`, не по домену | unit-тест перебирает все `ToggleNextEffect` на одном intent с фиксированным `state`; чтение `formatToggleConfirmation` — ветвление только по `nextEffect`/`isGroup`, не по domain/semantics | подтверждено |
| 3. локализация power/cover/valve/virtual/group | unit (partial/all-off group, virtual) + smoke `englishGroupAndVirtualCopy`, RU/EN рендер | подтверждено |
| 4. partial group не обещает изменение skipped | unit `describes executable group targets and skipped targets separately` (denominator = только `targets`, skipped — отдельная строка); проверено на способность теста падать | подтверждено |
| 5. `toggle` → «решит HA» | unit-тест `nextEffect: 'toggle'` → `expected:by-ha` | подтверждено |
| 6. no-op не открывает confirmation | smoke `noOperationDoesNotOpen` (guarded cover, secure no-op) | подтверждено |
| 7. Confirm выполняет заново разрешённый intent | smoke `sameTargetUsesCurrentDirection` (cover меняет state closed→open между открытием и подтверждением, выполняется `close_cover`, а не команда снимка) | подтверждено |
| 8. смена target set отменяет actuation, старый toast | smoke `changedTargetCancels` (переброс bindingRef на другую cover-сущность → 0 service calls, `toast.tap_target_changed`) | подтверждено |
| 9. desktop/mobile, keyboard, screen-reader order | smoke `accessibleDomOrder` (`dialog.title === текст вопроса`, `data-line 0/1`, 2 кнопки) + `narrowDialogDoesNotScrollHorizontally` на 390px; keyboard focus/Escape/scrim — **проверено чтением, не исполнением**: `dismiss-on-scrim`, `@hp-close` и структура footer/кнопок в диффе не изменены | подтверждено (смок + чтение) |
| 10. run/другие confirmations не регрессируют | smoke `smoke_tap_run.mjs` green; чтение шаблона — ветка `kind==='run'` рендерит тот же `<p>{text}</p>` с тем же заголовком `btn.run`, что и раньше | подтверждено |
## Что проверено и корректно
- Инвариант lock/alarm/guarded-cover не тронут: секьюрные цели отфильтровываются
резолвером `#94` до формирования `nextEffect`/`targets`, confirmation для них
не строится ни при каком новом коде.
- `formatToggleConfirmation` — чистая функция, не знает про HA/DOM; вся i18n и
HA-formatter-специфика инкапсулированы в `_toggleConfirmationStateText`/
`_toggleConfirmationLines` в `houseplan-card.ts`, как и требовало ТЗ §5
(«`houseplan-card.ts` не выводит next state по domain самостоятельно»).
- Direction всегда берётся из `intent.nextEffect`, не из `currentState` —
единственный источник расчёта — резолвер #94; UI не дублирует его логику.
- Denominator группы — количество фактических `targets` (`byEntity` без
skipped), а не число сконфигурированных ссылок; skipped выводится отдельной
строкой и не входит в «включено N из M».
- Race-контракт #94 не нарушен: `exec()` заново находит `currentDevice` →
пересчитывает intent → сравнивает через `sameToggleOperationTargets` с
исходным снимком; при совпадении целей выполняется **текущее**
направление/команда (не снимок на момент открытия).
- `run`-подтверждение осталось на прежней простой форме (`text` + `<p>`),
discriminated union не заставил его притворяться toggle — ТЗ §7 требование
выполнено буквально.
- i18n: все 15 новых ключей присутствуют в обоих словарях; в целом по файлам
`en.json`/`ru.json` расхождений ключей нет.
- Три копии бандла синхронны (`cmp` byte-for-byte), `docs/images/screenshots.json`
обновил только fingerprint/sourceSha256 — `imageSha256` каждого сценария не
изменился, то есть визуальный результат существующих сцен не задет; отдельного
golden-сценария для диалога не требовалось (ТЗ §11 — narrow smoke покрывает).
- Оба changelog правлены в том же коммите, что и поведение (`932773773f0b`),
трейлеры `Issue: #103`/`User-Visible: yes|no` на месте на всех трёх коммитах.
## Находки
Ни одной High/Medium-находки. Три ранее отмеченные Low из ревью ТЗ
(`SPEC-REVIEW-103-r1.md`) не имеют кодового следствия — направление в рантайме
берётся из `nextEffect`, а не из нормативной таблицы ТЗ, поэтому неточность
таблицы (`closing` в одной строке с `open`) не воспроизвелась в коде.
- **Low (снято, без правки).** `houseplan-card.ts`, ветка
`if (actionDevice.marker?.tap_confirm) { const lines = this._toggleConfirmationLines(initial); if (!lines.length) return; ... }`
— защитный `if (!lines.length) return` недостижим при нынешних инвариантах
резолвера: везде, где `toggleOperation(intent)` истинен (единственное условие,
пропущенное раньше по коду), у intent уже гарантированно есть `nextEffect` и
`targets.length >= 1` (прослежено по всем веткам `resolveToggleIntent`/
`resolveGroupEntities`/virtual-light intent). Формально это тихий no-op вместо
открытия диалога, если бы условие когда-нибудь стало достижимым, но сегодня
это мёртвый код, а не наблюдаемый дефект. Правка не требуется; снимаю с
записью здесь, как того требует §8/§12 процесса.
## Чего не проверял
- **Backend** (`python -m pytest tests_backend`) — не прогонял: диапазон не
затрагивает ни одного файла `custom_components/**/*.py`.
- **Performance-профили** — не прогонял: AC их не называет, изменение не
трогает чувствительные к перфу пути (только текст диалога и аддитивный CSS).
- **Полный набор из 127 браузерных смоков** — прогнал только целевой
(`smoke_toggle_confirmation`) плюс пять смежных по confirmation/cover/virtual-
light/run поверхности; остальные 121 не запускал — diff не задевает
геометрию, wall-thickness, canvas, sun/light рендер и т.д., которые они
покрывают.
- **Ручная проверка в реальном браузере вне demo-харнесса** (реальный screen
reader, реальная Tab-навигация) — не выполнялась; keyboard/Escape/scrim
утверждение AC9 закрыто чтением кода (структура диалога не менялась), как и
предусмотрено самим ТЗ («narrow browser smoke + code review»).
- **`node scripts/check-docs.mjs --external`** — не перепрогонял; автор в
хендоффе указал green, доверяю без повторного запуска (внешний, не входит в
обязательный набор ревью).
- **`process-gate.mjs --issues`** (сетевая проверка статуса issue через `gh`) —
не прогонял с токеном; офлайн-часть (`process-gate.mjs` без флага) прошла
чисто.
## Вердикт
Зелёный. Все 10 AC доказаны — юнитами/смоками, которые я прогнал и убедился,
что они умеют падать, либо явным «проверено чтением, не исполнением» там, где
так предусмотрено самим ТЗ (клавиатура/screen-reader в AC9, идентичность
`run`-ветки в AC10). High: 0, Medium: 0. Единственная Low-находка — недостижимая
защитная ветка — снята без правки, с записью выше.
`Вердикт: зелёный · цикл r1/4 · High: 0 · Medium: 0 → нет · Документ: docs/reviews/CODE-REVIEW-103-r1.md`
+179
View File
@@ -0,0 +1,179 @@
# CODE-REVIEW-113-r1 — честный optional-контракт `_spaceModel()`
- **Issue:** https://github.com/Matysh/houseplan-card/issues/113
- **ТЗ:** `docs/specs/113-optional-space-model.md`, ревью ТЗ — `docs/reviews/SPEC-REVIEW-113-r1.md` (зелёный, Medium-1 → #184, не блокирует)
- **Диапазон:** `origin/dev..HEAD` = `5f02dd3` (docs: specify optional space model contract), `7660868` (docs: review document), `09b5a74` (Make empty space model explicit)
- **Коммит реализации:** `09b5a74e866a595138bd93703fd09a4860bf7757`
- **Цикл:** r1/4
- **Роль:** ревьюер кода (Claude), свежая сессия, без контекста реализации Codex
## 1. Скоуп
Единственный коммит класса A/B по существу — `09b5a74`. Он:
- меняет `_spaceModel(): SpaceModel` → `_spaceModel(): SpaceModel | undefined` и вводит
`_spaceModelById(id): SpaceModel | undefined` (exact lookup, без first-space fallback);
- выносит чистые селекторы в новый файл `src/space-model-selection.ts`
(`selectActiveSpaceModel`, `selectSpaceModelById`) с unit-тестами;
- классифицирует и правит все ~45+ производственных обращений к `_spaceModel()` по классам
§5 ТЗ (lifecycle/render-гейт, event handlers редакторов, pure render helpers, paths с уже
доказанным space);
- вводит `_syncEmptySpaceState()` — вызываемый из `willUpdate()` guard, который один раз на
переход non-empty→empty снимает pointer capture, обрывает жесты/drag/pan/pinch/resize/vac-fit,
закрывает draft/history/space-scoped диалоги, отменяет debounced config write и возвращает
`_mode` в `'view'`; повторно вооружается при следующем appearance/disappearance цикле;
добавляет `Debounced.cancel()`;
- добавляет `demo/smoke_optional_space_model.mjs`, `test/space-model-selection.test.mjs`,
`test/optional-space-model-contract.test.mjs`, мутационный guard
`empty-space-cleanup-disabled` в `scripts/mutation-gate.mjs`;
- документирует инвариант в `docs/ARCHITECTURE.md` и добавляет чек-лист в `docs/TESTING.md`.
`User-Visible: no` — согласовано с владельцем на этапе ТЗ (§13 спецификации): видимое поведение
при существующих пространствах не меняется (гейт `render()` на `model.length === 0` уже отделял
пустой план от общего пути, старый код уже безопасно работал по факту порядка вызовов). Изменение
устраняет класс латентных крашей (#111 повторно), не новую пользовательскую возможность.
Соответствует, обе changelog-правки в этом коммите отсутствуют обоснованно.
Medium-1 из ревью ТЗ (#184, fallback-семантика §6 для явного `id`) вынесен отдельным issue,
не расширяет #113; ниже проверено, что реализация фактически перевела все стабильные-id call
sites на exact lookup (сильнее, чем требовало формально необязательное AC8) — см. §4.
## 2. Как проверялось — таблица гейтов
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | OK, без ошибок |
| Unit | `npm test` | `811/811` — совпадает с заявленным в хендоффе и с `npm run inventory` |
| Build + 3 копии бандла | `npm run build` затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | OK, обе сверки — байт-в-байт совпадение |
| Именованный smoke (AC3–AC6) | `node demo/smoke_optional_space_model.mjs` | OK, все 9 подпроверок `true` |
| Именованный smoke (AC6, read-only cold start) | `node demo/smoke_readonly_cold_start.mjs` | OK, все 13 подпроверок `true` |
| Мутационный guard AC10/AC4/AC5 | `node scripts/mutation-gate.mjs --id=empty-space-cleanup-disabled` | `поймано 1 из 1` — чистый прогон зелёный, мутант (`if (empty) return;` вместо `if (this._emptySpaceStateActive) return;`) красит смок, т.е. тест **умеет падать** |
| Регрессия непустого пути (поверхности: markers/positions, wall-thickness, free walls/columns, opening preview, open/close wall, isometric contract, merge/split, vacuum fit, corner split, draw-wall thickness) | `node demo/smoke_marker_stay.mjs`, `smoke_wall_thickness.mjs`, `smoke_free_walls.mjs`, `smoke_opening_preview.mjs`, `smoke_openwall.mjs`, `smoke_isometric_contract.mjs`, `smoke_merge_split.mjs`, `smoke_vacuum.mjs`, `smoke_split_corner_wall.mjs`, `smoke_draw_wall_thickness.mjs` | OK, все 10 зелёные |
| Process gate | `node scripts/process-gate.mjs --issues` | `гейт пройден, предупреждений 0` |
**Не прогонялось, и почему:**
- **Полный набор из 135 браузерных смоков** — diff трогает десятки геометрических хелперов
(`_frameOf`, `_isoScene`, wall/opening/decor/room helpers), но во всех них новый код — это
*guard, добавленный до* существующей логики, срабатывающий только при `model.length === 0`.
При непустой модели `_spaceModel()` = `models.find(...) ?? models[0]`, что при непустом массиве
никогда не даёт `undefined` — путь `if (!space) return …` в каждом таком месте логически
недостижим для непустого плана (проверено чтением, не исполнением, во всех местах диффа).
Прогнан репрезентативный набор из 10 смоков по каждой тронутой геометрической поверхности
(таблица выше) — все зелёные, что подтверждает отсутствие регрессии на практике, а не только
по рассуждению. Прогон всех 135 не добавил бы информации, пропорционально задаче не прогонялся
(PROCESS §8).
- **`npm run golden:verify`** — не прогонялся. По той же причине (недостижимость нового кода
при непустой модели, подтверждённая чтением `render()`: `if (!model.length) return html\`…\`;`
идёт раньше `const space = this._spaceModel(); if (!space) return nothing;`, и второй `return`
математически недостижим, когда `model.length > 0`) видимый результат при непустом плане не
меняется; для пустого плана экран тот же самый JSX-блок, что и раньше диффа (не тронут).
Пиксельного риска нет, golden не запускался осознанно, а не пропущен по умолчанию.
- **`python -m pytest tests_backend`** — не тронут ни один файл `custom_components/**/*.py`,
бэкенд не запускался.
- **Performance-профили** — ни в AC, ни в диффе не затронут ни один hot render path кроме
добавления O(1)-проверок в начале функций и одного one-shot `querySelectorAll('*')`,
срабатывающего не на каждый рендер, а один раз на реальный переход в пустой план (проверено
чтением `_syncEmptySpaceState`/`willUpdate`). Не прогонялись.
- Оставшиеся ~120 смоков вне таблицы (isometric-live-touch и т.п. поверхности, которые дифф не
трогает напрямую) — не прогонялись; сознательное решение, а не молчаливый пропуск.
## 3. Проверка AC (docs/specs/113-optional-space-model.md §10)
| AC | Доказательство | Статус |
|---|---|---|
| AC1 optional тип у `_spaceModel`/exact варианта | `npx tsc --noEmit` зелёный при сигнатуре `SpaceModel \| undefined`; `test/optional-space-model-contract.test.mjs` проверяет regex-ом наличие точной сигнатуры обоих методов | доказано unit-тестом, тест умеет падать (см. §4) |
| AC2 все production call sites без non-null assertions | тот же contract-test: `assert.doesNotMatch(source, /this\._spaceModel\(\)\s*!/ …)` плюс запрет наготовой `.` без `?.`/guard | доказано unit-тестом, экспериментально подтверждена ловля регрессии (см. §4) |
| AC3 empty render/update/resize/theme/WS не бросают исключений | `smoke_optional_space_model.mjs`: `deleteLastRendersEmpty`, `wsEmptyAbortsLiveGesture`, `emptySurvivesThemeResizeReadonly` — все `true`, без падения страницы | доказано браузерным smoke (исполнение) |
| AC4 удаление последнего space оставляет рабочий empty-state | `deleteLastRendersEmpty`, `createFlowSurvivesEmpty` | доказано smoke |
| AC5 pending pointer/drag/editor action abort без history/persist/service call | `deleteLastAbortsEditorState`, `deleteLastCancelsPendingWrite`; проверено чтением, что `_drag`/`_pointers` очищаются **до** какого-либо `pointerup`-коммита (коммит живёт в up-хендлерах, которые физически не вызываются после очистки состояния) | смок + чтение кода up-хендлеров |
| AC6 read-only cold start с `spaces: []` стабилен | `emptySurvivesThemeResizeReadonly` (в `smoke_optional_space_model.mjs`, `_serverCanWrite=false`) + `smoke_readonly_cold_start.mjs` (непустой read-only путь, не регрессировал) | доказано smoke |
| AC7 non-empty View/editors — pixels/actions без изменений | логически недостижимый guard (см. §2) + 10 регрессионных smoke по тронутым поверхностям, все зелёные | проверено чтением + smoke, golden сознательно не прогонялся (см. §2) |
| AC8 missing explicit stale id не мутирует первый space | `test/space-model-selection.test.mjs`: `selectSpaceModelById(spaces, 'stale') === undefined`; `optional-space-model-contract.test.mjs` проверяет, что `_livePos`, `_vacPlanRoomAnchors`, `_vacStartFit`, `_labelMove`, `_rlResizeMove` используют `_spaceModelById`; лично сверено чтением, что все явные call sites (`_livePos`, `_vacPlanRoomAnchors`, `_vacStartFit`, `_labelMove`, `_rlResizeMove`, `_saveMarker`'s `targetSpaceModel`) действительно вызывают `_spaceModelById`, а не `_spaceModel(id)` (метод с параметром `id` больше не существует) | доказано unit-тестом + подтверждено чтением исходника |
| AC9 active-id fallback при непустой модели сохраняет legacy behavior | `test/space-model-selection.test.mjs`: `selectActiveSpaceModel(spaces, 'stale') === spaces[0]`, `selectActiveSpaceModel(spaces, null) === spaces[0]` | доказано unit-тестом |
| AC10 type/source gates не пускают прежнюю ложную сигнатуру | `node scripts/mutation-gate.mjs --id=empty-space-cleanup-disabled` → `поймано 1 из 1`; отдельно вручную подтверждено, что regex контракт-теста ловит намеренно испорченный naked-deref (см. §4) | доказано мутационным тестом (исполнение) |
## 4. Дисциплина «тест умеет падать» — что лично проверено исполнением
- **Мутационный гейт**: `node scripts/mutation-gate.mjs --id=empty-space-cleanup-disabled`
фактически применяет патч (`if (this._emptySpaceStateActive) return;` →
`if (empty) return;`), пересобирает и гоняет `smoke_optional_space_model.mjs` — результат
`тест покраснел, как обязан`. Не «предположительно ловит», а подтверждённая красная реакция.
- **Source-contract regex**: собран мутированный в памяти текст (без изменения репозитория) с
заменой `this._spaceModel()?.bg` → `this._spaceModel().bg` и прогнан ровно тем же regex, что
использует `optional-space-model-contract.test.mjs`
(`/this\._spaceModel\(\)\s*\./`) — совпадение `true`, то есть тест обязательно упадёт при
возврате naked-deref. Проверка выполнена вживую через `node -e`, не только прочитана.
## 5. Находки
Нет находок High. Нет находок Medium.
**Low-1** (не блокирует, не требует правки — оставлено с записью). `_syncEmptySpaceState()` не
сбрасывает `_splitSel`, `_mergeDialog`, `_wallDialog` при переходе в пустой план (ТЗ §7
перечисляет конкретный список, и эти три в него не входят напрямую). Разобрано чтением: все три
рендерятся только внутри ветки `render()` после `if (!model.length) return …` и
`if (!space) return nothing;`, то есть при пустом плане не видны; `_splitClick`/аналоги уже
содержат защитный код на случай "roomId не найден в моделе" (строки ~11239–11244), который
сработает безопасно, если пользователь пересоздаст план с другими id комнат и продолжит с
залипшим `_splitSel`. Наблюдение косметическое (не входит ни в один пользовательский сценарий из
ТЗ, не даёт сбоя) — снимаю без issue.
**Low-2** (не блокирует). `render()`'s `if (!space) return nothing;` (после
`const space = this._spaceModel();` в непустой ветке) недостижим при `model.length > 0` — то же
верно и для нескольких других мест диффа (см. §2/AC7). Это защитный код на случай будущего
разъединения инварианта «модель непуста ⇒ активный space существует», не дефект; TS не может
доказать эту связь сам, так что явная проверка оправдана как type-safety, а не как признак
недоделки. Отмечено, правки не требуется.
## 6. Что проверено и корректно
- `_model` (геттер) строится через `spaceModels(cfg).map(...)` 1:1 по `cfg.spaces`, без
фильтрации записей; `_renderCfg` сохраняет длину `spaces` (заменяет только контент одного
элемента при активном resize-preview). Значит проверка `_syncEmptySpaceState` на
`this._serverCfg.spaces.length === 0` и проверка `render()` на `this._model.length === 0`
наблюдают одно и то же состояние — не расходятся (прочитано `_buildModel`, `spaceModels`,
`_renderCfg`).
- `_syncEmptySpaceState()` вызывается из `willUpdate()` **до** `_captureRenderDeviceSnapshot()`
на каждом обновлении (не только при смене `_serverCfg`), но благодаря флагу
`_emptySpaceStateActive` весь путь очистки — не более двух проверок свойств при непустой
модели; повторный вход в пустое состояние (после пересоздания и нового WS-опустошения) снова
срабатывает — подтверждено вторым циклом внутри самого smoke.
- Все guard'ы в event-хендлерах редакторов (`_physicalDown`, `_openingClick`, `_opPointerDown`,
`_physicalRotateDown`, `_savePhysicalDialog`, `_commitMerge`, `_commitRoom`, `_saveMarker` и
далее по диффу) расположены **до** `stopPropagation`/`capturePointer`/мутации диалога —
соответствует ТЗ §5.2 «guard до side effects». Проверено построчным чтением каждого изменённого
метода в диффе, не выборочно.
- `_saveMarker` — самая тяжёлая перестройка диффа: `targetSpaceModel` (через
`_spaceModelById(explicitSpaceId)` либо активный `_spaceModel()`) вычисляется и проверяется
(`if (!targetSpaceModel) return;`) до `this._markerDialog = { ...dlg, busy: true }` — то есть до
первого наблюдаемого побочного эффекта транзакции. Старый код вместо этого читал
`this._spaceModel(space || undefined).vb` внутри `try`, что при пустой модели тип обещал
безопасным, а исполнение — падало; теперь путь физически не достижим без валидной модели.
- `_isoSource`/`_isoScene`/`_frameOf` — все места, где сигнатура стала `| null`, имеют парный
`if (!x) return …` у каждого вызывающего (7 точек использования `_isoScene()` в файле,
проверены все, не выборочно).
- `npx tsc --noEmit` зелёный — это, в частности, доказывает, что каждый параметр, куда
«доказанный» `SpaceModel` передаётся вниз по вызовам (§5.4 ТЗ), действительно набран нешироким
(не `| undefined`) типом на входе — компилятор не пропустил бы несовпадение, обходов через `any`
в диффе не найдено при чтении.
- Коммит `09b5a74` несёт `Issue: #113` и `User-Visible: no`; изменений в `docs/CHANGELOG*.md` нет
и не требуется. `docs/ARCHITECTURE.md`/`docs/TESTING.md` обновлены в том же коммите.
- `node scripts/process-gate.mjs --issues` зелёный на этом диапазоне.
## 7. Чего не проверял
- Полный браузерный набор (135 смоков) и `golden:verify` — обоснование сужения в §2.
- `pytest tests_backend` — класс A/B бэкенда не тронут.
- Performance-профили — не названы в AC, hot-path не тронут (обоснование в §2).
- Не проверялось поведение при повреждённой модели с дублирующимися id (явно вне скоупа, ТЗ §3).
- Не перепроверял Medium-1/#184 по существу — это отдельный issue, не в диапазоне этого код-ревью.
## 8. Вердикт
Все AC1–AC10 доказаны — либо исполняемым тестом с подтверждённой способностью упасть (unit,
browser smoke, mutation gate), либо явной записью «проверено чтением, не исполнением» с указанием
конкретных строк/инвариантов. High: 0. Medium: 0. Two Low observations waived with a note, no new
issue required.
**Вердикт: зелёный · цикл r1/4 · High: 0 · Medium: 0 → нет · Документ: docs/reviews/CODE-REVIEW-113-r1.md**
+170
View File
@@ -0,0 +1,170 @@
# CODE-REVIEW-113-r2 — honest optional contract for `_spaceModel()`, post-rebase re-check
- **Issue:** https://github.com/Matysh/houseplan-card/issues/113
- **ТЗ:** `docs/specs/113-optional-space-model.md`, ревью ТЗ — `docs/reviews/SPEC-REVIEW-113-r1.md`
(зелёный, Medium-1 → #184, не блокирует)
- **Предыдущий цикл:** `docs/reviews/CODE-REVIEW-113-r1.md` — зелёный, High 0, Medium 0. Слияние
автоматически не прошло (конфликт `issue/113-optional-space-model` ↔ `dev`), поэтому issue
вернулся в `S6-in-progress` не за переделку кода, а за ребейз (AGENTS.md «Two-agent workflow»:
«после ребейза на сдвинутый `dev` это другой код, и принимать его без проверки нельзя»).
- **Диапазон:** `origin/dev..HEAD`, `origin/dev` = `c1676cf` (docs: review document for #117).
`git merge-base origin/dev HEAD` = `c1676cf` — ветка полностью перебазирована, дивергенции нет.
- **Коммиты (5):** `0c2a5de` (specify contract), `135497b` (spec review doc), `1e8503b`
(**реализация** — «Make empty space model explicit», это переехавший `09b5a74` из r1), `047363c`
(r1 code-review doc), `66fa8f4` (**новый после r1** — «Keep mutation anchor aligned after optional
model change»).
- **Цикл:** r2/4 (счётчик по этапу код-ревью; ревью ТЗ отдельно на r1)
- **Роль:** ревьюер кода (Claude), свежая сессия, без контекста реализации автора и без контекста
собственного прогона r1.
## 1. Скоуп цикла r2
`dev` продвинулся между r1 и этим ребейзом: слиты #132/#157 (hosted/partition openings) и #173
(unified wall topology). Автор перебазировал `issue/113-optional-space-model` на новый `dev`
(`c1676cf`) и разрешил конфликты, сохранив обе стороны. Это **другой код**, чем тот, что получил
зелёный вердикт в r1 — задача этого цикла не «доверять r1», а заново пройти по чек-листу §2.7 на
итоговом дереве.
Diff `origin/dev...HEAD` (16 файлов) идентичен по содержанию r1 плюс один новый коммит:
- `66fa8f4` меняет **только** `scripts/mutation-gate.mjs`: якорь мутации
`same-space-room-change-recenters` использовал буквальный текст
`prevPos.s === targetSpace`, но ребейз/реализация переименовала локальную переменную в
`targetSpaceId` (проверено чтением `src/houseplan-card.ts:12908,13018,13033` — переменная
везде называется `targetSpaceId`, старого имени `targetSpace` в файле нет). Мутационный якорь —
точное совпадение строки, поэтому несовпадающий текст сделал бы мутационный тест **тихо
неработающим** (find не совпадает → патч не применяется → «поймано» покажет ложный `OK`, а не
реальную проверку). `66fa8f4` возвращает якорь к соответствию исходнику.
- `Issue: #113` / `User-Visible: no` на месте на всех 5 коммитах, включая новый.
Остальные 15 файлов — то же содержание, что было предметом r1 (проверено `git diff`: единственный
новый функциональный код появляется только в `66fa8f4`). Часть строк diff'а (`_saveMarker`,
`partitions`) теперь физически стоит рядом с кодом #132/#157/#173, слитым в `dev` до ребейза — эти
call sites `_spaceModel()`/`.partitions` уже были в скоупе оригинальной реализации (видно по
`targetSpaceModel`, `space.partitions`/`model.partitions` в diff'е — не новые правки этого цикла,
перенеслись без изменений при ребейзе). Отдельно проверено: слитый в `dev` код не добавил **новых**
необработанных обращений к `_spaceModel()` вне diff'а этой ветки (см. §3).
`User-Visible: no` остаётся в силе без изменений (§13 ТЗ), changelog не трогается — согласовано.
## 2. Как проверялось — таблица гейтов
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | OK, без ошибок |
| Unit | `npm test` | `877/877` OK (было `811/811` в r1 — рост за счёт тестов, слитых в `dev` вместе с #132/#157/#173, не связан с этим диффом) |
| Build + 3 копии бандла | `npm run build`, затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | OK, обе сверки — байт-в-байт совпадение |
| Именованный smoke (AC3–AC6) | `node demo/smoke_optional_space_model.mjs` | OK, все 9 подпроверок `true` |
| Именованный smoke (AC6, read-only cold start) | `node demo/smoke_readonly_cold_start.mjs` | OK, все 13 подпроверок `true` |
| Мутационный guard AC10/AC4/AC5 (не менялся этим циклом) | `node scripts/mutation-gate.mjs --id=empty-space-cleanup-disabled` | `поймано 1 из 1`, чистый прогон зелёный |
| **Мутационный guard, якорь которого правит `66fa8f4`** | `node scripts/mutation-gate.mjs --id=same-space-room-change-recenters` | `поймано 1 из 1` — якорь совпал с исходником, патч применился, `smoke_subarea.mjs` покраснел, как обязан. Это прямое подтверждение, что правка якоря не сделала тест немым |
| Именованный smoke затронутого якоря | `node demo/smoke_subarea.mjs` (чистое дерево) | OK, все 14 подпроверок `true` |
| Regression: поверхности, слитые из #132/#157/#173, рядом с которыми правился diff (`_saveMarker`/`partitions`) | `node demo/smoke_partition_openings.mjs`, `node demo/smoke_unified_wall_tool.mjs`, `node demo/smoke_room_autoclose.mjs` | OK, все зелёные |
| Process gate | `node scripts/process-gate.mjs --issues` | `диапазон origin/dev..HEAD, коммитов 5`, `гейт пройден, предупреждений 0` |
| Контракт call sites | `grep -n "_spaceModel()!" src/houseplan-card.ts` и `grep -n "_spaceModel()\s*\." src/houseplan-card.ts \| grep -v "?\."` | пусто — ни non-null assertion, ни naked deref после `_spaceModel()` в production source |
**Не прогонялось, и почему:**
- **Полный набор 135(+) браузерных смоков** — не расширился в этом цикле относительно r1: этот
ребейз не добавил новых поверхностей, кроме тех трёх (`partition_openings`, `unified_wall_tool`,
`room_autoclose`), которые были рядом с изменёнными строками и прогнаны выше. Остальное
логическое обоснование r1 (guard, недостижимый при непустой модели) не изменилось — новый
функциональный код этого цикла ограничен одной правкой строки в тестовом скрипте, не в продукте.
- **`npm run golden:verify`** — не прогонялся; тот же аргумент, что и в r1 (guard добавлен *до*
существующей логики и физически недостижим при непустой модели), плюс этот цикл не меняет ни
одной строки `src/houseplan-card.ts` относительно r1 — только `scripts/mutation-gate.mjs`
(класс B, не влияет на визуальный результат).
- **`python -m pytest tests_backend`** — ни один файл `custom_components/**/*.py` не тронут ни в
этом коммите, ни в диапазоне.
- **Performance-профили** — не названы в AC, hot path не тронут этим циклом (единственная правка —
тестовый скрипт).
- Остальные смоки вне таблицы — сознательно не прогонялись; сужение соразмерно объёму нового кода
цикла (одна строка в `mutation-gate.mjs`), а не всей задаче #113 заново.
## 3. Проверка AC (docs/specs/113-optional-space-model.md §10) — переподтверждение после ребейза
Продуктовый код (`src/houseplan-card.ts`, `src/space-model-selection.ts`) идентичен принятому в r1
(проверено `git diff` — единственный новый коммит цикла трогает только `scripts/mutation-gate.mjs`),
поэтому доказательства AC1–AC9 из r1 переносятся без изменений; ниже — что перепроверено заново на
итоговом дереве, а не просто процитировано.
| AC | Статус в r2 |
|---|---|
| AC1–AC2 (optional тип, отсутствие non-null assertions) | перепроверено заново: `tsc --noEmit` зелёный на итоговом дереве; grep на `_spaceModel()!`/naked deref — пусто (см. §2) |
| AC3–AC6 (empty lifecycle, delete-last, pending action abort, read-only cold start) | перепроверено исполнением: `smoke_optional_space_model.mjs`, `smoke_readonly_cold_start.mjs` — оба зелёные на итоговом дереве после ребейза |
| AC7 (non-empty View/editors pixel/action parity) | перепроверено smoke-регрессией на поверхностях, слитых из #132/#157/#173 и физически соседствующих с diff'ом (`partition_openings`, `unified_wall_tool`, `room_autoclose`, `subarea`) — все зелёные; логический аргумент r1 (guard недостижим при непустой модели) не изменился, т.к. продуктовый код не менялся |
| AC8 (stale explicit id не мутирует первый space) | код не менялся; `test/space-model-selection.test.mjs` в составе зелёного `npm test` |
| AC9 (active-id fallback) | код не менялся; тот же unit-тест |
| AC10 (type/source gates ловят регрессию сигнатуры) | **перепроверено исполнением заново** — `empty-space-cleanup-disabled` (не менялся) и, отдельно, мутационный тест, чей якорь чинит этот цикл (`same-space-room-change-recenters`): подтверждено, что после правки якорь совпадает с исходником и мутация по-прежнему ловится (§2, §4) |
## 4. Дисциплина «тест умеет падать» — что именно проверено исполнением в этом цикле
Ядро этого цикла — не переисполнение доказательств r1 (код там не менялся), а верификация того, что
единственная новая правка (`66fa8f4`) не превратила существующий мутационный гейт в фиктивный:
- До правки старый якорь (`prevPos.s === targetSpace`) не совпал бы ни с одной строкой текущего
`src/houseplan-card.ts` (переменная называется `targetSpaceId` во всех трёх местах использования —
проверено grep'ом). Несовпадающий `find` в `scripts/mutation-gate.mjs` не бросает ошибку — он
просто не применяет патч, тест против чистого дерева проходит с эффективно нулевым мутационным
покрытием, а `поймано 1 из 1` в CI осталось бы зелёным без реальной проверки — то есть перед
`66fa8f4` этот конкретный guard тихо перестал бы что-либо ловить.
- Прогон `node scripts/mutation-gate.mjs --id=same-space-room-change-recenters` на текущем дереве
подтверждает обратное: `find` совпал, патч применился, `smoke_subarea.mjs` **покраснел** — тест
умеет падать по факту исполнения, не предположительно.
- То же самое для `empty-space-cleanup-disabled` (якорь не менялся этим циклом, но перепрогнан
заново на итоговом дереве после ребейза, а не процитирован из r1): `поймано 1 из 1`.
## 5. Находки
Нет находок High. Нет находок Medium.
Два Low из r1 (стейл `_splitSel`/`_mergeDialog`/`_wallDialog` при пустом переходе; недостижимый
защитный `if (!space) return nothing` в `render()`) относятся к продуктовому коду, который в этом
цикле не менялся — они разобраны и сняты в `docs/reviews/CODE-REVIEW-113-r1.md` §5, повторно не
переоткрываю.
## 6. Что проверено и корректно
- `git merge-base origin/dev HEAD` совпадает с `origin/dev` — ветка действительно линейно
перебазирована, диапазон `origin/dev..HEAD` не содержит посторонних коммитов и не пропускает ни
один слитый коммит основной линии.
- Продуктовый diff (`src/houseplan-card.ts`, `src/space-model-selection.ts`,
`docs/ARCHITECTURE.md`, `docs/TESTING.md`, тесты, smoke) идентичен r1 построчно — единственная
дельта цикла ограничена `scripts/mutation-gate.mjs` (класс B).
- Call sites, физически расположенные рядом со слитыми из #132/#157/#173 фичами (`_saveMarker`
targetSpaceModel/targetSpaceId, `.partitions` в `_openPairs`/соседних хелперах), уже были частью
оригинальной классификации #113 (видно по `targetSpaceModel`/`space.partitions` в diff'е — не
новые изменения этого цикла); слитый код сам по себе использует новую optional-сигнатуру
корректно там, где обращается к `_spaceModel()` (например, `_spaceModel()?.partitions` в коде,
пришедшем из `dev`) — проверено чтением и не нарушено ни одним из трёх smoke по этим
поверхностям.
- `docs/ARCHITECTURE.md`: баннер «Updated» корректно продвинут на дату ребейза, инвариант
optional-space описан консистентно с кодом; `docs/TESTING.md` чек-лист ссылается на реальные
smoke/unit/mutation имена, все они существуют и зелёные.
- Трейлеры на всех 5 коммитах диапазона: `Issue: #113`, `User-Visible: no` — включая новый
`66fa8f4`. `node scripts/process-gate.mjs --issues` зелёный, 0 предупреждений.
- `tsconfig.test.json` и `docs/specs/README.md` — механические регистрации нового файла/записи,
соответствуют содержимому.
## 7. Чего не проверял
- Полный браузерный набор смоков и `golden:verify` — обоснование в §2 (нулевой продуктовый diff
относительно r1, узкий новый код цикла — одна строка в тестовом скрипте).
- `pytest tests_backend` — класс A/B бэкенда не тронут.
- Performance-профили — не названы в AC, hot path не тронут ни в r1, ни в этом цикле.
- Содержательный повторный аудит AC1–AC9 «с нуля» без опоры на r1 — не требуется: код, который они
доказывают, не менялся байт в байт (проверено `git diff` между продуктовым кодом r1 и текущим
деревом), поэтому это не «доверие прошлому вердикту», а подтверждённый факт отсутствия изменений
в этой части.
- Medium-1/#184 (fallback-семантика ТЗ §6) — по-прежнему отдельный issue, не в скоупе этого
код-ревью и не в скоупе #113.
## 8. Вердикт
Ребейз на `c1676cf` не изменил ни строки продуктового кода #113: единственная новая правка цикла —
восстановление мутационного якоря, сломанного переименованием переменной в результате мержа. Правка
проверена исполнением на способность ловить регрессию (не голословно), все фаст-гейты и релевантные
smoke зелёные на итоговом дереве, AC1–AC10 подтверждены заново (без изменений там, где код не
менялся, и напрямую — там, где менялся). High: 0. Medium: 0.
**Вердикт: зелёный · цикл r2/4 · High: 0 · Medium: 0 → нет · Документ: docs/reviews/CODE-REVIEW-113-r2.md**
+176
View File
@@ -0,0 +1,176 @@
# CODE-REVIEW-113-r3 — post-merge `docs` gate refresh, no product change
- **Issue:** https://github.com/Matysh/houseplan-card/issues/113
- **ТЗ:** `docs/specs/113-optional-space-model.md`, ревью ТЗ — `docs/reviews/SPEC-REVIEW-113-r1.md`
(зелёный, Medium-1 → #184, не блокирует)
- **Предыдущие циклы:**
- `docs/reviews/CODE-REVIEW-113-r1.md` — зелёный, High 0, Medium 0. Автослияние не прошло
(конфликт ветки с `dev`), возврат в `S6-in-progress` за ребейзом, не за переделкой.
- `docs/reviews/CODE-REVIEW-113-r2.md` — зелёный, High 0, Medium 0, после ребейза на
`dev`=`c1676cf`. Пайплайн слил ветку в `dev` и поставил `S8-merged`.
- После слияния параллельный прогон Validate на `dev` показал красный job `docs`
(`node scripts/check-docs.mjs --external`): встроенный в `docs/images/screenshots.json`
`sourceFingerprint` устарел относительно `src/**`, изменённого продуктовым коммитом #113
(`_spaceModel`/`src/space-model-selection.ts`). Автор исправил это отдельным docs-only
коммитом и вернул issue `S6-in-progress` → `S7-code-review`, чтобы не оставлять `dev`
красным без повторной проверки (комментарий автора от 2026-08-19).
- **Диапазон:** `origin/dev..HEAD` = один коммит `acad3b3` («Refresh documentation source
fingerprint»). `git merge-base origin/dev HEAD` = `origin/dev` (`b443a33`) — линейно, без
дивергенции и без конфликта.
- **Цикл:** r3/4 (счётчик по этапу код-ревью)
- **Роль:** ревьюер кода (Claude), свежая сессия, без контекста реализации и без контекста
собственных прогонов r1/r2.
## 1. Скоуп цикла r3
Единственный коммит диапазона, `acad3b3`:
```
docs/images/screenshots.json | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
```
Меняются только два повторяющихся поля манифеста — `sourceFingerprint` (один раз, в шапке) и
`sourceSha256` (в каждом из 10 сценариев, то же самое значение). Ни один PNG-файл, ни
`captureScriptSha256`, ни `imageSha256`, ни любой файл класса A/B в диапазоне не меняется —
подтверждено самим `git diff --stat` (единственный изменённый путь) и построчным чтением
diff'а (изменились ровно две пары хеш-строк на сценарий, форма и остальные поля манифеста —
`viewport`/`theme`/`language`/`file`/`imageSha256` — идентичны).
Класс файла — `docs/**` (класс C, `AGENTS.md`). Продуктовый код (`src/houseplan-card.ts`,
`src/space-model-selection.ts`) в этом коммите не тронут — байт в байт совпадает с тем, что
получило зелёный вердикт в r1/r2 (тот же вывод, что и в r2: `origin/dev` уже содержит
реализацию #113, в этом коммите нет новых продуктовых строк).
`Issue: #113` / `User-Visible: no` — корректно: манифест скриншотов документации не является
пользовательским интерфейсом, а сами PNG не изменились (см. §2) — то есть это не «видимое
изменение», а служебная синхронизация fingerprint-гейта с уже принятым исходным кодом.
Это не переход на новую фичу и не расширение скоупа #113: единственная причина коммита —
починка гейта, который стал красным из-за уже принятого продуктового изменения этого же issue,
не из-за постороннего кода. Формально это ближе к «предрелизному гейту» из `PROCESS.md` §11.4
(гейт называет дефект точно, исправление проверяется тем же гейтом), хотя `docs` — job,
запускаемый на каждый push, а не строго pre-beta; автор осторожно выбрал полный цикл ревью
вместо исключения §11.4, что процессом не запрещено и не требует правки с моей стороны.
## 2. Как проверялось — таблица гейтов
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | OK, без ошибок |
| Unit | `npm test` | `877/877` OK — идентично r2 (продуктовый код не менялся) |
| Build + 3 копии бандла | `npm run build`, затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | OK, обе сверки — байт-в-байт совпадение |
| **Именно упавший гейт** | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links).` — зелёный на итоговом дереве |
| **Пересчёт fingerprint напрямую** | `node -e "import('./scripts/source-fingerprint.mjs').then(({sourceFingerprint}) => console.log(sourceFingerprint(process.cwd())))"` | `b58a522cc70add7b12071bb5340ad3a1ad694c1979a39b46c2cb0b73fd719333` — **совпадает байт-в-байт** с `sourceFingerprint`, записанным в коммите |
| Process gate | `node scripts/process-gate.mjs --issues` | `диапазон origin/dev..HEAD, коммитов 1`, `гейт пройден, предупреждений 0` |
**Не прогонялось, и почему:**
- **Браузерные смоки (127+)** — не прогонялись. Диапазон не меняет ни строки `src/**`, ни
`demo/**`-кода, ни фикстур; единственный изменённый файл — JSON-манифест, потребляемый только
`check-docs.mjs`. Ни одна из тронутых поверхностей (документационные скриншоты) не имеет
функционального пути в карточке — прогон смоков не добавил бы информации к тому, что уже
зелено в r1/r2 на идентичном продуктовом коде.
- **`npm run golden:verify`** — не прогонялся. `docs/images/screenshots.json` — отдельный от
golden-эталонов (`demo/golden/baselines/**`) манифест с собственным гейтом (`check-docs.mjs`),
который прогнан и зелёный. Продуктовый рендер не менялся, пиксельного риска golden-эталонов
нет.
- **`python -m pytest tests_backend`** — не тронут ни один файл `custom_components/**/*.py`.
- **Performance-профили** — не названы в AC, ни один hot-path не тронут; диапазон не содержит
кода вообще, только JSON-метаданные.
- Обоснование сознательное, соразмерное объёму диапазона (PROCESS.md §8): один изменённый файл,
один назначенный ему гейт (`check-docs.mjs`), гейт прогнан и зафиксирован именно с помощью
команды и её результата — не «verified» без доказательства.
## 3. Проверка AC (docs/specs/113-optional-space-model.md §10)
Продуктовый код, доказывающий AC1–AC10, не менялся в этом цикле (§1) — доказательства r1/r2
переносятся без изменений. Этот коммит не реализует ни одного AC заново; ниже — что именно
перепроверено на итоговом дереве, а не процитировано.
| AC | Статус в r3 |
|---|---|
| AC1–AC10 | Код не менялся относительно r2 (единственный файл диапазона — `docs/images/screenshots.json`, не участвующий ни в одном AC из §10 ТЗ). `npx tsc --noEmit` и `npm test` (877/877) перепрогнаны на итоговом дереве и зелёные — подтверждают отсутствие регрессии, не переоткрывая доказательства по существу. |
Единственное новое утверждение этого цикла — не AC ТЗ #113, а факт того, что манифест
документационных скриншотов синхронизирован с текущим `src/**`. Оно доказано исполнением
(`check-docs.mjs` зелёный + независимый пересчёт fingerprint, см. §2), а не пересказом.
## 4. Дисциплина «тест умеет падать» — что проверено исполнением
- **`check-docs.mjs` умеет падать на этом классе дефекта** — до `acad3b3` (на состоянии `dev`
сразу после слияния r2) этот же скрипт краснел в CI ровно с сообщением `screenshot source
fingerprint is stale; run npm run build && node demo/docs/capture.mjs` (см. слово автора и
сам факт, что job `docs` был красным в Validate). Причина понятна и воспроизводима логикой
скрипта (`scripts/check-docs.mjs:118-119`): сравнение `manifest.sourceFingerprint !==
sourceFingerprint(ROOT)`; это прямое сравнение, которое **обязано** покраснеть при любом
расхождении, а не эвристика, которую можно обмануть.
- **Пересчёт fingerprint выполнен независимо от `check-docs.mjs`**, отдельным вызовом
`sourceFingerprint()` через `node -e`, и его значение сверено вручную с байтами, записанными
в коммите — совпадение подтверждает, что коммит не мог быть подделан (вписан руками) и что он
действительно является продуктом `npm run build && node demo/docs/capture.mjs` на этом дереве,
как и заявлено автором.
- **PNG не менялись** — сам `git diff --stat` не содержит ни одного `.png`-пути; отдельно
`check-docs.mjs` сверяет `scenario.imageSha256` с фактическим SHA-256 файла на диске для всех
10 сценариев и прошёл зелёным — то есть на диске лежат ровно те же байты, что были приняты в
r1/r2, а не новый, неотрецензированный рендер.
## 5. Находки
Нет находок High. Нет находок Medium в диапазоне этого коммита.
**Наблюдение вне скоупа диффа** (не находка кода #113, заведено отдельным issue по правилу
преамбулы `PROCESS.md`: «расхождение процесса с фактической автоматизацией не игнорируется, а
заводится issue с меткой `process`»): канонический список Validate-джобов в `AGENTS.md` («CI is
pinned to an exact SHA... Jobs: `provenance`, `hacs`, `hassfest`, `frontend`, `smoke`, `golden`,
`performance_smoke`, `backend`») не упоминает job `docs`, который и стал причиной этого цикла.
Строка `AGENTS.md` не менялась с `0f8d35f` (2026-08-14), `docs`-job/`check-docs.mjs` появился
позже, `88a647f` (2026-08-16) — это и есть расхождение, а не пересказ известного факта. Заведён
[#191](https://github.com/Matysh/houseplan-card/issues/191) (`process`, `docs`, `P3`), не
блокирует #113 — сам этот коммит корректно работает с фактическим, не с задокументированным,
набором гейтов.
## 6. Что проверено и корректно
- Диапазон `origin/dev..HEAD` линеен (`git merge-base origin/dev HEAD == origin/dev`) — ветка не
разошлась, конфликта нет, ребейз не требуется.
- Единственный изменённый файл диапазона — `docs/images/screenshots.json`, класс C; не задевает
ни одного файла класса A/B. Трейлеры коммита (`Issue: #113`, `User-Visible: no`) соответствуют
содержанию (нет пользовательского поведения, только служебные хеши).
- `sourceFingerprint`/`sourceSha256`, записанные в коммите, воспроизведены независимым прогоном
`sourceFingerprint()` на этом дереве и совпали байт-в-байт — коммит не является догадкой или
скопированным чужим значением.
- `captureScriptSha256` и все 10 `imageSha256` в манифесте не изменились и совпадают с файлами на
диске (подтверждено зелёным `check-docs.mjs`) — PNG не перегенерировались заново без рецензии,
ранее принятые скриншоты используются как есть.
- Продуктовый код (`src/houseplan-card.ts`, `src/space-model-selection.ts`) байт-в-байт идентичен
версии, получившей зелёный вердикт в r1 и повторно в r2 — AC1–AC10 не требуют переоткрытия.
- Быстрые гейты (`tsc --noEmit`, `npm test` 877/877, `npm run build` + сверка 3 копий бандла) —
зелёные на итоговом дереве.
- `node scripts/process-gate.mjs --issues` — зелёный, 0 предупреждений, диапазон 1 коммит.
- `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` корректно не тронуты — `User-Visible: no`.
## 7. Чего не проверял
- Полный набор браузерных смоков и `golden:verify` — обоснование в §2 (диапазон не содержит ни
строки `src/**`/`demo/**`-кода, только JSON-манифест документационных скриншотов).
- `pytest tests_backend` — класс A/B бэкенда не тронут.
- Performance-профили — не названы в AC, не затронуты (диапазон без кода).
- Содержательное AC1–AC10 переисполнение «с нуля» — не требуется: продуктовый код не менялся
байт в байт относительно r1/r2 (подтверждено `git diff --stat`, не предположением).
- Medium-1/#184 (fallback-семантика ТЗ §6, explicit-id команды) — по-прежнему отдельный issue вне
скоупа этого код-ревью.
- Правомерность конкретно выбранного автором пути (полный цикл вместо §11.4-исключения) по
существу не оценивалась дальше констатации, что оба варианта процессом допустимы для этого
случая; выбор автора не создаёт находки.
## 8. Вердикт
Единственный коммит цикла — служебная синхронизация fingerprint-манифеста документационных
скриншотов с уже принятым (дважды зелёным) продуктовым кодом #113; PNG не менялись, продуктовый
код не менялся, упавший гейт (`check-docs.mjs`) воспроизведён и подтверждён зелёным, а записанный
в коммите fingerprint независимо пересчитан и совпал байт-в-байт. Все быстрые гейты зелёные.
High: 0. Medium: 0 в диапазоне диффа. Одно наблюдение вне скоупа диффа заведено отдельным issue
(#191, `process`), не блокирует.
**Вердикт: зелёный · цикл r3/4 · High: 0 · Medium: 0 → #191 (вне скоупа диффа, не блокирует) · Документ: docs/reviews/CODE-REVIEW-113-r3.md**
+186
View File
@@ -0,0 +1,186 @@
# CODE-REVIEW-117-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/117
- **ТЗ:** `docs/specs/117-registryless-opening-entity.md`, зелёное
`docs/reviews/SPEC-REVIEW-117-r1.md` (High 0, Medium 0)
- **Диапазон:** `git log --oneline origin/dev..HEAD` — три коммита:
- `03cca97` `fix: support registryless opening entities` (`Issue: #117`,
`User-Visible: yes`)
- `3bd80ff` `docs: review document for #117` (`Issue: #117`, `User-Visible: no`)
- `cc22988` `docs: specify registryless opening entities` (`Issue: #117`,
`User-Visible: no`)
- **Роль:** ревьюер кода (не автор), этап `S7-code-review`, цикл r1/4
## Скоуп ревью
Диф (`git diff origin/dev...HEAD --stat`, 16 файлов):
| Класс | Файлы |
|---|---|
| A (продукт) | `src/ha-binding-status.ts` |
| B (гейты/инструменты) | `demo/smoke_registryless_opening.mjs` (новый), `scripts/mutation-gate.mjs`, `test/ha-binding-status.test.mjs`, `test/render-device-snapshot.test.mjs` |
| C (документация) | `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, `docs/TESTING.md`, `docs/USER-GUIDE.ru.md`, `docs/reviews/SPEC-REVIEW-117-r1.md`, `docs/specs/117-registryless-opening-entity.md`, `docs/specs/README.md` |
| D (сгенерированное) | `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js`, `demo/srv/assets/houseplan-card.js` |
Продуктовая правка — ровно одна логическая точка: `renderOpeningEntityAvailable()`
в `src/ha-binding-status.ts:533-545` больше не требует `projectedHass.entities[entityId]`
одновременно с `projectedHass.states[entityId]`; сохранена проверка только
наличия state в замороженной активной проекции. `_openingAmt()` и
`_renderOpeningLocks()` в `src/houseplan-card.ts` не менялись — оба уже шли
через единственную точку `_renderOpeningEntityAvailable()`, поэтому фикс
закрывает contact и lock одновременно без риска рассинхронизации.
## Как проверялось
Гейты, которые прогнал я лично (не со слов автора):
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | 804/804 pass |
| Build + сверка бандлов | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | зелёный; `sha256sum` всех трёх копий совпадает: `73fffed183e23ab015cb0dd43ee0b3d2b277822bdd2f44813af6a1051398c881` |
| Целевой smoke (AC1/2/3/7/8/9) | `node demo/smoke_registryless_opening.mjs` | `OK`, все 13 подпроверок `true` |
| Регрессия opening-биндинга | `node demo/smoke_opening_binding.mjs` | `OK`, все 15 подпроверок `true` |
| Регрессия lock action | `node demo/smoke_lock_action.mjs` | `OK`, все 8 подпроверок `true` |
| Регрессия lock invariant (SCOPE.md) | `node demo/smoke_lock_invariant.mjs` | `OK`, все 7 подпроверок `true` |
| Mutation-guard | `node scripts/mutation-gate.mjs --id=registryless-opening-requires-registry-row` | `поймано 1 из 1` — мутант (возврат `.entities[entityId]`) красит smoke, чистый прогон зелёный |
Дополнительно — доказательство «тест умеет падать» для unit-покрытия, не
только для smoke (§2.7 требует это явно): временно вернул проверку
`!!projectedHass?.entities?.[entityId]` в `src/ha-binding-status.ts`,
пересобрал `test-build` (`npx tsc -p tsconfig.test.json && node
scripts/fix-test-build.mjs`) и прогнал `node --test test/ha-binding-status.test.mjs
test/render-device-snapshot.test.mjs` — результат `18 pass / 1 fail`, упал
именно новый тест `'opening render availability trusts one frozen active
projection'` (assert на `binary_sensor.yaml_only === true`). После проверки
восстановил `src/ha-binding-status.ts` из `git`, пересобрал `test-build` и
перепрогнал `npm test` — снова 804/804, дерево чистое (`git status --short`
пусто, `npx tsc -p tsconfig.test.json` — сгенерированный `test-build/` не
версионируется).
Что не прогонял и почему (соразмерность гейтов, PROCESS.md §8):
- **Полный набор из 127 browser-смоков** — diff задевает ровно одну точку
(`renderOpeningEntityAvailable`) и один потребитель (opening
contact/lock); прогнал новый целевой smoke плюс три регрессионных,
напрямую покрывающих opening-биндинг и lock-security — этого достаточно
для поверхности изменения, полный прогон здесь не добавил бы сигнала.
- **`npm run golden:verify`** — не прогонял. Обоснование не «со слов
автора», а по коду: `space-render.ts`/`logic.ts`/любой геометрический или
стилевой путь не тронуты; единственная правка убирает одно `&&`-условие,
которое для уже зарегистрированных сущностей (у них `entities[eid]` и
`states[eid]` совпадают одновременно) не меняет результат вообще —
registry-backed painted frame буквально не может стать другим. Влияет
только на entity без registry row, которых ни в одном golden-сценарии нет
(не заведены YAML-only fixtures). Golden-эталоны диф не трогает.
- **`python -m pytest tests_backend -q`** — не прогонял, диф не касается
`custom_components/houseplan/**/*.py` (нет таких файлов в списке
изменений).
- **Performance-профили** — не прогонял. ТЗ §14 явно заявляет «нет», и
правка добавляет один and-член в уже читаемый на каждом кадре boolean —
не новый проход по данным, не новая структура.
## Находки
Находок уровня **High** и **Medium** нет.
Low не завожу: код, тесты и документация построены по написанному в ТЗ
без отклонений, я не нашёл ни одного места, которое стоило бы поправить или
снять с запиской.
## Что проверено и корректно
- **AC1 (registry-less live contact меняет presentation)** — доказано
smoke: `closedContactControlsPresentation` (closed) и
`stateTickSwapsOneAtomicFrame` (open) на `binary_sensor.hp117_yaml_contact`
без registry row (`card.hass.entities[...] == null` подтверждено смоком).
- **AC2 (registry-less live lock badge)** — доказано smoke:
`yamlLockBadgeRendersLocked`, затем `.oplock.unlocked` после смены
состояния, `unknownKeepsExistingTypeSemantics` для `unknown`.
- **AC3 (frame — только immutable projection, не raw hass)** — доказано и
чтением, и исполнением: `_renderOpeningEntityAvailable` (`src/houseplan-card.ts:16283`)
— однострочная делегация в `renderOpeningEntityAvailable(this._renderPlanHass, eid)`,
`this.hass` не упоминается; source-contract тест
`test/render-device-snapshot.test.mjs` проверяет это регексом
(`assert.doesNotMatch(..., /this\.hass\b/)`) и подтверждает через
`methodBody`. Мутационный гейт и мой ручной revert показывают, что тест
красится при регрессии — не косметическая проверка.
- **AC4 (active registry entity — прежнее поведение)** — не могло
измениться логически (см. «Что не прогонял», golden) и подтверждено
smoke-регрессией `smoke_opening_binding.mjs` (`exactOpeningReferencesStayActive`
и другие).
- **AC5 (disabled/orphan/missing остаются unavailable)** — доказано unit
(`test/ha-binding-status.test.mjs`, ветки `lock.disabled_entity`,
`lock.disabled_parent`, `lock.orphan`, `lock.missing` — все `false`) и
smoke (`explicitDisabledRowsRemoveStaleStates`, включая ожидание паузы на
дебаунс реестра `220ms` перед проверкой). Логика опирается на уже
существующий (не изменённый этим дифом) `activeRegistryHass()`
(`src/ha-binding-status.ts:323-354`) — прочитано построчно: state
registry-less сущности проходит проекцию, только когда для неё нет
соответствующей записи `entities[eid]` вовсе (значит проверки disabled
не применяются), либо запись есть и явно не отключена.
- **AC6 (limited-registry live exact entity работает)** — доказано unit
(`limitedFrame` в тесте, `authoritative: false`).
- **AC7 (marker tombstone не блокирует opening reference)** — доказано unit
(`markerTombstoneIsNotAnInput`) и smoke (`markerTombstonesDoNotBlockOpening`,
который дополнительно контрастирует с `!card._planEntityAvailable(...)` —
показывает, что общий marker-путь по-прежнему блокируется, а
opening-путь — нет, то есть сужение скоупа из ТЗ §4 не создало разрыва).
- **AC8 (lock security/unlock confirmation не меняются)** — подтверждено
чтением: `_lockAction()` (`src/houseplan-card.ts:16411-16424`) не входит в
диф, по-прежнему проверяет live `_openingEntityAvailable()` перед
`callService`, требует `confirm()` только для `unlock`. Регрессия
подтверждена исполнением: `smoke_lock_action.mjs`,
`smoke_lock_invariant.mjs` зелёные, плюс новый smoke явно проверяет
`planOpeningAndBadgeNeverActuate` (тап по проёму/badge не вызывает
`callService`) и `explicitInfoActionStillWorks` (только явное действие в
открытой info-карточке вызывает `lock.unlock`). Инвариант SCOPE.md «The
lock invariant, stated precisely» не ослаблен.
- **AC9 (state update не пересобирает geometry/config)** — доказано smoke:
`stateTickDoesNotRebuildGeometryOrConfig` сравнивает
`_physicalBodiesCache`, `_cfgEpoch` и сериализованный `_serverCfg` до/после
тика состояния.
- **AC10 (существующие opening golden/interactions без регресса)** — golden
baseline в дифе не тронут; логическое обоснование неизменности
registry-backed пути дано выше (AC4); `smoke_opening_binding.mjs` зелёный
подтверждает поведенческую регрессию отдельно от golden.
- **Трейлеры и changelog:** все три коммита несут терминальные `Issue: #117`
и `User-Visible: yes|no`; коммит с `User-Visible: yes`
(`03cca97`) правит `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же
коммите (проверено `git show --stat 03cca97`, оба файла присутствуют).
- **Документация:** `docs/USER-GUIDE.ru.md` описывает новое поведение
словами, согласующимися с уже принятой терминологией раздела («Контактный
датчик», «замок»); `docs/ARCHITECTURE.md` фиксирует правило проекции;
`docs/TESTING.md` добавляет пункт с явным списком `[auto: ...]`, все
перечисленные файлы существуют и были прогнаны выше.
- **Соответствие ТЗ и SCOPE.md:** правка не расширяет скоуп (§4 «не входит»
дословно соблюдён — picker, миграция, marker lifecycle, generic
registry-less для прочих marker-биндингов, geometry не тронуты), лежит
внутри уже закрытых J4/J6, лок-инвариант не ослаблен.
## Чего не проверял
- Полный набор из 127 browser-smoke (см. обоснование сужения выше) —
предрелизный гейт, не гейт ревью.
- `npm run golden:verify` — обоснование дано выше (логическая неизменность
registry-backed пути плюс отсутствие изменений в geometry/render-стилях).
- `python -m pytest tests_backend -q` — Python не тронут этим дифом.
- Performance-профили — ТЗ явно исключает влияние, изменение не добавляет
новый проход по кадру.
- Реальную установку Home Assistant с живой YAML-сущностью без `unique_id` —
вне возможностей этого ревью; демо-стенд (`demo/srv/demo.html`) и его
фейковый `hass`/registry являются каноническим суррогатом по
`AGENTS.md`/`docs/DEVELOPMENT.md`, и я прогнал его лично, а не со слов
автора.
- Историю ветки/ребейз на `dev` — вне обязанностей код-ревью; это забота
конвейера при слиянии (риск отмечен автором в хендоффе).
## Вердикт
Зелёный. High: 0, Medium: 0. Каждый AC либо доказан автотестом, который я
лично прогнал и для которого лично подтвердил способность падать (unit —
ручным откатом фикса, smoke — mutation-гейтом), либо разобран чтением кода с
явной пометкой выше. Регрессионные lock-security смоки и SCOPE.md-инвариант
подтверждены исполнением, а не только чтением ТЗ. Продуктовый диф — одна
точка (`renderOpeningEntityAvailable`), в точности соответствующая описанию
и границам ТЗ; трейлеры, changelog (RU+EN) и три копии бандла в порядке.
+227
View File
@@ -0,0 +1,227 @@
# CODE-REVIEW-117-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/117
- **ТЗ:** `docs/specs/117-registryless-opening-entity.md`, зелёное
`docs/reviews/SPEC-REVIEW-117-r1.md` (High 0, Medium 0)
- **Предыдущий цикл:** `docs/reviews/CODE-REVIEW-117-r1.md` — зелёный, High 0,
Medium 0. Слияние не удалось (конфликт с `dev`), задача вернулась в
`S6-in-progress` не по правкам кода, а для ребейза (комментарий владельца от
2026-08-18). Ветка ребейзнута на актуальный `origin/dev` и опубликована
`--force-with-lease` на `c65cbcc96c644a2bfca3dbd6c62482dc16c092c3`.
- **Диапазон:** `git log --oneline origin/dev..HEAD` — четыре коммита:
- `563a850` `docs: specify registryless opening entities` (`Issue: #117`,
`User-Visible: no`)
- `9baf533` `docs: review document for #117` (`Issue: #117`, `User-Visible: no`)
- `01fe48d` `fix: support registryless opening entities` (`Issue: #117`,
`User-Visible: yes`)
- `c65cbcc` `docs: review document for #117` (`Issue: #117`, `User-Visible: no`)
- **Роль:** ревьюер кода (не автор), свежая сессия без контекста реализации,
этап `S7-code-review`, цикл **r2/4** (лимит считается по этапу — вердикт ТЗ
бюджет код-ревью не расходует).
## Скоуп ревью
`git diff origin/dev...HEAD --stat` — 18 файлов:
| Класс | Файлы |
|---|---|
| A (продукт) | `src/ha-binding-status.ts` |
| B (гейты/инструменты) | `demo/smoke_registryless_opening.mjs` (новый), `scripts/mutation-gate.mjs`, `test/ha-binding-status.test.mjs`, `test/render-device-snapshot.test.mjs` |
| C (документация) | `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, `docs/TESTING.md`, `docs/USER-GUIDE.ru.md`, `docs/images/screenshots.json`, `docs/specs/117-registryless-opening-entity.md`, `docs/specs/README.md`, `docs/reviews/SPEC-REVIEW-117-r1.md`, `docs/reviews/CODE-REVIEW-117-r1.md` |
| D (сгенерированное) | `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js`, `demo/srv/assets/houseplan-card.js` |
**Что изменилось между r1 и r2:** сам продуктовый диф не изменился ни на
строку — `git show 01fe48d -- src/ha-binding-status.ts` даёт байт-в-байт тот же
патч, что был одобрен в r1 (единственная точка: `renderOpeningEntityAvailable`
в `src/ha-binding-status.ts:533-546` больше не требует одновременно
`projectedHass.entities[entityId]` и `projectedHass.states[entityId]`, только
наличие state в замороженной проекции). Ребейз добавил только слияние трёх
generated-бандлов с ушедшим вперёд `dev` (конфликт разрешён пересборкой) и
`docs/images/screenshots.json` (изменился только `sourceFingerprint`/
`sourceSha256` из-за нового содержимого бандла; все `imageSha256` — то есть сам
пиксельный результат существующих golden-сцен — не изменились). Это не
формальность: я не полагался на констатацию «код тот же», а сверил патч
построчно и независимо пересобрал бандл, см. ниже.
## Как проверялось
Я не ассистент автора — свежая сессия, ни один гейт не принят «со слов».
Все команды ниже прогнаны лично на этом дереве (`origin/issue/117-registryless-opening`
@ `c65cbcc`).
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | 870/870 pass (совпадает с заявленным автором после ребейза; в r1 было 804/804 — рост за счёт коммитов `dev`, вошедших при ребейзе) |
| Build + сверка бандлов | `npm run build`, затем `sha256sum` трёх копий | зелёный; все три — `72f660c0c574574ab07d8d7dd96262c928f333dbe5747e8091c48fbb6f0094ca`, совпадает с заявленным автором хешем и с уже закоммиченными файлами (`git status --short` после сборки — пусто) |
| Целевой smoke (AC1/2/3/7/8/9) | `node demo/smoke_registryless_opening.mjs` | `OK`, все 13 подпроверок `true` |
| Регрессия opening-биндинга | `node demo/smoke_opening_binding.mjs` | `OK`, все 15 подпроверок `true` |
| Регрессия lock action | `node demo/smoke_lock_action.mjs` | `OK`, все 8 подпроверок `true` |
| Регрессия lock invariant (SCOPE.md) | `node demo/smoke_lock_invariant.mjs` | `OK`, все 7 подпроверок `true` |
| Mutation-guard | `node scripts/mutation-gate.mjs --id=registryless-opening-requires-registry-row` | `поймано 1 из 1` — возврат `.entities[entityId]` красит smoke, чистый прогон зелёный |
| Process-gate (офлайн + `--issues`) | `node scripts/process-gate.mjs --range origin/dev..HEAD --target-ref refs/heads/issue/117-registryless-opening --issues` | `коммитов 4`, `гейт пройден, предупреждений 0` |
Что не прогонял и почему (соразмерность гейтов, PROCESS.md §8, диф не менялся
относительно уже принятого r1):
- **Полный набор из 127 browser-смоков** — diff по-прежнему задевает ровно одну
логическую точку и один класс потребителей (opening contact/lock). Целевой
smoke плюс три регрессионных (opening-биндинг, lock action, lock invariant)
покрывают contract, security-инвариант и frame-atomicity — этого достаточно
для поверхности изменения; расширение до полного набора не добавило бы
сигнала сверх r1.
- **`npm run golden:verify`** — не прогонял. Прочитал diff `src/space-render.ts`
и геометрических модулей в `origin/dev...HEAD` — их там нет; единственная
правка убирает один операнд `&&`, который для уже зарегистрированных
сущностей (`entities[eid]` и `states[eid]` присутствуют или отсутствуют
синхронно) не меняет результат вовсе. `docs/images/screenshots.json` в этом
же коммите подтверждает то же самое замером: `imageSha256` всех 10 сцен не
изменился, изменился только `sourceFingerprint` (хеш нового бандла). Ни один
golden-сценарий не использует YAML-only fixture без registry row.
- **`python -m pytest tests_backend -q`** — не прогонял, диф не касается
`custom_components/houseplan/**/*.py` (таких файлов в изменениях нет).
- **Performance-профили** — не прогонял. ТЗ §14 явно заявляет «нет», правка не
добавляет новый проход по кадру или структуру данных.
- **Реальную установку Home Assistant с живой YAML-сущностью без `unique_id`**
— вне возможностей код-ревью; демо-стенд с фейковым `hass`/registry —
канонический суррогат по `AGENTS.md`, и я прогнал его лично.
- **Ребейз/историю ветки как таковую** (корректность merge-base, что именно
было в конфликте) — не переисполнял `git rebase`; вместо этого сверил *итог*:
продуктовый патч побайтово идентичен r1, три бандла воспроизводимо
собираются из текущего дерева с тем же хешем, что заявил автор, `npm test`
зелёный на полном (после ребейза выросшем) наборе. Это доказывает результат
ребейза, а не процесс его выполнения — для код-ревью этого достаточно.
- **Локальный `pre-push`, обойдённый автором при публикации ребейзнутой ветки**
— не входит в скоуп код-ревью #117: это отдельный дефект инструмента
(диапазон `pre-push` после `--force-with-lease` неверно берёт старый remote
SHA), заведён владельцем отдельно как **#190** (`process`, `bug`, `P2`,
`S1-new` — проверено `gh issue view 190`, issue существует с ровно этими
метками). Страховка CI (`process-gate` job в `validate.yml`) не обходится
этим действием, и я независимо прогнал тот же скрипт офлайн — гейт чист.
## Находки
Находок уровня **High** и **Medium** нет.
Low не завожу. Диф идентичен уже одобренному в r1, повторное построчное чтение
не выявило ничего нового; единственное новое обстоятельство ребейза
(`docs/images/screenshots.json`) — ожидаемое следствие смены хеша бандла, а не
дефект.
## Что проверено и корректно
Ревью выполнено заново, не как штамп поверх r1: каждый AC перепроверен чтением
текущего кода на этом SHA и/или повторным исполнением, без опоры на текст
предыдущего документа как на источник истины.
- **AC1 (registry-less live contact меняет presentation).** Прочитан
`_openingAmt()` (`src/houseplan-card.ts:17101-17107`): amount берётся из
`this._renderPlanHass.states[o.contact]` только когда
`_renderOpeningEntityAvailable(o.contact)` истинно. Доказано smoke:
`closedContactControlsPresentation` (closed) и `stateTickSwapsOneAtomicFrame`
(open) на `binary_sensor.hp117_yaml_contact`, для которой smoke явно проверяет
`noRegistryRowsExist` (нет строки в `card.hass.entities`).
- **AC2 (registry-less live lock badge).** `_renderOpeningLocks()`
(`src/houseplan-card.ts:17221-17224`) фильтрует по тому же
`_renderOpeningEntityAvailable(o.lock)`. Доказано smoke:
`yamlLockBadgeRendersLocked`, затем `.oplock.unlocked` после смены состояния,
`unknownKeepsExistingTypeSemantics` — `unknown` не становится
ни `locked`, ни `unlocked`, значок `.oplock.unknown`.
- **AC3 (frame — только immutable projection, не raw hass).** Прочитано:
`_renderOpeningEntityAvailable` (`src/houseplan-card.ts:17129-17131`) —
однострочная делегация в `renderOpeningEntityAvailable(this._renderPlanHass, eid)`,
`this.hass` не упоминается нигде в теле. Source-contract тест
`test/render-device-snapshot.test.mjs` проверяет это регексом на тело именно
этого метода (`assert.doesNotMatch(openingRenderAvailability, /this\.hass\b/)`)
и через `methodBody`. Mutation-гейт подтверждает, что тест красится при
регрессии (возврат `.entities[entityId]`), а не только выглядит проверкой.
- **AC4 (active registry entity — прежнее поведение).** Логическое обоснование
подтверждено чтением `activeRegistryHass()` (`src/ha-binding-status.ts:323-354`):
для зарегистрированной активной сущности `entities[eid]` и `states[eid]`
присутствуют или отсутствуют синхронно — удаление одного операнда `&&` не
меняет результат. Исполнением подтверждено `smoke_opening_binding.mjs`
(`exactOpeningReferencesStayActive` и вся регрессионная матрица, 15/15).
- **AC5 (disabled/orphan/missing остаются unavailable).** Прочитан
`activeRegistryHass()` построчно: явный `disabled_by` на сущности или
родительском устройстве, а также authoritative-orphan (`device_id` указывает
на отсутствующее устройство) — все три ветки удаляют `state` из проекции
*до* её передачи в render helper, поэтому `renderOpeningEntityAvailable`
честно не может их оживить. Доказано unit
(`test/ha-binding-status.test.mjs`, кейсы `lock.disabled_entity`,
`lock.disabled_parent`, `lock.orphan`, `lock.missing` — все `false`) и smoke
(`explicitDisabledRowsRemoveStaleStates`, с ожиданием debounce-паузы `220ms`
перед проверкой удаления state).
- **AC6 (limited-registry live exact entity работает).** Доказано unit —
`limitedFrame` с `authoritative: false` и без registry rows,
`renderOpeningEntityAvailable(limitedFrame, 'binary_sensor.limited_yaml')`
→ `true`.
- **AC7 (marker tombstone не блокирует opening reference).** Доказано unit
(`markerTombstoneIsNotAnInput` — projection с `removed: true` маркером той же
сущности всё равно даёт `true`) и smoke
(`markerTombstonesDoNotBlockOpening`, который дополнительно контрастирует с
`!card._planEntityAvailable(...)`, то есть общий marker-путь по-прежнему
блокируется, а opening-путь — нет; узкий скоуп из ТЗ §4 не создаёт разрыва).
- **AC8 (lock security/unlock confirmation не меняются).** Прочитан
`_lockAction()` (`src/houseplan-card.ts:17260-17273`) — вне дифа, по-прежнему
проверяет `_openingEntityAvailable()` (live path) перед `callService`,
`confirm()` только для `unlock`. Значок `.oplock` по клику лишь открывает
info-карточку (`this._openingInfo = o`), не вызывает сервис — прочитано на
`src/houseplan-card.ts:17246-17250`. Подтверждено исполнением:
`smoke_lock_action.mjs`, `smoke_lock_invariant.mjs` зелёные (SCOPE.md-инвариант
«The lock invariant, stated precisely» не ослаблен), плюс новый smoke явно
проверяет `planOpeningAndBadgeNeverActuate` (тап по проёму и badge не меняет
число вызовов `callService`) и `explicitInfoActionStillWorks` (только явное
действие в открытой info-карточке вызывает `lock.unlock`).
- **AC9 (state update не пересобирает geometry/config).** Доказано smoke:
`stateTickDoesNotRebuildGeometryOrConfig` сравнивает `_physicalBodiesCache`,
`_cfgEpoch` и сериализованный `_serverCfg` до/после тика состояния.
- **AC10 (existing opening golden/interactions без регресса).** Golden baseline
в дифе не тронут; `docs/images/screenshots.json` показывает, что все 10
`imageSha256` не изменились — то есть уже принятые пиксельные сцены
идентичны и после ребейза. Поведенческая регрессия подтверждена отдельно
исполнением `smoke_opening_binding.mjs` (зелёный).
- **Трейлеры и changelog.** Все четыре коммита несут терминальные `Issue: #117`
и `User-Visible: yes|no` (проверено `git show -s --format` по каждому).
Коммит с `User-Visible: yes` (`01fe48d`) правит `docs/CHANGELOG.md` и
`docs/CHANGELOG.ru.md` в себе же (`git show 01fe48d --stat`), обе записи под
одним и тем же заголовком `v1.65.0-beta.2`, без дублирования с уже
существующими записями.
- **Соответствие ТЗ и SCOPE.md.** Диф не расширяет скоуп (§4 ТЗ дословно
соблюдён: picker, миграция, marker lifecycle, generic registry-less для
прочих marker-биндингов, geometry не тронуты). Задача лежит внутри уже
закрытых J1/J3/J6 (`docs/SCOPE.md`), lock-инвариант не ослаблен.
- **Целостность ребейза.** Продуктовый патч (`src/ha-binding-status.ts`)
побайтово идентичен версии, одобренной в r1 (сверено `git show` diff-текста).
Три копии бандла воспроизводимо пересобираются из текущего дерева с тем же
SHA-256, что заявил автор в хендоффе. `git status --short` после сборки —
пусто: закоммиченные бандлы уже соответствуют пересборке, конфликт разрешён
корректно, а не «на глаз».
## Чего не проверял
- Полный набор из 127 browser-smoke — предрелизный гейт, не гейт ревью;
обоснование сужения дано выше и не изменилось относительно r1.
- `npm run golden:verify` — обоснование дано выше (`imageSha256` неизменны,
геометрические/стилевые модули вне дифа).
- `python -m pytest tests_backend -q` — Python не тронут этим дифом.
- Performance-профили — ТЗ §14 явно исключает влияние, изменение не меняет
проход по кадру.
- Реальную установку Home Assistant с YAML-сущностью без `unique_id` — вне
возможностей ревью; демо-стенд — канонический суррогат, прогнан лично.
- Сам процесс `git rebase` (порядок разрешения конфликтов, промежуточные
состояния) — не переисполнялся; проверен итог (см. «Целостность ребейза»
выше), что для код-ревью достаточно.
- Дефект `pre-push` при force-push после rebase — вне скоупа этого issue,
отслеживается отдельно в #190; подтверждено, что issue заведён и что CI
backstop (`process-gate`) при этом не обойден (перепрогнан лично, чист).
## Вердикт
Зелёный. High: 0, Medium: 0. Ребейз не изменил продуктовый код ни на строку —
проверено побайтовым сравнением диффа, а не заявлением автора; три копии
бандла воспроизводимо пересобираются с тем же хешем; полный (выросший после
ребейза) unit-набор 870/870, typecheck и целевые/регрессионные smoke зелёные,
mutation-guard подтверждает, что покрытие умеет падать. Каждый AC1–AC10 либо
доказан автотестом, который я лично прогнал, либо разобран чтением текущего
кода на этом SHA с явной пометкой «проверено чтением, не исполнением».
Трейлеры, changelog (RU+EN) и process-gate в порядке. Задача готова к слиянию.
+382
View File
@@ -0,0 +1,382 @@
# CODE-REVIEW-132-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/132
- **Связанный bug в том же scope:** #185 (закрывается тем же кодом, по решению владельца)
- **Диапазон:** `origin/dev...HEAD` на ветке `issue/132-partition-openings-v2`,
коммиты `b9bf210..9f77e3e` (5 коммитов: 4 документационных + 1 продуктовый —
`9f77e3e feat: support openings in independent walls`)
- **ТЗ:** `docs/specs/132-partition-openings.md`, зелёное ревью
`docs/reviews/SPEC-REVIEW-132-r1.md`
- **Роль:** ревьюер кода (не автор), этап `S7-code-review`
- **Цикл:** r1/4
## Скоуп ревью
Диапазон правит 43 файла (2961 / 603): новый резолвер хоста
(`src/partition-openings.ts`), геометрию cut (`src/physical-geometry.ts`,
`src/wall-thickness.ts` косвенно), размещение (`src/opening-placement.ts`,
`src/align-grid.ts`), структурную топологию комнат/#185
(`src/plan-snap-overlay.ts`, `src/houseplan-card.ts`), свет/Glow/sun
(`src/houseplan-card.ts`, `src/styles.ts`), HA-состояние и команды
move/delete/undo (`src/houseplan-card.ts`), backend-валидацию и
import/export/websocket (`custom_components/houseplan/*.py`), i18n
(`src/i18n/en.json`, `ru.json`), 9 канонических документов + оба changelog,
и полный слой автотестов (unit/backend/smoke).
Проверялось соответствие 12 AC из ТЗ (§20), контракту `docs/SCOPE.md`
(lock-инвариант, J1–J4), каноническим документам подсистем и трейлерам
коммитов.
## Как проверялось
Дешёвые гейты (прогнаны лично, точные команды и результат):
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `870 passed / 0 failed` |
| Build + сверка бандлов | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | сборка ок, обе копии побайтово совпадают |
Смоки, прогнанные локально (по diff и AC — не весь набор из 141):
| Смок | Результат | Почему выбран |
|---|---|---|
| `node demo/smoke_partition_openings.mjs` | OK, все 12 полей `true` | новый, основное доказательство AC1–AC3, частично AC5 |
| `node demo/smoke_room_autoclose.mjs` | OK, все 9 полей `true` | изменён, доказательство #185/AC5 |
| `node demo/smoke_opening_preview.mjs` | OK, все 39 полей `true` | изменён, placement/AC1 |
| `node demo/smoke_glow.mjs` | OK | AC4, свет |
| `node demo/smoke_inert_openings.mjs` | OK | AC8, passage inert |
| `node demo/smoke_opening_binding.mjs` | OK | AC8, HA/security |
| `node demo/smoke_opening_tunnel_fill.mjs` | OK | AC2, tunnel/cut |
| `node demo/smoke_wall_junctions.mjs` | OK | AC2, junction patches |
| `node demo/smoke_unified_wall_tool.mjs` | OK | AC12, регресс #173 |
| `node demo/smoke_isometric_contract.mjs` | OK | AC7, Iso renderer |
| `node demo/smoke_openwall.mjs` | OK | AC12, регресс |
| `node demo/smoke_sun.mjs` | OK | AC14/sun regression |
Остальные 129 смоков не запускались — задача не задевает все поверхности
разом (см. таблицу выше — выбор покрывает placement/geometry/topology/light/
HA/regression, по одному представителю на AC-кластер).
`npm run golden:verify` (после свежей сборки и копирования бандла) —
запущен, поскольку diff меняет геометрию/свет/рендер. Результат: 60/62
сцен `passed`, одна `different` (`plan-snap-line-gaps-dark`), один
транзиентный `error` при первом прогоне (`openings-filled-tunnel-dark`),
исчезнувший при повторном прогоне на том же дереве (похоже на конкуренцию
ресурсов при параллельных фоновых процессах ревью, не на дефект — см.
«Чего не проверял»). Эталоны `demo/golden/baselines/**` в этом диапазоне
**не менялись** (подтверждено `git diff --stat`), что соответствует
процессу: обновление golden — предрелизный шаг через
`golden:accept --reviewed`, не часть этого код-ревью.
`python -m pytest tests_backend -q` — прогнан после ручной установки
отсутствовавших в этом окружении `pytest`/`voluptuous` (`homeassistant` не
установлен). Результат: **139 passed**, но это только «чистое» подмножество
— `conftest.py` молча игнорирует `test_ha_*.py`, когда `homeassistant`
недоступен (задокументированное в `AGENTS.md` поведение). Значит,
`tests_backend/test_ha_import_export.py` (изменённый в этом диапазоне файл,
+4/−…) **не выполнялся** в этом прогоне. Учитывая, что именно в
`import_export.py` найден блокирующий High (см. ниже), это существенное
ограничение — см. «Чего не проверял».
Дополнительно организовано 7 независимых агентских разборов (каждый —
отдельная поверхность: геометрия/cut, топология комнат #185, свет/Glow,
HA-state/move-delete-undo, backend-валидация, качество тестов/смоков,
документация/i18n), каждый со своими file:line-цитатами и с явным заданием
мысленно отменить конкретную строку продукта и проверить, падает ли
привязанный тест. Их находки перепроверены мной лично чтением исходников
(конкретные цитаты — в разделе «Находки»); один инцидент — агент по ошибке
выполнил `git checkout -- src/physical-geometry.ts` в общем чекауте поверх
активной сборки и вручную восстановил строку — проверен: `git status
--short` и `git diff HEAD` после инцидента пусты, рабочее дерево совпадает с
HEAD, порчи нет.
## Находки
### High-1 — импорт/дублирование space с partition-hosted проёмом всегда отклоняется
**Файл:** `custom_components/houseplan/import_export.py:830–845` (ремап id),
согласовано с `custom_components/houseplan/validation.py:836–839`
(референциальная проверка `host.id`).
`build_space_merge()` — код пути «Backup → импорт одного space» (вызывается
из `create_preview()` для `document["kind"] == "space"`, `import_export.py:
1153` — это реальная пользовательская фича экспорта/импорта одной комнаты/
этажа, представленная в golden-сценах `backup-*`). Цикл ремапа id
(`:830–838`) проходит по коллекциям `rooms, room_drafts, partitions,
wall_columns, openings, decor` и переписывает **собственный** `item["id"]`
каждой записи на свежий id, но нигде не переписывает вложенную ссылку
`opening["host"]["id"]`, которая указывает на **старый** id partition.
Далее `merged_config = CONFIG_SCHEMA(merged_config)` (`:977`) прогоняет
`_space_geometry_invariants` (`validation.py:813–853`), который для каждого
`opening.host.id` требует существующий partition в том же space
(`validation.py:836–839: raise vol.Invalid(...)`). После ремапа
`opening.host.id` физически не может совпасть ни с одним новым id partition
— значит **любой** импорт/дублирование space, содержащего хотя бы один
partition-hosted проём, безусловно завершается `ImportFailure("invalid_config",
...)`.
Проверено лично чтением: `grep -n "host" custom_components/houseplan/
import_export.py` даёт ровно 2 упоминания вне ремап-цикла — `:269` (простое
сохранение поля при экспорте, ремапа id не требует) и `:985` (вызов
`validate_partition_opening_hosts`, который проверяет только «host не исчез
у уже существовавшего проёма», а не референциальную целостность после
ремапа — эту проверку делает уже упомянутый `_space_geometry_invariants`
через `CONFIG_SCHEMA`). Ни один call site не трогает вложенный `host.id`.
**Тест:** `tests_backend/test_ha_import_export.py:876–909`
(`test_space_merge_remaps_every_space_owned_id_and_room_link`) — единственный
тест, гоняющий ремап id через `build_space_merge`, и его фикстура-проём
(`op1`, строка ~885) намеренно не имеет `host`. Регрессия непокрыта. Этот же
тестовый файл в принципе не выполнялся в доступном окружении ревью (нет
`homeassistant`) — см. «Чего не проверял»; сама находка получена чтением
кода и подтверждена независимым агентским разбором, не прогоном теста.
**Почему High:** это прямое нарушение AC9 («host fields survive save/export/
import/optimize») и §18 ТЗ («Export/import/backup сохраняют host object и
referential order»); ломается не крайний случай, а любое использование
только что реализованной фичи через уже существующий, регулярно
используемый путь backup/duplicate. Блокирует.
**Как чинить (для автора, не мой домен):** при ремапе `openings` в этом же
цикле переписывать `item.host.id = id_map[item.host.id]`, когда `host.kind
=== 'partition'`, аналогично тому, как уже переписывается `room.open_to`
через `old_room_ids` (`:839–844`) и `marker.room_id` (`:875–876`).
## Medium-находки (заведены отдельными issue)
### Medium-1 → #186 — нет jamb safety margin
`src/partition-openings.ts:41` (`jambMargin = 0`, не переопределяется ни на
одном из ~9 call sites: `src/houseplan-card.ts:7290, 7751, 7783, 11094,
11393, 11402, 17301`, `src/space-render.ts:214`) и
`custom_components/houseplan/validation.py:840–846` (только `1e-9`
float-допуск). ТЗ §7/§16/§23 явно называет jamb safety margin отдельно от
допуска на погрешность. Проём можно поставить впритык к концу перегородки
без зазора на откос. Функционально не ломает (fail-dark по-прежнему
работает), но расходится с принятым ТЗ. Заведено: #186.
### Medium-2 → #187 — fallback-гвард источника света не fail-dark «по построению»
`src/houseplan-card.ts:14585` (`pointInOpaquePlanBody(sourcePoint,
masonryGeometry, physical)`) использует в качестве fallback
`physical = this._physicalBodiesR(space)` (`:14527`) — тело, вырезанное по
**всем** типам hosted-проёмов без различия света, а не
light-policy-фильтрованный `lightPhysical` (`:14442–14451`), который
`_lightBarriers` использует для основной геометрии. Обнаружено, что
параметр `physical` внутри функции больше нигде не используется (мёртвый
после рефакторинга, кроме этого места). Если `wallBodiesGeometry()`
(`src/wall-thickness.ts:1790–1792`) выбросит исключение, `masonryGeometry`
станет `[]`, и guard будет опираться на дырявое (в т.ч. по окну) тело —
контракт «boolean failure fail-dark» (ТЗ §13/§17) в этой ветке держится на
случайной само-ограниченности развёртки, а не на архитектуре. Практическое
проявление маловероятно (требует throw в объединении), поэтому Medium, не
High. Заведено: #187.
### Medium-3 → #188 — тест junction-patch не умеет падать
`test/partition-openings.test.mjs:64–82` («computed junction patches cannot
bridge a hosted slot») не отражает регрессию в
`src/physical-geometry.ts:206–208` (`.flatMap((body) =>
cutPartitionBody(body, partitionCuts, epsilon))` на join-patches): проверено
мысленным (и одним агентом — фактическим) удалением этой строки — все 8
подтестов остаются зелёными, так как bbox патча в T-образной фикстуре не
достигает проверочной точки `[92,0]` независимо от вырезания. Сам
production-код при этом корректен — я перечитал `physical-geometry.ts:186–
211` лично и подтверждаю: патчи действительно прогоняются через
`cutPartitionBody` со всеми `partitionCuts`, до объединения в `all`
(`:209`), т.е. заявленное в §10 ТЗ поведение реализовано верно, только не
доказано этим конкретным тестом. Заведено: #188.
## AC1–AC12 — разбор по каждому критерию
1. **AC1 (Walls workflow/placement).** Доказано: unit
(`test/opening-placement.test.mjs` — партиционный candidate побеждает
room-wall при точном совпадении/collinear, отклоняется при
пересечении/неоднозначности; `test/partition-openings.test.mjs` — резолвер
центра/угла/длины/depth) + smoke (`smoke_partition_openings.mjs`,
`smoke_opening_preview.mjs`, оба зелёные). Active draft/column/virtual span
как host отклоняются — подтверждено чтением `resolvePartitionOpening`
(принимает только `PartitionCfg`) и юнитами placement. **Пройдено.**
2. **AC2 (geometry/composite overlap).** Full-depth cut для 1/15/100 см и
diagonal — доказано unit (`test/partition-openings.test.mjs:52–73`) и
смоком `smoke_opening_tunnel_fill.mjs`. Composite double-cut (partition +
coincident room-wall режутся оба) — проверено **чтением, не
исполнением**: `_roomWallOpeningInputs` (`houseplan-card.ts:7795–7816`)
эмитит room-wall cut только при точном collinear-покрытии
(`partitionOpeningHasCompositeRoomWall`), а независимое тело partition
режется параллельно через `_partitionOpeningCuts`; оба пути сходятся в
`wallBodiesGeometry`/`_wallUnionGeometry`. Отдельного unit/smoke именно на
coincident-случай нет (см. «Чего не проверял»), но код-путь однозначен и
golden-сцены `openings-thick-wall-dark`, `wall-junctions-*-dark`,
`geometry-diagonal-45-opening-dark` прошли без изменений — косвенно
подтверждает. Junction patches не закрывают cut заново — код корректен
(см. Medium-3 про сам тест). **Пройдено**, с оговоркой Medium-3.
3. **AC3 (host lifecycle).** Rigid move — чисто трансляция, `t`/length
инвариантны по построению (`_physicalUp`, `houseplan-card.ts:7266–7317`).
Delete-с-confirmation: единственный путь удаления partition с hosted
openings — `_deletePhysicalSelection` → `_partitionDeleteDialog` →
`_confirmPartitionDelete`, список отсортирован по `t`
(`:7104`), Cancel — чистый no-op, Confirm атомарен, один `_recordGeometry`
вызов на всю операцию. Других путей удаления, обходящих confirmation, не
найдено — `_dropLegacySegments()` чистит только структурно некорректные
partitions и не трогает `openings` (в этом случае backend
`_space_geometry_invariants` отклонит сохранение, а не тихо потеряет
данные). Undo/Redo — единый `CommandStack` снапшот всего пространства.
Доказано unit (delete/undo команды) + smoke
(`smoke_partition_openings.mjs`: `deleteRequiresAccessibleListDialog`,
`deleteCancelIsMutationFree`, `deleteConfirmCascadesHostAndOpening`,
`deleteUndoRestoresHostAndOpening`, `deleteRedoCascadesAgain` — все
`true`, и мутационно подтверждено: удаление cascade-фильтра в
`_confirmPartitionDelete` заваливает именно эти два поля). **Пройдено.**
4. **AC4 (light).** Interior door/gate/passage пропускают Glow через полный
composite cut, exterior/window остаются opaque, invalid host fail-dark —
подтверждено чтением (`_lightBarriers`, `_roomWallOpeningInputs`,
`_partitionOpeningCuts`) и смоком `smoke_glow.mjs`. **Пройдено**, с
оговоркой Medium-2 (fallback-ветка) и отсутствием прямого unit-теста на
cache fingerprint/composite-Glow сценарий (см. «Чего не проверял»).
5. **AC5 (#185 room closure).** Реальный фикс — не в
`plan-snap-overlay.ts` (не изменился по логике), а в удалении
type-agnostic `_planSnapOpeningCuts()` из вызова
`buildPlanSnapGeometry()` в `houseplan-card.ts`: раньше каждый opening
резал структурную ось, теперь `roomCuts` строится только из реальных
`open_spans`. Подтверждено эмпирически (один из агентов подменил бандл на
собранный из `origin/dev` и прогнал обновлённый
`smoke_room_autoclose.mjs` — assertion `openingKeepsStructuralAutoClose`
стала `false` на старом бандле и `true` на текущем: воспроизводимо
красный → зелёный переход именно от этого коммита, как и требует
доказательство AC5 в ТЗ). Для partition-хостов структурная непрерывность
тривиальна: `buildPlanSnapGeometry` никогда не резал partition-оси
opening-катами (`cuts: []` для partition-источников что до, что после).
**Пройдено**, но матрица неполна: `smoke_room_autoclose.mjs` и
`smoke_partition_openings.mjs` гоняют только `type: 'door'`; window/gate/
passage и полный `_wallFaceBatch`/`_roomDialog` UI-путь для
partition-хоста явно не прогнаны (для partition проверена только
структурная снапшот-функция, не UI-диалог). Логика type-agnostic, дефекта
не вижу при чтении, но заявленная в §21 ТЗ «матрица по всем четырём
типам» не покрыта тестами буквально — фиксирую как незакрытый пробел
evidence, не как блокирующую находку (код прочитан и корректен).
6. **AC6 (no passive topology mutation).** `_offerWallFaces()` вызывается
только из двух click-обработчиков внутри Walls-инструмента
(`houseplan-card.ts:6719, 6827`), ни разу — из lifecycle/hass-сеттеров/
`_editOpening`/`_saveOpening`. Ни один из этих call sites не менялся в
этом diff. **Пройдено, проверено чтением.**
7. **AC7 (render parity).** Golden прошёл на всех Plan/Iso/View-сценах, кроме
одной «different» (см. ниже). `smoke_isometric_contract.mjs` зелёный.
Единый resolver (`resolvePartitionOpening`) используется и в
`space-render.ts`, и в `houseplan-card.ts` рендер-путях — подтверждено
чтением обоих файлов. **Пройдено.**
8. **AC8 (HA/security parity).** `resolveHaBindingStatus`, `_lockAction`,
`_renderOpeningInfoCard` не ветвятся по `host.kind`, только по `o.type`;
`_openingsR` резолвит partition-hosted openings в тот же `RenderOpening`
через spread, не параллельной реализацией. Перемещение host не трогает
`entity`/`flip_*`/id (`materializePartitionOpening` переписывает только
`x/y/angle`). Доказано чтением + smoke (`smoke_inert_openings.mjs`,
`smoke_opening_binding.mjs`). **Пройдено.**
9. **AC9 (compatibility).** Backend-схема реально проверяет существование
`host.id` среди partitions пространства, границы `t`, вписывание длины,
пересечения между hosted-проёмами одного host — не поверхностно (
`validation.py:813–853`), тест на отклонение реален (падает при удалении
`raise`, `tests_backend/test_validation.py`). Legacy без `host` не
мигрируют. Экспорт одиночного plan/backup сохраняет `host` без ремапа id
— корректно. **Не пройдено**: см. High-1 — space merge/import ломает
ссылку `host.id` при ремапе, что прямо противоречит этому AC.
10. **AC10 (accessibility/touch).** Диалог удаления — `hp-dialog` с
доступным списком (`deleteRequiresAccessibleListDialog: true` в
smoke). Pointercancel/multi-touch код (`_stagePointerCancel`) не
менялся этим diff и уже покрывал generic drag-отмену по kind+pid.
**Пройдено, проверено чтением** (специализированного touch-смока с
реальным multi-pointer эмулятором на partition-drag не гонял — вне
моего окружения, см. «Чего не проверял»).
11. **AC11 (cache/performance).** Glow не строится в Plan-режиме, где
происходит drag partition (`glowLayerVisible` завязан на `_markup`);
fingerprint для `_lightBarriers` включает геометрию/cuts/`
transparentHostedIds`, что эквивalентно требованиям ТЗ, хотя не
дословно совпадает по списку полей. HA-only tick не трогает
`_curSpaceCfg`, значит не меняет fingerprint. **Пройдено, проверено
чтением**; отдельного performance-смока не гонял (в AC не назван,
в diff нет изменений `demo/smoke_performance*`/`performance_smoke`
файлов — см. «Чего не проверял»).
12. **AC12 (regression #173/#157).** `smoke_unified_wall_tool.mjs`,
`smoke_openwall.mjs` зелёные без изменений. `PASSAGE_FORBIDDEN_FIELDS`
контракт #157 не тронут (`validation.py`, не изменялся в этой части).
**Пройдено.**
## Что проверено и корректно
- Полный резолвер `src/partition-openings.ts` — pure, explicit host, без
nearest-wall fallback, как требует §8 ТЗ; fail-dark для `resolved: null`
на всех потребителях (`_partitionOpeningCuts`, `space-render.ts:212–218`).
- Full-depth cut, jamb returns, diagonal/1-15-100см — реализовано и
протестировано юнитами против скомпилированного (не переизобретённого)
кода.
- Delete/Undo/Redo — атомарность одним `CommandStack`-снапшотом, без
обходных путей удаления без confirmation.
- HA-состояние/actions/lock-инвариант не расширяются и не ветвятся по
host kind — соответствует `docs/SCOPE.md`.
- #185 действительно исправлен для legacy room-wall openings, воспроизводимо
(red на `origin/dev`-бандле, green на текущем).
- i18n: все шесть новых ключей есть в en и ru, без утечки английского текста
в ru.json, плейсхолдеры совпадают.
- Трейлеры: единственный класс-A коммит `9f77e3e` несёт `Issue: #132`,
`User-Visible: yes`, и в этом же коммите правки обоих changelog (проверено
`git show 9f77e3e --stat`). Документационные коммиты — `User-Visible: no`,
корректно.
- `docs/CONFIG-COMPATIBILITY.md` — новая секция `host` оформлена по
установленному в файле паттерну (сравнение с секцией #157).
- Восемь канонических документов (`CANVAS.md`, `WALL-THICKNESS.md`,
`LIGHT.md`, `SUN.md`, `UX-MODES.md`, `ARCHITECTURE.md`, `USER-GUIDE.ru.md`,
`TESTING.md`) обновлены содержательно и непротиворечиво друг другу.
- Bundle freshness: три копии (`dist/`, `custom_components/houseplan/
frontend/`, `demo/srv/assets/`) побайтово идентичны после чистой сборки.
## Чего не проверял
- **`tests_backend/test_ha_import_export.py` не выполнялся** — в этом
окружении нет `homeassistant`/`.venv-backend`; `conftest.py` молча
игнорирует `test_ha_*.py` без него (задокументированное поведение).
Именно High-1 находится в файле, который этот тест покрывает — находка
получена чтением, не прогоном; автор/CI на Linux обязаны подтвердить
фикс тем же тестом с добавленным hosted-host фикстурным случаем.
- Не гонял оставшиеся 129 из 141 browser-смоков — выбор 12 покрывает по
одному представителю на каждый AC-кластер (placement/geometry/topology/
light/HA/regression/render), не весь набор поверхностей разом.
- Не гонял `performance_smoke`/выделенные performance-профили — AC11 их не
называет по имени, diff не трогает файлы `demo/smoke_performance*.mjs`;
вывод по AC11 сделан чтением кеш-инвалидации.
- Golden: не расследовал до пикселя причину `different` на
`plan-snap-line-gaps-dark` — по превью выглядит как последствие удаления
type-agnostic opening-cut из structural snap-preview (ожидаемо для #185),
но точная причина не подтверждена построчно. Эталон не тронут в этом
diff — обновление (если diff признают корректным) идёт стандартным
`golden:accept --reviewed` на полном Linux CI артефакте, не в рамках этого
ревью. Транзиентный `error` на `openings-filled-tunnel-dark` при первом
прогоне не воспроизвёлся при повторном — похоже на конкуренцию ресурсов
от параллельных фоновых процессов на этой машине, не расследовал глубже.
- Не гонял `smoke_opening_measure.mjs` — по `AGENTS.md` он уже нестабилен на
этом Chromium независимо от этой задачи (`known environment-sensitive
smoke`), диагностический шум не относится к #132.
- Composite (coincident partition + room-wall) double-cut подтверждён только
чтением кода и косвенно golden-сценами; отдельного unit/smoke на именно
этот сценарий нет (зафиксировано выше при разборе AC2, не заведено
отдельным issue — граница между «стоит добавить» и «доказано чтением»
решена в пользу второго, так как код однозначен).
- Touch-специфичный multi-pointer сценарий для partition-drag — проверен
чтением generic `_stagePointerCancel`, не эмулировал реальный
multi-touch в браузере.
## Вердикт
Красный. High: 1, Medium: 3 (заведены #186, #187, #188). AC9 не выполнен
из-за High-1: `build_space_merge()` не ремапит `opening.host.id` при смене
id partition, из-за чего экспорт/импорт (дублирование) любого space с
partition-hosted проёмом безусловно отклоняется backend-валидацией. Это
блокирующий дефект по прямо заявленному в ТЗ AC, а не крайний случай.
Остальные 11 AC пройдены (частично — с проверкой чтением там, где отдельного
теста нет, см. разбор по AC и «Чего не проверял»); дешёвые гейты и
целевые смоки/golden зелёные. После исправления High-1 (ремап
`opening.host.id` через тот же `id_map`, что и остальные ссылки в
`build_space_merge`) и добавления регрессионного теста в
`test_ha_import_export.py`, ожидаю зелёный вердикт без повторного разбора
остальных AC — они не затронуты фиксом.
+265
View File
@@ -0,0 +1,265 @@
# CODE-REVIEW-132-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/132
- **Связанный bug в том же scope:** #185 (закрывается тем же кодом, по решению владельца)
- **Диапазон:** `origin/dev...HEAD` на ветке `issue/132-partition-openings-v2`,
но предметно этот цикл разбирает только новый коммит с момента r1 —
`3fe0f8c fix: address partition opening review regressions` (16 файлов,
+306/−244), поверх уже принятого без изменений `9f77e3e` (r1 покрыл его целиком).
- **ТЗ:** `docs/specs/132-partition-openings.md`, зелёное ревью
`docs/reviews/SPEC-REVIEW-132-r1.md`
- **Предыдущий цикл:** `docs/reviews/CODE-REVIEW-132-r1.md` — красный,
High: 1 (`build_space_merge()` не ремапил `opening.host.id`), Medium: 3
(#186, #187, #188, заведены отдельно, не входят в этот фикс)
- **Роль:** ревьюер кода (не автор), этап `S7-code-review`
- **Цикл:** r2/4
## Скоуп ревью
Коммит `3fe0f8c` заявлен как исправление ровно High-1 из r1. Фактически несёт
два независимых изменения:
1. **Ремап `opening.host.id`** в `build_space_merge()`
(`custom_components/houseplan/import_export.py:845-854`) — прямое исправление
High-1, плюс регрессионный тест в `tests_backend/test_ha_import_export.py`.
2. **Разделение presentation/structural снапшотов** `buildPlanSnapGeometry()`
в `src/houseplan-card.ts` (новый `_planStructuralGeometrySnapshot()`,
`_planSnapGeometrySnapshot()` восстанавливает opening-cuts, `_wallGraphSources`
переключён на структурный снапшот) плюс правки `src/plan-snap-overlay.ts`
(только докстринг), `docs/CANVAS.md`, changelog RU/EN, doc-screenshots.
Второе не было прямо потребовано r1 (High-1 касался только backend), но
устраняет регрессию, которую r1 обнаружил и не смог объяснить (golden-diff
`plan-snap-line-gaps-dark`, зафиксирован в r1 как «Чего не проверял», не как
находка) — это восстановление поведения `_planSnapOpeningCuts()`, которое
существовало на `origin/dev` до правки #132 и было потеряно в `9f77e3e`. Не
считаю это расширением скоупа: правка находится в файлах и подсистеме, которые
сам r1 разбирал по AC5/AC6, и не добавляет пользователю ничего вне контракта
из ТЗ §11.
Прочие 11 AC (AC1–AC4, AC6–AC12) не затронуты диапазоном `3fe0f8c` — сам файл-состав
диффа (backend id-ремап + presentation/structural split) не пересекается с их
кодовыми путями placement/lifecycle/light/HA-state/compatibility-schema/i18n,
что подтверждено построчным чтением диффа. Повторный разбор по каждому из них
не производился — r1 их разобрал, вердикт по этому циклу ограничен тем, что
изменилось.
## Как проверялось
Дешёвые гейты (прогнаны лично в этой сессии, точные команды и результат):
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `870 passed / 0 failed`, совпадает с заявленным |
| Build + сверка бандлов | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | сборка ок, обе копии побайтово совпадают, `git status --short` после сборки пуст (закоммиченный бандл идентичен свежей сборке) |
Смоки, прогнанные локально (по diff — только затронутые этим коммитом поверхности):
| Смок | Результат | Почему выбран |
|---|---|---|
| `node demo/smoke_partition_openings.mjs` | OK, все 12 полей `true` | использует переименованный `_planStructuralGeometrySnapshot`, регрессия по AC1/AC3/AC5 |
| `node demo/smoke_room_autoclose.mjs` | OK, все 9 полей `true` | `openingKeepsStructuralAutoClose: true` — #185 не пострадал от разделения снапшотов |
| `node demo/smoke_plan_snap_overlay.mjs` | OK, все 32 поля `true`, включая `openingGapHasNoLine: true` | прямая проверка presentation-cut; **не входил** в список из 12 смоков r1, хотя diff трогает именно эту поверхность — восполняю здесь |
Остальные смоки из целевого набора r1 (`smoke_opening_preview`, `smoke_glow`,
`smoke_inert_openings`, `smoke_opening_binding`, `smoke_opening_tunnel_fill`,
`smoke_wall_junctions`, `smoke_unified_wall_tool`, `smoke_isometric_contract`,
`smoke_openwall`, `smoke_sun`) не перегонялись: `3fe0f8c` не меняет код на их
путях (placement/light/HA-binding/junction geometry/iso/#173 regression —
подтверждено чтением диффа), а r1 уже доказал их зелёными на предшествующем
коммите. Не весь набор из 141 — избыточно для 16-файлового фикса.
**Backend.** В окружении этого ревью нет `homeassistant`/`.venv-backend` (то же
ограничение, что у r1). Установил `pytest`+`voluptuous` и прогнал чистое
подмножество: `python -m pytest tests_backend -q` → **139 passed** — тот же
результат, что у r1, `test_ha_import_export.py` (файл с фиксом High-1 и новым
регрессионным тестом) в этом прогоне **не участвовал** (`conftest.py` молча
игнорирует `test_ha_*.py` без `homeassistant`).
Вместо предположения проверил через реальный CI на этом SHA (Linux,
`homeassistant` установлен): job `backend` для `3fe0f8c` —
**`completed / success`**, лог оканчивается `280 passed in 4.30s`, команда
в логе — `python -m pytest tests_backend/ -q`. Для контраста: тот же job на
предыдущем коммите `9f77e3e` (до фикса) — `completed / failure`. Это прямое
подтверждение, что регрессионный тест `test_space_merge_remaps_every_space_owned_id_and_room_link`
(теперь с `host`-фикстурой) действительно запускается на Linux CI и проходит
после фикса, а до фикса — ломался. Не «verified» без команды: команда и результат
процитированы из фактического лога джобы
(`https://github.com/Matysh/houseplan-card/actions/runs/32193375736/job/95892362527`).
**Golden.** `job golden` на `3fe0f8c` — `completed / success`, в логе все
перечисленные сцены `passed`, включая ранее «different»/нерасследованную в r1
`plan-snap-line-gaps-dark` — теперь **`passed`**. Это лучше, чем 60/62 у r1:
второй компонент фикса (presentation-снапшот) действительно устранил тот
golden-diff, который r1 оставил неразобранным.
**Прочитано построчно, не исполнено:**
- `custom_components/houseplan/import_export.py:845-854` — ремап `opening.host.id`
через тот же `id_map`, что и id перегородки; порядок операций корректен:
цикл ремапа id (`:830-838`) выполняется раньше, `id_map` уже содержит
`old_partition_id → new_partition_id`, когда цикл над `openings` (`:849-854`)
читает `host.id`.
- `src/houseplan-card.ts:6156-6198` — `_planSnapGeometrySnapshot` восстановил
`roomCuts: [...openCuts, ...this._planSnapOpeningCuts(space, openCuts)]`
(буквально то же выражение, что было на `origin/dev` до `9f77e3e`, см.
`git show origin/dev:src/houseplan-card.ts` — метод `_planSnapOpeningCuts`
существовал там же). Новый `_planStructuralGeometrySnapshot` использует
`roomCuts: this._openCuts()` — ровно то, что `_planSnapGeometrySnapshot`
вычислял в промежуточном (r1) коде. Все потребители разделены корректно и
без остатка: `_wallGraphSources` (`:6900-6901`, единственный вызывающий
`_wallFaceGraph`/#185-путь) — на структурный; четыре презентационных сайта
(`:6213, 6236, 11771, 17598, 17688`) — на исходный. Grep по всему файлу
подтверждает отсутствие смешанных вызовов.
## Находки
### High
Нет. High-1 из r1 исправлен корректно (см. «Как проверялось» — фикс, тест и
зелёный CI).
### Medium (заведена отдельным issue)
#### Medium-1 → #189 — presentation snap-overlay не режет ось независимой перегородки в месте её собственного проёма
Второй компонент этого коммита восстанавливает presentation-cut для
**legacy room-wall** openings и для **composite** partition/room-wall
случая, но не для основного сценария #132 — проёма на независимой
перегородке, не совпадающей ни с какой стеной комнаты.
**Почему это не тривиальная догадка, а воспроизведённый дефект.** Прочитано
и эмпирически проверено (headless Chromium, `demo/serve.mjs`, тот же харнесс,
что у смоков): пространство с одной перегородкой `{a:[0.25,0.5], b:[0.75,0.5]}`
без комнат и дверью `host:{kind:'partition', id, t:0.5}` даёт
`card._planSnapGeometrySnapshot().value.segments` **один** сегмент `partition`
от `[250,500]` до `[750,500]` — без разрыва в точке проёма (x=500). Причина
прослеживается по коду:
- `_roomWallOpeningInputs()` (`src/houseplan-card.ts:7834-7855`) для
partition-hosted проёма возвращает вход в выборку **только** когда
`partitionOpeningHasCompositeRoomWall(...)` истинно — т.е. только для
composite-случая (решение по Q4);
- `_planSnapOpeningCuts()` (`:6141-6154`) строит cuts исключительно из этой
выборки — для обычного (не composite) partition-hosted проёма cut не
создаётся вообще;
- `buildPlanSnapGeometry()` (`src/plan-snap-overlay.ts:118-156`) в любом
случае передаёt `cuts: []` для `kind: 'partition'` безусловно (`roomCuts`
применяется только к `kind: 'room'`, `:123-131`) — так что даже если бы cut
был вычислен, для partition-источника он никуда не попал бы без изменения
этой функции.
**Почему это противоречит контракту, а не просто пробел evidence.**
ТЗ §11 буквально: «Контракт применяется одинаково к legacy room-wall opening
и новому partition-hosted opening» — про presentation/snap boundary cut.
Этот же коммит переписал `docs/CANVAS.md` («Door, window, gate and
intentionally open-span intervals are cut from presentation axes», без
оговорки про host kind) и оба changelog («the editor's visual snap guide
keeps its physical gap across the opening» / «визуальная направляющая
привязки по-прежнему показывает физический разрыв в месте проёма») —
универсально, без оговорки о composite-случае. Заявленное в документации и
реализованное в коде расходятся именно для главного, а не краевого сценария
#132 (проём на независимой, не совпадающей с комнатой перегородке).
**Почему Medium, не High.** Не портит сохранённые данные, не ломает
physical/light геометрию (та режется верно — `hostBodyHasFullDepthOpeningGap`
в `smoke_partition_openings.mjs` зелёный) и не ломает structural room-face
граф (#185 — `_planStructuralGeometrySnapshot` намеренно и корректно
игнорирует cuts для обоих host kind, что и требуется). Затрагивает только
inline snap-подсказку инструмента «Стены» в Plan-редакторе (admin-only
поверхность): пользователь может получить снап на точку, физически лежащую
внутри проёма, как если бы там была сплошная кладка — ровно то поведение,
которое контракт «opening gap remains a gap» (#173) должен исключать, только
для нового host kind. Не покрыто ни одним из 12 AC ТЗ буквально (все они
описывают geometry/light/HA/render — не snap-guide), поэтому не проваливает
формальный AC, но нарушает явный текст §11 и текст только что обновлённой
документации/changelog в этом же коммите.
Заведено: #189 (bug, P2, S1-new), со ссылкой на #132 и точной репродукцией.
### Low
Нет новых Low в этом цикле. Три Low из SPEC-REVIEW-132-r1 были закрыты до
начала кода (запись автора «Начало реализации»); не пересматривались здесь,
диапазон `3fe0f8c` их не касается.
## AC — статус после r2
AC1–AC4, AC6–AC8, AC10–AC12: без изменений относительно r1 (**пройдены**,
диапазон `3fe0f8c` их кода не касается — подтверждено чтением diff-состава).
AC5 (#185 room closure): **пройден**, подтверждено заново для этого коммита
(`smoke_room_autoclose.mjs`: `openingKeepsStructuralAutoClose: true`;
`smoke_partition_openings.mjs`: `openingKeepsRoomFaceAxisContinuous: true`
через переименованный, но не изменённый по семантике метод). Разделение
снапшотов не меняет вход `_wallFaceGraph` — структурный снапшот вычисляется
идентично тому, что использовался в r1.
AC9 (compatibility): **пройден** — это ровно то, что чинил High-1. Backend
export/import/duplicate одного space с partition-hosted openings теперь
сохраняет referential integrity `host.id`; подтверждено новым регрессионным
кейсом в `test_ha_import_export.py` и зелёным `backend` job на точном SHA
`3fe0f8c` в Linux CI (280 passed, 0 failed).
Ни один из 12 AC не описывает presentation snap-overlay буквально (см.
Medium-1/#189) — находка не проваливает формальный AC, но является
нарушением §11 ТЗ вне списка AC1–AC12.
## Что проверено и корректно
- High-1 из r1 фактически исправлен: код читается корректно (порядок ремапа,
тот же `id_map`, что у остальных ссылок), новый regression-тест
целенаправленно бьёт по сценарию High-1 (фикстура с `host`), Linux CI
backend job зелёный на точном SHA с командой и результатом в логе.
- Presentation/structural разделение снапшотов реализовано чисто: ни одного
оставшегося смешанного вызова (`_wallGraphSources` — единственный
потребитель структурного, четыре презентационных сайта — исходного;
проверено grep по всему файлу).
- Golden CI зелёный полностью (все перечисленные сцены `passed`, включая
ранее неразобранную `plan-snap-line-gaps-dark`), лучше результата r1.
- Три Medium из r1 (#186, #187, #188) корректно остаются отдельными
открытыми issue, не включены и не спрятаны в этом фиксе — проверено
`gh issue view` по каждому: все три открыты, `S1-new`, ссылаются на #132.
- Трейлеры коммита `3fe0f8c`: `Issue: #132`, `User-Visible: yes`, оба
changelog (RU+EN) правлены в этом же коммите — проверено `git show --stat`.
- Bundle freshness: три копии (`dist/`, `custom_components/houseplan/frontend/`,
`demo/srv/assets/`) побайтово идентичны после чистой пересборки в этой
сессии; `git status --short` после сборки пуст.
## Чего не проверял
- Полный набор из 141 браузерного смока — не запускал, diff 16-файлового
фикса не касается большинства поверхностей (обоснование выбора трёх
целевых смоков — выше). CI job `smoke` на момент завершения этого документа
ещё выполнялся (`in_progress`) — полный прогон относится к предрелизному
гейту, не к гейту этого код-ревью, и не блокирует вердикт.
- `tests_backend/test_ha_import_export.py` не выполнялся мной локально (нет
`homeassistant` в этом окружении) — заменено проверкой факта и результата
прогона на Linux CI на точном SHA (см. «Как проверялось»), а не
предположением.
- `performance_smoke` — не запускал, diff не касается кеш-инвалидации/hot-path
(только backend id-ремап и разделение уже кешируемых снапшотов по тому же
шаблону, что был).
- Composite (coincident partition + room-wall) presentation-cut сценарий — не
проверял отдельно эмпирически в этом цикле; логика `_roomWallOpeningInputs`
не менялась в `3fe0f8c` и была прочитана r1 как корректная для этого случая
(см. CODE-REVIEW-132-r1, разбор AC2). Мой репродукшн для #189 намеренно взял
**не**-composite случай, чтобы изолировать дефект.
- Точную причину, почему исходный golden-diff `plan-snap-line-gaps-dark`
использовал именно room-wall, а не partition-сценарий (и поэтому не
вскрыл #189 в r1/r2 через golden) — не расследовал; вне golden-набора нет
сцены с независимой перегородкой и проёмом без совпадения с комнатой,
что и объясняет, почему CI не поймал #189.
## Вердикт
Зелёный · цикл r2/4 · High: 0 · Medium: 1 → #189
High-1 из r1 исправлен и подтверждён (код + новый тест + зелёный Linux CI
backend job на точном SHA). Остальные 11 AC не затронуты этим диапазоном и
остаются в состоянии r1. Новая Medium-находка (#189) — presentation
snap-overlay не режет ось независимой перегородки в месте собственного
проёма, вопреки §11 ТЗ и тексту этого же changelog — не проваливает ни один
из 12 сформулированных AC буквально, не портит данные, не затрагивает
structural/#185-путь и не расширяется на View/kiosk; заведена отдельным issue
и не блокирует переход, как и три предыдущих Medium из r1.
+257
View File
@@ -0,0 +1,257 @@
# SPEC-REVIEW-103-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/103
- **ТЗ под ревью:** `docs/specs/103-toggle-confirmation-state.md` (коммит
`49c0c3d`, ветка `issue/103-toggle-confirmation-state`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — согласно аналитике владельца от
2026-08-14: сложность 2/10, риск 3/10, но `trivial` намеренно не применён,
так как confirmation copy — UX-контракт, потенциально затрагивающий
несколько доменов (power/cover/valve/group/virtual light)
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание задачи в Core user jobs, отсутствие расширения
скоупа и конфликта с «lock invariant»;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §3/§12
(запреты), §5 (легкий трек — не применяется, но проверено, что признаки
`small` действительно отсутствуют);
- `AGENTS.md` — классы файлов коммита, ветка, трейлеры;
- фактическому состоянию кода `src/device-toggle.ts` и `src/houseplan-card.ts`
— технические утверждения ТЗ о существующем резолвере #94
(`ResolvedToggleIntent`, `formatToggleIntent`, `sameToggleOperationTargets`,
`_tapConfirm`) сверены построчно, чтобы отличить факт от догадки;
- `docs/USER-GUIDE.ru.md` — терминология («Переключить состояние», hint под
селектором действия, `tap_confirm`);
- `docs/TOUCH-SUPPORT.md` — категория «View dialogs and safe device actions»
(fully supported на touch, не best-effort).
## Как проверялось
1. Прочитан весь тред issue #103: исходное тело (уже содержит нормативную
матрицу, i18n-ключи и race-контракт от владельца), комментарий аналитики
2026-08-15 (`P3`, сложность 2/10, риск 3/10, «вопросов нет»), комментарий
автора ТЗ («продуктовых вопросов нет, метка остаётся `S3-spec` по поручению
владельца» — на момент ревью метка уже `S4-spec-review`, расхождение не
процессное: `docs/specs/README.md` подтверждает коммит и ветку).
2. Сверены обязательные разделы ТЗ (§7.1 PROCESS.md) построчно — таблица ниже.
3. Прочитан `src/device-toggle.ts` целиком: подтверждено существование
`ResolvedToggleIntent`, `ToggleNextEffect`, `formatToggleIntent()`,
`sameToggleOperationTargets()`, `toggleOperation()` — ровно тот резолвер
#94, на который ссылается ТЗ, а не придуманный интерфейс.
4. Прочитан `src/houseplan-card.ts` (окрестности `_clickDevice`, `:4336-4400`
и `:14838-14846`): подтверждено дословно, что текущий `_tapConfirm` —
`{ text: string; exec: () => void }`, а `text` строится только как
`this._t('confirm.tap_toggle', { name })` — то есть претензия ТЗ «диалог
показывает только имя» верна для текущего кода, не выдумана.
5. Прочитан `_toggleHintLines()` (`:17314-17358`) и соответствующие ключи
`marker.toggle_hint_current` / `marker.toggle_effect_*` /
`marker.toggle_hint_group_current` / `marker.toggle_hint_skipped` в
`src/i18n/ru.json` и `en.json`. Эта существующая инфраструктура уже строит
«текущее → эффект» для редакторского hint под селектором действия
(`docs/USER-GUIDE.ru.md:489`). ТЗ не игнорирует её: §5 явно допускает
расширение `formatToggleIntent()` либо добавление соседней pure-функции, а
разделение current/expected на отдельные строки в §8 обосновано
собственным accessibility-требованием (порядок чтения screen reader'ом),
которого нет у однострочного hint. Дублирования семантики без причины не
обнаружено.
6. Проверена логика `coverLikeService()` (`:307-331`) против нормативной
матрицы §6 ТЗ — см. Low-3.
7. Прочитан `test/device-toggle.test.mjs` и список `demo/smoke_*.mjs`:
подтверждено существование `smoke_ha_controls.mjs`, `smoke_controls.mjs`,
`smoke_virtual_light_toggle.mjs`, на которые ссылается план регрессии §11 —
не выдуманные имена.
8. Прочитан `docs/TOUCH-SUPPORT.md`: строка «View dialogs and safe device
actions» — «Fully supported» на touch (не best-effort), то есть touch-
влияние здесь блокирующее по DoR; ТЗ §8 адресует это узкой mobile-footer и
narrow-viewport smoke — соответствует канону.
9. Прочитан `docs/USER-GUIDE.ru.md:489` и «Краткая памятка безопасности»
(:1206): термин «Переключить состояние» и рекомендация включать
подтверждение для Toggle/Run/Cover совпадают с ТЗ дословно.
10. Проверена трассируемость: `docs/specs/README.md` получил секцию `## P3`
со строкой на #103 в том же коммите; `git diff --stat origin/dev...HEAD`
показывает только два файла класса C; коммит `49c0c3d` несёт
`Issue: #103` и `User-Visible: no` — корректно для документа ТЗ, который
сам не меняет поведение.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | Частично | §1 объясняет проблему технически (только имя в диалоге), но ни разу не называет персону/поверхность явной фразой — см. Low-1 |
| Что человек увидит до/после | ✅ | §2, буквально в виде текста диалога «до/после» |
| Проблема | ✅ | §1 |
| Скоуп / не-скоуп | ✅ | §3 / §4 |
| Контракт поведения | ✅ | §5, §6, §9 |
| Модель данных и миграция | ✅ | §7 (TS-интерфейс `_tapConfirm`) + §9 («config не меняются», миграции нет) |
| UX и i18n | ✅ | §8 |
| AC1…ACn с доказательством | Частично | §10 — 10 пронумерованных AC, но без построчной метки типа доказательства; тип восстанавливается однозначно из §11 (см. Low-2) |
| План автотестов | ✅ | §11, разбит на unit/integration/регрессию |
| Риски | ✅ | §14 |
| Откат | ✅ | §14 (последний абзац) |
| Release-артефакты | ✅ | §13 |
Дополнительно есть раздел «Принятые технические предположения» (§15) —
не требуется §7.1 буквально, но прямо соответствует духу PROCESS.md §7.1 о
записи технических решений отдельным блоком.
## Находки
Находок уровня **High** и **Medium** нет — отдельные issue заводить не
требуется.
### Low-1 — сценарий не называет персону/поверхность явной фразой
**Файл:** `docs/specs/103-toggle-confirmation-state.md` (§1)
AGENTS.md требует, чтобы первый раздел ТЗ отвечал: какая персона (из
`docs/SCOPE.md`), на какой поверхности, в какой момент встретит изменение.
§1 объясняет техническую проблему («текущий диалог показывает только имя»),
но не пишет explicit: диалог tap-confirm — это «View dialogs and safe device
actions» (`TOUCH-SUPPORT.md`), доступный любой персоне на любой поверхности
(desktop/kiosk/companion app) в момент нажатия на маркер с `tap_confirm`.
Смысл восстанавливается из §2 (пример текста диалога) и общего контекста
J3 `SCOPE.md`, поэтому неоднозначности для читателя нет, но формальное
требование выполнено не буквально.
**Решение ревьюера:** Low, не блокирует. Косметическая правка одним
предложением на усмотрение автора при следующей редакции.
### Low-2 — AC1–AC10 не промаркированы построчно типом доказательства
**Файл:** `docs/specs/103-toggle-confirmation-state.md` (§10, AC1–AC10)
DoR (`PROCESS.md` §2.5) и цепочка §7.1 требуют, чтобы «у каждого [AC] указано,
чем он доказывается: unit / backend / smoke / golden / ревью кода». §10
перечисляет десять проверяемых критериев, но ни один не несёт явной метки
доказательства — план тестирования (§11) даёт эти метки только группами
(Unit / Integration-browser / Регрессия), и сопоставление 1:1 нужно
восстанавливать вручную. Я проверил это сопоставление вручную: AC1–AC6
однозначно закрываются перечисленными в §11 unit-кейсами (formatter per
`ToggleNextEffect`, single/group/partial/unknown/no-operation), AC7–AC8 —
race-кейсами из Integration/browser, AC9 — DOM order/narrow viewport/keyboard
smoke, AC10 — «run confirmation unchanged» той же секции. Ни один AC не
остаётся недоказуемым по существу; отсутствует только явная метка в самом
тексте §10. Тот же класс находки (типы доказательства AC вне буквального
формата) уже фиксировался как Low и не блокировал приёмку в
`SPEC-REVIEW-137-r1` (Low-3).
**Решение ревьюера:** Low, не блокирует. Рекомендуется при следующей правке
приписать к каждому AC1–AC10 короткую метку (`unit`/`integration`/`review
кода`), чтобы код-ревью могло сверяться механически, а не восстанавливать
сопоставление заново — но проверяемость критериев уже подтверждена этим
документом.
### Low-3 — нормативная матрица §6 группирует cover-состояния не так, как резолвер
**Файл:** `docs/specs/103-toggle-confirmation-state.md:78-79` (§6, строки
«cover `closed/closing` + `open`» и «cover + `stop`»)
**Код:** `src/device-toggle.ts:307-331` (`coverLikeService`)
Таблица группирует «closed/closing» в одну строку с ожидаемым `open`. Но по
факту резолвера состояние `closed` действительно даёт `nextEffect: 'open'`,
а `closing` — отдельную ветку `nextEffect: 'stop'` (наравне с `opening`),
то есть реальная комбинация «`closing` → next `open`» в резолвере никогда не
возникает: она относится к строке «cover + `stop`» этой же таблицы. Так как
формула §5 явно требует брать current-строку из HA-formatted state, а не
пересчитывать её из этой таблицы, и направление всегда берётся из
`nextEffect` резолвера (а не выводится реализацией по названию строки), риска
для рантайма нет — но группировка вводит в заблуждение при написании unit-
теста по этой таблице буквально (можно ошибочно закодировать несуществующую
комбинацию `state=closing + effect=open` как «нормальный» кейс вместо
`state=closing + effect=stop`).
**Решение ревьюера:** Low, не блокирует. При следующей редакции стоит убрать
`closing` из строки `open` (оставить только `closed`) и явно отметить, что
строка `stop` покрывает оба переходных состояния (`opening`/`closing`) — но
это не меняет ни один AC и не требует возврата цикла.
## Что проверено и корректно
- Соответствие `docs/SCOPE.md`: задача уточняет содержимое существующего
Toggle-confirmation guard'а — это J3 («Let me act on the obvious right from
the plan», guarded quick actions), статус **Closed**; расширения продукта
нет, задача улучшает уже принятую функциональность и не создаёт нового
actuation-пути. Явно проверено, что «lock invariant» `SCOPE.md` не задет:
secure-цели (`lock.*`, `alarm_control_panel.*`, guarded cover) уже
фильтруются резолвером на уровне `secureEntity()` и никогда не попадают в
`targets`, следовательно новый confirmation-текст не может *обещать*
переключение замка.
- Легитимность полного трека: несмотря на низкую оценку сложности (2/10) от
владельца, автор корректно не применил `trivial`/`small` — confirmation
copy реально затрагивает пять разных семантик (power/cover/valve/group/
virtual light) и i18n; критерий «одна поверхность» (§5 PROCESS.md) не
выполняется буквально, обычный трек оправдан.
- Продуктовых вопросов владельцу нет, и это корректно: почти вся нормативная
матрица уже была продиктована владельцем в теле issue до написания ТЗ,
автор её только формализовал и уточнил (см. Low-3 как единственное реальное
уточнение, не вопрос). Ни одна догадка не выдана за факт без пометки —
§15 отдельно фиксирует три технических предположения (no live-update
snapshot, HA-formatter приоритет, `stop` как честный ожидаемый эффект),
все они соответствуют либо явному тексту issue, либо существующему коду.
- Технические утверждения о текущем коде подтверждены чтением, а не
голословны: `ResolvedToggleIntent`/`formatToggleIntent`/
`sameToggleOperationTargets` (#94, `src/device-toggle.ts`), текущий
однострочный `_tapConfirm.text` (`src/houseplan-card.ts:4388-4389`),
существующий toast `toast.tap_target_changed`, существующие тесты
`test/device-toggle.test.mjs` и смоки `smoke_ha_controls.mjs` /
`smoke_controls.mjs` / `smoke_virtual_light_toggle.mjs`.
- Не-скоуп (§4) корректно отсекает смежные соблазны: изменение resolver/
target-selection/service-call, прогноз scripts/scenes, история состояний,
redesign confirmation-диалогов удаления/unlock/run, live-анимация в
открытом диалоге, изменение схемы `tap_confirm` — типичные места, где скоуп
мог бы незаметно расшириться, явно исключены.
- Touch-контракт: диалог tap-confirm — категория «View dialogs and safe
device actions» (`TOUCH-SUPPORT.md`), полностью поддерживаемая на touch
(не best-effort). ТЗ §8 адресует это явно (narrow footer с двумя кнопками,
перенос длинного текста без horizontal scroll) и §11 добавляет narrow-
viewport smoke — соответствует блокирующей категории канона.
- Совместимость: `stored tap_confirm` и схема config не меняются, миграции
нет — корректно для confirmation copy, не меняющей контракт хранения.
Откат (§14) корректно опирается на то, что `_tapConfirm` возвращается к
одной строке без отдельного data rollback.
- i18n: ключи перечислены раздельно для RU/EN в одном release-коммите (§13);
plural rules осознанно не вводятся, что соответствует «counter-safe style»
уже принятому в существующих `marker.toggle_*` строках.
- Release-артефакты (§13) корректно относят golden/screenshot к точечному
необязательному кадру, а backend/migration/performance/security — явно
«не требуются», что соответствует характеру изменения (чистый UI-текст без
новых сервис-вызовов).
- Трассируемость: `docs/specs/README.md` обновлён тем же коммитом (новая
секция `## P3` со строкой на #103), ссылка issue ↔ ТЗ двусторонняя; ветка
`issue/103-toggle-confirmation-state` и трейлеры коммита (`Issue: #103`,
`User-Visible: no`) корректны для документа класса C, который сам не
меняет поведение.
- `git diff --stat origin/dev...HEAD` не содержит ни одного файла класса A —
код не тронут до `S5-ready`, что соответствует правилу №1.
## Чего не проверял
- Не проверял реализуемость `_tapConfirm` как размеченного union
(`{kind:'toggle', ...} | {kind:'run', ...}`) на уровне TypeScript-типов —
это по правилам ТЗ свободно изменяемое техническое решение автора кода
(§15), не предмет ревью ТЗ.
- Не запускал автотесты, build или browser-смоки — на этапе `spec` это не
требуется; существование резолвера, hint-инфраструктуры, i18n-ключей и
тестовых/смоук-файлов, на которые ссылается ТЗ, проверено чтением
исходников, а не исполнением.
- Не проверял корректность численных оценок аналитики (ценность 5/10 и 3/10,
сложность 2/10, риск 3/10, P3) по существу — это поле владельца
(PROCESS.md §2.2), уже принятое явным решением до написания ТЗ.
- Не проверял, как именно `hass.formatEntityState` поведёт себя для каждого
конкретного домена/state на реальном HA — ТЗ корректно требует safe
fallback на raw state, и это уже проверяемый unit-кейс по плану §11, а не
вопрос ревью ТЗ.
## Вердикт
Зелёный. High: 0, Medium: 0. Три находки Low (сценарий не называет
персону/поверхность явной фразой; AC1–AC10 не промаркированы построчно типом
доказательства, хотя проверяемость подтверждена этим документом; нормативная
матрица §6 вводит в заблуждение группировкой cover `closed/closing` в строке
`open`, хотя резолвер направляет `closing` в строку `stop`) — ни одна не
блокирует приёмку и не меняет ни один AC; все три либо правятся косметически
при следующей редакции, либо снимаются этой записью без нового цикла.
+328
View File
@@ -0,0 +1,328 @@
# SPEC-REVIEW-113-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/113
- **ТЗ под ревью:** `docs/specs/113-optional-space-model.md` (коммит
`5f02dd38a1b60fa5064ea1291559b32c502fa1f0`, ветка
`issue/113-optional-space-model`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — аналитика оценила сложность и
риск 6/10 и 7/10, что превышает порог лёгкого трека (§5 PROCESS.md, ≤3);
ТЗ корректно лежит файлом в `docs/specs/`, зарегистрировано в
`docs/specs/README.md:85`.
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание задачи в J6 («план остаётся правдивым по мере
развития», системная защита от ложного/пустого плана), отсутствие
расширения скоупа за пределы честного optional-контракта;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы ТЗ), §3/§12
(запреты, включая «догадка вместо решения»);
- `AGENTS.md` — классы файлов (класс C для этого коммита), ветка, трейлеры;
- фактическому состоянию `src/houseplan-card.ts` — на предмет того, что
диагноз ТЗ и цифры issue (45 полевых обращений `_spaceModel().<поле>`,
недостижимость до render-гейта, факт того, что #111 закрыл один вызов) не
являются непроверенной догадкой;
- прецедентам house-style в уже принятых `docs/reviews/SPEC-REVIEW-*.md`
(107, 122, 123, 131, 137, 138, 141, 146) — как единообразно оформлены
обязательные разделы §7.1 и как в этом репозитории калибруется
High/Medium/Low для находок такого типа (структурная полнота ТЗ против
дефекта контракта).
## Как проверялось
1. Прочитан весь тред issue #113: тело (находка M3 из ревью #111), комментарий
аналитики от 2026-08-14 (ценность 7/10 пользователю, 9/10 разработке,
сложность/риск 6/10, 7/10, P2, tech-debt, обычный трек, «продуктовых
вопросов нет — технические guard patterns решаются в ТЗ без вопроса
владельцу») и комментарий автора ТЗ от 2026-08-15 («продуктовых вопросов
нет; выбран честный optional API»). Продуктовых вопросов владельцу
корректно не задавалось — вся задача технического характера, что
соответствует правилу «владельцу задаются только продуктовые вопросы».
2. Построчно сверены обязательные разделы ТЗ (§7.1 PROCESS.md) — таблица
ниже.
3. Прочитан код, на который опирается диагноз ТЗ, чтобы отличить проверенный
факт от догадки:
- `_spaceModel(id?): SpaceModel` (`src/houseplan-card.ts:2984`) —
сигнатура и тело (`m.find(...) || m[0]`) подтверждают проблему буквально
как в §1 ТЗ.
- Подсчитано число полевых обращений `_spaceModel([^)]*)\.<поле>`:
`rooms` — 35, `wall_columns` — 6, `room_drafts` — 2, `bg` — 2, плюс один
дополнительный сайт `_spaceModel(spaceId).vb` (`:15897`), не учтённый в
таблице тела issue (45 vs фактических 46). Это неточность в теле самого
issue, не в ТЗ (ТЗ не повторяет число 45), и не влияет на контракт ТЗ —
не выношу отдельной находкой.
- `render()` (`:14281`) подтверждён как ранний empty-state gate
(`if (!model.length) return html\`...\`;`), `willUpdate`/`updated`
(`:3089`, `:3116`) подтверждены как выполняющиеся **до** и **независимо**
от результата `render()` (Lit вызывает их для каждого прохода жизненного
цикла), что подтверждает центральный тезис ТЗ и issue — недостижимость
держится на порядке вызовов, а не на типе.
- `_continuityAssetsReady()` (`:2763-2769`) подтверждён как фикс #111:
явный `if (!this._model.length) return false;` **до** вызова
`_spaceModel()` — соответствует описанию issue «#111 закрыл один вызов
из render snapshot».
- Default-parameter паттерн `space = this._spaceModel()`
(`_physicalBodiesR`, `_rawPhysicalBodiesR`, ещё один метод — `:11668`,
`:11679`, `:11688`) подтверждён как реально существующий — именно тот
паттерн, который §5.3 ТЗ явно требует убрать.
- Проверено отсутствие текущих non-null assertions на `_spaceModel()`
(`grep "_spaceModel([^)]*)!"` — пусто) — согласуется с ТЗ §8, который
запрещает их вводить, а не убирает существующие.
- Найдены реальные call sites с явным `id`, показательные для §6 ТЗ:
`_livePos(d)` (`:4535` → `this._spaceModel(d.space)`), `_vacStartFit`
(`:15376` → `this._spaceModel(d.space)`), `_vacPlanRoomAnchors`
(`:15302`), сайт создания/перемещения устройства (`:12043` →
`this._spaceModel(space || undefined)`) — все передают «стабильный»,
потенциально устаревающий `spaceId`, а не всегда `this._space`. Это
именно тот класс вызовов, который §6 ТЗ описывает как «команды с
stable spaceId».
- Существующие тесты вида `test/*-contract.test.mjs`
(`isometric-contract.test.mjs`, `release-contract.test.mjs`,
`performance-contract.test.mjs`, `native-select-contract.test.mjs`)
подтверждают, что «source-contract test» из §8/§10 ТЗ — не изобретённый
механизм, а продолжение существующего паттерна репозитория.
- `docs/ARCHITECTURE.md` и `docs/TESTING.md` существуют — release-артефакты
§13 ТЗ ссылаются на реальные файлы, не выдуманные.
4. Сопоставлены между собой §4 (нормативный API), §6 (active id и fallback),
§10 AC8 и §14 (риски) на непротиворечивость — обнаружено расхождение,
см. Medium-1.
5. Проверены трейлеры и class-принадлежность: `git show --stat 5f02dd3`
показывает только `docs/specs/113-optional-space-model.md` и
`docs/specs/README.md` (класс C, ни одного файла класса A — продуктовый
код не тронут до `S5-ready`, Rule #1 `AGENTS.md` соблюдено); коммит несёт
`Issue: #113` и `User-Visible: no` — корректно для документа ТЗ, который
сам не меняет поведение.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ⚠️ | Нет отдельного заголовка; содержание фактически распределено по §1 (кто и когда встречает пустой план) — см. Low-1 |
| Что человек увидит до/после | ⚠️ | Нет отдельного заголовка; ответ («ничего видимо не меняется, это профилактика класса #111») восстановим из §1+§9, но не сформулирован явно одной фразой — см. Low-1 |
| Проблема (с подтверждённой причиной) | ✅ | §1, факты подтверждены чтением кода (см. «Как проверялось» п.3) |
| Скоуп / не-скоуп | ✅ | §2 (цели) / §3 (не входит в задачу), границы чёткие (без глобального `noUncheckedIndexedAccess`, без миграции, без empty-state UX) |
| Контракт поведения | ✅ | §4–§8, классификация call sites по 4 категориям, конкретные примеры кода |
| Модель данных и миграция | ✅ | §9 — явно «config/layout schema и revisions не меняются» |
| UX, i18n, accessibility, touch | ⚠️ | §9 содержательно утверждает «editor touch safety floor не меняется», но не использует ни одну из трёх канонических формулировок `docs/TOUCH-SUPPORT.md` («Touch editor: supported/best effort/not exposed») — см. Low-3 |
| AC1…ACn с доказательством | ⚠️ | §10, 10 штук, пронумерованы и в целом проверяемы, но ни один не несёт явной пометки способа доказательства (`unit`/`smoke`/…), в отличие от всех сверенных прецедентов (107, 122, 123, 131, 137, 141, 146) — см. Low-2. Отдельно для AC8 отсутствие явного доказательства — не косметика, а реальный пробел, см. Medium-1 |
| План автотестов | ✅ | §11, разбит на unit/smoke/регрессию, включает mutation-тест («вернуть required signature — тест красный») |
| Риски | ✅ | §14, таблица риск/мера, 5 строк |
| Откат | ✅ | §14 (последний абзац) — без миграции данных |
| Release-артефакты | ✅ | §13, конкретный список (`ARCHITECTURE.md`, `TESTING.md`), оба файла существуют, `User-Visible: no` корректно снимает требование changelog |
Присутствует также раздел «Принятые технические предположения» (§15, 4
пункта) — соответствует требуемому PROCESS.md §7.1 блоку «принято
предположительно, поменять свободно», хотя заголовок не содержит буквально
эту оговорку (косметика, не отдельная находка).
## Находки
### Medium-1 — AC8 не имеет предъявленного механизма доказательства; риск и контракт расходятся
**Файл:** `docs/specs/113-optional-space-model.md:110-130` (§6),
`:180` (AC8), `:186-201` (§11, unit-план), `:234-241` (§14, таблица рисков)
§14 называет риск буквально: «Stale id редактирует первый space» и указывает
единственную меру — «exact lookup для commands». Это ровно тот самый риск,
который AC8 обязан закрывать: «Missing explicit stale space id не мутирует
первый space».
Но нормативный API §4 —
```ts
private _spaceModel(id?: string): SpaceModel | undefined {
const requested = id ?? this._space;
return this._model.find((space) => space.id === requested) ?? this._model[0];
}
```
— **всегда** возвращает `_model[0]`, когда `requested` не найден, независимо
от того, был ли `id` передан явно или нет. Для непустой модели эта функция
никогда не возвращает `undefined`: она возвращает объект, только не тот,
который просили. Проверено на реальных call sites: `_livePos(d)`
(`houseplan-card.ts:4535`) вызывает `this._spaceModel(d.space)` для позиции
устройства, `_vacStartFit` (`:15376`) — аналогично. Если пространство,
которому принадлежит устройство (`d.space`), было удалено (что штатно для
задачи #113 — «удаление последнего/произвольного space»), а другие
пространства остались, `_spaceModel(d.space)` молча вернёт **первое
оставшееся** пространство — тип этого не ловит, потому что результат
определён (не `undefined`).
Единственная названная в ТЗ мера — вынести отдельный
`_spaceModelById(id)` без fallback (§6) — сформулирована как **опция**:
«Чтобы исключить опасную двусмысленность, **допустимо** разделить API...
Это техническое разделение **рекомендуется** для drag/history/dialog
commands». Формулировка не обязывает автора реализации ввести этот метод
и не обязывает каждый call site с явным id использовать его вместо
`_spaceModel(id)`. Единственная альтернатива, которую ТЗ предлагает
взамен, — ручная проверка identity на каждом call site («callers... должны
проверять identity отдельно и abort-ить»), но:
- ни §8 (source-contract test), ни §11 (план тестов) не называют проверку,
которая убедилась бы, что *каждый* call site с явным id либо использует
no-fallback lookup, либо содержит ручной identity-guard;
- unit-пункт §11 «exact lookup не падает в first-space fallback» тестирует
сам вспомогательный selector в изоляции, а не то, что производственные
call sites (`_livePos`, `_vacStartFit`, сайт создания устройства `:12043`
и другие, использующие явный `id`/`spaceId`) действительно его
применяют.
**Почему это Medium, а не High:** контракт не производит немедленную,
гарантированную регрессию (в отличие от `SPEC-REVIEW-138-r1` High-1, где
буквальное прочтение ТЗ ломает работающий сегодня клик) — сегодняшний код
уже имеет такое же молчаливое поведение при stale `id`, задача #113 его не
ухудшает. Риск в том, что заявленная в AC8 защита от этого класса дефектов
не гарантирована структурно (типом или тестом), а оставлена на
дисциплину конкретных call sites без перечня, что именно нужно проверить —
то есть тот же класс проблемы, из-за которого появился сам #113
(«недостижимость держится на порядке вызовов, а не на типе»), может
частично воспроизвестись для explicit-id веток, если реализация не
проявит собственную дисциплину сверх того, что явно требует контракт.
**Что нужно поправить:** заменить «допустимо»/«рекомендуется» в §6 на
обязательное правило — любой call site, передающий явный/стабильный
`id`/`spaceId` в мутирующем или persist-контексте, обязан использовать
no-fallback lookup (или эквивалентный identity-guard), и добавить в §8/§11
конкретную проверку (source-contract grep по списку известных
call sites, либо unit-тест, дергающий каждую публичную мутирующую точку со
stale id и проверяющий отсутствие записи/side effect). Это техническое
уточнение, не продуктовый вопрос — решается автором и ревьюером кода без
эскалации владельцу.
**Решение ревьюера:** Medium, заведён отдельный issue
[#184](https://github.com/Matysh/houseplan-card/issues/184) со ссылкой на
#113 и на этот документ. Не блокирует переход ТЗ в `S5-ready`: остальной
контракт (9 из 10 AC, вся lifecycle/render/cleanup часть) самодостаточен,
проверяем и корректен, а сама эта находка — уточнение контракта, которое
разумно донести до автора кода явно, а не через возврат ТЗ на цикл.
### Low-1 — нет отдельных заголовков «Сценарий» и «Что человек увидит до/после»
**Файл:** `docs/specs/113-optional-space-model.md:9-27` (§1)
PROCESS.md §7.1 требует эти два раздела первыми, отдельно от «Проблема».
Все восемь других сверенных ТЗ репозитория (`107`, `122`, `123`, `131`,
`137`, `138`, `141`, `146`) оформляют их отдельными заголовками. В
`113-optional-space-model.md` содержание фактически присутствует —
§1 называет причину и предшествующий инцидент (#111), §9 фиксирует, что
видимое поведение не меняется, — но не сформулировано как явный ответ на
«кто/где/когда» и «одна фраза до/после». Прецедент `SPEC-REVIEW-107-r1`
(Low-1: «раздел «Проблема» не выделен отдельным заголовком») фиксирует тот
же класс находки как некритичный, если содержание по существу
присутствует.
**Решение ревьюера:** Low, не блокирует. Можно поправить в следующей
редакции (например, короткая явная фраза: «Персона — любой пользователь
View/editors при удалении пространства или холодном старте с `spaces: []`;
до — риск исключения при будущей правке порядка вызовов (класс #111),
после — тот же надёжный empty-state, без видимых изменений»), либо снять
записью в этом документе, если автор сочтёт §1 достаточным.
### Low-2 — AC1…AC10 не несут явной пометки способа доказательства
**Файл:** `docs/specs/113-optional-space-model.md:170-182` (§10)
DoR (`PROCESS.md` §2.5) и §7.1 требуют «у каждого [AC] указано, чем он
доказывается: unit / backend / smoke / golden / «ревью кода»». Ни один из
десяти пунктов §10 такой пометки не несёт — способ доказательства
приходится реконструировать по §11 (план автотестов), что для девяти из
десяти AC делается однозначно (например, AC3/AC4/AC5/AC6 — browser smoke,
AC1/AC2/AC10 — typecheck + source-contract test, AC7 — golden/существующий
скриншот, AC9 — unit), но для AC8 реконструкция не удаётся структурно (см.
Medium-1). Прецедент `SPEC-REVIEW-141-r1` (Low-3) относился к похожему, но
более мягкому случаю (формулировка доказательства вне буквального
перечня, а не полное отсутствие) и не блокировал.
**Решение ревьюера:** Low, не блокирует. При следующей правке ТЗ
рекомендуется приписать к каждому пункту §10 короткую пометку в скобках
(`(smoke)`, `(unit)`, `(typecheck+source-contract)` и т.п.) — механическая
правка без изменения контракта.
### Low-3 — нет буквальной метки touch-контракта по `docs/TOUCH-SUPPORT.md`
**Файл:** `docs/specs/113-optional-space-model.md:161-168` (§9)
`docs/TOUCH-SUPPORT.md` требует одну из трёх формулировок (`Touch editor:
supported` / `best effort / intentionally degraded` / `not exposed`) для
спецификаций, затрагивающих поведение редакторов. §9 по существу описывает
best-effort/unchanged-контракт («editor touch safety floor не меняется»),
но не использует канонический ярлык. Тот же класс находки зафиксирован как
Low и не блокировал в `SPEC-REVIEW-141-r1` (Low-1).
**Решение ревьюера:** Low, не блокирует. Косметическая правка на усмотрение
автора (добавить строку `Touch editor: best effort / intentionally
degraded (unchanged)`).
## Что проверено и корректно
- **Соответствие `docs/SCOPE.md`:** задача закрывает J6 («план остаётся
правдивым по мере развития» — плановые правки не должны ронять карточку
при пустом плане) как профилактика, не расширяя продуктовую поверхность;
User-Visible: no подтверждён и содержанием ТЗ (§13), и отсутствием любых
UX/i18n/визуальных изменений в контракте.
- **Легитимность полного трека:** сложность/риск 6/10 и 7/10 превышают
порог `small` (≤3) — полный трек и файл `docs/specs/` выбраны верно,
issue корректно НЕ помечен `small`.
- **Технический диагноз не голословен.** Число полевых обращений
`_spaceModel().<поле>`, факт единственного гейта в `render()`, порядок
`willUpdate`/`updated` относительно `render()`, факт фикса #111 через
явный ранний `return false` — всё подтверждено чтением
`src/houseplan-card.ts`, а не пересказом issue.
- **Классификация call sites (§5) полна и специфична для этой кодовой
базы:** default-parameter паттерн (`space = this._spaceModel()`) назван
и подтверждён существующим в трёх методах; onboarding-гейт `updated()`
(`this._model.length === 0`) как пример уже существующей ручной
дисциплины, которую ТЗ формализует типом.
- **Запреты (§4) конкретны и адресуют реальный анти-паттерн:** явный запрет
на synthetic empty `SpaceModel`, на `!` у каждого consumer, на скрытый
fallback через `this._serverCfg.spaces[0]` — не общие слова, а прямая
реакция на то, как решались подобные проблемы в других частях кодовой
базы.
- **Продуктовые вопросы закрыты по процессу:** задача целиком техническая
(tech-debt, «guard patterns»), аналитик и автор корректно не эскалировали
ничего владельцу; ни один вопрос, вынесенный бы во владельцу, на самом
деле не был продуктовым — сверено построчно, эскалаций нет вообще.
- **Трейлеры и класс файлов:** коммит `5f02dd3` — только `docs/specs/**`
(класс C), `Issue: #113`, `User-Visible: no` — корректно для документа
ТЗ; ссылка issue ↔ ТЗ двусторонняя (issue → комментарий со ссылкой на
файл; `docs/specs/README.md:85` → issue).
- **Release-артефакты не выдуманы:** `docs/ARCHITECTURE.md` и
`docs/TESTING.md`, которые §13 обязывает дополнить, существуют в
репозитории.
## Чего не проверял
- Не проверял реализуемость предложенного разделения `_spaceModel()` /
`_spaceModelById()` как факта работающего TypeScript-кода — на этапе ТЗ
реализации ещё нет, это предмет код-ревью.
- Не запускал автотесты, не собирал бандл и не проверял backend — на этапе
`spec` продуктовый код не менялся (подтверждено `git show --stat`), гейты
из §8 PROCESS.md здесь неприменимы.
- Не проверял полноту всех ~46 call sites `_spaceModel()` по отдельности —
проверена корректность классификации (§5 ТЗ) на представительной выборке
(lifecycle-хуки, default-parameter паттерн, explicit-id команды), а не
построчный аудит всех вхождений; это ожидаемо станет предметом код-ревью,
когда появится diff.
- Не проверял `docs/TESTING.md` на предмет того, легко ли туда встроить
«empty-plan lifecycle matrix» — детали документации оставлены на
усмотрение автора реализации (класс C, не влияет на продуктовый контракт).
- Не оценивал производительность предложенных изменений — ТЗ (§13) явно и
обоснованно откладывает performance-гейт до случая, когда diff
действительно заденет горячие render-пути; на этапе спецификации это не
проверяется.
## Вердикт
Зелёный. High: 0, Medium: 1 (заведён отдельным issue, не блокирует), Low: 3
(отсутствие отдельных заголовков «Сценарий»/«Что человек увидит»; отсутствие
явной пометки доказательства у AC1…AC10; отсутствие буквальной touch-метки
— все три косметические, содержание по существу присутствует, не блокируют
приёмку). ТЗ решает заявленную проблему (#111-класс дефектов) для
lifecycle/render/cleanup путей полно и проверяемо; единственный содержательный
пробел (Medium-1, explicit-id command paths) не отменяет ценность и
корректность контракта для основного объёма из ~46 call sites, но должен
быть закрыт до или во время реализации — что и обеспечивает заведённый
issue.
+264
View File
@@ -0,0 +1,264 @@
# SPEC-REVIEW-117-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/117
- **ТЗ под ревью:** `docs/specs/117-registryless-opening-entity.md` (коммит `cc2298816b8ca4e41a41830fef69fd2ee1cb7ed9`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`) — аналитика оценила сложность 4/10, риск 5/10
(> 3), поверхности: opening entity picker/resolver, View state/action,
unit/smoke/docs — больше одной поверхности; лёгкий трек корректно не применён
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §3.18 и §12
(запреты, включая «Medium как TODO в тексте ревью»);
- `AGENTS.md` — классы файлов, ветка, трейлеры;
- `docs/USER-GUIDE.ru.md` — терминология «Контактный датчик» / «Замок» /
«проём»;
- issue #104 — откуда #117 выделен решением владельца, и не пересекается ли
ТЗ с уже закрытым скоупом #104 (marker tombstone);
- фактическому состоянию кода (`src/ha-binding-status.ts`, `src/houseplan-card.ts`)
— на предмет того, что технические утверждения ТЗ не являются непроверенной
догадкой, а не описанием несуществующего поведения.
## Как проверялось
1. Прочитано тело issue #117 (автор — владелец, `Matysh`, 2026-08-13) и оба
комментария: аналитика (2026-08-14, P2/bug/обычный трек, продуктовых
вопросов нет) и заведение ТЗ (2026-08-15, ссылка на файл и коммит).
2. Прочитан весь текст `docs/specs/117-registryless-opening-entity.md`
(223 строки) построчно.
3. Прочитан `src/ha-binding-status.ts:308-545`: подтверждено, что
`activeRegistryHass()` уже сознательно сохраняет live state registry-less
сущности (комментарий в коде на строках 342-346 прямо это объясняет), а
`renderOpeningEntityAvailable()` (строка 538) требует одновременно
`.entities[entityId]` **и** `.states[entityId]` — то есть расхождение,
которое ТЗ называет причиной бага, реально существует в коде, а не
придумано. Более того, комментарий к функции на строке 536 уже сегодня
говорит: «Registry-less render parity is #117» — само ТЗ не изобретает
этот план, а закрывает уже отмеченный в коде долг.
4. Прочитан `src/houseplan-card.ts:16256-16380`: подтверждено, что и
`_openingAmt()`, и `_renderOpeningLocks()` идут через единственную точку
`_renderOpeningEntityAvailable()` → `renderOpeningEntityAvailable(this._renderPlanHass, eid)`
— то есть один фикс на уровне `ha-binding-status.ts` действительно
закрывает contact и lock одновременно, как заявляет ТЗ, без риска
рассинхронизации двух копий логики.
5. Прочитано `_captureRenderDeviceSnapshot()` (`src/houseplan-card.ts:3559-3673`):
подтверждено, что `opening.contact`/`opening.lock` уже входят в `entityIds`
при захвате snapshot (строки 3595-3596) и что `planHass` в этом снимке —
результат `activeRegistryHass()` (строка 3553/3574) — то есть registry-less
state уже присутствует в замороженном кадре сегодня; баг только в лишней
проверке `.entities[entityId]` при чтении, как и утверждает §2 ТЗ.
6. Прочитан `src/houseplan-card.ts:16269-16284` (`_renderEntityAvailable` vs
`_renderOpeningEntityAvailable`): подтверждено, что общий
`_renderEntityAvailable()` (используется для прочих marker-биндингов) не
входит в фикс — ТЗ §4 прямо исключает «generic поддержку registry-less
entities во всех marker bindings», и это согласуется с кодом: два разных
потребителя, значит сужение скоупа не оставляет незамеченного разрыва.
7. Прочитан `docs/USER-GUIDE.ru.md:409-444` («Настройки проёма», «Замок») —
терминология ТЗ («контакт», «замок», «info card», «unlock confirmation»)
совпадает с пользовательским словарём; текущая формулировка «Контактный
датчик и замок — самостоятельные точные привязки проёма» уже описывает
независимость от marker (#104), но ничего не говорит о registry —
подтверждает, что баг сегодня не задокументирован пользователю и правка
§14 (уточнить в user-guide поддержку YAML-сущности без `unique_id`)
осмысленна.
8. Сверено, что упомянутые в плане тестирования файлы существуют:
`test/ha-binding-status.test.mjs`, `test/render-device-snapshot.test.mjs`,
`demo/smoke_opening_binding.mjs`, `demo/smoke_lock_action.mjs`,
`demo/smoke_lock_invariant.mjs` — план тестирования не ссылается на
несуществующее покрытие.
9. Прочитано тело issue #104: подтверждено, что #104 занимался независимостью
opening-биндинга от marker tombstone (не от registry-строки) — #117 не
пересекается с уже закрытым скоупом #104, а закрывает отдельно вынесенную
часть, как и написано в issue.
10. Проверена запись `docs/specs/README.md:85` — добавлена тем же коммитом,
ссылка issue ↔ ТЗ двусторонняя.
11. Автор issue — владелец (`Matysh`), легитимность входа в процесс вопросов
не вызывает.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ❌ | нет отдельного раздела; персона и поверхность не названы явно нигде в документе |
| Что человек увидит до/после (без терминов реализации) | ❌ | ближе всего — последняя фраза §1 («сохранённый contact/lock не влияет на отрисованный проём»), но она внутри технического абзаца с `hass.states`/render path, а не отдельной продуктовой фразой |
| Проблема | ✅ | §1–2, с точной причиной по коду |
| Скоуп / не-скоуп | ✅ | §3 (Цели) / §4 (Не входит) |
| Контракт поведения | ✅ | §5, §8, §9 |
| UX | ~ | нет отдельного раздела, но §6 (матрица), §8, §9 покрывают поведение по существу |
| Модель данных и миграция | ✅ | §10 («config/storage/API не меняются») |
| i18n | ✅ | §14 («новых RU/EN строк нет») |
| AC1…ACn с доказательством | ~ | §11, 10 пунктов, но без инлайн-тега типа доказательства у каждого (см. Low-2) |
| План автотестов | ✅ | §12 |
| Риски | ✅ | §15 |
| Откат | ✅ | §15 |
| Release-артефакты | ✅ | §14 |
Два обязательных продуктовых раздела (сценарий, что человек увидит) в виде
отдельных секций отсутствуют — см. Low-1. Технической неоднозначности это не
создаёт: контент восстановим из §1/§9 и подтверждён чтением кода (см. «Как
проверялось»), поэтому не поднимаю до High/Medium (см. обоснование в Low-1).
## Находки
Находок уровня **High** и **Medium** нет.
### Low-1 — нет отдельных разделов «Сценарий» и «Что человек увидит»
**Файл:** `docs/specs/117-registryless-opening-entity.md:9-17` (§1)
PROCESS.md §7.1 требует эти два раздела первыми и объясняет почему: «ТЗ,
которое не может ответить на эти два вопроса, описывает работу, а не
изменение продукта». В этом ТЗ persona/surface/moment нигде не названы явно,
а «что увидит человек» растворено в техническом абзаце про `hass.states` и
render path.
Восстановленный ответ (проверяемый по SCOPE.md и коду, не по догадке):
персона — **Home admin** привязывает live YAML-сущность без `unique_id`
(например, `binary_sensor` без `unique_id`) к контакту/замку проёма в Plan
editor, picker её принимает; после сохранения все три персоны, смотрящие на
**View** (включая kiosk-планшет), видят проём/замок, который не реагирует на
реальное изменение состояния сущности, хотя в HA оно меняется. После фикса —
тот же проём ведёт себя как с обычной зарегистрированной сущностью.
**Решение ревьюера:** Low, не блокирует. Ни один AC не становится
двусмысленным из-за отсутствия этих разделов, и содержание однозначно
восстанавливается из §1/§9/кода — не тот случай, когда «домыслил ревьюер»
подменяет решение владельца, потому что сама сцена уже зафиксирована в issue
и SCOPE.md (View — продукт для двух персон), а не изобретена сейчас. Оставляю
на усмотрение автора: можно добавить двумя короткими фразами при следующей
правке файла, отдельного цикла ревью это не требует.
### Low-2 — AC1–AC10 (§11) не несут инлайн-тег способа доказательства
**Файл:** `docs/specs/117-registryless-opening-entity.md:145-158`
DoR (`PROCESS.md` §2.5) требует: «AC1…ACn — ... у каждого указано, чем он
доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»». Ни один
из десяти пунктов §11 не несёт такой пометки; способ доказательства нужно
реконструировать из §12 «План тестирования» отдельно.
Реконструкция (сверена с реальными файлами, см. «Как проверялось», п.8):
| AC | Доказательство |
|---|---|
| AC1 (registry-less contact меняет presentation) | `smoke`: новый YAML-like `binary_sensor` сценарий (§12) |
| AC2 (registry-less lock badge) | `smoke`: новый YAML-like `lock` сценарий (§12) |
| AC3 (frame использует projection, не raw hass) | `unit`: mutation-guard «вернуть `.entities[entityId]` — YAML case красный» (§12) — единственный AC, где мутационный тест сам по себе и есть доказательство, а не просто регрессия |
| AC4 (registry entity — прежнее поведение) | `smoke`/`golden`: существующие opening-сценарии без изменений |
| AC5 (disabled/orphan/missing остаются unavailable) | `unit`: расширенная матрица в `ha-binding-status.test.mjs` |
| AC6 (limited-registry live entity работает) | `unit`: тот же файл, ветка `authoritative: false` |
| AC7 (marker tombstone не блокирует) | `smoke`: «same entity tombstoned as marker» (§12) |
| AC8 (lock security/confirmation не меняются) | не назван явно; фактически покрыт существующими `demo/smoke_lock_action.mjs` и `demo/smoke_lock_invariant.mjs` (регрессия, п.3 «Регрессия» §12) плюс «ревью кода» |
| AC9 (state update без geometry/config rebuild) | `smoke`: «render snapshot atomicity on state tick» (§12) |
| AC10 (существующие opening golden/interactions без регресса) | `golden`/`smoke`: регрессионный прогон существующих сценариев (§12, явно уточнено, что golden-эталоны не переакцептуются) |
Каждый AC в итоге прослеживается к конкретному, реально существующему тесту
или к явно допустимому DoR методу «ревью кода» (AC8). Ни один пункт не
остаётся недоказуемым по существу — разрыв чисто в оформлении: тег не
проставлен инлайн, как это было сделано, например, в предыдущем ТЗ #123 (13/13
AC с явным типом).
**Решение ревьюера:** Low, не блокирует. Реконструкция выше снимает риск
«недоказанного AC» для этого цикла; рекомендую при следующей правке файла
добавить тег каждому AC (особенно явно объявить AC8 как «ревью кода +
`smoke_lock_action.mjs`/`smoke_lock_invariant.mjs`», а не оставлять
подразумеваемым). Отдельного issue не завожу: это дефект оформления текущего
ТЗ, а не самостоятельная будущая работа — заводить для него `S1-new` issue
было бы имитацией процесса, а не пользой.
### Low-3 — матрица (§6) не содержит отдельной строки для registry-less entity в состоянии `unavailable/unknown`
**Файл:** `docs/specs/117-registryless-opening-entity.md:79-95`
Матрица явно разбирает «registry entity `unavailable/unknown`» (строка 85), но
не добавляет параллельную строку для «YAML/no registry row entity в
`unavailable/unknown`». Пояснение под таблицей («unavailable/unknown здесь
означает, что exact reference существует... #117 не подменяет эти states
active/open») по формулировке относится к обеим ветвям, и это подтверждено
чтением кода: `openingAmount()` (`src/logic.ts:316`) не различает
registry-статус сущности, работает только со строкой state — поэтому поведение
для YAML-сущности в `unavailable` идентично зарегистрированной. Риска для
корректности нет, только асимметрия изложения.
**Решение ревьюера:** Low, снимаю с этой записью, правка не обязательна.
## Что проверено и корректно
- Соответствие `docs/SCOPE.md`: правка внутри уже закрытых J4 (онбординг/editor)
и J6 («Keep the plan true as the home evolves», два редактора, drag/resize,
editable icon rules) — это регрессионный/пограничный баг внутри принятой
функциональности opening-биндинга, а не новая фича и не расширение скоупа.
- Технический диагноз (§2) подтверждён построчным чтением `ha-binding-status.ts`
и `houseplan-card.ts` — не голословное утверждение автора; комментарий в коде
уже сегодня ссылается на #117 как на известный долг.
- Единая точка фикса (`renderOpeningEntityAvailable`) реально закрывает и
contact, и lock одновременно — проверено по вызывающему коду, а не
предположено.
- Матрица поведения (§6) корректно защищает все смежные случаи, которые могли
бы регрессировать: явный `disabled_by` у сущности и у устройства,
authoritative orphan, отсутствующий state, limited-registry режим — все эти
ветки уже реализованы в `activeRegistryHass()`/`resolveHaBindingStatus()` и
проверены построчно (см. «Как проверялось», п.3).
- Независимость от marker tombstone (AC7) корректно унаследована из #104:
`renderOpeningEntityAvailable` не обращается к `isRemovedPlanEntity()`, в
отличие от соседнего `_renderEntityAvailable()` — сужение скоупа (§4,
«generic поддержка... во всех marker bindings» — не входит) не оставляет
незамеченного разрыва, оба потребителя разделены в коде.
- Lock security и confirmation (§9) сформулированы дословно в терминах
существующего инварианта `docs/SCOPE.md` («The lock invariant, stated
precisely») — не ослабление и не новая семантика actuation.
- Раздел 16 «Принятые технические предположения» корректно отделяет свободно
изменяемые технические решения от продуктового контракта; ни одна догадка не
выдана за факт без пометки — я не нашёл ни одного утверждения о поведении,
которое не следует из кода, SCOPE.md или явно принятых допущений.
- Открытых продуктовых вопросов действительно нет: единственные развилки
(registry vs registry-less, authoritative vs limited access, disabled vs
orphaned) — все технические, не продуктовые, и решены автором согласно
правилу §7.1 «всё, чего пользователь не наблюдает, агенты решают сами».
- Терминология (USER-GUIDE.ru.md) не изобретена заново — «контакт», «замок»,
«info card», «unlock confirmation» совпадают с действующим словарём.
- Совместимость (§10): config/storage/API не меняются, существующие
registry-backed openings объявлены pixel-identical, touch/kiosk жесты не
меняются, i18n ключей не добавляется — всё согласуется с
`docs/CONFIG-COMPATIBILITY.md` и `docs/TOUCH-SUPPORT.md` (нет заявленного
влияния, что и требуется).
- Реестр `docs/specs/README.md` обновлён тем же коммитом, ссылка issue ↔ ТЗ
двусторонняя; issue заведён владельцем — легитимность входа в процесс не
вызывает вопросов.
- Golden явно не требует переакцептации (существующие baseline не меняются) —
корректное сужение release-артефактов, не попытка обойти гейт.
## Чего не проверял
- Не запускал никаких автотестов или смоков — на этапе `spec` это не
требуется; существование файлов, упомянутых в плане тестирования, проверено
чтением (`test/ha-binding-status.test.mjs`, `test/render-device-snapshot.test.mjs`,
`demo/smoke_opening_binding.mjs`, `demo/smoke_lock_action.mjs`,
`demo/smoke_lock_invariant.mjs`), не исполнением.
- Не проверял реальное поведение реальной HA-инсталляции с YAML-сущностью без
`unique_id` — это будет предметом browser smoke на код-ревью (§12 плана
тестирования), не ревью ТЗ.
- Не проверял `src/logic.ts:openingAmount()` построчно на предмет всех
инвариантов инверсии/leaf-угла — доверяю формулировке §8 «state проходит
текущий `openingAmount(...)`; invert сохраняется» как описанию уже
существующего, не нового контракта; это не предмет этого фикса (§4: «не
входит в задачу» новые opening types или geometry).
- Не проверял historical переписку по ревью ТЗ #104 (сам документ ревью #104
не найден отдельно) — доверяю формулировке issue #117 «решением владельца
registry-less render исключён из scope #104», подтверждённой владельцем в
комментарии аналитики (нет открытых вопросов/несогласия по этой границе).
- Не проверял производительность — ТЗ явно заявляет «нет» (§14), и правка не
трогает горячий путь по кадрам иначе, чем чтением одного дополнительного
boolean на существующей projection.
## Вердикт
Зелёный. High: 0, Medium: 0. Три находки Low (отсутствие двух обязательных
продуктовых разделов; отсутствие инлайн-тегов доказательства у AC1–AC10;
асимметрия одной строки матрицы) — ни одна не блокирует и не оставляет
AC недоказуемым по существу; все зафиксированы с реконструкцией и оставлены на
усмотрение автора при следующей правке файла, без нового цикла ревью.
+326
View File
@@ -0,0 +1,326 @@
# SPEC-REVIEW-132-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/132
- **ТЗ под ревью:** `docs/specs/132-partition-openings.md` (коммит `2aaabc48d65b8886b72578907e9622379807d991`, ветка `issue/132-partition-openings-v2`)
- **Связанный bug в том же scope:** #185 (решением владельца исправляется в рамках #132)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — сложность/риск 9/10, несколько
поверхностей (placement, geometry, light, room-topology, HA state, i18n,
golden); лёгкий трек корректно не применён, файл ТЗ в `docs/specs/`
создан, как требуется
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs (J4, с поддержкой J1/J2/J3),
отсутствие расширения скоупа, lock-инвариант, правило «никогда не удалять
файл по догадке» (задача файлов не касается — у openings нет attachments,
§16 ТЗ это явно фиксирует);
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §12 (запреты);
- `AGENTS.md` — классы файлов, ветка `issue/132-partition-openings-v2`,
трейлеры, связь issue ↔ ТЗ в `docs/specs/README.md`;
- канонические документы затронутых подсистем: `docs/LIGHT.md`,
`docs/WALL-THICKNESS.md`, `docs/SUN.md`, `docs/CANVAS.md`,
`docs/UX-MODES.md`, `docs/CONFIG-COMPATIBILITY.md`,
`docs/TOUCH-SUPPORT.md` — на предмет того, что технические утверждения ТЗ о
«текущем поведении» не являются непроверенной догадкой, а описывают код и
контракты, которые действительно существуют;
- `docs/USER-GUIDE.ru.md` — терминология («Стены», «Проём», «Перегородка»,
«Открытый проём») берётся оттуда, а не изобретается;
- весь тред issue #132 (7 комментариев, 3 раунда вопросов владельцу, две
редакции ТЗ) — на предмет того, что продуктовые вопросы были заданы
владельцу, а технические автор решил сам и пометил как предположения.
## Как проверялось
1. Прочитан весь тред issue #132: исходный отчёт, решение владельца «проём
пропускает свет» (2026-08-13), аналитика S2 (сложность 8/10 → 9/10 после
переоценки от 2026-08-19 после релиза #173), вопросы Q1–Q3
(комментарий 2026-08-14) и их дефолты, принятые владельцем без изменений
(2026-08-15), первая редакция ТЗ (`issue/132-partition-openings`,
коммит `da05728`), актуализация аналитики после #173 (комментарий
2026-08-19, семь конкретных пунктов о том, что изменилось), вопросы Q4–Q5
и их принятие владельцем вместе с решением включить фикс #185 в этот же
issue, финальная редакция ТЗ (`issue/132-partition-openings-v2`,
коммит `2aaabc4`).
2. Прочитаны issue #173 (единый инструмент «Стены», слияние
room-outline/partition) и #157 (`passage` — «Открытый проём») — оба
закрыты и уже выпущены в `v1.65.0-beta.2` (см. `git log` — коммит
`54c5ca3 test: accept v1.65.0-beta.2 golden baselines` уже в истории до
ветки задачи). Прочитан #185 (bug, замыкание контура ломается проёмом на
участвующей стене) — открыт, без статусной метки `S*`, что нормально: по
`AGENTS.md`/`PROCESS.md` §9 issue без статуса вне процесса, а решение
владельца прямо говорит, что #185 закрывается кодом #132 и тем же
код-ревью — отдельная метка статуса ему на данном этапе не нужна.
3. Построчно сверены обязательные разделы ТЗ (`PROCESS.md` §7.1) — таблица
ниже.
4. Проверены **технические утверждения ТЗ о текущем (уже реализованном)
поведении** чтением реального кода на этой же ветке, а не поверено на
слово:
- §3 «current opening placement/index принимает только derived room
walls» — подтверждено: `openingWallIndex()` (`src/wall-thickness.ts:2059-2092`)
строит `edges` только из `for (const room of rooms || [])` →
`roomWallProfile(...)`; `partitions` в этой функции не участвуют вовсе;
- §3 «Physical union специально добавляет partitions после opening cuts»
— подтверждено: `_lightBarriers()` (`src/houseplan-card.ts:14157-14231`)
режет `walls` проёмами (`openCuts`/`passages`) и передаёт уже нарезанные
`walls` вместе с сырым (без вычетов) `physical` в `wallBodiesGeometry(...)`;
`physicalBodySet()`/`physicalBodyParts()` (`src/physical-geometry.ts:108-173`)
строят тела partitions/drafts/columns и их junction-патчи независимо от
`openings` — ни один вызов не режет их проёмом;
- §3 «#173 заменил отдельный инструмент "Перегородка" одной цепочкой
"Стены"; активные сегменты живут в `room_drafts`, явное завершение
превращает каждый сегмент в `partition`» — подтверждено кодом
(`src/houseplan-card.ts:6657` комментарий «Walls: every completed
segment is crash-safe in room_drafts until an […] finish») и текстом
`docs/USER-GUIDE.ru.md:282-315` («Выберите Стены… сегменты сохранятся
обычными независимыми стенами»), а также `docs/CANVAS.md` разделами
«Architectural connection overlay» и «Planar wall faces» (эти разделы
явно добавлены после #173 и описывают именно ту архитектуру, на которую
опирается ТЗ #132);
- §11/#185 «room-face detection режется опенингом» — подтверждено:
`buildPlanSnapGeometry()` (`src/plan-snap-overlay.ts:118-132`) строит
`sources` из `roomEdges(...)` и явно передаёт `cuts: roomCuts` на каждый
сегмент — то есть сегодня граф для замыкания комнаты режется по
opening cuts, что и есть причина #185; `docs/CANVAS.md` раздел «Planar
wall faces» прямо говорит «any physical gap — including an opening
cut — remains a gap» про действующее поведение;
- `OpeningCfg` (`src/types.ts:169-181`) сегодня не содержит `host` —
подтверждено; заявление ТЗ §7 «получает optional host discriminator»
корректно описывает это как новое поле, а не переименование
существующего.
5. Сверены type-specific light-правила §13 ТЗ (door/gate/passage прозрачны
при полу с обеих сторон, window всегда opaque, source внутри exterior
opening/window fail-dark) с `docs/LIGHT.md` («Deliberately opaque…»,
«The classifier is an explicit `door | gate | passage` allowlist») —
ТЗ не придумывает новую световую семантику, а переиспользует
существующую type-specific политику один в один для нового host kind.
6. Сверены sun-правила §14 («partition window не создаёт exterior wedge») с
`docs/SUN.md` («For every opening of type "window" sitting on an EXTERIOR
wall… windows on interior walls do not participate») — совпадает, это не
новое правило, а прямое следствие уже принятого канона.
7. Сверена толщина/cut-геометрия §10 (1–100 см для partition, jamb returns,
composite cut только для collinear-покрывающих тел) с
`docs/WALL-THICKNESS.md` §9 («1–100 cm for draft and partition segments»,
«unioned with room-wall bodies only after door/window/gate cuts») —
совпадает дословно.
8. Сверена терминология с `docs/USER-GUIDE.ru.md`: «Стены» (§8, «Выберите
Стены»), «Проём» (§9, подменю «Окно / Дверь / Открытый проём / Ворота»),
host kind «Перегородка» (уже используется в §8 таблице инструментов и в
контекстной панели, см. заголовок скриншота «Выбранная перегородка»);
ТЗ не вводит новых, не согласованных с гайдом слов.
9. Проверено `docs/CONFIG-COMPATIBILITY.md` — раздел «Open-passage opening
type (#157)» уже фиксирует, что `passage` запрещает `contact/lock/invert/
flip_h/flip_v` даже при `null`/`false`; ТЗ §4 п.1 и §15 корректно этого не
меняют и не противоречат зарегистрированной схеме совместимости.
10. Проверено `docs/TOUCH-SUPPORT.md` на предмет обязательного заявления
`Touch editor: supported / best effort / not exposed` для новой editor
feature — в тексте ТЗ такого явного маркера нет (см. находку Low-2).
11. Проверены трейлеры коммитов `b9bf210`/`2aaabc4` (`Issue: #132`,
`User-Visible: no` — верно для документации ТЗ) и двусторонняя ссылка
issue ↔ ТЗ в `docs/specs/README.md:91`.
12. Не запускал автотесты и не собирал бандл — на этапе `spec` это не
требуется; факты о существовании кода и функций проверены чтением
файлов на диске, не исполнением.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 — администратор, инструменты «Стены»/«Проём», Plan editor (поверхность называется через инструменты, не текстом «Редактор плана», но однозначно определяется) |
| Что человек увидит до/после (без терминов реализации) | ⚠️ | §2 — см. Low-1: использует внутренний термин «host-сегмент» |
| Проблема и связь со scope | ✅ | §3, с подтверждённым построчно техническим диагнозом |
| Скоуп / не-скоуп | ✅ | §5 / §6, оба конкретны и проверяемы |
| Контракт поведения | ✅ | §7–17: модель данных, resolver, placement, толщина/cut, room topology (#185), floor/tunnel, light, sun, HA state/actions, move/edit/delete, orphan |
| UX | ✅ | §9 (placement), §16 (move/edit/delete) |
| i18n / accessibility | ⚠️ | §19 — строки описаны по смыслу, но не перечислены как конкретные ключи en+ru; см. Low-3 |
| Touch | ⚠️ | затронуто по существу (§9, §21, AC10), но нет обязательного по `docs/TOUCH-SUPPORT.md` явного маркера `Touch editor: …`; см. Low-2 |
| Модель данных и миграция | ✅ | §7, §18 — explicit host discriminator, backward compatibility, no schema migration |
| Критерии приёмки AC1…ACn с доказательством | ✅ | §20, 12 штук, у каждого назван тип доказательства (unit/backend/smoke/golden/reviewed golden) |
| План автотестов | ✅ | §21 — Unit/Backend/Browser smoke/Golden/Performance, конкретные сценарии |
| Риски | ✅ | §23, 9 пунктов с мерами (без явной ссылки на закрывающий AC — необязательно по §7.1, но снижает читаемость) |
| Откат | ✅ | §23, последний абзац |
| Release-артефакты | ✅ | §24 — оба changelog, USER-GUIDE.ru.md, шесть канонических документов, TESTING.md |
Все обязательные по `PROCESS.md` §7.1 разделы присутствуют и содержательны.
Дополнительно есть §25 «Принятые технические предположения», корректно
отделяющий свободно изменяемые технические решения от продуктовых решений
владельца.
## Находки
Находок уровня **High** и **Medium** нет.
### Low-1 — §2 использует термин реализации «host-сегмент»
**Файл:** `docs/specs/132-partition-openings.md:22`
Раздел «Что человек увидит до и после» обязан по `PROCESS.md` §7.1
формулироваться «одной фразой, без терминов реализации». Текущая
формулировка — «выбранный door/window/gate/passage вырезает её тело,
следует за конкретным **host-сегментом** и ведёт себя как тот же тип
проёма в обычной стене» — использует «host» и «host-сегмент», термины
модели данных этого же ТЗ (§7), которых нет ни в `docs/USER-GUIDE.ru.md`,
ни в обычной речи пользователя. Человек не думает про «host» — он видит,
что дверь/окно теперь можно поставить на перегородку и она держится на
своём месте при переносе стены.
**Почему не блокирует:** раздел присутствует, разбит на «до» и «после»,
и по существу корректен; страдает только буквальное соответствие «без
терминов реализации». AC и контракт поведения (§7–17) не зависят от этой
фразы.
**Решение ревьюера:** Low, не блокирует. Рекомендация — заменить
«host-сегмент» на «эту перегородку» при следующей правке; можно также
оставить как есть с записью здесь, так как продуктовый смысл раздела не
искажён.
### Low-2 — нет обязательного маркера `Touch editor: …` по `docs/TOUCH-SUPPORT.md`
**Файл:** `docs/specs/132-partition-openings.md` (раздел §9/§21, отсутствует
явное заявление)
`docs/TOUCH-SUPPORT.md` требует буквально: «New editor feature
specifications and code reviews must state one of: `Touch editor:
supported`; `Touch editor: best effort / intentionally degraded`; `Touch
editor: not exposed`.» ТЗ #132 добавляет новую функциональность
Plan-редактора (placement/drag/delete проёма на перегородке) и по существу
описывает touch-поведение («Touch placement — best effort;
pointercancel/multi-touch не сохраняют draft», AC10, browser smoke «touch
cancel/pinch safety»), но нигде не даёт этой ровно сформулированной строки.
**Почему не блокирует:** содержательно контракт уже соответствует
best-effort политике редакторов (`docs/TOUCH-SUPPORT.md`: «Plan editor:
Best effort»), никакого расхождения с политикой нет — не хватает только
формальной декларативной строки, которую сам канон требует именно текстом.
View/kiosk (обязательная touch-поверхность) в этом ТЗ — чисто presentation
(рендер уже существующим пайплайном через общий resolver, AC7), интерактива
там не добавляется.
**Решение ревьюера:** Low, не блокирует. Рекомендация — добавить строку
`Touch editor: best effort / intentionally degraded` в §9 или §21 при
следующей правке.
### Low-3 — i18n-раздел не перечисляет конкретные ключи en/ru
**Файл:** `docs/specs/132-partition-openings.md:330-342`
DoR-чеклист (`PROCESS.md` §2.5) требует на входе в «Готово к разработке»:
«i18n: ключи en + ru перечислены». §19 ТЗ описывает нужные строки по
смыслу («Стена или перегородка» в placement guidance/error», host kind
«Перегородка» и т.д.), но не называет литеральные идентификаторы ключей
(например `opening_no_wall_or_partition`, `opening_host_kind_partition`).
**Почему не блокирует:** выбор конкретных строковых констант — техническое
решение (именование), прямо подпадающее под §25 «точная форма discriminator
fields может меняться на ревью» по духу того же принципа: разработчик и
ревьюер кода решают его сами, без продуктового смысла. Из текста однозначно
понятно, какие строки нужны и где.
**Решение ревьюера:** Low, не блокирует. Рекомендация — при переводе issue
в `S5-ready` дописать в §19 (или в отдельном комментарии) точные ключи,
чтобы DoR-пункт был закрыт буквально, а не только по духу.
## Что проверено и корректно
- **Соответствие `docs/SCOPE.md`.** Функция закрывает J4 («от нуля до
рабочего плана без внешнего SVG/YAML») и корректно поддерживает J1/J2/J3
через сохранение геометрии/contact/lock/actions идентичными room-wall
contract — ни один пункт «Out of scope» не задет, `passage` не создаёт
новую семантику (§6 явно это исключает), lock-инвариант не расширяется
(§15: «Door/gate lock action остаётся единственной sanctioned opening
surface», «Passage остаётся inert»).
- **Продуктовые вопросы владельцу заданы корректно и по существу.** За три
раунда (Q1–Q3, затем Q4–Q5) все вопросы — это «что человек видит/делает»
(какие типы проёма разрешить, что видно при переносе/удалении
перегородки, нужен ли отдельный chooser при совпадении стен) или «сколько
видимых изменений входит в issue» (включать ли уже реализованный
`passage`, сворачивать ли фикс #185 в этот же issue) — ни одного чисто
технического вопроса владельцу не передано; каждый вопрос шёл с
предлагаемым default. Владелец принял все defaults без правок.
- **Технические решения по существу верны и отделены от продуктовых.**
Обширный раздел «Актуализация после #173/#157» (комментарий 2026-08-19)
и итоговая ревизия ТЗ корректно диагностируют, что изменилось в кодовой
базе после #173/#157/#185 и что это означает для #132 — проверено
построчным чтением реального кода (см. «Как проверялось» п.4–7):
расхождений между заявленным и действительным поведением не найдено.
Раздел §25 явно маркирует свободно изменяемые технические предположения
(форма host-поля, имя resolver'а) отдельно от settled-решений владельца
(§4) — никакая догадка не выдана за факт без пометки.
- **AC1–AC12 однозначны и у каждого указан тип доказательства** из
допустимого по DoR перечня (unit/backend/smoke/golden/reviewed golden).
AC5 отдельно требует production-bundle smoke, который «краснеет на
`origin/dev`» — то есть автор заранее закладывает воспроизводимость
регресса #185 тестом, который **умеет падать** до фикса; это ровно тот
стандарт доказательства, который код-ревью потребует на следующем этапе
(`AGENTS.md`, issue #143 про смок, не умеющий падать).
- **Не-скоуп (§6) корректно отсекает смежные соблазны:** новый opening
type, несколько host segments на один opening, конверсия старых
room-wall openings в partition openings, новый способ завершения Walls
chain, изменение light/window semantics, sun rays от внутреннего окна,
полная touch parity редактора, свободное удаление host без confirmation —
все типичные места, где скоуп мог бы незаметно расшириться.
- **Миграция и совместимость (§18) корректны:** старые данные не
мигрируют, `host` — чисто additive optional-поле, поведение
room-wall openings без host не меняется; согласуется с
`docs/CONFIG-COMPATIBILITY.md` (существующая запись про `passage` не
противоречит новым правилам).
- **Откат описан симметрично** (§23, последний абзац): запрет на создание
новых partition-host openings плюс явный запрет тихой авто-конвертации
уже сохранённых host-объектов в room-wall openings.
- **Release-артефакты (§24) называют реальные документы**, шесть из семи
канонических файлов подсистемы (`ARCHITECTURE.md`, `CANVAS.md`,
`UX-MODES.md`, `WALL-THICKNESS.md`, `LIGHT.md`, `SUN.md`,
`CONFIG-COMPATIBILITY.md`) плюс `USER-GUIDE.ru.md`/`TESTING.md`/оба
changelog — соответствует правилу «документация в том же коммите, что
поведение».
- **Трассируемость:** `docs/specs/README.md:91` ссылается на ТЗ и на #185
одной строкой в обе стороны; трейлеры обоих коммитов ТЗ (`Issue: #132`,
`User-Visible: no`) корректны для чисто документационного изменения.
- **Решение свернуть #185 в #132** — явное решение владельца
(комментарий 2026-08-18), а не самовольное расширение скоупа автором; в
`AGENTS.md`/`PROCESS.md` нет запрета объединять связанный bug в feature
issue по решению владельца, а отсутствие статусной метки `S*` у #185 не
создаёт противоречия, так как #185 явно не идёт по процессу отдельно —
его код и доказательство целиком описаны AC5/AC6 этого ТЗ.
## Чего не проверял
- Не проверял, что предложенный `ResolvedOpeningHost` resolver (§8)
реализуем без побочных эффектов на существующие `_glowClipCache`/
`OpeningWallIndex` кеши — по §25 п.7 это свободно изменяемое техническое
решение автора кода, предмет код-ревью, а не ревью ТЗ.
- Не запускал автотесты, не собирал бандл и не гонял golden/смоки — на
этапе `spec` это не требуется; существование упомянутых модулей и
контрактов проверено чтением файлов на диске (см. «Как проверялось»
п.4–7), не исполнением кода.
- Не проверял, сколько именно golden-сцен с перегородками изменится
визуально (владелец просил оценить это заранее в первом комментарии) —
это в явном виде эксплуатируется через переоценку сложности 8→9/10 и
через требование «reviewed Flat/Iso/Glow golden artifacts» (§24); точное
число сцен — вопрос реализации и пре-релизного гейта, не ревью ТЗ.
- Не проверял реализуемость composite room-wall/partition cut в текущей
boolean-геометрии (`polyclip-ts`) на предельных случаях (например, три и
более совпадающих тела) — §23 называет это риском («Composite overlap
режет nearby/crossing body») с мерой («collinear full-interval coverage +
negative units»), достаточной для ТЗ; сама корректность реализации —
предмет код-ревью на unit-тестах §21.
- Не проверял статус issue #185 на предмет корректности процесса
еженедельной гигиены (issue без `S*`-метки дольше некоторого срока) — это
вне скоупа ревью ТЗ #132.
## Вердикт
Зелёный. High: 0, Medium: 0. Три находки Low (жаргон в §2, отсутствие
обязательной по `docs/TOUCH-SUPPORT.md` строки `Touch editor: …`,
отсутствие конкретных i18n-ключей) — ни одна не блокирует переход в
«Готово к разработке»; все три — точечные текстовые дополнения, не
меняющие контракт поведения или AC. Технические утверждения ТЗ о текущем
состоянии кодовой базы (после #173/#157) проверены построчным чтением
реального кода на этой же ветке и подтверждены без расхождений. Продуктовые
вопросы за три раунда обсуждения заданы владельцу корректно (что видит/
делает человек, какой объём входит в issue) и закрыты явными решениями;
технические вопросы автор решил сам и промаркировал как предположения
(§25), не выдавая догадку за факт.
+238
View File
@@ -0,0 +1,238 @@
# Issue #103 — текущее и ожидаемое состояние в Toggle confirmation
- **Issue:** https://github.com/Matysh/houseplan-card/issues/103
- **Статус документа:** принято ревью ТЗ; реализация разрешена из `S5-ready`
- **Приоритет:** P3
- **Тип:** feature/polish, обычный трек
- **Пользовательское изменение:** да
## 1. Контекст
Универсальный toggle из #94 уже строит один `ResolvedToggleIntent` для hint,
confirmation guard и фактической service call. При `tap_confirm` текущий диалог
показывает только имя цели. Пользователь не видит исходное состояние и
направление команды, особенно у cover/valve и смешанных групп.
Это изменение встречает любую персону House Plan в View на desktop, companion
app или wall panel в момент короткого нажатия на маркер с включённым
`tap_confirm`; поверхность относится к fully-supported safe device actions.
#103 расширяет только содержимое существующего confirmation. Resolver, target
selection, кнопки и safety re-resolve остаются прежними.
## 2. Пользовательский результат
Одиночная цель:
> Переключить «Лампа в прихожей»?
>
> Текущее состояние: Выключено
>
> После переключения: Включено
Группа:
> Текущее состояние: включено 2 из 4
>
> После переключения: все выключены
Строки являются обычным доступным текстом, а не цветом, и читаются до кнопок
Cancel/Confirm.
## 3. Цели
1. Объяснить текущее и ожидаемое состояние каждой исполняемой toggle-команды.
2. Использовать только `ResolvedToggleIntent`, без UI-domain эвристики.
3. Не обещать результат для skipped/unknown целей.
4. Сохранить re-resolve перед service call и target-set race protection #94.
## 4. Не входит в задачу
- изменение resolver, command или service;
- прогноз scripts/scenes и произвольных actions;
- история состояний;
- confirmations удаления/unlock/run и общий dialog redesign #32;
- live-анимация состояния в открытом диалоге;
- изменение `tap_confirm` schema.
## 5. Источник данных
Confirmation snapshot строится из `ResolvedToggleIntent`:
- `targets[].state` и `targets[].name`;
- `kind` и `semantics`;
- `nextEffect`;
- `skippedTargets`;
- stable identity из `sameToggleOperationTargets()`.
`src/device-toggle.ts` остаётся владельцем line selection. Допустимо расширить
`formatToggleIntent()` либо добавить рядом pure `formatToggleConfirmation()`, но
`houseplan-card.ts` не выводит next state по domain самостоятельно.
State label берётся через HA formatter, когда state object доступен. Raw state
допустим только как безопасный fallback; неизвестный будущий результат никогда
не подменяется уверенным On/Off.
## 6. Нормативная матрица
| Intent | Текущее состояние | После переключения |
| --- | --- | --- |
| power `off` + `turn-on` | Выключено | Включено |
| active power + `turn-off` | HA-formatted current | Выключено |
| cover `closed` + `open` | Закрыто | Открыто |
| cover `open` + `close` | Открыто | Закрыто |
| cover `opening/closing` + `stop` | Открывается/Закрывается | Остановлено |
| valve + `open/close` | Закрыто/Открыто | Открыто/Закрыто |
| group, все off | Все выключены | Все включены |
| group, есть active | Включено N из M | Все выключены |
| partial group | доступное подмножество + Недоступно N | результат только command targets |
| `toggle` | HA-formatted current | Состояние определит Home Assistant |
| no operation | confirmation не открывается | — |
Direction всегда следует `nextEffect`; UI не пересчитывает её из текущей строки.
Для группы denominator результата равен числу фактических `targets`, а skipped
выводятся отдельно. Формулировка не обещает, что unavailable/disabled/missing
entities изменятся.
## 7. Dialog state и race
`_tapConfirm` расширяется структурированным snapshot, а не одним HTML string:
```ts
interface TapToggleConfirmation {
kind: 'toggle';
title: string;
lines: string[];
initialIntent: ResolvedToggleIntent;
deviceId: string;
exec: () => void;
}
```
Минимальный обязательный контракт:
- при открытии title/lines фиксируются из initial intent;
- при Confirm текущий device и intent разрешаются заново;
- если operation targets изменились, service call отсутствует и показывается
существующий `toast.tap_target_changed`;
- если targets прежние, выполняется актуальное direction/command, даже если
state изменился после открытия;
- dialog snapshot не обязан live-обновлять строки.
`run` confirmation продолжает использовать нынешнюю простую форму. Общий union
не должен заставлять run/delete dialogs притворяться toggle.
## 8. UX, i18n и accessibility
- title сохраняет нынешнее «Переключить …?»;
- current/expected/skipped — отдельные строки/paragraphs;
- long friendly name и group text переносятся без horizontal scroll;
- accessible DOM order: title → current → expected → skipped → buttons;
- смысл не выражается только иконкой/цветом/стрелкой;
- narrow mobile footer сохраняет две доступные кнопки;
- keyboard focus и Escape/scrim contract не меняются.
Минимальные новые RU/EN keys:
- `confirm.current_state`;
- `confirm.expected_state`;
- `confirm.group_current`;
- `confirm.group_all_on` / `confirm.group_all_off`;
- `confirm.unavailable_targets`;
- `confirm.expected_by_ha`;
- state/effect labels, которых ещё нет в общей toggle vocabulary.
Не вводятся plural rules; строки следуют текущему counter-safe style.
## 9. Совместимость и безопасность
- stored `tap_confirm` и config не меняются;
- no-op по-прежнему тихий и не открывает modal;
- secure/disabled/missing filtering остаётся resolver-owned;
- confirmation не исполняет команду из initial snapshot;
- operation target identity проверяется перед actuation;
- virtual-light intent использует тот же current/expected formatter и current
backend snapshot, не создавая HA service;
- никаких новых permissions или network requests.
## 10. Acceptance criteria
1. Toggle confirmation для operation показывает current и expected lines — **unit + browser smoke**.
2. Expected line строго соответствует `nextEffect` — **unit**.
3. Power, cover, valve, virtual light и group имеют локализованные формулировки — **unit + RU/EN browser smoke**.
4. Partial group явно показывает skipped count и не обещает их изменение — **unit**.
5. `toggle` сообщает, что результат определит HA — **unit**.
6. No-operation intent не открывает confirmation — **unit + browser smoke**.
7. Confirm выполняет заново разрешённый current intent — **browser smoke**.
8. Изменившийся target set отменяет actuation и показывает прежний toast — **browser smoke**.
9. Desktop/mobile layout, keyboard и screen-reader order не регрессируют — **narrow browser smoke + code review**.
10. Run и другие confirmations сохраняют прежнее содержимое — **existing run smoke + code review**.
## 11. План тестирования
### Unit
- pure formatter для каждого `ToggleNextEffect`;
- single power/cover/valve/virtual-light;
- all-off, mixed и partial group;
- formatted current state и raw fallback;
- unknown `toggle` result;
- no-operation returns no confirmation lines;
- EN/RU placeholder parity.
### Integration/browser
- confirmation DOM order на desktop и narrow mobile;
- initial snapshot + state changes + same targets → current command executes;
- changed targets → zero service calls + toast;
- skipped target is not included in command/result promise;
- keyboard focus, Escape, Cancel and scrim;
- run confirmation unchanged.
### Регрессия
- `test/device-toggle.test.mjs`;
- existing HA-controls/toggle smoke;
- typecheck, full unit и build.
Golden не требуется, если modal reflow покрыт narrow render smoke и не меняет
принятый внешний layout. При необходимости добавляется одна deterministic dialog
scene без переакцептации несвязанных baseline.
## 12. План реализации
1. Добавить pure confirmation formatter рядом с resolver.
2. Расширить `_tapConfirm` discriminated state для toggle.
3. Отрисовать semantic lines в текущем dialog.
4. Добавить RU/EN strings и parity tests.
5. Проверить race contract и existing confirmations.
## 13. Документация и release-артефакты
- оба changelog получают user-visible пункт;
- `docs/USER-GUIDE.ru.md` показывает current→expected confirmation;
- `docs/TESTING.md` получает group/race/narrow dialog matrix;
- RU/EN dictionaries меняются в одном implementation commit;
- screenshot/golden — только для точечной modal scene при необходимости;
- backend, migration, performance profile и security artifact не требуются.
## 14. Риски и откат
| Риск | Мера |
| --- | --- |
| Dialog обещает не ту команду | nextEffect-only formatter |
| Initial snapshot исполняется после race | mandatory re-resolve |
| Skipped входят в denominator | separate targets/skipped assertions |
| UI дублирует resolver | pure device-toggle formatter |
| Long group ломает mobile | narrow viewport smoke |
Откат возвращает `_tapConfirm` к одному title string. Config и resolver не
меняются, поэтому data rollback отсутствует.
## 15. Принятые технические предположения
- первая версия показывает snapshot и не live-обновляет открытый dialog;
- current state использует HA formatter при наличии;
- `stop` локализуется как честный ожидаемый effect, не как конечная позиция;
- group result описывает только фактические command targets.
+253
View File
@@ -0,0 +1,253 @@
# Issue #113 — честный optional-контракт `_spaceModel()`
- **Issue:** https://github.com/Matysh/houseplan-card/issues/113
- **Статус документа:** готово к будущей реализации; issue остаётся на `S3-spec`
- **Приоритет:** P2
- **Тип:** tech-debt, обычный продуктовый трек
- **Пользовательское изменение:** нет; предотвращается повтор класса crash #111
## 1. Проблема
`_spaceModel(id?): SpaceModel` возвращает найденное пространство либо первый
элемент `_model`. При пустом массиве фактический результат — `undefined`, но тип
обещает `SpaceModel`. `strict` не ловит это без `noUncheckedIndexedAccess`.
#111 закрыл один вызов из render snapshot. Остальные consumers остаются
безопасными только благодаря текущему порядку render/lifecycle. Новый вызов до
empty-state gate может снова получить `Cannot read properties of undefined`.
## 2. Цели
1. Типом зафиксировать отсутствие SpaceModel при `spaces: []`.
2. Разобрать каждый production call site по допустимому empty behavior.
3. Не заменять проблему non-null assertions или фиктивной моделью.
4. Сохранить нормальное поведение всех editor/render paths при существующем
пространстве.
5. Расширить regression contract пустого плана за пределы одного snapshot.
## 3. Не входит в задачу
- изменение empty-state UX;
- автоматическое создание пространства;
- migration config/layout;
- включение `noUncheckedIndexedAccess` для всего репозитория;
- рефакторинг всех методов `houseplan-card.ts`;
- изменение backend API;
- восстановление повреждённой модели с duplicate ids.
Глобальный `noUncheckedIndexedAccess` может быть отдельным tech-debt issue после
измерения diff. Он не должен незаметно расширить #113.
## 4. Нормативный API
`_spaceModel()` становится честно optional:
```ts
private _spaceModel(id?: string): SpaceModel | undefined {
const requested = id ?? this._space;
return this._model.find((space) => space.id === requested) ?? this._model[0];
}
```
Допустимо вынести pure `selectSpaceModel(models, activeId, requestedId)` для
unit. Возврат `null` вместо `undefined` допустим только единообразно; публичное
требование — тип не обещает объект.
Запрещено:
- возвращать synthetic empty `SpaceModel`;
- ставить `!` у каждого consumer;
- бросать исключение в обычном empty plan lifecycle;
- использовать `this._serverCfg.spaces[0]` как второй скрытый fallback.
## 5. Классы call sites
### 5.1 Lifecycle до render gate
`willUpdate`, `updated`, snapshot capture, theme/paper resolver, frame/viewport,
mode transition, timers, ResizeObserver и WS handlers обязаны принимать
отсутствие модели.
Нормативное поведение:
- не строить geometry/devices/openings;
- очистить transient tooltip/hover/drag/dialog state, относящееся к удалённому
пространству;
- не писать config/layout;
- не запускать service call;
- сохранить рабочий empty-state и кнопку создания пространства.
### 5.2 Event handlers редакторов
Draw/split/resize/opening/decor/device handlers начинают с локального guard:
```ts
const space = this._spaceModel();
if (!space) return;
```
Guard расположен до pointer capture, history mutation, optimistic mutation и
persist. Если активный drag потерял пространство, вызывается соответствующий
abort path, а не частичный commit.
### 5.3 Pure render helpers
Методы, формирующие TemplateResult/geometry, при отсутствии space возвращают
пустой layer/empty array/fallback frame согласно типу. Они не создают dummy room
или wall. Default parameters вида `space = this._spaceModel()` удаляются, если
их тип больше не гарантирует объект.
### 5.4 Paths с уже доказанным space
После единственного локального guard объект передаётся параметром вниз по
вызовам. Не нужно повторять lookup и optional chaining десятки раз. Такой
локальный narrowing предпочтительнее нового throwing `_requireSpaceModel()`.
Strict helper допускается только в pure internal function, если caller уже
передал `SpaceModel`; метод карточки не должен падать на пользовательском empty
state.
## 6. Active id и fallback
Если `_model` непуст, но текущий `_space` отсутствует после adoption новой
конфигурации, сохраняется нынешний fallback на первый space. Lifecycle, который
принимает новую model, затем нормализует active id обычным путём.
Явный `id`:
- возвращает exact space при наличии;
- если id отсутствует, fallback на текущий первый элемент сохраняет legacy
semantics только там, где caller намеренно просит display fallback;
- callers, для которых missing explicit id означает stale object, должны
проверять identity отдельно и abort-ить, а не редактировать первый этаж.
Чтобы исключить опасную двусмысленность, допустимо разделить API:
- `_spaceModel()` — active-or-first optional;
- `_spaceModelById(id)` — exact optional без fallback.
Это техническое разделение рекомендуется для drag/history/dialog commands,
содержащих stable `spaceId`.
## 7. Empty-state cleanup
При переходе non-empty → empty очищаются или отменяются:
- `_tip`, `_hoverRoom`, opening/info transient cards;
- active pointer/pan/pinch/drag и pointer capture;
- editor drafts/selections, которые ссылаются на удалённый space;
- projection/room-fit transition текущего space;
- pending persist debounce, если у него нет валидного target.
Глобальные config dialogs, которые создают новое пространство, остаются
доступны. Cleanup не удаляет server files, layout другого пространства или
пользовательский backup.
## 8. Type-system guard
После смены return type `npm run typecheck` обязан заставить обработать все
production consumers. Исправление не считается полным, если ошибки погашены
массовым `?.` без определения поведения.
Добавляется source-contract test:
- `_spaceModel` объявлен optional;
- в production source нет `_spaceModel()!` и `this._spaceModel()!`;
- опасные default parameters не возвращают optional как required;
- empty-sensitive lifecycle helpers покрыты executable tests.
Source test — дополнительный guard, а не замена typecheck/code review.
## 9. Совместимость, touch и security
- visible UI с одним/несколькими spaces pixel-identical;
- config/layout schema и revisions не меняются;
- empty View одинаково безопасен на desktop/touch/kiosk/read-only;
- editor touch safety floor не меняется;
- нет новых service calls или permissions;
- удаление последнего space не удаляет файл подложки автоматически.
## 10. Acceptance criteria
1. `_spaceModel`/exact variant возвращает optional тип.
2. Все production call sites компилируются без non-null assertions на lookup.
3. Empty render/update/resize/theme/WS cycles не бросают исключений.
4. Удаление последнего space оставляет рабочий empty-state.
5. Pending pointer/drag/editor action при исчезновении space abort-ится без
history/persist/service call.
6. Read-only cold start с `spaces: []` остаётся полным и стабильным.
7. Non-empty View/editors сохраняют текущие pixels/actions.
8. Missing explicit stale space id не мутирует первый space.
9. Active-id fallback при непустой model сохраняет согласованное legacy behavior.
10. Type/source gates не позволяют вернуть прежнюю ложную сигнатуру.
## 11. План тестирования
### Unit
- selector: empty, active match, requested match, missing active, missing exact;
- exact lookup не падает в first-space fallback;
- cleanup state transition non-empty → empty;
- optional geometry helpers возвращают safe empty result.
### Browser smoke
- cold load с zero spaces;
- удалить единственное пространство и дождаться нескольких Lit/RAF cycles;
- theme/resize/visibility/registry/WS update после удаления;
- empty → create first space → View/editor usable;
- удалить space во время pointer drag/mode transition;
- read-only empty config;
- no console errors/unhandled promise rejection.
### Регрессия
- targeted smoke #111 и #131;
- View/Plan/Devices/Backdrop open-close cycle с одним и несколькими spaces;
- typecheck, full unit и build;
- mutation: вернуть required signature/unguarded lifecycle read — тест красный.
Golden не требуется при нулевом visual diff. Существующий empty-state screenshot
может использоваться как regression evidence без новой переакцептации.
## 12. План реализации
1. Ввести optional active и exact lookup helpers с unit tests.
2. Поменять type и классифицировать compile errors по §5.
3. Исправить lifecycle/render paths.
4. Исправить editor handlers и передавать narrowed space вниз.
5. Добавить empty-state cleanup и smoke.
6. Добавить source/mutation contract и прогнать гейты.
## 13. Документация и release-артефакты
- changelog не нужен: ожидаемый user-visible behavior не меняется
(`User-Visible: no`);
- `docs/ARCHITECTURE.md` фиксирует optional active-space invariant;
- `docs/TESTING.md` расширяет empty-plan lifecycle matrix;
- user guide и i18n не меняются;
- golden/screenshots не требуются при pixel parity;
- performance gate нужен только если diff затронет hot render helpers;
- backend HA harness не требуется.
## 14. Риски и откат
| Риск | Мера |
| --- | --- |
| Optional chaining скрывает частичный mutation | guard до side effects |
| Stale id редактирует первый space | exact lookup для commands |
| Cleanup закрывает Create flow | отдельная global/space-bound state matrix |
| Массовый diff меняет pixels | targeted smoke и existing golden parity |
| Ложный тип возвращается позже | source + mutation guard |
Откат возможен без data migration, но возвращать required signature допустимо
только вместе с доказанным total/sentinel API, которого #113 не вводит.
## 15. Принятые технические предположения
- глобальный `noUncheckedIndexedAccess` остаётся отдельной задачей;
- exact lookup вводится для stable-id commands, active lookup сохраняет legacy
first-space fallback при непустой model;
- empty-state visual design не меняется;
- guards группируются на границах, а не размазываются optional chain по каждому
полю.
@@ -0,0 +1,222 @@
# Issue #117 — registry-less YAML entity работает у проёма в View
- **Issue:** https://github.com/Matysh/houseplan-card/issues/117
- **Статус документа:** готово к будущей реализации; issue остаётся на `S3-spec`
- **Приоритет:** P2
- **Тип:** bug, обычный трек
- **Пользовательское изменение:** да
## 1. Проблема
Picker проёма принимает точную live YAML entity без `unique_id`: у неё есть
`hass.states[entity_id]`, но нет Entity Registry row. Live availability уже
считает такую ссылку active. Immutable render path дополнительно требует
`_renderPlanHass.entities[entity_id]`, поэтому сохранённый contact/lock не влияет
на отрисованный проём.
Это разрыв одного exact-binding contract между editor, action и painted frame.
## 2. Установленная причина
`activeRegistryHass()` намеренно сохраняет live states registry-less entities и
удаляет states только при явном disabled/orphaned registry evidence. Однако
`renderOpeningEntityAvailable()` требует одновременно:
```ts
projectedHass.entities[entityId] && projectedHass.states[entityId]
```
У YAML entity первая часть всегда отсутствует. При этом `_openingAmt()` и lock
badges используют именно frame-local helper. Issue #104 сознательно оставил эту
часть отдельной и отметил parity как #117.
## 3. Цели
1. Если exact entity предлагается picker и имеет active live state, её contact
или lock работает в painted frame.
2. Сохранить запрет disabled/orphaned/missing entities.
3. Сохранить immutable render snapshot: ни один слой не читает raw live hass в
середине кадра.
4. Не связывать opening reference с marker lifecycle/tombstone.
## 4. Не входит в задачу
- добавление Entity Registry row или `unique_id`;
- изменение candidate picker;
- миграция `opening.contact`/`opening.lock`;
- marker lifecycle и removed bindings;
- изменение lock security/confirmation;
- generic поддержка registry-less entities во всех marker bindings;
- новые opening types или geometry.
## 5. Каноническая availability policy
### 5.1 Live path
`openingEntityAvailable(hass, entityId, snapshot)` остаётся authoritative для
picker, info card и lock action. Exact opening reference active, когда
`resolveHaBindingStatus(...).kind === 'active'`.
### 5.2 Render path
`renderOpeningEntityAvailable(projectedHass, entityId)` принимает только
projection, построенную `activeRegistryHass()` и замороженную в общем render
snapshot.
В такой projection наличие state является достаточным frame-local доказательством:
```ts
return !!entityId && !!projectedHass.states?.[entityId];
```
Отсутствующая registry row не является доказательством disabled. Явно disabled
entity/device и authoritative orphan не проходят, потому что
`activeRegistryHass()` удаляет их state до capture.
Запрещено вызывать render helper с raw `this.hass`. Тип/имя параметра,
комментарий и source-contract test должны сохранять эту trust boundary.
## 6. Матрица поведения
| Entity | Live helper | Render helper | Результат |
| --- | --- | --- | --- |
| registry row active + state | active | active | работает |
| YAML/no registry row + live state | active | active | работает |
| registry entity `unavailable/unknown` | active | active | существующая unknown semantics |
| explicit `disabled_by` entity | inactive | state удалён | не работает |
| explicit disabled parent device | inactive | state удалён | не работает |
| authoritative orphan parent | inactive | state удалён | не работает |
| missing state | inactive/missing | inactive | не работает |
| limited registry + exact live state | active | active | работает |
| marker tombstone того же entity | не влияет | не влияет | opening работает |
`unavailable/unknown` здесь означает, что exact reference существует; текущая
opening renderer semantics решает, рисовать closed/unknown и badge state. #117
не подменяет эти states active/open.
## 7. Render snapshot
Capture уже добавляет `opening.contact` и `opening.lock` в `entityIds`. После
исправления необходимо доказать:
- registry-less state входит в `_renderPlanHass.states`;
- один кадр использует одну и ту же state projection для leaf amount, tone,
lock badge и tooltip data;
- HA tick заменяет snapshot атомарно;
- config/registry update не смешивается с предыдущим state;
- static `houseplan-space-card` не получает новую интерактивность.
Нельзя исправлять баг чтением `this.hass.states` непосредственно в
`_openingAmt()` или `_renderOpeningLocks()`.
## 8. Contact contract
Для door/window/gate с registry-less contact:
- state проходит текущий `openingAmount(type, state, invert)`;
- invert сохраняется;
- open tone/leaf и opening info согласованы;
- неизвестный state не становится самовольно open;
- скрытие opening symbols не меняет физическую/light semantics, определённую
существующим opening contract.
## 9. Lock contract и безопасность
Registry-less `lock.*`:
- показывает badge/state по той же матрице, что registry lock;
- info card использует live exact availability;
- lock/unlock action остаётся только в явно открытой info card;
- unlock по-прежнему требует confirmation;
- перед service call выполняется live availability check;
- tap по plan badge/opening не получает новую actuation семантику.
#117 не ослабляет secure-device rules универсального toggle.
## 10. Совместимость
- config/storage/API не меняются;
- существующие registry-backed openings pixel-identical;
- disabled/missing cases не становятся видимыми;
- Plan/View/kiosk touch gestures и hit targets не меняются;
- no new i18n key;
- backend не меняется.
## 11. Acceptance criteria
1. Registry-less live contact, выбранный picker, меняет opening presentation.
2. Registry-less live lock показывает корректный badge/info state.
3. Full-card frame использует immutable active projection, не raw hass.
4. Active registry entity сохраняет прежнее поведение.
5. Disabled entity/device, authoritative orphan и missing state остаются
unavailable.
6. Limited-registry live exact entity работает.
7. Marker tombstone не блокирует independent opening reference.
8. Lock action security и unlock confirmation не меняются.
9. Contact/lock state update не создаёт geometry/config rebuild.
10. Existing opening golden и interactions не регрессируют.
## 12. План тестирования
### Unit
- расширить `ha-binding-status.test.mjs` для render helper:
registry-backed, registry-less, disabled entity, disabled parent, orphan,
missing state, limited registry;
- mutation: вернуть requirement `.entities[entityId]` — YAML case красный;
- `openingAmount` registry-less state/invert parity.
### Browser smoke
- YAML-like `binary_sensor` без registry row: closed → open → closed;
- YAML-like `lock`: locked/unlocked/unknown badge и info;
- disabled row со stale live state не отображается;
- same entity tombstoned as marker, opening всё ещё работает;
- render snapshot atomicity на state tick;
- no service call from direct opening/badge tap; explicit info action unchanged.
### Регрессия
- existing opening/contact/lock smokes;
- `test/render-device-snapshot.test.mjs`;
- typecheck, full unit и build.
Golden обновлять не нужно: registry-backed baseline не меняется. Новый YAML case
проверяется targeted smoke/DOM state; screenshot допустим как новое доказательство,
но не требует переакцептации старых сцен.
## 13. План реализации
1. Исправить frame-local helper и его trust-boundary comment.
2. Добавить unit matrix и mutation guard.
3. Добавить registry-less contact/lock smoke.
4. Проверить snapshot/action parity и regression suite.
## 14. Документация и release-артефакты
- оба changelog получают user-visible bug-fix пункт;
- `docs/USER-GUIDE.ru.md`/diagnostics уточняет, что exact YAML entity без
`unique_id` поддерживается у contact/lock;
- `docs/ARCHITECTURE.md` или HA binding doc фиксирует render projection rule;
- новых RU/EN строк нет;
- performance/golden/full backend harness не требуются;
- targeted browser smoke обязателен перед S7 по AC.
## 15. Риски и откат
| Риск | Мера |
| --- | --- |
| Raw hass обходит immutable frame | helper принимает только projection + source test |
| Disabled state случайно оживает | complete disabled/orphan matrix |
| Lock security расширяется | отдельные render/live action assertions |
| Marker tombstone снова влияет | independent-reference unit/smoke |
Откат возвращает прежний render helper. Данные не мигрируют; сохранённые exact
references остаются в config.
## 16. Принятые технические предположения
- `activeRegistryHass()` остаётся единственным producer render projection;
- наличие state в этой projection достаточно для exact opening reference;
- unavailable/unknown считаются существующей entity, но не автоматически open;
- `houseplan-space-card` остаётся статическим и не получает actions.
+520
View File
@@ -0,0 +1,520 @@
# Issue #132 — проёмы в независимых стенах
- **Issue:** https://github.com/Matysh/houseplan-card/issues/132
- **Связанный bug в том же scope:** https://github.com/Matysh/houseplan-card/issues/185
- **Статус документа:** актуализировано после #173 и #157; готово к ревью ТЗ
- **Приоритет:** P2
- **Тип:** feature, обычный трек
- **Пользовательское изменение:** да
## 1. Сценарий
Администратор рисует незамкнутую цепочку инструментом «Стены», завершает её
сменой инструмента, редактора или этажа и размещает в одном сохранённом сегменте
дверь, окно, ворота либо открытый проём тем же инструментом «Проём», которым
работает со стенами комнат. Домочадцы затем видят корректный разрыв, а для
stateful-типов — тот же live state и действия в View/kiosk.
## 2. Что человек увидит до и после
До изменения завершённая независимая стена остаётся сплошной и не принимает
проём; после изменения выбранные дверь, окно, ворота или открытый проём вырезают
её тело, перемещаются вместе с выбранным отрезком стены и ведут себя как тот же
тип проёма в обычной стене. При этом стена с проёмом по #185 остаётся полноценным
ребром для распознавания замкнутой комнаты.
## 3. Проблема и связь со scope
`partitions[]` — канонические независимые физические стены, но current opening
placement/index принимает только derived room walls. Physical union специально
добавляет partitions после opening cuts, поэтому проём не может вырезать их.
#173 заменил отдельный публичный инструмент «Перегородка» одной непрерывной
цепочкой «Стены». Активные сегменты crash-safe живут в `room_drafts`; явное
завершение превращает каждый сегмент в отдельный `partition` со stable `id`.
Face graph использует сохранённые partitions и может создать room wall на той же
оси, не удаляя исходный объект. Это делает composite room-wall/partition overlap
штатным, а не ошибочным случаем.
#185 фиксирует более новый продуктовый контракт: проём является свойством стены,
а не разрывом её структурного ребра. Поэтому opening cuts влияют на физическую и
световую геометрию, но room-face detection должен уметь замыкать область через
room wall или partition с любым поддержанным проёмом. `open_spans` остаются
реальным отсутствием стены и в topology не возвращаются.
Функция закрывает J4: администратор может без внешнего SVG точно воспроизвести
план. Правильный итоговый View также поддерживает J1/J2/J3: физическая геометрия,
contact/lock status и безопасные actions не расходятся с домом.
## 4. Решения владельца
1. На независимой стене разрешены все существующие типы: `door`, `window`,
`gate`, `passage`. #132 не добавляет новый opening type.
2. Проём следует за host при движении перегородки.
3. Удаление перегородки с проёмами требует confirmation со списком и удаляет их
только после явного согласия.
4. Contact, lock, badges и actions идентичны одноимённому проёму в room wall;
меняется только host geometry.
5. Физический passage в перегородке участвует в световой геометрии по тем же
type-specific правилам, что passage в стене.
6. При точном совпадении saved partition со стеной комнаты они считаются одним
составным физическим барьером без дополнительного host chooser. Explicit host
остаётся partition; hosted interval прорезает оба совпадающих тела. После
движения partition вычисляемый room-wall cut исчезает, room config не
переписывается.
7. По #185 проём любого поддержанного типа не разрывает структурное ребро для
room-face detection. Контур через стену с проёмом замыкается, а проём и его
host сохраняются.
## 5. Scope
- явная persisted host identity для partition opening;
- placement/hover/drag/edit/delete door, window, gate и passage на partition;
- full-depth cut, jamb/tunnel/symbol geometry с толщиной partition;
- composite cut совпадающих partition + derived room wall без persisted room
provenance;
- движение/удаление host и единая Undo/Redo команда;
- structural wall axes для #185 отдельно от physical opening gaps; regression
контракта #173 для endpoint/T/X/collinear faces и реальных `open_spans`;
- Flat Plan/View/kiosk/static и hidden Isometric renderers;
- clean floor, Glow barrier/source guard и действующие sun semantics;
- existing opening HA state/actions/security;
- backend validation, import/export/optimization compatibility;
- i18n, user/canonical docs, unit/backend/smoke/golden/performance evidence.
## 6. Не входит в задачу
- новый opening type или новая семантика существующего `passage`;
- проёмы в unfinished room drafts или columns;
- несколько host segments на один opening;
- автоматическая конверсия старого room-wall opening в partition opening;
- новый способ завершения Walls chain либо auto-resume finished partition;
- новая light model либо изменение window/door/gate semantics;
- sun rays, источником которых становится внутреннее partition window;
- полная touch parity Plan editor;
- свободное удаление host с неявной потерей его openings.
## 7. Модель данных и host identity
`OpeningCfg` получает optional host discriminator:
```ts
host?: {
kind: 'partition';
id: string;
t: number;
}
```
- `host` отсутствует — current legacy/current room-wall association по `x/y/
angle/length`; старые конфиги не мигрируют.
- `kind='partition'` — `id` обязан ссылаться на partition того же space.
- Один finished Walls segment создаёт один partition id. Бывшая multi-segment
chain не становится составным host: соседние segments двигаются и удаляются
независимо.
- `t` — нормализованное положение центра вдоль направленного `a→b`, `[0,1]`.
- `x`, `y`, `angle` остаются materialized compatibility projection и обновляются
атомарно из host; новый frontend считает valid host+t authority.
- `length` остаётся normalized physical length и существующий dialog показывает
cm/in по `cell_cm`.
Если implementation выбирает flat fields вместо object, semantics и
compatibility остаются теми же. Host kind расширяем discriminated union, но #132
добавляет только `partition`.
Backend new writes проверяет: existing partition id, finite `t`, finite geometry,
length fits host с jamb safety margin и total limits. Referentially invalid full
config write отклоняется, чтобы старый client не мог тихо удалить host и оставить
opening.
## 8. Canonical resolved opening
Один pure resolver строит immutable `ResolvedOpeningHost` для всех consumers:
- kind/id;
- directed centreline and normalized unit vector;
- center from `a + t(b-a)`;
- angle modulo current opening convention;
- full length and host depth from partition `cm`;
- derived room-wall intervals, collinear with and covering the hosted opening
interval within the canonical wall epsilon;
- validity/orphan reason;
- adjacent floor samples/type-specific passage policy.
Placement preview, symbol, body/composite cut, tunnel, hit test, light barriers,
Iso panel, move/delete и tests используют этот resolver. Запрещён render-only
nearest-wall fallback для explicit partition host. Coincident room-wall coverage
— вычисляемая projection valid partition host, а не второй persisted host.
Если endpoints хоста представлены в обратном порядке при canonical rewrite,
rewrite одновременно заменяет `t → 1-t`, сохраняя physical center и hinge/flip
orientation. Простое rigid translation сохраняет `t`, length и flip.
## 9. Placement UX
Активный инструмент «Проём» рассматривает два host kind:
- solid derived room-wall intervals;
- saved independent partition segments.
Hover preview показывает тот же translucent door/window/gate и привязывается к
partition axis; `passage` использует свою действующую symbol-less preview. Center
квантуется по действующему wall-bound grid step, length целиком помещается между
endpoints. Click повторно разрешает candidate и открывает существующий opening
dialog.
При близких candidates выбирается минимальная perpendicular distance, затем
полное покрытие opening length. Точное collinear overlap room-wall/partition,
покрывающее reserved interval, считается одним composite barrier и выбирает
explicit partition host без дополнительного dialog. Не совпадающая геометрически
tie остаётся неразрешимой и отклоняется с локализованной причиной. Existing
opening hit имеет приоритет над placement.
Toast `opening_no_wall` обновляется до «стены или перегородки». Virtual room span,
room draft и column остаются невалидными host. Touch placement — best effort;
pointercancel/multi-touch не сохраняют draft по safety floor.
## 10. Толщина, cut и symbol
Partition body строится из centreline и `cm`, half-depth с каждой стороны.
Hosted opening:
- вырезает body на полную глубину ровно по своему reserved interval;
- получает два flat jamb returns на концах cut;
- door swing начинается с выбранной face по `flip_v`;
- window glass располагается в середине partition depth;
- gate leaves используют current compact gate geometry;
- passage остаётся symbol-less full-depth cut по контракту #157;
- overlapping hosted openings не могут резервировать один interval дважды;
- junction patches после cut не должны заново закрыть passage.
Для zero/invalid thickness new partition невозможна по current validation
(1–100 cm). Malformed legacy host fail-dark: cut не применяется. Partition cut
выполняется по explicit host identity до единого physical union. Он дополнительно
вырезает только derived room-wall bodies, которые collinear и покрывают тот же
hosted interval в пределах canonical epsilon. Случайное пересечение, nearby
parallel wall или второй independent body не прорезаются.
## 11. Room topology и bug #185
Room-face graph получает два разных представления одной архитектуры:
- **physical/presentation:** opening interval вырезан из кладки и остаётся
проходом для изображения, hit geometry и type-specific света;
- **structural room topology:** axis валидной стены непрерывен через
door/window/gate/passage, потому что проём не отменяет принадлежность стены
контуру комнаты.
`buildPlanSnapGeometry()` либо следующий pure collector обязан сохранить
structural room/partition segment через opening interval для face traversal, не
возвращая этот interval в physical body. Действующий opening cut может оставаться
snap boundary/measure input, но не удаляет connectivity для room detection.
Контракт применяется одинаково к legacy room-wall opening и новому
partition-hosted opening. Контур, который последним segment опирается на такую
стену endpoint/T/X/collinear-примыканием, предлагает room по обычному #173 flow;
opening config, host, entities и materialized geometry не мутируют. `open_spans`
и отсутствующий/invalid host остаются настоящими gaps. Пассивные HA state ticks,
редактирование opening state и render не запускают room dialog.
## 12. Floor fill и tunnel
Partition не является границей room ownership. После вычитания hosted opening
видна уже существующая clean-floor/fill geometry под body. Дополнительный
нейтральный paper tunnel не рисуется.
Если partition пересекает две room fills, каждый side сохраняет свою canonical
fill ownership; cut не смешивает alpha и не создаёт новую room. Glow/sun layers
остаются выше base fill. Opening symbol скрывается по `hide_openings`, но physical
cut, HA state и barrier semantics остаются активны, как у room-wall opening.
## 13. Glow и light barriers
Решение владельца «проём пропускает свет» реализуется через type-specific
существующий контракт:
- door/gate/passage — transparent passage только там, где по обе стороны host
есть floor; `passage` не получает state или створку;
- exterior/no-floor с одной стороны остаётся opaque, чтобы Glow не уходил наружу;
- window сохраняет текущую политику: glass виден, но indoor Glow через него не
проходит;
- virtual boundary к partition не относится;
- source внутри valid interior door/gate/passage допустим; source внутри window
tunnel или invalid/orphan cut fail-dark.
Barrier geometry получает joined partition body **после вычитания только его
hosted passages**. Cache fingerprint включает host id/t, partition endpoints/cm,
opening length/type и geometry validity. HA-only state tick не rebuild-ит barriers.
## 14. Sun semantics
Partition window не является exterior room-boundary window и не создаёт новый
sun wedge. Existing exterior windows и their wedges сохраняются. Если canonical
sun/physical clipping использует independent body set, hosted cut не должен
восстанавливать opaque partition поверх уже разрешённого passage; никаких новых
direction/source rules #132 не вводит.
## 15. HA state, actions и security
Contact/invert, lock, badges, open amount, info card, lock/unlock confirmation и
`resolveHaBindingStatus()` не зависят от host kind.
- Door/gate lock action остаётся единственной sanctioned opening surface.
- Marker tombstone не отключает exact opening entity reference.
- Disabled/orphaned/unverified entity не выполняет service call.
- Window не получает lock control, если current type contract его не допускает.
- Passage остаётся inert: без contact/lock/invert/flip, badge, info card и service
call, по тому же контракту #157 на room wall.
- Перемещение host не меняет entity ids и runtime state.
Новый action resolver или отдельный partition status model запрещён.
## 16. Move, edit, delete и Undo
### Move/edit
Rigid drag одного partition segment обновляет endpoints и materialized
`x/y/angle` всех его hosted openings в одном preview/commit. `t`, length,
entities and flips сохраняются. Соседние segments бывшей Walls chain не
двигаются. Одна Undo/Redo command восстанавливает partition и все hosted
projections, включая computed composite cut.
Editor operation, которая сократила бы host меньше opening+jamb margin, блокирует
commit с причиной. Import/legacy invalid geometry не clamped молча.
### Delete
Если hosted openings нет, действует current delete path. Если есть:
1. dialog перечисляет openings в порядке `t`: локализованный type + length;
2. Cancel ничего не меняет;
3. Confirm атомарно удаляет partition и ровно его hosted openings;
4. одна Undo восстанавливает host и весь список с entities/geometry;
5. attachments отсутствуют у opening model, file cleanup не вызывается.
Удалять hosted openings без confirmation через generic cleanup запрещено.
## 17. Orphan и failure policy
Explicit partition opening с отсутствующим/invalid host:
- не вырезает physical body и не создаёт transparent light path;
- не рендерится как рабочий opening в View/kiosk/static/Iso;
- в Plan editor показывается диагностический orphan affordance в materialized
`x/y`, позволяющий удалить или перепривязать объект;
- entity action из orphan affordance не выполняется;
- production console не спамится; diagnostic reason доступна test/support hook.
Boolean failure по одному cut возвращает непрорезанный opaque partition
(fail-dark), не удаляет config и не делает все independent bodies прозрачными.
## 18. Migration и compatibility
- Старые openings без `host` читаются и пишутся по current room-wall contract.
- Existing room-wall opening около partition не начинает прорезать partition.
- Room-wall opening, существовавший до #132, сохраняет implicit host и участвует
в structural room topology по #185 без migration/write-on-read.
- New optional fields добавляются в frontend/backend schema и
`docs/CONFIG-COMPATIBILITY.md`/field registry.
- Export/import/backup сохраняют host object и referential order; counts остаются
в общем числе openings.
- Align/optimize переносит partition и hosted opening согласованно, не re-snaps
explicit host к nearby room wall.
- Older frontend may preserve unknown host fields, but backend referential
validation prevents destructive orphan write; backward visual support не
обещается до версии, реализующей #132.
Schema migration существующих данных не нужна: capability появляется только у
новых/явно перепривязанных openings.
## 19. i18n и accessibility
Новые en/ru strings:
- `toast.opening_no_wall` — обновлённая guidance/error «стена или независимая
стена»;
- `opening.host_partition` — host kind «Независимая стена» в
properties/diagnostic;
- `confirm.delete_partition_openings_title`,
`confirm.delete_partition_openings_body` и
`confirm.delete_partition_openings_item` — confirmation и строки списка с
параметрами `{count}`, `{type}`, `{length}`;
- `opening.partition_orphan` и `opening.rebind_partition` — orphan reason и
доступное действие перепривязки по §17.
Opening dialog, list и confirmation используют существующий `hp-dialog`, focus
trap, Escape и restore focus. List доступен screen reader; confirmation сообщает
точное количество. Canvas preview не является единственным объяснением host:
properties/diagnostic имеет текстовое accessible name.
**Touch editor: best effort / intentionally degraded.** Desktop остаётся
reference surface. На touch обязательны только safety floor: tap повторно решает
candidate на commit, а pinch/second pointer/pointercancel не создают opening, не
удаляют host и не завершают Walls chain. Hover parity не обещается; View и kiosk
не получают новых interactions.
## 20. Acceptance criteria
1. **AC1 — Walls workflow и placement.** После завершения multi-segment цепочки
«Стены» каждый saved segment является отдельным candidate host. На partition
размещаются door/window/gate/passage; active draft, column и virtual span не
принимаются. **Доказательство:** resolver/lifecycle unit + desktop smoke.
2. **AC2 — geometry и composite overlap.** Opening cut full-depth с корректными
jamb/symbol для 1/15/100 cm и diagonal partition. При точном покрывающем
room-wall overlap partition выбирается explicit host и весь composite barrier
прорезается; crossing/nearby/второй independent body не затрагиваются.
**Доказательство:** geometry units + reviewed golden.
3. **AC3 — host lifecycle.** Rigid move одного segment переносит его openings и
убирает computed room-wall cut; delete показывает точный список и атомарно
удаляет после Confirm; Undo/Redo восстанавливает host/openings/composite.
**Доказательство:** command units + browser smoke.
4. **AC4 — light.** Interior door/gate/passage пропускают Glow через полный
composite cut; exterior/no-floor варианты и window остаются opaque по current
policy; invalid host/boolean failure fail-dark. **Доказательство:** light
visibility units + smoke/golden.
5. **AC5 — #185 room closure.** Room-face detection считает wall axis
непрерывным через door/window/gate/passage как на legacy room wall, так и на
partition host. Endpoint/T/X/collinear closure предлагает room и сохраняет
opening bit-equivalent; `open_span` остаётся gap. **Доказательство:** pure
topology unit + production-bundle smoke, который краснеет на `origin/dev`.
6. **AC6 — no passive topology mutation.** HA state, opening edit/render,
reload и existing closed face не показывают room dialog и не меняют geometry;
только последний accepted Walls segment может инициировать предложение по
#173. **Доказательство:** wall-face graph unit + smoke.
7. **AC7 — render parity.** Plan/View/kiosk/static/Iso используют один resolved
host и не расходятся по center/angle/depth/open state; passage остаётся
symbol-less. **Доказательство:** cross-render smoke + golden.
8. **AC8 — HA/security parity.** Door/window/gate contact/lock/badge/actions
совпадают с room-wall contract, secure/disabled guards не меняются; passage
остаётся inert. **Доказательство:** unit matrix + smoke.
9. **AC9 — compatibility.** Legacy openings unchanged; host fields survive
save/export/import/optimize; invalid reference rejected or fail-dark; schema
принимает ровно четыре уже существующих type. **Доказательство:**
frontend/backend round-trip tests.
10. **AC10 — accessibility/touch safety.** Dialog/confirmation keyboard
доступны, touch cancel/multi-touch не создают opening и не завершают Walls
chain. **Доказательство:** browser smoke.
11. **AC11 — cache/performance.** HA tick и pointermove не rebuild-ят host,
composite или face topology; caches bounded и инвалидируются geometry host /
opening type/length. **Доказательство:** counters + performance smoke.
12. **AC12 — regression #173/#157.** Walls finish/no-auto-resume, room queue,
passage field restrictions, room-wall opening placement и планы без hosted
openings остаются совместимыми. **Доказательство:** existing targeted suites
+ named #132 smoke.
## 21. План автотестов
### Unit
- host serialization/resolution, t and reversed endpoints;
- room-wall vs partition candidate, composite/non-composite tie and length fit;
- horizontal/vertical/diagonal, 1/15/100 cm cut and jambs;
- overlap reservation, junction patch, coincident derived wall vs independent body;
- move/delete/Undo atomic snapshots;
- interior/exterior/window/passage light policy and fail-dark;
- structural-vs-physical collector: all four openings preserve room-face axes,
while `open_span` remains a gap; endpoint/T/X/collinear #185 matrix;
- only the latest accepted segment may cause room proposal; opening/HA updates do not;
- orphan, missing host and cache fingerprint;
- HA state/action host-kind parity.
### Backend
- host discriminator/id/t and referential validation;
- config size/count limits, export/import/unknown fields;
- canonical `passage` field restrictions from #157 with partition host present;
- malformed/missing partition rejection;
- full HA harness authority — Linux CI/WSL.
### Browser smoke
- finish multi-segment Walls chain; place/edit/drag door, window, gate and passage
on one resulting partition without resuming the chain;
- host move, delete Cancel/Confirm and Undo/Redo;
- contact opens, lock confirmation/action, hide_openings;
- Glow across interior passage and blocked by window/exterior passage;
- reproduce #185 on legacy room wall and hosted partition for all four types,
including T closure; verify `open_span` still blocks closure and opening config
is unchanged;
- exact composite overlap selects partition host without an extra chooser and
leaves no opaque wall layer across the opening;
- Plan/View/kiosk/static/Iso parity;
- touch cancel/pinch safety and keyboard confirmation;
- import/optimize round trip.
### Golden
- thick/diagonal partition with all four types, light/dark;
- coincident room-wall/partition composite before and after hosted cut;
- Glow before/after door passage and opaque window;
- hidden Iso panels/cuts and static-card parity;
- expected diffs accepted only from reviewed full Linux artifact.
### Performance
- cold structural build within current large-house budget;
- HA state tick geometry build count 0;
- repeated render cache growth 0/bounded caps;
- no per-opening repeated full wall index build.
## 22. Затронутые поверхности
- `OpeningCfg`, backend validation and config compatibility registry;
- opening placement/resolver, wall-thickness/physical geometry and command stack;
- `plan-snap-overlay`/wall-face structural collector and #173 room queue;
- Flat/static/Iso opening renderers;
- Glow/light and current sun physical consumers;
- Plan dialog/i18n, import/export/align/optimize;
- unit/backend/smoke/golden/performance fixtures;
- `docs/ARCHITECTURE.md`, `docs/CANVAS.md`, `docs/UX-MODES.md`,
`docs/WALL-THICKNESS.md`, `docs/LIGHT.md`, `docs/SUN.md`,
`docs/USER-GUIDE.ru.md`, `docs/TESTING.md`.
## 23. Риски и откат
| Риск | Мера |
| --- | --- |
| Host inference режет не ту стену | explicit partition id+t |
| Composite overlap режет nearby/crossing body | collinear full-interval coverage + negative units |
| Junction union закрывает passage | cut-aware physical union tests |
| #185 возвращает физическую кладку в проём | отдельные structural и physical projections |
| Opening начинает пассивно открывать room dialog | topology-delta и no-passive smoke #173 |
| Delete теряет entities | atomic command snapshot + confirmation |
| Light проходит через window/outside | existing type/floor-side policy |
| Older writer оставляет orphan | backend referential validation |
| Geometry cost растёт per opening | single immutable host/index snapshot |
Откат запрещает создание новых partition-host openings и возвращает body union
after room cuts. Уже сохранённые host objects должны либо оставаться
read-preserved/diagnostic, либо требовать downgrade warning; автоматически
преобразовывать их в room-wall openings нельзя.
## 24. Release-артефакты
Implementation commit имеет `User-Visible: yes` и одновременно обновляет:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — Walls finish, placement/types/move/delete/light и
closure через стену с проёмом;
- `docs/ARCHITECTURE.md`, `docs/CANVAS.md`, `docs/UX-MODES.md`,
`docs/WALL-THICKNESS.md`, `docs/LIGHT.md`, `docs/SUN.md`,
`docs/CONFIG-COMPATIBILITY.md`;
- `docs/TESTING.md`.
Нужны reviewed Flat/Iso/Glow golden artifacts, targeted browser/performance
reports and exact Linux backend evidence. Visual baselines принимаются только
через `golden:accept -- --reviewed` по полному Linux artifact.
## 25. Принятые технические предположения
- partition host хранится explicit id+t, materialized x/y/angle сохраняются;
- room-wall openings остаются implicit для backward compatibility;
- exact covering room-wall/partition overlap выбирает partition host как
owner-approved composite; прочие ambiguous ties отклоняются;
- structural room topology may use uncut wall axes, while all physical/light
consumers continue using type-specific cuts; shared persisted geometry не
создаётся;
- partition window не становится exterior sun source;
- invalid host/cut fail-dark and hidden from ordinary View;
- точная форма discriminator fields может меняться на ревью без изменения
persisted identity/lifecycle semantics.
+10 -1
View File
@@ -1,4 +1,4 @@
# Спецификации задач P1 и P2
# Спецификации задач
Актуально на 2026-08-17.
@@ -86,8 +86,11 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) |
| [#101](https://github.com/Matysh/houseplan-card/issues/101) Плавный переход View ↔ редакторы | [101-view-editor-transition.md](101-view-editor-transition.md) |
| [#107](https://github.com/Matysh/houseplan-card/issues/107) Переключение виртуального источника света «Всегда» | [107-virtual-light-toggle.md](107-virtual-light-toggle.md) |
| [#113](https://github.com/Matysh/houseplan-card/issues/113) Optional-контракт SpaceModel | [113-optional-space-model.md](113-optional-space-model.md) |
| [#117](https://github.com/Matysh/houseplan-card/issues/117) Registry-less entity у проёма | [117-registryless-opening-entity.md](117-registryless-opening-entity.md) |
| [#122](https://github.com/Matysh/houseplan-card/issues/122) Изометрический режим Stage 2: скрытый режим и визуальная полировка | [122-isometric-stage2.md](122-isometric-stage2.md) |
| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |
| [#132](https://github.com/Matysh/houseplan-card/issues/132) Проёмы в независимых стенах (+ bug [#185](https://github.com/Matysh/houseplan-card/issues/185)) | [132-partition-openings.md](132-partition-openings.md) |
| [#137](https://github.com/Matysh/houseplan-card/issues/137) Узлы и линии привязки в редакторе Плана | [137-plan-snap-overlay.md](137-plan-snap-overlay.md) |
| [#141](https://github.com/Matysh/houseplan-card/issues/141) Бесшовные стыки перегородок и открытых контуров | [141-wall-junctions.md](141-wall-junctions.md) |
| [#157](https://github.com/Matysh/houseplan-card/issues/157) Тип проёма «Открытый проём» | [157-open-passage.md](157-open-passage.md) |
@@ -97,6 +100,12 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#174](https://github.com/Matysh/houseplan-card/issues/174) Связанный виртуальный источник следует реальному контроллеру | [174-linked-virtual-light-controller.md](174-linked-virtual-light-controller.md) |
| [#178](https://github.com/Matysh/houseplan-card/issues/178) Выбор сущности для действия «Переключить состояние» | [178-toggle-entity.md](178-toggle-entity.md) |
## P3
| Issue | ТЗ |
|---|---|
| [#103](https://github.com/Matysh/houseplan-card/issues/103) Состояния в Toggle confirmation | [103-toggle-confirmation-state.md](103-toggle-confirmation-state.md) |
## Правило актуализации
При изменении продуктового решения сначала обновляется соответствующее issue, затем ТЗ. Реализация не считается завершённой только по наличию кода: нужны выполненные acceptance criteria, предусмотренная ТЗ проверка и актуальный статус Project v2.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.65.0-beta.1",
"version": "1.65.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.65.0-beta.1",
"version": "1.65.0-beta.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.65.0-beta.1",
"version": "1.65.0-beta.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+28 -5
View File
@@ -40,6 +40,17 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'empty-space-cleanup-disabled',
guard: 'node demo/smoke_optional_space_model.mjs',
because: 'удаление последнего пространства обязано завершать жесты, редакторы и отложенную запись; '
+ 'смок дважды переводит живую карточку в пустой план и проверяет реальный lifecycle cleanup',
patches: [{
file: 'src/houseplan-card.ts',
find: 'if (this._emptySpaceStateActive) return;',
replace: 'if (empty) return;',
}],
},
{
id: 'continuity-long-resume-noop',
guard: 'node demo/smoke_visual_continuity.mjs',
@@ -58,8 +69,20 @@ export const MUTANTS = [
+ 'проходить через дверь; смок обязан это увидеть по освещённому полу за проёмом',
patches: [{
file: 'src/houseplan-card.ts',
find: 'cuts.push([o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy]);',
replace: 'cuts.push([o.rx, o.ry, o.rx, o.ry]);',
find: 'cuts.push([opening.x - dx, opening.y - dy, opening.x + dx, opening.y + dy]);',
replace: 'cuts.push([opening.x, opening.y, opening.x, opening.y]);',
}],
},
{
id: 'registryless-opening-requires-registry-row',
guard: 'node demo/smoke_registryless_opening.mjs',
because: 'возврат требования Entity Registry row снова делает выбранную YAML-сущность '
+ 'невидимой в painted frame; smoke обязан доказать contact, lock и frozen-snapshot parity',
patches: [{
file: 'src/ha-binding-status.ts',
find: ' return !!entityId\n && !!projectedHass?.states?.[entityId];',
replace: ' return !!entityId\n && !!projectedHass?.entities?.[entityId]\n'
+ ' && !!projectedHass?.states?.[entityId];',
}],
},
{
@@ -71,7 +94,7 @@ export const MUTANTS = [
file: 'src/houseplan-card.ts',
// Physical bodies now enter the canonical masonry union. Mutating only
// its legacy fallback is a no-op on every valid thick-wall fixture.
find: 'this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, physical,',
find: 'this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,',
replace: 'this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, [],',
}],
},
@@ -352,8 +375,8 @@ export const MUTANTS = [
+ 'расставленный маркер; smoke сравнивает закреплённые координаты до и после Save',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (!replacingRemoved && prevPos && prevPos.s === targetSpace) {',
replace: ' if (!replacingRemoved && prevPos && prevPos.s === targetSpace && !roomChanged) {',
find: ' if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId) {',
replace: ' if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId && !roomChanged) {',
}],
},
];
+35 -1
View File
@@ -240,7 +240,20 @@ export function alignAllToGrid(
const b = [snapN(p.b[0]), snapN(p.b[1])];
let d = Math.max(dist(p.a[0], p.a[1], a[0], a[1]),
dist(p.b[0], p.b[1], b[0], b[1]));
if (dist(a[0], a[1], b[0], b[1]) > EPS) { p.a = a; p.b = b; }
const snappedLength = dist(a[0], a[1], b[0], b[1]);
const hostedFit = (sp.openings || [])
.filter((opening: any) => opening.host?.kind === 'partition'
&& opening.host.id === p.id)
.every((opening: any) => {
const length = Number(opening.length);
const t = Number(opening.host.t);
const along = t * snappedLength;
return Number.isFinite(length) && length > 0
&& Number.isFinite(t) && t >= 0 && t <= 1
&& along - length / 2 >= -EPS
&& along + length / 2 <= snappedLength + EPS;
});
if (snappedLength > EPS && hostedFit) { p.a = a; p.b = b; }
else d = 0;
note(d, cell, sid);
}
@@ -281,6 +294,27 @@ export function alignAllToGrid(
// exactly where it is rather than teleported across the plan.
for (const o of sp.openings || []) {
total++;
if (o.host?.kind === 'partition') {
const partition = (sp.partitions || []).find((item: any) => item.id === o.host.id);
const t = Number(o.host.t);
if (!partition || !Number.isFinite(t) || t < 0 || t > 1) continue;
const dx = partition.b[0] - partition.a[0];
const dy = partition.b[1] - partition.a[1];
if (Math.hypot(dx, dy) <= EPS) continue;
const nx = partition.a[0] + dx * t;
const ny = partition.a[1] + dy * t;
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
if (angle >= 90) angle -= 180;
else if (angle < -90) angle += 180;
const raw = Number(o.angle);
const turned = !(Number.isFinite(raw) && raw === angle);
const d = openingShift(o.x, o.y, Number.isFinite(raw) ? raw : angle,
Number(o.length) || 0, nx, ny, angle);
o.x = nx; o.y = ny; o.angle = angle;
if (turned) rotated++;
note(d, cell, sid, turned);
continue;
}
const q = snapToWall([o.x, o.y], sp.rooms || [], WALL_TOL,
{ step: GRID_STEP_N, length: Number(o.length) || 0 });
if (!q) continue;
+52
View File
@@ -766,6 +766,58 @@ export function toggleOperation(intent: ResolvedToggleIntent | null): ToggleOper
return intent.command ? { kind: 'ha-service', command: intent.command } : null;
}
export interface ToggleConfirmationFormatter {
/** Localized/formatted current state for one executable target. */
state: (target: ResolvedToggleTarget) => string;
current: (state: string) => string;
expected: (state: string) => string;
groupCurrent: (on: number, total: number) => string;
groupAllOn: () => string;
groupAllOff: () => string;
unavailable: (count: number) => string;
effect: (effect: Exclude<ToggleNextEffect, 'toggle'>) => string;
expectedByHa: () => string;
}
/**
* Accessible current/expected/skipped copy for an executable confirmation.
* Direction is read exclusively from `nextEffect`; this formatter never
* derives a command from a domain or from the current-state label.
*/
export function formatToggleConfirmation(
intent: ResolvedToggleIntent,
formatter: ToggleConfirmationFormatter,
): string[] {
if (!toggleOperation(intent) || !intent.nextEffect || !intent.targets.length) return [];
const isGroup = intent.kind === 'group';
const on = isGroup
? intent.targets.filter((target) => target.state === 'on').length
: 0;
const currentState = isGroup
? (on === 0 ? formatter.groupAllOff() : formatter.groupCurrent(on, intent.targets.length))
: formatter.state(intent.targets[0]);
let expectedState: string;
if (intent.nextEffect === 'toggle') {
expectedState = formatter.expectedByHa();
} else if (isGroup && intent.nextEffect === 'turn-on') {
expectedState = formatter.groupAllOn();
} else if (isGroup && intent.nextEffect === 'turn-off') {
expectedState = formatter.groupAllOff();
} else {
expectedState = formatter.effect(intent.nextEffect);
}
return [
formatter.current(currentState),
formatter.expected(expectedState),
...(intent.skippedTargets.length
? [formatter.unavailable(intent.skippedTargets.length)]
: []),
];
}
/** Stable confirmation identity; direction is deliberately re-resolved later. */
export function sameToggleOperationTargets(
a: ResolvedToggleIntent | null,
+5 -3
View File
@@ -532,15 +532,17 @@ export function openingEntityAvailable(
/**
* Frame-local counterpart of `openingEntityAvailable`. The caller supplies an
* immutable active-registry projection, so both the registry row and state
* must belong to the same painted frame. Registry-less render parity is #117.
* immutable active-registry projection. State presence in that projection is
* sufficient evidence for an exact opening reference: YAML entities without
* unique_id have no registry row, while explicit disabled/orphan rows have
* already had their states removed by `activeRegistryHass`. Never call this
* helper with raw live hass; every read must belong to one painted frame.
*/
export function renderOpeningEntityAvailable(
projectedHass: any,
entityId: string | null | undefined,
): boolean {
return !!entityId
&& !!projectedHass?.entities?.[entityId]
&& !!projectedHass?.states?.[entityId];
}
+801 -200
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -131,7 +131,13 @@
"opening.unlocked": "Unlocked",
"opening.state_unknown": "unavailable",
"opening.no_entities": "No sensors bound — a static symbol on the plan.",
"toast.opening_no_wall": "Click next to a room wall — openings sit on walls",
"toast.opening_no_wall": "Click next to a room wall or independent wall",
"opening.host_partition": "Independent wall",
"opening.partition_orphan": "The independent wall for this opening no longer exists",
"opening.rebind_partition": "Attach to another independent wall",
"confirm.delete_partition_openings_title": "Delete wall and openings?",
"confirm.delete_partition_openings_body": "This wall contains {count} opening(s). They will be deleted together.",
"confirm.delete_partition_openings_item": "• {type}, {length}",
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Shift — 45° steps · Esc/Ctrl+Z — undo a dot · closing an area offers a room",
"markup.hint_start": "click a grid dot to start a wall chain",
@@ -673,6 +679,21 @@
"confirm.tap_run": "Run \"{name}\"?",
"confirm.tap_toggle": "Toggle \"{name}\"?",
"confirm.tap_cover": "Open/close \"{name}\"?",
"confirm.current_state": "Current state: {state}",
"confirm.expected_state": "After switching: {state}",
"confirm.group_current": "on {on} of {total}",
"confirm.group_all_on": "all are on",
"confirm.group_all_off": "all are off",
"confirm.unavailable_targets": "Unavailable: {count}",
"confirm.expected_by_ha": "Home Assistant will determine the state",
"confirm.state_on": "On",
"confirm.state_off": "Off",
"confirm.state_open": "Open",
"confirm.state_closed": "Closed",
"confirm.state_opening": "Opening",
"confirm.state_closing": "Closing",
"confirm.state_stopped": "Stopped",
"confirm.state_unknown": "Unknown",
"toast.run_started": "Started: {name}",
"toast.run_target_missing": "Run target not found — check the device settings",
"toast.run_target_required": "Pick an automation, script or scene",
+22 -1
View File
@@ -131,7 +131,13 @@
"opening.unlocked": "Не заперто",
"opening.state_unknown": "недоступно",
"opening.no_entities": "Датчики не привязаны — статичный символ на плане.",
"toast.opening_no_wall": "Кликните рядом со стеной комнаты — проёмы ставятся на стены",
"toast.opening_no_wall": "Кликните рядом со стеной комнаты или независимой стеной",
"opening.host_partition": "Независимая стена",
"opening.partition_orphan": "Независимая стена этого проёма больше не существует",
"opening.rebind_partition": "Привязать к другой независимой стене",
"confirm.delete_partition_openings_title": "Удалить стену и проёмы?",
"confirm.delete_partition_openings_body": "В стене есть проёмы: {count}. Они будут удалены вместе со стеной.",
"confirm.delete_partition_openings_item": "• {type}, {length}",
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Shift — шаг 45° · Esc/Ctrl+Z — убрать точку · при замыкании будет предложена комната",
"markup.hint_start": "кликните точку сетки, чтобы начать цепочку стен",
@@ -673,6 +679,21 @@
"confirm.tap_run": "Запустить «{name}»?",
"confirm.tap_toggle": "Переключить «{name}»?",
"confirm.tap_cover": "Открыть/закрыть «{name}»?",
"confirm.current_state": "Текущее состояние: {state}",
"confirm.expected_state": "После переключения: {state}",
"confirm.group_current": "включено {on} из {total}",
"confirm.group_all_on": "все включены",
"confirm.group_all_off": "все выключены",
"confirm.unavailable_targets": "Недоступно: {count}",
"confirm.expected_by_ha": "Состояние определит Home Assistant",
"confirm.state_on": "Включено",
"confirm.state_off": "Выключено",
"confirm.state_open": "Открыто",
"confirm.state_closed": "Закрыто",
"confirm.state_opening": "Открывается",
"confirm.state_closing": "Закрывается",
"confirm.state_stopped": "Остановлено",
"confirm.state_unknown": "Неизвестно",
"toast.run_started": "Запущено: {name}",
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
+55 -2
View File
@@ -16,6 +16,10 @@ export interface OpeningPlacementTarget {
b: [number, number];
physicalHalfWidth: number;
sourceOrder: number;
/** Explicit independent-wall owner; room-wall targets leave it absent. */
partitionHost?: { kind: 'partition'; id: string };
/** More than one coincident independent wall cannot be selected safely. */
ambiguousPartitionHost?: boolean;
}
export interface OpeningPlacementMeasureGeometry {
@@ -39,6 +43,7 @@ export interface OpeningPlacementCore {
angle: number;
renderedLength: number;
target: OpeningPlacementTarget;
host?: { kind: 'partition'; id: string; t: number };
measure: OpeningPlacementMeasureGeometry;
}
@@ -102,7 +107,9 @@ function atomicSegmentKey(a: readonly number[], b: readonly number[]): string {
* into OpeningCfg, whose compatibility contract remains absolute x/y/angle.
*/
export function openingPlacementTargets(
intervals: readonly WallInterval[],
intervals: readonly (WallInterval & {
partitionHost?: { kind: 'partition'; id: string };
})[],
): OpeningPlacementTarget[] {
const targets = new Map<string, OpeningPlacementTarget>();
intervals.forEach((interval, sourceOrder) => {
@@ -114,6 +121,11 @@ export function openingPlacementTargets(
if (previous) {
previous.physicalHalfWidth = Math.max(previous.physicalHalfWidth, interval.half || 0);
previous.sourceOrder = Math.min(previous.sourceOrder, sourceOrder);
if (interval.partitionHost) {
if (previous.partitionHost && previous.partitionHost.id !== interval.partitionHost.id)
previous.ambiguousPartitionHost = true;
else previous.partitionHost = interval.partitionHost;
}
return;
}
targets.set(segmentKey, {
@@ -122,6 +134,7 @@ export function openingPlacementTargets(
b: ends.b,
physicalHalfWidth: Math.max(0, interval.half || 0),
sourceOrder,
...(interval.partitionHost ? { partitionHost: interval.partitionHost } : {}),
});
});
return [...targets.values()];
@@ -218,6 +231,10 @@ export function resolveOpeningPlacement(
);
return { target, ...p, envelope };
}).filter((item) => item.distance <= item.envelope + 1e-9)
// Room-wall compatibility keeps its historical wide-gate behaviour, but
// an explicit partition host must be able to own the complete interval.
.filter((item) => !item.target.partitionHost
|| input.renderedLength <= item.length + 1e-9)
.filter((item) => !pointerInsideCollinearOpenSpan(
input.pointer,
item.target,
@@ -226,9 +243,42 @@ export function resolveOpeningPlacement(
Math.max(1e-9, Math.min(input.baseTolerance, input.gridStep * 0.04)),
))
.sort(targetCompare);
const picked = eligible[0];
let picked = eligible[0];
if (!picked) return null;
// A room wall and an independently persisted wall may cover the same axis
// without sharing identical endpoints. In that composite case the stable
// partition id is the only useful persisted owner. A genuinely crossing
// partition tie is not a composite and must not be resolved by lexical key.
const tied = eligible.filter((item) =>
Math.abs(item.distance - picked.distance) <= 1e-9
&& Math.abs(item.perpendicular - picked.perpendicular) <= 1e-9);
const hosted = tied.filter((item) => item.target.partitionHost);
if (hosted.length) {
const hostIds = new Set(hosted.map((item) => item.target.partitionHost!.id));
if (hostIds.size !== 1 || hosted.some((item) => item.target.ambiguousPartitionHost)) return null;
const hostPick = hosted[0];
const hx = hostPick.target.b[0] - hostPick.target.a[0];
const hy = hostPick.target.b[1] - hostPick.target.a[1];
const hLength = Math.hypot(hx, hy);
const hux = hx / hLength, huy = hy / hLength;
const composite = tied.every((item) => {
const tx = item.target.b[0] - item.target.a[0];
const ty = item.target.b[1] - item.target.a[1];
const tLength = Math.hypot(tx, ty);
const tux = tx / tLength, tuy = ty / tLength;
const parallel = Math.abs(hux * tuy - huy * tux) <= 1e-6;
const offset = Math.abs(
(item.target.a[0] - hostPick.target.a[0]) * huy
- (item.target.a[1] - hostPick.target.a[1]) * hux,
);
return parallel && offset <= 1e-9;
});
if (!composite) return null;
picked = hostPick;
}
if (picked.target.ambiguousPartitionHost) return null;
const { target, length } = picked;
const dx = target.b[0] - target.a[0], dy = target.b[1] - target.a[1];
const ux = dx / length, uy = dy / length;
@@ -273,6 +323,9 @@ export function resolveOpeningPlacement(
angle,
renderedLength: input.renderedLength,
target,
...(target.partitionHost ? {
host: { ...target.partitionHost, t: length > 0 ? along / length : 0 },
} : {}),
measure: {
labels: [
{ distance: sideA, midpoint: midpointA },
+194
View File
@@ -0,0 +1,194 @@
/** Pure host resolution shared by editor, renderers and backend-facing projections. */
import { wallCmToUnits, type WallInterval } from './wall-thickness';
import type {
OpeningCfg, PartitionCfg, PartitionOpeningHost,
} from './types';
export type PartitionOpeningOrphanReason =
| 'invalid-host'
| 'missing-partition'
| 'invalid-position'
| 'invalid-length'
| 'does-not-fit';
export interface ResolvedPartitionOpening {
opening: OpeningCfg;
host: PartitionOpeningHost;
partition: PartitionCfg;
center: [number, number];
angle: number;
length: number;
depth: number;
t: number;
axis: { a: [number, number]; b: [number, number]; ux: number; uy: number; length: number };
}
export interface PartitionOpeningResolution {
resolved: ResolvedPartitionOpening | null;
reason: PartitionOpeningOrphanReason | null;
}
const finitePoint = (point: readonly number[] | null | undefined): point is readonly [number, number] =>
!!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
/** Resolve explicit host identity. There is intentionally no nearest-wall fallback. */
export function resolvePartitionOpening(
opening: OpeningCfg,
partitions: readonly PartitionCfg[],
lengthScale = 1,
cellCm = 5,
gridPitch = 5,
jambMargin = 0,
): PartitionOpeningResolution {
const host = opening.host;
if (!host || host.kind !== 'partition' || typeof host.id !== 'string' || !host.id)
return { resolved: null, reason: 'invalid-host' };
const partition = partitions.find((item) => item.id === host.id);
if (!partition) return { resolved: null, reason: 'missing-partition' };
if (!finitePoint(partition.a) || !finitePoint(partition.b) || !Number.isFinite(host.t)
|| host.t < 0 || host.t > 1) return { resolved: null, reason: 'invalid-position' };
const dx = partition.b[0] - partition.a[0];
const dy = partition.b[1] - partition.a[1];
const axisLength = Math.hypot(dx, dy);
const length = Number(opening.length) * lengthScale;
if (!(axisLength > 1e-9) || !(length > 0) || !Number.isFinite(length))
return { resolved: null, reason: 'invalid-length' };
const along = host.t * axisLength;
if (along - length / 2 < jambMargin - 1e-9
|| along + length / 2 > axisLength - jambMargin + 1e-9)
return { resolved: null, reason: 'does-not-fit' };
const ux = dx / axisLength, uy = dy / axisLength;
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
if (angle >= 90) angle -= 180;
else if (angle < -90) angle += 180;
return {
reason: null,
resolved: {
opening,
host,
partition,
center: [partition.a[0] + dx * host.t, partition.a[1] + dy * host.t],
angle,
length,
depth: wallCmToUnits(partition.cm, cellCm, gridPitch),
t: host.t,
axis: {
a: [partition.a[0], partition.a[1]],
b: [partition.b[0], partition.b[1]],
ux, uy, length: axisLength,
},
},
};
}
export function partitionOpeningCut(resolved: ResolvedPartitionOpening): {
hostId: string; a: [number, number]; b: [number, number]; depth: number;
} {
const { center, length, axis, host } = resolved;
const half = length / 2;
return {
hostId: host.id,
a: [center[0] - axis.ux * half, center[1] - axis.uy * half],
b: [center[0] + axis.ux * half, center[1] + axis.uy * half],
depth: resolved.depth,
};
}
export function partitionOpeningFace(
resolved: ResolvedPartitionOpening,
flipV = false,
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
const side = (flipV ? 1 : -1) as -1 | 1;
const half = resolved.depth / 2;
return {
ox: -resolved.axis.uy * side * half,
oy: resolved.axis.ux * side * half,
cm: resolved.partition.cm,
side,
};
}
/** Candidate intervals for the existing room-wall placement resolver. */
export function partitionPlacementIntervals(
partitions: readonly PartitionCfg[], cellCm: number, gridPitch: number,
): Array<WallInterval & { partitionHost: { kind: 'partition'; id: string } }> {
return partitions.flatMap((partition) => {
if (!finitePoint(partition.a) || !finitePoint(partition.b)
|| Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]) <= 1e-9)
return [];
return [{
roomId: '',
a: [partition.a[0], partition.a[1]],
b: [partition.b[0], partition.b[1]],
key: `partition:${partition.id}`,
kind: 'outer' as const,
cm: partition.cm,
open: false,
half: wallCmToUnits(partition.cm, cellCm, gridPitch) / 2,
partitionHost: { kind: 'partition' as const, id: partition.id },
}];
});
}
/** Exact collinear coverage, used only for a computed composite room-wall cut. */
export function partitionOpeningHasCompositeRoomWall(
resolved: ResolvedPartitionOpening,
intervals: readonly WallInterval[],
epsilon: number,
): boolean {
const { center, length, axis } = resolved;
const half = length / 2;
const coverage = intervals.flatMap((interval) => {
if (!interval.kind || interval.open) return [];
const dx = interval.b[0] - interval.a[0], dy = interval.b[1] - interval.a[1];
const span = Math.hypot(dx, dy);
if (!(span > epsilon)) return [];
const ux = dx / span, uy = dy / span;
if (Math.abs(axis.ux * uy - axis.uy * ux) > 1e-6) return [];
const lineDistance = Math.abs(
(center[0] - interval.a[0]) * uy - (center[1] - interval.a[1]) * ux,
);
if (lineDistance > epsilon) return [];
const along = (point: readonly number[]) =>
(point[0] - center[0]) * axis.ux + (point[1] - center[1]) * axis.uy;
const a = along(interval.a), b = along(interval.b);
const lo = Math.max(-half, Math.min(a, b));
const hi = Math.min(half, Math.max(a, b));
return hi >= lo - epsilon ? [[lo, hi] as [number, number]] : [];
});
if (!coverage.length) return false;
coverage.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
let reached = -half;
for (const [lo, hi] of coverage) {
if (lo > reached + epsilon) return false;
reached = Math.max(reached, hi);
if (reached >= half - epsilon) return true;
}
return false;
}
export function hostedOpeningIntervalsOverlap(
candidate: ResolvedPartitionOpening,
others: readonly ResolvedPartitionOpening[],
epsilon = 1e-9,
): boolean {
const half = candidate.length / (2 * candidate.axis.length);
const lo = candidate.t - half, hi = candidate.t + half;
return others.some((other) => {
if (other.host.id !== candidate.host.id || other.opening.id === candidate.opening.id) return false;
const otherHalf = other.length / (2 * other.axis.length);
return Math.max(lo, other.t - otherHalf) < Math.min(hi, other.t + otherHalf) - epsilon;
});
}
/** Update compatibility fields after a host move without changing host semantics. */
export function materializePartitionOpening(
opening: OpeningCfg, resolved: ResolvedPartitionOpening, coordScale: number,
): OpeningCfg {
return {
...opening,
x: resolved.center[0] / coordScale,
y: resolved.center[1] / coordScale,
angle: resolved.angle,
};
}
+60 -2
View File
@@ -105,6 +105,49 @@ export interface PhysicalBodySet extends PhysicalBodyParts {
geometry: any | null;
}
export interface PartitionOpeningCut {
hostId: string;
a: [number, number];
b: [number, number];
depth: number;
}
/**
* Cut only the explicitly hosted independent-wall body. Boolean failure keeps
* the original body opaque (fail-dark) instead of manufacturing a light leak.
*/
export function cutPartitionBody(
body: number[][], cuts: readonly PartitionOpeningCut[], epsilon = 1e-9,
): number[][][] {
if (!cuts.length) return [body];
let geometry: any = [closedRing(body)];
try {
for (const cut of cuts) {
const dx = cut.b[0] - cut.a[0], dy = cut.b[1] - cut.a[1];
const length = Math.hypot(dx, dy);
if (!(length > epsilon)) continue;
const ux = dx / length, uy = dy / length;
const nx = -uy, ny = ux;
const pad = Math.max(Number(cut.depth) || 0, epsilon * 4) * 1.25;
const longitudinalPad = Math.max(epsilon * 2, length * 1e-9);
const slot = [
[cut.a[0] - ux * longitudinalPad - nx * pad,
cut.a[1] - uy * longitudinalPad - ny * pad],
[cut.b[0] + ux * longitudinalPad - nx * pad,
cut.b[1] + uy * longitudinalPad - ny * pad],
[cut.b[0] + ux * longitudinalPad + nx * pad,
cut.b[1] + uy * longitudinalPad + ny * pad],
[cut.a[0] - ux * longitudinalPad + nx * pad,
cut.a[1] - uy * longitudinalPad + ny * pad],
];
geometry = difference(geometry, closedRing(slot) as any);
}
return geometryOuterRings(geometry);
} catch {
return [body];
}
}
/**
* Raw editable bodies plus their computed, order-independent junction volumes.
* Most runtime consumers need these polygons directly and must not pay for an
@@ -115,11 +158,19 @@ export function physicalBodyParts(
cellCm: number,
gridPitch: number,
epsilon = Math.max(gridPitch * 0.0002, 1e-9),
partitionCuts: readonly PartitionOpeningCut[] = [],
): PhysicalBodyParts {
const draftSegments: LinearWallSegment[] = [];
const partitionSegments: LinearWallSegment[] = [];
const cutsByPartition = new Map<string, PartitionOpeningCut[]>();
for (const cut of partitionCuts) {
const list = cutsByPartition.get(cut.hostId) || [];
list.push(cut);
cutsByPartition.set(cut.hostId, list);
}
const drafts: number[][][] = [];
const partitions: number[][][] = [];
const presentedPartitions: number[][][] = [];
for (const draft of space.room_drafts || []) {
for (let i = 0; i + 1 < draft.points.length; i++) {
const halfDepth = wallCmToUnits(
@@ -142,13 +193,20 @@ export function physicalBodyParts(
if (!body) continue;
partitionSegments.push(segment);
partitions.push(body);
presentedPartitions.push(...cutPartitionBody(
body, cutsByPartition.get(partition.id) || [], epsilon,
));
}
const columns = (space.wall_columns || []).map((column) =>
columnBody(column, cellCm, gridPitch));
// Join volumes are presentation masonry too. Leaving them uncut can bridge
// an opening placed close to a T/endpoint even though its raw host body was
// correctly split. Other crossing walls remain opaque through their own raw
// bodies; only the extra shared mitre/bevel volume is trimmed here.
const patches = linearWallJoinPatches(
[...draftSegments, ...partitionSegments], epsilon,
);
const all = [...drafts, ...partitions, ...patches, ...columns];
).flatMap((body) => cutPartitionBody(body, partitionCuts, epsilon));
const all = [...drafts, ...presentedPartitions, ...patches, ...columns];
return { drafts, partitions, columns, patches, all };
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Active-space selection is allowed to preserve the legacy first-space
* fallback while the model is non-empty. Explicit/stable ids use the exact
* selector so a stale object can never silently target another floor.
*/
export function selectActiveSpaceModel<T extends { id: string }>(
models: readonly T[],
activeId: string | null | undefined,
): T | undefined {
return models.find((space) => space.id === activeId) ?? models[0];
}
export function selectSpaceModelById<T extends { id: string }>(
models: readonly T[],
id: string | null | undefined,
): T | undefined {
if (!id) return undefined;
return models.find((space) => space.id === id);
}
+78 -9
View File
@@ -11,17 +11,26 @@ import { buildDevices, areaLqi, areaTemp, resolvedLightSources, resolvedLightSta
import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
openingAmount,
type ResolvedRoomFill,
} from './logic';
import {
openingTunnelGeometries, wallBodiesUnionPath, wallBodyNeedsSolid, type WallEntry,
openingTunnelGeometries, wallBodiesUnionPath, wallBodyNeedsSolid, wallIntervals,
type WallEntry,
} from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, resolveDayCycle } from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import type { DevItem, ServerConfig } from './types';
import { physicalBodies } from './physical-geometry';
import type { DevItem, OpeningCfg, ServerConfig } from './types';
import { physicalBodyParts } from './physical-geometry';
import {
materializePartitionOpening, partitionOpeningCut,
partitionOpeningFace, partitionOpeningHasCompositeRoomWall, resolvePartitionOpening,
} from './partition-openings';
import {
renderOpeningVisibleGeometry, type OpeningVisibleSpec,
} from './render/opening-symbol';
import { activeRegistryHass, fullRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
import {
resolveDevicePresentation, type PresentationActivityRuntime, type ResolvedDevicePresentation,
@@ -200,15 +209,29 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const spCfg: any = o.cfg.spaces.find((s: any) => s.id === o.spaceId) || {};
const walls: WallEntry[] = Array.isArray(spCfg.walls) ? spCfg.walls : [];
const cellCm = Number(spCfg.cell_cm) > 0 ? Number(spCfg.cell_cm) : 5;
const resolvedHosted = (spCfg.openings || []).flatMap((opening: OpeningCfg) => {
if (!opening.host) return [];
const resolved = resolvePartitionOpening(
opening, space.partitions, NORM_W, cellCm, GRID_PITCH,
).resolved;
return resolved ? [resolved] : [];
});
const physicalFingerprint = contentFingerprint({
partitions: space.partitions,
roomDrafts: space.room_drafts,
columns: space.wall_columns,
cellCm,
hostedOpenings: resolvedHosted.map((resolved) => ({
id: resolved.opening.id, host: resolved.host,
length: resolved.length, type: resolved.opening.type,
})),
});
const extras = cachedStaticPhysicalBodies(
o.cfg, space.id, physicalFingerprint,
() => physicalBodies(space, cellCm, GRID_PITCH),
() => physicalBodyParts(
space, cellCm, GRID_PITCH, GRID_PITCH * 0.0002,
resolvedHosted.map(partitionOpeningCut),
).all,
);
for (const body of extras) {
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
@@ -244,7 +267,23 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
}
// Static intentionally keeps the historical door/window/gate wall output.
// Only the new negative-space type participates in its masonry fingerprint.
const staticPassages = staticPassageOpenings(spCfg.openings, NORM_W);
const resolvedRawOpenings = (spCfg.openings || []).flatMap((opening: OpeningCfg) => {
if (!opening.host) return [opening];
const resolved = resolvedHosted.find((item) => item.opening.id === opening.id);
return resolved ? [materializePartitionOpening(opening, resolved, NORM_W)] : [];
});
const staticPassages = staticPassageOpenings(resolvedRawOpenings, NORM_W);
const roomIntervals = wallIntervals(
space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W,
);
const hostedCompositeOpenings = resolvedHosted
.filter((resolved) => partitionOpeningHasCompositeRoomWall(
resolved, roomIntervals, GRID_PITCH * 0.0002,
))
.map((resolved) => ({
x: resolved.center[0], y: resolved.center[1],
angle: resolved.angle, length: resolved.length,
}));
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders || roomFillModeOf(disp.fill, r) !== 'none')
@@ -381,14 +420,17 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
? contentFingerprint(staticPassages.length
? { rooms: space.rooms, walls, extras, cellCm, passages: staticPassages.map((opening) => ({
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
})) }
})), hostedCompositeOpenings }
: { rooms: space.rooms, walls, extras, cellCm })
: '';
const canonicalWallGeometry = needsCanonicalWallGeometry
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
space.rooms, walls, [], staticPassages.map((opening) => ({
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
})), GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
space.rooms, walls, [], [
...staticPassages.filter((opening) => !opening.host).map((opening) => ({
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
})),
...hostedCompositeOpenings,
], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
))
: null;
const passageTunnelGeometry = staticPassages.length && walls.length
@@ -427,6 +469,32 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
const wallStroke = disp.color || '#607d8b';
const hostedOpeningSymbols = disp.hideOpenings ? [] : resolvedHosted.map((resolved) => {
const opening = resolved.opening;
const state = opening.type === 'passage' || !opening.contact
? null : planHass.states?.[opening.contact]?.state;
const amount = openingAmount(opening.type, state, !!opening.invert);
const active = amount > 0 && !!opening.contact;
const faceFlipV = opening.type === 'gate' ? !opening.flip_v : !!opening.flip_v;
const spec: OpeningVisibleSpec = {
type: opening.type,
length: resolved.length,
angle: resolved.angle,
amount,
flipH: !!opening.flip_h,
flipV: !!opening.flip_v,
base: wallStroke,
tone: active ? 'var(--hp-open)' : wallStroke,
cellCm,
gridPitch: GRID_PITCH,
face: partitionOpeningFace(resolved, faceFlipV),
};
return svg`<g class="opening static-opening" data-hp="opening"
data-id=${opening.id} data-kind=${opening.type} pointer-events="none"
transform="translate(${resolved.center[0]} ${resolved.center[1]}) rotate(${resolved.angle})">
${renderOpeningVisibleGeometry(spec)}
</g>`;
});
return html`
<div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}"
@@ -472,6 +540,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
stroke="${wallStroke}" stroke-width="0.6" pointer-events="none"></path>
</g>`
: nothing}
${hostedOpeningSymbols}
</svg>
${''/* docs/CANVAS.md §6: the same expression as the full card. The
static card has no zoom, but its frame is the CONTENT now, so a
+30
View File
@@ -721,6 +721,22 @@ export const cardStyles = css`
.stage.markup .op-hit:active {
cursor: grabbing;
}
.stage.markup .opening.orphan {
pointer-events: auto;
cursor: pointer;
color: var(--error-color, #db4437);
}
.stage.markup .opening.orphan circle {
fill: var(--hp-bg);
stroke: currentColor;
stroke-width: 2;
}
.stage.markup .opening.orphan text {
fill: currentColor;
font-weight: 800;
font-size: 12px;
pointer-events: none;
}
/* HP-1550-04: in the resize tool the wall handles own the hit test — the
transparent .op-hit of a door at the midpoint of a wall used to sit ON
TOP of the handle and made that wall ungrabbable for both rooms.
@@ -2940,6 +2956,20 @@ export const cardStyles = css`
flex-direction: column;
gap: var(--sp-3);
}
hp-dialog .tapconfirm-body {
min-width: 0;
overflow-x: hidden;
}
hp-dialog .tapconfirm-body p {
max-width: 100%;
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
white-space: normal;
}
hp-dialog .tapconfirm-line {
color: var(--hp-muted);
}
hp-dialog .body label {
font-size: var(--fs-s);
color: var(--hp-muted);
+10
View File
@@ -166,6 +166,14 @@ export interface Marker {
}
/** A door, window, gate or open passage: plan geometry (normalized coords). */
export interface PartitionOpeningHost {
kind: 'partition';
/** Stable id of one saved independent wall segment in the same space. */
id: string;
/** Centre position along the directed partition a -> b. */
t: number;
}
export interface OpeningCfg {
id: string;
type: 'door' | 'window' | 'gate' | 'passage';
@@ -178,6 +186,8 @@ export interface OpeningCfg {
invert?: boolean;
flip_h?: boolean; // hinge on the other jamb
flip_v?: boolean; // opens to the other side of the wall
/** Explicit owner for an opening cut into an independent wall. */
host?: PartitionOpeningHost;
}
export interface ServerConfig {
+33
View File
@@ -74,6 +74,39 @@ test('an opening stays ON its wall — it is wall-bound, not node-bound', () =>
assert.ok(t > 0 && t < 1, 'opening slid off the end of its wall');
});
test('a hosted opening follows aligned partition identity and t', () => {
const spaces = [{
id: 'f1', cell_cm: 5, rooms: [],
partitions: [{ id: 'p1', a: [0.203, 0.2], b: [0.603, 0.2], cm: 15 }],
openings: [{
id: 'o1', type: 'door', x: 9, y: 9, angle: 77, length: 0.1,
host: { kind: 'partition', id: 'p1', t: 0.25 },
}],
}];
const { spaces: aligned } = alignAllToGrid(spaces, {});
const partition = aligned[0].partitions[0];
const opening = aligned[0].openings[0];
assert.deepEqual(opening.host, { kind: 'partition', id: 'p1', t: 0.25 });
assert.equal(opening.x, partition.a[0] + (partition.b[0] - partition.a[0]) * 0.25);
assert.equal(opening.y, partition.a[1] + (partition.b[1] - partition.a[1]) * 0.25);
assert.equal(opening.angle, 0);
});
test('alignment never shortens a host past its opening interval', () => {
const spaces = [{
id: 'f1', cell_cm: 5, rooms: [],
partitions: [{ id: 'p1', a: [0.0022, 0], b: [0.101, 0], cm: 15 }],
openings: [{
id: 'o1', type: 'door', x: 0.0516, y: 0, angle: 0, length: 0.098,
host: { kind: 'partition', id: 'p1', t: 0.5 },
}],
}];
const { spaces: aligned } = alignAllToGrid(spaces, {});
assert.deepEqual(aligned[0].partitions[0].a, [0.0022, 0]);
assert.deepEqual(aligned[0].partitions[0].b, [0.101, 0]);
assert.ok(Math.abs(aligned[0].openings[0].x - 0.0516) < 1e-12);
});
test('idempotent: a second run moves nothing and changes nothing', () => {
const first = alignAllToGrid(detuned().spaces, detunedLayout());
const second = alignAllToGrid(first.spaces, first.layout);
+89
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
formatToggleConfirmation,
projectedTapAction,
resolveToggleIntent,
sameToggleCommandTargets,
@@ -11,6 +12,30 @@ import {
toggleOriginOf,
} from '../test-build/device-toggle.js';
const confirmationFormatter = {
state: (target) => `state:${target.state}`,
current: (value) => `current:${value}`,
expected: (value) => `expected:${value}`,
groupCurrent: (on, total) => `group:${on}/${total}`,
groupAllOn: () => 'all-on',
groupAllOff: () => 'all-off',
unavailable: (count) => `unavailable:${count}`,
effect: (effect) => `effect:${effect}`,
expectedByHa: () => 'by-ha',
};
const confirmationIntent = (overrides = {}) => ({
origin: 'explicit-toggle',
kind: 'single',
semantics: 'power',
targets: [{ entityId: 'switch.sample', name: 'Sample', state: 'off', via: 'binding' }],
skippedTargets: [],
noneReason: null,
nextEffect: 'turn-on',
command: { domain: 'switch', service: 'turn_on', data: { entity_id: 'switch.sample' } },
...overrides,
});
const services = {
homeassistant: { turn_on: {}, turn_off: {}, toggle: {} },
light: { turn_on: {}, turn_off: {}, toggle: {} },
@@ -847,3 +872,67 @@ test('confirmation target comparison ignores order but detects target-set change
assert.equal(sameToggleCommandTargets(a, b), true);
assert.equal(sameToggleCommandTargets(a, c), false);
});
test('toggle confirmation formats every next effect without deriving direction from state', () => {
for (const effect of ['turn-on', 'turn-off', 'open', 'close', 'stop']) {
const lines = formatToggleConfirmation(
confirmationIntent({ nextEffect: effect }), confirmationFormatter,
);
assert.deepEqual(lines, ['current:state:off', `expected:effect:${effect}`], effect);
}
assert.deepEqual(
formatToggleConfirmation(
confirmationIntent({ nextEffect: 'toggle' }), confirmationFormatter,
),
['current:state:off', 'expected:by-ha'],
);
});
test('toggle confirmation describes executable group targets and skipped targets separately', () => {
const targets = [
{ entityId: 'switch.one', name: 'One', state: 'on', via: 'control-entity' },
{ entityId: 'light.two', name: 'Two', state: 'off', via: 'control-entity' },
];
const partial = confirmationIntent({
kind: 'group', semantics: 'group-power', targets,
skippedTargets: [{
ref: 'switch.missing', entityId: 'switch.missing', name: 'Missing', reason: 'unavailable',
}],
nextEffect: 'turn-off',
command: {
domain: 'homeassistant', service: 'turn_off',
data: { entity_id: targets.map((target) => target.entityId) },
},
});
assert.deepEqual(formatToggleConfirmation(partial, confirmationFormatter), [
'current:group:1/2', 'expected:all-off', 'unavailable:1',
]);
const allOff = {
...partial,
targets: targets.map((target) => ({ ...target, state: 'off' })),
skippedTargets: [],
nextEffect: 'turn-on',
command: { ...partial.command, service: 'turn_on' },
};
assert.deepEqual(formatToggleConfirmation(allOff, confirmationFormatter), [
'current:all-off', 'expected:all-on',
]);
});
test('toggle confirmation covers virtual lights and refuses non-executable intents', () => {
const virtual = confirmationIntent({
targets: [{ entityId: '', name: 'Virtual lamp', state: 'on', via: 'virtual-light' }],
nextEffect: 'turn-off',
command: null,
operation: { kind: 'virtual-light', markerId: 'virtual-lamp' },
});
assert.deepEqual(formatToggleConfirmation(virtual, confirmationFormatter), [
'current:state:on', 'expected:effect:turn-off',
]);
assert.deepEqual(formatToggleConfirmation(confirmationIntent({
kind: 'none', targets: [], nextEffect: null, command: null,
noneReason: 'no-actionable-entity',
}), confirmationFormatter), []);
});
+49 -9
View File
@@ -247,19 +247,59 @@ test('opening entity availability ignores marker lifecycle and follows exact HA
assert.equal(openingEntityAvailable(hass, null, snapshot), false);
});
test('opening render availability requires one frozen registry-backed frame', () => {
const frame = {
entities: {
'binary_sensor.door': { entity_id: 'binary_sensor.door' },
'lock.no_state': { entity_id: 'lock.no_state' },
test('opening render availability trusts one frozen active projection', () => {
const devices = {
active: { id: 'active', disabled_by: null },
disabled: { id: 'disabled', disabled_by: 'user' },
};
const entities = {
'binary_sensor.door': {
entity_id: 'binary_sensor.door', device_id: 'active', disabled_by: null,
},
states: {
'binary_sensor.door': { state: 'unknown' },
'binary_sensor.yaml_only': { state: 'on' },
'lock.no_state': { entity_id: 'lock.no_state', device_id: 'active', disabled_by: null },
'lock.disabled_entity': {
entity_id: 'lock.disabled_entity', device_id: 'active', disabled_by: 'user',
},
'lock.disabled_parent': {
entity_id: 'lock.disabled_parent', device_id: 'disabled', disabled_by: null,
},
'lock.orphan': {
entity_id: 'lock.orphan', device_id: 'missing-device', disabled_by: null,
},
};
const states = {
'binary_sensor.door': { entity_id: 'binary_sensor.door', state: 'unknown' },
'binary_sensor.yaml_only': { entity_id: 'binary_sensor.yaml_only', state: 'on' },
'lock.yaml_unknown': { entity_id: 'lock.yaml_unknown', state: 'unavailable' },
'lock.disabled_entity': { entity_id: 'lock.disabled_entity', state: 'locked' },
'lock.disabled_parent': { entity_id: 'lock.disabled_parent', state: 'locked' },
'lock.orphan': { entity_id: 'lock.orphan', state: 'locked' },
};
const frame = activeRegistryHass({ devices, entities, states }, full(devices, entities));
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.door'), true);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.no_state'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.yaml_only'), false, '#117 owns YAML parity');
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.yaml_only'), true);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.yaml_unknown'), true,
'unknown/unavailable remains an existing exact reference, not an open state');
assert.equal(renderOpeningEntityAvailable(frame, 'lock.disabled_entity'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.disabled_parent'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.orphan'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.missing'), false);
assert.equal(renderOpeningEntityAvailable(frame, ''), false);
const limitedFrame = activeRegistryHass({
devices: {}, entities: {},
states: { 'binary_sensor.limited_yaml': { state: 'off' } },
}, limited({}, {}));
assert.equal(renderOpeningEntityAvailable(limitedFrame, 'binary_sensor.limited_yaml'), true);
const markerTombstoneIsNotAnInput = {
...frame,
markers: [{ binding: 'entity:binary_sensor.yaml_only', removed: true }],
};
assert.equal(
renderOpeningEntityAvailable(markerTombstoneIsNotAnInput, 'binary_sensor.yaml_only'),
true,
);
});
+1 -1
View File
@@ -44,7 +44,7 @@ test('passage bindings cannot reach subscriptions, locks or the info card', () =
});
test('static passage cuts and tunnels are passage-only additions', () => {
assert.match(staticRender, /staticPassageOpenings\(spCfg\.openings, NORM_W\)/);
assert.match(staticRender, /staticPassageOpenings\(resolvedRawOpenings, NORM_W\)/);
assert.match(staticRender, /passages: staticPassages\.map/);
assert.match(staticRender, /renderOpeningTunnelFills/);
assert.match(staticRender, /passageDataTunnels/);

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