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.
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
# -*- 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'
|
||||
Reference in New Issue
Block a user