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.
421 lines
18 KiB
Python
421 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Host tests for `vibe.pa_pagespec` -- the module that turns Panel
|
|
Analysis's five section-module block lists into ONE JSON document for the
|
|
HTML front end (see `docs/pa-web-spec.md` for the contract this tests
|
|
against). Loaded the way every other `pa_*` suite is -- `conftest.py`
|
|
registers a bare `vibe` package pointed at `lib/vibe` so this imports on
|
|
plain host CPython with no Revit, no WPF, and no `pyrevit` on the path.
|
|
|
|
What this suite checks, per the task brief:
|
|
|
|
* `build_pagespec()` on a synthetic `values` dict with `know=None` never
|
|
raises and round-trips through `json.dumps`.
|
|
* every `field` block's key is accounted for -- present in `values` or
|
|
`choices`, or else named in that page's own `problems` (the section
|
|
module already says so; that is not this module's bug to hide).
|
|
* every `editable_table` block's table name is registered in the
|
|
top-level `tables` dict.
|
|
* every `drawing` block's `drawing_key` has a `geometry` entry.
|
|
* the NO-REVIT path (`values=None`, `reads=None`, `know=None`) still
|
|
yields a complete, defaulted form -- every FIELDS key present, `pa_
|
|
defaults` applied, and no drawing missing its geometry dict.
|
|
* provenance lands: a `_basis` entry and a `pa_defaults`-applied default
|
|
both reach `basis` / `defaults_why`, including for a ROW-TABLE key.
|
|
"""
|
|
import json
|
|
|
|
from vibe import pa_pagespec
|
|
from vibe import panelanalysisform as F
|
|
|
|
|
|
# --------------------------------------------------------------- utilities
|
|
|
|
def _walk_field_keys(blocks, out):
|
|
"""Every `field` block's key, recursing into `columns` and `section`
|
|
the way the real page renderer would."""
|
|
for b in blocks:
|
|
if not isinstance(b, dict):
|
|
continue
|
|
kind = b.get('kind')
|
|
if kind == 'field' and b.get('key'):
|
|
out.append(b['key'])
|
|
elif kind == 'columns':
|
|
for col in (b.get('columns') or []):
|
|
_walk_field_keys(col, out)
|
|
elif kind == 'section':
|
|
_walk_field_keys(b.get('blocks') or [], out)
|
|
|
|
|
|
def _walk_table_names(blocks, out):
|
|
for b in blocks:
|
|
if not isinstance(b, dict):
|
|
continue
|
|
kind = b.get('kind')
|
|
if kind == 'editable_table' and b.get('table'):
|
|
out.append(b['table'])
|
|
elif kind == 'columns':
|
|
for col in (b.get('columns') or []):
|
|
_walk_table_names(col, out)
|
|
elif kind == 'section':
|
|
_walk_table_names(b.get('blocks') or [], out)
|
|
|
|
|
|
def _walk_drawing_keys(blocks, out):
|
|
for b in blocks:
|
|
if not isinstance(b, dict):
|
|
continue
|
|
kind = b.get('kind')
|
|
if kind == 'drawing':
|
|
out.append(b.get('drawing_key'))
|
|
elif kind == 'columns':
|
|
for col in (b.get('columns') or []):
|
|
_walk_drawing_keys(col, out)
|
|
elif kind == 'section':
|
|
_walk_drawing_keys(b.get('blocks') or [], out)
|
|
|
|
|
|
def _synthetic_values():
|
|
"""A minimal but real `values` dict, built the same way the button
|
|
does (`F.prefill`) so this test exercises the actual prefill/defaults
|
|
pipeline rather than a hand-rolled stand-in that could drift from what
|
|
`build_pagespec` is really fed. `know=None` -- no plant layer -- is the
|
|
exact case the task brief names."""
|
|
reads = {
|
|
'mark': 'PNL101', 'design_number': 'PNL1.0',
|
|
'length_ft': 24.0, 'width_ft': 8.0, 'thickness_in': 8.0,
|
|
'bottom_elevation_ft': 0.0, 'top_elevation_ft': 24.0,
|
|
'restraints': '0, 24',
|
|
'mark_basis': "the element's Mark parameter",
|
|
}
|
|
return F.prefill(reads, know=None)
|
|
|
|
|
|
# ------------------------------------------------------------------ tests
|
|
|
|
def test_build_pagespec_never_raises_and_round_trips_json():
|
|
values = _synthetic_values()
|
|
spec = pa_pagespec.build_pagespec(values, know=None)
|
|
blob = json.dumps(spec) # must not raise
|
|
back = json.loads(blob)
|
|
assert back['schema'] == 1
|
|
assert back['title'] == 'Panel Analysis'
|
|
assert len(back['pages']) == 5
|
|
|
|
|
|
def test_pages_are_the_five_sections_in_order():
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
ids = [p['id'] for p in spec['pages']]
|
|
assert ids == ['design_criteria', 'concrete_extents', 'structural_model',
|
|
'handling', 'reinforcement']
|
|
|
|
|
|
def test_reinforcement_page_has_the_outline_section_drawing_pair():
|
|
"""Issue #23 item 9i: the Reinforcement page now carries TWO drawing
|
|
blocks (a plan/outline and the section), so its page record and the spec
|
|
document carry BOTH drawing keys -- `reinforcement.outline` alongside
|
|
the existing `reinforcement.section` -- and each has a (possibly
|
|
degraded-but-never-absent) geometry dict."""
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
reinf = next(p for p in spec['pages'] if p['id'] == 'reinforcement')
|
|
keys = []
|
|
_walk_drawing_keys(reinf['blocks'], keys)
|
|
assert keys == ['reinforcement.outline', 'reinforcement.section']
|
|
for key in keys:
|
|
assert key in spec['geometry'], key
|
|
geo = spec['geometry'][key]
|
|
assert isinstance(geo, dict)
|
|
assert 'problems' in geo
|
|
# the plan drawing's source is 'outline' and names the reinforcement
|
|
# sketch module, so the drawing dispatches before the section does
|
|
outline = next(b for b in reinf['blocks'] if b.get('source') == 'outline')
|
|
assert outline['module'] == 'pa_sketch_reinf'
|
|
assert outline['full_width'] is True
|
|
|
|
|
|
def test_every_field_key_is_accounted_for():
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
values = spec['values']
|
|
choices = spec['choices']
|
|
for page in spec['pages']:
|
|
keys = []
|
|
_walk_field_keys(page['blocks'], keys)
|
|
problems_text = ' '.join(page['problems'])
|
|
unaccounted = [
|
|
k for k in keys
|
|
if k not in values and k not in choices and k not in problems_text
|
|
]
|
|
assert unaccounted == [], (
|
|
'page {0!r} has field keys with no value, no choices, and no '
|
|
'problems entry explaining the gap: {1!r}'
|
|
.format(page['id'], unaccounted))
|
|
|
|
|
|
def test_every_editable_table_is_registered():
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
names = []
|
|
for page in spec['pages']:
|
|
_walk_table_names(page['blocks'], names)
|
|
assert names, 'expected at least one editable_table block across the form'
|
|
for name in names:
|
|
assert name in spec['tables'], \
|
|
"table {0!r} referenced by a block but missing from " \
|
|
"spec['tables']".format(name)
|
|
meta = spec['tables'][name]
|
|
assert isinstance(meta.get('columns'), list) and meta['columns']
|
|
assert isinstance(meta.get('min_rows'), int)
|
|
assert isinstance(meta.get('add_label'), str) and meta['add_label']
|
|
|
|
|
|
def test_every_drawing_has_geometry():
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
keys = []
|
|
for page in spec['pages']:
|
|
_walk_drawing_keys(page['blocks'], keys)
|
|
assert keys, 'expected at least one drawing block across the form'
|
|
for key in keys:
|
|
assert key, 'a drawing block has no drawing_key'
|
|
assert key in spec['geometry'], \
|
|
'drawing_key {0!r} has no entry in spec[geometry]'.format(key)
|
|
geo = spec['geometry'][key]
|
|
assert isinstance(geo, dict)
|
|
assert 'problems' in geo
|
|
|
|
|
|
def test_geometry_values_are_real_units_not_pixels():
|
|
"""The whole point of the rewrite: a page with no canvas size can still
|
|
draw. `outline_geometry_ft`-shaped geometry should carry the panel's
|
|
real length/width, not anything that looks like a pixel box."""
|
|
values = _synthetic_values()
|
|
spec = pa_pagespec.build_pagespec(values, know=None)
|
|
geo = spec['geometry']['concrete_extents.outline']
|
|
assert geo['length_ft'] == 24.0
|
|
assert geo['width_ft'] == 8.0
|
|
assert geo['outline'] == [[0.0, 0.0], [24.0, 0.0], [24.0, 8.0],
|
|
[0.0, 8.0]]
|
|
assert geo['degraded'] is False
|
|
|
|
|
|
def test_values_include_every_table_row_key():
|
|
"""The restraint rows `prefill()` seeded from `reads['restraints']`
|
|
('0, 24' -> two rows) must ride through into spec['values']."""
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
values = spec['values']
|
|
ids = F.row_ids('restraint', values)
|
|
assert len(ids) >= 2
|
|
for n in ids:
|
|
assert F.row_key('restraint', n, 'elevation_ft') in values
|
|
|
|
|
|
# --------------------------------------------------------- no-Revit path
|
|
|
|
def test_no_revit_path_builds_a_complete_form():
|
|
"""`values=None`, `reads=None`, `know=None` -- the owner's scope note:
|
|
the page must open and run before Revit ever supplies anything."""
|
|
spec = pa_pagespec.build_pagespec(values=None, know=None, reads=None)
|
|
json.dumps(spec) # never raises, always JSON-safe
|
|
|
|
values = spec['values']
|
|
for f in F.FIELDS:
|
|
assert f['key'] in values, \
|
|
'no-Revit form is missing FIELDS key {0!r}'.format(f['key'])
|
|
|
|
# pa_defaults ran: at least one generic, plant-agnostic default landed.
|
|
assert spec['defaults_why'], \
|
|
'expected at least one defaults_why entry with no plant/model input'
|
|
|
|
# every drawing still has a geometry dict, degraded or not, never absent
|
|
keys = []
|
|
for page in spec['pages']:
|
|
_walk_drawing_keys(page['blocks'], keys)
|
|
for key in keys:
|
|
assert key in spec['geometry']
|
|
assert isinstance(spec['geometry'][key], dict)
|
|
|
|
# the placeholder outline is a real (non-zero-area) rectangle
|
|
outline_geo = spec['geometry']['concrete_extents.outline']
|
|
assert outline_geo['degraded'] is True
|
|
assert outline_geo['length_ft'] > 0
|
|
assert outline_geo['width_ft'] > 0
|
|
|
|
|
|
def test_no_revit_path_pads_tables_to_min_rows():
|
|
"""`min_rows` is 0 for every table today (no page module sets it), so
|
|
this is a no-op in practice -- checked anyway so the padding path is
|
|
exercised and does not silently break if a future page module sets one.
|
|
"""
|
|
spec = pa_pagespec.build_pagespec(values=None, know=None, reads=None)
|
|
for name, meta in spec['tables'].items():
|
|
min_rows = meta.get('min_rows') or 0
|
|
ids = F.row_ids(name, spec['values'])
|
|
assert len(ids) >= min_rows
|
|
|
|
|
|
# ------------------------------------------------------------- provenance
|
|
|
|
def test_basis_lands_with_the_basis_suffix_stripped():
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
assert spec['basis'].get('mark') == "the element's Mark parameter"
|
|
assert 'mark_basis' not in spec['basis']
|
|
|
|
|
|
def test_defaults_why_covers_an_applied_default_and_a_field_rule():
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
values = _synthetic_values()
|
|
applied = values.get('_defaults_applied') or []
|
|
assert applied, 'expected pa_defaults to have applied at least one default'
|
|
for key, _value_text, why in applied:
|
|
assert spec['defaults_why'].get(key) == why
|
|
|
|
# a field pa_defaults knows a rule for, whether or not it fired this run
|
|
from vibe import pa_defaults
|
|
for f in F.FIELDS:
|
|
why = pa_defaults.why_of(f['key'])
|
|
if why:
|
|
assert spec['defaults_why'].get(f['key']) == why
|
|
break
|
|
else:
|
|
raise AssertionError('expected at least one FIELDS key with a '
|
|
'pa_defaults.why_of() rule')
|
|
|
|
|
|
def test_provenance_not_dropped_for_row_table_keys():
|
|
"""A row-table cell (e.g. a restraint row's elevation) is not a
|
|
top-level FIELDS key, so `pa_defaults.why_of()` has nothing to say about
|
|
it by name -- but it must not silently vanish from `values` either."""
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
values = spec['values']
|
|
ids = F.row_ids('restraint', values)
|
|
assert ids
|
|
key = F.row_key('restraint', ids[0], 'elevation_ft')
|
|
assert key in values
|
|
assert values[key] not in (None,)
|
|
|
|
|
|
def test_choices_upgrade_matches_wpf_renderer_rule():
|
|
"""A plain text/num field whose key is a known enumeration should
|
|
arrive as `editor: 'choice'` with the registry's own options -- the
|
|
same upgrade `script.py`'s `_render_field` applies for the WPF form."""
|
|
spec = pa_pagespec.build_pagespec(_synthetic_values(), know=None)
|
|
found = False
|
|
for page in spec['pages']:
|
|
keys = []
|
|
_walk_field_keys(page['blocks'], keys)
|
|
# re-walk to get the full block dicts, not just keys
|
|
stack = list(page['blocks'])
|
|
while stack:
|
|
b = stack.pop()
|
|
if not isinstance(b, dict):
|
|
continue
|
|
if b.get('kind') == 'field' and b.get('choices') and \
|
|
F.choices_for(b.get('key')):
|
|
assert b['editor'] == 'choice'
|
|
found = True
|
|
if b.get('kind') == 'columns':
|
|
for col in (b.get('columns') or []):
|
|
stack.extend(col)
|
|
if b.get('kind') == 'section':
|
|
stack.extend(b.get('blocks') or [])
|
|
assert found, 'expected at least one field upgraded to a choice editor'
|
|
|
|
|
|
# ---------------------------------------------------------------- issue #22
|
|
|
|
def _field_blocks(spec):
|
|
out = {}
|
|
|
|
def walk(blocks):
|
|
for b in blocks or []:
|
|
if b.get('kind') == 'field' and b.get('key'):
|
|
out[b['key']] = b
|
|
elif b.get('kind') == 'columns':
|
|
for col in b['columns']:
|
|
walk(col)
|
|
elif b.get('kind') == 'section':
|
|
walk(b['blocks'])
|
|
for page in spec['pages']:
|
|
walk(page['blocks'])
|
|
return out
|
|
|
|
|
|
def test_derived_fields_reach_the_page():
|
|
# Issue #22, item 1: the page recomputes Ec / Eci client-side as f'c,
|
|
# f'ci or the unit weight is edited. It needs the relationship, not
|
|
# the number -- `DERIVED_FIELDS` serialised as sources + formula name.
|
|
spec = pa_pagespec.build_pagespec(reads={})
|
|
d = spec['derived']
|
|
assert d['conc_ec_psi'] == {'sources': ['fc_psi', 'unit_weight_pcf'],
|
|
'formula': 'concrete_modulus_psi'}
|
|
assert d['conc_eci_psi'] == {'sources': ['fci_psi', 'unit_weight_pcf'],
|
|
'formula': 'concrete_modulus_psi'}
|
|
assert set(d) == set(F.DERIVED_FIELDS)
|
|
json.dumps(d)
|
|
|
|
|
|
def test_legacy_problem_keys_map_to_their_row_tables():
|
|
# Issue #22, item 11: `validate()` still refuses under the LEGACY key
|
|
# (`supports_ft`), which no page renders any more; the page routes
|
|
# that problem onto the `support` table through this map.
|
|
spec = pa_pagespec.build_pagespec(reads={})
|
|
pt = spec['problem_targets']
|
|
assert pt['supports_ft'] == 'support'
|
|
assert pt['face_lifters'] == 'face_lifter'
|
|
assert pt['edge_lifters'] == 'edge_lifter'
|
|
assert pt['openings_rows'] == 'opening'
|
|
for table in set(pt.values()):
|
|
assert table in spec['tables'], table
|
|
|
|
|
|
def test_sandwich_only_fields_carry_visible_when():
|
|
# Issue #22, item 3: `SANDWICH_ONLY_KEYS` (the field model's own
|
|
# declaration the WPF renderer honoured) and `top_in_form` (owner,
|
|
# 2026-09-07) are shown only while section_type is Sandwich.
|
|
spec = pa_pagespec.build_pagespec(reads={})
|
|
fields = _field_blocks(spec)
|
|
want = {'key': 'section_type', 'in': ['Sandwich']}
|
|
for key in tuple(F.SANDWICH_ONLY_KEYS) + ('top_in_form',):
|
|
if key in fields:
|
|
assert fields[key].get('visible_when') == want, key
|
|
assert fields['top_in_form']['visible_when'] == want
|
|
# the stamp itself, on a field block a page module did not gate: no
|
|
# page renders `fc2_psi` today (2026-09-07), so exercise `_walk`
|
|
# directly rather than pretend it is on the page.
|
|
ctx = {'tables': {}, 'choices': {}, 'geometry': {},
|
|
'field_keys': set(), 'values': {}, 'page_id': 'x'}
|
|
out = pa_pagespec._walk({'kind': 'field', 'key': 'fc2_psi',
|
|
'editor': 'num', 'label': 'x', 'hint': ''}, ctx)
|
|
assert out['visible_when'] == want
|
|
assert 'visible_when' not in fields['fc_psi']
|
|
assert 'visible_when' not in fields['section_type']
|
|
|
|
|
|
def test_controls_the_solve_ignores_are_marked_unused():
|
|
# Issue #22, item 2: every control whose own hint says the solve does
|
|
# not read it, plus `design_spec` (no formula branches on the ACI
|
|
# edition) and the miter flags (a footnote only), is stamped
|
|
# `unused_by_solve` so the page mutes it; a live input is not.
|
|
spec = pa_pagespec.build_pagespec(reads={})
|
|
fields = _field_blocks(spec)
|
|
for key in ('design_spec', 'left_miter', 'right_miter', 'top_in_form',
|
|
'seismic_cs', 'seismic_apply_to'):
|
|
assert fields[key].get('unused_by_solve') is True, key
|
|
for key in ('fc_psi', 'length_ft', 'width_ft', 'thickness_in'):
|
|
assert 'unused_by_solve' not in fields[key], key
|
|
# stage-matrix rows: only `include` and `load_multiplier` drive the
|
|
# solve; every other row's own help text says "Not yet used".
|
|
matrices = [b for page in spec['pages'] for b in page['blocks']
|
|
if b.get('kind') == 'matrix']
|
|
assert matrices
|
|
by_label = dict((r['label'], r) for r in matrices[0]['rows'])
|
|
unused = [lbl for lbl, r in by_label.items() if r.get('unused_by_solve')]
|
|
live = [lbl for lbl, r in by_label.items() if not r.get('unused_by_solve')]
|
|
assert any('Lift type' in lbl for lbl in unused)
|
|
assert any('Sling angle' in lbl for lbl in unused)
|
|
assert len(live) == 2, live
|
|
|
|
|
|
def test_table_empty_state_is_mirrored_into_tables():
|
|
spec = pa_pagespec.build_pagespec(reads={})
|
|
es = spec['tables']['support']['empty_state']
|
|
assert es['basis_keys'][0] == 'supports'
|
|
assert 'empty_state' not in spec['tables']['conc_load']
|