Files
willtools-demo/WillTools.extension/tests/test_pa_handling.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

341 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""Host tests for `vibe.pa_handling` -- the Panel Analysis form's Handling
section, rendered as generic blocks for the WPF page renderer.
Loaded via the sibling `conftest.py` trick, same as `test_panelanalysisform.py`
-- plain CPython, no Revit, no pyRevit. Covers the things the module's own
docstring promises: only the fixed block vocabulary comes out, "does not
apply" and "not filled in" render as different cell shapes, an unticked
stage never drops or re-gates a value the user already typed, every field and
matrix row carries a plain-English label and a hint, a missing
`panelanalysisform` accessor degrades to a `problems` sentence instead of
raising, and `values` is never mutated.
"""
import copy
from vibe import knowledge # noqa: F401 (registers the vibe stub)
from vibe import panelanalysisform as paf
from vibe import pa_handling
# The fixed vocabulary `pa_handling.page()` may emit. Anything else is a bug
# -- the generic renderer draws an unknown `kind` as nothing, so a typo here
# is a silently missing section rather than a crash, which is exactly why
# this is asserted rather than assumed.
_KNOWN_KINDS = set([
'heading', 'columns', 'field', 'matrix', 'table', 'legend', 'kv',
'sentence', 'drawing', 'rule', 'space',
])
def _walk(blocks):
"""Flatten every block, including nested 'columns' children."""
for b in blocks:
yield b
if b.get('kind') == 'columns':
for col in b.get('columns') or []:
for sub in _walk(col):
yield sub
def _matrix_blocks(blocks):
return [b for b in _walk(blocks) if b.get('kind') == 'matrix']
def _field_blocks(blocks):
return [b for b in _walk(blocks) if b.get('kind') == 'field']
# --------------------------------------------------------------- fixtures
def _some_values():
"""A plausible in-progress form: a couple of fields filled, most blank."""
values = {}
for key in paf.stage_field_keys():
values[key] = ''
for f in paf.FIELDS:
values[f['key']] = ''
values['weight_lb'] = '32000'
values[paf.stage_field_key('Stripping', 'include')] = 'yes'
values[paf.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
values[paf.stage_field_key('Primary Erection', 'rigging')] = 'Auto'
values[paf.stage_field_key('Primary Erection', 'sling_angle')] = '60'
# A stage the user switched OFF but already typed a value into --
# exactly the case the "unticked stage keeps values" rule protects.
values[paf.stage_field_key('Secondary Erection', 'include')] = 'no'
values[paf.stage_field_key('Secondary Erection', 'load_multiplier')] = '2.0'
return values
# --------------------------------------------------------------- vocabulary
def test_every_block_kind_is_known():
# `problems` is expected to be non-empty here -- see the module
# docstring's list of gaps (no centroid, no top-edge-lifter field, no
# dunnage field, no lifting-configuration drawing source). Those are
# honest, permanent gaps, not bugs; this test only checks the block
# vocabulary itself.
blocks, _problems = pa_handling.page(_some_values())
for b in _walk(blocks):
assert 'kind' in b
assert b['kind'] in _KNOWN_KINDS, b
def test_empty_values_still_renders_known_kinds_only():
values = dict((k, '') for k in paf.stage_field_keys())
for f in paf.FIELDS:
values[f['key']] = ''
blocks, _problems = pa_handling.page(values)
for b in _walk(blocks):
assert b['kind'] in _KNOWN_KINDS, b
def test_page_never_raises_on_none_values():
# page() must degrade, never raise, even with nothing handed in.
blocks, problems = pa_handling.page(None)
assert isinstance(blocks, list)
assert isinstance(problems, list)
# ---------------------------------------------------- not-applicable vs blank
def test_stage_matrix_shape():
blocks, _problems = pa_handling.page(_some_values())
matrices = _matrix_blocks(blocks)
assert len(matrices) == 1
m = matrices[0]
assert m['columns'] == list(paf.STAGES)
assert len(m['rows']) == len(paf.STAGE_OPTIONS)
for row in m['rows']:
assert len(row['cells']) == len(paf.STAGES)
def test_erection_only_option_is_na_for_stripping_and_transportation():
blocks, _problems = pa_handling.page(_some_values())
m = _matrix_blocks(blocks)[0]
columns = m['columns']
rigging_row = next(r for r in m['rows']
if r['cells'] is not None and
'rigging configuration' in r['label'].lower())
for stage_name in ('Stripping', 'Transportation'):
idx = columns.index(stage_name)
assert rigging_row['cells'][idx] == {'na': True}
for stage_name in ('Primary Erection', 'Secondary Erection'):
idx = columns.index(stage_name)
cell = rigging_row['cells'][idx]
assert cell != {'na': True}
assert cell is not None
assert 'editor' in cell
def test_na_cell_and_unfilled_cell_render_differently():
blocks, _problems = pa_handling.page(_some_values())
m = _matrix_blocks(blocks)[0]
columns = m['columns']
rigging_row = next(r for r in m['rows']
if 'rigging configuration' in r['label'].lower())
load_mult_row = next(r for r in m['rows']
if 'load multiplier' in r['label'].lower())
na_cell = rigging_row['cells'][columns.index('Stripping')]
# Load Multiplier applies to every stage, so Stripping's cell there is
# an ordinary (currently unfilled, from the caller's point of view)
# editable cell -- shape must differ from the NA cell above.
unfilled_cell = load_mult_row['cells'][columns.index('Transportation')]
assert na_cell == {'na': True}
assert 'na' not in unfilled_cell
assert unfilled_cell['editor'] == 'num'
def test_include_row_never_na():
blocks, _problems = pa_handling.page(_some_values())
m = _matrix_blocks(blocks)[0]
include_row = next(r for r in m['rows'] if 'include' in r['label'].lower())
for cell in include_row['cells']:
assert cell != {'na': True}
assert cell['editor'] == 'check'
# ------------------------------------------- unticked stage keeps its values
def test_excluded_stage_cell_still_present_and_editable():
"""Secondary Erection is switched off in `_some_values()` but its
load_multiplier was typed in; the matrix must still offer that cell as
an ordinary editable cell (never NA, never dropped) -- the caller's
values dict is what actually decides required-ness at validate() time,
not this block, and the cell key must still be there for the renderer
to read/write against."""
values = _some_values()
blocks, _problems = pa_handling.page(values)
m = _matrix_blocks(blocks)[0]
columns = m['columns']
load_mult_row = next(r for r in m['rows'] if 'load multiplier' in
r['label'].lower())
cell = load_mult_row['cells'][columns.index('Secondary Erection')]
assert cell != {'na': True}
assert cell is not None
assert cell['key'] == paf.stage_field_key('Secondary Erection',
'load_multiplier')
# And the value the user typed is still sitting in `values`, untouched.
assert values[cell['key']] == '2.0'
def test_values_dict_is_never_mutated():
values = _some_values()
before = copy.deepcopy(values)
pa_handling.page(values)
assert values == before
# --------------------------------------------------------- labels and hints
def test_every_field_block_has_label_and_hint():
blocks, _problems = pa_handling.page(_some_values())
for f in _field_blocks(blocks):
assert f.get('label'), f
assert f.get('hint'), f
def test_every_matrix_row_has_label_and_hint():
blocks, _problems = pa_handling.page(_some_values())
m = _matrix_blocks(blocks)[0]
for row in m['rows']:
assert row.get('label'), row
# 'include' is the only row Eriksson gives a very short help line
# to, but every STAGE_OPTIONS row does carry one -- assert it rides
# through rather than getting dropped in translation.
assert row.get('hint'), row
def test_symbol_rides_beside_label_not_instead_of_it():
blocks, _problems = pa_handling.page(_some_values())
m = _matrix_blocks(blocks)[0]
load_mult_row = next(r for r in m['rows'] if 'load multiplier' in
r['label'].lower())
# The symbol (x self-weight) must be ADDITIONAL to the plain words, per
# the em-dash join `_row_label` uses -- never a bare symbol standing in
# for the label.
assert load_mult_row['label'].lower().startswith('load multiplier')
assert u'—' in load_mult_row['label'] # em dash
# --------------------------------------------------- missing-accessor degrade
def test_missing_stage_accessor_degrades_to_problem_not_exception():
# Build the values dict BEFORE removing STAGES -- `stage_field_keys()`
# itself reads the module-global `STAGES`, so this must not depend on
# the attribute we are about to take away.
values = _some_values()
had_stages = hasattr(paf, 'STAGES')
saved = getattr(paf, 'STAGES', None)
try:
if had_stages:
delattr(paf, 'STAGES')
blocks, problems = pa_handling.page(values)
assert isinstance(blocks, list)
assert any('STAGES' in p for p in problems)
# The rest of the page still renders around the hole.
assert any(b.get('kind') == 'heading' and b.get('text') == 'Handling'
for b in blocks)
finally:
if had_stages:
paf.STAGES = saved
def test_missing_stage_field_key_degrades_to_problem():
values = _some_values()
had = hasattr(paf, 'stage_field_key')
saved = getattr(paf, 'stage_field_key', None)
try:
if had:
delattr(paf, 'stage_field_key')
blocks, problems = pa_handling.page(values)
assert any('stage_field_key' in p for p in problems)
assert not _matrix_blocks(blocks)
finally:
if had:
paf.stage_field_key = saved
def test_missing_weight_field_degrades_to_problem():
had = 'FIELDS' in vars(paf)
saved = paf.FIELDS
try:
paf.FIELDS = [f for f in paf.FIELDS if f['key'] != 'weight_lb']
blocks, problems = pa_handling.page(_some_values())
assert any('weight_lb' in p for p in problems)
# kv block still renders (weight row falls back to the "not
# entered" sentence), it does not vanish or raise.
kvs = [b for b in _walk(blocks) if b['kind'] == 'kv']
assert kvs
finally:
if had:
paf.FIELDS = saved
def test_no_top_edge_lifter_or_dunnage_field_is_a_known_gap():
"""These are genuinely absent from panelanalysisform today (only
face_lifters / edge_lifters exist) -- confirm the page says so rather
than inventing a field or silently going blank."""
blocks, problems = pa_handling.page(_some_values())
assert any('top edge lifters' in p for p in problems)
assert any('dunnage' in p for p in problems)
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
assert 'Design contains no top edge lifters.' in sentences
assert 'Design contains no dunnage entered.' in sentences
# --------------------------------------------------------------- real keys
def test_matrix_cell_keys_match_panelanalysisform_stage_field_key():
blocks, _problems = pa_handling.page(_some_values())
m = _matrix_blocks(blocks)[0]
for opt, row in zip(paf.STAGE_OPTIONS, m['rows']):
opt_key = opt[0]
for stage, cell in zip(m['columns'], row['cells']):
if cell is None or cell.get('na'):
continue
assert cell['key'] == paf.stage_field_key(stage, opt_key)
def test_load_factor_and_lifter_fields_use_real_keys():
blocks, _problems = pa_handling.page(_some_values())
keys = set(f['key'] for f in _field_blocks(blocks))
assert 'weight_lb' in keys
assert 'self_weight_factor' in keys
assert 'face_lifters' in keys
assert 'edge_lifters' in keys
def test_drawing_blocks_name_real_sibling_sources_only():
blocks, _problems = pa_handling.page(_some_values())
drawings = [b for b in _walk(blocks) if b['kind'] == 'drawing']
sources = set(d['source'] for d in drawings)
# 'plan' and 'weight_diagram' are the two panelsketch entry points the
# sibling agent added; pa_handling must never invent a third.
assert sources <= set(['plan', 'weight_diagram'])
assert 'plan' in sources
for d in drawings:
assert d.get('caption')
assert isinstance(d.get('w'), int)
assert isinstance(d.get('h'), int)
def test_legend_groups_match_documented_vocabulary():
blocks, _problems = pa_handling.page(_some_values())
legends = [b for b in _walk(blocks) if b['kind'] == 'legend']
assert len(legends) == 1
names = [g[0] for g in legends[0]['groups']]
assert names == ['Shapes', 'Fills']
def test_import_failure_degrades_cleanly(monkeypatch):
import vibe.pa_handling as mod
def _boom():
return None, 'vibe.panelanalysisform could not be imported (boom)'
monkeypatch.setattr(mod, '_import_paf', _boom)
blocks, problems = mod.page(_some_values())
assert problems == ['vibe.panelanalysisform could not be imported (boom)']
assert any(b['kind'] == 'heading' for b in blocks)