Files
epaper-dashboard/tests/test_add_route_no_repack.py
ki c6919c5104 BUG-04: Add-Item ohne Re-Pack (first-fit)
Beim Hinzufügen eines neuen Widgets via /api/layout/add rief der Server
layout_mod.pack() auf alle Items auf — pack() sortiert nach Fläche
absteigend und platziert scan-line greedy. Ein 1x1 hello konnte dabei
einen 2x1 spotify aus seiner Position drängen, weil die Sort-Reihenfolge
sich ändert sobald ein neues Item im Mix ist.

Reproduktion vor dem Fix:
  Bestehende config: c1@(0,0) w1@(2,0) st1@(0,2) sp1@(2,2) sv1@(2,3)
  Add hello (1x1) → w1 wurde nach (0,2) verschoben, hello landete bei (2,0).
  Siehe RED-Test in tests/test_add_route_no_repack.py.

Fix:
- layout.py: neue Funktion first_fit(item, others) — platziert ein Item in
  der ersten freien scan-line-Zelle OHNE andere Items zu verändern.
- admin.py /api/layout/add nutzt first_fit. Wenn kein Platz: HTTP 409 mit
  {ok:false, error:'kein Platz für WxH-Item', hint:'use_auto_pack'}.
- templates/index.html addItem(): 409 als 'warn'-Toast mit Auto-Pack-Hinweis.

Akzeptanzkriterien (BUG-04):
- bestehende Items bleiben bei Add unverändert an (x,y)
- neues Item landet in erster freier Zelle
- voller Grid → 409, kein bestehendes Item verschoben
- Auto-Pack bleibt als expliziter User-Wunsch erhalten (BUG-06)

Tests:
- tests/test_layout_firstfit.py: 3 unit tests (empty, gappy, full grid)
- tests/test_add_route_no_repack.py: 2 integration tests gegen /api/layout/add
  mit gemocktem dashboard-Modul + Flask test_client

Closes #6
2026-08-29 17:20:22 +04:00

126 lines
5.9 KiB
Python

"""RED-Test für BUG-04: Verifiziert dass die echte admin.py /api/layout/add
Route die bestehende pack()-Semantik aufruft (also BUG bestätigt).
Verwendet Flask test_client, kein Live-Server.
"""
import sys, os, json, tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# admin.py benutzt dashboard_mod.load_config etc. — wir mocken das minimal.
import unittest
from unittest.mock import patch, MagicMock
def make_admin_app():
"""Importiert admin mit gemockten dashboard-Funktionen. Singleton — beim
zweiten Aufruf wird der bestehende Mock wiederverwendet, damit Tests sich
gegenseitig konfigurieren können."""
import importlib
import types
# bestehender Mock? dann wiederverwenden
existing = sys.modules.get("dashboard")
if existing is None:
fake_dashboard = types.ModuleType("dashboard")
fake_dashboard.load_config = MagicMock(return_value={
"version": 2,
"refresh_interval_s": 180,
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 2, "h": 1},
{"id": "sv1","plugin": "strava", "x": 2, "y": 3, "w": 2, "h": 1},
]},
"plugin_configs": {},
})
fake_dashboard.save_config = MagicMock()
fake_dashboard.get_widget_classes = MagicMock(return_value={"hello": MagicMock()})
sys.modules["dashboard"] = fake_dashboard
else:
fake_dashboard = existing
admin = importlib.import_module("admin")
admin.app.config["TESTING"] = True
admin.require_auth = lambda: None
return admin, fake_dashboard
class TestAddRouteDoesNotRepack(unittest.TestCase):
def setUp(self):
# Reset Mock-Return auf den Default vor jedem Test
admin, fake = make_admin_app()
self._default_cfg = {
"version": 2,
"refresh_interval_s": 180,
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 2, "h": 1},
{"id": "sv1","plugin": "strava", "x": 2, "y": 3, "w": 2, "h": 1},
]},
"plugin_configs": {},
}
fake.load_config.return_value = self._default_cfg
fake.save_config.reset_mock()
self.admin, self.fake = admin, fake
self.client = admin.app.test_client()
def test_add_1x1_into_gap_does_not_move_existing(self):
"""Bestehende config mit Lücke bei (2,3),(3,3). Add hello (1x1) soll
auf (2,3) gehen (erste scan-line freie Zelle) — alle anderen UNVERÄNDERT."""
self.fake.load_config.return_value = {
"version": 2,
"refresh_interval_s": 180,
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 1, "h": 1},
{"id": "sv1","plugin": "strava", "x": 3, "y": 2, "w": 1, "h": 1},
]},
"plugin_configs": {},
}
r = self.client.post("/api/layout/add", data={"plugin": "hello"})
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
j = r.get_json()
self.assertTrue(j["ok"], j)
items = j["items"]
by_id = {it["id"]: it for it in items}
# Existierende UNVERÄNDERT
self.assertEqual((by_id["c1"]["x"], by_id["c1"]["y"]), (0, 0), "clock moved!")
self.assertEqual((by_id["w1"]["x"], by_id["w1"]["y"]), (2, 0), "weather moved!")
self.assertEqual((by_id["st1"]["x"], by_id["st1"]["y"]), (0, 2), "system moved!")
self.assertEqual((by_id["sp1"]["x"], by_id["sp1"]["y"]), (2, 2), "spotify moved!")
self.assertEqual((by_id["sv1"]["x"], by_id["sv1"]["y"]), (3, 2), "strava moved!")
# Neues hello auf (2,3) — erste scan-line freie Zelle
new_items = [it for it in items if it["plugin"] == "hello" and it["id"] not in ("c1","w1","st1","sp1","sv1")]
self.assertEqual(len(new_items), 1, f"expected exactly 1 new hello, got {new_items}")
new = new_items[0]
self.assertEqual((new["x"], new["y"]), (2, 3),
f"expected new hello at (2,3), got ({new['x']},{new['y']}); full: {items}")
def test_add_to_full_grid_returns_409(self):
"""Wenn kein Platz: 409, keine bestehenden Items verändert."""
self.fake.load_config.return_value["layout"]["items"] = [
{"id": f"f{i}", "plugin": "x", "x": (i % 4), "y": (i // 4),
"w": 1, "h": 1} for i in range(16)
]
before = [(it["id"], it["x"], it["y"]) for it in
self.fake.load_config.return_value["layout"]["items"]]
r = self.client.post("/api/layout/add", data={"plugin": "hello"})
self.assertEqual(r.status_code, 409, r.get_data(as_text=True))
j = r.get_json()
self.assertFalse(j["ok"])
self.assertIn("Auto-Pack", j.get("error", ""))
# In-memory config unverändert
after = [(it["id"], it["x"], it["y"]) for it in
self.fake.load_config.return_value["layout"]["items"]]
self.assertEqual(before, after, "full-grid add still mutated state")
if __name__ == "__main__":
unittest.main(verbosity=2)