Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 48s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 49s
Lint / Python (flake8) (pull_request) Successful in 51s
Test / Python Tests (pytest) (push) Successful in 1m2s
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 50s
Test / Python Tests (pytest) (pull_request) Successful in 53s
Cutsheet items gain a "Gandalf device" link, so an item named after the product (UDM-Pro) shows the live device's faceplate, ports and status. Cable checks, suggestions, tickets and the device page treat an item and its linked device as one. Without this, product-named items drew as bare name plates. Items can sit on a shelf instead of a rack unit; the shelf draws what is on it with live status. Items have a rack, the page shows a tab per rack (config "racks"), and a rack can be downloaded as SVG or PNG. The rack is centred, long runs of empty units fold into one band (with a toggle), every device fills the 19" width, the UPS shows its front only, and the rack keeps its own palette so it reads in light mode. New cutsheet_items columns rack, shelf and device are added on first use. Deleting a shelf, or making it something else, is refused while it holds items; renaming it carries over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
16 KiB
Python
324 lines
16 KiB
Python
"""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'
|
||
|
||
|
||
class TestDeviceAliases:
|
||
ITEMS = {'UDM-Pro': {'name': 'UDM-Pro', 'kind': 'device', 'device': 'udm'},
|
||
'Big box': {'name': 'Big box', 'kind': 'device', 'device': 'large1'}}
|
||
|
||
def test_cable_on_an_item_is_checked_against_its_device(self):
|
||
switches = {'udm': switch([port(3, seen='large1')])['sw']}
|
||
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'))
|
||
assert cs.evaluate([c], self.ITEMS, switches)[1]['verdict'] == 'match'
|
||
|
||
def test_wrong_neighbor_still_caught_through_an_alias(self):
|
||
switches = {'udm': switch([port(3, seen='printer')])['sw']}
|
||
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'))
|
||
assert cs.evaluate([c], self.ITEMS, switches)[1]['verdict'] == 'wrong_neighbor'
|
||
|
||
def test_aliased_cable_counts_as_documented(self):
|
||
switches = {'udm': switch([port(3, seen='large1')])['sw']}
|
||
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'))
|
||
assert cs.undocumented([c], switches, self.ITEMS) == []
|
||
assert len(cs.undocumented([c], switches)) == 1
|
||
|
||
def test_runs_for_the_device_include_aliased_cables(self):
|
||
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'), label='C1')
|
||
assert [r['label'] for r in cs.runs_for('udm', [c], self.ITEMS, {})] == ['C1']
|