# -*- coding: utf-8 -*- """Host tests for `vibe.pa_sketch_extents` -- the two drawing functions (`outline()` / `section()`) the Concrete Extents page's new `drawing` blocks name via `module: 'pa_sketch_extents'` (see `pa_extents.py`'s "WHY THE TWO DRAWINGS ARE NOW STACKED" docstring note for the 2026-09-04 owner ruling this exists to satisfy). Loaded the way `test_panelsketch.py` is -- `conftest.py` registers a bare `vibe` package pointed at `lib/vibe` so this imports on plain host CPython with no Revit and no `pyrevit` on the path. What this suite checks, per the task brief: `outline()` never returns an empty/blank canvas even when geometry is missing; the cut-line tick + label move with `values['_cut_frac']`; every primitive from both functions stays inside the canvas box; `section()` removes an opening's gap only when the cut station falls inside that opening's along-length range; a Reveal cuts a partial-depth notch instead; a Solid Zone removes nothing; a Sandwich section draws its wythe/insulation layers; the printed net area matches the gross area minus what was actually drawn as removed; and neither function raises or mutates `values`. """ import copy from vibe import panelsketch from vibe import pa_sketch_extents as S # --------------------------------------------------------------- utilities def _values(**over): base = {'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'mark': 'PNL101', 'design_number': 'PNL1'} base.update(over) return base def _rows(primitives, kind, role=None): return [p for p in primitives if p['kind'] == kind and (role is None or p.get('role') == role)] 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 _tick_x(primitives): ticks = _rows(primitives, 'line', 'cutline-tick') assert len(ticks) == 2 xs = set() for t in ticks: assert abs(t['x1'] - t['x2']) < 1e-9 # a tick is vertical xs.add(round(t['x1'], 6)) assert len(xs) == 1, 'top and bottom ticks should share one x' return ticks[0]['x1'] _OPENING_ROW = {'type': 'Opening', 'pos_along': 10, 'pos_along_from': 'start', 'length': 4, 'pos_vert': 2, 'height': 3, 'label': 'DOOR'} # =========================================================== outline() === def test_outline_missing_geometry_is_a_note_not_a_blank_canvas(): primitives, problems = S.outline(_values(length_ft=''), 400, 300) assert primitives, 'the canvas must never come back empty' assert any(p['kind'] == 'text' for p in primitives) assert problems def test_outline_draws_the_panel_and_readable_overall_labels(): primitives, _problems = S.outline( _values(length_ft='40', width_ft='8'), 800, 300) rects = _rows(primitives, 'rect', 'panel') assert len(rects) == 1 texts = _rows(primitives, 'text', 'dimension') l_text = [t for t in texts if t['text'].startswith('L = ')] w_text = [t for t in texts if t['text'].startswith('W = ')] assert l_text and l_text[0]['font_size'] >= 11 assert w_text and w_text[0]['font_size'] >= 11 def test_outline_draws_cutline_tick_and_station_label(): primitives, _problems = S.outline( _values(length_ft='40', width_ft='8', _cut_frac='0.25'), 800, 300) labels = _rows(primitives, 'text', 'cutline-label') assert len(labels) == 1 assert labels[0]['text'] == 'cut @ 10.0 ft' assert labels[0]['font_size'] >= 11 def test_outline_cut_tick_moves_with_cut_frac(): values_lo = _values(length_ft='40', width_ft='8', _cut_frac='0.2') values_hi = _values(length_ft='40', width_ft='8', _cut_frac='0.8') prims_lo, _ = S.outline(values_lo, 800, 300) prims_hi, _ = S.outline(values_hi, 800, 300) x_lo = _tick_x(prims_lo) x_hi = _tick_x(prims_hi) assert x_hi > x_lo def test_outline_cut_frac_defaults_to_midpoint(): primitives, _problems = S.outline( _values(length_ft='40', width_ft='8'), 800, 300) labels = _rows(primitives, 'text', 'cutline-label') assert labels[0]['text'] == 'cut @ 20.0 ft' def test_outline_cut_frac_out_of_range_clamps(): lo_prims, _ = S.outline( _values(length_ft='40', width_ft='8', _cut_frac='-3'), 800, 300) hi_prims, _ = S.outline( _values(length_ft='40', width_ft='8', _cut_frac='9'), 800, 300) assert _rows(lo_prims, 'text', 'cutline-label')[0]['text'] == 'cut @ 0.0 ft' assert _rows(hi_prims, 'text', 'cutline-label')[0]['text'] == 'cut @ 40.0 ft' def test_outline_draws_openings_to_scale(): values = _values(length_ft='40', width_ft='10', openings_rows=[dict(_OPENING_ROW)]) primitives, _problems = S.outline(values, 800, 300) assert len(_rows(primitives, 'rect', 'opening')) == 1 def test_outline_stays_inside_box(): values = _values(length_ft='40', width_ft='10', openings_rows=[dict(_OPENING_ROW)]) for w, h in ((800, 300), (60, 60), (20, 15)): primitives, _problems = S.outline(values, w, h) _within_box(primitives, w, h) def test_outline_never_mutates_values(): values = _values(length_ft='40', width_ft='10', _cut_frac='0.3', openings_rows=[dict(_OPENING_ROW)]) before = copy.deepcopy(values) S.outline(values, 800, 300) assert values == before def test_outline_never_raises_on_garbage(): garbage = {'length_ft': object(), 'width_ft': [1, 2], '_cut_frac': {}, 'openings_rows': 'not-a-list'} primitives, problems = S.outline(garbage, 800, 300) assert isinstance(primitives, list) and primitives assert isinstance(problems, list) def test_outline_degrades_when_panelsketch_unavailable(monkeypatch): # `outline()` no longer needs `panelsketch` for the panel rectangle, the # L/W labels or the cut marker (see the module docstring's "WHY # `outline()` NO LONGER DELEGATES" note) -- only openings/reveals/solid # zones and the title are lost. So the picture still draws; only the # openings problem is what names `panelsketch` as missing. monkeypatch.setattr(S, 'panelsketch', None) primitives, problems = S.outline( _values(length_ft='40', width_ft='8'), 800, 300) assert _rows(primitives, 'rect', 'panel') assert any('panelsketch' in p for p in problems) def test_outline_degrades_when_openings_row_parsing_raises(monkeypatch): # The panel rectangle and its labels must survive even if the # `panelsketch` openings machinery this module reuses blows up -- # never take the whole outline down for a row-parsing bug. def _boom(values, length_ft, width_ft, thickness_ft): raise RuntimeError('boom') monkeypatch.setattr(panelsketch, '_openings_rows', _boom) values = _values(length_ft='40', width_ft='8', openings_rows=[dict(_OPENING_ROW)]) primitives, problems = S.outline(values, 800, 300) assert _rows(primitives, 'rect', 'panel') assert any('boom' in p for p in problems) def test_outline_panel_spans_at_least_85pct_of_canvas_width(): # The headless-render finding this rewrite exists to fix: the panel # rectangle must fill (almost) the whole canvas width, never letterboxed # down to roughly half of it. Uses the real target dimensions from the # owner's render (37.58 x 8.375 ft x 8 in) at a realistic canvas size. values = _values(length_ft='37.58', width_ft='8.375', thickness_in='8') primitives, _problems = S.outline(values, 1100, 300) panel = _rows(primitives, 'rect', 'panel')[0] assert panel['w'] >= 0.85 * 1100 def test_outline_cut_and_l_labels_stay_off_the_cut_tick(): values = _values(length_ft='37.58', width_ft='8.375', thickness_in='8') primitives, _problems = S.outline(values, 1100, 300) tick_x = _tick_x(primitives) cut_label = _rows(primitives, 'text', 'cutline-label')[0] l_label = [t for t in _rows(primitives, 'text', 'dimension') if t['text'].startswith('L = ')][0] assert abs(cut_label['x'] - tick_x) > 12.0 assert abs(l_label['x'] - tick_x) > 12.0 def test_outline_opening_rect_scales_with_length(): row = dict(_OPENING_ROW) # pos_along=10, length=4, from 'start' short = S.outline(_values(length_ft='20', width_ft='10', openings_rows=[row]), 1000, 300)[0] long_ = S.outline(_values(length_ft='40', width_ft='10', openings_rows=[row]), 1000, 300)[0] w_short = _rows(short, 'rect', 'opening')[0]['w'] w_long = _rows(long_, 'rect', 'opening')[0]['w'] # the same absolute 4 ft opening is a bigger FRACTION of a 20 ft panel # than of a 40 ft one, so it must draw roughly half as wide in pixels. assert w_long < w_short assert abs(w_long - w_short / 2.0) < 1.0 def test_outline_geometry_matches_the_drawn_panel_rect(): values = _values(length_ft='37.58', width_ft='8.375', thickness_in='8') for w, h in ((1100, 300), (800, 260), (600, 220)): primitives, _problems = S.outline(values, w, h) panel = _rows(primitives, 'rect', 'panel')[0] geo = S.outline_geometry(values, w, h) assert abs(panel['x'] - geo['x0']) < 1e-6 assert abs(panel['y'] - geo['y0']) < 1e-6 assert abs(panel['x'] + panel['w'] - geo['x1']) < 1e-6 assert abs(panel['y'] + panel['h'] - geo['y1']) < 1e-6 def test_outline_geometry_cut_tick_matches_the_geometry_helper(): values = _values(length_ft='37.58', width_ft='8.375', _cut_frac='0.3') primitives, _problems = S.outline(values, 1100, 300) tick_x = _tick_x(primitives) geo = S.outline_geometry(values, 1100, 300) expected = geo['x0'] + 0.3 * (geo['x1'] - geo['x0']) assert abs(tick_x - expected) < 1e-6 def _all_ys(primitives): ys = [] for p in primitives: if p['kind'] == 'rect': ys.append(p['y']) ys.append(p['y'] + p['h']) elif p['kind'] == 'line': ys.append(p['y1']) ys.append(p['y2']) elif 'y' in p: ys.append(p['y']) return ys def test_outline_every_primitive_has_4px_clearance_top_and_bottom(): # Live-render finding, 2026-09-04: the panel's bottom edge was pinned to # the canvas bottom and the L label clipped off entirely -- both because # nothing reserved room outside the panel rectangle. Checked at several # realistic canvas sizes (the container's real height is # `width_px * aspect`, `aspect` from `pa_extents` -- see # `_OUTLINE_LABEL_ROWS_ASPECT`), not the degenerate tiny-canvas sizes # `test_outline_stays_inside_box` exercises (where 4px clearance is not # geometrically possible at all). values = _values(length_ft='37.58', width_ft='8.375', thickness_in='8', openings_rows=[dict(_OPENING_ROW)]) for width_px in (600, 900, 1100): height_px = width_px * 0.4232 # width_ft/length_ft (~0.2228, # clamped through [0.12, 0.5]) + 0.10 primitives, _problems = S.outline(values, width_px, height_px) for y in _all_ys(primitives): assert -1e-6 <= y <= height_px + 1e-6, (y, height_px) assert y >= 4.0 - 1e-6, 'too close to the top: {0}'.format(y) assert y <= height_px - 4.0 + 1e-6, \ 'too close to the bottom: {0}'.format(y) def test_outline_l_label_sits_below_the_panel_rect(): values = _values(length_ft='37.58', width_ft='8.375', thickness_in='8') primitives, _problems = S.outline(values, 1100, 1100 * 0.4232) panel = _rows(primitives, 'rect', 'panel')[0] l_label = [t for t in _rows(primitives, 'text', 'dimension') if t['text'].startswith('L = ')][0] assert l_label['y'] > panel['y'] + panel['h'] def test_outline_geometry_never_raises_and_always_returns_a_box(): geo = S.outline_geometry( {'length_ft': object(), 'width_ft': None}, 'nope', []) for key in ('x0', 'x1', 'y0', 'y1'): assert key in geo assert geo['x1'] >= geo['x0'] assert geo['y1'] >= geo['y0'] # =========================================================== section() === def test_section_missing_width_or_thickness_is_a_note_not_a_blank_canvas(): primitives, problems = S.section( _values(width_ft='', thickness_in=''), 400, 300) assert primitives and primitives[0]['kind'] == 'text' assert any('Width' in p for p in problems) assert any('Thickness' in p for p in problems) def test_section_draws_the_gross_rectangle_and_labels(): primitives, _problems = S.section( _values(width_ft='8.375', thickness_in='8', length_ft='24'), 600, 260) rects = _rows(primitives, 'rect', 'panel-section') assert len(rects) == 1 t_text = [t['text'] for t in _rows(primitives, 'text', 'dimension') if t['text'].startswith('t = ')] w_text = [t['text'] for t in _rows(primitives, 'text', 'dimension') if t['text'].startswith('W = ')] assert t_text == ['t = 8.00 in'] assert w_text == ['W = 8.38 ft'] def test_section_net_area_equals_gross_when_no_openings(): primitives, _problems = S.section( _values(width_ft='8', thickness_in='8', length_ft='24', openings_rows=[]), 600, 260) net = _rows(primitives, 'text', 'annotation') gross_in2 = 8.0 * 12.0 * 8.0 assert len(net) == 1 assert '{0:.1f}'.format(gross_in2) in net[0]['text'] def test_section_opening_inside_along_range_removes_a_gap_and_area(): # station = 0.5 * 24 = 12 ft, inside the opening's [10, 14] along range. values = _values(width_ft='10', thickness_in='8', length_ft='24', _cut_frac='0.5', openings_rows=[dict(_OPENING_ROW)]) primitives, _problems = S.section(values, 600, 260) gaps = _rows(primitives, 'rect', 'opening') assert len(gaps) == 1 net_text = _rows(primitives, 'text', 'annotation')[0]['text'] gross_in2 = 10.0 * 12.0 * 8.0 removed_in2 = 3.0 * 12.0 * 8.0 # pos_vert=2..height=3 -> [2,5] * full t expected_net = gross_in2 - removed_in2 assert '{0:.1f}'.format(expected_net) in net_text def test_section_opening_outside_along_range_draws_nothing(): # station = 0.05 * 24 = 1.2 ft, outside the opening's [10, 14] range. values = _values(width_ft='10', thickness_in='8', length_ft='24', _cut_frac='0.05', openings_rows=[dict(_OPENING_ROW)]) primitives, _problems = S.section(values, 600, 260) assert not _rows(primitives, 'rect', 'opening') net_text = _rows(primitives, 'text', 'annotation')[0]['text'] gross_in2 = 10.0 * 12.0 * 8.0 assert '{0:.1f}'.format(gross_in2) in net_text def test_section_reveal_cuts_a_partial_depth_notch(): # width=2/12 ft (2 in) -- a reveal deeper than the 8 in thickness would # be nonsensical, and internal units are decimal feet throughout this # codebase (`CLAUDE.md` sec.3), the same as `pos_along` / `length` above. row = {'type': 'Reveal', 'pos_along': 10, 'pos_along_from': 'start', 'length': 4, 'pos_vert': 2, 'height': 3, 'pos_cross': 0, 'pos_cross_from': 'left', 'width': 2.0 / 12.0, 'label': 'REVEAL1'} values = _values(width_ft='10', thickness_in='8', length_ft='24', _cut_frac='0.5', openings_rows=[row]) primitives, _problems = S.section(values, 600, 260) reveals = _rows(primitives, 'rect', 'reveal') assert len(reveals) == 1 panel = _rows(primitives, 'rect', 'panel-section')[0] # a reveal 2 in deep out of an 8 in thickness must be shorter than the # full panel-section height, and still sit inside it. assert reveals[0]['h'] < panel['h'] assert reveals[0]['y'] >= panel['y'] - 1e-6 net_text = _rows(primitives, 'text', 'annotation')[0]['text'] gross_in2 = 10.0 * 12.0 * 8.0 removed_in2 = 3.0 * 12.0 * 2.0 # vert extent (ft->in) * cross depth, in expected_net = gross_in2 - removed_in2 assert '{0:.1f}'.format(expected_net) in net_text def test_section_solid_zone_at_station_removes_nothing(): row = {'type': 'Solid Zone', 'pos_along': 10, 'pos_along_from': 'start', 'length': 4, 'pos_vert': 2, 'height': 3, 'label': 'SZ1'} values = _values(width_ft='10', thickness_in='8', length_ft='24', _cut_frac='0.5', openings_rows=[row]) primitives, problems = S.section(values, 600, 260) assert not _rows(primitives, 'rect', 'opening') assert not _rows(primitives, 'rect', 'reveal') net_text = _rows(primitives, 'text', 'annotation')[0]['text'] gross_in2 = 10.0 * 12.0 * 8.0 assert '{0:.1f}'.format(gross_in2) in net_text assert any('Solid Zone' in p for p in problems) def test_section_length_missing_draws_no_openings_but_reports_why(): values = _values(width_ft='10', thickness_in='8', length_ft='', openings_rows=[dict(_OPENING_ROW)]) primitives, problems = S.section(values, 600, 260) assert not _rows(primitives, 'rect', 'opening') assert any('Length' in p for p in problems) station = _rows(primitives, 'text', 'cutline-label') assert station and 'not entered' in station[0]['text'] def test_section_sandwich_draws_wythe_and_insulation_layers(): values = _values(width_ft='8', thickness_in='8', length_ft='24', section_type='Sandwich', wythe_top_in='2', insulation_in='2', wythe_bottom_in='4') primitives, problems = S.section(values, 600, 260) assert len(_rows(primitives, 'rect', 'wythe')) == 2 assert len(_rows(primitives, 'rect', 'insulation')) == 1 assert not any('do not add up' in p for p in problems) def test_section_sandwich_missing_layers_falls_back_to_one_outline(): values = _values(width_ft='8', thickness_in='8', length_ft='24', section_type='Sandwich') primitives, problems = S.section(values, 600, 260) assert not _rows(primitives, 'rect', 'wythe') assert any('not all entered' in p for p in problems) def test_section_openings_rows_as_plain_text_is_reported_not_guessed(): values = _values(width_ft='8', thickness_in='8', length_ft='24', openings='2 openings near the middle') primitives, problems = S.section(values, 600, 260) assert not _rows(primitives, 'rect', 'opening') assert any('plain text' in p for p in problems) def test_section_stays_inside_box(): values = _values(width_ft='10', thickness_in='8', length_ft='24', _cut_frac='0.5', openings_rows=[dict(_OPENING_ROW)]) for w, h in ((600, 260), (60, 60), (20, 15)): primitives, _problems = S.section(values, w, h) _within_box(primitives, w, h) def test_section_never_mutates_values(): values = _values(width_ft='10', thickness_in='8', length_ft='24', _cut_frac='0.5', openings_rows=[dict(_OPENING_ROW)]) before = copy.deepcopy(values) S.section(values, 600, 260) assert values == before def test_section_never_raises_on_garbage(): garbage = {'width_ft': object(), 'thickness_in': [1], 'length_ft': {}, '_cut_frac': 'nope', 'openings_rows': 'not-a-list'} primitives, problems = S.section(garbage, 600, 260) assert isinstance(primitives, list) and primitives assert isinstance(problems, list) def test_section_degrades_when_panelsketch_unavailable(monkeypatch): monkeypatch.setattr(S, 'panelsketch', None) primitives, problems = S.section(_values(), 400, 300) assert primitives and primitives[0]['kind'] == 'text' assert problems # --------------------------------------------------------- _cut_fraction def test_cut_fraction_defaults_and_clamps(): assert S._cut_fraction({}) == 0.5 assert S._cut_fraction({'_cut_frac': None}) == 0.5 assert S._cut_fraction({'_cut_frac': 'garbage'}) == 0.5 assert S._cut_fraction({'_cut_frac': '-1'}) == 0.0 assert S._cut_fraction({'_cut_frac': '2'}) == 1.0 assert S._cut_fraction({'_cut_frac': '0.37'}) == 0.37 assert S._cut_fraction({'_cut_frac': 0.6}) == 0.6