Files
homelable/backend/tests/test_zone_size_migration.py
T
Pouzor 851141951d fix(canvas): store a zone's size in the width/height columns
Every node type persisted its size in `nodes.width` / `nodes.height` except
`groupRect`, which stashed it inside the `custom_colors` JSON next to its
colours. The columns already existed and were simply unused for zones, so
this was an inconsistency rather than a missing-column workaround, and it
put geometry in a blob that otherwise holds style.

The serializer now writes the columns for a zone too, and strips the legacy
`width`/`height` keys out of the blob so the two cannot drift apart and
leave an older canvas reading a stale size.

No data is lost on upgrade:

- `_backfill_zone_size` copies the blob geometry into the columns at
  startup. It only fills a column that is still NULL, so it cannot overwrite
  a size set since; it parses the JSON in Python rather than with
  `json_extract`, so it does not depend on the SQLite build carrying JSON1;
  and an unreadable row is skipped without costing the others their size.
  Re-running it is a no-op.
- the reader still falls back to the blob, covering a payload the backfill
  has not reached — an older server, or an import.

Standalone mode is unaffected: it stores React Flow nodes verbatim, so the
size was always on `node.width` / `node.height` there.

The four serializer tests that pinned the size to the blob now assert the
columns, since that is the behaviour being changed.

ha-relevant: yes
2026-08-27 01:45:23 +02:00

140 lines
4.7 KiB
Python

"""Moving a zone's size out of the custom_colors blob into the real columns.
Every node type stored its size in `nodes.width` / `nodes.height` except
`groupRect`, which kept it inside the style JSON. Get this backfill wrong and
every zone a user ever drew comes back at the default 360x240, losing a layout
they arranged by hand.
"""
import json
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
import app.db.database as database
pytestmark = pytest.mark.asyncio
_DDL = (
"CREATE TABLE nodes ("
"id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
"custom_colors JSON, width FLOAT, height FLOAT)"
)
async def _engine(tmp_path, rows):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'zones.db'}")
async with engine.begin() as conn:
await conn.exec_driver_sql(_DDL)
for node_id, node_type, colors, width, height in rows:
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, custom_colors, width, height) "
"VALUES (?, ?, ?, ?, ?, ?)",
(node_id, node_type, node_id, json.dumps(colors) if colors else None, width, height),
)
return engine
async def _sizes(engine) -> dict[str, tuple]:
async with engine.begin() as conn:
rows = (await conn.exec_driver_sql("SELECT id, width, height FROM nodes")).fetchall()
return {r[0]: (r[1], r[2]) for r in rows}
async def _run(engine, monkeypatch) -> None:
monkeypatch.setattr(database, "engine", engine)
await database._backfill_zone_size()
async def test_moves_the_size_from_the_blob_to_the_columns(tmp_path, monkeypatch):
engine = await _engine(
tmp_path,
[("z1", "groupRect", {"width": 640, "height": 480, "border": "#0ff"}, None, None)],
)
await _run(engine, monkeypatch)
assert (await _sizes(engine))["z1"] == (640, 480)
# The style the blob actually owns is untouched.
async with engine.begin() as conn:
blob = (await conn.exec_driver_sql("SELECT custom_colors FROM nodes")).scalar()
assert json.loads(blob)["border"] == "#0ff"
await engine.dispose()
async def test_never_overwrites_a_size_already_in_the_columns(tmp_path, monkeypatch):
# A zone resized since the upgrade: the column is the truth, and a stale
# blob left over from before must not win.
engine = await _engine(
tmp_path,
[("z1", "groupRect", {"width": 111, "height": 222}, 800, None)],
)
await _run(engine, monkeypatch)
assert (await _sizes(engine))["z1"] == (800, 222)
await engine.dispose()
async def test_leaves_other_node_types_alone(tmp_path, monkeypatch):
# Only zones ever stashed their size in the blob. A width key on any other
# type is not geometry we should be moving.
engine = await _engine(tmp_path, [("s1", "server", {"width": 999}, None, None)])
await _run(engine, monkeypatch)
assert (await _sizes(engine))["s1"] == (None, None)
await engine.dispose()
async def test_skips_a_blob_with_no_usable_size(tmp_path, monkeypatch):
engine = await _engine(
tmp_path,
[
("colors_only", "groupRect", {"border": "#0ff"}, None, None),
("not_a_number", "groupRect", {"width": "wide", "height": True}, None, None),
("no_blob", "groupRect", None, None, None),
],
)
await _run(engine, monkeypatch)
sizes = await _sizes(engine)
assert sizes["colors_only"] == (None, None)
assert sizes["not_a_number"] == (None, None)
assert sizes["no_blob"] == (None, None)
await engine.dispose()
async def test_survives_a_corrupt_blob_without_killing_boot(tmp_path, monkeypatch):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'zones.db'}")
async with engine.begin() as conn:
await conn.exec_driver_sql(_DDL)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, custom_colors) VALUES ('bad', 'groupRect', 'b', '{oops')"
)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, custom_colors) "
"VALUES ('good', 'groupRect', 'g', '{\"width\": 500, \"height\": 400}')"
)
await _run(engine, monkeypatch)
sizes = await _sizes(engine)
assert sizes["bad"] == (None, None)
# One unreadable row must not cost the others their size.
assert sizes["good"] == (500, 400)
await engine.dispose()
async def test_is_idempotent(tmp_path, monkeypatch):
engine = await _engine(
tmp_path,
[("z1", "groupRect", {"width": 640, "height": 480}, None, None)],
)
await _run(engine, monkeypatch)
await _run(engine, monkeypatch)
assert (await _sizes(engine))["z1"] == (640, 480)
await engine.dispose()