# -*- 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_`, 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=)` -- 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=)`: 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 ` 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']