WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,895 @@
# -*- 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', 'editable_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'
# ------------------------------------------------ 2026-09-04 dropdown pass
#
# Owner: "很多东西应该是选项". A matrix cell is built by `make_editor` directly
# (`_render_matrix` bypasses the field renderer's `FIELD_CHOICES` fallback),
# so `_stage_options_editor` has to ask for `choice` itself whenever the row
# carries a fixed vocabulary -- otherwise these four rows would stay free
# text forever no matter what `FIELD_CHOICES` says.
def _row(blocks, label_substr):
m = _matrix_blocks(blocks)[0]
return next(r for r in m['rows'] if label_substr in r['label'].lower())
def test_lift_type_stage_cells_are_choice_editors():
blocks, _problems = pa_handling.page(_some_values())
row = _row(blocks, 'lift type')
for cell in row['cells']:
assert cell['editor'] == 'choice'
assert tuple(cell['choices']) == ('Static Lift', 'Rolling Block')
def test_concrete_strength_stage_cells_are_choice_editors():
blocks, _problems = pa_handling.page(_some_values())
row = _row(blocks, 'concrete strength')
for cell in row['cells']:
assert cell['editor'] == 'choice'
assert tuple(cell['choices']) == ('Initial', 'In-Place')
def test_stress_limits_stage_cells_are_choice_editors():
blocks, _problems = pa_handling.page(_some_values())
row = _row(blocks, 'stress limits')
for cell in row['cells']:
assert cell['editor'] == 'choice'
assert tuple(cell['choices']) == ('Handling', 'In-Place')
def test_rigging_stage_cells_are_choice_editors_with_expanded_vocabulary():
"""`rigging`'s choices used to be just `('Auto',)`; the owner's ruling
(2026-09-04) widens the offered vocabulary to the real point-count
configurations, still never enforced (the box stays editable) and never
a default."""
blocks, _problems = pa_handling.page(_some_values())
row = _row(blocks, 'rigging configuration')
for cell in row['cells']:
if cell == {'na': True}:
continue
assert cell['editor'] == 'choice'
assert tuple(cell['choices']) == (
'Auto', 'Two-point', 'Four-point', 'Eight-point')
def test_stage_options_editor_falls_back_to_text_with_no_choices():
assert pa_handling._stage_options_editor('text', None) == 'text'
assert pa_handling._stage_options_editor('text', []) == 'text'
assert pa_handling._stage_options_editor('text', ('a', 'b')) == 'choice'
assert pa_handling._stage_options_editor('bool', ('a', 'b')) == 'check'
assert pa_handling._stage_options_editor('num', ('a', 'b')) == 'num'
# ------------------------------------------- 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 as real FIELDS) -- confirm the page
says so rather than inventing a field or silently going blank. The
top-edge-lifters sentence is QUIET (2026-09-04 ruling) -- routed to the
collapsed Notes panel. The dunnage sentence is now VISIBLE, not quiet
(2026-09-05 ruling): a bare heading with only a quiet sentence under it
read as an empty section on a live page, and this module cannot tell
"the plant has no dunnage concept" apart from "this panel just does not
use it" -- see `pa_handling._lifter_section_blocks`'s own comment."""
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 for b in _walk(blocks) if b['kind'] == 'sentence']
texts = [b['text'] for b in sentences]
assert 'Design contains no top edge lifters.' in texts
dunnage_text = next(t for t in texts if 'dunnage' in t.lower()
and 'top edge' not in t.lower())
assert 'no dunnage support in the drawing above' in dunnage_text
for b in sentences:
if b['text'] == 'Design contains no top edge lifters.':
assert b.get('quiet') is True, b
if b['text'] == dunnage_text:
assert b.get('quiet') is not True, b
def test_dunnage_table_shown_when_positions_found_via_list():
"""2026-09-04 THIRD ruling: 'Dunnage' now prints an actual positions
TABLE when there is data, instead of a quiet sentence -- a quiet
sentence is routed out of inline view entirely, which is exactly why
the heading used to render as empty on a live page."""
values = _some_values()
values['dunnage_positions_ft'] = [18.0, 6.0]
blocks, _problems = pa_handling.page(values)
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
dunnage_table = next(t for t in tables
if t['headers'] == ['Dunnage Position'])
# Feet-inches now, never decimal feet (owner ruling).
assert dunnage_table['rows'] == [[u'6\'-0"'], [u'18\'-0"']]
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
assert not any('No dunnage positions entered' in t for t 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_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
def test_lifter_row_tables_present_not_the_old_text_fields():
"""Owner ruling, 2026-09-05: `face_lifters` / `edge_lifters` free text
is replaced on the page by two editable row tables -- the old FIELDS
entries stay declared (a fallback for an old saved values file) but are
no longer rendered as their own `field` block."""
blocks, _problems = pa_handling.page(_some_values())
keys = set(f['key'] for f in _field_blocks(blocks))
assert 'face_lifters' not in keys
assert 'edge_lifters' not in keys
tables = [b for b in _walk(blocks) if b['kind'] == 'editable_table']
table_names = set(t['table'] for t in tables)
assert 'face_lifter' in table_names
assert 'edge_lifter' in table_names
def test_drawing_blocks_name_real_sibling_sources_only():
"""2026-09-04 (second ruling): the old 'weight_diagram' drawing under
Lifting Hardware is gone, replaced by a table (see
`test_lifting_hardware_table_...` below) -- 'lifter_dunnage' is now the
ONLY drawing this page may ever emit."""
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)
assert sources == set(['lifter_dunnage'])
for d in drawings:
assert d.get('caption')
def test_lifter_dunnage_drawing_block_names_its_module_and_is_full_width():
"""2026-09-04 ruling #3: the LIFTER AND DUNNAGE DETAIL drawing moved off
`panelsketch.plan` onto `vibe.pa_sketch_handling.lifter_dunnage`, and
carries `full_width` + a computed `aspect` instead of a fixed `w`/`h`."""
blocks, _problems = pa_handling.page(_some_values())
drawings = [b for b in _walk(blocks) if b['kind'] == 'drawing']
d = next(b for b in drawings if b['source'] == 'lifter_dunnage')
assert d['module'] == 'pa_sketch_handling'
assert d['full_width'] is True
assert 0.15 <= d['aspect'] <= 0.5
assert set(d['depends_on']) >= set(['face_lifters', 'edge_lifters',
'openings_rows'])
# Lifting Hardware no longer carries a drawing at all (2026-09-04
# second ruling) -- 'lifter_dunnage' is the only one on the page.
assert len(drawings) == 1
def test_lifter_dunnage_drawing_aspect_matches_panel_proportions():
values = _some_values()
values['length_ft'] = '40'
values['width_ft'] = '8'
blocks, _problems = pa_handling.page(values)
d = next(b for b in _walk(blocks) if b.get('kind') == 'drawing'
and b.get('source') == 'lifter_dunnage')
assert abs(d['aspect'] - 0.2) < 1e-9 # 8/40, inside [0.15, 0.5]
def test_legend_groups_match_documented_vocabulary():
"""2026-09-04 ruling #3 replaced the drawing this legend describes --
the old 'Fills' group (panelsketch.plan's per-stage marker colours) has
no data behind it any more and is gone, not kept as an unused key."""
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']
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)
# Even the failure-path sentence is quiet -- see ruling #1.
sentences = [b for b in blocks if b['kind'] == 'sentence']
assert sentences and all(b.get('quiet') is True for b in sentences)
# ---------------------------------------------- 2026-09-04 "小灰字" pass (#1)
#
# Every helper sentence pa_handling builds must carry 'quiet': True -- the
# renderer is expected to route those into the same collapsed Notes panel
# `problems` already lands in, so nothing but headings / fields / tables /
# the matrix / the drawing / the kv block / the legend shows up inline.
def test_every_sentence_block_is_quiet():
# 2026-09-05: the ONE deliberate exception is the Dunnage fallback --
# it must be VISIBLE (not routed to the collapsed Notes panel), see
# `test_no_top_edge_lifter_or_dunnage_field_is_a_known_gap`.
blocks, _problems = pa_handling.page(_some_values())
sentences = [b for b in _walk(blocks) if b['kind'] == 'sentence']
assert sentences, 'expected at least one sentence block to exist'
loud = [b for b in sentences if b.get('quiet') is not True]
assert len(loud) == 1
assert 'no dunnage support in the drawing above' in loud[0]['text']
def test_every_sentence_block_is_quiet_on_empty_values():
values = dict((k, '') for k in paf.stage_field_keys())
for f in paf.FIELDS:
values[f['key']] = ''
blocks, _problems = pa_handling.page(values)
sentences = [b for b in _walk(blocks) if b['kind'] == 'sentence']
loud = [b for b in sentences if b.get('quiet') is not True]
assert len(loud) <= 1
for b in loud:
assert 'no dunnage support in the drawing above' in b['text']
def test_no_non_sentence_block_carries_the_quiet_key():
"""'quiet' is a 'sentence'-only hint -- confirm it never leaks onto any
other block kind, which would be ambiguous for whatever renders it."""
blocks, _problems = pa_handling.page(_some_values())
for b in _walk(blocks):
if b['kind'] != 'sentence':
assert 'quiet' not in b, b
# --------------------------------------------- 2026-09-04 centroid pass (#2)
def _values_with_geometry(**over):
values = _some_values()
values['length_ft'] = '24'
values['width_ft'] = '8'
values['thickness_in'] = '8'
values.update(over)
return values
def _centroid_kv_rows(blocks):
kvs = [b for b in _walk(blocks) if b['kind'] == 'kv']
assert len(kvs) == 1
return dict((label, value) for label, value in kvs[0]['rows'])
def test_centroid_not_computed_when_geometry_missing():
blocks, problems = pa_handling.page(_some_values())
rows = _centroid_kv_rows(blocks)
assert rows['Centroid, X (from start)'] == 'not entered yet'
assert rows['Centroid, Y (from bottom)'] == 'not entered yet'
assert rows['Centroid, Z (from left)'] == 'not entered yet'
assert any('centroid' in p.lower() for p in problems)
def test_centroid_computed_for_solid_rectangle():
# Feet-inches now, never decimal feet (owner ruling).
blocks, _problems = pa_handling.page(_values_with_geometry())
rows = _centroid_kv_rows(blocks)
assert rows['Centroid, X (from start)'].startswith(u'12\'-0"')
assert 'solid rectangle' in rows['Centroid, X (from start)']
assert rows['Centroid, Y (from bottom)'].startswith(u'4"')
assert rows['Centroid, Z (from left)'].startswith(u'4\'-0"')
def test_centroid_corrected_for_an_opening():
values = _values_with_geometry()
values['openings_rows'] = [
{'type': 'Opening', 'pos_along': 0.0, 'pos_along_from': 'start',
'length': 24.0, 'pos_vert': 0.0, 'height': 8.0},
]
blocks, _problems = pa_handling.page(values)
rows = _centroid_kv_rows(blocks)
# The opening covers the whole panel -- shown at the geometric center,
# not a divide-by-zero or a fabricated number. Feet-inches, never
# decimal feet (owner ruling).
assert rows['Centroid, X (from start)'].startswith(u'12\'-0"')
assert 'geometric center' in rows['Centroid, X (from start)']
def test_centroid_x_shifts_toward_the_solid_side_of_an_opening():
values = _values_with_geometry()
values['openings_rows'] = [
{'type': 'Opening', 'pos_along': 0.0, 'pos_along_from': 'start',
'length': 4.0, 'pos_vert': 0.0, 'height': 8.0},
]
blocks, _problems = pa_handling.page(values)
rows = _centroid_kv_rows(blocks)
x_text = rows['Centroid, X (from start)']
assert '1 opening(s) deducted' in x_text
# 24x8 panel, a 4 ft opening at the start: net centroid = 14.0 ft --
# pulled away (right, in feet-inches) from the panel's own 12'-0" mid.
assert x_text.startswith(u'14\'-0"')
def test_centroid_y_from_sandwich_wythes():
values = _values_with_geometry(section_type='sandwich',
wythe_top_in='2', insulation_in='2',
wythe_bottom_in='4')
blocks, _problems = pa_handling.page(values)
rows = _centroid_kv_rows(blocks)
# (2+2+4)/2 = 4 in, same as the solid case above -- see
# pa_sketch_handling.centroid()'s docstring for why this is not
# mass-weighted by wythe. Feet-inches, never decimal feet.
assert rows['Centroid, Y (from bottom)'].startswith(u'4"')
assert 'sandwich wythes' in rows['Centroid, Y (from bottom)']
def test_import_psh_failure_degrades_centroid_not_whole_page(monkeypatch):
import vibe.pa_handling as mod
monkeypatch.setattr(mod, '_import_psh',
lambda: (None, 'vibe.pa_sketch_handling boom'))
blocks, problems = mod.page(_values_with_geometry())
assert 'vibe.pa_sketch_handling boom' in problems
rows = _centroid_kv_rows(blocks)
assert rows['Centroid, X (from start)'] == 'not entered yet'
# The rest of the page still renders.
assert _matrix_blocks(blocks)
# --------------------------------------------------- lifter table (#3, sib)
def test_lifter_table_groups_identical_x_into_one_row():
values = _values_with_geometry(
face_lifters='2.00 @ 0.25\n2.00 @ 0.75\n10.00 @ 0.50',
edge_lifters='')
blocks, _problems = pa_handling.page(values)
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
assert len(tables) == 1
t = tables[0]
assert t['headers'] == ['Type', 'Qty', 'Longitudinal Position',
'Transverse Position(s)']
# Feet-inches now, never decimal feet (owner ruling).
row_at_2 = next(r for r in t['rows'] if r[2] == u'2\'-0"')
assert row_at_2[0] == 'Face'
assert row_at_2[1] == '2'
assert u'2\'-0"' in row_at_2[3] and u'6\'-0"' in row_at_2[3]
row_at_10 = next(r for r in t['rows'] if r[2] == u'10\'-0"')
assert row_at_10[1] == '1'
def test_lifter_table_absent_when_nothing_typed():
blocks, _problems = pa_handling.page(_some_values())
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
assert tables == []
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
assert 'No face or side edge lifter positions entered yet.' in sentences
def test_lifter_row_tables_stay_visible_and_editable_beside_the_summary():
"""Owner, 2026-09-04/09-05: the summary table is not a replacement for
editing -- the `face_lifter`/`edge_lifter` row tables (which replaced
the old free-text fields) must still be on the page."""
values = _values_with_geometry(face_lifters='2.00 @ 0.25')
blocks, _problems = pa_handling.page(values)
tables = [b for b in _walk(blocks) if b['kind'] == 'editable_table']
table_names = set(t['table'] for t in tables)
assert 'face_lifter' in table_names
assert 'edge_lifter' in table_names
# --------------------------------------- 2026-09-04 second ruling (headless)
#
# A headless render of the Handling tab against a real wall (37.58 x 8.375
# ft, 8 face lifters, 4 edge lifters all at x == length_ft) found five
# things wrong -- this section is the regression coverage for each.
def _wall_values(**over):
values = _values_with_geometry(length_ft='37.58', width_ft='8.375',
face_lifters=('3.50 @ 0.18\n14.00 @ 0.18\n'
'23.58 @ 0.18\n34.08 @ 0.18\n'
'3.50 @ 0.82\n14.00 @ 0.82\n'
'23.58 @ 0.82\n34.08 @ 0.82'),
edge_lifters=('37.58 @ 0.15\n37.58 @ 0.36\n'
'37.58 @ 0.64\n37.58 @ 0.85'))
values.update(over)
return values
def _table_by_headers(blocks, first_header):
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
return next(t for t in tables if t['headers'][0] == first_header)
def _table_under_heading(blocks, heading_text):
"""The first `table` block appearing after the named `heading` block
and before the next one, or `None` -- lets a test tell apart the TWO
'Type'-headed tables the 2026-09-04 THIRD ruling introduced (the main
Face / Side Edge one, and the separate Top Edge Lifters one) by which
heading they actually sit under, rather than by grabbing whichever one
happens to come first."""
started = False
for b in _walk(blocks):
if b.get('kind') == 'heading' and b.get('text') == heading_text:
started = True
continue
if started:
if b.get('kind') == 'heading':
return None
if b.get('kind') == 'table':
return b
return None
# ---- item 1: edge lifter table classification (Face / Side / Top Edge)
def test_lifter_table_groups_end_edge_lifters_under_top_edge_heading():
"""2026-09-04 THIRD ruling: the main 'Face / Side Edge Lifters' table
holds Face + Side Edge rows ONLY -- Top Edge rows moved to their own
table under the separate 'Top Edge Lifters' heading, matching
Eriksson's own three-way split and fixing the live-render bug where
that heading rendered with nothing visible under it."""
blocks, _problems = pa_handling.page(_wall_values())
main = _table_under_heading(blocks, 'Face / Side Edge Lifters')
top = _table_under_heading(blocks, 'Top Edge Lifters')
assert main is not None and top is not None
main_types = set(r[0] for r in main['rows'])
assert 'Top Edge' not in main_types
assert main_types == set(['Face'])
face_rows = [r for r in main['rows'] if r[0] == 'Face']
assert len(face_rows) == 4 # four distinct face x's
assert all(r[1] == '2' for r in face_rows) # each x has two fracs
top_types = set(r[0] for r in top['rows'])
assert top_types == set(['Top Edge'])
top_row = next(r for r in top['rows'] if r[0] == 'Top Edge')
assert top_row[1] == '4' # all four grouped by x
assert top_row[2] == u'37\'-7"' # feet-inches, not '37.58 ft'
def test_lifter_table_prefers_explicit_top_and_side_edge_fields():
values = _wall_values(edge_lifters='garbage that will not parse')
values['top_edge_lifters'] = '37.58 @ 0.15\n37.58 @ 0.85'
values['side_edge_lifters'] = '18.00 @ 0.05'
blocks, problems = pa_handling.page(values)
main = _table_under_heading(blocks, 'Face / Side Edge Lifters')
top = _table_under_heading(blocks, 'Top Edge Lifters')
assert set(r[0] for r in main['rows']) == set(['Face', 'Side Edge'])
assert set(r[0] for r in top['rows']) == set(['Top Edge'])
# the garbage edge_lifters text must never have been parsed.
assert not any('expected' in p for p in problems)
def test_lifter_table_side_edge_lifters_still_grouped_correctly():
values = _values_with_geometry(face_lifters='',
edge_lifters='2.00 @ 0.05\n10.00 @ 0.95')
blocks, _problems = pa_handling.page(values)
main = _table_under_heading(blocks, 'Face / Side Edge Lifters')
assert set(r[0] for r in main['rows']) == set(['Side Edge'])
assert len(main['rows']) == 2
assert _table_under_heading(blocks, 'Top Edge Lifters') is None
# ---- item 4: Lifting Hardware table off `lifters_basis`, old drawing gone
def test_lifting_hardware_table_from_lifters_basis():
values = _wall_values()
values['lifters_basis'] = ('8 face lifters (FAMILY_A) and 4 edge '
'lifters (FAMILY_B) read off the assembly.')
blocks, _problems = pa_handling.page(values)
drawings = [b for b in _walk(blocks) if b['kind'] == 'drawing']
assert not any(d['source'] == 'weight_diagram' for d in drawings)
captions = [d.get('caption') for d in drawings]
assert 'PANEL WEIGHT DISTRIBUTION' not in captions
t = _table_by_headers(blocks, 'Family')
# Tension/Shear columns added 2026-09-05 (vibe.knowledge.
# lifter_capacity); `know` is not passed here, so both print '-'.
assert t['headers'] == ['Family', 'Kind', 'Qty', 'Tension', 'Shear']
rows_by_family = dict((r[0], r) for r in t['rows'])
assert rows_by_family['FAMILY_A'][1] == 'Face lifter'
assert rows_by_family['FAMILY_A'][2] == '8'
assert rows_by_family['FAMILY_A'][3] == '-'
assert rows_by_family['FAMILY_B'][1] == 'Edge lifter'
assert rows_by_family['FAMILY_B'][2] == '4'
def test_lifting_hardware_table_reads_fallback_when_values_blank():
blocks, _problems = pa_handling.page(
_wall_values(), reads={'lifters_basis': '3 face lifters (FAMILY_C) '
'read off the assembly.'})
t = _table_by_headers(blocks, 'Family')
assert t['rows'] == [['FAMILY_C', 'Face lifter', '3', '-', '-']]
def test_lifting_hardware_table_absent_without_a_basis_sentence():
blocks, problems = pa_handling.page(_wall_values())
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
assert not any(t['headers'][0] == 'Family' for t in tables)
assert any('lifters_basis' in p for p in problems)
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
assert ('No lifter family/quantity basis available yet; the Lifting '
'Hardware table has nothing to show.') in sentences
# ---- item 5: legend moved directly under the drawing, and smaller
def test_legend_sits_directly_under_the_drawing():
blocks, _problems = pa_handling.page(_wall_values())
drawing_idx = next(i for i, b in enumerate(blocks)
if b.get('kind') == 'drawing')
assert blocks[drawing_idx + 1]['kind'] == 'legend'
def test_legend_is_compact():
blocks, _problems = pa_handling.page(_wall_values())
legend = next(b for b in blocks if b['kind'] == 'legend')
assert legend.get('compact') is True
# ---- item 6 (from the module docstring's own centroid pass, extended):
# weight_centroid_ft preferred over the computed rectangle arithmetic
def test_weight_centroid_ft_axes_mapped_correctly_not_swapped():
"""2026-09-04 THIRD ruling, regression fixture: a live render printed
"Centroid, Y (from bottom) 4.187 ft" and "Centroid, Z (from left)
0.333 ft" off this EXACT dict -- backwards, because `weight_centroid_ft`
uses the MODEL's own axes ({'x': along length, 'y': across width, 'z':
through thickness}), not the kv rows' own X-from-start / Y-from-bottom
/ Z-from-left labelling. Y (from bottom) is the THROUGH-THICKNESS
figure (0.333 ft, `dict['z']`); Z (from left) is the ACROSS-WIDTH
figure (4.187 ft, `dict['y']`)."""
# Feet-inches now, never decimal feet (owner ruling).
values = _wall_values()
values['weight_centroid_ft'] = {'x': 18.79, 'y': 4.187, 'z': 0.333}
blocks, _problems = pa_handling.page(values)
rows = _centroid_kv_rows(blocks)
assert rows['Centroid, X (from start)'] == \
u'18\'-9 1/2" (concrete solid centroid)'
assert rows['Centroid, Y (from bottom)'] == \
u'4" (concrete solid centroid)'
assert rows['Centroid, Z (from left)'] == \
u'4\'-2 1/4" (concrete solid centroid)'
def test_weight_centroid_ft_read_from_reads_fallback():
blocks, _problems = pa_handling.page(
_wall_values(), reads={'weight_centroid_ft':
{'x': 1.0, 'y': 2.0, 'z': 3.0}})
rows = _centroid_kv_rows(blocks)
assert rows['Centroid, X (from start)'].startswith(u'1\'-0"')
assert 'concrete solid centroid' in rows['Centroid, X (from start)']
# dict.z (through thickness) -> Y (from bottom); dict.y (across width)
# -> Z (from left) -- the fallback path must not skip the mapping fix.
assert rows['Centroid, Y (from bottom)'].startswith(u'3\'-0"')
assert rows['Centroid, Z (from left)'].startswith(u'2\'-0"')
def test_weight_centroid_ft_partial_dict_falls_back_to_computed():
values = _wall_values()
values['weight_centroid_ft'] = {'x': 18.79, 'y': None} # incomplete
blocks, _problems = pa_handling.page(values)
rows = _centroid_kv_rows(blocks)
# falls all the way back to the geometry-based computation -- never a
# half-measured, half-computed centroid.
assert 'concrete solid centroid' not in rows['Centroid, X (from start)']
assert rows['Centroid, X (from start)'].startswith(u'18\'-9 1/2"')
def test_lifter_table_unparseable_text_falls_back_to_field_only():
# `face_lifters` text is only a fallback now (no `face_lifter` table
# rows present), and the row table itself is always on the page
# regardless -- the summary table is what disappears when nothing
# parses.
values = _values_with_geometry(face_lifters='not a lifter line')
blocks, problems = pa_handling.page(values)
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
assert tables == []
assert any('could not be parsed' in p for p in problems)
editable_tables = [b for b in _walk(blocks)
if b['kind'] == 'editable_table']
assert 'face_lifter' in set(t['table'] for t in editable_tables)
# ---------------------------------------------------------------- issue #22
def test_lifter_drawing_redraws_off_the_lifter_row_tables():
# Issue #22, item 7 (2026-09-07): the drawing listed only the LEGACY
# free-text keys (`face_lifters` / `edge_lifters`), so a `face_lifter.
# 3.x_ft` row edit never matched `depends_on` and the picture went
# stale the moment the user corrected a mis-read lifter. The page
# matches a row key on its `<table>.` prefix, so the TABLE names must
# be listed -- the same way the free body lists `restraint`/`support`.
values = _values_with_geometry()
blocks, _problems = pa_handling.page(values)
drawings = [b for b in _walk(blocks) if b['kind'] == 'drawing'
and b['source'] == 'lifter_dunnage']
assert len(drawings) == 1
deps = drawings[0]['depends_on']
for key in ('face_lifter', 'edge_lifter', 'opening',
'length_ft', 'width_ft', 'dunnage'):
assert key in deps, key
# the legacy fallback keys are still read, so still listed
assert 'face_lifters' in deps and 'edge_lifters' in deps
def test_lifter_tables_carry_an_empty_state_naming_the_read_basis():
# Issue #22, items 5/11: an empty table says WHY -- the model read's
# own `lifters_basis` sentence when there is one, else this text.
values = _values_with_geometry()
blocks, _problems = pa_handling.page(values)
tables = dict((b['table'], b) for b in _walk(blocks)
if b['kind'] == 'editable_table')
for name in ('face_lifter', 'edge_lifter'):
es = tables[name]['empty_state']
assert es['basis_keys'] == ['lifters']
assert 'could-not-judge' in es['text']