mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
Vendored
+45
-45
File diff suppressed because one or more lines are too long
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Каждая запись конфигурации и раскладки устройств теперь приводит геометрию к
|
||||
девяти десятичным знакам, не привязывая её к сетке. Невидимые
|
||||
floating-point-хвосты удаляются до того, как смогут сломать общие стены,
|
||||
объединение комнат или Glow; тот же инвариант действует для импорта,
|
||||
восстановления служебных операций и серверной отмены. Повторное сохранение
|
||||
уже канонических данных больше не создаёт ревизию и не съедает одношаговую
|
||||
отмену. Старый план очищается при следующей записи либо сразу через
|
||||
«Оптимизировать планы»
|
||||
([#224](https://github.com/Matysh/houseplan-card/issues/224)).
|
||||
|
||||
- Точность сетки больше не меняет внешний вид одного и того же физического
|
||||
плана. Контуры комнат и стен, проёмы и их зоны попадания, подсказки редактора,
|
||||
статическая карточка и скрытая изометрия сохраняют эталонный вид
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1332,6 +1332,17 @@ show_signal: true
|
||||
|
||||
## 19. Обслуживание планов
|
||||
|
||||
Обычное сохранение конфигурации или позиции устройства автоматически убирает
|
||||
из координат невидимый вычислительный шум. Это **не привязка к сетке**:
|
||||
диагональные стены, свободный декор и другие допустимые положения остаются на
|
||||
своих местах. Импорт и серверная отмена используют то же правило. Повторное
|
||||
сохранение уже неизменного плана не создаёт новую ревизию и не отменяет
|
||||
доступность последней служебной отмены.
|
||||
|
||||
Существующий план не переписывается при простом открытии. Его геометрия
|
||||
очистится при следующем изменении; если очистить всё нужно сразу, используйте
|
||||
явную оптимизацию ниже.
|
||||
|
||||
Кнопка **Общие настройки → Оптимизировать планы** запускает явное обслуживание. Сначала показывается точный предпросмотр; данные не меняются до подтверждения.
|
||||
|
||||
### Что делает оптимизация
|
||||
@@ -1365,7 +1376,7 @@ show_signal: true
|
||||
|
||||
### Риск и отмена
|
||||
|
||||
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Новый edit делает резервную копию оптимизации устаревшей.
|
||||
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей.
|
||||
|
||||
Обычное открытие и редактирование плана не удаляет даже очень короткие точные
|
||||
границы толщины. Описанное схлопывание выполняется только после явного
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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".
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user