fix: canonicalize persisted geometry

Issue: #224
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 14:47:38 +03:00
parent 8442538b6b
commit 4a798e3e13
25 changed files with 1489 additions and 196 deletions
@@ -0,0 +1,149 @@
"""Lossless, allow-listed canonicalisation of persisted geometry.
The frontend mirrors this module in src/coordinate-canonicalization.ts.
Keep the precision, scalar formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
import copy
import math
from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed number."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
result = sign * (
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
/ COORDINATE_FACTOR
)
if result == 0:
return 0.0
return result
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def _records(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_number(record[name])
def _point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_number(value[index])
def _points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_point(point)
def canonicalize_position(position: Any) -> Any:
"""Canonicalise x/y in one layout record without touching its metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise x/y in every layout record, preserving future fields."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
return result
for position in record.values():
item = _record(position)
if item is not None:
_fields(item, ("x", "y"))
return result
def canonicalize_config_geometry(config: Any) -> Any:
"""Canonicalise only the named persisted geometry fields."""
result = copy.deepcopy(config)
root = _record(result)
if root is None:
return result
for space in _records(root.get("spaces")):
_fields(
space,
(
"plan_x",
"plan_y",
"plan_scale",
"plan_scale_x",
"plan_scale_y",
"plan_angle",
),
)
for room in _records(space.get("rooms")):
_fields(room, ("x", "y", "w", "h"))
_points(room.get("poly"))
for wall in _records(space.get("walls")):
_point(wall.get("a"))
_point(wall.get("b"))
for opening in _records(space.get("openings")):
_fields(opening, ("x", "y", "angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
_fields(decor, ("x", "y", "w", "h", "angle"))
elif kind == "text":
_fields(decor, ("x", "y", "scale", "angle"))
for draft in _records(space.get("room_drafts")):
_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_point(partition.get("a"))
_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_point(column.get("center"))
if column.get("shape") == "square":
_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_point(span.get("a"))
_point(span.get("b"))
for marker in _records(root.get("markers")):
_fields(marker, ("angle",))
return result
File diff suppressed because one or more lines are too long
+8 -3
View File
@@ -20,6 +20,10 @@ from .const import (
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
_LOGGER = logging.getLogger(__name__)
@@ -157,7 +161,7 @@ def layout_store_payload(
}
if metadata:
out.update(metadata)
out["layout"] = layout
out["layout"] = canonicalize_layout_geometry(layout)
out["rev"] = rev
return out
@@ -205,7 +209,8 @@ async def async_save_config_state(
except (TypeError, ValueError):
previous_rev = 0
payload = {"config": config, "rev": rev}
canonical_config = canonicalize_config_geometry(config)
payload = {"config": canonical_config, "rev": rev}
await runtime.config_store.async_save(payload)
# The config is already durable at this point. Reconciliation remains a
@@ -216,7 +221,7 @@ async def async_save_config_state(
try:
await async_reconcile_virtual_lights(
runtime.virtual_light_store,
config,
canonical_config,
rev,
previous_config_rev=previous_rev,
)
+54 -38
View File
@@ -9,6 +9,12 @@ import re
import voluptuous as vol
from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
canonicalize_position,
)
# ---------- limits and extension sets ----------
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
@@ -522,11 +528,18 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
CANVAS_LIMIT = 5000.0
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
POS_SCHEMA = vol.All(
vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
),
canonicalize_position,
)
LAYOUT_SCHEMA = vol.All(
vol.Schema({str: POS_SCHEMA}),
vol.Length(max=MAX_LAYOUT),
canonicalize_layout_geometry,
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
@@ -1124,38 +1137,41 @@ MARKER_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
)
+54 -14
View File
@@ -25,6 +25,10 @@ from .const import (
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
from .auth import may_write
from .import_export import (
ImportFailure,
@@ -426,8 +430,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
if kind == "full":
backup = {
"kind": "import",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
@@ -444,16 +452,20 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "import",
"config": target_config,
"layout": target_layout,
"config": canonicalize_config_geometry(target_config),
"layout": canonicalize_layout_geometry(target_layout),
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "import_rollback",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
@@ -566,6 +578,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
if layout == data.get("layout", {}):
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
return
new_rev = current_rev + 1
await async_save_layout_state(
rt, data, layout, new_rev,
@@ -627,7 +642,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
if layout.get(msg["device_id"]) == msg["pos"]:
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
return
layout = {**layout, msg["device_id"]: msg["pos"]}
# keep the revision: a point-wise write used to drop it, which made the
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
@@ -735,7 +753,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
rt, data, new_layout, new_rev,
metadata={
**_optimizer_backup_after_layout_maintenance(data, new_rev),
"repair_backup": {"space": space_id, "positions": touched},
"repair_backup": {
"space": space_id,
"positions": canonicalize_layout_geometry(touched),
},
},
remove=("repair_backup",),
)
@@ -1281,6 +1302,17 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
if msg["config"] == data.get("config"):
# A semantic no-op still has to reconcile Repairs with external
# file-system changes. It must not create a revision, event, or
# discard the optimizer snapshot merely to refresh diagnostics.
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {"ok": True, "rev": int(current_rev)})
return
new_rev = current_rev + 1
await async_save_config_state(
rt,
@@ -1397,15 +1429,19 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
new_layout_rev = layout_rev + 1
backup = {
"kind": "optimize",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout", {})
),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": msg["config"],
"layout": msg["layout"],
"config": canonicalize_config_geometry(msg["config"]),
"layout": canonicalize_layout_geometry(msg["layout"]),
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
@@ -1475,8 +1511,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
backup = layout_data[_OPTIMIZE_BACKUP]
restored_kind = str(backup.get("kind") or "optimize")
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
restored_config = canonicalize_config_geometry(
backup.get("config") or DEFAULT_CONFIG
)
restored_layout = canonicalize_layout_geometry(
backup.get("layout") or {}
)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
File diff suppressed because one or more lines are too long
+45 -45
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -45,6 +45,29 @@ centimetres, screen-fixed strokes/handles, plan-relative icon sizes and the
grid pitch must not receive that factor again. Full, static and hidden
isometric renderers share this classification.
### Persisted coordinate canonicalisation
Every current config/layout write removes IEEE-754 representation tails from
persisted geometry by rounding an explicit allow-list to nine decimal places.
This is not grid snapping: an off-grid or diagonal coordinate stays where the
editor put it, with a maximum normalized change of `5e-10`. The contract is
mirrored in Python and TypeScript and normalizes negative zero.
The allow-list covers room outlines/extents, exact wall endpoints, openings
(including angle, length and hosted `t`), decor transforms, drafts,
partitions, columns, open spans, backdrop transforms, marker angle and layout
`x/y`. It deliberately excludes `cell_cm`, `plan_aspect`, `view_box`,
physical centimetre fields, colours/opacities/live values, presentation scales
and vacuum affine calibration. Unknown/future numeric fields round-trip
unchanged.
Schema validation is the public door; the common config/layout storage helpers
repeat the same idempotent operation for internal import, maintenance and
startup-recovery writers. A canonical read/write echo is a no-op: optimistic
locking is still checked, but the revision, update event and maintenance Undo
snapshot do not move. Existing stores are not rewritten on read; Optimize Plans
remains the explicit bulk-cleanup path.
## Model
| Concept | Before | Now |
+9
View File
@@ -2,6 +2,15 @@
## Unreleased
- Every config and device-layout write now canonicalizes persisted geometry to
nine decimal places without snapping it to the grid. This removes invisible
floating-point tails before they can break shared walls, room unions or Glow;
import, maintenance recovery and server Undo use the same invariant. Repeating
an unchanged canonical Save no longer creates a revision or consumes the
one-deep Undo snapshot. Existing noisy plans are cleaned on their next write,
or immediately through Optimize Plans
([#224](https://github.com/Matysh/houseplan-card/issues/224)).
- Grid precision no longer changes the appearance of the same physical plan.
Room and wall outlines, openings and their hit areas, Plan hints, the static
card and hidden isometric geometry now retain the `cell_cm: 5` visual size at
+10
View File
@@ -8,6 +8,16 @@
## Не выпущено
- Каждая запись конфигурации и раскладки устройств теперь приводит геометрию к
девяти десятичным знакам, не привязывая её к сетке. Невидимые
floating-point-хвосты удаляются до того, как смогут сломать общие стены,
объединение комнат или Glow; тот же инвариант действует для импорта,
восстановления служебных операций и серверной отмены. Повторное сохранение
уже канонических данных больше не создаёт ревизию и не съедает одношаговую
отмену. Старый план очищается при следующей записи либо сразу через
«Оптимизировать планы»
([#224](https://github.com/Matysh/houseplan-card/issues/224)).
- Точность сетки больше не меняет внешний вид одного и того же физического
плана. Контуры комнат и стен, проёмы и их зоны попадания, подсказки редактора,
статическая карточка и скрытая изометрия сохраняют эталонный вид
+21
View File
@@ -48,6 +48,27 @@ Unknown future fields remain outside this report and continue to follow the
backend's forward-compatibility policy. Absence from the report is therefore
not permission to delete a field.
## Canonical geometry on write (#224)
Config and layout schemas canonicalize only named geometry numbers to nine
decimal places. The common storage helpers repeat the same idempotent contract
for internal writers, while the frontend adopts the exact candidate it sends.
This prevents ULP noise without changing the schema, JSON number type,
model/store version or any user-visible placement.
The operation is lossless at the product scale and intentionally narrow.
`view_box`, `cell_cm`, `plan_aspect`, physical centimetre values,
presentation settings, colours, opacity/brightness/temperature, vacuum
calibration and unknown/future numeric fields retain their exact input values.
No recursive “round every number” migration is allowed.
Existing stores remain byte-for-byte untouched on read. Their geometry becomes
canonical on the next config/layout write; Optimize Plans is the immediate bulk
path. Optimize/Import/repair Undo restores the previous semantic geometry and
unknown fields in canonical representation, not the invisible noisy IEEE-754
tail. A repeated canonical Save with the current revision is a no-op and does
not invalidate the one-deep maintenance backup.
## Open-passage opening type (#157)
`space.openings[].type` additionally accepts the literal `passage`. Its
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.66.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion of the published v1.66.0-beta.1 product line |
| Current local cycle | Post-v1.66.0 Unreleased development includes reliable Plan drawing (#228): exact Shift rays, ambiguous-node blocking, active axis/node chrome, room creation from an existing face with an explicit ≤2 cm repair, and room deletion with Keep/Delete walls consequences. Entity/parent marker deduplication (#226) and exact coordinate maintenance (#223) are also included. |
| Current local cycle | Post-v1.66.0 Unreleased development includes reliable Plan drawing (#228): exact Shift rays, ambiguous-node blocking, active axis/node chrome, room creation from an existing face with an explicit ≤2 cm repair, and room deletion with Keep/Delete walls consequences. Entity/parent marker deduplication (#226), explicit coordinate maintenance (#223) and canonical geometry on every write (#224) are also included. |
| 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) |
+14 -1
View File
@@ -2090,11 +2090,24 @@ require hands on real hardware — they remain for the human pass.
`moved: 0` and a positive cleaned-coordinate count. Preview distinguishes
updated spaces from removed coordinate noise; Cancel writes nothing;
Apply stores exact grid nodes in one transaction; the next run is a no-op
and server Undo restores the original noisy bits. A rejected hosted
and server Undo restores the original geometry in canonical
representation. A rejected hosted
partition contributes nothing to the counter
[unit: align-grid + plan-optimizer + i18n; auto:
smoke_optimize_coordinate_canonicalization; mutation:
`snapn-returns-input-near-node`].
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
schema, import, direct storage writers, startup recovery and maintenance
Undo produce the same nine-decimal allow-listed geometry as the frontend.
A first noisy write creates one canonical revision; a repeated canonical
write creates no store write/event/revision and preserves the maintenance
backup. `view_box`, physical/presentation values, colours and vacuum
calibration remain exact; the six-room #218 union and Glow clip stay
non-empty [unit: coordinate-canonicalization + physical-geometry;
backend: test_coordinate_canonicalization + test_ha_websocket +
test_ha_import_export; mutations: `schema-quantization-removed`,
`frontend-writes-raw-coords`, `quantization-hits-allowlist`,
`import-path-bypasses-schema`; pre-release: golden verify].
- [ ] Optimizer migration safety: legacy decor width/text size is clamped to
the backend schema, `fill: true` receives explicit fill style, invalid
legacy `plan_scale` is preserved for repair, an already canonical plan is
+12 -1
View File
@@ -1332,6 +1332,17 @@ show_signal: true
## 19. Обслуживание планов
Обычное сохранение конфигурации или позиции устройства автоматически убирает
из координат невидимый вычислительный шум. Это **не привязка к сетке**:
диагональные стены, свободный декор и другие допустимые положения остаются на
своих местах. Импорт и серверная отмена используют то же правило. Повторное
сохранение уже неизменного плана не создаёт новую ревизию и не отменяет
доступность последней служебной отмены.
Существующий план не переписывается при простом открытии. Его геометрия
очистится при следующем изменении; если очистить всё нужно сразу, используйте
явную оптимизацию ниже.
Кнопка **Общие настройки → Оптимизировать планы** запускает явное обслуживание. Сначала показывается точный предпросмотр; данные не меняются до подтверждения.
### Что делает оптимизация
@@ -1365,7 +1376,7 @@ show_signal: true
### Риск и отмена
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Новый edit делает резервную копию оптимизации устаревшей.
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей.
Обычное открытие и редактирование плана не удаляет даже очень короткие точные
границы толщины. Описанное схлопывание выполняется только после явного
+2 -1
View File
@@ -6,9 +6,10 @@ if (!pattern) {
console.error('usage: node scripts/backend-test-guard.mjs <pytest-k-pattern>');
process.exit(2);
}
const testFile = process.argv[3] || 'tests_backend/test_ha_import_export.py';
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
const result = spawnSync(python, [
'-m', 'pytest', 'tests_backend/test_ha_import_export.py', '-q', '-k', pattern,
'-m', 'pytest', testFile, '-q', '-k', pattern,
], { stdio: 'inherit' });
process.exit(result.status ?? 2);
+51
View File
@@ -178,6 +178,57 @@ export const MUTANTS = [
replace: ' const qx = x;\n const qy = y;',
}],
},
{
id: 'schema-quantization-removed',
guard: 'node scripts/backend-test-guard.mjs backend_schemas_apply '
+ 'tests_backend/test_coordinate_canonicalization.py',
because: 'backend schemas are the public write door for cards, imports and manual clients; '
+ 'removing their canonicalisation would let the same ULP noise enter every future writer',
patches: [{
file: 'custom_components/houseplan/validation.py',
find: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
+ ' ),\n canonicalize_config_geometry,\n)',
replace: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
+ ' ),\n lambda value: value,\n)',
}],
},
{
id: 'frontend-writes-raw-coords',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="frontend write paths" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'a server-only fix leaves the open card rendering its noisy mutable config until '
+ 'reload, so the current session can still reproduce the geometry failure after Save',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const candidate = canonicalizeConfigGeometry(this._serverCfg);',
replace: ' const candidate = this._serverCfg;',
}],
},
{
id: 'quantization-hits-allowlist',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="nine-decimal fixture contract" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'presentation and calibration values are deliberately outside geometry; widening '
+ 'the allow-list silently changes user data that has no ULP topology problem',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: " for (const marker of records(root.markers)) fields(marker, ['angle']);",
replace: " for (const marker of records(root.markers)) fields(marker, ['angle', 'size']);",
}],
},
{
id: 'import-path-bypasses-schema',
guard: 'node scripts/backend-test-guard.mjs import_document_canonicalizes_external_coordinates',
because: 'an externally assembled backup is an independent source of noisy geometry and '
+ 'must enter preview and storage through the same canonical schema as the live card',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: ' config = CONFIG_SCHEMA(_json_copy(payload.get("config")))',
replace: ' config = _json_copy(payload.get("config"))',
}],
},
{
id: 'union-failure-kills-space',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+135
View File
@@ -0,0 +1,135 @@
/**
* Stable, allow-listed persisted geometry.
*
* Mirrored by custom_components/houseplan/coordinate_canonicalization.py.
* Keep the precision, scalar formula and field allow-list in lockstep.
*/
export const COORDINATE_DECIMALS = 9;
export const COORDINATE_FACTOR = 10 ** COORDINATE_DECIMALS;
type JsonRecord = Record<string, any>;
function cloneJson<T>(value: T): T {
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
if (value !== null && typeof value === 'object') {
const out: JsonRecord = {};
for (const [key, item] of Object.entries(value as JsonRecord)) out[key] = cloneJson(item);
return out as T;
}
return value;
}
export function canonicalizeNumber(value: unknown): unknown {
if (typeof value !== 'number' || !Number.isFinite(value)) return value;
const sign = value < 0 || Object.is(value, -0) ? -1 : 1;
const result = sign
* (Math.floor(Math.abs(value) * COORDINATE_FACTOR + 0.5) / COORDINATE_FACTOR);
return result === 0 ? 0 : result;
}
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as JsonRecord : null;
}
function records(value: unknown): JsonRecord[] {
return Array.isArray(value)
? value.filter((item): item is JsonRecord => record(item) !== null)
: [];
}
function fields(item: JsonRecord, names: readonly string[]): void {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(item, name)) {
item[name] = canonicalizeNumber(item[name]);
}
}
}
function point(value: unknown): void {
if (!Array.isArray(value)) return;
for (let index = 0; index < Math.min(2, value.length); index++) {
value[index] = canonicalizeNumber(value[index]);
}
}
function points(value: unknown): void {
if (!Array.isArray(value)) return;
for (const item of value) point(item);
}
export function canonicalizePosition<T>(position: T): T {
const result = cloneJson(position);
const item = record(result);
if (item) fields(item, ['x', 'y']);
return result;
}
export function canonicalizeLayoutGeometry<T>(layout: T): T {
const result = cloneJson(layout);
const root = record(result);
if (!root) return result;
for (const value of Object.values(root)) {
const item = record(value);
if (item) fields(item, ['x', 'y']);
}
return result;
}
export function canonicalizeConfigGeometry<T>(config: T): T {
const result = cloneJson(config);
const root = record(result);
if (!root) return result;
for (const space of records(root.spaces)) {
fields(space, [
'plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle',
]);
for (const room of records(space.rooms)) {
fields(room, ['x', 'y', 'w', 'h']);
points(room.poly);
}
for (const wall of records(space.walls)) {
point(wall.a);
point(wall.b);
}
for (const opening of records(space.openings)) {
fields(opening, ['x', 'y', 'angle', 'length']);
const host = record(opening.host);
if (host) fields(host, ['t']);
}
for (const decor of records(space.decor)) {
if (decor.kind === 'line') fields(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
fields(decor, ['x', 'y', 'w', 'h', 'angle']);
} else if (decor.kind === 'text') {
fields(decor, ['x', 'y', 'scale', 'angle']);
}
}
for (const draft of records(space.room_drafts)) points(draft.points);
for (const partition of records(space.partitions)) {
point(partition.a);
point(partition.b);
}
for (const column of records(space.wall_columns)) {
point(column.center);
if (column.shape === 'square') fields(column, ['angle']);
}
for (const span of records(space.open_spans)) {
point(span.a);
point(span.b);
}
}
for (const marker of records(root.markers)) fields(marker, ['angle']);
return result;
}
+14 -2
View File
@@ -187,6 +187,11 @@ import {
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import {
canonicalizeConfigGeometry,
canonicalizeLayoutGeometry,
canonicalizePosition,
} from './coordinate-canonicalization';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
@@ -4416,8 +4421,9 @@ class HouseplanCard extends LitElement {
const ids = [...this._dirtyPos];
this._dirtyPos.clear();
for (const id of ids) {
const pos = this._layout[id];
const pos = canonicalizePosition(this._layout[id]);
if (!pos) continue;
this._layout = { ...this._layout, [id]: pos };
// in flight until the server answers: a layout reload triggered in the
// meantime must keep this position, not the one the server still has
this._sentPos.set(id, pos);
@@ -4429,6 +4435,7 @@ class HouseplanCard extends LitElement {
}
this._cacheSnapshot();
} else {
this._layout = canonicalizeLayoutGeometry(this._layout);
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
}
}, 600);
@@ -6945,8 +6952,13 @@ class HouseplanCard extends LitElement {
.then(async () => {
if (!this._serverCfg) return;
this._dropLegacySegments();
const candidate = canonicalizeConfigGeometry(this._serverCfg);
const candidateFingerprint = contentFingerprint(candidate);
if (candidateFingerprint !== contentFingerprint(this._serverCfg)) this._cfgEpoch++;
this._serverCfg = candidate;
this._cfgContentFingerprint = candidateFingerprint;
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
type: 'houseplan/config/set', config: candidate, expected_rev: this._cfgRev,
});
this._cfgRev = r?.rev ?? this._cfgRev + 1;
});
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
COORDINATE_DECIMALS,
canonicalizeConfigGeometry,
canonicalizeLayoutGeometry,
canonicalizeNumber,
canonicalizePosition,
} from '../test-build/coordinate-canonicalization.js';
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/coordinate-canonicalization.json', import.meta.url),
'utf8',
));
test('frontend and backend share the nine-decimal fixture contract (#224)', () => {
assert.equal(COORDINATE_DECIMALS, fixture.decimals);
const configBefore = structuredClone(fixture.configInput);
const layoutBefore = structuredClone(fixture.layoutInput);
const config = canonicalizeConfigGeometry(fixture.configInput);
const layout = canonicalizeLayoutGeometry(fixture.layoutInput);
assert.deepEqual(config, fixture.configExpected);
assert.deepEqual(layout, fixture.layoutExpected);
assert.deepEqual(fixture.configInput, configBefore, 'config input is immutable');
assert.deepEqual(fixture.layoutInput, layoutBefore, 'layout input is immutable');
assert.deepEqual(canonicalizeConfigGeometry(config), config, 'config is idempotent');
assert.deepEqual(canonicalizeLayoutGeometry(layout), layout, 'layout is idempotent');
assert.equal(Object.is(layout['rl:poly'].x, -0), false, 'negative zero becomes positive');
});
test('scalar canonicalization is symmetric and never snaps off-grid geometry (#224)', () => {
assert.equal(canonicalizeNumber(1.2345678905), 1.234567891);
assert.equal(canonicalizeNumber(-1.2345678905), -1.234567891);
assert.equal(canonicalizeNumber(0.20833333333333334), 0.208333333);
assert.ok(Math.abs(canonicalizeNumber(0.20833333333333334) - 0.20833333333333334) <= 5e-10);
assert.equal(canonicalizeNumber(Number.NaN), Number.NaN);
assert.equal(canonicalizeNumber(Number.POSITIVE_INFINITY), Number.POSITIVE_INFINITY);
});
test('one position changes only x/y and preserves future metadata (#224)', () => {
const input = {
s: 'floor', x: 0.1000000006, y: -0.0000000004,
k: 1.0000000004, nested: { numeric: 0.1234567896 },
};
assert.deepEqual(canonicalizePosition(input), {
s: 'floor', x: 0.100000001, y: 0,
k: 1.0000000004, nested: { numeric: 0.1234567896 },
});
});
test('frontend write paths adopt canonical candidates before persistence (#224)', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
assert.match(
source,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
);
assert.match(
source,
/const pos = canonicalizePosition\(this\._layout\[id\]\);[\s\S]*device_id: id, pos/,
);
assert.match(
source,
/this\._layout = canonicalizeLayoutGeometry\(this\._layout\);[\s\S]*localStorage\.setItem/,
);
});
+435
View File
@@ -0,0 +1,435 @@
{
"decimals": 9,
"configInput": {
"spaces": [
{
"id": "floor",
"title": "Floor",
"cell_cm": 5.0000000004,
"plan_aspect": 1.5000000004,
"plan_x": 0.1234567894,
"plan_y": -0.1234567896,
"plan_scale": 1.0000000001,
"plan_scale_x": 1.2345678906,
"plan_scale_y": 0.9876543216,
"plan_angle": 44.1234567896,
"view_box": [0.0000000004, -0.0000000004, 1.0000000004, 1.0000000004],
"settings": {
"room_opacity": 0.5000000004,
"temp_min": 18.1234567896,
"temp_max": 28.1234567896,
"card_font_scale": 1.0000000004
},
"rooms": [
{
"id": "poly",
"name": "Poly",
"area": null,
"poly": [
[0.0000000004, -0.0000000004],
[0.3000000004, 0.0000000004],
[0.0000000004, 0.3000000004]
],
"settings": {
"name_scale": 1.0000000004,
"label_scale": 1.2000000004
}
},
{
"id": "rect",
"name": "Rect",
"area": null,
"x": 0.4000000004,
"y": 0.4000000006,
"w": 0.2000000004,
"h": 0.2000000006
}
],
"walls": [
{
"key": "wall",
"cm": 15.0000000004,
"a": [0.0000000004, 0.3000000004],
"b": [0.3000000006, 0.3000000006]
}
],
"openings": [
{
"id": "opening",
"type": "door",
"x": 0.5000000004,
"y": 0.0000000004,
"angle": -0.0000000004,
"length": 0.2000000006,
"host": {
"kind": "partition",
"id": "partition",
"t": 0.5000000004
}
}
],
"decor": [
{
"id": "line",
"kind": "line",
"x1": 0.1000000004,
"y1": 0.1000000006,
"x2": 0.2000000004,
"y2": 0.2000000006,
"width_cm": 2.0000000004,
"opacity": 0.7000000004
},
{
"id": "rect-decor",
"kind": "rect",
"x": 0.1000000004,
"y": 0.2000000006,
"w": 0.3000000004,
"h": 0.4000000006,
"angle": 12.1234567896,
"fill_opacity": 0.6000000004
},
{
"id": "ellipse",
"kind": "ellipse",
"x": -0.1000000004,
"y": -0.2000000006,
"w": 0.3000000006,
"h": 0.4000000004,
"angle": -12.1234567896
},
{
"id": "text",
"kind": "text",
"x": 0.7000000004,
"y": 0.8000000006,
"text": "Text",
"scale": 1.2345678906,
"angle": 2.1234567896,
"size_cm": 25.0000000004
},
{
"id": "chair",
"kind": "furniture",
"symbol": "chair",
"x": 0.6000000004,
"y": 0.6000000006,
"w": 0.1000000004,
"h": 0.1000000006,
"angle": 90.1234567896
}
],
"room_drafts": [
{
"id": "draft",
"points": [
[0.1000000004, 0.9000000006],
[0.2000000006, 0.9000000004],
[0.3000000004, 0.9000000006]
],
"segments": [
{"cm": 10.0000000004},
{"cm": 20.0000000004}
]
}
],
"partitions": [
{
"id": "partition",
"a": [0.0000000004, 0.0000000004],
"b": [1.0000000004, 0.0000000004],
"cm": 12.0000000004
}
],
"wall_columns": [
{
"id": "column",
"shape": "square",
"center": [0.7500000004, 0.7500000006],
"cm": 30.0000000004,
"angle": 45.1234567896
}
],
"open_spans": [
{
"a": [0.3000000004, 0.3000000006],
"b": [0.6000000006, 0.3000000004]
}
],
"future": {
"numeric": 0.1234567896
}
}
],
"markers": [
{
"id": "virtual",
"binding": "virtual",
"angle": -30.1234567896,
"size": 1.1000000004,
"ripple_size": 1.5000000004,
"glow_radius_cm": 120.0000000004,
"glow_color": {
"c": "#112233",
"bri": 0.5000000004
},
"vacuum": {
"calibration": {
"map": [
-0.0017921234567,
0.0000000004,
1.0000000004,
0.0017921234567,
-0.0000000004,
2.0000000004
]
}
}
}
],
"settings": {
"glow_radius_cm": 150.0000000004,
"fill_colors": {
"on": {
"c": "#abcdef",
"a": 0.5000000004
}
},
"future_numeric": 0.9876543216
},
"future_root": {
"numeric": 0.1234567896
}
},
"configExpected": {
"spaces": [
{
"id": "floor",
"title": "Floor",
"cell_cm": 5.0000000004,
"plan_aspect": 1.5000000004,
"plan_x": 0.123456789,
"plan_y": -0.12345679,
"plan_scale": 1,
"plan_scale_x": 1.234567891,
"plan_scale_y": 0.987654322,
"plan_angle": 44.12345679,
"view_box": [0.0000000004, -0.0000000004, 1.0000000004, 1.0000000004],
"settings": {
"room_opacity": 0.5000000004,
"temp_min": 18.1234567896,
"temp_max": 28.1234567896,
"card_font_scale": 1.0000000004
},
"rooms": [
{
"id": "poly",
"name": "Poly",
"area": null,
"poly": [
[0, 0],
[0.3, 0],
[0, 0.3]
],
"settings": {
"name_scale": 1.0000000004,
"label_scale": 1.2000000004
}
},
{
"id": "rect",
"name": "Rect",
"area": null,
"x": 0.4,
"y": 0.400000001,
"w": 0.2,
"h": 0.200000001
}
],
"walls": [
{
"key": "wall",
"cm": 15.0000000004,
"a": [0, 0.3],
"b": [0.300000001, 0.300000001]
}
],
"openings": [
{
"id": "opening",
"type": "door",
"x": 0.5,
"y": 0,
"angle": 0,
"length": 0.200000001,
"host": {
"kind": "partition",
"id": "partition",
"t": 0.5
}
}
],
"decor": [
{
"id": "line",
"kind": "line",
"x1": 0.1,
"y1": 0.100000001,
"x2": 0.2,
"y2": 0.200000001,
"width_cm": 2.0000000004,
"opacity": 0.7000000004
},
{
"id": "rect-decor",
"kind": "rect",
"x": 0.1,
"y": 0.200000001,
"w": 0.3,
"h": 0.400000001,
"angle": 12.12345679,
"fill_opacity": 0.6000000004
},
{
"id": "ellipse",
"kind": "ellipse",
"x": -0.1,
"y": -0.200000001,
"w": 0.300000001,
"h": 0.4,
"angle": -12.12345679
},
{
"id": "text",
"kind": "text",
"x": 0.7,
"y": 0.800000001,
"text": "Text",
"scale": 1.234567891,
"angle": 2.12345679,
"size_cm": 25.0000000004
},
{
"id": "chair",
"kind": "furniture",
"symbol": "chair",
"x": 0.6,
"y": 0.600000001,
"w": 0.1,
"h": 0.100000001,
"angle": 90.12345679
}
],
"room_drafts": [
{
"id": "draft",
"points": [
[0.1, 0.900000001],
[0.200000001, 0.9],
[0.3, 0.900000001]
],
"segments": [
{"cm": 10.0000000004},
{"cm": 20.0000000004}
]
}
],
"partitions": [
{
"id": "partition",
"a": [0, 0],
"b": [1, 0],
"cm": 12.0000000004
}
],
"wall_columns": [
{
"id": "column",
"shape": "square",
"center": [0.75, 0.750000001],
"cm": 30.0000000004,
"angle": 45.12345679
}
],
"open_spans": [
{
"a": [0.3, 0.300000001],
"b": [0.600000001, 0.3]
}
],
"future": {
"numeric": 0.1234567896
}
}
],
"markers": [
{
"id": "virtual",
"binding": "virtual",
"angle": -30.12345679,
"size": 1.1000000004,
"ripple_size": 1.5000000004,
"glow_radius_cm": 120.0000000004,
"glow_color": {
"c": "#112233",
"bri": 0.5000000004
},
"vacuum": {
"calibration": {
"map": [
-0.0017921234567,
0.0000000004,
1.0000000004,
0.0017921234567,
-0.0000000004,
2.0000000004
]
}
}
}
],
"settings": {
"glow_radius_cm": 150.0000000004,
"fill_colors": {
"on": {
"c": "#abcdef",
"a": 0.5000000004
}
},
"future_numeric": 0.9876543216
},
"future_root": {
"numeric": 0.1234567896
}
},
"layoutInput": {
"virtual": {
"s": "floor",
"x": 0.1234567894,
"y": -0.1234567896,
"k": 1.2000000004,
"future_numeric": 0.9876543216
},
"rl:poly": {
"s": "floor",
"x": -0.0000000004,
"y": 0.7500000006,
"k": 1.5000000004
}
},
"layoutExpected": {
"virtual": {
"s": "floor",
"x": 0.123456789,
"y": -0.12345679,
"k": 1.2000000004,
"future_numeric": 0.9876543216
},
"rl:poly": {
"s": "floor",
"x": 0,
"y": 0.750000001,
"k": 1.5000000004
}
}
}
+23
View File
@@ -9,6 +9,7 @@ import {
import {
polygonSegments, splitAtIntersections, visibilityPolygon,
} from '../test-build/light-visibility.js';
import { canonicalizeConfigGeometry } from '../test-build/coordinate-canonicalization.js';
const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
@@ -162,6 +163,28 @@ test('six-room ULP topology keeps a complete visible floor and is permutation-st
closeTo(geometryArea(direct), 0.31835083680549986, 1e-9);
});
test('write canonicalization cleans the six-room #218 topology before union', () => {
const config = {
spaces: [{
rooms: noisySixRoomFloor.map((poly, index) => ({
id: `r${index}`, name: `Room ${index}`, area: null, poly,
})),
}],
markers: [],
settings: {},
};
const clean = canonicalizeConfigGeometry(config);
const outlines = clean.spaces[0].rooms.map((room) => room.poly);
assert.equal(outlines[0][0][0], outlines[0][2][0]);
assert.equal(outlines[2][2][1], outlines[3][0][1]);
assert.ok(unionBodies(outlines), 'canonical persisted rooms have a valid union');
const fan = [[-0.1, -0.1], [1.1, -0.1], [1.1, 1.1], [-0.1, 1.1]];
assert.ok(intersectionPaths([fan], outlines).length > 0, 'Glow clip remains non-empty');
assert.deepEqual(config.spaces[0].rooms.map((room) => room.poly), noisySixRoomFloor,
'write canonicalization never mutates the editor candidate');
});
test('one malformed room is diagnosed and cannot erase healthy lit floor', () => {
const fan = [[-1, -1], [6, -1], [6, 3], [-1, 3]];
const healthy = [[3, 0], [5, 0], [5, 2], [3, 2]];
@@ -0,0 +1,104 @@
"""Shared coordinate canonicalisation contract (#224)."""
from __future__ import annotations
import copy
import json
import math
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from custom_components.houseplan import virtual_lights
from custom_components.houseplan.coordinate_canonicalization import (
COORDINATE_DECIMALS,
canonicalize_config_geometry,
canonicalize_layout_geometry,
canonicalize_number,
)
from custom_components.houseplan.store import (
async_save_config_state,
async_save_layout_state,
)
from custom_components.houseplan.validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
POS_SCHEMA,
)
FIXTURE = (
Path(__file__).parents[1]
/ "test"
/ "fixtures"
/ "coordinate-canonicalization.json"
)
def _fixture() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_python_and_frontend_share_the_nine_decimal_fixture_contract() -> None:
fixture = _fixture()
config_input = fixture["configInput"]
layout_input = fixture["layoutInput"]
config_before = copy.deepcopy(config_input)
layout_before = copy.deepcopy(layout_input)
assert COORDINATE_DECIMALS == fixture["decimals"]
config = canonicalize_config_geometry(config_input)
layout = canonicalize_layout_geometry(layout_input)
assert config == fixture["configExpected"]
assert layout == fixture["layoutExpected"]
assert config_input == config_before
assert layout_input == layout_before
assert canonicalize_config_geometry(config) == config
assert canonicalize_layout_geometry(layout) == layout
assert math.copysign(1.0, layout["rl:poly"]["x"]) == 1.0
def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
assert canonicalize_number(1.2345678905) == 1.234567891
assert canonicalize_number(-1.2345678905) == -1.234567891
value = 0.20833333333333334
assert canonicalize_number(value) == 0.208333333
assert abs(canonicalize_number(value) - value) <= 5e-10
assert math.isnan(canonicalize_number(float("nan")))
assert canonicalize_number(float("inf")) == float("inf")
def test_backend_schemas_apply_the_same_allowlist() -> None:
fixture = _fixture()
assert CONFIG_SCHEMA(fixture["configInput"]) == fixture["configExpected"]
assert LAYOUT_SCHEMA(fixture["layoutInput"]) == fixture["layoutExpected"]
assert POS_SCHEMA(fixture["layoutInput"]["virtual"]) == fixture["layoutExpected"]["virtual"]
@pytest.mark.asyncio
async def test_storage_helpers_are_the_final_canonical_barrier(monkeypatch) -> None:
fixture = _fixture()
reconcile = AsyncMock()
monkeypatch.setattr(virtual_lights, "async_reconcile_virtual_lights", reconcile)
config_store = AsyncMock()
config_store.async_load.return_value = {"rev": 0}
config_runtime = SimpleNamespace(
config_store=config_store,
virtual_light_store=object(),
)
payload = await async_save_config_state(
config_runtime, fixture["configInput"], 1, previous_rev=0
)
assert payload["config"] == fixture["configExpected"]
assert config_store.async_save.await_args.args[0] == payload
assert reconcile.await_args.args[1] == fixture["configExpected"]
layout_store = AsyncMock()
layout_runtime = SimpleNamespace(store=layout_store)
layout_payload = await async_save_layout_state(
layout_runtime, {}, fixture["layoutInput"], 1
)
assert layout_payload["layout"] == fixture["layoutExpected"]
assert layout_store.async_save.await_args.args[0] == layout_payload
+12
View File
@@ -88,6 +88,18 @@ def _document(tmp_path: Path, kind: str = "full") -> dict:
return document
def test_import_document_canonicalizes_external_coordinates(tmp_path: Path) -> None:
document = _document(tmp_path)
room = document["payload"]["config"]["spaces"][0]["rooms"][0]
room["poly"][0][0] = 0.1234567896
document["payload"]["layout"]["lamp"]["x"] = -0.1234567896
parsed = parse_document(json.dumps(document).encode("utf-8"))
assert parsed["payload"]["config"]["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
assert parsed["payload"]["layout"]["lamp"]["x"] == -0.12345679
# --- issue #225: an attachment url carries a cache-buster ---------------------
#
# Legacy references look like "/houseplan_files/files/m1/doc.pdf?v=1783170649".
+153
View File
@@ -172,6 +172,159 @@ async def test_config_rev_conflict(hass: HomeAssistant, hass_ws_client: WebSocke
assert resp["result"]["rev"] == 1
async def test_canonical_rewrites_are_noops_without_events_or_undo_loss(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""#224: a read/write echo must not manufacture another edit."""
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
config_events = []
layout_events = []
hass.bus.async_listen(
"houseplan_config_updated", lambda event: config_events.append(event.data)
)
hass.bus.async_listen(
"houseplan_layout_updated", lambda event: layout_events.append(event.data)
)
noisy_config = {
"spaces": [{
"id": "floor",
"title": "Floor",
"view_box": [0, 0, 1, 1],
"rooms": [{
"id": "room",
"name": "Room",
"poly": [[0.1234567896, 0], [0.5, 0], [0.5, 0.5]],
}],
}],
"markers": [],
"settings": {},
}
await client.send_json_auto_id({
"type": "houseplan/config/set", "config": noisy_config, "expected_rev": 0,
})
first = await client.receive_json()
assert first["success"] and first["result"]["rev"] == 1
await client.send_json_auto_id({"type": "houseplan/config/get"})
canonical_config = (await client.receive_json())["result"]["config"]
assert canonical_config["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
runtime = get_data(hass)
assert runtime is not None
layout_data = await runtime.store.async_load() or {}
layout_data["optimize_backup"] = {"sentinel": True}
await runtime.store.async_save(layout_data)
await client.send_json_auto_id({
"type": "houseplan/config/set", "config": canonical_config, "expected_rev": 1,
})
noop = await client.receive_json()
assert noop["success"] and noop["result"]["rev"] == 1
assert len(config_events) == 1
assert "optimize_backup" in (await runtime.store.async_load())
# CAS remains authoritative even when the body is otherwise identical.
await client.send_json_auto_id({
"type": "houseplan/config/set", "config": canonical_config, "expected_rev": 0,
})
stale = await client.receive_json()
assert not stale["success"] and stale["error"]["code"] == "conflict"
noisy_layout = {
"lamp": {"s": "floor", "x": 0.1234567896, "y": -0.1234567896}
}
await client.send_json_auto_id({
"type": "houseplan/layout/set", "layout": noisy_layout, "expected_rev": 0,
})
first_layout = await client.receive_json()
assert first_layout["success"] and first_layout["result"]["rev"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
canonical_layout = (await client.receive_json())["result"]["layout"]
assert canonical_layout["lamp"]["x"] == 0.12345679
layout_data = await runtime.store.async_load() or {}
layout_data["optimize_backup"] = {"sentinel": True}
await runtime.store.async_save(layout_data)
await client.send_json_auto_id({
"type": "houseplan/layout/set",
"layout": canonical_layout,
"expected_rev": 1,
})
layout_noop = await client.receive_json()
assert layout_noop["success"] and layout_noop["result"]["rev"] == 1
await client.send_json_auto_id({
"type": "houseplan/layout/update",
"device_id": "lamp",
"pos": canonical_layout["lamp"],
})
point_noop = await client.receive_json()
assert point_noop["success"] and point_noop["result"]["rev"] == 1
assert len(layout_events) == 1
assert "optimize_backup" in (await runtime.store.async_load())
async def test_optimize_undo_restores_geometry_but_not_legacy_noisy_bits(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""#224 supersedes #223's invisible exact-bit Undo promise."""
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
runtime = get_data(hass)
assert runtime is not None
noisy_config = {
"spaces": [{
"id": "floor",
"title": "Floor",
"view_box": [0, 0, 1, 1],
"rooms": [{
"id": "room",
"name": "Room",
"poly": [[0.1234567896, 0], [0.5, 0], [0.5, 0.5]],
}],
}],
"markers": [],
"settings": {},
}
noisy_layout = {
"lamp": {"s": "floor", "x": -0.1234567896, "y": 0.5}
}
# Seed the pre-#224 store directly: public writers can no longer create it.
await runtime.config_store.async_save({"config": noisy_config, "rev": 1})
await runtime.store.async_save({"layout": noisy_layout, "rev": 1})
await client.send_json_auto_id({
"type": "houseplan/plan/optimize",
"config": noisy_config,
"layout": noisy_layout,
"expected_config_rev": 1,
"expected_layout_rev": 1,
})
optimized = await client.receive_json()
assert optimized["success"]
stored_layout = await runtime.store.async_load()
backup = stored_layout["optimize_backup"]
assert backup["config"]["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
assert backup["layout"]["lamp"]["x"] == -0.12345679
await client.send_json_auto_id({
"type": "houseplan/plan/optimize_undo",
"expected_config_rev": 2,
"expected_layout_rev": 2,
})
undone = await client.receive_json()
assert undone["success"] and undone["result"]["can_undo"] is False
restored_config = (await runtime.config_store.async_load())["config"]
restored_layout = (await runtime.store.async_load())["layout"]
assert restored_config["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
assert restored_layout["lamp"]["x"] == -0.12345679
async def test_config_set_validates_new_marker_light_links(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
) -> None:
+1
View File
@@ -25,6 +25,7 @@
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
"src/render-device-snapshot.ts",
"src/command-stack.ts",
"src/coordinate-canonicalization.ts",
"src/align-grid.ts",
"src/plan-optimizer.ts",
"src/furniture.ts",