# -*- 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 _add_strand_row(values, count, depth_in, dia_in='', pull_lb=''): """Writes one `strand_row` table row via the real `add_row` helper -- the table-based replacement for the old `ps_rows` text field.""" F.add_row('strand_row', values, F.STRAND_ROW_COLS, defaults={ 'count': str(count), 'depth_in': str(depth_in), 'dia_in': str(dia_in) if dia_in != '' else '', 'pull_lb': str(pull_lb) if pull_lb != '' else '', }) return values def _with_prestress(values=None, count='4', dia='1/2', pull='28900', rows='4 @ 2'): """`rows` keeps the OLD 'count @ depth_in [@ pull_pct]' mini-grammar as this helper's own argument shape (most call sites just want '4 @ 2'), parsed with `parse_strand_rows` and written into the `strand_row` table -- `pull_pct`, where given, is left for the caller to convert into an explicit per-row `pull_lb` afterwards (the table has no percentage column; see `test_prestress_group_with_multiple_rows_and_per_row_pull_pct`).""" values = dict(values or _complete_values()) values['ps_count'] = count values['ps_dia_in'] = dia # 2026-09-07 (issue #23, 9e): the rows are the only source of initial # pull -- `ps_pull_lb` is no longer read by `_prestress_from` -- so the # helper writes `pull` onto every row (and still into the scalar, which # the model read continues to supply; a test that wants to prove the # scalar is NOT a fallback blanks the row's own cell explicitly). values['ps_pull_lb'] = pull groups, problems = F.parse_strand_rows(rows) assert not problems, problems for g in groups: _add_strand_row(values, g['count'], g['depth_in'], pull_lb=pull) 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_mode_is_the_only_wind_field_and_is_collected_not_yet_driving(): """2026-09-04 owner ruling: every ASCE site-data field (Vult, risk/ exposure/enclosure, code version, C&C/MWFRS chapter, mean roof height, effective wind area) and the old direct-entry pressure/suction pair are GONE -- ASCE data belongs to project config. `wind_mode` is the one `FIELDS` entry left in the Wind group; the actual pressure/suction is typed into the `wind_zone` row table, not a `FIELDS` entry at all.""" wind = [f for f in F.FIELDS if f['key'].startswith('wind')] keys = set(f['key'] for f in wind) assert keys == {'wind_mode'} 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) assert values['wind_mode'] == 'Manual' # prefill's own default for gone in ('wind_speed_vult', 'wind_risk_category', 'wind_exposure', 'wind_enclosure', 'wind_asce_version', 'wind_cc_chapter', 'wind_mwfrs_chapter', 'wind_mean_roof_height_ft', 'wind_effective_wind_area_ft2', 'wind_pressure_psf', 'wind_suction_psf'): assert gone not in values, gone assert F.choices_for('wind_mode') == ('Manual', 'From project config') 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 def test_row_table_fields_are_gone_2026_09_04(): """`restraints`, `conc_loads`, `ps_rows` and `mild_as` (all free text) migrated INTO row tables (`RESTRAINT_TABLE`, `CONC_LOAD_TABLE`, `STRAND_ROW_TABLE`, `MILD_BAR_TABLE`), not left sitting beside them.""" keys = set(f['key'] for f in F.FIELDS) for gone in ('restraints', 'conc_loads', 'ps_rows', 'mild_as'): assert gone not in keys for gone in ('ps_rows', 'mild_as'): assert gone not in F.PREFILLABLE # ----------------------------------------------------------------- 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 real-loading / strand-schedule field still come up # blank for the user -- these are exactly what the owner's 2026-09-04 # ruling leaves to be typed by hand. assert values['supports_ft'] == '' assert values['ps_count'] == '' assert F.rows_of('strand_row', values, F.STRAND_ROW_COLS) == [] assert values['openings'] == '' # self_weight_factor, since 2026-09-04, gets `pa_defaults`' Eriksson # Settings-page seed (1) rather than staying blank -- it is not loading, # it is a defensible generic multiplier. assert values['self_weight_factor'] == '1' 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_before_pa_defaults( monkeypatch): """The stage matrix's OWN mechanism (`stage_matrix()`, `STAGE_OPTIONS`' `choices`) never pre-selects a cell -- that half of the owner's 2026-09-03 ruling ("do NOT make any of them a default") still holds. Isolated from `pa_defaults`' separate, later pass (see `test_prefill_stage_matrix_cells_get_defensible_defaults_2026_09_04` below for what the REAL, wired-together `prefill()` now does).""" vibe_pkg = sys.modules['vibe'] monkeypatch.delattr(vibe_pkg, 'pa_defaults', raising=False) monkeypatch.setitem(sys.modules, 'vibe.pa_defaults', None) values = F.prefill(READS) for key in F.stage_field_keys(): assert values[key] == '' def test_prefill_stage_matrix_cells_get_defensible_defaults_2026_09_04(): """Owner ruling, 2026-09-04, layers ON TOP of (does not retract) the 2026-09-03 'never a default' rule for `STAGE_OPTIONS`' own `choices`: `pa_defaults`, wired into `prefill` as a SECOND pass, fills every non-loading cell of the stages the owner's own sample runs include, so pressing the button and typing only the real numbers is enough to run. Secondary Erection is switched OFF (its sample runs do not include it) and every one of its other cells stays genuinely blank -- nothing defaults a stage nobody asked for.""" values = F.prefill(READS) for stage in ('Stripping', 'Transportation', 'Primary Erection'): assert values[F.stage_field_key(stage, 'include')] == 'Yes' assert values[F.stage_field_key(stage, 'lift_type')] != '' assert values[F.stage_field_key(stage, 'rigging')] != '' assert values[F.stage_field_key(stage, 'load_multiplier')] != '' assert values[F.stage_field_key('Secondary Erection', 'include')] == 'No' for opt in F.STAGE_OPTIONS: if opt[0] == 'include': continue assert values[F.stage_field_key('Secondary Erection', opt[0])] == '' # -------------------------------------------------------------- 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 stage3 text field typed with garbage -> still runnable values['strand_rail_location'] = 'not a real place' 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']) # --------------------------------------------- strand x position (2026-09-04) def test_strand_y_uses_real_x_when_strand_x_in_matches_the_strand_count(): """The coordinator's own fixture: 8 strands, x 12.5...94.5 in, panel width 8.375 ft -- `y` is `x_in / 12`, not the even-spread formula, and the prestress block records `x_basis: 'model'`.""" values = _with_prestress(count='8', pull='28900', rows='8 @ 4') values['width_ft'] = '8.375' values['strand_x_in'] = [12.5, 22.0, 35.0, 47.5, 58.0, 70.5, 82.0, 94.5] errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert ps['x_basis'] == 'model' ys = [s['y'] for s in ps['strands']] assert ys == [1.0417, 1.8333, 2.9167, 3.9583, 4.8333, 5.875, 6.8333, 7.875] def test_strand_y_falls_back_to_even_spread_without_strand_x_in(): values = _with_prestress(count='8', pull='28900', rows='8 @ 4') values['width_ft'] = '8.375' errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert ps['x_basis'] == 'even spread' ys = [s['y'] for s in ps['strands']] expected = [round((i + 0.5) / 8.0 * 8.375, 4) for i in range(8)] assert ys == expected def test_strand_y_falls_back_when_strand_x_in_length_mismatches(): """A wrong-length list is refused outright, not partially used -- `x_basis` still reads 'even spread', matching an absent list.""" values = _with_prestress(count='8', pull='28900', rows='8 @ 4') values['width_ft'] = '8.375' values['strand_x_in'] = [12.5, 22.0] # only 2, not 8 errors, parsed = F.validate(values) assert errors == [] assert parsed['prestress']['x_basis'] == 'even spread' def test_strand_y_mirrors_for_right_edge_rail(): values = _with_prestress(count='2', pull='28900', rows='2 @ 4') values['width_ft'] = '8.375' values['strand_rail_location'] = 'Right edge' values['strand_x_in'] = [12.5, 22.0] errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert ps['x_basis'] == 'model' ys = [s['y'] for s in ps['strands']] assert ys == [round(8.375 - 12.5 / 12.0, 4), round(8.375 - 22.0 / 12.0, 4)] def test_strand_y_offsets_by_half_width_for_centerline_rail(): values = _with_prestress(count='2', pull='28900', rows='2 @ 4') values['width_ft'] = '8.375' values['strand_rail_location'] = 'Centerline' values['strand_x_in'] = [1.0, 2.0] errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert ps['x_basis'] == 'model' ys = [s['y'] for s in ps['strands']] assert ys == [round(8.375 / 2.0 + 1.0 / 12.0, 4), round(8.375 / 2.0 + 2.0 / 12.0, 4)] def test_strand_x_basis_is_even_spread_when_no_prestress_typed(): """`x_basis` is a `prestress` block field -- a blank design has no block at all, so nothing here to mis-report.""" _errors, parsed = F.validate(_complete_values()) assert parsed['prestress'] is None def test_prestress_group_with_multiple_rows_and_per_row_pull_lb(): """`strand_row.pull_lb` replaces the old `ps_rows` '@ pull_pct' grammar (2026-09-04): a per-row pull is now typed directly as an absolute force, not a percentage the form used to compute off fpu -- so this test supplies the equivalent force directly instead of a percentage.""" values = dict(_complete_values()) values['ps_count'] = '8' values['ps_dia_in'] = '1/2' values['ps_pull_lb'] = '28900' values['ps_fpu_ksi'] = '270' # pull = pct/100 * fpu(ksi)*1000 * area -- the same force the old # '@ 75' / '@ 56' pull_pct grammar used to compute, typed directly now. pull_shallow = 0.75 * 270.0 * 1000.0 * 0.153 pull_deep = 0.56 * 270.0 * 1000.0 * 0.153 _add_strand_row(values, 4, 2, pull_lb=pull_shallow) _add_strand_row(values, 4, 9.25, pull_lb=pull_deep) 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 assert all(abs(s['initial_pull_lb'] - pull_shallow) < 1 for s in shallow) assert all(abs(s['initial_pull_lb'] - pull_deep) < 1 for s in deep) def test_blank_scalar_defaults_do_not_block_run_when_every_row_has_its_own(): """2026-09-05 finding: `ps_dia_in`/`ps_pull_lb` used to be required UNCONDITIONALLY once any prestress field was filled, even when every `strand_row` already carried its own `dia_in`/`pull_lb` (the ordinary shape a model read produces) -- refusing to run a perfectly complete panel. Blank scalars are only an error now when some row is actually missing one.""" values = dict(_complete_values()) values['ps_count'] = '' values['ps_dia_in'] = '' values['ps_pull_lb'] = '' _add_strand_row(values, 4, 2, dia_in='1/2', pull_lb='28900') _add_strand_row(values, 4, 9.25, dia_in='3/8', pull_lb='21500') errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert ps is not None assert len(ps['strands']) == 8 def test_blank_scalar_default_still_required_when_a_row_is_missing_its_own(): values = dict(_complete_values()) values['ps_count'] = '' values['ps_dia_in'] = '' values['ps_pull_lb'] = '28900' _add_strand_row(values, 4, 2, dia_in='', pull_lb='28900') errors, _parsed = F.validate(values) assert any('Strand diameter' in e or 'ps_dia_in' in e for e in errors) def test_strand_row_dia_in_overrides_the_default_diameter(): values = dict(_complete_values()) values['ps_count'] = '' values['ps_dia_in'] = '1/2' values['ps_pull_lb'] = '28900' _add_strand_row(values, 2, 2, dia_in='3/8', pull_lb='28900') errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert all(s['area_in2'] == 0.085 for s in ps['strands']) # 3/8 area # and the block's nominal diameter follows the row's own fraction # spelling -- not a float() failure falling back to 0.5 assert ps['diameter_in'] == 0.5 # the scalar default names 1/2 def test_prestress_diameter_in_resolves_fraction_spellings(): """Before 2026-09-07 `diameter_in` was `float(ps_dia_in)`, which fails on every fraction the dropdown offers and fell back to 0.5 silently -- a 3/8" strand developed as a 1/2" one.""" values = dict(_complete_values()) values['ps_count'] = '' values['ps_dia_in'] = '3/8' _add_strand_row(values, 2, 2, pull_lb='21500') errors, parsed = F.validate(values) assert errors == [] assert parsed['prestress']['diameter_in'] == 0.375 # scalar blank: the first row's own size decides values['ps_dia_in'] = '' values[F.row_key('strand_row', 1, 'dia_in')] = '9/16' errors, parsed = F.validate(values) assert errors == [] assert parsed['prestress']['diameter_in'] == 0.5625 def test_half_inch_special_strand_is_its_own_size(): """Issue #23 item 9d: '1/2 SP' (0.167 in²) beside plain '1/2' (0.153 in²) -- both dropdowns offer it, and it resolves by area.""" assert '1/2 SP' in F.STRAND_DIA_CHOICES assert '1/2' in F.STRAND_DIA_CHOICES assert F.FIELD_CHOICES['ps_dia_in'] == F.STRAND_DIA_CHOICES assert F.strand_area_in2('1/2 SP') == (0.167, None) assert F.strand_area_in2('1/2" SP') == (0.167, None) assert F.strand_area_in2('1/2sp') == (0.167, None) assert F.strand_area_in2('0.52') == (0.167, None) # stored spelling assert F.strand_area_in2('1/2') == (0.153, None) assert F.strand_dia_choice('0.52') == '1/2 SP' assert F.strand_dia_nominal_in('1/2 SP') == 0.5 assert F.strand_dia_nominal_in('3/8') == 0.375 assert F.strand_dia_nominal_in('0.6') == 0.6 assert F.strand_dia_nominal_in('0.99') is None values = dict(_complete_values()) values['ps_count'] = '' values['ps_dia_in'] = '' _add_strand_row(values, 2, 2, dia_in='1/2 SP', pull_lb='31000') errors, parsed = F.validate(values) assert errors == [] ps = parsed['prestress'] assert all(s['area_in2'] == 0.167 for s in ps['strands']) assert ps['diameter_in'] == 0.5 def test_partial_prestress_group_is_refused_field_by_field(): values = _complete_values() values['ps_count'] = '4' # started, but no size/rows errors, parsed = F.validate(values) assert parsed is None assert any('diameter' in e for e in errors) assert any('Strand groups' in e for e in errors) # 2026-09-07 (9e): the scalar default pull is not a form input any more, # so it is never what is asked for assert not any('Initial pull per strand' in e for e in errors) def test_strand_row_without_its_own_pull_is_refused_by_row(): """Issue #23 item 9e: the rows are the only source of initial pull. A scalar `ps_pull_lb` (which the model read still supplies) is NOT a fallback for a row that left its own blank -- the row is named.""" values = dict(_complete_values()) values['ps_count'] = '' values['ps_dia_in'] = '1/2' values['ps_pull_lb'] = '28900' _add_strand_row(values, 4, 2, pull_lb='28900') _add_strand_row(values, 4, 9.25, pull_lb='') errors, parsed = F.validate(values) assert parsed is None assert any('row 2' in e and 'initial pull is required' in e for e in errors) assert not any('row 1' 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_is_the_rows_sum_once_rows_exist(): """Issue #23 item 9b (2026-09-07): with rows present the count IS the rows' sum and the page shows it read-only, so a stale typed value is not compared against them -- it cannot block a run through a field the user can no longer see. (Until then a mismatch was an error.)""" values = _with_prestress(count='5', rows='4 @ 2') # 5 != 4 errors, parsed = F.validate(values) assert errors == [] assert len(parsed['prestress']['strands']) == 4 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_is_named_by_row_number(): values = _with_prestress(rows='4 @ 2') F.add_row('strand_row', values, F.STRAND_ROW_COLS, defaults={'count': 'banana', 'depth_in': '2'}) # row 2 errors, parsed = F.validate(values) assert parsed is None assert any('row 2' in e and 'count' in e for e in errors) def test_strand_rows_are_kept_in_parsed_even_when_group_incomplete(): values = _complete_values() # a row typed, but no default dia/pull on the form -> the whole panel # is refused (as before -- rows alone are not a complete group) F.add_row('strand_row', values, F.STRAND_ROW_COLS, defaults={'count': '4', 'depth_in': '2'}) errors, parsed = F.validate(values) assert parsed is None # but strand_rows is not something to re-derive here; validate() does # not crash trying to reach into the table regardless. # ------------------------------------------------------------- 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(monkeypatch): """`stage_matrix()` itself still never pre-selects a cell -- isolated from `pa_defaults`' separate, later pass the same way the sibling test above is.""" 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 vibe_pkg = sys.modules['vibe'] monkeypatch.delattr(vibe_pkg, 'pa_defaults', raising=False) monkeypatch.setitem(sys.modules, 'vibe.pa_defaults', None) values = F.prefill(READS) for key in F.stage_field_keys(): assert values[key] == '' # nothing pre-selected def test_stage_matrix_rigging_choices_expanded_2026_09_04(): """Owner ruling, 2026-09-04: rigging's offered vocabulary widens from just `('Auto',)` to the real point-count configurations -- still never enforced (the box stays editable) and never a default.""" m = F.stage_matrix() by_key = dict((r['opt_key'], r) for r in m['rows']) assert by_key['rigging']['choices'] == ( 'Auto', 'Two-point', 'Four-point', 'Eight-point') 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(): """`in_place` and `structural_model.restraints` are still explicit unread placeholders on a `_complete_values()` panel. `loading.wind` is NOT included here any more -- since the 2026-09-04 `pa_defaults` wiring, `prefill()` always seeds the ASCE risk/exposure/enclosure/version/ cc-chapter metadata, so a real panel's wind block is `asce_incomplete` (still missing real loading, Vult and mean roof height), never the bare placeholder; see `test_prefilled_wind_metadata_reports_asce_incomplete` below for that behaviour on its own.""" deck = _deck_from(_complete_values()) for block in (deck['in_place'], deck['structural_model']['restraints']): assert block['value'] is None assert block['reason'] def test_opening_and_reveal_rows_reach_blockouts_and_recesses(): """Wired 2026-09-04: an 'Opening' row becomes a full-depth blockout, a 'Reveal' row a recess carrying a depth (pos_cross/width x12, ft->in), and a 'Solid Zone' row is left out of both, counted in `_openings_note` instead of silently dropped. Axis mapping: `pos_along`/`length` -> `x0`/ `length` (the panel length axis); `pos_vert`/`height` -> `y0`/`width` (the panel width axis, the same call `panelsketch._parse_opening_row` already makes).""" values = _complete_values() # length_ft=24.0, width_ft=8.0 values['openings'] = ( 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door\n' 'Reveal, 10, start, 1, 0.25, left, 0.5, 2, 3, Reveal1\n' 'Solid Zone, 15, start, 2, 0, left, 8, 0, 8, Wall1' ) deck = _deck_from(values) ce = deck['concrete_extents'] blockouts = ce['blockouts'] assert len(blockouts) == 1 b = blockouts[0] assert b['name'] == 'Door' assert b['x0'] == 2.0 and b['length'] == 3.0 # pos_along / length assert b['y0'] == 1.0 and b['width'] == 6.0 # pos_vert / height assert b['depth_in'] is None # Opening: full depth recesses = ce['recesses'] assert len(recesses) == 1 r = recesses[0] assert r['name'] == 'Reveal1' assert r['x0'] == 10.0 and r['length'] == 1.0 assert r['y0'] == 2.0 and r['width'] == 3.0 assert r['depth_in'] == 6.0 # 0.5 ft x 12 assert r['concrete_in'] is None # not on this form assert 'solid-zone' in ce['_openings_note'] assert '1' in ce['_openings_note'] # And the schema actually reads the shape back without raising. panel = schema.to_panel(deck) assert len(panel.blockouts) == 1 assert len(panel.recesses) == 1 def test_along_from_end_flips_the_opening_position(): values = _complete_values() # length_ft=24.0 values['openings'] = 'Opening, 2, end, 3, 0, left, 4, 0, 4, Door' deck = _deck_from(values) b = deck['concrete_extents']['blockouts'][0] # 24 - 2 - 3 = 19 assert b['x0'] == 19.0 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 # ------------------------------------------------------- Stage 3: parsers def test_parse_feet_accepts_decimal_and_architectural(): assert F._parse_feet('11.5') == (11.5, None) v, err = F._parse_feet('11\'-1 1/2"') assert err is None and abs(v - 11.125) < 1e-9 v, err = F._parse_feet("11'-1\"") assert err is None and abs(v - (11.0 + 1.0 / 12.0)) < 1e-9 v, err = F._parse_feet("11'") assert err is None and v == 11.0 assert F._parse_feet('') == (None, None) assert F._parse_feet(None) == (None, None) v, err = F._parse_feet('not a number') assert v is None and 'not a number of feet' in err def _add_conc_load_row(values, label='', elevation_ft='', offset_in='', dead_k='', live_k=''): return F.add_row('conc_load', values, F.CONC_LOAD_COLS, defaults={ 'label': label, 'elevation_ft': str(elevation_ft), 'offset_in': str(offset_in), 'dead_k': str(dead_k), 'live_k': str(live_k)}) def test_conc_loads_from_reads_the_eriksson_columns(): values = {} _add_conc_load_row(values, 'Beam1', 10, 4, 5, 2) _add_conc_load_row(values, '', 20, -2, 3.5, 1.5) errors = [] rows = F._conc_loads_from(values, 0.0, errors) assert errors == [] assert len(rows) == 2 r0 = rows[0] assert r0['label'] == 'Beam1' assert r0['elevation_ft'] == 10.0 and r0['x_ft'] == 10.0 assert r0['offset_in'] == 4.0 assert r0['dead_lb'] == 5000.0 and r0['live_lb'] == 2000.0 r1 = rows[1] assert r1['label'] == 'P2' # no label typed -> P assert r1['offset_in'] == -2.0 def test_conc_loads_from_subtracts_the_bottom_elevation(): values = {} _add_conc_load_row(values, '', 102, 0, 1, 0) errors = [] rows = F._conc_loads_from(values, 100.0, errors) assert errors == [] assert rows[0]['elevation_ft'] == 102.0 assert rows[0]['x_ft'] == 2.0 def test_conc_loads_from_accepts_architectural_feet(): values = {} _add_conc_load_row(values, '', '11\'-6"', 0, 1, 0) errors = [] rows = F._conc_loads_from(values, 0.0, errors) assert errors == [] assert rows[0]['elevation_ft'] == 11.5 def test_conc_loads_from_names_the_bad_row(): values = {} _add_conc_load_row(values, '', 10, 0, 1, 0) _add_conc_load_row(values, '', 'nope', 0, 1, 0) errors = [] rows = F._conc_loads_from(values, 0.0, errors) assert rows is None assert any('row 2' in e for e in errors) def test_conc_loads_from_no_rows_is_no_loads(): errors = [] assert F._conc_loads_from({}, 0.0, errors) is None assert errors == [] def test_cracking_class_t_maps_the_binary_choice_honestly(): assert F._cracking_class_t('Uncracked') == (False, F._CRACKING_NOTE) assert F._cracking_class_t('Cracked') == (True, F._CRACKING_NOTE) assert F._cracking_class_t('') == (None, None) val, note = F._cracking_class_t('Mostly Cracked') assert val is None and 'neither Cracked nor Uncracked' in note def test_parse_deflection_ratio_accepts_l_over_n_and_bare_numbers(): assert F._parse_deflection_ratio('L/150') == (150.0, None) assert F._parse_deflection_ratio('L / 150') == (150.0, None) assert F._parse_deflection_ratio('150') == (150.0, None) assert F._parse_deflection_ratio('') == (None, None) v, err = F._parse_deflection_ratio('bogus') assert v is None and err # --------------------------------------------------- Stage 3: build_deck def test_build_deck_service_block_from_a_real_prefilled_panel(): """A `prefill()`-derived panel always carries `deflection_limit` ('L/150' via `pa_defaults`) and `cracking_assumption` ('Uncracked'), so its deck's `loading.service` is a REAL block, not the unread placeholder -- this is exactly the WPA655 gap this task closes ("the page says NOT JUDGED although the form had L/150").""" deck = _deck_from(_complete_values()) service = deck['loading']['service'] assert 'value' not in service # no longer unread assert service['deflection_limit_ratio'] == 150.0 assert service['cracking_class_t'] is False # Uncracked default assert service['initial_bow_in'] == 0.0 assert service['axial_eccentricity_in'] == 0.0 # And the schema reads it straight back. read = schema.service_options(deck) assert read['deflection_limit_ratio'] == 150.0 assert read['cracking_class_t'] is False def test_build_deck_service_block_is_unread_with_nothing_manually_supplied(): """Independent of `prefill()`'s own defaults: a `values` dict built by hand with none of the four service source fields set keeps the explicit unread placeholder.""" values = _complete_values() for key in ('deflection_limit', 'design_criteria.deflection_add_bow', 'load_opt_selfweight_ecc_in', 'cracking_assumption'): values[key] = '' deck = _deck_from(values) assert deck['loading']['service']['value'] is None assert deck['loading']['service']['reason'] def test_build_deck_service_bow_only_when_the_add_bow_flag_is_yes(): values = _complete_values() values['design_criteria.deflection_add_bow'] = 'Yes' values['design_criteria.deflection_additional_bow_in'] = '0.25' deck = _deck_from(values) assert deck['loading']['service']['initial_bow_in'] == 0.25 values2 = _complete_values() values2['design_criteria.deflection_add_bow'] = 'No' values2['design_criteria.deflection_additional_bow_in'] = '0.25' deck2 = _deck_from(values2) assert deck2['loading']['service']['initial_bow_in'] == 0.0 def test_build_deck_applied_axial_from_conc_load_rows(): values = _complete_values() # length_ft=24.0 _add_conc_load_row(values, 'Ledger', 10, 4, 5, 2) _add_conc_load_row(values, '', 20, 0, 3, 1) deck = _deck_from(values) aa = deck['loading']['applied_axial'] assert len(aa) == 2 assert aa[0]['label'] == 'Ledger' assert aa[0]['x_ft'] == 10.0 assert aa[0]['dead_lb'] == 5000.0 and aa[0]['live_lb'] == 2000.0 assert aa[0]['offset_in'] == 4.0 assert deck['loading']['_applied_axial_bottom_note'] # And the schema reads it as real applied-axial loads. read = schema.applied_axial(deck) assert len(read) == 2 assert read[0].x_ft == 10.0 and read[0].dead_lb == 5000.0 def test_bad_conc_load_row_blocks_the_run_and_names_the_row(): values = _complete_values() _add_conc_load_row(values, '', 10, 0, 1, 0) _add_conc_load_row(values, '', 'nope', 0, 1, 0) errors, parsed = F.validate(values) assert parsed is None assert any('Point load row 2' in e for e in errors) def test_no_conc_load_rows_means_no_applied_axial_block(): deck = _deck_from(_complete_values()) assert 'applied_axial' not in deck['loading'] # ------------------------------------------------------------------ batch # ------------------------------------------------------------------- wind # # 2026-09-04 owner ruling: ASCE site data is GONE from this form (moved to # project config); wind is purely manual now, one `wind_zone` row per zone. # `wind_gcpi` / the old `_wind_values()` ASCE fixture are gone with it. # `resolve_wind_pressure` is KEPT (see its own docstring) -- a cross-file # caller in `tools/panelanalysis_schema.py`, a separate module under # parallel edit, still uses it from the OLD ASCE path. 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 _add_wind_zone_row(values, label='', from_elev_ft='', to_elev_ft='', width_ft='', pressure_psf='', suction_psf=''): return F.add_row('wind_zone', values, F.WIND_ZONE_COLS, defaults={ 'label': label, 'from_elev_ft': str(from_elev_ft), 'to_elev_ft': str(to_elev_ft), 'width_ft': str(width_ft), 'pressure_psf': str(pressure_psf), 'suction_psf': str(suction_psf)}) def test_build_deck_carries_a_manual_wind_zone_when_filled(): values = _complete_values() # length_ft=24.0 _add_wind_zone_row(values, 'W-test', 0, 24, 8, 20, -15) deck = _deck_from(values) wind = deck['loading']['wind'] assert 'value' not in wind # no longer unread assert wind['mode'] == 'manual' zones = [z for z in wind['zones'] if z['label'] == 'W-test'] assert len(zones) == 1 z = zones[0] assert z['x0_ft'] == 0.0 and z['x1_ft'] == 24.0 assert z['width_ft'] == 8.0 assert z['pressure_psf'] == 20.0 assert z['suction_psf'] == -15.0 def test_wind_zone_row_with_only_suction_is_accepted(): values = _complete_values() _add_wind_zone_row(values, 'W2', 0, 24, 8, '', -12) deck = _deck_from(values) z = [z for z in deck['loading']['wind']['zones'] if z['label'] == 'W2'][0] assert z['pressure_psf'] is None assert z['suction_psf'] == -12.0 def test_wind_zone_row_with_both_blank_is_ignored_not_an_error(): """`prefill()` always seeds exactly one `wind_zone` row with pressure and suction BLANK -- so a totally untouched panel must still keep the deck's explicit unread placeholder, never a fabricated empty-but-present zone list.""" deck = _deck_from(_complete_values()) assert deck['loading']['wind']['value'] is None assert deck['loading']['wind']['reason'] def test_wind_zone_outside_the_panel_is_refused(): values = _complete_values() # length_ft=24.0 _add_wind_zone_row(values, 'Wbad', 0, 40, 8, 20, -15) errors, parsed = F.validate(values) assert parsed is None assert any('Wind zone' in e and 'outside the panel' in e for e in errors) def test_wind_zone_zero_width_is_refused(): values = _complete_values() _add_wind_zone_row(values, 'Wbad', 0, 10, 0, 20, '') errors, parsed = F.validate(values) assert parsed is None assert any('width' in e for e in errors) def test_wind_mode_from_project_config_refuses_to_run(): """The owner's 'From project config' button is not built yet -- choosing it is a form-level refusal, not a fabricated pressure.""" values = _complete_values() values['wind_mode'] = 'From project config' errors, parsed = F.validate(values) assert parsed is None assert any('From project config' in e and 'not' in e for e in errors) def test_wind_mode_manual_or_blank_runs_fine(): values = _complete_values() values['wind_mode'] = 'Manual' assert F.is_complete(values) values2 = _complete_values() values2['wind_mode'] = '' assert F.is_complete(values2) # --------------------------------------------------------------- restraints def _add_restraint_row(values, elevation_ft='', type_='', gravity='yes', thermal='', stiffness_kin=''): return F.add_row('restraint', values, F.RESTRAINT_COLS, defaults={ 'elevation_ft': str(elevation_ft), 'type': type_, 'gravity': gravity, 'thermal': thermal, 'stiffness_kin': str(stiffness_kin)}) def test_restraint_rows_reach_the_deck_when_filled(): """No `bottom_elevation_ft` on the form -> bottom defaults to 0 ft, so the converted `x_ft` equals the typed elevation here -- see `test_restraint_rows_convert_off_a_real_bottom_elevation` for a real bottom.""" values = _complete_values() # length_ft=24.0 _add_restraint_row(values, 0, 'Pin') _add_restraint_row(values, 20, 'Floor tie') deck = _deck_from(values) r = deck['structural_model']['restraints'] assert len(r) == 2 assert r[0]['x_ft'] == 0.0 and r[0]['type'] == 'Pin' assert r[0]['elevation_ft'] == 0.0 and r[0]['gravity'] is True assert r[1]['x_ft'] == 20.0 and r[1]['type'] == 'Floor tie' assert deck['structural_model']['_restraints_bottom_note'] def test_restraint_rows_convert_off_a_real_bottom_elevation(): values = _complete_values() # length_ft=24.0 values['bottom_elevation_ft'] = '100' _add_restraint_row(values, 102, 'Pin') _add_restraint_row(values, 122, 'Floor tie') deck = _deck_from(values) r = deck['structural_model']['restraints'] assert r[0]['x_ft'] == 2.0 and r[0]['elevation_ft'] == 102.0 assert r[1]['x_ft'] == 22.0 and r[1]['elevation_ft'] == 122.0 assert '_restraints_bottom_note' not in deck['structural_model'] def test_restraint_row_outside_the_panel_is_refused(): values = _complete_values() # length_ft=24.0 _add_restraint_row(values, 0, 'Pin') _add_restraint_row(values, 40, 'Floor tie') # off the end of the panel errors, parsed = F.validate(values) assert parsed is None assert any('outside the panel' in e for e in errors) def test_single_restraint_row_is_refused_need_at_least_two(): values = _complete_values() _add_restraint_row(values, 0, 'Pin') errors, parsed = F.validate(values) assert parsed is None assert any('at least two restraint' in e for e in errors) def test_restraints_are_still_editable_not_locked_down_2026_09_04(): """Owner, 2026-09-04: "只是让你预先输入,不是让你直接锁死啊" -- a row seeded from the model read is a plain row-table row like any other, freely overwritten.""" reads = dict(READS) reads['restraints'] = '0, 24' values = F.prefill(reads) rows = F.rows_of('restraint', values, F.RESTRAINT_COLS) assert len(rows) == 2 assert rows[0]['type'] == 'Pin' and rows[1]['type'] == 'Floor tie' # freely overwritten -- nothing on this form refuses the edit values[F.row_key('restraint', rows[0]['n'], 'elevation_ft')] = '5' rows2 = F.rows_of('restraint', values, F.RESTRAINT_COLS) assert rows2[0]['elevation_ft'] == '5' def test_no_restraint_rows_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' # ------------------------------------------------------- constant defaults def test_field_defaults_fill_only_blanks_and_never_overwrite_a_model_read(): """Owner, 2026-09-03: a fresh panel starts at normal weight, rebar 60/29000, mesh 80/29000, 270 ksi / 28500 ksi strand -- but a value the model actually supplied always wins. A default that overwrote a read value would silently replace measured data with policy.""" values = F.prefill(READS) assert values['concrete_type'] == 'Normalweight' assert values['rebar_fy_ksi'] == '60' assert values['rebar_es_ksi'] == '29000' assert values['mesh_fy_ksi'] == '80' assert values['mesh_es_ksi'] == '29000' assert values['ps_fpu_ksi'] == '270' assert values['strand_ep_ksi'] == '28500' reads = dict(READS) reads['concrete_type'] = 'Lightweight' reads['rebar_fy_ksi'] = 75 reads['ps_fpu_ksi'] = 250 read_values = F.prefill(reads) assert read_values['concrete_type'] == 'Lightweight' assert read_values['rebar_fy_ksi'] == '75' assert read_values['ps_fpu_ksi'] == '250' # the ones the model did NOT supply still get their default assert read_values['mesh_fy_ksi'] == '80' def test_second_order_and_dev_mult_defaults_2026_09_04(): """Owner ruling, 2026-09-04: second_order defaults to 'Yes' (P-Delta considered by default; the plant settings path still overrides it), and dev_mult defaults to Eriksson's own strand-normal development multiplier, 1.3.""" values = F.prefill(READS) assert values['second_order'] == 'Yes' assert values['dev_mult'] == '1.3' def test_every_field_default_key_is_a_real_form_key(): keys = set(f['key'] for f in F.FIELDS) | set(F.PREFILLABLE) for key in F.PREFILL_DEFAULTS: assert key in keys, ( 'default for {0!r} names no field on the form'.format(key)) # ------------------------------------------------------- derived dependents def test_derived_dependents_names_what_follows_from_a_source_field(): assert F.derived_dependents('unit_weight_pcf') == ('conc_ec_psi', 'conc_eci_psi') assert F.derived_dependents('fc_psi') == ('conc_ec_psi',) assert F.derived_dependents('fci_psi') == ('conc_eci_psi',) def test_derived_dependents_is_empty_for_a_field_nothing_derives_from(): assert F.derived_dependents('length_ft') == () assert F.derived_dependents(None) == () # a DERIVED field is not itself a source: E must never write back to f'c assert F.derived_dependents('conc_ec_psi') == () def test_format_number_matches_the_prefill_formatting(): """The renderer writes recomputed moduli straight back into a live box; a different formatter here would make a field visibly change shape the moment it recomputed.""" v = F.derived_value('conc_ec_psi', F.prefill(READS)) assert F.format_number(round(v)) == F.prefill(READS)['conc_ec_psi'] assert F.format_number(None) == '' assert F.format_number(4696.0) == '4696' # ------------------------------------------------------- load combinations def test_load_combo_factor_key_shape(): assert F.load_combo_factor_key('strength', 1, 'D') == \ 'load_combo.strength.1.D' assert F.load_combo_factor_key('service', 'u2', 'W') == \ 'load_combo.service.u2.W' def test_load_combo_row_ids_lists_code_rows_then_user_rows_in_order(): values = {} assert F.load_combo_row_ids('strength', values) == \ ['1', '2', '3', '4', '5', '6', '7', '8'] assert len(F.load_combo_row_ids('service', values)) == 10 # User rows are discovered from the include keys the form wrote, and come # after every code row, numerically -- a table never renumbers under the # user, so the row they typed into keeps its id. values = { 'load_combo.strength.u2.include': 'yes', 'load_combo.strength.u1.include': '', 'load_combo.service.u1.include': 'yes', } assert F.load_combo_row_ids('strength', values)[-2:] == ['u1', 'u2'] assert F.load_combo_row_ids('service', values)[-1] == 'u1' def test_load_combo_row_ids_ignores_keys_that_are_not_row_includes(): values = {'load_combo.strength.u1.D': '1.2', # a factor, not a row 'load_combo.strength.uX.include': 'yes', # not a number 'stage.stripping.include': 'yes'} assert F.load_combo_row_ids('strength', values) == \ ['1', '2', '3', '4', '5', '6', '7', '8'] def test_prefill_seeds_every_load_combo_factor_and_keeps_the_default_ticks(): values = F.prefill(READS) for name, rows in F.LOAD_COMBO_ROWS.items(): for row in rows: for i, col in enumerate(F.LOAD_COMBO_FACTORS): key = F.load_combo_factor_key(name, row[0], col) assert values.get(key) == row[i + 1], ( '{0} was not seeded with the ACI default'.format(key)) # The owner's starting selection is untouched by the factor seeding. for n in (1, 2, 5, 7): assert values[F.load_combo_include_key('strength', n)] == 'yes' for n in (1, 2, 5, 9): assert values[F.load_combo_include_key('service', n)] == 'yes' assert not values.get(F.load_combo_include_key('strength', 3)) assert not values.get(F.load_combo_include_key('service', 4)) def test_load_combinations_returns_only_the_ticked_rows_with_their_factors(): values = F.prefill(READS) combos = F.load_combinations(values) ids = [(c['table'], c['id']) for c in combos] assert ids == [('strength', '1'), ('strength', '2'), ('strength', '5'), ('strength', '7'), ('service', '1'), ('service', '2'), ('service', '5'), ('service', '9')] first = combos[0] assert first['included'] is True assert first['factors']['D'] == 1.4 assert first['factors']['L'] == 0.0 assert 'problem' not in first def test_load_combinations_reports_an_unparsable_factor_rather_than_zeroing(): """A factor that will not parse comes back None with a sentence -- never a silent 0, which is a real instruction ('this load is excluded').""" values = F.prefill(READS) values[F.load_combo_factor_key('strength', '1', 'D')] = 'one point four' combos = F.load_combinations(values) row = [c for c in combos if c['id'] == '1' and c['table'] == 'strength'][0] assert row['factors']['D'] is None assert 'D' in row['problem'] and 'not a number' in row['problem'] def test_load_combinations_reports_a_blank_factor_as_blank_not_zero(): values = F.prefill(READS) values[F.load_combo_factor_key('service', '1', 'W')] = '' row = [c for c in F.load_combinations(values) if c['table'] == 'service' and c['id'] == '1'][0] assert row['factors']['W'] is None assert 'blank' in row['problem'] def test_load_combinations_picks_up_a_user_added_row(): values = F.prefill(READS) values[F.load_combo_include_key('strength', 'u1')] = 'yes' for col in F.LOAD_COMBO_FACTORS: values[F.load_combo_factor_key('strength', 'u1', col)] = '1' row = [c for c in F.load_combinations(values) if c['id'] == 'u1'][0] assert row['table'] == 'strength' assert row['factors'] == dict((c, 1.0) for c in F.LOAD_COMBO_FACTORS) assert 'problem' not in row def test_validate_carries_the_load_combinations_into_parsed(): errors, parsed = F.validate(_complete_values()) assert errors == [] assert len(parsed['load_combinations']) == 8 def test_an_unreadable_factor_now_blocks_the_run_button(): """Since 2026-09-04 the ticked combinations reach the in-place solve for real (`_combinations_for_deck`), so a factor nobody can read is no longer a harmless note -- it is refused by name, the same discipline `openings` / `ps_rows` already use for their own line-level garbage.""" values = _complete_values() values[F.load_combo_factor_key('strength', '1', 'D')] = 'oops' errors, parsed = F.validate(values) assert parsed is None assert any('strength combination 1' in e and 'factor D' in e for e in errors) def test_build_deck_emits_the_combinations_as_the_list_the_schema_reads(): deck = _deck_from(_complete_values()) combos = deck['loading']['combinations'] assert isinstance(combos, list) assert len(combos) == 8 strength1 = [c for c in combos if c['name'] == 'LC1'][0] assert strength1['kind'] == 'strength' assert strength1['factors']['D'] == 1.4 assert strength1['label'] == '1.4D + 1.0T + 1.6H' assert strength1['cite'] == 'ACI 318-19 5.3.1' service1 = [c for c in combos if c['name'] == 'SC1'][0] assert service1['kind'] == 'service' assert service1['cite'] == 'ASCE 7-22 2.4' # And the schema actually reads it as a real combination list, not the # engine's own ASCE default set. combo_objs = schema._parse_combinations(combos) names = set(c.name for c in combo_objs) assert 'LC1' in names and 'SC1' in names def test_build_deck_combinations_is_empty_list_when_nothing_is_ticked(): values = _complete_values() for name in F.LOAD_COMBO_ROWS: for rid in F.load_combo_row_ids(name, values): values[F.load_combo_include_key(name, rid)] = '' deck = _deck_from(values) assert deck['loading']['combinations'] == [] # ---------------------------------------------------- FIELD_CHOICES registry # # Owner, 2026-09-04: "很多地方明显不应该是人类填写数字,应该是做成选项" -- the # enumerated fields become dropdowns. Vocabulary is pinned exactly because # `vibe.pa_defaults` (a sibling module) writes these values verbatim. def test_field_choices_registry_has_the_2026_09_04_keys(): expected = { 'design_spec': ('ACI 318-19', 'ACI 318-14', 'ACI 318-11'), 'wind_mode': ('Manual', 'From project config'), 'design_criteria.analysis_beta_d': ('Non-Sway', 'Sway'), 'design_criteria.cracking_method': ('Mast Method', 'Uncracked', 'Cracked'), 'design_criteria.flexure_method': ('Whitney Stress Block', 'Strain Compatibility'), 'design_criteria.flexure_cracking_check': ('None', '1.2 Mcr', '1.33 Mu'), 'design_criteria.deflection_cantilever_limit': ('L/75', 'L/120', 'L/150'), 'deflection_limit': ('L/150', 'L/120', 'L/240', 'L/360'), 'load_opt_offset_from': ('Deflection Section', 'Top in Form', 'Bottom in Form'), 'seismic_apply_to': ('None', 'Out-of-plane', 'In-plane', 'Both'), 'restraint_foundation_connection': ('Pin', 'Fixed', 'Free'), 'strand_rail_location': ('Left edge', 'Right edge', 'Centerline'), } for key, choices in expected.items(): assert F.choices_for(key) == choices, key def test_choices_for_unknown_key_is_none_not_empty_tuple(): assert F.choices_for('not_a_real_field_key') is None # --------------------------------------------------------- pa_defaults wiring def test_prefill_still_accepts_a_single_argument(): """Every existing caller passes just `reads` -- `know` must default so none of them has to change.""" values = F.prefill(READS) assert values['mark'] == 'PNL101' def test_prefill_degrades_cleanly_when_pa_defaults_is_unimportable(monkeypatch): """A `None` entry in `sys.modules` is the standard way to make an import fail deterministically regardless of whether `vibe.pa_defaults` actually exists on disk (it does, by the time this suite runs, as the sibling module this depends on -- `getattr(vibe, 'pa_defaults')` finding an already-imported real module is what `IMPORT_FROM` tries FIRST, before ever consulting `sys.modules`, so the attribute has to be cleared too or the `None` sentinel is never reached). `prefill` must come back exactly as it does today -- no `_defaults_applied` key, no exception.""" vibe_pkg = sys.modules['vibe'] monkeypatch.delattr(vibe_pkg, 'pa_defaults', raising=False) monkeypatch.setitem(sys.modules, 'vibe.pa_defaults', None) values = F.prefill(READS) assert '_defaults_applied' not in values assert values['mark'] == 'PNL101' def test_prefill_wires_in_pa_defaults_when_present(monkeypatch): import types calls = [] def fake_apply_defaults(values, know): calls.append((dict(values), know)) applied = [] if not (values.get('wind_asce_version') or '').strip(): values['wind_asce_version'] = 'ASCE 7-22' applied.append( ('wind_asce_version', 'ASCE 7-22', 'stub plant default')) return applied stub = types.ModuleType('vibe.pa_defaults') stub.apply_defaults = fake_apply_defaults monkeypatch.setitem(sys.modules, 'vibe.pa_defaults', stub) vibe_pkg = sys.modules['vibe'] monkeypatch.setattr(vibe_pkg, 'pa_defaults', stub, raising=False) know = {'plant_id': 'sample_plant'} values = F.prefill(READS, know) assert values['wind_asce_version'] == 'ASCE 7-22' assert values['_defaults_applied'] == [ ('wind_asce_version', 'ASCE 7-22', 'stub plant default')] assert calls and calls[0][1] == know def test_prefill_records_an_empty_defaults_applied_list_when_nothing_to_fill( monkeypatch): """'ran, found nothing to fill' must not look like 'could not run' -- the module answered, it just had nothing to add.""" import types stub = types.ModuleType('vibe.pa_defaults') stub.apply_defaults = lambda values, know: [] monkeypatch.setitem(sys.modules, 'vibe.pa_defaults', stub) vibe_pkg = sys.modules['vibe'] monkeypatch.setattr(vibe_pkg, 'pa_defaults', stub, raising=False) values = F.prefill(READS) assert values['_defaults_applied'] == [] def test_defaults_applied_key_never_reaches_validate_build_deck_batch_plan(): """`_defaults_applied` (and anything else starting with `_`) is not a `FIELDS` key -- `validate` / `build_deck` / `batch_plan` all walk `FIELDS`, never `values.keys()`, so a stray internal key must never trip them up.""" values = _complete_values() values['_defaults_applied'] = [ ('handling_tension_sqrt', '5', 'code default')] values['_unrelated_internal_note'] = object() # even a junk value errors, parsed = F.validate(values) assert errors == [] deck = F.build_deck(parsed) assert deck['schema'] == F.SCHEMA to_run, skipped = F.batch_plan([('Panel A', values)]) assert skipped == [] assert to_run and to_run[0][0] == 'Panel A' # ------------------------------------------------------------- row tables def test_row_key_shape(): assert F.row_key('restraint', 3, 'elevation_ft') == \ 'restraint.3.elevation_ft' def test_add_row_writes_present_and_defaults_and_returns_n(): values = {} n = F.add_row('restraint', values, F.RESTRAINT_COLS, defaults={'elevation_ft': '5', 'type': 'Pin'}) assert n == 1 assert values[F.row_key('restraint', 1, 'present')] == 'yes' assert values[F.row_key('restraint', 1, 'elevation_ft')] == '5' assert values[F.row_key('restraint', 1, 'type')] == 'Pin' # a column with no default in the dict starts blank, never a KeyError assert values[F.row_key('restraint', 1, 'gravity')] == '' def test_add_row_ids_go_up_and_never_reuse_a_removed_id(): values = {} n1 = F.add_row('restraint', values, F.RESTRAINT_COLS) n2 = F.add_row('restraint', values, F.RESTRAINT_COLS) assert (n1, n2) == (1, 2) F.remove_row('restraint', values, n1) n3 = F.add_row('restraint', values, F.RESTRAINT_COLS) assert n3 == 3 # never reuses removed row 1 def test_remove_row_marks_absent_but_leaves_cells(): values = {} n = F.add_row('restraint', values, F.RESTRAINT_COLS, defaults={'elevation_ft': '5'}) F.remove_row('restraint', values, n) assert F.row_ids('restraint', values) == [] assert F.rows_of('restraint', values, F.RESTRAINT_COLS) == [] # the cell is still sitting in `values`, just unread assert values[F.row_key('restraint', n, 'elevation_ft')] == '5' def test_row_ids_only_counts_present_yes_rows(): values = { F.row_key('conc_load', 1, 'present'): 'yes', F.row_key('conc_load', 2, 'present'): 'no', F.row_key('conc_load', 3, 'present'): '', F.row_key('conc_load', 5, 'present'): 'YES', # case-insensitive } assert F.row_ids('conc_load', values) == [1, 5] def test_rows_of_returns_one_dict_per_present_row_with_n(): values = {} F.add_row('conc_load', values, F.CONC_LOAD_COLS, defaults={'label': 'P1', 'dead_k': '5'}) F.add_row('conc_load', values, F.CONC_LOAD_COLS, defaults={'label': 'P2', 'dead_k': '3'}) rows = F.rows_of('conc_load', values, F.CONC_LOAD_COLS) assert [r['n'] for r in rows] == [1, 2] assert [r['label'] for r in rows] == ['P1', 'P2'] assert rows[0]['live_k'] == '' # a column never written -> blank def test_row_helpers_never_raise_on_a_malformed_values(): assert F.row_ids('restraint', None) == [] assert F.rows_of('restraint', None, F.RESTRAINT_COLS) == [] assert F.add_row('restraint', None, F.RESTRAINT_COLS) is None F.remove_row('restraint', None, 1) # must not raise assert F.add_row('restraint', 'not a dict', F.RESTRAINT_COLS) is None def test_two_tables_never_collide_on_the_same_values_dict(): values = {} F.add_row('restraint', values, F.RESTRAINT_COLS, defaults={'elevation_ft': '1'}) F.add_row('conc_load', values, F.CONC_LOAD_COLS, defaults={'elevation_ft': '2'}) assert F.row_ids('restraint', values) == [1] assert F.row_ids('conc_load', values) == [1] assert F.rows_of('restraint', values, F.RESTRAINT_COLS)[0][ 'elevation_ft'] == '1' assert F.rows_of('conc_load', values, F.CONC_LOAD_COLS)[0][ 'elevation_ft'] == '2' # ---------------------------------------------------- mild reinforcement def test_parse_mild_as_summary_reads_count_x_size(): rows = F._parse_mild_as_summary( '8 x #3 (MC_S03), 24 x #4 (MC_S04)') assert rows == [{'size': '#3', 'count': 8}, {'size': '#4', 'count': 24}] def test_parse_mild_as_summary_ignores_a_bare_mark_name(): """Today's actual button read is just mark names, no size -- e.g. 'MC_S03, MC_S04' -- and this must seed nothing from that shape rather than guess.""" assert F._parse_mild_as_summary('MC_S03, MC_S04') == [] assert F._parse_mild_as_summary('') == [] assert F._parse_mild_as_summary(None) == [] def _add_mild_bar_row(values, size='', count='', depth_in='', direction='', from_ft='', to_ft='', spacing_in=''): return F.add_row('mild_bar', values, F.MILD_BAR_COLS, defaults={ 'size': size, 'count': str(count), 'depth_in': str(depth_in), 'direction': direction, 'from_ft': str(from_ft), 'to_ft': str(to_ft), 'spacing_in': str(spacing_in)}) def test_mild_bars_from_computes_area_from_astm_table(): values = {} _add_mild_bar_row(values, '#4', 6, 2, 'Longitudinal', 0, 24) out, notes = F._mild_bars_from(values, 24.0) assert notes == [] assert len(out) == 1 b = out[0] assert b['size'] == '#4' and b['count'] == 6 assert b['area_in2'] == round(6 * 0.20, 4) assert b['depth_in'] == 2.0 assert b['direction'] == 'Longitudinal' assert b['from_ft'] == 0.0 and b['to_ft'] == 24.0 def test_mild_bars_from_blank_depth_is_kept_not_fabricated(): values = {} _add_mild_bar_row(values, '#5', 4, '', 'Transverse') out, notes = F._mild_bars_from(values, 24.0) assert len(out) == 1 assert out[0]['depth_in'] is None assert any('depth' in n and '#5' in n for n in notes) def test_mild_bars_from_unrecognised_size_is_skipped_not_blocking(): values = {} _add_mild_bar_row(values, '#20', 4, 2) out, notes = F._mild_bars_from(values, 24.0) assert out == [] assert any('#20' in n for n in notes) def test_mild_bars_from_defaults_direction_and_span_when_blank(): values = {} _add_mild_bar_row(values, '#3', 2, 1) # no direction/from/to typed out, _notes = F._mild_bars_from(values, 30.0) assert out[0]['direction'] == 'Longitudinal' assert out[0]['from_ft'] == 0.0 assert out[0]['to_ft'] == 30.0 # defaults to length_ft def test_mild_bars_never_blocks_the_run_button(): values = _complete_values() _add_mild_bar_row(values, '#99', 'not a number', 'also bad') assert F.is_complete(values) def test_build_deck_emits_reinforcement_mild_bars_additively(): values = _with_prestress(rows='4 @ 2') _add_mild_bar_row(values, '#4', 6, 2, 'Longitudinal', 0, 24) deck = _deck_from(values) rf = deck['reinforcement'] assert rf['prestress'] is not None # still a prestressed panel assert len(rf['mild_bars']) == 1 assert rf['mild_bars'][0]['size'] == '#4' def test_build_deck_mild_bars_empty_list_when_no_rows(): deck = _deck_from(_complete_values()) assert deck['reinforcement']['mild_bars'] == [] assert '_mild_bars_note' not in deck['reinforcement'] def test_prefill_seeds_mild_bar_from_mild_bar_rows_read_verbatim(): reads = dict(READS) reads['mild_bar_rows'] = [ {'size': '#5', 'count': 4, 'depth_in': 1.5, 'direction': 'Transverse', 'from_ft': 2, 'to_ft': 20}] values = F.prefill(reads) rows = F.rows_of('mild_bar', values, F.MILD_BAR_COLS) assert len(rows) == 1 assert rows[0]['size'] == '#5' and rows[0]['count'] == '4' assert rows[0]['depth_in'] == '1.5' assert rows[0]['direction'] == 'Transverse' def test_prefill_seeds_mild_bar_from_mild_as_text_when_it_has_sizes(): reads = dict(READS) reads['mild_as'] = '8 x #3 (MC_S03), 24 x #4 (MC_S04)' values = F.prefill(reads) rows = F.rows_of('mild_bar', values, F.MILD_BAR_COLS) assert [(r['size'], r['count']) for r in rows] == \ [('#3', '8'), ('#4', '24')] assert 'mild_as' not in values # consumed, not left lying around def test_prefill_seeds_no_mild_bar_rows_from_a_bare_mark_summary(): reads = dict(READS) reads['mild_as'] = 'MC_S03, MC_S04' # today's real button read values = F.prefill(reads) assert F.rows_of('mild_bar', values, F.MILD_BAR_COLS) == [] def test_prefill_seeds_strand_row_from_ps_rows_text(): reads = dict(READS) reads['ps_rows'] = '4 @ 2\n4 @ 6' values = F.prefill(reads) rows = F.rows_of('strand_row', values, F.STRAND_ROW_COLS) assert [(r['count'], r['depth_in']) for r in rows] == \ [('4', '2'), ('4', '6')] assert 'ps_rows' not in values # --------------------------------------- strand dia/pull seeding (2026-09-04) def test_strand_area_in2_accepts_both_the_decimal_and_fraction_spelling(): """The render finding's own trigger: `ps_dia_in` reads '0.5' off the model while the `strand_row` table's own dropdown only offers '1/2' -- both spellings must resolve to the SAME published area.""" for decimal, fraction in (('0.375', '3/8'), ('0.4375', '7/16'), ('0.5', '1/2'), ('0.5625', '9/16')): area_d, err_d = F.strand_area_in2(decimal) area_f, err_f = F.strand_area_in2(fraction) assert err_d is None and err_f is None assert area_d == area_f def test_wwf_designation_resolves_by_its_own_grammar(): """Issue #23 item 9g: a WWF designation is spacing x spacing then wire x wire, the W/D number being the wire section in hundredths of a square inch -- resolved by that definition, not a lookup table.""" r, err = F.wwf_designation('6x6-W2.9xW2.9') assert err is None assert r['spacing_long_in'] == 6.0 and r['spacing_trans_in'] == 6.0 assert r['wire_long'] == 'W2.9' and r['wire_trans'] == 'W2.9' assert abs(r['wire_area_long_in2'] - 0.029) < 1e-9 assert abs(r['as_long_in2_per_ft'] - 0.058) < 1e-9 # spelling variants the drafters actually use for text in ('6x6 W2.9xW2.9', '6 x 6 - W2.9 x W2.9', '6X6-W2.9/W2.9', '6x6-w2.9xw2.9'): r2, err2 = F.wwf_designation(text) assert err2 is None, text assert r2 == r, text # deformed wire, unequal each way r, err = F.wwf_designation('4x12-D4xD2') assert err is None assert r['wire_long'] == 'D4' and r['wire_trans'] == 'D2' assert abs(r['as_long_in2_per_ft'] - 0.04 * 12 / 4) < 1e-9 assert abs(r['as_trans_in2_per_ft'] - 0.02 * 12 / 12) < 1e-9 # blank is not an error; nonsense is refused by name assert F.wwf_designation('') == (None, None) assert F.wwf_designation(None) == (None, None) r, err = F.wwf_designation('heavy mesh') assert r is None and 'heavy mesh' in err r, err = F.wwf_designation('6x6-W0xW2.9') assert r is None and 'positive' in err def test_strand_dia_choice_maps_decimal_to_the_dropdown_fraction(): assert F.strand_dia_choice('0.375') == '3/8' assert F.strand_dia_choice('0.4375') == '7/16' assert F.strand_dia_choice('0.5') == '1/2' assert F.strand_dia_choice('0.5625') == '9/16' # no fraction spelling in the table's own vocabulary -- pass through assert F.strand_dia_choice('0.6') == '0.6' assert F.strand_dia_choice('0.7') == '0.7' # already a fraction, or unrecognised -- pass through, never invented assert F.strand_dia_choice('1/2') == '1/2' assert F.strand_dia_choice('0.99') == '0.99' assert F.strand_dia_choice('') == '' assert F.strand_dia_choice(None) is None def test_prefill_normalises_the_scalar_ps_dia_in_itself_not_just_row_seeds(): """2026-09-05 finding: `ps_dia_in` is a `choice` editor on the page whose options are the fraction spelling ('1/2', not '0.5') -- a