Files
willtools-demo/WillTools.extension/tests/test_pa_reinforcement.py
T
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

1119 lines
46 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for `vibe.pa_reinforcement.page` -- the Reinforcement section of the
Panel Analysis input form.
Runs on plain CPython via `tests/conftest.py`'s import trick (no Revit, no
pyRevit, no WPF -- Revit is NOT running for this suite). Covers the doctrine
the module docstring commits to, after the 2026-09-04 restructure that
replaced the free-text `ps_rows` / `mild_as` fields with structured
`strand_row` / `mild_bar` TABLES (owner: "rebar 怎么可能是打字上去? ... 你这样
计算模型怎么接收这个数据?"):
* only block kinds from the fixed vocabulary are ever emitted, and the two
reinforcement tables render as `editable_table` blocks with the exact
columns the owner specified;
* every 'field' block carries a plain-English label and a hint;
* every 'sentence' block is quiet (`'quiet': True`) -- helper prose, not an
inline warning;
* every optional sub-block (strands / rebar / mesh / interrupts) states an
empty-case sentence when it has nothing, never a blank;
* row data lives under `<table>.<n>.<col>` with `<table>.<n>.present` ==
'yes', read through `row_ids` / `row_key`;
* no summary quantity (area, cgx, ex, ey) is ever invented;
* the drawing block names `pa_sketch_reinf.section`, full width, an aspect
ratio clamped to [0.12, 0.4];
* `values` is never mutated.
"""
import copy
import json
from vibe import pa_reinforcement as par
from vibe import panelanalysisform as pf
_KNOWN_KINDS = set(['heading', 'columns', 'field', 'editable_table', 'table',
'kv', 'sentence', 'drawing', 'rule', 'space'])
def _walk(blocks):
"""Yield every block, recursing into 'columns' children."""
for b in blocks:
yield b
if b.get('kind') == 'columns':
for col in b.get('columns') or []:
for sub in _walk(col):
yield sub
def _fields(blocks):
return [b for b in _walk(blocks) if b.get('kind') == 'field']
def _sentences(blocks):
return [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence']
def _tables(blocks):
return [b for b in _walk(blocks) if b.get('kind') == 'editable_table']
def _strand_row_values(rows):
"""A `values` dict fragment with one `strand_row` row per entry in
`rows` (each `{'count', 'chain', 'depth_in', 'dia_in', 'pull_lb'}`, any
key optional)."""
values = {}
for i, row in enumerate(rows):
n = str(i + 1)
values[par.row_key('strand_row', n, 'present')] = 'yes'
for col in ('count', 'chain', 'depth_in', 'dia_in', 'pull_lb'):
if col in row:
values[par.row_key('strand_row', n, col)] = row[col]
return values
def _mild_row_values(rows):
values = {}
for i, row in enumerate(rows):
n = str(i + 1)
values[par.row_key('mild_bar', n, 'present')] = 'yes'
for col in ('size', 'count', 'depth_in', 'direction', 'from_ft',
'to_ft', 'spacing_in'):
if col in row:
values[par.row_key('mild_bar', n, col)] = row[col]
return values
# ------------------------------------------------------------------- shape
def test_only_known_block_kinds_empty_values():
blocks, problems = par.page({})
for b in _walk(blocks):
assert b.get('kind') in _KNOWN_KINDS, b
def test_only_known_block_kinds_filled_values():
values = {'strand_rail_location': 'Left edge',
'strand_rail_offset_in': '0', 'ps_count': '4',
'ps_dia_in': '1/2', 'ps_pull_lb': '30000', 'ps_loss_pct': '8',
'mesh': '6x6 W1.4xW1.4'}
values.update(_strand_row_values([{'count': 4, 'depth_in': 2.0,
'dia_in': '1/2'}]))
values.update(_mild_row_values([{'size': '#4', 'count': 6,
'depth_in': 1.5}]))
blocks, problems = par.page(values)
for b in _walk(blocks):
assert b.get('kind') in _KNOWN_KINDS, b
def test_page_never_raises_on_garbage_values():
garbage = {'strand_row': 'not a dict', 'ps_rows': 12345,
'ps_dia_in': None, 'mild_as': object(),
12: 'a non-string key', ('t', 'u', 'p', 'l', 'e'): 'also weird'}
blocks, problems = par.page(garbage)
assert isinstance(blocks, list)
assert isinstance(problems, list)
def test_page_never_raises_on_none_values():
blocks, problems = par.page(None)
assert isinstance(blocks, list)
# ------------------------------------------------------------- field labels
def test_every_field_has_plain_english_label_and_hint():
values = _strand_row_values([{'count': 2, 'depth_in': 2.0}])
blocks, _ = par.page(values)
fields = _fields(blocks)
assert fields, 'expected at least one field block'
for f in fields:
assert f['label'] and isinstance(f['label'], str)
# A plain-English label is not just the raw key repeated back.
assert f['label'] != f['key']
assert f['hint'], 'field {0!r} has no hint'.format(f['key'])
def test_field_blocks_reference_real_panelanalysisform_keys():
blocks, problems = par.page({})
known_keys = set(f['key'] for f in pf.FIELDS)
for f in _fields(blocks):
assert f['key'] in known_keys, (
'{0!r} is not a real panelanalysisform key'.format(f['key']))
# ---------------------------------------------------------------- sentences
def test_all_sentences_are_quiet():
blocks, _ = par.page({})
sentences = [b for b in _walk(blocks) if b.get('kind') == 'sentence']
assert sentences
for s in sentences:
assert s.get('quiet') is True, s
# ---------------------------------------------------------- empty-case text
def test_strands_blank_prints_no_prestressing_sentence():
blocks, _ = par.page({})
assert 'Design contains no prestressing' in _sentences(blocks)
def test_rebar_blank_prints_no_rebar_sentence():
blocks, _ = par.page({})
assert 'Design contains no rebar' in _sentences(blocks)
def test_mesh_blank_prints_no_mesh_sentence():
blocks, _ = par.page({})
assert 'Design contains no mesh' in _sentences(blocks)
def test_interrupts_is_an_editable_table_and_empty_is_a_judged_answer():
"""Issue #23 item 9h (2026-09-07): the could-not-judge placeholder is
gone -- an `interrupt` table exists, so no rows IS "Design contains no
interrupts", and nothing about interrupts lands in `problems`."""
blocks, problems = par.page({})
tables = [t for t in _tables(blocks) if t['table'] == 'interrupt']
assert len(tables) == 1
cols = {c['col']: c for c in tables[0]['columns']}
assert set(cols) == {'x_ft', 'cuts'}
assert cols['x_ft']['editor'] == 'num'
assert cols['cuts']['editor'] == 'choice'
assert set(cols['cuts']['choices']) == {'All', 'Strands', 'Rebar', 'Mesh'}
assert tables[0]['add_label'] == 'Add interrupt'
sentences = _sentences(blocks)
assert 'Design contains no interrupts' in sentences
assert not any('could not judge' in s.lower() for s in sentences)
assert not any('interrupt' in p.lower() for p in problems)
def test_interrupt_table_columns_match_panelanalysisform():
from vibe import panelanalysisform as F
assert F.INTERRUPT_TABLE == 'interrupt'
assert tuple(c['col'] for c in par._INTERRUPT_COLUMNS) == F.INTERRUPT_COLS
cuts = [c for c in par._INTERRUPT_COLUMNS if c['col'] == 'cuts'][0]
assert tuple(cuts['choices']) == F.INTERRUPT_CUTS_CHOICES
def test_interrupt_rows_are_read_and_incomplete_ones_named():
from vibe import panelanalysisform as F
values = {}
F.add_row('interrupt', values, F.INTERRUPT_COLS,
defaults={'x_ft': '12', 'cuts': 'Strands'})
F.add_row('interrupt', values, F.INTERRUPT_COLS,
defaults={'x_ft': '', 'cuts': ''})
F.add_row('interrupt', values, F.INTERRUPT_COLS,
defaults={'x_ft': 'twelve', 'cuts': 'All'})
blocks, problems = par.page(values)
sentences = _sentences(blocks)
assert 'Design contains no interrupts' not in sentences
assert any('Interrupt row 2' in s and 'no position' in s for s in sentences)
assert any('Interrupt row 2' in s and 'what it cuts' in s for s in sentences)
assert not any('Interrupt row 1' in s for s in sentences)
assert any('interrupt row 3' in p and 'not a number' in p for p in problems)
# the page says plainly that the solve does not consume them yet
assert any('does not yet restart development' in s for s in sentences)
def test_rebar_filled_has_no_empty_sentence():
values = _mild_row_values([{'size': '#4', 'count': 6, 'depth_in': 1.5}])
blocks, _ = par.page(values)
assert 'Design contains no rebar' not in _sentences(blocks)
def test_rebar_row_missing_depth_is_a_quiet_sentence_not_a_problem():
"""2026-09-04: the drawing used to double as the notice for this (an
in-canvas text primitive AND a `problems` entry). It is neither any
more -- the page names the row as its own quiet sentence instead."""
values = _mild_row_values([{'size': '#4', 'count': 6}]) # no depth_in
blocks, problems = par.page(values)
sentences = _sentences(blocks)
assert any('Rebar row 1' in s and 'no depth' in s for s in sentences)
assert not any('no depth' in p.lower() for p in problems)
def test_rebar_row_with_depth_gets_no_missing_depth_sentence():
values = _mild_row_values([{'size': '#4', 'count': 6, 'depth_in': 1.5}])
blocks, _ = par.page(values)
sentences = _sentences(blocks)
assert not any('no depth' in s for s in sentences)
def test_mesh_filled_has_no_empty_sentence():
blocks, _ = par.page({'mesh': '6x6 W1.4xW1.4'})
assert 'Design contains no mesh' not in _sentences(blocks)
def test_mesh_is_a_structured_group_with_a_resolved_summary():
"""Issue #23 item 9g (2026-09-07): designation + layers + depth as one
field group, and the designation RESOLVED (spacing, wire area, As/ft)
in a kv beneath -- W2.9 is 0.029 in² per wire; at 6" that is 0.058
in²/ft each way."""
blocks, problems = par.page({'mesh': '6x6-W2.9xW2.9', 'mesh_layers': '1',
'mesh_depth_in': '4'})
keys = [f['key'] for f in _fields(blocks)]
for key in ('mesh', 'mesh_layers', 'mesh_depth_in'):
assert key in keys
kv = [b for b in blocks if b['kind'] == 'kv'
and b['rows'][0][0].startswith('Wire spacing')][0]
rows = dict(kv['rows'])
assert rows['Wire spacing, in (long x trans)'] == '6 x 6'
assert rows['Wire area, in² each (long / trans)'] == \
'0.029 (W2.9) / 0.029 (W2.9)'
assert rows['As per ft, in²/ft (long / trans), one layer'] == \
'0.058 / 0.058'
assert not problems
assert not any('Mesh depth' in s for s in _sentences(blocks))
def test_mesh_without_depth_says_so_quietly():
blocks, _ = par.page({'mesh': '4x4-D4xD4'})
assert any('Mesh depth to CG not entered' in s for s in _sentences(blocks))
def test_unreadable_mesh_designation_is_named_not_resolved():
blocks, problems = par.page({'mesh': 'some mesh'})
assert not [b for b in blocks if b['kind'] == 'kv'
and b['rows'][0][0].startswith('Wire spacing')]
assert any('some mesh' in p and 'not readable' in p for p in problems)
assert 'Design contains no mesh' not in _sentences(blocks)
def test_strands_filled_has_no_empty_sentence():
values = _strand_row_values([{'count': 2, 'depth_in': 2.0,
'dia_in': '1/2'}])
blocks, _ = par.page(values)
assert 'Design contains no prestressing' not in _sentences(blocks)
# --------------------------------------------------------- editable_table
def test_strand_row_editable_table_has_exact_columns():
"""The `strand_row` table's columns, in order: qty, the rail dimension
chain (issue #23 item 9a, 2026-09-07), depth, dia, pull."""
blocks, _ = par.page({})
tables = [t for t in _tables(blocks) if t['table'] == 'strand_row']
assert len(tables) == 1
t = tables[0]
assert t['add_label']
cols = {c['col']: c for c in t['columns']}
assert set(cols) == {'count', 'chain', 'depth_in', 'dia_in', 'pull_lb'}
assert tuple(c['col'] for c in t['columns']) == \
('count', 'chain', 'depth_in', 'dia_in', 'pull_lb')
assert cols['count']['editor'] == 'int'
assert cols['chain']['label'] == 'Location chain from rail, inches: 2;2;2;2'
assert cols['chain']['editor'] == 'text'
assert cols['depth_in']['editor'] == 'num'
assert cols['pull_lb']['editor'] == 'num'
assert cols['dia_in']['editor'] == 'choice'
assert set(cols['dia_in']['choices']) == \
set(['3/8', '7/16', '1/2', '1/2 SP', '9/16', '0.6', '0.7'])
# No 'rows' key -- the renderer reads the live rows off `values` itself.
assert 'rows' not in t
def test_strand_dia_choices_are_the_one_shared_list():
"""Issue #23 item 9d: the table's `dia_in` dropdown and the scalar
`ps_dia_in` field offer the SAME sizes, from `panelanalysisform.
STRAND_DIA_CHOICES`; the local fallback list is kept identical so the
sibling-mid-restructure path cannot drift either. '1/2 SP' sits beside
'1/2' as a distinct choice."""
from vibe import panelanalysisform as F
cols = {c['col']: c for c in par._STRAND_COLUMNS}
assert list(cols['dia_in']['choices']) == list(F.STRAND_DIA_CHOICES)
assert par._STRAND_DIA_CHOICES_FALLBACK == list(F.STRAND_DIA_CHOICES)
assert list(F.FIELD_CHOICES['ps_dia_in']) == list(F.STRAND_DIA_CHOICES)
choices = list(F.STRAND_DIA_CHOICES)
assert '1/2' in choices and '1/2 SP' in choices
assert choices.index('1/2 SP') == choices.index('1/2') + 1
def test_mild_bar_editable_table_has_exact_columns():
blocks, _ = par.page({})
tables = [t for t in _tables(blocks) if t['table'] == 'mild_bar']
assert len(tables) == 1
t = tables[0]
assert t['add_label']
cols = {c['col']: c for c in t['columns']}
assert set(cols) == {'size', 'count', 'depth_in', 'direction',
'from_ft', 'to_ft', 'spacing_in'}
assert cols['size']['editor'] == 'choice'
assert set(cols['size']['choices']) == \
set(['#3', '#4', '#5', '#6', '#7', '#8', '#9', '#10', '#11'])
assert cols['direction']['editor'] == 'choice'
assert set(cols['direction']['choices']) == \
set(['Longitudinal', 'Transverse'])
assert cols['count']['editor'] == 'int'
for col in ('depth_in', 'from_ft', 'to_ft', 'spacing_in'):
assert cols[col]['editor'] == 'num'
def test_editable_tables_present_even_when_empty():
"""The tables are the input mechanism now -- they render even with no
rows yet, so the user has somewhere to click "Add"."""
blocks, _ = par.page({})
tables = _tables(blocks)
assert set(t['table'] for t in tables) == {'strand_row', 'mild_bar',
'interrupt'}
# ------------------------------------------------------------------ row_ids
def test_row_ids_reads_present_rows_in_numeric_order():
values = {}
values[par.row_key('strand_row', '2', 'present')] = 'yes'
values[par.row_key('strand_row', '10', 'present')] = 'yes'
values[par.row_key('strand_row', '1', 'present')] = 'yes'
assert par.row_ids('strand_row', values) == [1, 2, 10]
def test_row_ids_ignores_rows_not_marked_present():
values = {par.row_key('strand_row', '1', 'count'): '4'} # no .present
assert par.row_ids('strand_row', values) == []
def test_row_key_shape_matches_owner_spec():
assert par.row_key('strand_row', '1', 'count') == 'strand_row.1.count'
assert par.row_key('mild_bar', '3', 'depth_in') == 'mild_bar.3.depth_in'
# ------------------------------------------------------------- strand rows
def _summary_kv(blocks):
"""The strand summary kv (the one carrying 'Aps, in²'), or None."""
for b in blocks:
if b['kind'] == 'kv' and any(r[0] == 'Aps, in²' for r in b['rows']):
return b
return None
def _value_map(rows):
"""label -> value text for a kv block's rows -- a plain 2-tuple row's
[1], or a [label, value, live] row's value with the 3rd-element `live`
spec ignored (issue #23, 2026-09-07)."""
return dict((r[0], r[1]) for r in rows)
def _live_rows(blocks):
"""(label, live_spec) for every kv row carrying a 3rd-element `live`
spec (docs/pa-web-spec.md §2, "kv")."""
out = []
for b in blocks:
if b.get('kind') != 'kv':
continue
for row in b.get('rows') or []:
if len(row) >= 3 and isinstance(row[2], dict) \
and row[2].get('table'):
out.append((row[0], row[2]))
return out
def test_structured_strand_rows_make_ps_count_read_only_sum():
"""Issue #23 item 9b (2026-09-07): with rows present `ps_count` is a
kv row showing the rows' sum, not an editable field -- a typed value
(here a stale '9') cannot sit out of step with the table."""
values = {'ps_count': '9'}
values.update(_strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'},
{'count': 2, 'depth_in': 6.0, 'dia_in': '1/2'},
]))
blocks, _ = par.page(values)
assert not [f for f in _fields(blocks) if f['key'] == 'ps_count']
count_kvs = [b for b in blocks if b['kind'] == 'kv'
and b['rows'][0][0] == 'Strand count']
assert len(count_kvs) == 1
assert count_kvs[0]['rows'][0][1].startswith('4 ')
assert '9' not in count_kvs[0]['rows'][0][1]
def test_ps_count_is_an_editable_field_only_while_no_rows_exist():
blocks, _ = par.page({'ps_count': ''})
assert [f for f in _fields(blocks) if f['key'] == 'ps_count']
assert not [b for b in blocks if b['kind'] == 'kv'
and b['rows'][0][0] == 'Strand count']
def test_ps_pull_lb_default_is_not_on_the_page():
"""Issue #23 item 9e: "IP per strand (default)" is gone -- the rows
carry the pull. Tombstone: the key must not come back as a field."""
blocks, _ = par.page({'ps_pull_lb': '28900'})
assert not [f for f in _fields(blocks) if f['key'] == 'ps_pull_lb']
assert 'ps_pull_lb' not in par._STRAND_SCALAR_KEYS
def test_total_ip_is_the_sum_of_count_times_pull_per_row():
"""Issue #23 item 9c."""
values = _strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2', 'pull_lb': 28900},
{'count': 3, 'depth_in': 6.0, 'dia_in': '1/2', 'pull_lb': 21500},
])
blocks, _ = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
total = float(rows_by_label['Total IP, lb (sum of initial pull)'])
assert abs(total - (2 * 28900 + 3 * 21500)) < 1e-6
def test_total_ip_withheld_when_a_row_has_no_pull():
values = _strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2', 'pull_lb': 28900},
{'count': 3, 'depth_in': 6.0, 'dia_in': '1/2'},
])
blocks, _ = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert 'not shown' in rows_by_label['Total IP, lb (sum of initial pull)']
# Aps is still real -- a missing pull does not hide the area
assert abs(float(rows_by_label['Aps, in²']) - 5 * 0.153) < 1e-6
def test_half_inch_special_strand_resolves_in_the_summary():
values = _strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2 SP', 'pull_lb': 31000},
])
blocks, problems = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert abs(float(rows_by_label['Aps, in²']) - 2 * 0.167) < 1e-6
assert not any('section area' in p or 'lookup failed' in p
for p in problems)
def test_missing_strand_rows_is_quiet_no_prestressing():
blocks, problems = par.page({})
assert 'Design contains no prestressing' in _sentences(blocks)
def test_malformed_strand_row_cell_reported_not_silently_dropped():
values = _strand_row_values([{'count': 'not-a-number', 'depth_in': 2.0,
'dia_in': '1/2'}])
blocks, problems = par.page(values)
assert any('count' in p and 'not a number' in p for p in problems)
# --------------------------------------------------- live kv rows (#23)
def test_strand_summary_rows_carry_live_specs():
"""Issue #23 (2026-09-07): the strand count, Total IP and Aps rows are
the three a user edits into being row by row, so exactly those three kv
rows ship a `live` spec (docs/pa-web-spec.md §2, "kv") and the browser
re-sums them over the table as rows are edited."""
values = _strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2', 'pull_lb': 28900},
{'count': 3, 'depth_in': 6.0, 'dia_in': '1/2', 'pull_lb': 21500},
])
blocks, _ = par.page(values)
live = dict(_live_rows(blocks))
assert set(live) == set(['Strand count',
'Total IP, lb (sum of initial pull)',
'Aps, in²'])
count_live = live['Strand count']
assert count_live['table'] == 'strand_row'
assert count_live['terms'] == ['count']
assert count_live['format'] == '%d'
ip_live = live['Total IP, lb (sum of initial pull)']
assert ip_live['table'] == 'strand_row'
assert ip_live['terms'] == ['count', 'pull_lb']
assert ip_live['format'] == '%.1f'
assert 'missing its initial pull' in ip_live['unknown']
aps_live = live['Aps, in²']
assert aps_live['table'] == 'strand_row'
assert aps_live['terms'][0] == 'count'
dia_term = aps_live['terms'][1]
assert dia_term['col'] == 'dia_in'
assert aps_live['format'] == '%.4f'
assert 'area' in aps_live['unknown'].lower()
# the shipped dia map covers EVERY dropdown size, with the same area
# the module itself looks up -- a row's dia can change to any option
# after the page was built, and the browser must still find its area.
area_map = dia_term['map']
assert set(area_map) == set(pf.STRAND_DIA_CHOICES)
for dia in pf.STRAND_DIA_CHOICES:
area, err = pf.strand_area_in2(dia)
assert err is None, dia
assert area_map[dia] == area
assert isinstance(area_map[dia], float) and area_map[dia] > 0.0, dia
def test_kv_rows_without_live_stay_two_element_rows():
"""Issue #23: only the three row-derived summary rows grow a 3rd
element -- every other kv row (cgy, the strand x positions, cgx/ex/ey,
the mesh summary) stays an unchanged 2-tuple."""
values = _strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2', 'pull_lb': 28900},
])
blocks, _ = par.page(values)
summary = _summary_kv(blocks)
by_label = dict((r[0], r) for r in summary['rows'])
for label in ('Aps, in²', 'Total IP, lb (sum of initial pull)'):
assert len(by_label[label]) == 3, label
for label in ('cgy, in (depth to CG)', 'Strand x positions', 'cgx, in',
'ex, in', 'ey, in'):
assert len(by_label[label]) == 2, label
# every kv row across the page is still JSON-shippable
json.dumps(summary['rows'])
# --------------------------------------------------------- never fabricated
def test_unknown_strand_size_refused_not_guessed():
values = _strand_row_values([{'count': 2, 'depth_in': 2.0,
'dia_in': 'made-up-size'}])
blocks, problems = par.page(values)
assert _summary_kv(blocks) is None # no Aps/cgy summary without an area
assert any('lookup failed' in p or 'not a recognised' in p or
'no section area' in p for p in problems)
def test_strand_summary_never_prints_cgx_ex_ey_as_numbers():
values = _strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'},
{'count': 2, 'depth_in': 6.0, 'dia_in': '1/2'},
])
blocks, problems = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert 'not shown' in rows_by_label['cgx, in']
assert 'not shown' in rows_by_label['ex, in']
assert 'not shown' in rows_by_label['ey, in']
# Aps and cgy ARE real derived numbers, not refused.
assert rows_by_label['Aps, in²'].replace('.', '').isdigit()
assert any('cgx' in p and 'not computed' in p for p in problems)
def test_aps_and_cgy_derived_correctly_from_entered_rows():
values = _strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'},
{'count': 2, 'depth_in': 6.0, 'dia_in': '1/2'},
])
blocks, _ = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
area = 0.153
expected_aps = 4 * area
expected_cgy = (2 * 2.0 + 2 * 6.0) / 4.0
assert abs(float(rows_by_label['Aps, in²']) - expected_aps) < 1e-6
assert abs(float(rows_by_label['cgy, in (depth to CG)']) -
expected_cgy) < 1e-6
def test_row_dia_overrides_default_ps_dia_in():
"""Each row's OWN dia_in is used for area, not the global default,
when the row supplies one."""
values = {'ps_dia_in': '3/8'} # default; row below overrides it
values.update(_strand_row_values([{'count': 2, 'depth_in': 2.0,
'dia_in': '1/2'}]))
blocks, _ = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert abs(float(rows_by_label['Aps, in²']) - 2 * 0.153) < 1e-6
def test_row_blank_dia_falls_back_to_default_ps_dia_in():
values = {'ps_dia_in': '1/2'}
values.update(_strand_row_values([{'count': 2, 'depth_in': 2.0}]))
blocks, _ = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert abs(float(rows_by_label['Aps, in²']) - 2 * 0.153) < 1e-6
# ------------------------------------------ strand_x_in fallback (2026-09-04)
def test_strand_x_fallback_is_a_quiet_sentence_when_strand_x_in_absent():
values = _strand_row_values([{'count': 2, 'depth_in': 2.0,
'dia_in': '1/2'}])
values['width_ft'] = '4'
blocks, problems = par.page(values)
sentences = _sentences(blocks)
assert any('spread' in s.lower() and 'strand_x_in' in s
for s in sentences)
assert not any('strand_x_in' in p for p in problems)
def test_strand_x_fallback_sentence_absent_when_strand_x_in_present():
values = _strand_row_values([{'count': 2, 'depth_in': 2.0,
'dia_in': '1/2'}])
values['width_ft'] = '4'
values['strand_x_in'] = [1.0, 3.0]
blocks, _ = par.page(values)
sentences = _sentences(blocks)
assert not any('spread' in s.lower() and 'strand_x_in' in s
for s in sentences)
def test_strand_x_fallback_sentence_absent_with_no_strand_rows():
blocks, _ = par.page({})
sentences = _sentences(blocks)
assert not any('strand_x_in' in s for s in sentences)
# ------------------------------------- location chain (issue #23 item 9a)
def test_parse_length_in_reads_plain_inches_feet_and_fractions():
assert par.parse_length_in('2') == 2.0
assert par.parse_length_in('2"') == 2.0
assert par.parse_length_in('2in') == 2.0
assert par.parse_length_in('2 1/2"') == 2.5
assert par.parse_length_in('1/4"') == 0.25
assert par.parse_length_in('0\'-2"') == 2.0
assert par.parse_length_in('1\'-6"') == 18.0
assert par.parse_length_in("1'-6 1/2\"") == 18.5
# typed quotes/primes are normalised
assert par.parse_length_in(u'2\u2033') == 2.0
assert par.parse_length_in(u'0\u2032-2\u2033') == 2.0
# refused, never guessed
assert par.parse_length_in('') is None
assert par.parse_length_in('two') is None
assert par.parse_length_in('2 x 4') is None
def test_strand_positions_cumulative_chain_from_left_rail():
positions, problem = par.strand_positions(4, '2;2;2;2', 'left', '0', 96)
assert problem is None
assert positions == [2, 4, 6, 8]
def test_strand_positions_applies_rail_offset():
positions, problem = par.strand_positions(4, '2;2;2;2', 'left', '1', 96)
assert problem is None
assert positions == [1, 3, 5, 7]
def test_strand_positions_mirrors_for_right_rail():
positions, problem = par.strand_positions(4, '2;2;2;2', 'right', '0', 96)
assert problem is None
assert positions == [94, 92, 90, 88]
def test_strand_positions_feet_inch_cells_and_blank_offset():
# 2" then 0'-2" == 2 in etc.; a blank rail offset reads as 0
positions, problem = par.strand_positions(
2, '2";0\'-2"', 'Left edge', '', 96)
assert problem is None
assert positions == [2, 4]
def test_strand_positions_count_mismatch_is_a_named_problem():
positions, problem = par.strand_positions(4, '2;2;2', 'left', '0', 96)
assert positions is None
assert problem is not None
assert '3' in problem and '4' in problem
def test_strand_positions_bad_cell_is_a_named_problem():
positions, problem = par.strand_positions(3, '2;bogus;2', 'left', '0',
96)
assert positions is None
assert problem is not None
assert 'bogus' in problem
def test_strand_positions_beyond_width_is_a_named_problem():
# 60 + 60 = 120 in, past the 96 in width
positions, problem = par.strand_positions(2, '60;60', 'left', '0', 96)
assert positions is None
assert problem is not None
assert '120' in problem
def test_strand_positions_blank_rail_side_is_a_problem():
positions, problem = par.strand_positions(2, '2;2', '', '0', 96)
assert positions is None
assert problem is not None
assert 'rail' in problem
def test_strand_positions_never_guesses_out_of_panel_negative():
# a chain starting before the rail datum (offset larger than first
# cumulative) must be refused, not clamped to 0
positions, problem = par.strand_positions(2, '2;2', 'left', '5', 96)
assert positions is None
assert problem is not None
def test_page_summary_lists_strand_x_positions_from_chain():
values = {'width_ft': '8', 'strand_rail_location': 'Left edge',
'strand_rail_offset_in': '0'}
values.update(_strand_row_values([
{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2',
'chain': '2;2;2;2'},
]))
blocks, problems = par.page(values)
assert not any('chain has' in p or 'chain cell' in p or 'outside' in p
for p in problems)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert rows_by_label['Strand x positions'] == 'row 1: 2", 4", 6", 8"'
# a resolved chain means the cross-section is NOT spread evenly
assert not any('spread' in s.lower() and 'strand_x_in' in s
for s in _sentences(blocks))
def test_page_summary_says_not_entered_without_chains():
values = {'width_ft': '8'}
values.update(_strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'},
]))
blocks, _ = par.page(values)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert rows_by_label['Strand x positions'] == 'not entered'
def test_page_reports_a_chain_count_mismatch_in_problems():
values = {'width_ft': '8', 'strand_rail_location': 'Left edge'}
values.update(_strand_row_values([
{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2'},
]))
blocks, problems = par.page(values)
assert any('chain has 3 cells for 4 strands' in p for p in problems)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert rows_by_label['Strand x positions'] == 'not entered'
def test_page_reports_a_bad_chain_cell_in_problems():
values = {'width_ft': '8', 'strand_rail_location': 'Left edge'}
values.update(_strand_row_values([
{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;bogus'},
]))
blocks, problems = par.page(values)
assert any('bogus' in p and 'chain cell' in p for p in problems)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
assert rows_by_label['Strand x positions'] == 'not entered'
def test_page_chain_right_rail_mirrors_in_the_summary():
values = {'width_ft': '8', 'strand_rail_location': 'Right edge',
'strand_rail_offset_in': '0'}
values.update(_strand_row_values([
{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'},
]))
blocks, problems = par.page(values)
assert not any('chain has' in p or 'chain cell' in p or 'outside' in p
for p in problems)
rows_by_label = _value_map(_summary_kv(blocks)['rows'])
# width 8 ft = 96 in; mirror of [2,4,6,8] is [94,92,90,88], shown
# feet-inches per the page's own display rule
assert rows_by_label['Strand x positions'] == \
'row 1: 7\'-10", 7\'-8", 7\'-6", 7\'-4"'
# --------------------------------------------------------------- no mutation
def test_values_dict_not_mutated():
values = {'strand_rail_location': 'Left edge',
'strand_rail_offset_in': '0', 'mesh': ''}
values.update(_strand_row_values([{'count': 2, 'depth_in': 2.0,
'dia_in': '1/2'}]))
before = copy.deepcopy(values)
par.page(values)
assert values == before
def test_missing_field_key_reports_problem_not_crash(monkeypatch):
"""A field this module asks panelanalysisform for that no longer exists
there degrades to a MISSING problem, never a crash."""
real_spec = pf._spec
def _fake_spec(key):
if key == 'mesh':
return None
return real_spec(key)
monkeypatch.setattr(pf, '_spec', _fake_spec)
blocks, problems = par.page({})
assert any('mesh' in p and 'MISSING' in p for p in problems)
# The section still renders -- heading + empty sentence, just no field.
assert 'Design contains no mesh' in _sentences(blocks)
# -------------------------------------------------------------- drawing tag
# The reinforcement inputs BOTH drawing blocks watch (issue #23 item 9i):
# the row tables, the strand rail datum, mesh, and the panel's real
# length/width/thickness. The cross-section block additionally watches
# `section_type` (it draws the Sandwich layering); the plan does not.
_DRAWING_DEPENDS_BASE = {
'strand_row', 'mild_bar', 'strand_rail_location',
'strand_rail_offset_in', 'mesh', 'mesh_layers', 'mesh_depth_in',
'thickness_in', 'width_ft', 'length_ft'}
def test_drawing_blocks_are_the_outline_section_pair():
"""Issue #23 item 9i: the page now carries TWO stacked drawings --
the plan (`source: 'outline'`) first, then the existing cross-section
(`source: 'section'`), the same pair shape Concrete Extents uses.
Both name the same drawing module and both watch every reinforcement
input (rows, the rail datum, mesh, and the panel's real length/width/
thickness)."""
blocks, _ = par.page({})
drawings = [b for b in blocks if b['kind'] == 'drawing']
assert [d['source'] for d in drawings] == ['outline', 'section']
for d in drawings:
assert d['module'] == 'pa_sketch_reinf'
assert d['full_width'] is True
assert d['caption']
# Both watch every reinforcement input -- the plan (which knows nothing
# of Sandwich layering) omits `section_type`; the section (which draws
# the layering) carries it.
assert set(drawings[0]['depends_on']) == _DRAWING_DEPENDS_BASE
assert set(drawings[1]['depends_on']) == \
_DRAWING_DEPENDS_BASE | {'section_type'}
assert drawings[0]['caption'] == \
'Reinforcement plan -- strands from the rail, bars, mesh'
assert drawings[1]['caption'] == 'Reinforcement cross-section'
def _section_drawing(blocks):
"""The page's cross-section `drawing` block (the second of the two,
`source: 'section'`)."""
return [b for b in blocks if b['kind'] == 'drawing'
and b['source'] == 'section'][0]
def test_drawing_block_aspect_is_clamped():
"""2026-09-04: the section block asks `pa_sketch_reinf.aspect_for(values)`
for its full aspect now -- `clamp_aspect()`'s own [0.12, 0.4] plus THIS
`values`' own fixed/legend bands, which for a bare `{}` (no legend) is
the smallest that extra can be."""
from vibe import pa_sketch_reinf as psr
blocks, _ = par.page({})
d = _section_drawing(blocks)
assert abs(d['aspect'] - psr.aspect_for({})) < 1e-9
assert 0.12 <= d['aspect'] <= 0.4 + 1.0 # sane, generous bounds
def test_drawing_block_aspect_reflects_real_geometry():
from vibe import pa_sketch_reinf as psr
values = {'width_ft': '10', 'thickness_in': '8'} # 8/12/10 = 0.0667 -> clamps to 0.12
blocks, _ = par.page(values)
d = _section_drawing(blocks)
assert abs(d['aspect'] - psr.aspect_for(values)) < 1e-9
values2 = {'width_ft': '4', 'thickness_in': '8'} # 8/12/4 = 0.1667
blocks2, _ = par.page(values2)
d2 = _section_drawing(blocks2)
assert abs(d2['aspect'] - psr.aspect_for(values2)) < 1e-9
def test_aspect_grows_with_a_real_reinforcement_schedule():
"""A page with strand/bar rows must ask for MORE aspect than a bare
one -- `_section_aspect` -> `aspect_for` folds in the legend's real
size, so a busy schedule is not squeezed into the same canvas height
as an empty one. The plan block's own aspect (issue #23 item 9i) is a
separate number (`_outline_aspect`, width/length) -- this test only
follows the cross-section's, the legend-driven one."""
values = {'width_ft': '10', 'thickness_in': '8'}
values.update(_strand_row_values([
{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2'},
{'count': 4, 'depth_in': 6.0, 'dia_in': '1/2'}]))
values.update(_mild_row_values([
{'size': '#4', 'count': 8, 'depth_in': 1.5},
{'size': '#5', 'count': 6, 'depth_in': 6.5}]))
values['mesh'] = '6x6 W1.4xW1.4'
blocks_bare, _ = par.page({'width_ft': '10', 'thickness_in': '8'})
blocks_busy, _ = par.page(values)
d_bare = _section_drawing(blocks_bare)
d_busy = _section_drawing(blocks_busy)
assert d_busy['aspect'] > d_bare['aspect']
def test_aspect_extra_local_fallback_matches_the_sketch_module_constant():
"""`_section_aspect`'s local fallback constant is only ever used when
`pa_sketch_reinf.aspect_for` cannot be reached at all -- guard that it
has not drifted from the real module's own `ASPECT_EXTRA`."""
from vibe import pa_sketch_reinf as psr
assert par._ASPECT_EXTRA_DEFAULT == psr.ASPECT_EXTRA
# -------------------------------- plant's own mesh styles (issue #23, 9g 2nd)
def _mesh_know():
"""A plant-knowledge-shaped `know` dict whose `mesh:` section mirrors
the sample_plant layer's (standard mesh sheet M9, dated 2023-07-18): the
four structural stocking sections plus `not_in_standard_but_stocked`,
with one 'Var'-spacing entry and one plant-only entry among them."""
return {'mesh': {
'dated': '2023-07-18',
'd_number_meaning': None,
'flat_sheets': {
'WM-B-022': {
'size': '4 x 4 - D3 / D3 96" (+2,+2) x 11\'-10" (1,1)',
'used_in': 'Various Product', 'previous': 'Item B New'},
'WM-VAR-01': { # variable long spacing: not convertible
'size': 'Var x 7.5 - D2 / D2.9 20" (+1,+1) x 5\'-2" (1,1)',
'used_in': 'Special dt'},
},
'dt_stem': {
'SM-C-039': {
'size': '6 x 6 - W1.4 / W1.4 96" (+2,+2) x 8\'-0" (1,1)',
'used_in': '12DT24 / 12DT26'},
},
'metrodeck_stem': {
'WM-MD-010': {
'size': '4 x 8 - D3 / D2 120" (+1,+1) x 12\'-0" (1,1)',
'used_in': 'Metrodeck'},
},
'beam': {
'WM-BM-002': {
'size': '6 x 6 - D4 / D4 96" (+2,+2) x 11\'-10" (1,1)',
'used_in': 'Beams'},
},
'not_in_standard_but_stocked': {
'WM-IW-032': {
'size': '4 x 8 - D3 / D2 96" (0,0) x 9\'-0" (0,0)',
'used_in': 'IW panels', 'plant_only': 'Winchester',
'why_not_landed': 'not listed on the standard sheet'},
},
}}
def _mesh_kv(blocks):
"""The mesh summary kv block (the one whose rows name the plant style or
the wire spacing), or None."""
for b in blocks:
if b.get('kind') == 'kv':
labels = [r[0] for r in b['rows']]
if any(l == 'Plant style' for l in labels) or \
any(l.startswith('Wire spacing') for l in labels):
return b
return None
def test_plant_mesh_styles_lists_every_stocked_style():
"""Issue #23 item 9g (second half): every style under `know['mesh']` is
listed with its short parseable designation rebuilt from the sheet's
`size` grammar, and a 'Var' spacing or unreadable size marks the style
NOT convertible -- the code stays in the list, never dropped."""
styles = par.plant_mesh_styles(_mesh_know())
by_code = dict((s['code'], s) for s in styles)
assert set(by_code) == set(['WM-B-022', 'WM-VAR-01', 'SM-C-039',
'WM-MD-010', 'WM-BM-002', 'WM-IW-032'])
wm = by_code['WM-B-022']
assert wm['designation'] == '4x4-D3xD3'
assert wm['convertible'] is True
assert wm['section'] == 'flat_sheets'
assert wm['used_in'] == 'Various Product'
assert wm['plant_only'] is None
assert wm['size_text'].startswith('4 x 4 - D3 / D3')
assert by_code['SM-C-039']['designation'] == '6x6-W1.4xW1.4'
assert by_code['WM-MD-010']['designation'] == '4x8-D3xD2'
assert by_code['WM-MD-010']['section'] == 'metrodeck_stem'
assert by_code['WM-BM-002']['designation'] == '6x6-D4xD4'
# a 'Var' spacing still gets a (display-only) designation, marked False
var = by_code['WM-VAR-01']
assert var['designation'] == 'Varx7.5-D2xD2.9'
assert var['convertible'] is False
# the plant-only style is listed with its stocking plant named
iw = by_code['WM-IW-032']
assert iw['designation'] == '4x8-D3xD2'
assert iw['convertible'] is True
assert iw['plant_only'] == 'Winchester'
assert iw['section'] == 'not_in_standard_but_stocked'
def test_plant_mesh_styles_handles_missing_or_shapeless_know():
assert par.plant_mesh_styles(None) == []
assert par.plant_mesh_styles({}) == []
assert par.plant_mesh_styles({'mesh': None}) == []
assert par.plant_mesh_styles({'mesh': {'dated': '2023-07-18'}}) == []
# a size that does not follow the sheet grammar stays, marked unusable
know = {'mesh': {'flat_sheets': {'X-1': {'size': 'not a size',
'used_in': 'odd'}}}}
styles = par.plant_mesh_styles(know)
assert len(styles) == 1
assert styles[0]['code'] == 'X-1'
assert styles[0]['designation'] is None
assert styles[0]['convertible'] is False
def test_mesh_field_offers_plant_styles_with_custom_last():
"""With the plant's `mesh:` knowledge in front of the page, the mesh
designation field becomes a choices field: each option's VALUE is the
plant's designation code and the last option is the custom one -- the
dropdown is a shortcut, free text is still the contract."""
blocks, _ = par.page({'mesh': '', 'mesh_layers': '1', 'mesh_depth_in': '4'},
know=_mesh_know())
mesh = [f for f in _fields(blocks) if f['key'] == 'mesh']
assert len(mesh) == 1
f = mesh[0]
assert f['editor'] == 'choice'
assert f['choices']
by_value = dict((o['value'], o) for o in f['choices'])
assert set(by_value) == set(['WM-B-022', 'WM-VAR-01', 'SM-C-039',
'WM-MD-010', 'WM-BM-002', 'WM-IW-032', ''])
# flat_sheets come first (their section is first; codes sorted), and the
# option for the 4x4 D3 sheet carries code + designation + sheet + use
first = f['choices'][0]
assert first['value'] == 'WM-B-022'
assert first['label'] == \
'WM-B-022 -- 4x4-D3xD3 (96" (+2,+2) x 11\'-10" (1,1); Various Product)'
# the plant-only style says so in its label
assert by_value['WM-IW-032']['label'].startswith(
'WM-IW-032 -- 4x8-D3xD2')
assert ' [Winchester only]' in by_value['WM-IW-032']['label']
# the custom option is last and intentionally value-less
assert f['choices'][-1]['value'] == ''
assert f['choices'][-1]['label'] == '(custom -- type a designation)'
def test_chosen_plant_style_resolves_through_its_designation():
"""Choosing the plant's own code keeps `values['mesh']` the plain code
string, and the page resolves it through the style's short designation:
D3 is 0.03 in² per wire (the catalogue.wwf rule, hundredths of a square
inch), so at 4" spacing As = 0.03 * 12 / 4 = 0.09 in²/ft each way."""
values = {'mesh': 'WM-B-022', 'mesh_layers': '1', 'mesh_depth_in': '4'}
blocks, problems = par.page(values, know=_mesh_know())
assert not problems
kv = _mesh_kv(blocks)
assert kv is not None
rows = dict((r[0], r[1]) for r in kv['rows'])
assert kv['rows'][0] == ('Plant style', 'WM-B-022 (Various Product)')
assert rows['Wire spacing, in (long x trans)'] == '4 x 4'
assert rows['Wire area, in² each (long / trans)'] == \
'0.03 (D3) / 0.03 (D3)'
assert rows['As per ft, in²/ft (long / trans), one layer'] == '0.09 / 0.09'
def test_var_spacing_style_is_named_and_rows_withheld():
"""A chosen code whose style is not convertible ('Var' spacing) is named
in `problems` with the reason, and the kv shows only the 'Plant style'
row -- a wire area is never guessed from a variable spacing."""
blocks, problems = par.page({'mesh': 'WM-VAR-01', 'mesh_depth_in': '4'},
know=_mesh_know())
assert any('WM-VAR-01' in p and 'variable' in p for p in problems)
kv = _mesh_kv(blocks)
assert kv is not None
assert kv['rows'] == [('Plant style', 'WM-VAR-01 (Special dt)')]
assert not any('Wire spacing' in r[0] or 'Wire area' in r[0]
for r in kv['rows'])
def test_free_text_designation_still_parses_with_plant_styles_known():
"""Free text is untouched by the dropdown: a typed designation resolves
as it always did, and the summary says the style is custom."""
blocks, problems = par.page({'mesh': '6x6-W2.9xW2.9',
'mesh_depth_in': '4'}, know=_mesh_know())
assert not problems
kv = _mesh_kv(blocks)
rows = dict((r[0], r[1]) for r in kv['rows'])
assert kv['rows'][0] == ('Plant style', 'custom designation')
assert rows['Wire spacing, in (long x trans)'] == '6 x 6'
assert rows['Wire area, in² each (long / trans)'] == \
'0.029 (W2.9) / 0.029 (W2.9)'
def test_mesh_stays_a_plain_text_box_without_plant_knowledge():
"""`know` None (or no `mesh:` section in it) leaves the designation
field exactly the text box it always was -- no choices, no 'Plant
style' row -- and free text parses as before."""
for know in (None, {'other_section': {}}, {'mesh': {'dated': 'x'}}):
blocks, problems = par.page({'mesh': '6x6-W2.9xW2.9'}, know=know)
mesh = [f for f in _fields(blocks) if f['key'] == 'mesh']
assert len(mesh) == 1
assert mesh[0]['editor'] == 'text'
assert not mesh[0].get('choices')
assert not problems
kv = _mesh_kv(blocks)
rows = dict((r[0], r[1]) for r in kv['rows'])
assert kv['rows'][0][0].startswith('Wire spacing')
assert 'Plant style' not in rows
assert rows['Wire spacing, in (long x trans)'] == '6 x 6'