2026-06-24 13:42:21 +02:00
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
import gen_rack
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def item(**kw):
|
|
|
|
|
|
base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
|
|
|
|
|
|
base.update(kw)
|
|
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_accepts_valid_placement():
|
|
|
|
|
|
gen_rack.validate_item(item(rack_u=12, u_height=2, rack_face="front"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_rejects_u_overflow():
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_rejects_u_below_one():
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_rejects_bad_face():
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_rejects_zero_u_with_units():
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_accepts_zero_u_rail():
|
|
|
|
|
|
gen_rack.validate_item(item(rack_face="left"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_rejects_missing_units_on_faced_item():
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_item(item(rack_face="front"))
|
2026-06-24 13:45:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 14:05:56 +02:00
|
|
|
|
def test_validate_rejects_empty_rack():
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_item(item(rack=None, rack_u=1, u_height=1, rack_face="front"))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 13:45:08 +02:00
|
|
|
|
def test_overlaps_detects_same_face_overlap():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
|
|
|
|
|
|
item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
|
|
|
|
|
|
]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.check_overlaps(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_overlaps_allows_same_u_different_face():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="a", rack_u=5, u_height=1, rack_face="front"),
|
|
|
|
|
|
item(hostname="b", rack_u=5, u_height=1, rack_face="rear"),
|
|
|
|
|
|
]
|
|
|
|
|
|
gen_rack.check_overlaps(items) # no raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_overlaps_both_face_conflicts_with_front():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="a", rack_u=5, u_height=1, rack_face="both"),
|
|
|
|
|
|
item(hostname="b", rack_u=5, u_height=1, rack_face="front"),
|
|
|
|
|
|
]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.check_overlaps(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_overlaps_ignores_zero_u_rails():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="p1", rack_face="left"),
|
|
|
|
|
|
item(hostname="p2", rack_face="left"),
|
|
|
|
|
|
]
|
|
|
|
|
|
gen_rack.check_overlaps(items) # no raise
|
2026-06-24 13:48:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_svg_has_two_columns_of_48_slots():
|
|
|
|
|
|
svg = gen_rack.render_svg("rack01", [])
|
|
|
|
|
|
# one faint slot rect per U per column (front + rear)
|
|
|
|
|
|
assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
|
|
|
|
|
|
assert svg.startswith("<svg")
|
|
|
|
|
|
assert svg.rstrip().endswith("</svg>")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_svg_includes_device_label():
|
|
|
|
|
|
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
|
|
|
|
|
svg = gen_rack.render_svg("rack01", items)
|
|
|
|
|
|
assert "mf00" in svg
|
|
|
|
|
|
assert "U1" in svg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_svg_is_deterministic():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
|
|
|
|
|
|
item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
|
|
|
|
|
|
]
|
|
|
|
|
|
assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
|
|
|
|
|
|
"rack01", list(reversed(items))
|
|
|
|
|
|
)
|
2026-06-24 13:51:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_page_has_banner_image_and_table():
|
|
|
|
|
|
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
|
|
|
|
|
page = gen_rack.render_page("rack01", items)
|
|
|
|
|
|
assert "do not edit by hand" in page
|
|
|
|
|
|
assert "" in page
|
|
|
|
|
|
assert "../../hardware/mf00.md" in page
|
|
|
|
|
|
assert "U1–U2" in page
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_item(d, name, body):
|
|
|
|
|
|
(d / f"{name}.md").write_text(body, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_generate_writes_artifacts(tmp_path):
|
|
|
|
|
|
hw = tmp_path / "hardware"
|
|
|
|
|
|
out = tmp_path / "out"
|
|
|
|
|
|
hw.mkdir()
|
|
|
|
|
|
_write_item(
|
|
|
|
|
|
hw,
|
|
|
|
|
|
"mf00",
|
|
|
|
|
|
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
|
|
|
|
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
# a non-rack file must be ignored
|
|
|
|
|
|
_write_item(hw, "cloud", "---\nhostname: cloud\nkind: server\nstatus: in-use\n---\n")
|
|
|
|
|
|
|
|
|
|
|
|
rc = gen_rack.generate(hw, out)
|
|
|
|
|
|
|
|
|
|
|
|
assert rc == 0
|
|
|
|
|
|
assert (out / "rack01.md").exists()
|
|
|
|
|
|
assert (out / "rack01-elevation.svg").exists()
|
|
|
|
|
|
assert "mf00" in (out / "rack01-elevation.svg").read_text()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 14:05:56 +02:00
|
|
|
|
def test_render_svg_draws_zero_u_rail():
|
|
|
|
|
|
items = [item(hostname="pdu01", kind="pdu", rack_face="left")]
|
|
|
|
|
|
svg = gen_rack.render_svg("rack01", items)
|
|
|
|
|
|
assert "pdu01" in svg
|
|
|
|
|
|
assert "rotate(-90" in svg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_svg_both_face_draws_in_both_columns():
|
|
|
|
|
|
items = [item(hostname="dev", rack_u=10, u_height=1, rack_face="both")]
|
|
|
|
|
|
svg = gen_rack.render_svg("rack01", items)
|
|
|
|
|
|
assert svg.count("dev (U10)") == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_generate_writes_one_pair_per_rack(tmp_path):
|
|
|
|
|
|
hw = tmp_path / "hardware"
|
|
|
|
|
|
out = tmp_path / "out"
|
|
|
|
|
|
hw.mkdir()
|
|
|
|
|
|
_write_item(
|
|
|
|
|
|
hw,
|
|
|
|
|
|
"a",
|
|
|
|
|
|
"---\nhostname: a\nkind: server\nstatus: in-use\n"
|
|
|
|
|
|
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
_write_item(
|
|
|
|
|
|
hw,
|
|
|
|
|
|
"b",
|
|
|
|
|
|
"---\nhostname: b\nkind: server\nstatus: in-use\n"
|
|
|
|
|
|
"rack: rack02\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
rc = gen_rack.generate(hw, out)
|
|
|
|
|
|
assert rc == 0
|
|
|
|
|
|
assert (out / "rack01.md").exists() and (out / "rack02.md").exists()
|
|
|
|
|
|
assert (out / "rack01-elevation.svg").exists()
|
|
|
|
|
|
assert (out / "rack02-elevation.svg").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 13:51:52 +02:00
|
|
|
|
def test_generate_returns_1_on_overlap(tmp_path):
|
|
|
|
|
|
hw = tmp_path / "hardware"
|
|
|
|
|
|
out = tmp_path / "out"
|
|
|
|
|
|
hw.mkdir()
|
|
|
|
|
|
for n, u in (("a", 1), ("b", 1)):
|
|
|
|
|
|
_write_item(
|
|
|
|
|
|
hw,
|
|
|
|
|
|
n,
|
|
|
|
|
|
f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
|
|
|
|
|
|
f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\n---\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rc = gen_rack.generate(hw, out)
|
|
|
|
|
|
|
|
|
|
|
|
assert rc == 1
|
|
|
|
|
|
assert not (out / "rack01.md").exists()
|
2026-06-24 14:35:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_power_accepts_valid_feed():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
|
|
|
|
|
|
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
|
|
|
|
power=[{"pdu": "pdu01", "outlet": 1}]),
|
|
|
|
|
|
]
|
|
|
|
|
|
gen_rack.validate_power(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_power_rejects_unknown_pdu():
|
|
|
|
|
|
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
|
|
|
|
power=[{"pdu": "ghost", "outlet": 1}])]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_power(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_power_rejects_non_pdu_target():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="sw01", kind="switch", rack_u=1, u_height=1,
|
|
|
|
|
|
rack_face="front"),
|
|
|
|
|
|
item(hostname="mf00", rack_u=2, u_height=1, rack_face="front",
|
|
|
|
|
|
power=[{"pdu": "sw01", "outlet": 1}]),
|
|
|
|
|
|
]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_power(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_power_rejects_outlet_over_count():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
|
|
|
|
|
|
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
|
|
|
|
power=[{"pdu": "pdu01", "outlet": 9}]),
|
|
|
|
|
|
]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_power(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_power_rejects_outlet_zero():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
|
|
|
|
|
|
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
|
|
|
|
power=[{"pdu": "pdu01", "outlet": 0}]),
|
|
|
|
|
|
]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_power(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_power_rejects_malformed_entry():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
|
|
|
|
|
|
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
|
|
|
|
power=["pdu01"]),
|
|
|
|
|
|
]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_power(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_power_rejects_pdu_without_outlets():
|
|
|
|
|
|
items = [
|
|
|
|
|
|
item(hostname="pdu01", kind="pdu", rack_face="left"),
|
|
|
|
|
|
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
|
|
|
|
power=[{"pdu": "pdu01", "outlet": 1}]),
|
|
|
|
|
|
]
|
|
|
|
|
|
with pytest.raises(gen_rack.SchemaError):
|
|
|
|
|
|
gen_rack.validate_power(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_generate_returns_1_on_bad_power_ref(tmp_path):
|
|
|
|
|
|
hw = tmp_path / "hardware"
|
|
|
|
|
|
out = tmp_path / "out"
|
|
|
|
|
|
hw.mkdir()
|
|
|
|
|
|
_write_item(
|
|
|
|
|
|
hw,
|
|
|
|
|
|
"mf00",
|
|
|
|
|
|
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
|
|
|
|
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
|
|
|
|
|
|
"power:\n - { pdu: ghost, outlet: 1 }\n---\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
rc = gen_rack.generate(hw, out)
|
|
|
|
|
|
assert rc == 1
|
|
|
|
|
|
assert not (out / "rack01.md").exists()
|