"""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'])