"""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)