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.
1119 lines
46 KiB
Python
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'
|
|
|