# -*- 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 `..` with `
..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'