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.
349 lines
12 KiB
Python
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)
|