Files
willtools-demo/WillTools.extension/tests/test_ba_pagespec.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

491 lines
20 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for `tools/ba_pagespec.py` -- the job <-> values half AND the
page-spec builder of the Beam Analysis page.
Loaded by path, exactly like `test_deckcheck.py` / `test_snapshot_defer.py`
beside it: this is host CPython code sitting in a repository whose extension
is IronPython 2.7, so `tools/` is put on `sys.path` and the module is
imported directly (it has no `vibe` dependency at all; it imports
`beamcalc.model` the plain way, falling back to `beamcalc/src` on
`sys.path`).
What this suite checks, per the ba-web-spec brief:
1. the round trip (spec section 1) on both fixtures in
`beamcalc/fixtures/` -- `from_job(job_from_values(values_from_job(J)[0]))`
equals `from_job(J)` field by field (`beam_equal == []`);
2. every section-3 table appears in the values of the RBA1 fixture with at
least one row, and row 1 carries every declared column key;
3. a non-numeric `extents.length_ft` raises `ValueError` naming the key;
4. a blank `settings.phi.shear` in values yields a job without that key,
and `from_job` then applies its own `phi_shear == 0.75`;
5. the page-spec builder (`build_pagespec` / `page_<id>`, spec sections
1-4) -- the five pages in order with no page problems, every `field`
key in `values`, every `TABLES` table registered once with its declared
columns, every drawing `depends_on` resolvable, the L-section required
fields, and the `main()` CLI path.
"""
from __future__ import annotations
import json
import math
import os
import subprocess
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
TOOLS = os.path.join(ROOT, 'tools')
BEAMCALC_SRC = os.path.join(ROOT, 'beamcalc', 'src')
for p in (TOOLS, BEAMCALC_SRC):
if p not in sys.path:
sys.path.insert(0, p)
import beamcalc.model as BM # noqa: E402
ba_pagespec = pytest.importorskip('ba_pagespec') # noqa: E402
P = ba_pagespec
FIXTURES = os.path.join(ROOT, 'beamcalc', 'fixtures')
FIXTURE_FILES = ('rba5_16x22.json', 'rba1_8x48.json')
# ------------------------------------------------------------------ helpers
def _load_fixture(name):
with open(os.path.join(FIXTURES, name), 'r', encoding='utf-8') as fh:
return json.load(fh)
def _round_trip_beam(fixture_name):
"""`from_job(job_from_values(values_from_job(J)[0]))` for a fixture."""
job = _load_fixture(fixture_name)
values, defaults_why = P.values_from_job(job)
rebuilt = P.job_from_values(values)
return BM.from_job(job), BM.from_job(rebuilt), values, defaults_why
def _walk_blocks(blocks, kind, out):
"""Every block of `kind`, recursing into `columns` and `section` the
way the front end's renderer does."""
for b in blocks:
if not isinstance(b, dict):
continue
if b.get('kind') == kind:
out.append(b)
elif b.get('kind') == 'columns':
for col in (b.get('columns') or []):
_walk_blocks(col, kind, out)
elif b.get('kind') == 'section':
_walk_blocks(b.get('blocks') or [], kind, out)
def _all_field_keys(blocks):
out = []
_walk_blocks(blocks, 'field', out)
return [b['key'] for b in out if b.get('key')]
# ------------------------------------------------------------------ tests
def test_round_trip_rba5():
original, rebuilt, values, defaults_why = _round_trip_beam(
'rba5_16x22.json')
assert P.beam_equal(original, rebuilt) == []
def test_round_trip_rba1():
original, rebuilt, values, defaults_why = _round_trip_beam(
'rba1_8x48.json')
assert P.beam_equal(original, rebuilt) == []
@pytest.mark.parametrize('fixture_name', FIXTURE_FILES)
def test_defaults_why_is_populated(fixture_name):
"""The flatten must have filled *something* from a model.py default --
neither fixture carries every key (settings, ec_ksi, strand keys, ...)."""
_, _, values, defaults_why = _round_trip_beam(fixture_name)
assert defaults_why, \
'expected defaults_why to name at least one model.py default'
for key, why in defaults_why.items():
assert isinstance(key, str) and key
assert isinstance(why, str) and why
def test_every_table_has_row_1_with_all_declared_columns_in_rba1():
"""Every section-3 table appears in the RBA1 fixture's values with at
least one present row, and row 1 carries every declared column key
(ba-web-spec.md section 3)."""
job = _load_fixture('rba1_8x48.json')
values, _ = P.values_from_job(job)
for name, meta in P.TABLES.items():
assert values.get('{0}.1.present'.format(name)) == 'true', \
'table {0} has no present row 1 in the RBA1 values'.format(name)
for col, typ, default in meta['columns']:
key = '{0}.1.{1}'.format(name, col)
assert key in values, \
'row 1 of table {0} is missing declared column {1!r}'.format(
name, col)
assert isinstance(values[key], str), \
'cell {0} is not a string'.format(key)
def test_job_from_values_rejects_non_numeric_length():
values, _ = P.values_from_job(_load_fixture('rba5_16x22.json'))
values['extents.length_ft'] = 'abc'
with pytest.raises(ValueError) as exc:
P.job_from_values(values)
assert str(exc.value).startswith('extents.length_ft')
def test_tapered_line_load_round_trips_after_page_edit():
"""A page edit that makes a line load's end differ from its start (a
taper, model.py:646-649) must rebuild as an explicit [start, end]
pair, not two scalar keys the engine would misread."""
job = _load_fixture('rba5_16x22.json')
values, _ = P.values_from_job(job)
# L1 carries deadc: 3.05 (uniform). Edit it into a taper.
assert values['line_loads.1.deadc_start'] == '3.05'
values['line_loads.1.deadc_end'] = '4.0'
rebuilt = P.job_from_values(values)
l1 = rebuilt['loads']['line'][0]
assert l1['deadc'] == [3.05, 4.0]
beam = BM.from_job(rebuilt)
assert beam.line_loads[0].cases['deadc'] == (3.05, 4.0)
def test_blank_phi_shear_omits_key_and_default_applies():
"""A blank `settings.phi.shear` yields a job without that key, and
`from_job` then applies its own default (Settings.phi_shear = 0.75,
model.py:162)."""
job = _load_fixture('rba5_16x22.json')
values, _ = P.values_from_job(job)
assert values['settings.phi.shear'] == '0.75'
values['settings.phi.shear'] = ''
rebuilt = P.job_from_values(values)
assert 'shear' not in rebuilt['settings']['phi']
beam = BM.from_job(rebuilt)
assert beam.settings.phi_shear == 0.75
# ----------------------------------------------------- the page-spec builder
#
# `build_pagespec(reads=<fixture job>)` -- a fixture job is a legal reads
# dict (a partial job, ba-web-spec.md section 6). These are the five checks
# the task brief names.
def _build_spec_from_fixture(fixture_name='rba5_16x22.json'):
return P.build_pagespec(values=None, reads=_load_fixture(fixture_name))
def test_build_pagespec_has_five_pages_in_order_with_no_problems():
"""(1) `build_pagespec(reads=<fixture>)`: five pages, ids in the fixed
order (spec section 2), no page problems, and every `field` block's key
present in `values`."""
spec = _build_spec_from_fixture()
ids = [p['id'] for p in spec['pages']]
assert ids == ['extents', 'materials', 'loads', 'reinforcement',
'handling']
for page in spec['pages']:
assert page['problems'] == [], (
'page {0!r} has problems: {1!r}'.format(page['id'],
page['problems']))
for key in _all_field_keys(page['blocks']):
assert key in spec['values'], \
'page {0!r} field {1!r} is missing from values'.format(
page['id'], key)
def test_build_pagespec_blank_never_raises_and_round_trips_json():
"""The no-Revit path (`values=None`, `reads={}`) -- a complete,
defaulted spec that survives `dumps`/`json.loads`."""
spec = P.build_pagespec(values=None, reads={})
assert spec['schema'] == 1
assert spec['title'] == 'Beam Analysis'
assert len(spec['pages']) == 5
assert all(not p['problems'] for p in spec['pages'])
blob = P.dumps(spec)
back = json.loads(blob)
assert back['schema'] == 1 and back['title'] == 'Beam Analysis'
def test_basis_and_defaults_why_are_filtered_to_rendered_keys():
"""The fixture carries no `basis`, so a synthetic one must land only on
values keys this spec renders; `defaults_why` comes from
`values_from_job` and must likewise be a subset of values."""
job = _load_fixture('rba5_16x22.json')
job['basis'] = {'extents.length_ft': 'the Length parameter',
'not.a.key': 'should be filtered out'}
spec = P.build_pagespec(values=None, reads=job)
assert spec['basis'].get('extents.length_ft') == 'the Length parameter'
assert 'not.a.key' not in spec['basis']
for key in spec['basis']:
assert key in spec['values']
for key in spec['defaults_why']:
assert key in spec['values']
def test_every_table_is_registered_once_with_declared_columns():
"""(2) Every table in TABLES appears exactly once in `spec['tables']`,
carrying the declared columns in order -- the de-duped top-level dict
(pa-web-spec.md section 4) mirrors what the page blocks show."""
spec = _build_spec_from_fixture()
for name, meta in P.TABLES.items():
assert name in spec['tables'], \
'table {0} is missing from spec[tables]'.format(name)
cols = spec['tables'][name]['columns']
assert [c['key'] for c in cols] == [c[0] for c in meta['columns']], \
'table {0} columns do not match TABLES'.format(name)
for c in cols:
assert c['label'] and 'editor' in c
if c['label'].endswith(')'):
assert c['unit'], \
'column {0}.{1} label has a unit but unit is null'.format(
name, c['key'])
# each table is named by exactly one editable_table block in the pages
found = {}
for page in spec['pages']:
blocks = []
_walk_blocks(page['blocks'], 'editable_table', blocks)
for b in blocks:
found[b['table']] = found.get(b['table'], 0) + 1
assert sorted(found) == sorted(P.TABLES)
assert all(n == 1 for n in found.values())
def test_drawing_depends_on_keys_resolve():
"""(3) Each `drawing` block's `depends_on` entries exist in values or
name a row table (a prefix that redraws on any of its cells)."""
spec = _build_spec_from_fixture()
drawings = []
for page in spec['pages']:
_walk_blocks(page['blocks'], 'drawing', drawings)
assert drawings, 'expected drawing blocks across the spec'
for b in drawings:
assert b['drawing_key'] in spec['geometry'], \
'{0} missing from geometry'.format(b['drawing_key'])
for dep in b['depends_on']:
assert dep in spec['values'] or dep in P.TABLES, \
'drawing {0} depends on unknown key/table {1!r}'.format(
b['drawing_key'], dep)
def test_l_section_marks_stem_width_required():
"""(4) A reads dict whose section type is 'L' marks `stem_width_in`
(and the other stem/flange fields) required (spec section 3: they
"render on every type but are required only when the type needs them",
model.py:562-567)."""
job = _load_fixture('rba5_16x22.json')
job['extents']['section'] = {
'type': 'L', 'side': 'left',
'stem_width_in': 6, 'stem_height_in': 16,
'flange_width_in': 10, 'flange_thickness_in': 6}
spec = P.build_pagespec(values=None, reads=job)
fields = {}
for page in spec['pages']:
if page['id'] != 'extents':
continue
blocks = []
_walk_blocks(page['blocks'], 'field', blocks)
for b in blocks:
fields[b['key']] = b
assert fields['extents.section.stem_width_in']['required'] is True
assert fields['extents.section.stem_height_in']['required'] is True
assert fields['extents.section.flange_width_in']['required'] is True
assert fields['extents.section.flange_thickness_in']['required'] is True
# the overall width/height stay required on every type
assert fields['extents.section.width_in']['required'] is True
assert fields['extents.section.height_in']['required'] is True
def test_rectangular_reads_do_not_require_stem_or_flange():
job = _load_fixture('rba5_16x22.json')
spec = P.build_pagespec(values=None, reads=job)
for page in spec['pages']:
if page['id'] != 'extents':
continue
blocks = []
_walk_blocks(page['blocks'], 'field', blocks)
by_key = {b['key']: b for b in blocks}
for key in ('extents.section.stem_width_in',
'extents.section.stem_height_in',
'extents.section.flange_width_in',
'extents.section.flange_thickness_in',
'extents.section.side'):
assert by_key[key]['required'] is False, key
def test_required_keys_present():
"""The spec's required fields exist and are marked: length,
section width/height, materials.fc_ksi (spec section 2)."""
spec = _build_spec_from_fixture()
required = {}
for page in spec['pages']:
blocks = []
_walk_blocks(page['blocks'], 'field', blocks)
for b in blocks:
if b.get('required'):
required[b['key']] = b
for key in ('extents.length_ft', 'extents.section.width_in',
'extents.section.height_in', 'materials.fc_ksi'):
assert key in required, key
assert required['materials.fc_ksi']['required'] is True
# ------------------------------------------------- the geometry dict
#
# `ba_pagespec.geometry(job)` (ba-web-spec.md section 4) -- the four
# drawing entries the spec lists, built off `beamcalc.model.from_job(job)`
# so every position comes from the code the engine runs.
GEO_KEYS = ('extents.elevation', 'loads.elevation', 'reinforcement.section',
'reinforcement.elevation')
@pytest.mark.parametrize('fixture_name', FIXTURE_FILES)
def test_geometry_fixture_entries_complete_and_clean(fixture_name):
"""(1) `geometry(rba5)` and `geometry(rba1)`: all four entries present,
every `problems` == [], supports count 2 with `x_ft` matching
`Support.x_in / 12`, and the bar count equals sum(rows*cols) over the
bar rows with every bar strictly inside the section."""
job = _load_fixture(fixture_name)
beam = BM.from_job(job)
geo = P.geometry(job)
for key in GEO_KEYS:
assert key in geo, 'geometry is missing the {0} entry'.format(key)
assert isinstance(geo[key], dict)
assert geo[key]['problems'] == [], \
'{0} has unexpected problems: {1!r}'.format(key,
geo[key]['problems'])
for key in ('extents.elevation', 'loads.elevation'):
elev = geo[key]
assert elev['length_ft'] == round(beam.length_ft, 3)
assert elev['height_in'] == beam.section.height_in
supports = elev['supports']
assert len(supports) == 2
for got, sup in zip(supports, beam.supports):
assert got['x_ft'] == round(sup.x_in / 12.0, 3), \
'support x_ft does not match Support.x_in / 12'
assert got['length_ft'] == round(sup.length_in / 12.0, 3)
assert got['kind'] == sup.kind
sec = geo['reinforcement.section']
assert sec['type'] == beam.section.kind
assert sec['width_in'] == beam.section.width_in
assert sec['height_in'] == beam.section.height_in
expected_bars = sum(row.rows * row.cols for row in beam.bars)
assert len(sec['bars']) == expected_bars, (
'section bar count {0} != sum(rows*cols) = {1}'.format(
len(sec['bars']), expected_bars))
assert len(sec['strands']) == sum(g.rows * g.cols for g in beam.strands)
for bar in sec['bars']:
assert 0.0 < bar['x_in'] < sec['width_in'], \
'bar x_in {0} outside the section'.format(bar)
assert 0.0 < bar['y_in'] < sec['height_in'], \
'bar y_in {0} outside the section'.format(bar)
for strand in sec['strands']:
assert 0.0 < strand['x_in'] < sec['width_in']
assert 0.0 < strand['y_in'] < sec['height_in']
elev = geo['reinforcement.elevation']
assert elev['length_ft'] == round(beam.length_ft, 3)
assert len(elev['bars']) == len(beam.bars)
assert len(elev['transverse']) == sum(
2 if z.symmetric else 1 for z in beam.transverse)
def test_geometry_individual_bar_centres_match_the_engine():
"""Every drawn bar centre must equal the centre `BarRow.bars(section)`
computes (model.py:366-380) -- the same even left/right/top/bottom
spacing the engine uses -- never a second, drifting spread."""
job = _load_fixture('rba1_8x48.json')
beam = BM.from_job(job)
sec = P.geometry(job)['reinforcement.section']
assert sec['width_in'] == beam.section.width_in
assert sec['height_in'] == beam.section.height_in
drawn = [(b['x_in'], b['y_in'], b['dia_in']) for b in sec['bars']]
engine = []
for row in beam.bars:
area = row.bar_area
db = BM.BAR_DIA.get(row.size, math.sqrt(4.0 * area / math.pi))
for x, y, _a, _d in row.bars(beam.section):
engine.append((x, y, db))
assert drawn == engine
# the fixture carries no custom bar areas, so every size is in the table
for b in sec['bars']:
assert b['dia_in'] == BM.BAR_DIA[b['size']]
def test_geometry_point_load_beyond_the_beam_is_absent_and_named():
"""(2) A point load beyond the beam length is absent from
`loads.elevation.point` and named in that entry's problems."""
job = _load_fixture('rba5_16x22.json')
beam = BM.from_job(job)
job['loads']['point'].append(
{'id': 'P9', 'x_in': beam.length_in + 60.0, 'ecc_in': 0.0,
'deadc': 5.0})
geo = P.geometry(job)
for key in GEO_KEYS:
assert key in geo
elev = geo['loads.elevation']
ids = [p['id'] for p in elev['point']]
assert 'P9' not in ids
assert any('P9' in sentence for sentence in elev['problems'])
# the in-beam load from the fixture is still drawn
assert 'P1' in ids
# the out-of-beam load leaves the other entries' problems untouched
assert geo['extents.elevation']['problems'] == []
def test_geometry_empty_job_returns_four_entries_with_problems():
"""(3) `geometry({'schema': 'beamcalc/1'})` -- nothing else -- returns
all four entries with non-empty problems and does not raise (a model
whose reads carry only the schema must never take the page down)."""
geo = P.geometry({'schema': 'beamcalc/1'})
for key in GEO_KEYS:
assert key in geo
assert isinstance(geo[key], dict)
assert geo[key]['problems'], \
'{0} expected a problems sentence for the empty job'.format(key)
assert 'from_job' in geo[key]['problems'][0]
def test_main_writes_a_parsing_spec_json():
"""(5) `python tools/ba_pagespec.py --out <tmp>` exits 0 and the JSON
parses -- the CLI no-Revit path."""
import tempfile
fd, out_path = tempfile.mkstemp(suffix='.json')
os.close(fd)
try:
proc = subprocess.run(
[sys.executable, os.path.join(TOOLS, 'ba_pagespec.py'),
'--out', out_path],
cwd=ROOT, capture_output=True, text=True, timeout=120)
assert proc.returncode == 0, proc.stderr
with open(out_path, 'r', encoding='utf-8') as fh:
spec = json.load(fh)
assert spec['schema'] == 1
assert spec['title'] == 'Beam Analysis'
assert len(spec['pages']) == 5
finally:
try:
os.remove(out_path)
except OSError:
pass
def test_spec_geometry_entries_are_the_drawings_own_not_the_whole_map():
"""Caught by the first headless screenshot: every geometry[key] held the
entire map, so drawings read length_ft off a dict of dicts."""
import json, os
job = json.load(open(os.path.join(FIXTURES, "rba5_16x22.json")))
spec = ba_pagespec.build_pagespec(reads=job)
g = spec['geometry']
assert 'extents.elevation' in g
assert 'extents.elevation' not in g['extents.elevation'], 'nested map'
assert g['extents.elevation']['length_ft'] > 17
assert g['reinforcement.section']['bars']