25592fa6c2
ci / test (push) Successful in 6m14s
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.
2161 lines
87 KiB
Python
2161 lines
87 KiB
Python
# -*- 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)
|