# -*- coding: utf-8 -*- """Host tests for `vibe.pa_sketch_handling` -- the Panel Analysis Handling tab's "Lifter and Dunnage Detail" plan drawing and the panel weight centroid arithmetic behind it. Loaded the way every sibling `pa_*` / `panelsketch` test is -- `conftest.py` registers a bare `vibe` package pointed at `lib/vibe`, so this imports on plain host CPython, no Revit, no `pyrevit`, no WPF. Covers what the module's own docstring promises: the length always fills the drawing's full width (never letterboxed to whichever axis is tighter), one drawn marker per lifter (circle for face, triangle for edge, on the edge its fraction is closer to), dimension text for both chains, the centroid arithmetic with and without openings and for a Sandwich section, and the missing-length/width hard stop -- never a raised exception, `values` never mutated. """ import copy from vibe import knowledge # noqa: F401 (registers the vibe stub) from vibe import pa_sketch_handling as S def _values(**over): base = { 'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'mark': 'PNL101', 'design_number': 'PNL1', } base.update(over) return base def _within_box(primitives, width_px, height_px, tol=1e-6): for p in primitives: if p['kind'] == 'rect': assert -tol <= p['x'] <= width_px + tol, p assert -tol <= p['y'] <= height_px + tol, p assert -tol <= p['x'] + p['w'] <= width_px + tol, p assert -tol <= p['y'] + p['h'] <= height_px + tol, p elif p['kind'] == 'line': for kx, ky in (('x1', 'y1'), ('x2', 'y2')): assert -tol <= p[kx] <= width_px + tol, p assert -tol <= p[ky] <= height_px + tol, p else: if 'x' in p: assert -tol <= p['x'] <= width_px + tol, p if 'y' in p: assert -tol <= p['y'] <= height_px + tol, p def _panel_rect(primitives): rects = [p for p in primitives if p['kind'] == 'rect' and p['role'] == 'panel-plan'] assert len(rects) == 1 return rects[0] # ------------------------------------------------------- lifter_dunnage() # --------------------------------------------------- length-fills-the-width def test_length_always_fills_the_drawing_width_even_for_a_squat_panel(): """A panel much WIDER than it is long used to letterbox (panelsketch.plan fits whichever axis is tighter) and end up far short of the column width. The 2026-09-04 ruling is explicit: the length always fills the window's full width. A 6 x 20 ft panel drawn at 400x400 must still stretch its length across (nearly) the full available width.""" primitives, problems = S.lifter_dunnage( _values(length_ft='6', width_ft='20'), 400, 400) # No lifters / dunnage typed in this fixture -- that is reported (see # the "no lifters" / "no dunnage" tests below), but it is not a hard # stop: the panel itself still draws, scale-true, full width. rect = _panel_rect(primitives) margin = S._margin(400, 400) left_margin = S._left_margin(400, 400) avail_w = 400 - left_margin - margin assert abs(rect['w'] - avail_w) < 1e-6 def test_length_fills_width_for_a_normal_long_panel_too(): primitives, _problems = S.lifter_dunnage(_values(), 380, 190) rect = _panel_rect(primitives) margin = S._margin(380, 190) left_margin = S._left_margin(380, 190) avail_w = 380 - left_margin - margin assert abs(rect['w'] - avail_w) < 1e-6 # scale-true: draw_h / draw_w == width_ft / length_ft assert abs((rect['h'] / rect['w']) - (8.0 / 24.0)) < 1e-6 def test_within_box_for_a_squat_panel(): primitives, _problems = S.lifter_dunnage( _values(length_ft='6', width_ft='20'), 400, 300) _within_box(primitives, 400, 300) # ------------------------------------------------------------- hard stop def test_missing_length_is_a_hard_stop(): primitives, problems = S.lifter_dunnage(_values(length_ft=''), 380, 190) assert primitives == [] assert any('length' in p.lower() for p in problems) def test_missing_width_is_a_hard_stop(): primitives, problems = S.lifter_dunnage(_values(width_ft='0'), 380, 190) assert primitives == [] assert any('width' in p.lower() for p in problems) def test_none_values_never_raises(): primitives, problems = S.lifter_dunnage(None, 380, 190) assert primitives == [] assert problems # ----------------------------------------------------------- lifter markers def test_one_dot_per_face_lifter(): values = _values(face_lifters='2.00 @ 0.25\n10.00 @ 0.75', edge_lifters='') primitives, problems = S.lifter_dunnage(values, 380, 190) dots = [p for p in primitives if p['kind'] == 'dot' and p['role'] == 'face-lifter'] assert len(dots) == 2 _within_box(primitives, 380, 190) def test_one_triangle_per_edge_lifter(): values = _values(face_lifters='', edge_lifters='2.00 @ 0.05\n10.00 @ 0.95') primitives, problems = S.lifter_dunnage(values, 380, 190) tris = [p for p in primitives if p['kind'] == 'support' and p['role'] == 'edge-lifter'] assert len(tris) == 2 _within_box(primitives, 380, 190) def test_edge_lifter_drawn_on_the_edge_its_fraction_is_closer_to(): values = _values(face_lifters='', edge_lifters='2.00 @ 0.05\n2.00 @ 0.95') primitives, _problems = S.lifter_dunnage(values, 380, 190) tris = [p for p in primitives if p['kind'] == 'support' and p['role'] == 'edge-lifter'] edges = sorted(t['edge'] for t in tris) assert edges == ['bottom', 'top'] bottom = next(t for t in tris if t['edge'] == 'bottom') top = next(t for t in tris if t['edge'] == 'top') # bottom (frac near 0) sits at the panel's bottom edge (larger pixel y, # since y increases downward); top sits at the panel's top edge. assert bottom['y'] > top['y'] def test_top_end_edge_lifters_classified_and_drawn_on_the_right_edge(): """Four edge lifters all at x == length_ft (the real render bug this is regression coverage for): each must classify as an END/top lifter -- a triangle pinned to the panel's RIGHT edge (px ~= ox + draw_w), NOT collapsed onto the panel outline the way a face lifter is positioned, with y varying continuously by fraction.""" values = _values(length_ft='37.58', width_ft='8.375', face_lifters='', edge_lifters=('37.58 @ 0.15\n37.58 @ 0.36\n' '37.58 @ 0.64\n37.58 @ 0.85')) primitives, problems = S.lifter_dunnage(values, 760, 380) tris = [p for p in primitives if p['kind'] == 'support' and p['role'] == 'edge-lifter'] assert len(tris) == 4 for t in tris: assert t['edge_kind'] == 'end' assert t['edge'] == 'top' assert t['ambiguous'] is False # all four sit on the panel's RIGHT edge, at distinct y positions. xs = set(round(t['x'], 3) for t in tris) assert len(xs) == 1 ys = sorted(round(t['y'], 3) for t in tris) assert len(set(ys)) == 4 assert not any('sits neither near' in p for p in problems) def test_bottom_end_edge_lifter_drawn_on_the_left_edge(): values = _values(face_lifters='', edge_lifters='0.00 @ 0.50') primitives, _problems = S.lifter_dunnage(values, 380, 190) tris = [p for p in primitives if p['kind'] == 'support'] assert len(tris) == 1 assert tris[0]['edge_kind'] == 'end' assert tris[0]['edge'] == 'bottom' left_margin = S._left_margin(380, 190) assert abs(tris[0]['x'] - left_margin) < 1e-6 def test_left_margin_is_at_least_90px_even_on_a_small_canvas(): """2026-09-04 THIRD ruling: the left margin reserved for the transverse dimension chain must be >= 90 px regardless of how small the ordinary `_margin()` figure comes out for a given canvas.""" assert S._left_margin(380, 190) >= 90.0 assert S._left_margin(200, 150) >= 90.0 # never smaller than the ordinary margin either, for a huge canvas. assert S._left_margin(4000, 4000) >= S._margin(4000, 4000) def test_edge_lifter_neither_near_end_nor_side_is_ambiguous_but_drawn(): values = _values(face_lifters='', edge_lifters='12.00 @ 0.50') primitives, problems = S.lifter_dunnage(values, 380, 190) tris = [p for p in primitives if p['kind'] == 'support'] assert len(tris) == 1 assert tris[0]['edge_kind'] == 'side' assert tris[0]['ambiguous'] is True assert any('sits neither near' in p for p in problems) def test_top_and_side_edge_lifter_fields_preferred_over_edge_lifters(): """When `top_edge_lifters` / `side_edge_lifters` carry text, they are used outright and `edge_lifters` is ignored entirely, even when it is unparseable garbage.""" values = _values(face_lifters='', edge_lifters='not @ parseable @ at @ all') values['top_edge_lifters'] = '24.00 @ 0.20' values['side_edge_lifters'] = '10.00 @ 0.05' primitives, problems = S.lifter_dunnage(values, 380, 190) tris = [p for p in primitives if p['kind'] == 'support'] assert len(tris) == 2 kinds = sorted((t['edge_kind'], t['edge']) for t in tris) assert kinds == [('end', 'top'), ('side', 'bottom')] assert not any('expected' in p for p in problems) def test_top_edge_lifters_field_still_splits_bottom_and_top_by_position(): """`pa_lifters.edge_lifter_lines` (the reads sibling) files BOTH a panel's bottom-end AND top-end lifters into `top_edge_lifters` -- the field name is a holdover from an earlier undivided grouping, not a promise every row in it is a TOP lifter. A row near x=0 in that field must still come back `side == 'bottom'`, not blindly 'top'.""" values = _values(length_ft='37.58', width_ft='8.375', face_lifters='', edge_lifters='') values['top_edge_lifters'] = '0.00 @ 0.50\n37.58 @ 0.50' rows, problems = S.parse_edge_lifters(values, 37.58, 8.375) assert len(rows) == 2 by_x = dict((round(r['x_ft'], 2), r) for r in rows) assert by_x[0.0]['kind'] == 'end' assert by_x[0.0]['side'] == 'bottom' assert by_x[37.58]['kind'] == 'end' assert by_x[37.58]['side'] == 'top' assert not any(r['ambiguous'] for r in rows) assert problems == [] def test_parse_edge_lifters_direct(): rows, problems = S.parse_edge_lifters( {'edge_lifters': '37.58 @ 0.15'}, 37.58, 8.375) assert problems == [] assert len(rows) == 1 assert rows[0]['kind'] == 'end' assert rows[0]['side'] == 'top' assert rows[0]['source'] == 'edge_lifters' def test_no_lifters_typed_still_draws_the_outline_and_says_so(): primitives, problems = S.lifter_dunnage( _values(face_lifters='', edge_lifters=''), 380, 190) assert _panel_rect(primitives) assert any('no face or edge lifter' in p.lower() for p in problems) def test_out_of_range_lifter_is_flagged_and_still_drawn(): values = _values(face_lifters='100.00 @ 0.50', edge_lifters='') primitives, problems = S.lifter_dunnage(values, 380, 190) dots = [p for p in primitives if p['kind'] == 'dot'] assert len(dots) == 1 assert dots[0]['flagged'] is True assert any('outside the panel' in p for p in problems) def test_unparseable_lifter_text_reported_not_raised(): values = _values(face_lifters='garbage', edge_lifters='') primitives, problems = S.lifter_dunnage(values, 380, 190) assert _panel_rect(primitives) assert any('x_ft @ frac' in p or 'not a number' in p for p in problems) # ---------------------------------------------------------- dimension chains def test_dimension_text_present_for_lifter_x_positions(): values = _values(face_lifters='2.00 @ 0.25\n10.00 @ 0.75', edge_lifters='') primitives, _problems = S.lifter_dunnage(values, 380, 190) dim_texts = [p['text'] for p in primitives if p['kind'] == 'text' and p.get('role') == 'dimension'] assert '2.00 ft' in dim_texts assert '10.00 ft' in dim_texts def test_dimension_text_present_for_transverse_positions(): values = _values(face_lifters='2.00 @ 0.25\n10.00 @ 0.75', edge_lifters='') primitives, _problems = S.lifter_dunnage(values, 380, 190) dim_texts = [p['text'] for p in primitives if p['kind'] == 'text' and p.get('role') == 'dimension' and p.get('orientation') == 'vertical'] # 0.25 * 8 = 2.00 ft, 0.75 * 8 = 6.00 ft assert '2.00 ft' in dim_texts assert '6.00 ft' in dim_texts def test_dimension_text_carries_a_readable_font_hint(): """2026-09-04: every dimension-chain label carries `font_px >= 11` -- a rendering hint (the current renderer does not read it yet, same status as `dashed` on a dunnage line), regression coverage for the 'squashed stack of ticks' render bug.""" values = _values(length_ft='37.58', width_ft='8.375', face_lifters='', edge_lifters=('37.58 @ 0.15\n37.58 @ 0.36\n' '37.58 @ 0.64\n37.58 @ 0.85')) primitives, _problems = S.lifter_dunnage(values, 760, 380) dim_texts = [p for p in primitives if p['kind'] == 'text' and p.get('role') == 'dimension'] assert dim_texts for p in dim_texts: assert p.get('font_px', 0) >= 11, p def test_transverse_chain_staggers_close_labels_inner_and_outer(): """The coordinator's exact live-render fixture: 37.58 x 8.375 ft, 8 face lifters at x 3.50/14.00/23.58/34.08 ft (fractions 0.18/0.82 -> transverse 1.51 / 6.87 ft) and 4 end-edge lifters at x = 37.58 ft (fractions 0.15/0.36/0.64/0.85 -> transverse 1.26 / 3.02 / 5.36 / 7.12 ft). At 760x380 the two close pairs (1.26/1.51 and 6.87/7.12, each only 0.25 ft apart) must stagger into different columns; the two isolated middle values (3.02, 5.36) must not.""" values = _values(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')) primitives, _problems = S.lifter_dunnage(values, 760, 380) dim_texts = [p for p in primitives if p['kind'] == 'text' and p.get('role') == 'dimension' and p.get('orientation') == 'vertical'] by_value = dict((p['text'], p) for p in dim_texts) expected = ['1.26 ft', '1.51 ft', '3.02 ft', '5.36 ft', '6.87 ft', '7.12 ft'] for text in expected: assert text in by_value, sorted(by_value) # the two close pairs stagger to different columns... assert by_value['1.26 ft']['stagger'] != by_value['1.51 ft']['stagger'] assert by_value['1.26 ft']['x'] != by_value['1.51 ft']['x'] assert by_value['6.87 ft']['stagger'] != by_value['7.12 ft']['stagger'] # ...and the isolated middle values stay in the default inner column. assert by_value['3.02 ft']['stagger'] == 'inner' assert by_value['5.36 ft']['stagger'] == 'inner' def test_no_dimension_chain_when_no_lifters(): values = _values(face_lifters='', edge_lifters='') primitives, _problems = S.lifter_dunnage(values, 380, 190) dims = [p for p in primitives if p.get('role') == 'dimension'] assert dims == [] # ---------------------------------------------------------------- openings def test_opening_drawn_as_hollow_rect(): values = _values() values['openings_rows'] = [ {'type': 'Opening', 'pos_along': 4.0, 'pos_along_from': 'start', 'length': 3.0, 'pos_vert': 1.0, 'height': 4.0, 'label': 'Door'}, ] primitives, problems = S.lifter_dunnage(values, 380, 190) openings = [p for p in primitives if p['kind'] == 'rect' and p['role'] == 'opening'] assert len(openings) == 1 labels = [p for p in primitives if p.get('role') == 'opening-label'] assert labels and labels[0]['text'] == 'Door' _within_box(primitives, 380, 190) def test_opening_row_with_no_vertical_extent_reported_not_drawn(): values = _values() values['openings_rows'] = [ {'type': 'Opening', 'pos_along': 4.0, 'pos_along_from': 'start', 'length': 3.0}, ] primitives, problems = S.lifter_dunnage(values, 380, 190) openings = [p for p in primitives if p['kind'] == 'rect' and p['role'] == 'opening'] assert openings == [] assert any('vertical' in p for p in problems) # ----------------------------------------------------------------- dunnage def test_dunnage_lines_drawn_when_positions_given(): values = _values(dunnage='6, 18') primitives, problems = S.lifter_dunnage(values, 380, 190) dunnage = [p for p in primitives if p.get('role') == 'dunnage'] assert len(dunnage) == 2 for d in dunnage: assert d['kind'] == 'line' assert d.get('dashed') is True def test_dunnage_omitted_and_quiet_when_not_entered(): """2026-09-04: no dunnage data means no dunnage lines AND no problems entry from this function -- `PanelAnalysis.pushbutton/script.py` prints every `problems` entry a drawing returns as a permanent RED note under the picture, so a panel with genuinely no dunnage data must not carry one any more. `pa_handling.page()` says so once, quietly, elsewhere.""" primitives, problems = S.lifter_dunnage(_values(), 380, 190) dunnage = [p for p in primitives if p.get('role') == 'dunnage'] assert dunnage == [] assert not any('dunnage' in p.lower() for p in problems) def test_dunnage_positions_ft_list_preferred_over_text(): """A '999' in the text field is out of range (length_ft is 24 in _values()) and would flag -- the list [6, 18] must win outright, so nothing here is flagged and the '999' text is never touched.""" values = _values(dunnage='999') values['dunnage_positions_ft'] = [6.0, 18.0] primitives, problems = S.lifter_dunnage(values, 380, 190) dunnage_lines = [p for p in primitives if p.get('role') == 'dunnage'] assert len(dunnage_lines) == 2 assert not any(p.get('flagged') for p in dunnage_lines) assert not any('999' in p for p in problems) def test_dunnage_from_positions_ft_list_with_no_text(): values = _values(dunnage='') values['dunnage_positions_ft'] = [6.0, 18.0] primitives, problems = S.lifter_dunnage(values, 380, 190) dunnage_lines = [p for p in primitives if p.get('role') == 'dunnage'] assert len(dunnage_lines) == 2 assert not any('dunnage' in p.lower() for p in problems) def test_dunnage_lines_carry_a_label(): values = _values(dunnage='6, 18') primitives, _problems = S.lifter_dunnage(values, 380, 190) labels = [p for p in primitives if p.get('role') == 'dunnage-label'] assert len(labels) == 2 assert all(p['text'] == 'Dunnage' for p in labels) def test_dunnage_positions_ft_bad_entries_falls_back_to_text(): values = _values(dunnage='6, 18') values['dunnage_positions_ft'] = ['not-a-number'] primitives, problems = S.lifter_dunnage(values, 380, 190) dunnage_lines = [p for p in primitives if p.get('role') == 'dunnage'] assert len(dunnage_lines) == 2 # fell back to the '6, 18' text assert any('not a number' in p for p in problems) def test_dunnage_positions_function_quiet_when_nothing_present(): positions, problems = S.dunnage_positions({}) assert positions is None assert problems == [] # --------------------------------------------------------------- centroid def test_centroid_marker_present_and_inside_panel(): values = _values(face_lifters='', edge_lifters='') primitives, _problems = S.lifter_dunnage(values, 380, 190) markers = [p for p in primitives if p['kind'] == 'marker' and p['role'] == 'centroid'] assert len(markers) == 1 labels = [p for p in primitives if p['kind'] == 'text' and p.get('text') == 'CG'] assert labels _within_box(primitives, 380, 190) def test_values_never_mutated_by_lifter_dunnage(): values = _values(face_lifters='2.00 @ 0.25', edge_lifters='2.00 @ 0.10') before = copy.deepcopy(values) S.lifter_dunnage(values, 380, 190) assert values == before def test_never_raises_on_malformed_openings_rows(): values = _values() values['openings_rows'] = 'not a list' primitives, problems = S.lifter_dunnage(values, 380, 190) assert _panel_rect(primitives) # degrades, does not raise values['openings_rows'] = ['not a dict'] primitives, problems = S.lifter_dunnage(values, 380, 190) assert _panel_rect(primitives) assert any('not a recognised row' in p for p in problems) # ------------------------------------------------------------- centroid() def test_centroid_solid_rectangle(): cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(_values()) assert abs(cg_x - 12.0) < 1e-9 assert abs(cg_z - 4.0) < 1e-9 assert abs(cg_y - (8.0 / 24.0)) < 1e-9 assert xz_basis == 'solid rectangle' assert y_basis == 'solid rectangle, half thickness' assert problems == [] def test_centroid_with_one_opening_shifts_away_from_it(): values = _values() values['openings_rows'] = [ {'type': 'Opening', 'pos_along': 0.0, 'pos_along_from': 'start', 'length': 4.0, 'pos_vert': 0.0, 'height': 8.0}, ] cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values) assert cg_x > 12.0 assert '1 opening(s) deducted' in xz_basis def test_centroid_with_opening_covering_everything_falls_back_to_center(): values = _values() values['openings_rows'] = [ {'type': 'Opening', 'pos_along': 0.0, 'pos_along_from': 'start', 'length': 24.0, 'pos_vert': 0.0, 'height': 8.0}, ] cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values) assert abs(cg_x - 12.0) < 1e-9 assert 'geometric center' in xz_basis assert any('geometric center' in p for p in problems) def test_centroid_reveal_and_solid_zone_do_not_reduce_plan_area(): values = _values() values['openings_rows'] = [ {'type': 'Reveal', 'pos_along': 0.0, 'pos_along_from': 'start', 'length': 4.0, 'pos_vert': 0.0, 'height': 8.0}, {'type': 'Solid Zone', 'pos_along': 10.0, 'pos_along_from': 'start', 'length': 4.0, 'pos_vert': 0.0, 'height': 8.0}, ] cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values) assert abs(cg_x - 12.0) < 1e-9 assert xz_basis == 'solid rectangle' def test_centroid_missing_length_or_width(): cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid( _values(length_ft='', width_ft='')) assert cg_x is None and cg_z is None and xz_basis is None assert any('length' in p.lower() for p in problems) def test_centroid_y_solid_missing_thickness(): cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid( _values(thickness_in='')) assert cg_y is None and y_basis is None assert any('thickness' in p.lower() for p in problems) def test_centroid_y_sandwich_full_wythes(): values = _values(section_type='sandwich', wythe_top_in='2', insulation_in='2', wythe_bottom_in='4') cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values) assert abs(cg_y - (8.0 / 24.0)) < 1e-9 assert 'sandwich wythes' in y_basis def test_centroid_y_sandwich_incomplete_wythes_falls_back_to_thickness(): values = _values(section_type='sandwich', wythe_top_in='2', insulation_in='', wythe_bottom_in='4') cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values) assert abs(cg_y - (8.0 / 24.0)) < 1e-9 assert 'sandwich wythe thicknesses not fully entered' in y_basis def test_centroid_y_sandwich_no_thickness_and_no_wythes_reports_gap(): values = _values(section_type='sandwich', thickness_in='', wythe_top_in='', insulation_in='', wythe_bottom_in='') cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values) assert cg_y is None and y_basis is None assert any('through-thickness centroid' in p for p in problems) def test_centroid_never_raises_on_none_values(): cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(None) assert cg_x is None and cg_z is None and cg_y is None def test_centroid_never_mutates_values(): values = _values() before = copy.deepcopy(values) S.centroid(values) assert values == before