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