test(dashboard): a widget's column and order don't follow its content height (#5713)
Runs the real static/dash-layout.js under node (CI's job container has it; the checkout action runs on it) over a saved layout with 60 random sets of content heights at 12-24 columns. Expected to fail against today's pack(), which places by content height: pushed alone so CI shows the guard can fail before the fix lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user