8e6ee31596
ci / test (push) Successful in 6m18s
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.
491 lines
20 KiB
Python
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']
|