Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb510290a | ||
|
|
932773773f | ||
|
|
6c7958c6d0 | ||
|
|
a449edc545 | ||
|
|
e88c23b8ee | ||
|
|
acad3b32c1 | ||
|
|
b443a333e0 | ||
|
|
66fa8f476c | ||
|
|
047363c2d3 | ||
|
|
1e8503bd46 | ||
|
|
135497b272 | ||
|
|
0c2a5dedea | ||
|
|
c1676cf26a | ||
|
|
c65cbcc96c | ||
|
|
01fe48de00 | ||
|
|
9baf533c90 | ||
|
|
563a850aac | ||
|
|
9e5ff0b8a0 | ||
|
|
3fe0f8c443 | ||
|
|
742b3279a2 | ||
|
|
9f77e3e932 | ||
|
|
083621342a | ||
|
|
4f20befd77 | ||
|
|
2aaabc48d6 | ||
|
|
b9bf210804 | ||
|
|
d6007dc444 | ||
|
|
54c5ca3840 | ||
|
|
57fc434d4f | ||
|
|
9530ee2e5a |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- Большие планы больше не пересчитывают неиспользуемое объединение физических
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.*`, действием по умолчанию уже является **Переключить состояние**. У остальных устройств по умолчанию открывается внутренняя карточка.
|
||||
|
||||
Если у маркера с привязкой к устройству есть две или больше собственных
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 286 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -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**
|
||||
@@ -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**
|
||||
@@ -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**
|
||||
@@ -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) и три копии бандла в порядке.
|
||||
@@ -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 в порядке. Задача готова к слиянию.
|
||||
@@ -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 — они не затронуты фиксом.
|
||||
@@ -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.
|
||||
@@ -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; все три либо правятся косметически
|
||||
при следующей редакции, либо снимаются этой записью без нового цикла.
|
||||
@@ -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.
|
||||
@@ -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 недоказуемым по существу; все зафиксированы с реконструкцией и оставлены на
|
||||
усмотрение автора при следующей правке файла, без нового цикла ревью.
|
||||
@@ -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), не выдавая догадку за факт.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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) {',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Выберите автоматизацию, скрипт или сцену",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||