Files
gandalf/tests/test_cutsheet.py
T
jaredandClaude Opus 5.5 b9412ff6ba
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
Add cutsheets: planned cabling checked against discovery
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>
2026-11-10 16:05:08 -05:00

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