Add an optional `chassis_u` field for shelf-mounted devices (their height in U where they stand on the shelf) and render it: - gen_rack draws each tower chassis_u U's tall, rising above the 1U shelf line; rail-mounted devices now paint on top so a PDU within a tower's span (e.g. pdu03 over srv05/06) stays visible - occupancy table shows each tower's real U-span (e.g. srv01 U37-U46) - validate_item checks chassis_u is a positive integer; absent chassis_u renders byte-identically to before - set chassis_u for srv01-07 (10/8/6/6/7/7/6U); document the field in the editing guide; regenerate rack01 artifacts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
834 lines
29 KiB
Python
834 lines
29 KiB
Python
import pytest
|
||
|
||
import gen_rack
|
||
|
||
|
||
def item(**kw):
|
||
base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
|
||
base.update(kw)
|
||
return base
|
||
|
||
|
||
def shelf(**kw):
|
||
base = {"hostname": "shf01", "kind": "shelf", "status": "in-use",
|
||
"rack": "rack01", "rack_u": 37, "u_height": 10, "rack_face": "both"}
|
||
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"))
|
||
|
||
|
||
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"))
|
||
|
||
|
||
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
|
||
|
||
|
||
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))
|
||
)
|
||
|
||
|
||
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 '<div class="rack-elevation">' in page
|
||
assert "<svg" in page
|
||
assert "[Download SVG](rack01-elevation.svg)" 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()
|
||
|
||
|
||
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_rail_label_is_centered_across_bar():
|
||
# The rotated 0U rail label must be centered across the narrow bar width,
|
||
# not sitting on the alphabetic baseline (which reads off-centre).
|
||
items = [item(hostname="pdu01", kind="pdu", rack_face="left")]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
assert 'dominant-baseline="central"' 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()
|
||
|
||
|
||
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()
|
||
|
||
|
||
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()
|
||
|
||
|
||
def test_render_power_has_nodes_and_edge_labels():
|
||
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": 3}]),
|
||
]
|
||
out = gen_rack.render_power("rack01", items)
|
||
assert "```mermaid" in out
|
||
assert "flowchart LR" in out
|
||
assert "pdu01" in out
|
||
assert "8 outlets" in out
|
||
assert "outlet 3" in out
|
||
assert "mf00" in out
|
||
|
||
|
||
def test_render_power_redundant_device_has_two_edges():
|
||
items = [
|
||
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
|
||
item(hostname="pdu02", kind="pdu", rack_face="right", outlets=8),
|
||
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
||
power=[{"pdu": "pdu01", "outlet": 1},
|
||
{"pdu": "pdu02", "outlet": 1}]),
|
||
]
|
||
out = gen_rack.render_power("rack01", items)
|
||
assert out.count("-->|outlet") == 2
|
||
|
||
|
||
def test_render_power_empty_when_no_feeds():
|
||
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
|
||
assert gen_rack.render_power("rack01", items) == ""
|
||
|
||
|
||
def test_render_power_is_deterministic():
|
||
a = item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8)
|
||
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
power=[{"pdu": "pdu01", "outlet": 2}])
|
||
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
||
power=[{"pdu": "pdu01", "outlet": 1}])
|
||
assert gen_rack.render_power("rack01", [a, b, c]) == \
|
||
gen_rack.render_power("rack01", [c, b, a])
|
||
|
||
|
||
def test_generate_includes_power_section(tmp_path):
|
||
hw = tmp_path / "hardware"
|
||
out = tmp_path / "out"
|
||
hw.mkdir()
|
||
_write_item(
|
||
hw,
|
||
"pdu01",
|
||
"---\nhostname: pdu01\nkind: pdu\nstatus: in-use\n"
|
||
"rack: rack01\nrack_face: left\noutlets: 8\n---\n",
|
||
)
|
||
_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: pdu01, outlet: 1 }\n---\n",
|
||
)
|
||
rc = gen_rack.generate(hw, out)
|
||
assert rc == 0
|
||
page = (out / "rack01.md").read_text()
|
||
assert "## Power" in page
|
||
assert "```mermaid" in page
|
||
assert "outlet 1" in page
|
||
|
||
|
||
def test_load_hardware_index_maps_all_hostnames(tmp_path):
|
||
hw = tmp_path / "hardware"
|
||
hw.mkdir()
|
||
_write_item(
|
||
hw, "sw01",
|
||
"---\nhostname: sw01\nkind: switch\nstatus: in-use\nports: 24\n---\n",
|
||
)
|
||
_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",
|
||
)
|
||
idx = gen_rack.load_hardware_index(hw)
|
||
assert set(idx) == {"sw01", "mf00"}
|
||
assert idx["sw01"]["ports"] == 24
|
||
|
||
|
||
def test_validate_links_accepts_valid_link():
|
||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01",
|
||
"peer_port": 1, "speed_gbps": 1}])]
|
||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
||
gen_rack.validate_links(items, hw_index)
|
||
|
||
|
||
def test_validate_links_rejects_unknown_peer():
|
||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "ghost", "peer_port": 1}])]
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_links(items, {})
|
||
|
||
|
||
def test_validate_links_rejects_peer_port_over_count():
|
||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 25}])]
|
||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_links(items, hw_index)
|
||
|
||
|
||
def test_validate_links_rejects_peer_port_below_one():
|
||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 0}])]
|
||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_links(items, hw_index)
|
||
|
||
|
||
def test_validate_links_accepts_peer_without_ports():
|
||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "rtr01", "peer_port": 99}])]
|
||
hw_index = {"rtr01": item(hostname="rtr01", kind="server")}
|
||
gen_rack.validate_links(items, hw_index) # no ports -> range check skipped
|
||
|
||
|
||
def test_validate_links_rejects_missing_local():
|
||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"peer": "sw01", "peer_port": 1}])]
|
||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_links(items, hw_index)
|
||
|
||
|
||
def test_validate_links_rejects_malformed_entry():
|
||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=["sw01"])]
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_links(items, {})
|
||
|
||
|
||
def test_generate_returns_1_on_bad_link_peer(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"
|
||
"links:\n - { local: eth0, peer: ghost, peer_port: 1 }\n---\n",
|
||
)
|
||
rc = gen_rack.generate(hw, out)
|
||
assert rc == 1
|
||
assert not (out / "rack01.md").exists()
|
||
|
||
|
||
def test_render_network_has_nodes_and_edge_labels():
|
||
items = [
|
||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||
rack_face="front", ports=24),
|
||
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01",
|
||
"peer_port": 1, "speed_gbps": 1}]),
|
||
]
|
||
out = gen_rack.render_network("rack01", items)
|
||
assert "```mermaid" in out
|
||
assert "flowchart LR" in out
|
||
assert "sw01<br/>switch" in out
|
||
assert "mf00" in out
|
||
assert "eth0" in out
|
||
assert "p1" in out
|
||
assert "1G" in out
|
||
|
||
|
||
def test_render_network_patch_panel_subtitle():
|
||
items = [
|
||
item(hostname="pp01", kind="patch-panel", rack_u=24, u_height=1,
|
||
rack_face="front", ports=24),
|
||
item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "pp01",
|
||
"peer_port": 1, "speed_gbps": 1}]),
|
||
]
|
||
out = gen_rack.render_network("rack01", items)
|
||
assert "pp01<br/>patch-panel" in out
|
||
|
||
|
||
def test_render_network_empty_when_no_links():
|
||
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
|
||
assert gen_rack.render_network("rack01", items) == ""
|
||
|
||
|
||
def test_render_network_omits_speed_when_absent():
|
||
items = [
|
||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||
rack_face="front", ports=24),
|
||
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
|
||
]
|
||
out = gen_rack.render_network("rack01", items)
|
||
assert "eth0" in out and "p1" in out
|
||
assert "·" not in out # no speed suffix rendered
|
||
|
||
|
||
def test_render_network_is_deterministic():
|
||
a = item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||
rack_face="front", ports=24)
|
||
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01",
|
||
"peer_port": 2, "speed_gbps": 1}])
|
||
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01",
|
||
"peer_port": 1, "speed_gbps": 1}])
|
||
assert gen_rack.render_network("rack01", [a, b, c]) == \
|
||
gen_rack.render_network("rack01", [c, b, a])
|
||
|
||
|
||
def test_generate_includes_network_section(tmp_path):
|
||
hw = tmp_path / "hardware"
|
||
out = tmp_path / "out"
|
||
hw.mkdir()
|
||
_write_item(
|
||
hw, "sw01",
|
||
"---\nhostname: sw01\nkind: switch\nstatus: in-use\n"
|
||
"rack: rack01\nrack_u: 10\nu_height: 1\nrack_face: front\nports: 24\n---\n",
|
||
)
|
||
_write_item(
|
||
hw, "mf00",
|
||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
|
||
"links:\n - { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }\n---\n",
|
||
)
|
||
rc = gen_rack.generate(hw, out)
|
||
assert rc == 0
|
||
page = (out / "rack01.md").read_text()
|
||
assert "## Network" in page
|
||
assert "```mermaid" in page
|
||
assert "eth0" in page
|
||
|
||
|
||
def test_validate_accepts_mounted_item():
|
||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1))
|
||
|
||
|
||
def test_validate_rejects_mounted_with_rack_u():
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1, rack_u=5))
|
||
|
||
|
||
def test_validate_rejects_mounted_bad_shelf_face():
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="left", shelf_slot=1))
|
||
|
||
|
||
def test_validate_rejects_mounted_bad_slot():
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=0))
|
||
|
||
|
||
def test_overlaps_skips_mounted_items():
|
||
items = [
|
||
item(hostname="a", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||
item(hostname="b", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||
]
|
||
gen_rack.check_overlaps(items) # no raise — mounted items claim no U-range
|
||
|
||
|
||
def test_check_shelves_accepts_valid_mount():
|
||
items = [shelf(),
|
||
item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1)]
|
||
gen_rack.check_shelves(items)
|
||
|
||
|
||
def test_check_shelves_rejects_missing_shelf():
|
||
items = [item(hostname="srv01", mounted_on="ghost",
|
||
shelf_face="front", shelf_slot=1)]
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.check_shelves(items)
|
||
|
||
|
||
def test_check_shelves_rejects_non_shelf_target():
|
||
items = [
|
||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||
rack_face="front"),
|
||
item(hostname="srv01", mounted_on="sw01",
|
||
shelf_face="front", shelf_slot=1),
|
||
]
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.check_shelves(items)
|
||
|
||
|
||
def test_check_shelves_rejects_duplicate_slot():
|
||
items = [shelf(),
|
||
item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1),
|
||
item(hostname="srv02", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1)]
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.check_shelves(items)
|
||
|
||
|
||
def test_generate_returns_1_on_dangling_mount(tmp_path):
|
||
hw = tmp_path / "hardware"
|
||
out = tmp_path / "out"
|
||
hw.mkdir()
|
||
_write_item(
|
||
hw, "srv01",
|
||
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
|
||
"rack: rack01\nmounted_on: ghost\nshelf_face: front\nshelf_slot: 1\n---\n",
|
||
)
|
||
rc = gen_rack.generate(hw, out)
|
||
assert rc == 1
|
||
assert not (out / "rack01.md").exists()
|
||
|
||
|
||
def test_render_svg_draws_shelf_and_occupants():
|
||
items = [
|
||
shelf(),
|
||
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||
item(hostname="srv03", mounted_on="shf01", shelf_face="rear", shelf_slot=1),
|
||
]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
assert "shf01" in svg
|
||
assert "srv01" in svg and "srv02" in svg and "srv03" in svg
|
||
# the shelf is NOT drawn as a generic full-height device box
|
||
assert "shf01 (U37" not in svg
|
||
|
||
|
||
def test_validate_accepts_chassis_u():
|
||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1, chassis_u=10))
|
||
|
||
|
||
def test_validate_rejects_bad_chassis_u():
|
||
with pytest.raises(gen_rack.SchemaError):
|
||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1, chassis_u=0))
|
||
|
||
|
||
def test_render_svg_tower_height_reflects_chassis_u():
|
||
items = [
|
||
shelf(hostname="shf01", rack_u=46, u_height=1),
|
||
item(hostname="srv01", mounted_on="shf01", shelf_face="front",
|
||
shelf_slot=1, chassis_u=10),
|
||
]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
# 10U tower drawn as 10*U_H - SHELF_STRIP_H - 2 px tall
|
||
assert 'height="192"' in svg
|
||
|
||
|
||
def test_render_svg_paints_rail_device_over_shelf_tower():
|
||
# A PDU rail-mounted within a tower's span must be painted after the tower
|
||
# (later in the SVG = on top) so it stays visible.
|
||
items = [
|
||
shelf(hostname="shf02", rack_u=35, u_height=1),
|
||
item(hostname="srv05", mounted_on="shf02", shelf_face="rear",
|
||
shelf_slot=1, chassis_u=7),
|
||
item(hostname="pdu03", kind="pdu", rack_u=34, u_height=1,
|
||
rack_face="rear"),
|
||
]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
assert svg.index("srv05") < svg.index("pdu03")
|
||
|
||
|
||
def test_render_page_mounted_shows_chassis_span():
|
||
items = [shelf(hostname="shf01", rack_u=46, u_height=1),
|
||
item(hostname="srv01", mounted_on="shf01", shelf_face="front",
|
||
shelf_slot=1, chassis_u=10)]
|
||
page = gen_rack.render_page("rack01", items)
|
||
assert "U37–U46" in page
|
||
|
||
|
||
def test_render_svg_shelf_is_deterministic():
|
||
base = [
|
||
shelf(),
|
||
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||
]
|
||
assert gen_rack.render_svg("rack01", base) == gen_rack.render_svg(
|
||
"rack01", list(reversed(base))
|
||
)
|
||
|
||
|
||
def test_render_page_lists_mounted_devices():
|
||
items = [shelf(),
|
||
item(hostname="srv01", mounted_on="shf01",
|
||
shelf_face="front", shelf_slot=1)]
|
||
page = gen_rack.render_page("rack01", items)
|
||
assert "../../hardware/srv01.md" in page
|
||
assert "front · shf01/1" in page
|
||
assert "U37–U46" in page # mounted device shows its shelf's U-range
|
||
|
||
|
||
def test_svg_boxes_link_to_host_pages():
|
||
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
assert '<a href="/hardware/srv04/">' in svg
|
||
assert "<title>" in svg
|
||
|
||
|
||
def test_svg_status_border_styles():
|
||
staging = gen_rack.render_svg("rack01", [
|
||
item(hostname="a", rack_u=1, u_height=1, rack_face="front",
|
||
status="staging")])
|
||
broken = gen_rack.render_svg("rack01", [
|
||
item(hostname="b", rack_u=1, u_height=1, rack_face="front",
|
||
status="broken")])
|
||
assert 'stroke-dasharray="4 2"' in staging
|
||
assert 'stroke="#e15759"' in broken and 'stroke-width="3"' in broken
|
||
|
||
|
||
def test_svg_tooltip_has_cluster_and_placement():
|
||
items = [item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
|
||
status="staging", cluster="tappaas")]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
assert "cluster: tappaas" in svg
|
||
assert "U1" in svg
|
||
|
||
|
||
def test_svg_has_responsive_style():
|
||
svg = gen_rack.render_svg("rack01", [])
|
||
assert "max-width:100%" in svg
|
||
|
||
|
||
def test_render_page_inlines_svg_with_download_link():
|
||
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
|
||
page = gen_rack.render_page("rack01", items)
|
||
assert '<div class="rack-elevation">' in page
|
||
assert "<svg" in page
|
||
assert "[Download SVG](rack01-elevation.svg)" in page
|
||
assert "![Rack rack01 elevation]" not in page
|
||
|
||
|
||
def test_svg_legend_shows_present_kinds():
|
||
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||
rack_face="front")]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
assert ">Legend<" in svg
|
||
assert ">switch<" in svg
|
||
|
||
|
||
def test_svg_legend_omits_absent_kinds():
|
||
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||
rack_face="front")]
|
||
svg = gen_rack.render_svg("rack01", items)
|
||
assert ">ups<" not in svg
|
||
|
||
|
||
def test_svg_u_numbers_in_both_gutters():
|
||
svg = gen_rack.render_svg("rack01", [])
|
||
assert 'text-anchor="end"' in svg # left gutter
|
||
assert 'text-anchor="start"' in svg # right gutter
|
||
|
||
|
||
def test_svg_has_column_frames():
|
||
svg = gen_rack.render_svg("rack01", [])
|
||
assert svg.count('fill="none"') >= 2 # one frame per column
|
||
|
||
|
||
def test_power_graph_colors_and_links_nodes():
|
||
items = [
|
||
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
|
||
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
|
||
power=[{"pdu": "pdu01", "outlet": 1}]),
|
||
]
|
||
out = gen_rack.render_power("rack01", items)
|
||
assert "style srv01 fill:" in out
|
||
assert "style pdu01 fill:" in out
|
||
assert 'click srv01 "/hardware/srv01/"' in out
|
||
|
||
|
||
def test_network_graph_colors_and_links_nodes():
|
||
items = [
|
||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||
rack_face="front", ports=24),
|
||
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
|
||
]
|
||
out = gen_rack.render_network("rack01", items)
|
||
assert "style sw01 fill:" in out
|
||
assert 'click sw01 "/hardware/sw01/"' in out
|
||
assert 'click srv01 "/hardware/srv01/"' in out
|
||
|
||
|
||
def test_network_graph_off_rack_peer_has_no_click():
|
||
items = [
|
||
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
|
||
links=[{"local": "eth0", "peer": "router0", "peer_port": 1}]),
|
||
]
|
||
out = gen_rack.render_network("rack01", items)
|
||
assert "style router0 fill:" in out # off-rack peer is still colored
|
||
assert 'click router0 "' not in out # but it is NOT clickable
|
||
assert 'click srv01 "/hardware/srv01/"' in out
|
||
|
||
|
||
# --- novice-friendly error messages ---------------------------------------
|
||
|
||
def test_overlap_message_explains_the_conflict():
|
||
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) as ei:
|
||
gen_rack.check_overlaps(items)
|
||
msg = str(ei.value).lower()
|
||
assert "overlap" in msg
|
||
assert "same u" in msg or "free u" in msg or "other face" in msg # tells them how to fix
|
||
|
||
|
||
def test_zero_u_message_tells_user_to_drop_units():
|
||
with pytest.raises(gen_rack.SchemaError) as ei:
|
||
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
|
||
msg = str(ei.value).lower()
|
||
assert "rack_u" in msg and "remove" in msg
|
||
|
||
|
||
def test_bad_face_message_lists_valid_faces():
|
||
with pytest.raises(gen_rack.SchemaError) as ei:
|
||
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
|
||
msg = str(ei.value)
|
||
assert "front" in msg and "left" in msg # both U-mounted and 0U options shown
|
||
|
||
|
||
def test_generate_error_output_is_novice_friendly(tmp_path, capsys):
|
||
hw = tmp_path / "hardware"
|
||
out = tmp_path / "out"
|
||
hw.mkdir()
|
||
_write_item(
|
||
hw, "srv01",
|
||
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
|
||
"rack: rack01\nrack_face: front\n---\n", # front face but no rack_u/u_height
|
||
)
|
||
rc = gen_rack.generate(hw, out)
|
||
err = capsys.readouterr().err
|
||
assert rc == 1
|
||
assert "make docs-index" in err
|
||
assert gen_rack.GUIDE_URL in err
|