diff --git a/.gitignore b/.gitignore index 1e5d484..c485705 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ minimax_*.png config.json *.service .backup/ +.venv/ +tests/__pycache__/ diff --git a/tests/test_add_route_no_repack.py b/tests/test_add_route_no_repack.py new file mode 100644 index 0000000..99ff219 --- /dev/null +++ b/tests/test_add_route_no_repack.py @@ -0,0 +1,125 @@ +"""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) diff --git a/tests/test_layout_firstfit.py b/tests/test_layout_firstfit.py new file mode 100644 index 0000000..dbb6fe0 --- /dev/null +++ b/tests/test_layout_firstfit.py @@ -0,0 +1,56 @@ +"""Tests für layout.first_fit (BUG-04). + +Behauptung: Add-Item darf bestehende Items NICHT verschieben. +- Leeres Layout → Add 2×2 hello → Position (0,0), kein Pack +- Layout mit Lücke → Add 1×1 hello → landet in erster Lücke, andere bleiben +- Layout voll → Add → None, kein anderes Item verändert +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import unittest +from layout import Item, first_fit, GRID_COLS, GRID_ROWS + + +class TestFirstFit(unittest.TestCase): + def test_first_fit_empty_grid(self): + """Auf leerem Grid wird Item bei (0,0) platziert — kein Pack nötig.""" + new_item = Item("new1", "hello", 0, 0, 1, 1) + placed = first_fit(new_item, []) + self.assertIsNotNone(placed) + self.assertEqual((placed.x, placed.y), (0, 0)) + + def test_first_fit_does_not_move_existing(self): + """Items mit Lücke: hello wird in Lücke gesetzt, andere bleiben.""" + existing = [ + Item("a", "clock", 0, 0, 2, 2), + Item("b", "weather", 2, 0, 2, 2), + Item("c", "system", 0, 2, 2, 2), + Item("d", "spotify", 2, 2, 2, 1), + ] + before = {(it.id, it.x, it.y) for it in existing} + new_item = Item("new", "hello", 0, 0, 1, 1) + placed = first_fit(new_item, existing) + self.assertIsNotNone(placed) + after = {(it.id, it.x, it.y) for it in existing} + self.assertEqual(before, after, f"EXISTING MOVED! before={before} after={after}") + self.assertEqual((placed.x, placed.y), (2, 3)) + + def test_first_fit_full_grid_returns_none(self): + """Wenn kein Platz: None zurück, keine Mutation.""" + existing = [ + Item("a", "x", 0, 0, 2, 2), + Item("b", "y", 2, 0, 2, 2), + Item("c", "z", 0, 2, 2, 2), + Item("d", "w", 2, 2, 2, 2), + ] + before = {(it.id, it.x, it.y, it.w, it.h) for it in existing} + new_item = Item("new", "hello", 0, 0, 1, 1) + placed = first_fit(new_item, existing) + self.assertIsNone(placed) + after = {(it.id, it.x, it.y, it.w, it.h) for it in existing} + self.assertEqual(before, after) + + +if __name__ == "__main__": + unittest.main(verbosity=2)