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