2026-11-10 16:05:08 -05:00
|
|
|
"""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'])
|
2026-11-10 16:54:57 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
2026-11-10 20:59:30 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-11-10 23:37:56 -05:00
|
|
|
assert out['placed'] == [] and out['outside'] == [{'name': 'tall', 'units': 'U41-43'}]
|
2026-11-10 20:59:30 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
2026-11-10 21:44:59 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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']
|
2026-11-10 22:35:37 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRackFaces:
|
|
|
|
|
def _item(self, name, u, h=1, mount='front', id_=1):
|
|
|
|
|
return {'id': id_, 'name': name, 'kind': 'device', 'rack_u': u, 'rack_height': h, 'mount': mount}
|
|
|
|
|
|
|
|
|
|
def test_front_and_rear_share_a_unit(self):
|
|
|
|
|
items = [self._item('switch', 10), self._item('pdu', 10, mount='rear', id_=2)]
|
|
|
|
|
front = cs.rack_layout(items, 25, 'front')
|
|
|
|
|
rear = cs.rack_layout(items, 25, 'rear')
|
|
|
|
|
assert [i['name'] for i in front['placed']] == ['switch'] and front['overlaps'] == []
|
|
|
|
|
assert [i['name'] for i in rear['placed']] == ['pdu'] and rear['overlaps'] == []
|
|
|
|
|
|
|
|
|
|
def test_full_depth_takes_both_sides(self):
|
|
|
|
|
items = [self._item('server', 5, 4, mount='full'), self._item('pdu', 6, mount='rear', id_=2)]
|
|
|
|
|
rear = cs.rack_layout(items, 25, 'rear')
|
|
|
|
|
assert [(i['name'], i['through']) for i in rear['placed']] == [('server', True)]
|
|
|
|
|
assert rear['overlaps'] == [{'name': 'pdu', 'units': 'U6', 'with': ['server']}]
|
|
|
|
|
front = cs.rack_layout(items, 25, 'front')
|
|
|
|
|
assert [(i['name'], i['through']) for i in front['placed']] == [('server', False)]
|
|
|
|
|
assert front['overlaps'] == rear['overlaps'] # reported on both faces
|
|
|
|
|
|
|
|
|
|
def test_mount_is_validated_and_shelf_items_take_the_shelf_side(self):
|
|
|
|
|
assert cs.validate_item({'name': 'p', 'mount': 'rear', 'rack_u': 3})['mount'] == 'rear'
|
|
|
|
|
assert cs.validate_item({'name': 'p'})['mount'] == 'front'
|
|
|
|
|
with pytest.raises(cs.ValidationError):
|
|
|
|
|
cs.validate_item({'name': 'p', 'mount': 'side'})
|
|
|
|
|
shelf = {'Shelf': {'name': 'Shelf', 'kind': 'shelf'}}
|
|
|
|
|
assert cs.validate_item({'name': 'm', 'shelf': 'Shelf', 'mount': 'rear'}, shelf)['mount'] == 'front'
|
|
|
|
|
|
|
|
|
|
def test_validate_rack(self):
|
|
|
|
|
rack = cs.validate_rack({'name': ' StarTech 25U ', 'units': '25'})
|
|
|
|
|
assert rack == {'name': 'StarTech 25U', 'units': 25, 'notes': ''}
|
|
|
|
|
for bad in ({'name': '', 'units': 25}, {'name': 'x'}, {'name': 'x', 'units': 61}, {'name': 'x', 'units': 0}):
|
|
|
|
|
with pytest.raises(cs.ValidationError):
|
|
|
|
|
cs.validate_rack(bad)
|
2026-11-10 23:25:02 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestChassis:
|
|
|
|
|
def test_known_drawing_kept_unknown_refused(self):
|
|
|
|
|
assert cs.validate_item({'name': 'nvr', 'chassis': 'unvr-pro'})['chassis'] == 'unvr-pro'
|
|
|
|
|
assert cs.validate_item({'name': 'x'})['chassis'] == ''
|
|
|
|
|
with pytest.raises(cs.ValidationError):
|
|
|
|
|
cs.validate_item({'name': 'x', 'chassis': 'tardis'})
|
|
|
|
|
|
|
|
|
|
def test_every_drawing_has_a_label(self):
|
2026-11-10 23:39:27 -05:00
|
|
|
assert all(cs.CHASSIS.values())
|
2026-11-11 00:01:12 -05:00
|
|
|
assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24',
|
2026-11-11 00:46:13 -05:00
|
|
|
'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3'} <= set(cs.CHASSIS)
|
2026-11-11 00:12:07 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFacingAndSlots:
|
|
|
|
|
def test_facing_and_slot_validation(self):
|
|
|
|
|
shelf = {'S': {'name': 'S', 'kind': 'shelf'}}
|
|
|
|
|
assert cs.validate_item({'name': 'p', 'facing': 'rear'})['facing'] == 'rear'
|
|
|
|
|
assert cs.validate_item({'name': 'p'})['facing'] == 'front'
|
|
|
|
|
assert cs.validate_item({'name': 'z', 'shelf': 'S', 'slot': '2'}, shelf)['slot'] == 2
|
|
|
|
|
with pytest.raises(cs.ValidationError):
|
|
|
|
|
cs.validate_item({'name': 'p', 'facing': 'up'})
|
|
|
|
|
with pytest.raises(cs.ValidationError):
|
|
|
|
|
cs.validate_item({'name': 'p', 'slot': 1}) # a position needs a shelf
|
|
|
|
|
|
|
|
|
|
def test_rear_posts_facing_front_are_seen_from_the_front(self):
|
|
|
|
|
items = [{'id': 1, 'name': 'pdu', 'kind': 'device', 'rack_u': 24, 'rack_height': 2, 'mount': 'rear'},
|
|
|
|
|
{'id': 2, 'name': 'udm', 'kind': 'device', 'rack_u': 24, 'rack_height': 1}]
|
|
|
|
|
assert [i['name'] for i in cs.rack_layout(items, 25, 'front')['placed']] == ['udm']
|
|
|
|
|
behind = cs.rack_layout(items, 25, 'behind')['placed']
|
|
|
|
|
assert [(i['name'], i['through']) for i in behind] == [('pdu', False)]
|
|
|
|
|
assert [(i['name'], i['through']) for i in cs.rack_layout(items, 25, 'rear')['placed']] == [('pdu', True)]
|