Files
willtools-demo/WillTools.extension/tests/test_specformat.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

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'