# -*- 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 `