8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
335 lines
12 KiB
Python
335 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for vibe.specformat: the spec shape, the strand matrix, and the
|
|
hand-editable YAML text it reads and writes.
|
|
|
|
No Revit dependency -- see conftest.py -- so the whole module is in scope.
|
|
`designspec.py` is the Revit-side half of this boundary (evidence scraping,
|
|
disk IO with a live `doc`) and is out of scope for this suite the same way
|
|
`ticketqc.py` is (see CLAUDE.md).
|
|
"""
|
|
import json
|
|
|
|
from vibe import specformat
|
|
|
|
|
|
WP1_LAYOUT_NEW = {
|
|
'datum': 'left_rail',
|
|
'columns_in': [3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5],
|
|
'rows': [
|
|
{'from_bottom_in': 1.75, 'strands': ['X'] * 7},
|
|
{'from_bottom_in': 6.25, 'strands': ['X'] * 7},
|
|
],
|
|
'total_in': 143.0,
|
|
}
|
|
|
|
WP1_LAYOUT_OLD = {
|
|
'datum': 'left_rail',
|
|
'positions_in': [3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5],
|
|
'rows': [
|
|
{'from_bottom_in': 1.75, 'note': '1 3/4"'},
|
|
{'from_bottom_in': 6.25, 'note': '4 1/2"'},
|
|
],
|
|
'total_in': 143.0,
|
|
}
|
|
|
|
|
|
def _blank_spec():
|
|
return json.loads(json.dumps(specformat.SPEC_TEMPLATE))
|
|
|
|
|
|
# --------------------------------------------------------------- get_path
|
|
|
|
def test_get_path_walks_dotted_keys():
|
|
spec = {'concrete': {'fc_psi': 6000}}
|
|
assert specformat.get_path(spec, 'concrete.fc_psi') == 6000
|
|
|
|
|
|
def test_get_path_missing_returns_default():
|
|
assert specformat.get_path({}, 'concrete.fc_psi', 'fallback') == 'fallback'
|
|
|
|
|
|
def test_get_path_none_value_returns_default_not_none():
|
|
spec = {'concrete': {'fc_psi': None}}
|
|
assert specformat.get_path(spec, 'concrete.fc_psi', 'fallback') == 'fallback'
|
|
|
|
|
|
# ------------------------------------------------------------- validate_spec
|
|
|
|
def test_validate_spec_flags_missing_required_paths():
|
|
spec = _blank_spec()
|
|
problems = specformat.validate_spec(spec)
|
|
assert any('design_number' in p for p in problems)
|
|
assert any('concrete.fc_psi' in p for p in problems)
|
|
assert any('concrete.fci_psi' in p for p in problems)
|
|
|
|
|
|
def test_validate_spec_happy_path_has_no_complaints():
|
|
spec = _blank_spec()
|
|
spec['design_number'] = 'WP1'
|
|
spec['concrete']['fc_psi'] = 6000
|
|
spec['concrete']['fci_psi'] = 3500
|
|
assert specformat.validate_spec(spec) == []
|
|
|
|
|
|
def test_validate_spec_flags_fci_higher_than_fc():
|
|
spec = _blank_spec()
|
|
spec['design_number'] = 'WP1'
|
|
spec['concrete']['fc_psi'] = 3500
|
|
spec['concrete']['fci_psi'] = 6000
|
|
problems = specformat.validate_spec(spec)
|
|
assert any("higher than f'c" in p for p in problems)
|
|
|
|
|
|
def test_validate_spec_flags_a_strand_group_with_zero_qty():
|
|
spec = _blank_spec()
|
|
spec['design_number'] = 'WP1'
|
|
spec['concrete']['fc_psi'] = 6000
|
|
spec['concrete']['fci_psi'] = 3500
|
|
spec['reinforcing']['strand_groups'] = [{'qty': 0, 'dia_in': 0.5}]
|
|
problems = specformat.validate_spec(spec)
|
|
assert any('qty is 0' in p for p in problems)
|
|
|
|
|
|
# ------------------------------------------------------------- normalize_spec
|
|
|
|
def test_normalize_spec_maps_strand_group_synonyms_onto_canonical_names():
|
|
spec = {'reinforcing': {'strand_groups': [
|
|
{'count': '14', 'diameter_in': '0.5', 'grade': '270', 'it_lbs': '28,920'},
|
|
]}}
|
|
specformat.normalize_spec(spec)
|
|
group = spec['reinforcing']['strand_groups'][0]
|
|
assert group['qty'] == 14
|
|
assert group['dia_in'] == 0.5
|
|
assert group['grade_ksi'] == 270.0
|
|
assert group['it_lb'] == 28920.0
|
|
|
|
|
|
def test_normalize_spec_prefers_canonical_field_when_both_present():
|
|
spec = {'reinforcing': {'strand_groups': [{'qty': 14, 'count': 99}]}}
|
|
specformat.normalize_spec(spec)
|
|
assert spec['reinforcing']['strand_groups'][0]['qty'] == 14
|
|
|
|
|
|
def test_normalize_spec_maps_concrete_synonyms():
|
|
spec = {'concrete': {'fc': '6000 PSI', 'fci': '3500'}}
|
|
specformat.normalize_spec(spec)
|
|
assert spec['concrete']['fc_psi'] == 6000.0
|
|
assert spec['concrete']['fci_psi'] == 3500.0
|
|
|
|
|
|
def test_normalize_spec_tolerates_a_non_dict():
|
|
assert specformat.normalize_spec(None) is None
|
|
|
|
|
|
# ----------------------------------------------------------- new_spec
|
|
|
|
def test_new_spec_carries_provenance_from_evidence():
|
|
evidence = {
|
|
'summary_views': [{'name': 'WP1.0 8" SOLID WALL', 'id': 123}],
|
|
'sheets': [{'number': 'WP1.0', 'name': '8" HORIZONTAL SOLID WALL'}],
|
|
'model': 'sample_job',
|
|
}
|
|
spec = specformat.new_spec('WP1', evidence)
|
|
assert spec['design_number'] == 'WP1'
|
|
assert spec['source']['view'] == 'WP1.0 8" SOLID WALL'
|
|
assert spec['source']['view_id'] == 123
|
|
assert spec['source']['sheet'] == 'WP1.0 - 8" HORIZONTAL SOLID WALL'
|
|
assert spec['source']['model'] == 'sample_job'
|
|
assert spec['source']['extracted'] # a timestamp got written
|
|
|
|
|
|
def test_new_spec_without_evidence_is_still_usable():
|
|
spec = specformat.new_spec('WP1')
|
|
assert spec['design_number'] == 'WP1'
|
|
assert spec['source']['view'] is None
|
|
|
|
|
|
# --------------------------------------------------------- the strand matrix
|
|
|
|
def test_strand_positions_reads_the_new_matrix_shape():
|
|
assert specformat.strand_positions(WP1_LAYOUT_NEW) == \
|
|
[3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5]
|
|
|
|
|
|
def test_strand_positions_falls_back_to_legacy_positions_in():
|
|
assert specformat.strand_positions(WP1_LAYOUT_OLD) == \
|
|
[3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5]
|
|
|
|
|
|
def test_strand_positions_empty_for_a_blank_layout():
|
|
assert specformat.strand_positions({}) == []
|
|
assert specformat.strand_positions(None) == []
|
|
|
|
|
|
def test_strand_row_marks_reads_an_explicit_strands_list():
|
|
row = {'from_bottom_in': 1.75, 'strands': ['X', '.', 'X']}
|
|
assert specformat.strand_row_marks(row, 3) == [True, False, True]
|
|
|
|
|
|
def test_strand_row_marks_a_legacy_row_with_no_strands_key_is_read_as_full():
|
|
row = {'from_bottom_in': 1.75, 'note': '1 3/4"'}
|
|
assert specformat.strand_row_marks(row, 7) == [True] * 7
|
|
|
|
|
|
def test_strand_row_marks_treats_various_blank_spellings_as_empty():
|
|
row = {'strands': ['X', '', 'null', 'None', '.', '-', '0']}
|
|
assert specformat.strand_row_marks(row, 7) == \
|
|
[True, False, False, False, False, False, False]
|
|
|
|
|
|
def test_strand_row_marks_short_row_pads_with_false():
|
|
row = {'strands': ['X']}
|
|
assert specformat.strand_row_marks(row, 3) == [True, False, False]
|
|
|
|
|
|
def test_strand_diagram_matches_wp1s_real_geometry():
|
|
lines = specformat.strand_diagram(WP1_LAYOUT_NEW)
|
|
# datum line, ruler, one line per row (furthest from bottom first), and
|
|
# the columns summary.
|
|
assert 'left_rail' in lines[0]
|
|
assert '143' in lines[0]
|
|
assert lines[1].startswith('+') and lines[1].endswith('+')
|
|
assert '6.25" from bottom (7 strands)' in lines[2]
|
|
assert '1.75" from bottom (7 strands)' in lines[3]
|
|
assert lines[-1] == ('columns (in, from datum): '
|
|
'3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5')
|
|
|
|
|
|
def test_strand_diagram_row_order_is_furthest_from_bottom_first():
|
|
# The picture reads top-down like the elevation it stands in for.
|
|
lines = specformat.strand_diagram(WP1_LAYOUT_NEW)
|
|
row_lines = [ln for ln in lines if 'from bottom' in ln]
|
|
heights = [float(ln.split('"')[0].split()[-1]) for ln in row_lines]
|
|
assert heights == sorted(heights, reverse=True)
|
|
|
|
|
|
def test_strand_diagram_incomplete_data_says_so_instead_of_guessing():
|
|
lines = specformat.strand_diagram({'datum': 'left_rail'})
|
|
assert any('not enough data' in ln for ln in lines)
|
|
|
|
|
|
def test_strand_diagram_not_a_dict_is_handled():
|
|
assert specformat.strand_diagram(None) == ['(no strand layout)']
|
|
|
|
|
|
def test_strand_diagram_marks_a_partial_row_with_dots():
|
|
layout = dict(WP1_LAYOUT_NEW)
|
|
layout['rows'] = [
|
|
{'from_bottom_in': 1.75, 'strands': ['X', 'X', '.', 'X', 'X', 'X', 'X']},
|
|
]
|
|
lines = specformat.strand_diagram(layout)
|
|
row_line = [ln for ln in lines if 'from bottom' in ln][0]
|
|
assert '(6 strands)' in row_line
|
|
|
|
|
|
# ------------------------------------------------------------ spec_text IO
|
|
|
|
def _sample_spec():
|
|
spec = _blank_spec()
|
|
spec['design_number'] = 'WP1'
|
|
spec['piece_description'] = '8" HORIZONTAL SOLID WALL'
|
|
spec['source']['extractor'] = 'regex'
|
|
spec['concrete'] = {'fc_psi': 6000, 'fci_psi': 3500,
|
|
'unit_weight_pcf': 150, 'weight_class': 'NORMAL'}
|
|
spec['thickness_in'] = 8
|
|
spec['reinforcing']['mild_grade_ksi'] = None
|
|
spec['reinforcing']['strand_groups'] = [
|
|
{'qty': 14, 'dia_in': 0.5, 'grade_ksi': 270, 'it_lb': 28920,
|
|
'role': None, 'note': None},
|
|
]
|
|
spec['reinforcing']['strand_layout'] = dict(WP1_LAYOUT_NEW)
|
|
spec['reinforcing']['strand_layout']['note'] = None
|
|
spec['unresolved'] = [{'text': 'STRAND PATTERN',
|
|
'reason': 'no extraction rule matched this note'}]
|
|
return spec
|
|
|
|
|
|
def test_spec_text_round_trips_every_value():
|
|
spec = _sample_spec()
|
|
text = specformat.spec_text(spec)
|
|
back = specformat.parse_spec_text(text)
|
|
assert back['design_number'] == 'WP1'
|
|
assert back['concrete']['fc_psi'] == 6000
|
|
assert back['concrete']['fci_psi'] == 3500
|
|
assert back['reinforcing']['strand_groups'][0]['it_lb'] == 28920
|
|
assert back['reinforcing']['strand_layout']['columns_in'] == \
|
|
WP1_LAYOUT_NEW['columns_in']
|
|
assert back['reinforcing']['strand_layout']['rows'][0]['strands'] == \
|
|
['X'] * 7
|
|
assert back['reinforcing']['strand_layout']['total_in'] == 143.0
|
|
assert back['unresolved'] == spec['unresolved']
|
|
|
|
|
|
def test_spec_text_carries_the_matrix_diagram_as_comments_only():
|
|
spec = _sample_spec()
|
|
text = specformat.spec_text(spec)
|
|
assert '# datum: left_rail' in text
|
|
assert '# columns (in, from datum): 3.5, 16.5' in text
|
|
# Comments must not survive the read back into data.
|
|
back = specformat.parse_spec_text(text)
|
|
assert 'datum: left_rail (rail on the left' not in json.dumps(back)
|
|
|
|
|
|
def test_spec_text_diagram_reflects_the_matrix_it_sits_above():
|
|
# Change a row height and the rendered diagram in the header changes with
|
|
# it -- the picture cannot drift from the data because it is drawn from
|
|
# the data every time spec_text() runs.
|
|
spec = _sample_spec()
|
|
spec['reinforcing']['strand_layout']['rows'][1]['from_bottom_in'] = 5.0
|
|
text = specformat.spec_text(spec)
|
|
assert '5" from bottom' in text
|
|
assert '6.25" from bottom' not in text
|
|
|
|
|
|
def test_spec_text_says_machine_read_and_unverified():
|
|
text = specformat.spec_text(_sample_spec())
|
|
assert 'read by machine' in text
|
|
assert 'reviewed_by' in text
|
|
|
|
|
|
def test_spec_text_handles_a_missing_design_number():
|
|
text = specformat.spec_text({})
|
|
assert 'WillTools design spec -- ?' in text
|
|
|
|
|
|
def test_parse_spec_text_of_an_empty_string_is_an_empty_dict():
|
|
assert specformat.parse_spec_text('') == {}
|
|
|
|
|
|
# ------------------------------------------------------------- design_key
|
|
#
|
|
# Moved here from `designspec` (which imports the Revit API at module scope)
|
|
# so that `reinforcebind` can ask "are these the same design?" both inside
|
|
# Revit and in `tools/reinforce_offline.py`, which runs with no Revit at all.
|
|
# One implementation, one answer -- a second spelling of this question is how
|
|
# two answers start. `designspec.design_key` re-exports it and every existing
|
|
# caller is unchanged.
|
|
|
|
|
|
def test_a_packet_suffix_does_not_split_a_design():
|
|
"""The nomenclature ruling: the cover sheet, the `.A` note and the bare
|
|
number on a piece are all ONE design."""
|
|
for spelling in ('WP1', 'WP1.0', 'WP1.A', 'WP1.B1', 'wp 1.0'):
|
|
assert specformat.design_key(spelling) == 'WP1'
|
|
|
|
|
|
def test_two_designs_stay_two_designs():
|
|
assert specformat.design_key('WP1.0') != specformat.design_key('WP2.0')
|
|
assert specformat.design_key('WP1.0') != specformat.design_key('IWP1.0')
|
|
|
|
|
|
def test_something_the_pattern_cannot_read_is_none_not_a_guess():
|
|
"""None is COULD NOT READ THIS. It is not a design, and two of them are
|
|
not a match -- see `reinforcebind.DonorFilter._design_reject`."""
|
|
for junk in (None, '', ' ', '...', 123):
|
|
assert specformat.design_key(junk) is None
|
|
|
|
|
|
def test_a_pattern_may_be_handed_in_instead_of_a_knowledge_tree():
|
|
"""`reinforcebind` has no knowledge layer offline, so the plant's
|
|
`nomenclature.design_number_re` is passed as a value."""
|
|
assert specformat.design_key('WP1.0', None, r'^([A-Za-z]+)') == 'WP'
|
|
# A pattern that will not compile falls back rather than raising: this is
|
|
# a reduction, not a place to take a run down over a typo in a YAML file.
|
|
assert specformat.design_key('WP1.0', None, '([') == 'WP1'
|