Files
willtools-demo/WillTools.extension/tests/test_pa_pagespec.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

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']