Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 1m36s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m40s
Lint / Python (flake8) (pull_request) Successful in 52s
Test / Python Tests (pytest) (push) Successful in 1m4s
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 1m13s
Test / Python Tests (pytest) (pull_request) Successful in 1m15s
A new Cutsheet page records where every cable is meant to go and compares it with what Gandalf discovers. - Inventory for what discovery can't see: patch panels, wall jacks, shelves and rack positions. Panels and jacks pass through front to rear. - Cables with two endpoints, ID, type, length, color, status (planned, installed, dark, retired) and notes; the far end may be unterminated. - Each cable's full run is traced through panels and jacks, and runs ending on a switch port are checked against its discovered neighbor (LLDP, client table, or a DAC serial seen at both ends): matches, wrong neighbor, no link, missing port, port conflict, unverified, idle. - Undocumented links: live switch ports no cable mentions, one click to add with what Gandalf already knows. Port panels show the planned cable. - Labels to make: cable IDs and panel-port labels not yet applied, with a print view, for a handheld label maker. - CSV export, and all-or-nothing import that upserts by cable ID. - Cables, panels and jacks appear in the dashboard search. Editing needs the admin group and a JSON request body. Every change is written to cutsheet_history in the same transaction. Tables are created on first use. Refs #12 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
11 KiB
Python
228 lines
11 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'])
|