diff --git a/tests/test_dash_layout.py b/tests/test_dash_layout.py new file mode 100644 index 0000000..a77eb84 --- /dev/null +++ b/tests/test_dash_layout.py @@ -0,0 +1,120 @@ +"""The dashboard layout (static/dash-layout.js) holds still (#5713): where a +widget sits depends on the saved layout and the column count, never on how +tall each widget's content turned out. Content arrives widget by widget and +changes on every poll, so a placement that followed the heights moved widgets +between columns as the page loaded. + +These run the real dash-layout.js under node, which CI's job container has +(its checkout action runs on it). Without node the test skips locally and +fails in CI, so it can't pass by not running.""" +from __future__ import annotations + +import json +import os +import random +import shutil +import subprocess +from pathlib import Path + +import pytest + +LAYOUT_JS = Path(__file__).resolve().parents[1] / "steward" / "static" / "dash-layout.js" +NODE = shutil.which("node") + +RUNNER = """ +const fs = require("fs"); +global.window = {}; +eval(fs.readFileSync(process.argv[1], "utf8")); +const L = window.StewardDashLayout; +const cases = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(cases.map(c => L.pack(c.items, c.cols)))); +""" + + +def _pack(cases: list[dict]) -> list[dict]: + if not NODE: + if os.environ.get("CI"): + pytest.fail("node is missing from the CI image; the layout test needs it") + pytest.skip("needs node") + out = subprocess.run([NODE, "-e", RUNNER, str(LAYOUT_JS)], input=json.dumps(cases), + capture_output=True, text=True, timeout=60, check=True) + return json.loads(out.stdout) + + +# A saved 12-column layout of the kind the dashboard holds, in saved rows. +SAVED = [ + {"id": "overview", "x": 0, "y": 0, "w": 6, "h": 4}, + {"id": "alerts", "x": 6, "y": 0, "w": 6, "h": 3}, + {"id": "hosts", "x": 6, "y": 3, "w": 6, "h": 5}, + {"id": "monitors", "x": 0, "y": 4, "w": 4, "h": 4}, + {"id": "docker", "x": 4, "y": 4, "w": 2, "h": 6}, + {"id": "routers", "x": 0, "y": 8, "w": 4, "h": 5}, + {"id": "chart", "x": 6, "y": 8, "w": 6, "h": 4}, + {"id": "snmp", "x": 4, "y": 10, "w": 2, "h": 3}, + {"id": "unifi", "x": 0, "y": 13, "w": 12, "h": 3}, +] +SPLIT = 7 # view rows per saved row (ROW_SPLIT) + + +def _viewed(fits: dict[str, int] | None = None) -> list[dict]: + items = [] + for s in SAVED: + full = s["h"] * SPLIT + items.append({"id": s["id"], "x": s["x"], "y": s["y"] * SPLIT, "w": s["w"], + "h": (fits or {}).get(s["id"], full), "full": full}) + return items + + +def _random_fits(rng: random.Random) -> dict[str, int]: + """Content heights as they might land mid-load: some widgets still at + their saved height (not measured yet), the rest anywhere from one saved + row up to the saved height.""" + return {s["id"]: rng.randint(SPLIT, s["h"] * SPLIT) for s in SAVED if rng.random() < 0.8} + + +def _overlap(a: dict, b: dict) -> bool: + return a["x"] < b["x"] + b["w"] and b["x"] < a["x"] + a["w"] + + +def _shape(pos: dict) -> tuple: + """What a reader would notice moving: each widget's column, and which of + two widgets sharing a column is above the other.""" + cols = tuple(sorted((k, p["x"], p["w"]) for k, p in pos.items())) + above = tuple(sorted((a, b) for a in pos for b in pos + if a != b and _overlap(pos[a], pos[b]) and pos[a]["y"] < pos[b]["y"])) + return cols, above + + +@pytest.mark.parametrize("cols", [12, 16, 18, 24]) +def test_no_content_height_moves_a_widget_to_another_column_or_past_another(cols): + rng = random.Random(5713 + cols) + cases = [{"items": _viewed(), "cols": cols}] + [ + {"items": _viewed(_random_fits(rng)), "cols": cols} for _ in range(60)] + results = _pack(cases) + first = _shape(results[0]) + for case, pos in zip(cases[1:], results[1:]): + assert _shape(pos) == first, {i["id"]: i["h"] for i in case["items"]} + + +@pytest.mark.parametrize("cols", [1, 12, 18, 24]) +def test_each_widget_is_its_fitted_height_with_no_overlap_and_no_gap_above(cols): + rng = random.Random(cols) + items = _viewed(_random_fits(rng)) + pos = _pack([{"items": items, "cols": cols}])[0] + fit = {i["id"]: i["h"] for i in items} + assert {k: p["h"] for k, p in pos.items()} == fit + ids = list(pos) + for i, a in enumerate(ids): + pa = pos[a] + assert 0 <= pa["x"] and pa["x"] + pa["w"] <= cols + for b in ids[i + 1:]: + pb = pos[b] + assert not (_overlap(pa, pb) and pa["y"] < pb["y"] + pb["h"] and pb["y"] < pa["y"] + pa["h"]), (a, b) + # Nothing could rise: it sits on the top or on a widget in its columns. + assert pa["y"] == 0 or any(_overlap(pa, pos[o]) and pos[o]["y"] + pos[o]["h"] == pa["y"] + for o in ids if o != a), a + + +def test_twelve_columns_keep_the_saved_columns(): + pos = _pack([{"items": _viewed(), "cols": 12}])[0] + assert {k: (p["x"], p["w"]) for k, p in pos.items()} == {s["id"]: (s["x"], s["w"]) for s in SAVED}