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