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.
896 lines
39 KiB
Python
896 lines
39 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', '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']
|