# -*- coding: utf-8 -*- """Host tests for `vibe.panelsketch` -- the panel elevation drawing geometry behind the Panel Analysis form's picture. The WPF `Canvas` painting is the button's job and out of scope here (see the module docstring); this pins the primitive list `sketch()` produces: scale-true letterboxing, support placement, the missing-input degrade, and the openings non-drawing rule. Loaded the way the sibling `test_panelanalysisform.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. """ from __future__ import annotations import copy from vibe import panelsketch as S def _values(**over): base = { 'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'supports_ft': '2, 22', 'openings': '', 'mark': 'PNL101', 'design_number': 'PNL1', } base.update(over) return base def _rect(primitives): rects = [p for p in primitives if p['kind'] == 'rect' and p['role'] == 'panel'] assert len(rects) == 1 return rects[0] 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 # ---------------------------------------------------------------- aspect def test_tall_panel_preserves_aspect_ratio(): primitives, problems = S.sketch( _values(length_ft='10', width_ft='30', supports_ft='2, 8'), 400, 400) assert problems == [] rect = _rect(primitives) assert abs((rect['w'] / rect['h']) - (10.0 / 30.0)) < 1e-6 def test_square_panel_preserves_aspect_ratio(): primitives, problems = S.sketch( _values(length_ft='20', width_ft='20', supports_ft='2, 18'), 500, 300) assert problems == [] rect = _rect(primitives) assert abs((rect['w'] / rect['h']) - 1.0) < 1e-6 def test_wide_panel_preserves_aspect_ratio(): primitives, problems = S.sketch( _values(length_ft='40', width_ft='8'), 300, 500) assert problems == [] rect = _rect(primitives) assert abs((rect['w'] / rect['h']) - (40.0 / 8.0)) < 1e-6 def test_letterboxed_panel_does_not_fill_the_box_when_aspect_differs(): # a very wide panel in a tall box must not stretch to fill the height primitives, _problems = S.sketch( _values(length_ft='40', width_ft='8'), 200, 800) rect = _rect(primitives) assert rect['h'] < 800 # ------------------------------------------------------------- inside box def test_everything_stays_inside_a_normal_box(): primitives, _problems = S.sketch(_values(), 640, 480) _within_box(primitives, 640, 480) def test_everything_stays_inside_a_small_box(): primitives, _problems = S.sketch(_values(), 60, 60) _within_box(primitives, 60, 60) def test_everything_stays_inside_a_very_small_box(): primitives, _problems = S.sketch(_values(), 20, 15) _within_box(primitives, 20, 15) def test_out_of_range_support_still_stays_inside_the_box(): primitives, _problems = S.sketch( _values(supports_ft='2, 999'), 400, 300) _within_box(primitives, 400, 300) # --------------------------------------------------------- missing input def test_missing_length_returns_no_primitives_and_names_the_field(): primitives, problems = S.sketch(_values(length_ft=''), 400, 300) assert primitives == [] assert any('Length' in p for p in problems) def test_missing_width_returns_no_primitives_and_names_the_field(): primitives, problems = S.sketch(_values(width_ft=''), 400, 300) assert primitives == [] assert any('Width' in p for p in problems) def test_zero_length_is_treated_as_missing(): primitives, problems = S.sketch(_values(length_ft='0'), 400, 300) assert primitives == [] assert any('Length' in p for p in problems) def test_negative_width_is_treated_as_missing(): primitives, problems = S.sketch(_values(width_ft='-4'), 400, 300) assert primitives == [] assert any('Width' in p for p in problems) def test_non_numeric_length_is_treated_as_missing(): primitives, problems = S.sketch(_values(length_ft='tall'), 400, 300) assert primitives == [] assert any('Length' in p for p in problems) def test_both_missing_reports_both_fields_not_just_one(): _primitives, problems = S.sketch(_values(length_ft='', width_ft=''), 400, 300) assert any('Length' in p for p in problems) assert any('Width' in p for p in problems) def test_missing_thickness_does_not_block_the_drawing_but_is_noted(): primitives, problems = S.sketch(_values(thickness_in=''), 400, 300) assert primitives != [] assert any('Thickness' in p for p in problems) notes = [p for p in primitives if p['role'] == 'annotation'] assert any('not entered' in n['text'] for n in notes) def test_missing_thickness_never_fakes_a_value(): primitives, _problems = S.sketch(_values(thickness_in=''), 400, 300) notes = [p for p in primitives if p['role'] == 'annotation'] for n in notes: assert 'in' not in n['text'].split('not entered')[0] or \ 'not entered' in n['text'] # --------------------------------------------------------------- supports def test_supports_land_at_the_true_proportional_position(): primitives, problems = S.sketch( _values(length_ft='40', supports_ft='10, 30'), 500, 300) assert problems == [] rect = _rect(primitives) markers = [p for p in primitives if p['kind'] == 'support'] assert len(markers) == 2 xs = sorted(m['x'] for m in markers) # 10/40 and 30/40 of the way along the drawn width, from the rect origin expect_1 = rect['x'] + (10.0 / 40.0) * rect['w'] expect_2 = rect['x'] + (30.0 / 40.0) * rect['w'] assert abs(xs[0] - expect_1) < 1e-6 assert abs(xs[1] - expect_2) < 1e-6 for m in markers: assert m['flagged'] is False def test_support_at_each_end_is_not_flagged(): primitives, problems = S.sketch( _values(length_ft='24', supports_ft='0, 24'), 400, 300) assert not any('outside the panel length' in p for p in problems) markers = [p for p in primitives if p['kind'] == 'support'] assert all(m['flagged'] is False for m in markers) def test_out_of_range_support_is_flagged_and_still_drawn(): primitives, problems = S.sketch( _values(length_ft='24', supports_ft='2, 40'), 400, 300) assert any('outside the panel length' in p for p in problems) markers = [p for p in primitives if p['kind'] == 'support'] assert len(markers) == 2 flagged = [m for m in markers if m['flagged']] assert len(flagged) == 1 # clamped to the panel end, not dropped rect = _rect(primitives) assert abs(flagged[0]['x'] - (rect['x'] + rect['w'])) < 1e-6 def test_negative_support_is_flagged_and_clamped_to_the_start(): primitives, problems = S.sketch( _values(length_ft='24', supports_ft='-5, 12'), 400, 300) assert any('outside the panel length' in p for p in problems) rect = _rect(primitives) markers = [p for p in primitives if p['kind'] == 'support'] flagged = [m for m in markers if m['flagged']] assert len(flagged) == 1 assert abs(flagged[0]['x'] - rect['x']) < 1e-6 def test_no_supports_entered_is_reported_but_still_draws_the_panel(): primitives, problems = S.sketch(_values(supports_ft=''), 400, 300) assert primitives != [] assert any('No support positions' in p for p in problems) assert not any(p['kind'] == 'support' for p in primitives) def test_pre_parsed_supports_list_is_honoured_over_raw_text(): values = _values(supports_ft='999') # would be out of range if read values['supports'] = [4.0, 20.0] primitives, problems = S.sketch(values, 400, 300) assert not any('999' in p for p in problems) markers = [p for p in primitives if p['kind'] == 'support'] assert len(markers) == 2 assert all(m['flagged'] is False for m in markers) # --------------------------------------------------------------- openings def test_blank_openings_produces_no_note(): primitives, problems = S.sketch(_values(openings=''), 400, 300) assert not any(p['role'] == 'openings-note' for p in primitives) assert not any('opening' in p.lower() for p in problems) def test_unparsed_openings_prose_produces_a_note_not_a_rectangle(): primitives, problems = S.sketch( _values(openings='one door near the south end'), 400, 300) notes = [p for p in primitives if p['role'] == 'openings-note'] assert len(notes) == 1 assert 'not drawn' in notes[0]['text'] # never a rectangle standing in for the opening rects = [p for p in primitives if p['kind'] == 'rect'] assert len(rects) == 1 # only the panel itself assert any('not drawn' in p for p in problems) def test_openings_with_a_leading_count_is_still_only_a_note(): primitives, _problems = S.sketch( _values(openings='2 openings, door and window'), 400, 300) notes = [p for p in primitives if p['role'] == 'openings-note'] assert len(notes) == 1 assert '2 opening' in notes[0]['text'] rects = [p for p in primitives if p['kind'] == 'rect'] assert len(rects) == 1 # ------------------------------------------------------------- dimensions def test_length_and_width_dimension_text_carries_value_and_units(): primitives, _problems = S.sketch( _values(length_ft='24', width_ft='8'), 400, 300) dim_texts = [p['text'] for p in primitives if p['kind'] == 'text' and p['role'] == 'dimension'] assert any('24.00 ft' in t for t in dim_texts) assert any('8.00 ft' in t for t in dim_texts) def test_title_carries_mark_when_given(): primitives, _problems = S.sketch(_values(mark='PNL101'), 400, 300) titles = [p for p in primitives if p['role'] == 'title'] assert len(titles) == 1 assert 'PNL101' in titles[0]['text'] def test_no_title_when_mark_and_design_number_are_blank(): primitives, _problems = S.sketch( _values(mark='', design_number=''), 400, 300) assert not any(p['role'] == 'title' for p in primitives) # ------------------------------------------------------------ no mutation def test_sketch_never_mutates_the_values_dict(): values = _values(supports_ft='2, 22') before = copy.deepcopy(values) S.sketch(values, 400, 300) assert values == before def test_sketch_never_mutates_a_values_dict_that_fails_the_gate(): values = _values(length_ft='') before = copy.deepcopy(values) S.sketch(values, 400, 300) assert values == before # =========================================================================== # elevation() / plan() / section() / weight_diagram() -- the four new entry # points. `sketch()` above is untouched and re-tested above; these pin the # additive contract: scale/aspect fidelity, containment, the missing-input # degrade, out-of-range flag-but-draw, and non-mutation, for each function. # =========================================================================== 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)] # --------------------------------------------------------------- elevation def test_elevation_missing_length_or_width_returns_no_primitives(): primitives, problems = S.elevation(_values(length_ft=''), 400, 300) assert primitives == [] assert any('Length' in p for p in problems) primitives, problems = S.elevation(_values(width_ft=''), 400, 300) assert primitives == [] assert any('Width' in p for p in problems) def test_elevation_front_panel_is_scale_true(): primitives, problems = S.elevation( _values(length_ft='10', width_ft='30', supports_ft='2, 8'), 500, 400) assert not any('Length' in p or 'Width' in p for p in problems) rects = _rows(primitives, 'rect', 'panel') assert len(rects) == 1 rect = rects[0] assert abs((rect['w'] / rect['h']) - (10.0 / 30.0)) < 1e-6 def test_elevation_side_view_matches_front_width_scale(): primitives, _problems = S.elevation( _values(length_ft='24', width_ft='8', thickness_in='12'), 640, 480) front = _rows(primitives, 'rect', 'panel')[0] side = _rows(primitives, 'rect', 'panel-side') assert len(side) == 1 side = side[0] # same px/ft scale on the shared width_ft axis -> equal drawn heights assert abs(side['h'] - front['h']) < 1e-6 # thickness (1 ft) drawn to the same scale as the front's width axis scale = front['h'] / 8.0 assert abs(side['w'] - 1.0 * scale) < 1e-6 def test_elevation_missing_thickness_degrades_side_view_without_blocking_front(): primitives, problems = S.elevation(_values(thickness_in=''), 400, 300) assert _rows(primitives, 'rect', 'panel') assert not _rows(primitives, 'rect', 'panel-side') assert not _rows(primitives, 'text', 'section-property') assert not _rows(primitives, 'rect', 'panel-crosssection') assert any('Thickness' in p and 'waiting on it' in p for p in problems) def test_elevation_openings_rows_are_drawn_to_scale_and_placed_correctly(): values = _values(length_ft='40', width_ft='10', openings_rows=[{'type': 'Opening', 'pos_along': 10, 'pos_along_from': 'start', 'length': 4, 'pos_vert': 2, 'height': 3, 'label': 'DOOR'}]) primitives, problems = S.elevation(values, 640, 480) assert not any('DOOR' in p and 'outside' in p for p in problems) openings = _rows(primitives, 'rect', 'opening') assert len(openings) == 1 op = openings[0] front = _rows(primitives, 'rect', 'panel')[0] expect_x = front['x'] + (10.0 / 40.0) * front['w'] expect_w = (4.0 / 40.0) * front['w'] assert abs(op['x'] - expect_x) < 1e-6 assert abs(op['w'] - expect_w) < 1e-6 assert op['flagged'] is False labels = _rows(primitives, 'text', 'opening-label') assert any('DOOR' in l['text'] for l in labels) def test_elevation_opening_position_from_end_is_resolved_correctly(): # pos_along measured from the far end -> x0 = length - pos_along - length values = _values(length_ft='24', width_ft='8', openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'end', 'length': 4, 'pos_vert': 1, 'height': 3, 'label': 'WIN'}]) primitives, problems = S.elevation(values, 640, 480) assert not any('outside' in p for p in problems) op = _rows(primitives, 'rect', 'opening')[0] front = _rows(primitives, 'rect', 'panel')[0] # x0 = 24 - 2 - 4 = 18 expect_x = front['x'] + (18.0 / 24.0) * front['w'] assert abs(op['x'] - expect_x) < 1e-6 def test_elevation_out_of_range_opening_is_flagged_and_still_drawn(): values = _values(length_ft='24', width_ft='8', openings_rows=[{'type': 'Opening', 'pos_along': 20, 'pos_along_from': 'start', 'length': 10, 'pos_vert': 0, 'height': 4, 'label': ''}]) primitives, problems = S.elevation(values, 400, 300) assert any('outside the panel' in p for p in problems) openings = _rows(primitives, 'rect', 'opening') assert len(openings) == 1 assert openings[0]['flagged'] is True def test_elevation_opening_entirely_outside_is_dropped_not_moved(): values = _values(length_ft='24', width_ft='8', openings_rows=[{'type': 'Opening', 'pos_along': 100, 'pos_along_from': 'start', 'length': 2, 'pos_vert': 100, 'height': 2, 'label': ''}]) primitives, problems = S.elevation(values, 400, 300) assert any('entirely outside' in p for p in problems) assert not _rows(primitives, 'rect', 'opening') def test_elevation_opening_missing_along_position_is_never_drawn(): values = _values(length_ft='24', width_ft='8', openings_rows=[{'type': 'Opening', 'length': 4, 'pos_vert': 0, 'height': 4, 'label': 'X'}]) primitives, problems = S.elevation(values, 400, 300) assert not _rows(primitives, 'rect', 'opening') assert any('along-length' in p for p in problems) def test_elevation_opening_missing_vertical_position_is_reported_not_guessed(): values = _values(length_ft='24', width_ft='8', openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 4, 'label': 'X'}]) primitives, problems = S.elevation(values, 400, 300) assert not _rows(primitives, 'rect', 'opening') assert any('no vertical position' in p for p in problems) def test_elevation_reveal_is_drawn_with_a_distinct_role(): values = _values(length_ft='24', width_ft='8', openings_rows=[{'type': 'Reveal', 'pos_along': 2, 'pos_along_from': 'start', 'length': 4, 'pos_vert': 0, 'height': 8, 'label': ''}]) primitives, _problems = S.elevation(values, 400, 300) assert _rows(primitives, 'rect', 'reveal') assert not _rows(primitives, 'rect', 'opening') def test_elevation_falls_back_to_openings_prose_note_when_no_rows(): values = _values(openings='one door near the south end') primitives, problems = S.elevation(values, 400, 300) notes = _rows(primitives, 'text', 'openings-note') assert len(notes) == 1 assert 'not drawn' in notes[0]['text'] assert any('not drawn' in p for p in problems) def test_elevation_stays_inside_box_various_sizes(): values = _values( openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}], supports_ft='2, 999') for w, h in ((640, 480), (60, 60), (20, 15)): primitives, _problems = S.elevation(values, w, h) _within_box(primitives, w, h) def test_elevation_never_mutates_values(): values = _values(openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}]) before = copy.deepcopy(values) S.elevation(values, 400, 300) assert values == before def test_sketch_alias_untouched_by_elevation_addition(): # sketch() keeps its original contract -- no side view, no scale-true # openings, prose-only openings note. values = _values(openings='2 openings, door and window') primitives, _problems = S.sketch(values, 400, 300) assert not _rows(primitives, 'rect', 'panel-side') assert _rows(primitives, 'text', 'openings-note') # -------------------------------------------------------------------- plan def test_plan_missing_length_or_width_returns_no_primitives(): primitives, problems = S.plan(_values(length_ft='', width_ft=''), 400, 300) assert primitives == [] assert any('Length' in p for p in problems) assert any('Width' in p for p in problems) def test_plan_outline_is_scale_true(): primitives, _problems = S.plan( _values(length_ft='40', width_ft='8'), 300, 500) rect = _rows(primitives, 'rect', 'panel-plan')[0] assert abs((rect['w'] / rect['h']) - (40.0 / 8.0)) < 1e-6 def test_plan_centroid_at_geometric_center_with_no_openings(): primitives, _problems = S.plan( _values(length_ft='20', width_ft='10'), 400, 300) rect = _rows(primitives, 'rect', 'panel-plan')[0] cg = _rows(primitives, 'marker', 'centroid')[0] assert abs(cg['x'] - (rect['x'] + rect['w'] / 2.0)) < 1e-6 assert abs(cg['y'] - (rect['y'] + rect['h'] / 2.0)) < 1e-6 def test_plan_centroid_shifts_away_from_a_large_opening(): values = _values(length_ft='20', width_ft='10', openings_rows=[{'type': 'Opening', 'pos_along': 0, 'pos_along_from': 'start', 'length': 8, 'pos_vert': 0, 'height': 10, 'label': ''}]) primitives, _problems = S.plan(values, 400, 300) rect = _rows(primitives, 'rect', 'panel-plan')[0] cg = _rows(primitives, 'marker', 'centroid')[0] # material removed from the left end -> centroid moves right of center assert cg['x'] > rect['x'] + rect['w'] / 2.0 def test_plan_weight_missing_is_reported_and_shows_not_entered(): primitives, problems = S.plan(_values(weight_lb=''), 400, 300) texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')] assert any('not entered' in t for t in texts) assert any('Weight' in p for p in problems) def test_plan_weight_present_is_printed(): primitives, _problems = S.plan(_values(weight_lb='12000'), 400, 300) texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')] assert any('12.0 k' in t for t in texts) def test_plan_centroid_block_has_all_three_axes(): primitives, _problems = S.plan(_values(weight_lb='12000'), 400, 300) texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')] assert any(t.startswith('X:') for t in texts) assert any(t.startswith('Y:') for t in texts) assert any(t.startswith('Z:') for t in texts) def test_plan_legend_always_present_even_with_no_lifter_data(): primitives, _problems = S.plan(_values(), 400, 300) swatches = _rows(primitives, 'swatch', 'legend-fill') assert len(swatches) == len(S.STAGE_FILLS) fills = set(s['fill'] for s in swatches) assert fills == set(S.STAGE_FILLS) glyphs = [p['text'] for p in _rows(primitives, 'text', 'legend-glyph')] assert any('Face Lifter' in g for g in glyphs) assert any('Dunnage' in g for g in glyphs) def test_plan_lifter_text_produces_a_note_never_a_marker_with_a_guessed_position(): values = _values(face_lifters='2 face lifters', edge_lifters='1 edge') primitives, problems = S.plan(values, 400, 300) notes = _rows(primitives, 'text', 'lifters-note') assert len(notes) == 1 assert 'not entered' in notes[0]['text'] or 'not drawn' in notes[0]['text'] # no marker kind was invented at a fake position markers = [p for p in primitives if p['kind'] == 'marker' and p['role'] != 'centroid'] assert markers == [] assert any('Lifter positions are not entered' in p for p in problems) def test_plan_no_lifter_text_produces_no_note(): primitives, _problems = S.plan( _values(face_lifters='', edge_lifters=''), 400, 300) assert not _rows(primitives, 'text', 'lifters-note') def test_plan_stages_list_is_printed_as_text_when_present(): values = _values(stages=['Stripping', 'Erection']) primitives, _problems = S.plan(values, 400, 300) notes = _rows(primitives, 'text', 'note') assert any('Stripping' in n['text'] and 'Erection' in n['text'] for n in notes) def test_plan_stages_absent_degrades_quietly(): primitives, _problems = S.plan(_values(), 400, 300) notes = _rows(primitives, 'text', 'note') assert not any('Stages:' in n['text'] for n in notes) def test_plan_stays_inside_box(): values = _values( openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}], face_lifters='2', stages=['Stripping']) for w, h in ((640, 480), (60, 60), (20, 15)): primitives, _problems = S.plan(values, w, h) _within_box(primitives, w, h) def test_plan_never_mutates_values(): values = _values(openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}], face_lifters='2', stages=['Stripping']) before = copy.deepcopy(values) S.plan(values, 400, 300) assert values == before # ----------------------------------------------------------------- section def _section_values(**over): base = { 'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'strand_rows': [{'count': 4, 'depth_in': 2, 'pull_pct': 100}, {'count': 2, 'depth_in': 6, 'pull_pct': None}], 'mark': 'PNL101', 'design_number': 'PNL1', } base.update(over) return base def test_section_missing_width_or_thickness_returns_no_primitives(): primitives, problems = S.section( _section_values(width_ft='', thickness_in=''), 400, 300) assert primitives == [] assert any('Width' in p for p in problems) assert any('Thickness' in p for p in problems) def test_section_outline_is_scale_true(): primitives, _problems = S.section( _section_values(width_ft='8', thickness_in='12'), 500, 400) rect = _rows(primitives, 'rect', 'panel-section')[0] assert abs((rect['w'] / rect['h']) - (8.0 / 1.0)) < 1e-6 def test_section_strand_depth_is_true_to_scale_from_the_bottom(): primitives, problems = S.section( _section_values(strand_rows=[{'count': 4, 'depth_in': 4}]), 500, 400) assert not any('outside' in p for p in problems) rect = _rows(primitives, 'rect', 'panel-section')[0] dots = _rows(primitives, 'dot') assert len(dots) == 4 # depth_in=4 of thickness_in=8 -> half way up from the bottom expect_y = rect['y'] + rect['h'] - 0.5 * rect['h'] for d in dots: assert abs(d['y'] - expect_y) < 1e-6 def test_section_strands_are_centred_not_spread_across_the_width(): primitives, _problems = S.section( _section_values(strand_rows=[{'count': 4, 'depth_in': 2}]), 500, 400) rect = _rows(primitives, 'rect', 'panel-section')[0] dots = _rows(primitives, 'dot') xs = [d['x'] for d in dots] center = rect['x'] + rect['w'] / 2.0 assert all(abs(x - center) < rect['w'] * 0.2 for x in xs) def test_section_out_of_range_strand_is_flagged_and_clamped(): primitives, problems = S.section( _section_values(strand_rows=[{'count': 2, 'depth_in': 99}]), 400, 300) assert any('outside the panel thickness' in p for p in problems) dots = _rows(primitives, 'dot') assert all(d['flagged'] is True for d in dots) rect = _rows(primitives, 'rect', 'panel-section')[0] for d in dots: assert abs(d['y'] - rect['y']) < 1e-6 # clamped to top (max depth) def test_section_no_strand_rows_still_draws_outline(): primitives, problems = S.section( _section_values(strand_rows=[]), 400, 300) assert _rows(primitives, 'rect', 'panel-section') assert any('No strand data' in p for p in problems) def test_section_missing_length_skips_full_length_view_only(): primitives, problems = S.section( _section_values(length_ft=''), 400, 300) assert _rows(primitives, 'rect', 'panel-section') assert not _rows(primitives, 'rect', 'panel-elevation-thin') assert any('full-length' in p for p in problems) def test_section_full_length_view_reports_debond_not_entered(): primitives, problems = S.section(_section_values(), 640, 480) assert _rows(primitives, 'rect', 'panel-elevation-thin') assert any('Debond' in p for p in problems) lines = _rows(primitives, 'line', 'strand-line') assert len(lines) == 2 def test_section_horizontal_spacing_not_entered_is_reported(): _primitives, problems = S.section(_section_values(), 400, 300) assert any('horizontal spacing was not entered' in p for p in problems) def test_section_stays_inside_box(): for w, h in ((640, 480), (60, 60), (20, 15)): primitives, _problems = S.section(_section_values(), w, h) _within_box(primitives, w, h) def test_section_never_mutates_values(): values = _section_values() before = copy.deepcopy(values) S.section(values, 400, 300) assert values == before # ------------------------------------------------------------ weight_diagram def test_weight_diagram_missing_length_returns_no_primitives(): primitives, problems = S.weight_diagram(_values(length_ft=''), 400, 300) assert primitives == [] assert any('Length' in p for p in problems) def test_weight_diagram_beam_spans_the_scaled_length(): primitives, _problems = S.weight_diagram(_values(length_ft='24'), 640, 480) beams = _rows(primitives, 'line', 'beam') assert len(beams) == 1 def test_weight_diagram_supports_at_true_proportional_position(): primitives, problems = S.weight_diagram( _values(length_ft='40', supports_ft='10, 30'), 500, 300) assert not any('outside' in p for p in problems) beam = _rows(primitives, 'line', 'beam')[0] markers = _rows(primitives, 'support') assert len(markers) == 2 xs = sorted(m['x'] for m in markers) expect_1 = beam['x1'] + (10.0 / 40.0) * (beam['x2'] - beam['x1']) expect_2 = beam['x1'] + (30.0 / 40.0) * (beam['x2'] - beam['x1']) assert abs(xs[0] - expect_1) < 1e-6 assert abs(xs[1] - expect_2) < 1e-6 def test_weight_diagram_out_of_range_support_is_flagged_and_still_drawn(): primitives, problems = S.weight_diagram( _values(length_ft='24', supports_ft='2, 40'), 400, 300) assert any('outside the panel length' in p for p in problems) markers = _rows(primitives, 'support') flagged = [m for m in markers if m['flagged']] assert len(flagged) == 1 def test_weight_diagram_cg_defaults_to_mid_length_with_no_openings(): primitives, _problems = S.weight_diagram( _values(length_ft='40', width_ft='10'), 500, 300) beam = _rows(primitives, 'line', 'beam')[0] cg = _rows(primitives, 'marker', 'cg')[0] expect_x = beam['x1'] + 0.5 * (beam['x2'] - beam['x1']) assert abs(cg['x'] - expect_x) < 1e-6 def test_weight_diagram_cg_shifts_with_a_large_opening(): values = _values(length_ft='40', width_ft='10', openings_rows=[{'type': 'Opening', 'pos_along': 0, 'pos_along_from': 'start', 'length': 16, 'pos_vert': 0, 'height': 10, 'label': ''}]) primitives, _problems = S.weight_diagram(values, 500, 300) beam = _rows(primitives, 'line', 'beam')[0] cg = _rows(primitives, 'marker', 'cg')[0] mid = beam['x1'] + 0.5 * (beam['x2'] - beam['x1']) assert cg['x'] > mid def test_weight_diagram_weight_missing_is_reported(): primitives, problems = S.weight_diagram(_values(weight_lb=''), 400, 300) texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')] assert any('not entered' in t for t in texts) assert any('Weight' in p for p in problems) def test_weight_diagram_moment_arms_are_dimensioned_per_support(): primitives, _problems = S.weight_diagram( _values(length_ft='40', supports_ft='10, 30'), 500, 300) arms = _rows(primitives, 'line', 'moment-arm') labels = _rows(primitives, 'text', 'moment-arm-label') assert len(arms) == 2 assert len(labels) == 2 def test_weight_diagram_stays_inside_box(): values = _values( openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}], supports_ft='2, 999') for w, h in ((640, 480), (60, 60), (20, 15)): primitives, _problems = S.weight_diagram(values, w, h) _within_box(primitives, w, h) def test_weight_diagram_never_mutates_values(): values = _values(openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}]) before = copy.deepcopy(values) S.weight_diagram(values, 400, 300) assert values == before def _sandwich_values(**over): values = {'width_ft': '15.8', 'thickness_in': '11', 'length_ft': '13.2', 'section_type': 'Sandwich', 'wythe_top_in': '3', 'insulation_in': '4', 'wythe_bottom_in': '4'} values.update(over) return values def test_section_sandwich_draws_three_layers_to_scale(): prims, problems = S.section(_sandwich_values(), 600, 240) outline = [p for p in prims if p.get('role') == 'panel-section'][0] layers = [p for p in prims if p.get('role') in ('wythe', 'insulation')] assert [p['role'] for p in layers] == ['wythe', 'insulation', 'wythe'] px_per_in = outline['h'] / 11.0 assert abs(layers[0]['h'] - 4 * px_per_in) < 1e-6 # bottom wythe assert abs(layers[1]['h'] - 4 * px_per_in) < 1e-6 # insulation assert abs(layers[2]['h'] - 3 * px_per_in) < 1e-6 # top wythe # Stacked from the rail up, edge to edge. assert abs(layers[0]['y'] + layers[0]['h'] - (outline['y'] + outline['h'])) < 1e-6 assert abs(layers[1]['y'] + layers[1]['h'] - layers[0]['y']) < 1e-6 assert abs(layers[2]['y'] - outline['y']) < 1e-6 labels = [p['text'] for p in prims if p.get('role') == 'layer-label'] assert labels == ['wythe 4.00 in', 'insulation 4.00 in', 'wythe 3.00 in'] assert not any('Sandwich section' in p for p in problems) def test_section_sandwich_that_does_not_add_up_stays_one_outline(): prims, problems = S.section(_sandwich_values(insulation_in='2'), 600, 240) assert not [p for p in prims if p.get('role') in ('wythe', 'insulation')] assert any('do not add up' in p for p in problems) def test_section_sandwich_missing_a_layer_stays_one_outline(): prims, problems = S.section(_sandwich_values(wythe_top_in=''), 600, 240) assert not [p for p in prims if p.get('role') in ('wythe', 'insulation')] assert any('not all entered' in p for p in problems) def test_section_solid_draws_no_layers(): values = _sandwich_values(section_type='Solid') prims, problems = S.section(values, 600, 240) assert not [p for p in prims if p.get('role') in ('wythe', 'insulation')] assert not any('Sandwich section' in p for p in problems) # =========================================================================== # text-vs-outline overlap (owner, 2026-09-03: "text still overlaps the # drawing"). No real font metrics exist in a host test, so this uses the same # conservative estimate the button's own `_sketch_text` reasons from (a fixed # per-character width and line height, anchor 'middle' centring the box, # 'start' left-aligning it, `orientation: 'vertical'` swapping width/height # the way the button's `RotateTransform(-90)` does) -- generous enough that a # real render is never narrower than this box, so "no overlap here" is a # genuine guarantee, not a false pass. # =========================================================================== _CHAR_W = 7.0 _LINE_H = 13.0 def _text_box(prim): text = prim.get('text', '') or '' w = max(len(text) * _CHAR_W, 1.0) h = _LINE_H if prim.get('orientation') == 'vertical': w, h = h, w x = prim.get('x', 0.0) y = prim.get('y', 0.0) if prim.get('anchor') == 'middle': x -= w / 2.0 return x, y, x + w, y + h def _boxes_overlap(a, b): ax0, ay0, ax1, ay1 = a bx0, by0, bx1, by1 = b return ax0 < bx1 and bx0 < ax1 and ay0 < by1 and by0 < ay1 def _assert_no_text_overlaps_any_outline(primitives): outlines = [p for p in primitives if p['kind'] == 'rect' and str(p.get('role', '')).startswith('panel')] assert outlines, 'expected at least one outline rect' outline_boxes = [(o['x'], o['y'], o['x'] + o['w'], o['y'] + o['h']) for o in outlines] for p in primitives: if p['kind'] != 'text': continue tbox = _text_box(p) for obox in outline_boxes: assert not _boxes_overlap(tbox, obox), \ 'text {0!r} overlaps outline {1!r}'.format(p, obox) def test_section_solid_no_text_overlaps_the_outline(): primitives, _problems = S.section(_section_values(), 900, 260) _assert_no_text_overlaps_any_outline(primitives) def test_section_sandwich_no_text_overlaps_the_outline(): values = _sandwich_values( strand_rows=[{'count': 4, 'depth_in': 2, 'pull_pct': 100}, {'count': 2, 'depth_in': 9, 'pull_pct': None}]) primitives, _problems = S.section(values, 900, 400) _assert_no_text_overlaps_any_outline(primitives) def test_elevation_solid_no_text_overlaps_the_outline(): primitives, _problems = S.elevation(_values(), 640, 480) _assert_no_text_overlaps_any_outline(primitives) def test_elevation_sandwich_no_text_overlaps_the_outline(): # elevation() does not read section_type/wythe fields, but must not # overlap its own outline regardless of what else is in `values`. values = _values(section_type='Sandwich', wythe_top_in='3', insulation_in='4', wythe_bottom_in='4') primitives, _problems = S.elevation(values, 640, 480) _assert_no_text_overlaps_any_outline(primitives) # =========================================================================== # drawing_display_scale() -- the fixed, non-adaptive shrink factor the # button applies as a `ScaleTransform` instead of a `Viewbox` (see the # function's own docstring for why a `Viewbox` cancels out against the # page's Ctrl+wheel zoom). Pure arithmetic, so this is the one part of the # zoom fix that is unit-testable outside WPF. # =========================================================================== def test_drawing_display_scale_shrinks_an_oversized_drawing(): assert S.drawing_display_scale(900, 260, max_w=360.0, max_h=520.0) < 1.0 def test_drawing_display_scale_never_grows_a_small_drawing(): assert S.drawing_display_scale(100, 80, max_w=360.0, max_h=520.0) == 1.0 def test_drawing_display_scale_is_uniform_and_aspect_true(): scale = S.drawing_display_scale(900, 260, max_w=360.0, max_h=1000.0) # the width axis is the binding constraint here (900 -> 360 needs more # shrink than 260 -> 1000 would) -- both axes get the SAME factor assert abs(scale - (360.0 / 900.0)) < 1e-9 def test_drawing_display_scale_binds_on_whichever_axis_needs_more_shrink(): # here the HEIGHT is the binding constraint scale = S.drawing_display_scale(260, 900, max_w=1000.0, max_h=360.0) assert abs(scale - (360.0 / 900.0)) < 1e-9 def test_drawing_display_scale_handles_bad_input_without_raising(): assert S.drawing_display_scale(0, 0) == 1.0 assert S.drawing_display_scale(None, None) == 1.0 assert S.drawing_display_scale(-5, 100) == 1.0 # =========================================================================== # extents_outline() / extents_section() -- the Concrete Extents drawings: # the panel's own concrete edge, its openings located by a dimension chain, # and (for a Sandwich section) the wythe layers -- with NO strands, NO # supports, and NO section-properties dump (owner, 2026-09-03: "just draw # the member's concrete edge"). # =========================================================================== def _extents_values(**over): base = {'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'mark': 'PNL101', 'design_number': 'PNL1'} base.update(over) return base def test_extents_outline_missing_length_or_width_returns_no_primitives(): primitives, problems = S.extents_outline( _extents_values(length_ft=''), 400, 300) assert primitives == [] assert any('Length' in p for p in problems) def test_extents_outline_is_scale_true_with_two_overall_dimensions(): primitives, _problems = S.extents_outline( _extents_values(length_ft='40', width_ft='8'), 640, 480) rects = _rows(primitives, 'rect', 'panel') assert len(rects) == 1 rect = rects[0] assert abs((rect['w'] / rect['h']) - (40.0 / 8.0)) < 1e-6 dim_lines = _rows(primitives, 'line', 'dimension') assert len(dim_lines) == 2 # length (bottom) + width (left) dim_texts = [p['text'] for p in _rows(primitives, 'text', 'dimension')] assert any('40.00 ft' in t for t in dim_texts) assert any('8.00 ft' in t for t in dim_texts) def test_extents_outline_draws_no_supports(): primitives, _problems = S.extents_outline( _extents_values(supports_ft='2, 22'), 640, 480) assert not any(p['kind'] == 'support' for p in primitives) def test_extents_outline_draws_opening_rect_and_locating_dimension_chain(): values = _extents_values( length_ft='40', width_ft='10', openings_rows=[{'type': 'Opening', 'pos_along': 10, 'pos_along_from': 'start', 'length': 4, 'pos_vert': 2, 'height': 3, 'label': 'DOOR'}]) primitives, problems = S.extents_outline(values, 640, 480) assert not any('outside' in p for p in problems) openings = _rows(primitives, 'rect', 'opening') assert len(openings) == 1 # the gap-dimension chain locating the opening along the length and # across the width -- at least one more 'dimension' line beyond the two # overall ones, from `_gap_dimension_chain`. dim_lines = _rows(primitives, 'line', 'dimension') assert len(dim_lines) > 2 def test_extents_outline_no_dimension_text_overlaps_the_outline(): # An opening's OWN label sits inside its opening rect, which is itself # inside the panel -- that is correct (it is labelling the opening, not # cluttering the outline), so this checks only the DIMENSION text (the # two overall dimensions plus the opening-locating chain), which must # stay outside the outline the way `_gap_dimension_chain` places it. values = _extents_values( length_ft='40', width_ft='10', openings_rows=[{'type': 'Opening', 'pos_along': 10, 'pos_along_from': 'start', 'length': 4, 'pos_vert': 2, 'height': 3, 'label': 'DOOR'}]) primitives, _problems = S.extents_outline(values, 640, 480) dim_only = [p for p in primitives if p['kind'] != 'text' or p.get('role') == 'dimension'] _assert_no_text_overlaps_any_outline(dim_only) def test_extents_outline_stays_inside_box(): values = _extents_values( openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}]) for w, h in ((640, 480), (60, 60), (20, 15)): primitives, _problems = S.extents_outline(values, w, h) _within_box(primitives, w, h) def test_extents_outline_never_mutates_values(): values = _extents_values( openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start', 'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}]) before = copy.deepcopy(values) S.extents_outline(values, 400, 300) assert values == before def test_extents_section_missing_width_or_thickness_returns_no_primitives(): primitives, problems = S.extents_section( _extents_values(width_ft='', thickness_in=''), 400, 300) assert primitives == [] assert any('Width' in p for p in problems) assert any('Thickness' in p for p in problems) def test_extents_section_draws_no_strands_even_when_strand_rows_present(): # the exact bug that made Concrete Extents "look like garbage": strand # data entered under a different tab of the same form must not appear # here, because this drawing has no reinforcement inputs of its own. values = _extents_values( strand_rows=[{'count': 4, 'depth_in': 2, 'pull_pct': 100}]) primitives, _problems = S.extents_section(values, 600, 240) assert not _rows(primitives, 'dot') assert not _rows(primitives, 'text', 'strand-label') def test_extents_section_sandwich_draws_layers_to_scale(): values = _extents_values( width_ft='15.8', thickness_in='11', section_type='Sandwich', wythe_top_in='3', insulation_in='4', wythe_bottom_in='4') primitives, problems = S.extents_section(values, 600, 240) outline = _rows(primitives, 'rect', 'panel-section')[0] layers = [p for p in primitives if p.get('role') in ('wythe', 'insulation')] assert [p['role'] for p in layers] == ['wythe', 'insulation', 'wythe'] px_per_in = outline['h'] / 11.0 assert abs(layers[0]['h'] - 4 * px_per_in) < 1e-6 assert not any('Sandwich section' in p for p in problems) def test_extents_section_no_text_overlaps_the_outline(): values = _extents_values( width_ft='15.8', thickness_in='11', section_type='Sandwich', wythe_top_in='3', insulation_in='4', wythe_bottom_in='4', strand_rows=[{'count': 4, 'depth_in': 2, 'pull_pct': 100}]) primitives, _problems = S.extents_section(values, 600, 240) _assert_no_text_overlaps_any_outline(primitives) def test_extents_section_stays_inside_box(): for w, h in ((640, 480), (60, 60), (20, 15)): primitives, _problems = S.extents_section(_extents_values(), w, h) _within_box(primitives, w, h) def test_extents_section_never_mutates_values(): values = _extents_values() before = copy.deepcopy(values) S.extents_section(values, 400, 300) assert values == before