Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

2161 lines
87 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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<n>
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
<select> asked to show a value that is not one of its own options
renders as if nothing were selected. `values['ps_dia_in']` used to stay
the model's raw '0.5' after prefill (only the `default_dia` LOCAL used
to seed table rows was ever normalised), so the field looked blank on
the page even though a real diameter had been read. Prefill now
normalises the scalar itself."""
reads = dict(READS)
reads['ps_dia_in'] = '0.5'
values = F.prefill(reads)
assert values['ps_dia_in'] == '1/2'
def test_prefill_seeds_strand_row_dia_and_pull_from_the_ps_scalars_text_path():
"""The render finding: rows from the `ps_rows` text parse used to come
up with `dia_in` / `pull_lb` BLANK regardless of what `ps_dia_in` /
`ps_pull_lb` carried -- seeded from those scalars now, `dia_in` mapped
to the table's own fraction vocabulary."""
reads = dict(READS)
reads['ps_rows'] = '4 @ 2\n4 @ 6'
reads['ps_dia_in'] = '0.5'
reads['ps_pull_lb'] = 33800
values = F.prefill(reads)
rows = F.rows_of('strand_row', values, F.STRAND_ROW_COLS)
assert len(rows) == 2
for r in rows:
assert r['dia_in'] == '1/2'
assert r['pull_lb'] == '33800'
def test_prefill_seeds_strand_row_from_structured_strand_rows_read():
"""The reads sibling's structured `strand_rows` -- a list of
{'count','depth_in','dia_in','pull_lb'} -- wins over the `ps_rows` text
parse when both are present, and a row's own dia/pull win over the
scalar defaults."""
reads = dict(READS)
reads['ps_rows'] = '99 @ 99' # must be ignored: structured wins
reads['ps_dia_in'] = '0.5'
reads['ps_pull_lb'] = 33800
reads['strand_rows'] = [
{'count': 4, 'depth_in': 2, 'dia_in': None, 'pull_lb': None},
{'count': 4, 'depth_in': 6, 'dia_in': '3/8', 'pull_lb': 12000},
]
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')]
# row 1 had no dia/pull of its own -> falls back to the scalars
assert rows[0]['dia_in'] == '1/2' and rows[0]['pull_lb'] == '33800'
# row 2 carried its own dia/pull -> those win
assert rows[1]['dia_in'] == '3/8' and rows[1]['pull_lb'] == '12000'
def test_prefill_seeds_ps_count_as_the_row_sum_when_blank():
reads = dict(READS)
reads['ps_rows'] = '4 @ 2\n4 @ 6'
reads['ps_dia_in'] = '0.5'
reads['ps_pull_lb'] = 33800
values = F.prefill(reads)
assert values['ps_count'] == '8'
def test_prefill_leaves_ps_count_alone_when_the_model_already_read_one():
reads = dict(READS)
reads['ps_rows'] = '4 @ 2\n4 @ 6'
reads['ps_count'] = 8
values = F.prefill(reads)
assert values['ps_count'] == '8' # the real read, not re-derived
def test_prefill_strand_x_in_passes_through_verbatim_as_a_list():
reads = dict(READS)
reads['strand_x_in'] = [1.0, 3.0, 5.0, 7.0]
values = F.prefill(reads)
assert values['strand_x_in'] == [1.0, 3.0, 5.0, 7.0]
def test_prefill_strand_x_in_defaults_to_empty_list_when_not_read():
values = F.prefill(READS)
assert values['strand_x_in'] == []
def test_prefill_wind_zone_seed_has_one_row_pressure_and_suction_blank():
values = F.prefill(READS)
rows = F.rows_of('wind_zone', values, F.WIND_ZONE_COLS)
assert len(rows) == 1
assert rows[0]['label'] == 'W1'
assert rows[0]['width_ft'] == '8' # READS' width_ft
assert rows[0]['pressure_psf'] == '' and rows[0]['suction_psf'] == ''
assert values['wind_mode'] == 'Manual'
# ------------------------------------------------- support table (2026-09-05)
def test_prefill_seeds_support_table_from_supports_ft_read():
reads = dict(READS)
reads['supports_ft'] = '2, 22'
values = F.prefill(reads)
rows = F.rows_of(F.SUPPORT_TABLE, values, F.SUPPORT_COLS)
assert [r['x_ft'] for r in rows] == ['2', '22']
def test_support_table_rows_win_over_the_legacy_text_field():
values = dict(_complete_values())
values['supports_ft'] = '99, 100' # must be ignored
F.add_row(F.SUPPORT_TABLE, values, F.SUPPORT_COLS,
defaults={'x_ft': '3'})
F.add_row(F.SUPPORT_TABLE, values, F.SUPPORT_COLS,
defaults={'x_ft': '21'})
errors, parsed = F.validate(values)
assert errors == []
assert parsed['supports'] == [3.0, 21.0]
def test_support_table_falls_back_to_text_when_table_empty():
# No support.*.present rows at all -- the legacy field is read exactly
# as before this table existed.
values = dict(_complete_values())
errors, parsed = F.validate(values)
assert errors == []
assert parsed['supports'] == [2.0, 22.0]
def test_support_table_still_enforces_at_least_two():
values = dict(_complete_values())
F.add_row(F.SUPPORT_TABLE, values, F.SUPPORT_COLS,
defaults={'x_ft': '5'})
errors, parsed = F.validate(values)
assert parsed is None
assert any('at least two support' in e for e in errors)
# ------------------------------------------------- opening table (2026-09-05)
def test_prefill_seeds_opening_table_from_openings_rows_read():
reads = dict(READS)
reads['openings_rows'] = [
{'type': 'Opening', 'pos_along': 2.0, 'pos_along_from': 'start',
'length': 3.0, 'pos_cross': None, 'pos_cross_from': None,
'width': None, 'pos_vert': 1.0, 'height': 2.0, 'label': 'OPENING 1'},
{'type': 'Reveal', 'pos_along': 10.0, 'pos_along_from': 'start',
'length': 4.0, 'pos_cross': 0.0, 'pos_cross_from': 'Face',
'width': 0.5, 'pos_vert': 0.0, 'height': 8.0, 'label': 'RECESS 1'},
]
values = F.prefill(reads)
rows = F.rows_of(F.OPENING_TABLE, values, F.OPENING_COLS)
assert len(rows) == 2
assert rows[0]['type'] == 'Opening'
assert rows[0]['x_ft'] == '2' and rows[0]['length_ft'] == '3'
assert rows[0]['y_ft'] == '1' and rows[0]['width_ft'] == '2'
assert rows[0]['depth_in'] == '' # an Opening never has one
assert rows[1]['type'] == 'Reveal'
assert rows[1]['x_ft'] == '10' and rows[1]['length_ft'] == '4'
assert rows[1]['depth_in'] == '6' # 0.5 ft * 12
def test_opening_table_rows_feed_build_deck_blockouts_and_recesses():
values = dict(_complete_values())
F.add_row(F.OPENING_TABLE, values, F.OPENING_COLS, defaults={
'type': 'Opening', 'x_ft': '2', 'length_ft': '3',
'y_ft': '1', 'width_ft': '2', 'depth_in': '',
})
F.add_row(F.OPENING_TABLE, values, F.OPENING_COLS, defaults={
'type': 'Reveal', 'x_ft': '10', 'length_ft': '4',
'y_ft': '0', 'width_ft': '8', 'depth_in': '6',
})
errors, parsed = F.validate(values)
assert errors == []
deck = F.build_deck(parsed)
ce = deck['concrete_extents']
assert len(ce['blockouts']) == 1
bo = ce['blockouts'][0]
assert bo['x0'] == 2.0 and bo['length'] == 3.0
assert bo['y0'] == 1.0 and bo['width'] == 2.0
assert bo['depth_in'] is None # full depth
assert len(ce['recesses']) == 1
rc = ce['recesses'][0]
assert rc['x0'] == 10.0 and rc['length'] == 4.0
assert rc['depth_in'] == 6.0
def test_opening_table_empty_falls_back_to_legacy_openings_rows():
values = dict(_complete_values())
values['openings_rows'] = [
{'type': 'Opening', 'pos_along': 5.0, 'pos_along_from': 'start',
'length': 2.0, 'pos_vert': 0.0, 'height': 8.0, 'label': ''},
]
errors, parsed = F.validate(values)
assert errors == []
assert parsed['openings_rows'] == values['openings_rows']
# --------------------------------------------- wind zone clamp (2026-09-05)
def test_wind_zone_within_1_inch_overhang_is_clamped_not_refused():
# length_ft = 24 (READS); bottom_elevation_ft not entered -> assumed 0.
# A zone from 0 to 24 + 0.5" (well under 1") must clamp, not refuse.
values = dict(_complete_values())
F.add_row(F.WIND_ZONE_TABLE, values, F.WIND_ZONE_COLS, defaults={
'label': 'W1', 'from_elev_ft': '0',
'to_elev_ft': str(24 + 0.5 / 12.0),
'width_ft': '8', 'pressure_psf': '20', 'suction_psf': '',
})
errors, parsed = F.validate(values)
assert errors == []
wind = parsed['wind']
assert wind is not None
zone = wind['zones'][0]
assert abs(zone['x1_ft'] - 24.0) < 1e-6 # clamped to length_ft
assert '_clamp_note' in wind
def test_wind_zone_beyond_1_inch_overhang_is_still_refused():
values = dict(_complete_values())
F.add_row(F.WIND_ZONE_TABLE, values, F.WIND_ZONE_COLS, defaults={
'label': 'W1', 'from_elev_ft': '0',
'to_elev_ft': str(24 + 6.0 / 12.0),
'width_ft': '8', 'pressure_psf': '20', 'suction_psf': '',
})
errors, _parsed = F.validate(values)
assert any('outside the panel' in e for e in errors)
def test_prefill_wind_zone_default_clamps_to_length_not_raw_elevation():
# WPA655-shaped case: elevation span (bottom->top) is 1" longer than
# length_ft -- the prefilled default row must clamp `to_elev_ft` to
# bottom + length_ft rather than the raw (longer) elevation read.
reads = dict(READS)
reads['bottom_elevation_ft'] = 0.0
reads['top_elevation_ft'] = 24.0 + 1.0 / 12.0 # 1" longer than length
values = F.prefill(reads)
rows = F.rows_of(F.WIND_ZONE_TABLE, values, F.WIND_ZONE_COLS)
assert len(rows) == 1
assert abs(float(rows[0]['to_elev_ft']) - 24.0) < 1e-6
# ---------------------------------------- face/edge lifter tables (2026-09-05)
def test_prefill_seeds_face_lifter_table_from_face_lifters_read():
reads = dict(READS)
reads['face_lifters'] = '2.00 @ 0.25\n2.00 @ 0.75'
values = F.prefill(reads)
rows = F.rows_of(F.FACE_LIFTER_TABLE, values, F.FACE_LIFTER_COLS)
assert len(rows) == 2
xs_ys = sorted((r['x_ft'], r['y_ft']) for r in rows)
assert xs_ys == [('2', '2'), ('2', '6')] # 0.25*8=2, 0.75*8=6
def test_prefill_seeds_edge_lifter_table_from_edge_lifters_read():
reads = dict(READS)
# length_ft = 24 -- an x at 0 classifies as an END/Left lifter.
reads['edge_lifters'] = '0.00 @ 0.20'
values = F.prefill(reads)
rows = F.rows_of(F.EDGE_LIFTER_TABLE, values, F.EDGE_LIFTER_COLS)
assert len(rows) == 1
assert rows[0]['edge'] == 'Left'
assert rows[0]['position_ft'] == '1.6' # 0.20 * 8 (width_ft)