# -*- 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'