Files
gandalf/tests/test_cutsheet.py
T
jaredandClaude Opus 5.5 f655836446
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 50s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 50s
Test / Python Tests (pytest) (push) Successful in 1m2s
Lint / Python (flake8) (pull_request) Successful in 52s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m42s
Test / Python Tests (pytest) (pull_request) Successful in 2m2s
Add a rack drawing (/rack)
The rack front is drawn from the rack units on cutsheet items. Switches
with a faceplate use it, with live port state; patch panels get a generic
panel with port labels; everything else is a labelled plate. Each
documented port shows its cutsheet state, and hovering a port lights up
every port on the same cable run (through patch panels) and names the
cables. Clicking opens the switch port or the cable.

Overlapping items and items past the top of the rack are listed instead
of drawn over another; where two claim the same top unit, the first one
recorded wins. rack.units sets the height (default 42).

/api/rack builds on the cutsheet view; cutsheet.rack_layout and
cutsheet.port_marks hold the placement and per-port logic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:59:30 -05:00

299 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for cutsheet.py: validation, path tracing and plan-vs-actual checks."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import cutsheet as cs # noqa: E402
PANEL = {'id': 1, 'name': 'PP1', 'kind': 'patch_panel', 'port_count': 24}
JACK = {'id': 2, 'name': 'Bedroom jack', 'kind': 'wall_jack', 'port_count': 2}
SHELF = {'id': 3, 'name': 'Shelf', 'kind': 'shelf', 'port_count': None}
ITEMS = {i['name']: i for i in (PANEL, JACK, SHELF)}
def cable(cid, a, b=None, status='installed', label=None, **kw):
a_dev, a_port, a_side = (a + ('',) * 3)[:3]
b_dev, b_port, b_side = ((b or ('', '', '')) + ('',) * 3)[:3]
c = {'id': cid, 'label': label, 'a_device': a_dev, 'a_port': a_port, 'a_side': a_side,
'b_device': b_dev, 'b_port': b_port, 'b_side': b_side, 'status': status,
'cable_type': '', 'length_m': None, 'color': '', 'notes': '', 'label_applied': False}
c.update(kw)
return c
def switch(ports):
return {'sw': {'ports': {f'Port {p["port_idx"]}': p for p in ports}}}
def port(idx, up=True, seen='', **kw):
p = {'port_idx': idx, 'up': up}
if seen:
p['lldp'] = {'system_name': seen}
p.update(kw)
return p
class TestValidation:
def test_minimal_cable(self):
c = cs.validate_cable({'a_device': 'sw', 'a_port': '3'}, ITEMS)
assert c['status'] == 'installed' and c['label'] is None and c['b_device'] == ''
def test_panel_needs_side_and_valid_port(self):
with pytest.raises(cs.ValidationError, match='front or rear'):
cs.validate_cable({'a_device': 'PP1', 'a_port': '3'}, ITEMS)
with pytest.raises(cs.ValidationError, match='ports 1 to 24'):
cs.validate_cable({'a_device': 'PP1', 'a_port': '25', 'a_side': 'front'}, ITEMS)
c = cs.validate_cable({'a_device': 'PP1', 'a_port': '3', 'a_side': 'rear'}, ITEMS)
assert c['a_side'] == 'rear'
def test_side_is_dropped_for_active_devices(self):
c = cs.validate_cable({'a_device': 'sw', 'a_port': '3', 'a_side': 'front'}, ITEMS)
assert c['a_side'] == ''
@pytest.mark.parametrize('data, message', [
({}, 'From device is required'),
({'a_device': 'sw', 'label': 'bad label!'}, 'Label may use'),
({'a_device': 'sw', 'status': 'gone'}, 'status must be'),
({'a_device': 'sw', 'cable_type': 'Cat9'}, 'Type must be'),
({'a_device': 'sw', 'length_m': 'long'}, 'Length must be a number'),
({'a_device': 'sw', 'length_m': '0'}, 'between 0 and 999'),
({'a_device': 'sw', 'b_port': '3'}, 'Choose a device before a port'),
({'a_device': 'sw', 'a_port': '1', 'b_device': 'sw', 'b_port': '1'}, 'same port'),
({'a_device': 'x' * 101}, '100 characters'),
])
def test_rejects(self, data, message):
with pytest.raises(cs.ValidationError, match=message):
cs.validate_cable(data, ITEMS)
def test_item_rules(self):
assert cs.validate_item({'name': 'S', 'kind': 'shelf', 'rack_u': '5'})['rack_height'] == 1
with pytest.raises(cs.ValidationError, match='port count'):
cs.validate_item({'name': 'P', 'kind': 'patch_panel'})
with pytest.raises(cs.ValidationError, match='rack unit to go with'):
cs.validate_item({'name': 'S', 'rack_height': '2'})
with pytest.raises(cs.ValidationError, match='kind must be'):
cs.validate_item({'name': 'S', 'kind': 'rack'})
def test_next_label_fills_gaps(self):
assert cs.next_label([]) == 'C01'
assert cs.next_label([{'label': 'C01'}, {'label': 'c03'}, {'label': 'X9'}]) == 'C02'
class TestTrace:
def test_switch_through_panel_to_jack(self):
cables = [
cable(1, ('sw', '13'), ('PP1', '5', 'front')),
cable(2, ('PP1', '5', 'rear'), ('Bedroom jack', '1', 'rear')),
cable(3, ('Bedroom jack', '1', 'front'), ('tv', 'eth0')),
]
for start in cables:
hops = cs.trace(start, cables, ITEMS)['hops']
ends = {hops[0][0], hops[-1][0]}
assert ends == {'sw', 'tv'}
assert cs.trace(start, cables, ITEMS)['cable_ids'] in ([1, 2, 3], [3, 2, 1])
def test_retired_cables_are_not_followed(self):
cables = [cable(1, ('sw', '1'), ('PP1', '1', 'front')),
cable(2, ('PP1', '1', 'rear'), ('Bedroom jack', '1', 'rear'), status='retired')]
assert cs.trace(cables[0], cables, ITEMS)['cable_ids'] == [1]
def test_unterminated_end(self):
hops = cs.trace(cable(1, ('PP1', '9', 'rear')), [cable(1, ('PP1', '9', 'rear'))], ITEMS)['hops']
assert hops[-1] is None
class TestEvaluate:
def verdict(self, cables, switches, cid=1):
return cs.evaluate(cables, ITEMS, switches)[cid]['verdict']
def test_match_through_a_panel(self):
cables = [cable(1, ('sw', '13'), ('PP1', '5', 'front')),
cable(2, ('PP1', '5', 'rear'), ('tv', 'eth0'))]
assert self.verdict(cables, switch([port(13, seen='tv')])) == 'match'
assert self.verdict(cables, switch([port(13, seen='TV ')]), cid=2) == 'match'
def test_wrong_neighbor(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'))]
result = cs.evaluate(cables, ITEMS, switch([port(13, seen='printer')]))[1]
assert result['verdict'] == 'wrong_neighbor' and 'printer' in result['detail']
def test_no_link_vs_idle(self):
assert self.verdict([cable(1, ('sw', '13'), ('tv', 'eth0'))], switch([port(13, up=False)])) == 'no_link'
# A run that ends at a jack with nothing plugged in is normal
jack = [cable(1, ('sw', '13'), ('Bedroom jack', '1', 'rear'))]
assert self.verdict(jack, switch([port(13, up=False)])) == 'idle'
def test_unverified_when_nothing_identifies_the_far_end(self):
assert self.verdict([cable(1, ('sw', '13'), ('tv', 'eth0'))], switch([port(13)])) == 'unverified'
jack = [cable(1, ('sw', '13'), ('Bedroom jack', '1', 'rear'))]
result = cs.evaluate(jack, ITEMS, switch([port(13, seen='camera')]))[1]
assert result['verdict'] == 'unverified' and 'camera' in result['detail']
def test_missing_port_and_unknown_devices(self):
assert self.verdict([cable(1, ('sw', '99'), ('tv', 'eth0'))], switch([port(13)])) == 'missing_port'
assert self.verdict([cable(1, ('host-a', 'eth0'), ('host-b', 'eth0'))], switch([])) == 'not_checked'
def test_statuses_skip_the_check(self):
for status in ('planned', 'dark', 'retired'):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'), status=status)]
assert self.verdict(cables, switch([port(13, seen='printer')])) == status
def test_a_planned_hop_makes_the_run_planned(self):
cables = [cable(1, ('sw', '13'), ('PP1', '5', 'front')),
cable(2, ('PP1', '5', 'rear'), ('tv', 'eth0'), status='planned')]
assert self.verdict(cables, switch([port(13, seen='printer')])) == 'planned'
def test_dac_serial_confirms_both_ends(self):
switches = {
'gw': {'ports': {'p': port(11, sfp={'kind': 'dac', 'cable_peer': {'switch': 'agg', 'port_idx': 7}})}},
'agg': {'ports': {'p': port(7, sfp={'kind': 'dac', 'cable_peer': {'switch': 'gw', 'port_idx': 11}})}},
}
result = cs.evaluate([cable(1, ('gw', '11'), ('agg', '7'))], ITEMS, switches)[1]
assert result['verdict'] == 'match' and 'DAC' in result['detail']
def test_switch_to_switch_reports_the_worse_end(self):
switches = {'a': {'ports': {'p': port(1, seen='b')}}, 'b': {'ports': {'p': port(2, seen='c')}}}
assert self.verdict([cable(1, ('a', '1'), ('b', '2'))], switches) == 'wrong_neighbor'
def test_two_installed_cables_on_one_port_conflict(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'), label='C01'),
cable(2, ('sw', '13'), ('printer', 'eth0'), label='C02'),
cable(3, ('sw', '13'), ('tv', 'eth0'), status='planned')]
result = cs.evaluate(cables, ITEMS, switch([port(13, seen='tv')]))
assert result[1]['verdict'] == 'conflict' and 'C02' in result[1]['detail']
assert result[3]['verdict'] == 'planned'
class TestUndocumented:
def test_lists_live_ports_without_a_cable(self):
switches = switch([port(1, seen='tv'), port(2), port(3, up=False)])
found = cs.undocumented([cable(1, ('sw', '2'), ('x', ''))], switches)
assert [(s['port_idx'], s['neighbor']) for s in found] == [(1, 'tv')]
def test_retired_cables_dont_count(self):
found = cs.undocumented([cable(1, ('sw', '1'), ('x', ''), status='retired')], switch([port(1)]))
assert len(found) == 1
def test_dac_offered_once_from_one_end(self):
switches = {
'agg': {'ports': {'p': port(7, sfp={'kind': 'dac', 'cable_peer': {'switch': 'gw', 'port_idx': 11}})}},
'gw': {'ports': {'p': port(11, sfp={'kind': 'dac', 'cable_peer': {'switch': 'agg', 'port_idx': 7}})}},
}
found = cs.undocumented([], switches)
assert len(found) == 1 and found[0]['cable_type'] == 'DAC' and found[0]['via'] == 'DAC serial'
class TestLabels:
def test_only_unapplied_labels_of_live_cables(self):
cables = [cable(1, ('sw', '1'), ('tv', ''), label='C10'), cable(2, ('sw', '2'), ('tv', ''), label='C2'),
cable(3, ('sw', '3'), ('tv', ''), label='C3', label_applied=True),
cable(4, ('sw', '4'), label='C4', status='dark'),
cable(5, ('sw', '5'), ('tv', ''), label='C5', status='planned'),
cable(6, ('sw', '6'), ('tv', ''))]
ports = [{'item_name': 'PP1', 'port': '10', 'label': 'OFFICE', 'applied': False},
{'item_name': 'PP1', 'port': '2', 'label': 'GARAGE', 'applied': False},
{'item_name': 'PP1', 'port': '3', 'label': 'DONE', 'applied': True}]
out = cs.labels_to_make(cables, ITEMS, ports)
assert [c['text'] for c in out['cables']] == ['C2', 'C4', 'C10']
assert [c['copies'] for c in out['cables']] == [2, 1, 2]
assert [p['text'] for p in out['ports']] == ['GARAGE', 'OFFICE']
assert out['unlabeled_cable_ids'] == [6]
class TestCsv:
def test_round_trip(self):
original = [cable(1, ('sw', '13'), ('PP1', '5', 'front'), label='C01', cable_type='Cat6',
length_m=1.5, color='white', label_applied=True, notes='to office, via panel')]
rows, errors = cs.parse_csv(cs.cables_to_csv(original), ITEMS)
assert errors == []
assert rows[0]['label'] == 'C01' and rows[0]['b_side'] == 'front' and rows[0]['length_m'] == 1.5
assert rows[0]['label_applied'] is True and rows[0]['notes'] == 'to office, via panel'
def test_formulas_are_neutralised_and_restored(self):
text = cs.cables_to_csv([cable(1, ('sw', '1'), label='C01', notes='=HYPERLINK("x")')])
assert "'=HYPERLINK" in text
rows, _ = cs.parse_csv(text, ITEMS)
assert rows[0]['notes'] == '=HYPERLINK("x")'
def test_any_bad_row_is_reported(self):
text = 'label,a_device,a_port,a_side\nC01,sw,1,\nC01,sw,2,\n,PP1,40,front\n'
rows, errors = cs.parse_csv(text, ITEMS)
assert any('appears twice' in e for e in errors) and any('Line 4' in e for e in errors)
def test_missing_required_column(self):
assert cs.parse_csv('label\nC1\n', ITEMS) == ([], ['Missing column: a_device'])
class TestProblems:
def test_one_problem_per_run_named_after_the_switch_cable(self):
cables = [cable(1, ('PP1', '5', 'rear'), ('tv', 'eth0')),
cable(2, ('sw', '13'), ('PP1', '5', 'front'), label='C07')]
found = cs.problems(cables, ITEMS, switch([port(13, seen='printer')]))
assert len(found) == 1
p = found[0]
assert (p['label'], p['switch'], p['port'], p['verdict']) == ('C07', 'sw', '13', 'wrong_neighbor')
assert p['key'] == 'cable 2' and 'PP1 port 5 (front)' in p['path']
def test_only_installed_problems(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'), status='planned'),
cable(2, ('sw', '14'), ('tv', 'eth0')),
cable(3, ('sw', '15'), ('Bedroom jack', '1', 'rear'))]
switches = switch([port(13, seen='x'), port(14, seen='tv'), port(15, up=False)])
assert cs.problems(cables, ITEMS, switches) == []
def test_each_conflicting_cable_is_reported(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0')), cable(2, ('sw', '13'), ('pc', 'eth0'))]
found = cs.problems(cables, ITEMS, switch([port(13, seen='tv')]))
assert sorted(p['cable_id'] for p in found) == [1, 2]
class TestRackLayout:
_ids = {}
def _item(self, name, u, h=None):
# IDs in creation order: on a tie for the same top unit, the earlier record is drawn
return {'id': self._ids.setdefault(name, len(self._ids) + 1), 'name': name, 'kind': 'device',
'rack_u': u, 'rack_height': h}
def test_top_first_with_top_unit(self):
out = cs.rack_layout([self._item('a', 1), self._item('b', 10, 2), {'name': 'loose', 'rack_u': None}], 42)
assert [(i['name'], i['rack_top']) for i in out['placed']] == [('b', 11), ('a', 1)]
assert out['overlaps'] == [] and out['outside'] == []
def test_overlap_is_reported_not_drawn(self):
out = cs.rack_layout([self._item('ups', 1, 2), self._item('shelf', 2)], 42)
assert [i['name'] for i in out['placed']] == ['ups']
assert out['overlaps'] == [{'name': 'shelf', 'units': 'U2', 'with': ['ups']}]
def test_past_the_top(self):
out = cs.rack_layout([self._item('tall', 41, 3)], 42)
assert out['placed'] == [] and out['outside'] == [{'name': 'tall', 'units': 'U41–43'}]
class TestPortMarks:
def test_worst_cable_wins_and_runs_merge(self):
cables = [
dict(cable(1, ('sw', '3'), ('PP1', '1', 'front'), label='C1'), verdict='match', detail='ok', run=[1, 2]),
dict(cable(2, ('PP1', '1', 'rear'), ('jack', '1')), verdict='wrong_neighbor', detail='bad', run=[1, 2]),
dict(cable(3, ('sw', '4'), ('x',)), verdict='planned', detail='Planned', run=[3]),
dict(cable(4, ('sw', '5'), ('y',), status='retired'), verdict='retired', detail='', run=[4]),
]
marks = cs.port_marks(cables)
assert marks[('sw', '3')]['mark'] == 'ok'
assert marks[('PP1', '1')]['mark'] == 'bad' and marks[('PP1', '1')]['cable_id'] == 2
assert marks[('PP1', '1')]['run'] == [1, 2]
assert marks[('PP1', '1')]['text'] == 'cable C1: ok; cable #2: bad'
assert marks[('sw', '4')]['text'] == 'cable #3: Planned'
assert marks[('sw', '4')]['mark'] == 'doc'
assert ('sw', '5') not in marks and ('x', '') not in marks
def test_shared_check_is_said_once(self):
cables = [
dict(cable(1, ('sw', '3'), ('PP1', '1', 'front'), label='C1'), verdict='idle', detail='Idle', run=[1, 2]),
dict(cable(2, ('PP1', '1', 'rear'), ('jack', '1')), verdict='idle', detail='Idle', run=[1, 2]),
]
assert cs.port_marks(cables)[('PP1', '1')]['text'] == 'cables C1, #2: Idle'