Files
willtools-demo/WillTools.extension/tests/test_latmodel.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

349 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for the Revit-free half of lateral extraction.
`latmodel` and `latrisa` import nothing from Revit at module scope, so they
run straight on host CPython through the conftest trick -- same as
`ticketpage`. `latextract` is the Revit half and is deliberately not
reachable from here.
No real parameter name and no real mark appears anywhere below. The marks
are `MARK_A` / `MARK_B` and the field names are placeholders, because a
parameter name is this office's template and the repository is shareable.
"""
import pytest
from vibe import latmodel as LM
from vibe import latrisa as LR
# ------------------------------------------------------------- the marker
@pytest.mark.parametrize('text', [
'lateral-resistent',
'LATERAL-RESISTENT',
'Lateral-Resistent',
'lateral resistent',
'lateral_resistent',
'lateral resistent',
'lateral.resistent',
'lateral/resistent',
'shear wall, lateral-resistent',
'lateral-resistent (see S2.1)',
'PRECAST BEAM - LATERAL RESISTENT - NORTH BAY',
])
def test_marker_accepts_owner_spelling(text):
verdict, token = LM.match_marker(text)
assert verdict == LM.MATCH_YES
assert token == 'lateral-resistent'
@pytest.mark.parametrize('text', [
'lateral-resistant',
'LATERAL RESISTANT',
'a lateral_resistant column',
])
def test_marker_accepts_conventional_spelling(text):
verdict, token = LM.match_marker(text)
assert verdict == LM.MATCH_YES
assert token == 'lateral-resistant'
@pytest.mark.parametrize('text', [
None,
'',
' ',
'lateral',
'resistant',
'resistent',
'lateralresistent', # no separator -- see normalise()
'laterally resistant', # 'laterally' is a different word
'lateral resistance',
'PRECAST CONCRETE BEAM',
])
def test_marker_rejects(text):
verdict, token = LM.match_marker(text)
assert verdict == LM.MATCH_NO
assert token is None
@pytest.mark.parametrize('text', [
'NOT lateral-resistent',
'not lateral resistant',
'non lateral-resistent',
'no lateral-resistent',
'excluded lateral-resistent',
])
def test_marker_negated_is_ambiguous_never_silently_included(text):
"""A negated marker is settled by a human, never by this code."""
verdict, _ = LM.match_marker(text)
assert verdict == LM.MATCH_AMBIGUOUS
def test_marker_tokens_are_configurable():
verdict, token = LM.match_marker('this is an LFRS member', ['LFRS'])
assert verdict == LM.MATCH_YES and token == 'LFRS'
# and the defaults no longer apply when a plant overrides them
assert LM.match_marker('lateral-resistent', ['LFRS'])[0] == LM.MATCH_NO
def test_normalise():
assert LM.normalise('A-B_C D') == 'a b c d'
assert LM.normalise(None) == ''
# ------------------------------------------------------------- the model
def _raw(eid, i, j, section='PLACEHOLDER-TYPE', material='PLACEHOLDER-MAT',
verified=False, **kw):
row = {'element_id': eid, 'mark': 'MARK_{0}'.format(eid),
'category': 'Structural Framing', 'desc': 'lateral-resistent',
'token': 'lateral-resistent', 'i': i, 'j': j,
'axis_source': 'location_curve',
'section': ({'name': section, 'source': 'revit_type_name',
'verified': verified} if section else None),
'material': material, 'note': None}
row.update(kw)
return row
def test_build_makes_nodes_and_members():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
_raw(2, (10, 0, 0), (10, 0, 12))])
assert len(m['members']) == 2
assert len(m['nodes']) == 3 # the shared end welded
assert m['members'][0]['j_node'] == m['members'][1]['i_node']
assert m['length_unit'] == 'ft'
def test_units_are_feet_and_nothing_converts():
"""A 12x error has already cost this project real time. Feet in, feet out."""
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
assert m['members'][0]['length_ft'] == pytest.approx(10.0)
assert m['nodes'][1]['x'] == pytest.approx(10.0)
assert 'feet' in m['coordinate_note'].lower()
def test_endpoints_within_tolerance_weld_into_one_node():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
_raw(2, (10.005, 0, 0), (10, 0, 12))])
assert len(m['nodes']) == 3
def test_endpoints_outside_tolerance_stay_separate_and_are_reported():
"""A gap is never quietly closed. It is reported as a near miss."""
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
_raw(2, (10.25, 0, 0), (10.25, 0, 12))])
assert len(m['nodes']) == 4
near = m['connectivity']['near_misses']
assert near and near[0]['gap_ft'] == pytest.approx(0.25)
def test_welding_works_across_a_grid_cell_boundary():
tol = 0.01
a = 5.0
m = LM.build([_raw(1, (0, 0, 0), (a, 0, 0)),
_raw(2, (a + tol * 0.4, 0, 0), (a, 0, 9))],
node_tol_ft=tol)
assert len(m['nodes']) == 3
def test_member_with_no_axis_is_skipped_with_a_reason_not_dropped():
m = LM.build([_raw(1, None, None)])
assert m['members'] == []
assert len(m['skipped']) == 1
assert m['skipped'][0]['element_id'] == 1
assert m['skipped'][0]['reason']
def test_zero_length_member_is_skipped():
m = LM.build([_raw(1, (0, 0, 0), (0, 0, 0.0001))])
assert m['members'] == []
assert 'zero-length' in m['skipped'][0]['reason']
def test_missing_section_is_never_defaulted():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), section=None)])
mem = m['members'][0]
assert mem['section'] is None
assert 'section' in mem['unresolved']
def test_unverified_section_counts_as_unresolved():
"""A type NAME is not a section property, and must not pass as one."""
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), verified=False)])
assert any(u.startswith('verified section')
for u in m['members'][0]['unresolved'])
def test_verified_section_is_accepted():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), verified=True)])
assert not any('section' in u for u in m['members'][0]['unresolved'])
def test_missing_material_is_never_defaulted():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), material=None)])
assert m['members'][0]['material'] is None
assert 'material' in m['members'][0]['unresolved']
def test_end_releases_are_always_unresolved():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), verified=True)])
assert m['members'][0]['releases'] is None
assert 'end releases' in m['members'][0]['unresolved']
def test_free_ends_are_reported_and_not_called_supports():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
assert sorted(m['connectivity']['free_ends']) == ['N1', 'N2']
def test_interior_intersections_are_not_searched_for_and_say_so():
"""A member ending mid-span on another is NOT connected. Stated, not hidden."""
m = LM.build([_raw(1, (0, 0, 0), (20, 0, 0)),
_raw(2, (10, 0, 0), (10, 0, 12))])
assert m['connectivity']['interior_intersections_checked'] is False
assert len(m['nodes']) == 4 # the T is four nodes, not three
def test_near_miss_search_stands_down_on_a_big_model_rather_than_lying():
"""Not run is not 'none found'. Revit's UI thread must not be blocked."""
raws = [_raw(n, (n * 100.0, 0, 0), (n * 100.0 + 10, 0, 0))
for n in range(700)]
m = LM.build(raws)
assert m['connectivity']['near_miss_checked'] is False
assert m['connectivity']['near_miss_total'] is None
assert m['connectivity']['near_misses'] == []
assert m['connectivity']['node_count'] == 1400
def test_near_miss_search_runs_on_a_small_model():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
_raw(2, (10.25, 0, 0), (10.25, 0, 12))])
assert m['connectivity']['near_miss_checked'] is True
assert m['connectivity']['near_miss_total'] == 1
def test_unresolved_rows_are_element_by_element():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), section=None, material=None)])
rows = LM.unresolved_rows(m)
assert {r['missing'] for r in rows} == {'section', 'material',
'end releases'}
assert all(r['element_id'] == 1 for r in rows)
def test_notes_state_the_absences():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
blob = ' '.join(m['notes']).upper()
assert 'END RELEASES ARE NOT EXTRACTED' in blob
assert 'SUPPORTS ARE NOT EXTRACTED' in blob
assert 'LOADS ARE NOT EXTRACTED' in blob
def test_is_analysable_is_false_while_anything_is_missing():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
assert LM.is_analysable(m) is False
assert LM.is_analysable(LM.build([])) is False
def test_empty_input_is_an_empty_model_not_a_crash():
m = LM.build([])
assert m['members'] == [] and m['nodes'] == [] and m['skipped'] == []
# ------------------------------------------------------------- the output
def _model():
return LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
_raw(2, (10, 0, 0), (10, 0, 12), section=None,
material=None)],
model_title='sample_job')
def test_json_round_trips():
import json
m = _model()
back = json.loads(LR.emit_json(m))
assert back['schema'] == 'willtools.lateral/1'
assert len(back['members']) == 2
def test_nodes_csv_carries_the_unit_on_every_row():
rows = LR.emit_nodes_csv(_model()).strip().split('\n')
assert rows[0].startswith('label,x,y,z,unit')
assert all(',ft,' in r for r in rows[1:])
def test_members_csv_leaves_unknowns_blank_rather_than_filled():
text = LR.emit_members_csv(_model())
rows = [r.split(',') for r in text.strip().split('\n')]
head, body = rows[0], rows[1:]
sec = head.index('section')
rel = head.index('end_releases')
assert body[1][sec] == '' # no section -> blank, not a default
assert all(r[rel] == '' for r in body)
def test_members_csv_names_what_is_unresolved_in_words_too():
text = LR.emit_members_csv(_model())
assert 'end releases' in text
def test_risa_output_is_labelled_a_draft_loudly():
text = LR.emit_risa_draft(_model())
assert 'DRAFT -- NOT A VERIFIED RISA-3D INPUT FILE' in text
assert 'DRAFT' in text.split('\n')[1]
def test_risa_draft_uses_risas_published_section_keywords():
text = LR.emit_risa_draft(_model())
for kw in ('[RISA-3D_INPUT_DATA]', '[NODES]', '[MEMBERS]',
'[.PROJECT_DESCRIPTION]'):
assert kw in text
assert '[NODES] <3>' in text
assert '[MEMBERS] <2>' in text
def test_risa_draft_emits_no_units_line():
"""A wrong units line rescales everything invisibly. Better absent."""
text = LR.emit_risa_draft(_model())
assert '\n[UNITS]' not in text
assert '! [UNITS] omitted on purpose' in text
def test_risa_draft_emits_no_supports_and_says_so():
text = LR.emit_risa_draft(_model())
assert '\n[BOUNDARY_CONDITIONS]' not in text
assert 'intentionally absent' in text
def test_risa_draft_uses_an_unresolvable_shape_rather_than_a_default():
"""It must fail to analyse, not analyse wrongly."""
text = LR.emit_risa_draft(_model())
assert LR.UNRESOLVED_SHAPE in text
assert LR.UNRESOLVED_MATERIAL in text
def test_risa_draft_records_every_skip_with_its_reason():
m = LM.build([_raw(7, None, None)])
text = LR.emit_risa_draft(m)
assert '! skipped element 7' in text
def test_risa_draft_says_nothing_was_skipped_when_nothing_was():
assert 'nothing was skipped' in LR.emit_risa_draft(_model())
def test_file_set_names_the_draft_so_it_cannot_be_opened_by_accident():
names = [n for n, _ in LR.file_set(_model(), 'sample_job')]
assert names == ['sample_job-lateral.json',
'sample_job-lateral-nodes.csv',
'sample_job-lateral-members.csv',
'sample_job-lateral.r3d.DRAFT.txt']
assert not any(n.endswith('.r3d') for n in names)
def test_csv_quotes_a_description_containing_a_comma():
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0),
desc='shear wall, lateral-resistent')])
assert '"shear wall, lateral-resistent"' in LR.emit_members_csv(m)