fix: keep Optimize idempotent across storage reload

Issue: #248
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 01:13:27 +03:00
parent 56fe4240d1
commit cbdd5a7e73
19 changed files with 636 additions and 173 deletions
@@ -33,6 +33,12 @@ FIXTURE = (
/ "fixtures"
/ "coordinate-canonicalization.json"
)
OPTIMIZE_ROUNDTRIP_FIXTURE = (
Path(__file__).parents[1]
/ "test"
/ "fixtures"
/ "optimize-storage-roundtrip.json"
)
def _fixture() -> dict:
@@ -76,6 +82,17 @@ def test_backend_schemas_apply_the_same_allowlist() -> None:
assert POS_SCHEMA(fixture["layoutInput"]["virtual"]) == fixture["layoutExpected"]["virtual"]
def test_optimize_roundtrip_fixture_has_one_backend_canonical_target() -> None:
"""#248: Python consumes the same cross-runtime target as the Node test."""
fixture = json.loads(OPTIMIZE_ROUNDTRIP_FIXTURE.read_text(encoding="utf-8"))
source = fixture["input"]
expected = fixture["expected"]
assert canonicalize_config_geometry(source["config"]) == expected["config"]
assert canonicalize_layout_geometry(source["layout"]) == expected["layout"]
assert CONFIG_SCHEMA(source["config"]) == expected["config"]
assert LAYOUT_SCHEMA(source["layout"]) == expected["layout"]
@pytest.mark.asyncio
async def test_storage_helpers_are_the_final_canonical_barrier(monkeypatch) -> None:
fixture = _fixture()
+43
View File
@@ -1627,6 +1627,49 @@ async def test_setup_recovers_durable_import_pair(
assert "optimize_pending" not in layout_data
async def test_setup_recovers_exact_optimize_storage_roundtrip_pair(
hass: HomeAssistant,
) -> None:
"""#248: startup recovery converges on the same shared canonical target."""
fixture = json.loads((
Path(__file__).parents[1]
/ "test"
/ "fixtures"
/ "optimize-storage-roundtrip.json"
).read_text(encoding="utf-8"))
expected = fixture["expected"]
entry = await _setup(hass)
rt = get_data(hass)
assert rt is not None
await rt.config_store.async_save({"config": DEFAULT_CONFIG, "rev": 1})
await rt.store.async_save({
"layout": {},
"rev": 1,
"optimize_backup": {"sentinel": True},
"optimize_pending": {
"kind": "optimize",
"config": expected["config"],
"layout": expected["layout"],
"config_rev": 2,
"layout_rev": 2,
"clear_backup": False,
},
})
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
recovered = get_data(hass)
assert recovered is not None
config_data = await recovered.config_store.async_load()
layout_data = await recovered.store.async_load()
assert config_data == {"config": expected["config"], "rev": 2}
assert layout_data["layout"] == expected["layout"]
assert layout_data["rev"] == 2
assert layout_data["optimize_backup"] == {"sentinel": True}
assert "optimize_pending" not in layout_data
async def test_apply_rechecks_plan_file_under_the_write_lock(
hass: HomeAssistant, tmp_path: Path,
) -> None:
+62
View File
@@ -1,4 +1,8 @@
"""WebSocket API tests (CI): layout ops, config rev conflict, not_ready gate."""
import copy
import json
from pathlib import Path
import pytest
@@ -530,6 +534,64 @@ async def test_optimize_undo_restores_geometry_but_not_legacy_noisy_bits(
assert restored_layout["lamp"]["x"] == -0.12345679
async def test_plan_optimize_persists_exact_storage_roundtrip_target(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""#248: intent, normal commit and reads expose one canonical pair."""
from custom_components.houseplan.store import get_data
fixture = json.loads((
Path(__file__).parents[1]
/ "test"
/ "fixtures"
/ "optimize-storage-roundtrip.json"
).read_text(encoding="utf-8"))
source = fixture["input"]
expected = fixture["expected"]
await _setup(hass)
client = await hass_ws_client(hass)
runtime = get_data(hass)
assert runtime is not None
layout_writes = []
real_layout_save = runtime.store.async_save
async def capture_layout_write(value: dict) -> None:
layout_writes.append(copy.deepcopy(value))
await real_layout_save(value)
monkeypatch.setattr(runtime.store, "async_save", capture_layout_write)
await client.send_json_auto_id({
"type": "houseplan/plan/optimize",
"config": source["config"],
"layout": source["layout"],
"expected_config_rev": 0,
"expected_layout_rev": 0,
})
response = await client.receive_json()
assert response["success"]
assert response["result"]["config_rev"] == 1
assert response["result"]["layout_rev"] == 1
intent_write = next(
item for item in layout_writes if "optimize_pending" in item
)
pending = intent_write["optimize_pending"]
final_config = await runtime.config_store.async_load()
final_layout = await runtime.store.async_load()
assert pending["config"] == expected["config"]
assert pending["layout"] == expected["layout"]
assert final_config["config"] == expected["config"]
assert final_layout["layout"] == expected["layout"]
assert "optimize_pending" not in final_layout
assert json.dumps(pending["config"], sort_keys=True, separators=(",", ":")) == \
json.dumps(final_config["config"], sort_keys=True, separators=(",", ":"))
assert json.dumps(pending["layout"], sort_keys=True, separators=(",", ":")) == \
json.dumps(final_layout["layout"], sort_keys=True, separators=(",", ":"))
async def test_config_set_validates_new_marker_light_links(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
) -> None: