# -*- coding: utf-8 -*- """Host tests for `vibe.panelanalysisform` -- the Panel Analysis input form's pure logic: pre-fill, the completeness gate, the deck build, the Analyze-All batch plan, the plain-language section/group structure, the Handling stage matrix, and the openings / strand-group row parsers. The WPF form itself (the accordion, the per-panel Run Analysis buttons, the live geometry read off the framing element) is owner-must-verify in Revit; everything below the form boundary is here. Loaded by path like the sibling `test_panelanalysis*` files -- host CPython reaching `tools/` for the schema so a deck this module builds can be fed straight back through the real `panelanalysis/1` reader and solve. """ from __future__ import annotations import os import sys import pytest ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) TOOLS = os.path.join(ROOT, 'tools') PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src') for p in (TOOLS, PANELLIFT_SRC): if p not in sys.path: sys.path.insert(0, p) from vibe import knowledge # noqa: E402,F401 (registers the vibe stub) from vibe import panelanalysisform as F # noqa: E402 schema = pytest.importorskip('panelanalysis_schema') statics = pytest.importorskip('panellift.statics') # A model-read for a plain solid panel: geometry + materials the framing # element supplied, supports blank (the user types those). Generic values. READS = { 'mark': 'PNL101', 'design_number': 'PNL1', 'length_ft': 24.0, 'width_ft': 8.0, 'thickness_in': 8.0, 'fc_psi': 6000.0, 'fci_psi': 3500.0, 'unit_weight_pcf': 150.0, } def _complete_values(): """A fully-fillable form: prefilled reads + the user's supports.""" values = F.prefill(READS) values['supports_ft'] = '2, 22' return values def _with_prestress(values=None, count='4', dia='1/2', pull='28900', rows='4 @ 2'): values = dict(values or _complete_values()) values['ps_count'] = count values['ps_dia_in'] = dia values['ps_pull_lb'] = pull values['ps_rows'] = rows return values # --------------------------------------------------------------- structure def test_sections_are_the_five_eriksson_names_and_cover_every_field(): names = [name for name, _groups in F.sections()] assert names == list(F.SECTION_ORDER) assert names == ['Design Criteria', 'Concrete Extents', 'Structural Model', 'Handling', 'Reinforcement'] covered = set() for _name, groups in F.sections(): for _group_name, fields in groups: for f in fields: covered.add(f['key']) field_keys = set(f['key'] for f in F.FIELDS) assert covered == field_keys def test_sections_group_fields_and_every_group_is_named_or_none(): for _name, groups in F.sections(): group_names = [g for g, _fields in groups] # at most one ungrouped (None) bucket, and it comes first if present assert group_names.count(None) <= 1 if None in group_names: assert group_names[0] is None # every named group is non-empty for g, fields in groups: assert fields if g is not None: assert isinstance(g, str) and g def test_design_criteria_has_the_four_named_groups(): by_section = dict(F.sections()) dc_groups = [g for g, _f in by_section['Design Criteria']] for want in ('Material Properties', 'Prestress Losses', 'Load Combinations', 'Stress Limits & Factors'): assert want in dc_groups def test_every_field_declares_a_known_section_group_and_drives_flag(): for f in F.FIELDS: assert f['section'] in F.SECTION_ORDER, f['key'] assert f['drives'] in ('solve', 'stage3', 'info'), f['key'] assert f['kind'] in ('text', 'num', 'int', 'supports', 'openings', 'strands'), f['key'] assert isinstance(f['label'], str) and f['label'] assert isinstance(f['help'], str) and f['help'] # the plain label carries no raw prime/subscript math -- that lives # in `symbol` instead (the owner's "太丑了" ruling) assert u'′' not in f['label'], f['key'] def test_wind_fields_are_present_blank_and_collected_not_yet_driving(): wind = [f for f in F.FIELDS if f['key'].startswith('wind_')] keys = set(f['key'] for f in wind) for want in ('wind_speed_vult', 'wind_risk_category', 'wind_exposure', 'wind_enclosure', 'wind_asce_version', 'wind_cc_chapter', 'wind_mwfrs_chapter', 'wind_pressure_psf', 'wind_suction_psf'): assert want in keys for f in wind: assert f['section'] == 'Structural Model' assert f['group'] == 'Wind' assert f['drives'] == 'stage3' # collected, not yet driving values = F.prefill(READS) for k in keys: assert values[k] == '' def test_strip_transport_erection_sling_fields_are_gone(): # migrated INTO the stage matrix, not left sitting beside it keys = set(f['key'] for f in F.FIELDS) for gone in ('strip_mult', 'transport_mult', 'erection_mult', 'sling_angle', 'ps_ecc_in'): assert gone not in keys # ----------------------------------------------------------------- prefill def test_prefill_fills_readable_and_blanks_the_rest(): values = F.prefill(READS) assert values['length_ft'] == '24' assert values['width_ft'] == '8' assert values['thickness_in'] == '8' assert values['fc_psi'] == '6000' assert values['fci_psi'] == '3500' assert values['mark'] == 'PNL101' # supports and every stage3 / load field come up blank for the user assert values['supports_ft'] == '' assert values['self_weight_factor'] == '' assert values['ps_count'] == '' assert values['ps_rows'] == '' assert values['openings'] == '' def test_prefill_blanks_a_field_the_model_could_not_read(): reads = dict(READS) reads['fci_psi'] = None del reads['unit_weight_pcf'] values = F.prefill(reads) assert values['fci_psi'] == '' assert values['unit_weight_pcf'] == '' def test_prefill_blanks_every_stage_matrix_cell(): values = F.prefill(READS) for key in F.stage_field_keys(): assert values[key] == '' # -------------------------------------------------------------- validation def test_complete_form_validates_and_parses(): errors, parsed = F.validate(_complete_values()) assert errors == [] assert parsed['supports'] == [2.0, 22.0] assert parsed['length_ft'] == 24.0 assert parsed['fc_psi'] == 6000.0 assert parsed['openings_rows'] == [] assert parsed['strand_rows'] == [] def test_blank_required_field_blocks_and_is_named(): values = _complete_values() values['fc_psi'] = '' errors, parsed = F.validate(values) assert parsed is None # The label now reads plain English; the symbol (f′c) still rides beside # it via `display_label`. What is pinned here is that the message names # the field, in BOTH vocabularies. assert any('Concrete strength at 28 days' in e and 'required' in e for e in errors) assert any(u'f′c' in e for e in errors) def test_fewer_than_two_supports_is_refused_by_name(): values = _complete_values() values['supports_ft'] = '2' errors, parsed = F.validate(values) assert parsed is None assert any('at least two support' in e for e in errors) assert not F.is_complete(values) def test_no_supports_is_refused(): values = _complete_values() values['supports_ft'] = '' errors, parsed = F.validate(values) assert parsed is None assert any('support' in e.lower() for e in errors) def test_garbage_support_token_is_named(): values = _complete_values() values['supports_ft'] = '2, banana' errors, parsed = F.validate(values) assert parsed is None assert any('banana' in e for e in errors) def test_stage3_field_never_blocks_the_run(): values = _complete_values() # a wind value typed in but the solve does not use it -> still runnable, # and even a non-numeric stray in a stage3 field must not block values['wind_speed_vult'] = 'not a number' values['wind_risk_category'] = 'II' assert F.is_complete(values) def test_bad_number_in_a_solve_field_blocks(): values = _complete_values() values['unit_weight_pcf'] = 'heavy' errors, parsed = F.validate(values) assert parsed is None assert any('Concrete unit weight' in e for e in errors) # -------------------------------------------------------------- prestress def test_no_prestress_fields_means_no_prestress_block(): _errors, parsed = F.validate(_complete_values()) assert parsed['prestress'] is None assert parsed['strand_rows'] == [] def test_full_prestress_group_builds_strands(): values = _with_prestress(rows='4 @ 2') errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert len(ps['strands']) == 4 assert all(s['area_in2'] == 0.153 for s in ps['strands']) assert all(s['initial_pull_lb'] == 28900.0 for s in ps['strands']) assert all(s['z_in'] == 2.0 for s in ps['strands']) def test_prestress_group_with_multiple_rows_and_per_row_pull_pct(): values = _with_prestress(count='8', rows='4 @ 2 @ 75\n4 @ 9.25 @ 56') values['ps_fpu_ksi'] = '270' errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert len(ps['strands']) == 8 shallow = [s for s in ps['strands'] if s['z_in'] == 2.0] deep = [s for s in ps['strands'] if s['z_in'] == 9.25] assert len(shallow) == 4 and len(deep) == 4 # pull = pct/100 * fpu(ksi)*1000 * area, not the default ps_pull_lb assert all(abs(s['initial_pull_lb'] - 0.75 * 270.0 * 1000.0 * 0.153) < 1 for s in shallow) assert all(abs(s['initial_pull_lb'] - 0.56 * 270.0 * 1000.0 * 0.153) < 1 for s in deep) def test_partial_prestress_group_is_refused_field_by_field(): values = _complete_values() values['ps_count'] = '4' # started, but no size/pull/rows errors, parsed = F.validate(values) assert parsed is None assert any('diameter' in e for e in errors) assert any('pull' in e for e in errors) assert any('Strand groups' in e for e in errors) def test_unknown_strand_size_is_refused_not_guessed(): values = _with_prestress(count='2', dia='0.99', rows='2 @ 2') errors, parsed = F.validate(values) assert parsed is None assert any('section area' in e for e in errors) def test_ps_count_disagreement_with_rows_is_reported_not_resolved(): values = _with_prestress(count='5', rows='4 @ 2') # 5 != 4 errors, parsed = F.validate(values) assert parsed is None assert any('does not match the sum' in e for e in errors) def test_ps_count_blank_skips_the_cross_check(): values = _with_prestress(count='', rows='4 @ 2') errors, parsed = F.validate(values) assert errors == [] assert len(parsed['prestress']['strands']) == 4 def test_garbage_strand_row_line_is_named_by_line_number(): values = _with_prestress(rows='4 @ 2\nbanana') errors, parsed = F.validate(values) assert parsed is None assert any('line 2' in e for e in errors) def test_strand_rows_are_kept_in_parsed_even_when_group_incomplete(): values = _complete_values() values['ps_rows'] = '4 @ 2' # rows typed, but no dia/pull -> refused errors, parsed = F.validate(values) assert parsed is None # the whole panel is refused (as before) # but strand_rows / ps_rows are not something to re-derive here; the # raw text is preserved off `values` regardless, and validate() does not # crash trying to reach into it. # ------------------------------------------------------------- openings def test_blank_openings_is_no_openings(): values = _complete_values() values['openings'] = '' errors, parsed = F.validate(values) assert errors == [] assert parsed['openings_rows'] == [] assert parsed['openings'] == '' def test_one_opening_per_line_parses_the_new_grammar(): rows, problems = F.parse_openings( 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door\n' 'Reveal, 1, end, 2, 1, right, 2, 0, 1') assert problems == [] assert len(rows) == 2 door = rows[0] assert door == {'type': 'Opening', 'pos_along': 2.0, 'pos_along_from': 'start', 'length': 3.0, 'pos_cross': 4.0, 'pos_cross_from': 'left', 'width': 5.0, 'pos_vert': 1.0, 'height': 6.0, 'label': 'Door'} reveal = rows[1] assert reveal['type'] == 'Reveal' assert reveal['pos_along_from'] == 'end' assert reveal['pos_cross_from'] == 'right' assert reveal['label'] == '' def test_openings_bad_type_is_named_by_line_number(): rows, problems = F.parse_openings('Doorhole, 2, start, 3, 4, left, 5, 1, 6') assert rows == [] assert any('line 1' in p and 'type' in p for p in problems) def test_openings_bad_datum_word_is_named(): rows, problems = F.parse_openings( 'Opening, 2, sideways, 3, 4, left, 5, 1, 6') assert rows == [] assert any('pos_along_from' in p for p in problems) def test_openings_all_or_nothing_across_lines(): rows, problems = F.parse_openings( 'Opening, 2, start, 3, 4, left, 5, 1, 6\nbanana') assert rows == [] # the good line is not kept either assert len(problems) == 1 assert 'line 2' in problems[0] def test_openings_row_reaches_validate_parsed_output(): values = _complete_values() values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door' errors, parsed = F.validate(values) assert errors == [] assert parsed['openings_rows'] == [{ 'type': 'Opening', 'pos_along': 2.0, 'pos_along_from': 'start', 'length': 3.0, 'pos_cross': 4.0, 'pos_cross_from': 'left', 'width': 5.0, 'pos_vert': 1.0, 'height': 6.0, 'label': 'Door'}] assert parsed['openings'] == values['openings'] def test_bad_openings_line_blocks_the_run_and_names_the_line(): values = _complete_values() values['openings'] = 'banana' errors, parsed = F.validate(values) assert parsed is None assert any('line 1' in e for e in errors) # ------------------------------------------------------------ stage matrix def test_stage_matrix_shape(): m = F.stage_matrix() assert m['stages'] == ['Stripping', 'Transportation', 'Primary Erection', 'Secondary Erection'] opt_keys = [r['opt_key'] for r in m['rows']] assert opt_keys == ['include', 'load_multiplier', 'lift_type', 'concrete_strength', 'stress_limits', 'rigging', 'sling_angle', 'ground_reaction', 'min_tilt_deg', 'max_tilt_deg', 'top_crane_carries'] for row in m['rows']: assert set(row['cells'].keys()) == set(m['stages']) for stage, key in row['cells'].items(): assert key == F.stage_field_key(stage, row['opt_key']) def test_stage_matrix_offers_choices_but_never_a_default(): m = F.stage_matrix() by_key = dict((r['opt_key'], r) for r in m['rows']) assert by_key['lift_type']['choices'] == ('Static Lift', 'Rolling Block') assert by_key['include']['choices'] is None values = F.prefill(READS) for key in F.stage_field_keys(): assert values[key] == '' # nothing pre-selected def test_stage_matrix_load_multiplier_rides_into_deck(): values = _complete_values() values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4' values[F.stage_field_key('Transportation', 'load_multiplier')] = '1.5' values[F.stage_field_key('Primary Erection', 'load_multiplier')] = '1.2' errors, parsed = F.validate(values) assert errors == [] deck = F.build_deck(parsed) assert deck['handling']['stripping_multiplier'] == 1.4 assert deck['handling']['transport_multiplier'] == 1.5 assert deck['handling']['erection_multiplier'] == 1.2 def test_excluded_stage_does_not_gate_the_run_on_its_other_fields(): values = _complete_values() values[F.stage_field_key('Secondary Erection', 'include')] = 'no' values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = \ 'not a number' assert F.is_complete(values) def test_excluded_stage_keeps_the_typed_value_rather_than_dropping_it(): values = _complete_values() values[F.stage_field_key('Secondary Erection', 'include')] = 'no' values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = '9.9' errors, parsed = F.validate(values) assert errors == [] row = parsed['stage_matrix']['Secondary Erection'] assert row['include'] is False assert row['load_multiplier'] == 9.9 # kept, just not required/used def test_excluded_stage_multiplier_does_not_reach_the_deck(): values = _complete_values() values[F.stage_field_key('Stripping', 'include')] = 'no' values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4' errors, parsed = F.validate(values) assert errors == [] deck = F.build_deck(parsed) assert 'stripping_multiplier' not in deck['handling'] def test_included_stage_bad_load_multiplier_blocks_and_names_the_stage(): values = _complete_values() values[F.stage_field_key('Stripping', 'load_multiplier')] = 'heavy' errors, parsed = F.validate(values) assert parsed is None assert any('Stripping' in e and 'Load multiplier' in e for e in errors) def test_bad_include_answer_is_named(): values = _complete_values() values[F.stage_field_key('Stripping', 'include')] = 'maybe' errors, parsed = F.validate(values) assert parsed is None assert any('Stripping' in e for e in errors) # ------------------------------------------------------------- deck build def _deck_from(values): errors, parsed = F.validate(values) assert errors == [], errors return F.build_deck(parsed) def test_build_deck_is_the_panelanalysis_schema(): deck = _deck_from(_complete_values()) assert deck['schema'] == F.SCHEMA == schema.SCHEMA schema.check_schema(deck) # does not raise panel = schema.to_panel(deck) assert panel.mark == 'PNL101' assert panel.length_ft == 24.0 assert panel.fc_psi == 6000.0 def test_deck_supports_reach_the_schema(): deck = _deck_from(_complete_values()) assert schema.supports(deck) == [(2.0, 'support'), (22.0, 'support')] def test_deck_solves_and_reactions_sum_to_load(): deck = _deck_from(_complete_values()) panel = schema.to_panel(deck) stage = schema.handling_stage(deck, panel) analysis = statics.analyse(panel, stage, schema.handling_rigging()) total_r = sum(line.reaction_lb for line in analysis.rig.lines) total_load = analysis.profile.total_lb # the closure that says the solve is real: reactions sum to the load assert abs(total_r - total_load) <= max(1.0, 1e-6 * abs(total_load)) assert total_load > 0 def test_unbuilt_blocks_are_explicit_unread_not_fabricated(): deck = _deck_from(_complete_values()) for block in (deck['in_place'], deck['loading']['wind'], deck['structural_model']['restraints']): assert block['value'] is None assert block['reason'] def test_openings_never_reach_the_deck_yet(): values = _complete_values() values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door' deck = _deck_from(values) assert deck['concrete_extents']['blockouts'] == [] def test_prestress_deck_reads_as_a_prestress_panel(): values = _with_prestress(count='2', rows='2 @ 2') deck = _deck_from(values) panel = schema.to_panel(deck) assert panel.prestress is not None assert len(panel.prestress.strands) == 2 # ------------------------------------------------------------------ batch # ------------------------------------------------------------------- wind def test_new_asce_fields_are_present_blank_and_stage3(): keys = set(f['key'] for f in F.FIELDS) for want in ('wind_mean_roof_height_ft', 'wind_effective_wind_area_ft2'): assert want in keys spec = F._spec(want) assert spec['section'] == 'Structural Model' assert spec['drives'] == 'stage3' values = F.prefill(READS) assert values['wind_mean_roof_height_ft'] == '' assert values['wind_effective_wind_area_ft2'] == '' def test_wind_gcpi_matches_windload_table_exactly(): # The form's GCpi table is a mirror of windload's; a host test pins it so # the two published-code tables can never drift. windload = pytest.importorskip('panellift.windload') for key, val in windload._GCPI.items(): got, cite = F.wind_gcpi(key) assert got == val, key assert cite def test_wind_gcpi_display_for_each_enclosure_and_spellings(): assert F.wind_gcpi('enclosed')[0] == 0.18 assert F.wind_gcpi('partially_enclosed')[0] == 0.55 assert F.wind_gcpi('Partially Enclosed')[0] == 0.55 # spacing/case assert F.wind_gcpi('partial-open')[0] == 0.18 # alias + dash assert F.wind_gcpi('open')[0] == 0.00 def test_wind_gcpi_blank_and_unknown_are_could_not_judge_not_zero(): val, why = F.wind_gcpi('') assert val is None and why # not 0.0 val, why = F.wind_gcpi('mostly closed') assert val is None and 'not recognized' in why def test_resolve_wind_pressure_direct_beats_computed(): d = F.resolve_wind_pressure(31.0, 28.3) assert d['source'] == 'direct' and d['pressure_psf'] == 31.0 def test_resolve_wind_pressure_computed_when_no_direct(): d = F.resolve_wind_pressure(None, 28.3) assert d['source'] == 'computed' and d['pressure_psf'] == 28.3 def test_resolve_wind_pressure_not_judged_passes_reason_through(): d = F.resolve_wind_pressure(None, None, 'NOT JUDGED: h > 60 ft ...') assert d['source'] == 'not_judged' assert d['pressure_psf'] is None # never fabricated assert 'NOT JUDGED' in d['reason'] def test_resolve_wind_pressure_direct_wins_even_over_not_judged(): d = F.resolve_wind_pressure(31.0, None, 'NOT JUDGED: h > 60 ft ...') assert d['source'] == 'direct' and d['pressure_psf'] == 31.0 def test_resolve_wind_pressure_blank_when_nothing_supplied(): d = F.resolve_wind_pressure(None, None, None) assert d['source'] == 'blank' and d['pressure_psf'] is None assert d['reason'] def _wind_values(): values = _complete_values() values['wind_speed_vult'] = '115' values['wind_risk_category'] = 'II' values['wind_exposure'] = 'C' values['wind_enclosure'] = 'enclosed' values['wind_asce_version'] = '7-16' values['wind_cc_chapter'] = '30' values['wind_mean_roof_height_ft'] = '30' values['wind_effective_wind_area_ft2'] = '100' return values def test_build_deck_carries_asce_wind_inputs_when_supplied(): deck = _deck_from(_wind_values()) wind = deck['loading']['wind'] assert 'value' not in wind # no longer unread a = wind['asce'] assert a['v_ult_mph'] == 115.0 assert a['exposure'] == 'C' assert a['enclosure'] == 'enclosed' assert a['code_version'] == '7-16' assert a['mean_roof_height_ft'] == 30.0 assert a['effective_wind_area_ft2'] == 100.0 def test_build_deck_carries_direct_pressure_override(): values = _wind_values() values['wind_pressure_psf'] = '45' values['wind_suction_psf'] = '50' deck = _deck_from(values) wind = deck['loading']['wind'] assert wind['direct_pressure_psf'] == 45.0 assert wind['direct_suction_psf'] == 50.0 def test_partial_asce_without_the_required_numbers_is_not_computed(): values = _complete_values() values['wind_exposure'] = 'C' # something typed, but no Vult/h/area deck = _deck_from(values) wind = deck['loading']['wind'] assert 'asce' not in wind assert 'asce_incomplete' in wind # says what is still needed def test_no_wind_inputs_keeps_the_explicit_unread_placeholder(): deck = _deck_from(_complete_values()) assert deck['loading']['wind']['value'] is None assert deck['loading']['wind']['reason'] def test_restraints_field_reaches_the_deck_when_filled(): values = _wind_values() values['restraints'] = '0, 30' deck = _deck_from(values) r = deck['structural_model']['restraints'] assert r == [{'x_ft': 0.0, 'type': 'restraint'}, {'x_ft': 30.0, 'type': 'restraint'}] def test_no_restraints_keeps_the_explicit_unread_placeholder(): deck = _deck_from(_complete_values()) assert deck['structural_model']['restraints']['value'] is None def test_batch_plan_runs_complete_and_skips_incomplete_with_reasons(): good = _complete_values() bad = _complete_values() bad['supports_ft'] = '5' # only one support worse = _complete_values() worse['fc_psi'] = '' # missing material named = [('PNL101', good), ('PNL102', bad), ('PNL103', worse)] to_run, skipped = F.batch_plan(named) run_labels = [lbl for lbl, _p in to_run] skip_labels = [lbl for lbl, _r in skipped] assert run_labels == ['PNL101'] assert set(skip_labels) == {'PNL102', 'PNL103'} reasons = dict((lbl, errs) for lbl, errs in skipped) assert any('support' in e for e in reasons['PNL102']) assert any(u'f′c' in e for e in reasons['PNL103']) # ------------------------------------------------------------ display_label def test_display_label_carries_both_label_and_symbol(): d = F.display_label('fc_psi') assert 'Concrete strength at 28 days' in d assert u'f′c' in d def test_display_label_falls_back_to_label_when_no_symbol(): d = F.display_label('concrete_type') assert d == 'Concrete type'