# -*- coding: utf-8 -*- """Tests for `vibe.pa_sketch_reinf.section` -- the Reinforcement tab's own cross-section drawing (see that module's docstring for why it exists separately from `panelsketch.section()`). Runs on plain CPython via `tests/conftest.py`'s import trick -- pure geometry, no Revit, no WPF. The module itself keeps zero project imports; `section_geometry` reaches `pa_reinforcement` (for the location-chain rule, issue #23 item 9a) only inside the function, so no import cycle is created. """ import copy from vibe import pa_sketch_reinf as sk _CANVAS_W, _CANVAS_H = 480.0, 360.0 def _strand_values(rows, extra=None): values = dict(extra or {}) for i, row in enumerate(rows): n = str(i + 1) values[sk.row_key('strand_row', n, 'present')] = 'yes' for col in ('count', 'chain', 'depth_in', 'dia_in', 'pull_lb'): if col in row: values[sk.row_key('strand_row', n, col)] = row[col] return values def _mild_values(rows, extra=None): values = dict(extra or {}) for i, row in enumerate(rows): n = str(i + 1) values[sk.row_key('mild_bar', n, 'present')] = 'yes' for col in ('size', 'count', 'depth_in', 'direction', 'spacing_in', 'from_ft', 'to_ft'): if col in row: values[sk.row_key('mild_bar', n, col)] = row[col] return values def _prims(primitives, kind=None, role=None): out = list(primitives) if kind is not None: out = [p for p in out if p.get('kind') == kind] if role is not None: out = [p for p in out if p.get('role') == role] return out def _within_canvas(prim, w, h): if prim.get('kind') == 'rect': return (-1e-6 <= prim['x'] <= w + 1e-6 and -1e-6 <= prim['y'] <= h + 1e-6 and prim['x'] + prim['w'] <= w + 1e-6 and prim['y'] + prim['h'] <= h + 1e-6) if prim.get('kind') == 'line': return all(-1e-6 <= v <= (w if i % 2 == 0 else h) + 1e-6 for i, v in enumerate((prim['x1'], prim['y1'], prim['x2'], prim['y2']))) x = prim.get('x') y = prim.get('y') ok = True if x is not None: ok = ok and -1e-6 <= x <= w + 1e-6 if y is not None: ok = ok and -1e-6 <= y <= h + 1e-6 return ok # ------------------------------------------------------------------- shape def test_missing_width_and_thickness_is_fatal_no_primitives(): prims, problems = sk.section({}, _CANVAS_W, _CANVAS_H) assert prims == [] assert any('width' in p.lower() for p in problems) assert any('thickness' in p.lower() for p in problems) def test_missing_thickness_only_is_fatal(): prims, problems = sk.section({'width_ft': '4'}, _CANVAS_W, _CANVAS_H) assert prims == [] assert any('thickness' in p.lower() for p in problems) def test_never_raises_on_garbage_values(): garbage = {'width_ft': 'nope', 'thickness_in': object(), 'strand_row': 'not a dict', 12: 'weird key', 'strand_x_in': 'not a list', 'section_type': object()} prims, problems = sk.section(garbage, _CANVAS_W, _CANVAS_H) assert isinstance(prims, list) assert isinstance(problems, list) def test_never_raises_on_none_values(): prims, problems = sk.section(None, _CANVAS_W, _CANVAS_H) assert isinstance(prims, list) def test_never_mutates_values(): values = _strand_values([{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) before = copy.deepcopy(values) sk.section(values, _CANVAS_W, _CANVAS_H) assert values == before # --------------------------------------------------------------- geometry def test_minimal_geometry_draws_one_outline_and_reports_no_strands(): values = {'width_ft': '4', 'thickness_in': '8'} prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) assert _prims(prims, kind='rect', role='panel-section') assert any('no strand rows' in p.lower() for p in problems) def test_all_primitives_land_inside_the_canvas(): values = _strand_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2'}, {'count': 2, 'depth_in': 6.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8', 'strand_rail_location': 'Left edge', 'mesh': '6x6 W1.4xW1.4'}) prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) assert prims for p in prims: assert _within_canvas(p, _CANVAS_W, _CANVAS_H), p def test_tiny_canvas_still_never_raises_and_clamps(): values = _strand_values([{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, 10.0, 10.0) assert isinstance(prims, list) for p in prims: assert _within_canvas(p, 10.0, 10.0), p # --------------------------------------------------------------- sandwich def test_sandwich_layers_drawn_when_they_resolve(): values = {'width_ft': '4', 'thickness_in': '8', 'section_type': 'Sandwich', 'wythe_top_in': '2.5', 'insulation_in': '2', 'wythe_bottom_in': '3.5'} prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) wythes = _prims(prims, kind='rect', role='wythe') insulations = _prims(prims, kind='rect', role='insulation') assert len(wythes) == 2 assert len(insulations) == 1 # No plain single-outline rect when the sandwich actually drew. assert not _prims(prims, kind='rect', role='panel-section') def test_sandwich_layers_not_adding_up_falls_back_to_one_outline(): values = {'width_ft': '4', 'thickness_in': '8', 'section_type': 'Sandwich', 'wythe_top_in': '2', 'insulation_in': '2', 'wythe_bottom_in': '2'} prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) assert _prims(prims, kind='rect', role='panel-section') assert not _prims(prims, kind='rect', role='wythe') assert any('do not add up' in p for p in problems) def test_sandwich_missing_layer_thickness_falls_back_to_one_outline(): values = {'width_ft': '4', 'thickness_in': '8', 'section_type': 'Sandwich'} prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) assert _prims(prims, kind='rect', role='panel-section') assert any('not all entered' in p for p in problems) # ----------------------------------------------------------------- strands def test_one_dot_per_strand_for_a_two_row_fixture(): values = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}, {'count': 3, 'depth_in': 6.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) dots = _prims(prims, kind='dot', role='strand') assert len(dots) == 5 def test_strand_row_missing_depth_is_not_drawn_but_reported(): values = _strand_values( [{'count': 2, 'dia_in': '1/2'}], # no depth_in {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) assert not _prims(prims, kind='dot', role='strand') assert any('no depth' in p.lower() for p in problems) def test_strand_dots_capped_at_twenty_with_a_problem(): values = _strand_values([{'count': 30, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) dots = _prims(prims, kind='dot', role='strand') assert len(dots) == 20 assert any('only 20 dots' in p for p in problems) def test_strands_spread_evenly_without_explicit_x(): values = _strand_values([{'count': 3, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) dots = _prims(prims, kind='dot', role='strand') xs = sorted(d['x'] for d in dots) # Evenly spread and symmetric about the section's own centre. assert len(xs) == 3 assert abs((xs[0] + xs[2]) / 2.0 - xs[1]) < 1e-6 # 2026-09-04: spreading evenly for want of strand_x_in is an honest, # expected degradation, not a `problems` entry any more -- `problems` # is reserved for genuine failures now. `used_fallback_strand_x` is the # sibling `pa_reinforcement.page()` uses to say so as a quiet sentence. assert not any('strand_x_in' in p or 'evenly' in p for p in problems) assert sk.used_fallback_strand_x(values) is True def test_explicit_strand_x_positions_the_dots(): values = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8', 'strand_x_in': [1.0, 3.0]}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) dots = _prims(prims, kind='dot', role='strand') xs = sorted(d['x'] for d in dots) assert len(xs) == 2 assert xs[0] < xs[1] assert not any('evenly across the width' in p for p in problems) def test_explicit_strand_x_wrong_length_is_ignored(): values = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8', 'strand_x_in': [1.0]}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) # A wrong-length list is simply not used -- falls back to spread # evenly, which is no longer a `problems` entry (see the test above). assert not any('evenly across the width' in p for p in problems) assert sk.used_fallback_strand_x(values) is True def test_used_fallback_strand_x_false_with_no_strand_rows(): """Nothing to spread -- matches `section()`'s own `if rows and ...` gate, so the sibling page never claims a fallback was used for a design with no prestressing at all.""" values = {'width_ft': '4', 'thickness_in': '8'} assert sk.used_fallback_strand_x(values) is False def test_used_fallback_strand_x_false_with_real_positions(): values = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8', 'strand_x_in': [1.0, 3.0]}) assert sk.used_fallback_strand_x(values) is False # ------------------------------------------------------------- mild bars def test_blank_depth_bar_row_is_not_drawn_and_not_a_problem(): """2026-09-04: a blank-depth mild bar row used to become an in-canvas text NOTE (drawn primitive) and a `problems` entry -- both are gone now; it is simply not drawn, and `pa_reinforcement.page()` is where the fact becomes the page's own quiet sentence instead.""" values = _mild_values([{'size': '#4', 'count': 4}], # no depth_in {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) assert not _prims(prims, kind='dot', role='bar') notes = [p for p in prims if p.get('kind') == 'text' and p.get('role') == 'note' and '#4' in p.get('text', '')] assert not notes assert not any('no depth' in p.lower() for p in problems) def test_mild_bar_row_with_depth_draws_dots(): values = _mild_values([{'size': '#4', 'count': 4, 'depth_in': 1.5}], {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) dots = _prims(prims, kind='dot', role='bar') assert len(dots) == 4 # ------------------------------------------------------------------- mesh def test_mesh_blank_draws_no_line(): values = {'width_ft': '4', 'thickness_in': '8'} prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) assert not _prims(prims, kind='line', role='mesh') def test_mesh_text_draws_a_dashed_mid_depth_line(): values = {'width_ft': '4', 'thickness_in': '8', 'mesh': '6x6 W1.4xW1.4'} prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) lines = _prims(prims, kind='line', role='mesh') assert len(lines) == 1 assert lines[0].get('dashed') is True def test_mesh_sandwich_two_layers_draws_two_lines(): values = {'width_ft': '4', 'thickness_in': '8', 'section_type': 'Sandwich', 'wythe_top_in': '2.5', 'insulation_in': '2', 'wythe_bottom_in': '3.5', 'mesh': '6x6 W1.4xW1.4', 'mesh_layers': '2'} prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) lines = _prims(prims, kind='line', role='mesh') assert len(lines) == 2 # -------------------------------------------------------------- rail label def test_rail_location_labelled_at_left_edge(): values = {'width_ft': '4', 'thickness_in': '8', 'strand_rail_location': 'Left edge'} prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) labels = [p for p in prims if p.get('role') == 'rail-label'] assert labels assert 'Left edge' in labels[0]['text'] def test_rail_location_blank_is_reported(): values = {'width_ft': '4', 'thickness_in': '8'} prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) assert not [p for p in prims if p.get('role') == 'rail-label'] assert any('rail location' in p.lower() for p in problems) # ---------------------------------------------------------------------- cg # # 2026-09-04: the CG's Aps/cg TEXT moved into the legend row (role # 'legend') -- only the dot+cross marker stays inside the section itself. # See the "legend" test section below for the helper that reads it back. def _legend_texts(prims): return [p['text'] for p in prims if p.get('kind') == 'text' and p.get('role') == 'legend'] def test_cg_marker_present_for_known_strand_size(): values = _strand_values([{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) cg_dots = _prims(prims, kind='dot', role='cg') cg_markers = _prims(prims, kind='marker', role='cg') legend = _legend_texts(prims) assert cg_dots and cg_markers assert any('Aps' in t for t in legend) def test_cg_text_says_x_unknown_without_explicit_positions(): values = _strand_values([{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) legend = _legend_texts(prims) assert any('x unknown' in t for t in legend) def test_cg_text_shows_real_cgx_with_explicit_positions(): values = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8', 'strand_x_in': [1.0, 3.0]}) prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) legend = _legend_texts(prims) cg_lines = [t for t in legend if 'Aps' in t] assert cg_lines assert 'x unknown' not in cg_lines[0] assert 'cg = (' in cg_lines[0] def test_cg_aps_unknown_for_unrecognised_strand_size(): values = _strand_values([{'count': 4, 'depth_in': 2.0, 'dia_in': 'bogus-size'}], {'width_ft': '4', 'thickness_in': '8'}) prims, problems = sk.section(values, _CANVAS_W, _CANVAS_H) legend = _legend_texts(prims) cg_lines = [t for t in legend if 'Aps' in t] assert cg_lines and 'unknown' in cg_lines[0] assert any('CG: Aps could not be fully computed' in p or 'unrecognised strand' in p for p in problems) # --------------------------------------------------------------- row_ids def test_row_ids_reads_present_rows_in_numeric_order(): values = {} values[sk.row_key('strand_row', '2', 'present')] = 'yes' values[sk.row_key('strand_row', '10', 'present')] = 'yes' values[sk.row_key('strand_row', '1', 'present')] = 'yes' assert sk.row_ids('strand_row', values) == [1, 2, 10] def test_row_key_shape(): assert sk.row_key('strand_row', '1', 'count') == 'strand_row.1.count' assert sk.row_key('mild_bar', '4', 'depth_in') == 'mild_bar.4.depth_in' # -------------------------------------------------------------- clamp_aspect def test_clamp_aspect_matches_pa_reinforcement_formula(): assert sk.clamp_aspect(10.0, 8.0) == 0.12 # clamps low assert abs(sk.clamp_aspect(4.0, 8.0) - (8.0 / 12.0 / 4.0)) < 1e-9 assert sk.clamp_aspect(1.0, 20.0) == 0.4 # clamps high def test_clamp_aspect_missing_input_returns_default_never_raises(): assert sk.clamp_aspect(None, None) == 0.2 assert sk.clamp_aspect('nope', 8.0) == 0.2 assert sk.clamp_aspect(4.0, -1.0) == 0.2 # -------------------------------------------------------- mesh vs cg (2026-09-04) # # Neither the mesh line nor the CG marker carries any text ON or NEAR it any # more -- both descriptions moved into the legend row (see the "legend" # section below), which is what fixes the collision the live render found # (a mesh label and the CG text both free to land at similar depths). def test_mesh_draws_no_text_on_or_near_the_line(): values = {'width_ft': '4', 'thickness_in': '8', 'mesh': '6x6 W1.4xW1.4'} prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) assert _prims(prims, kind='line', role='mesh') assert not [p for p in prims if p.get('kind') == 'text' and p.get('role') == 'mesh'] assert any('Mesh: 6x6' in t for t in _legend_texts(prims)) def test_cg_marker_draws_no_text_beside_it(): values = _strand_values([{'count': 4, 'depth_in': 4.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8', 'mesh': '6x6 W1.4xW1.4'}) prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) assert _prims(prims, kind='marker', role='cg') assert not [p for p in prims if p.get('kind') == 'text' and p.get('role') == 'cg'] assert any('Aps' in t for t in _legend_texts(prims)) def test_cg_aps_falls_back_to_ps_dia_in_when_row_dia_blank(): """A row with no dia_in of its own used to read "unrecognised strand size" in the CG text even though `ps_dia_in` carried a perfectly good default -- `pa_reinforcement._strand_block` already fell back to the default; this drawing now does too.""" values = _strand_values([{'count': 4, 'depth_in': 2.0}], # no dia_in {'width_ft': '4', 'thickness_in': '8', 'ps_dia_in': '1/2'}) prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) cg_lines = [t for t in _legend_texts(prims) if 'Aps' in t] assert cg_lines assert 'unrecognised' not in cg_lines[0] aps_num = cg_lines[0].split('Aps = ')[1].split(' in')[0] assert abs(float(aps_num) - 4 * 0.153) < 1e-6 # --------------------------------------------------------- mild bar spacing def test_mild_bar_dots_spread_evenly_when_spacing_blank(): values = _mild_values([{'size': '#4', 'count': 4, 'depth_in': 1.5}], {'width_ft': '4', 'thickness_in': '8'}) prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) dots = sorted(d['x'] for d in _prims(prims, kind='dot', role='bar')) assert len(dots) == 4 gaps = [dots[i + 1] - dots[i] for i in range(len(dots) - 1)] assert max(gaps) - min(gaps) < 1e-6 # evenly spread def test_mild_bar_dots_use_real_spacing_when_given(): values = _mild_values( [{'size': '#4', 'count': 20, 'depth_in': 1.5, 'spacing_in': 6.0}], {'width_ft': '10', 'thickness_in': '8'}) prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) dots = sorted(d['x'] for d in _prims(prims, kind='dot', role='bar')) assert len(dots) >= 2 gaps = [dots[i + 1] - dots[i] for i in range(len(dots) - 1)] # A real pitch is used -- the gap between dots is uniform and matches # 6 in converted to px, not the count-based even spread (10 ft / 21). assert max(gaps) - min(gaps) < 1e-6 # Recompute the expected pixel pitch the same way `section` does, off # the panel's own drawn width -- cross-checked against `draw_w` isn't # exposed, so assert the gap is materially different from (and smaller # than) the even-spread gap a 20-bar row across a 10 ft width would use. assert gaps[0] < (dots[-1] - dots[0]) / 19.0 * 1.5 # ------------------------------------------------- margins / no clipping def test_side_margins_are_fixed_regardless_of_row_count(): """The margins are FIXED now (2026-09-04) -- they no longer grow with the strand-row count (that growth was itself a source of the live render's collisions). A one-row and a four-row schedule draw the section rect at the exact same x.""" one_row = _strand_values([{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) four_rows = _strand_values( [{'count': 2, 'depth_in': d, 'dia_in': '1/2'} for d in (1.0, 2.0, 3.0, 4.0)], {'width_ft': '4', 'thickness_in': '8'}) prims1, _ = sk.section(one_row, 700.0, 300.0) prims4, _ = sk.section(four_rows, 700.0, 300.0) rect1 = _prims(prims1, kind='rect', role='panel-section')[0] rect4 = _prims(prims4, kind='rect', role='panel-section')[0] assert rect1['x'] == rect4['x'] def test_right_margin_is_at_least_130px_2026_09_04(): """The right-margin strand label ('4 × 1/2″ @ 6.00 in') was clipping at the canvas edge under the old 60 px floor -- widened to 130 px.""" values = _strand_values([{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) prims, _ = sk.section(values, 700.0, 300.0) rect = _prims(prims, kind='rect', role='panel-section')[0] assert 700.0 - (rect['x'] + rect['w']) >= 130.0 - 1e-6 # ------------------------------------------------------- the live scenario # # The coordinator's own live-render finding, reproduced as a fixture: 8 # strands at REAL x, 7 mild-bar groups (transverse lines seen along their # length + longitudinal end-on dots, some at closely-spaced depths -- 1.5 / # 6.5 / 7 / 2 / 6 in), and mesh. `height_px` is computed the same way # `pa_reinforcement._section_aspect` + the renderer would: width_px times # `aspect_for(values)`, never hand-picked. def _live_scenario_values(): values = _strand_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'pull_lb': '33800'}, {'count': 4, 'depth_in': 6.0, 'dia_in': '1/2', 'pull_lb': '33800'}], {'width_ft': str(100.0 / 12.0), 'thickness_in': '8', 'strand_rail_location': 'Left edge', 'mesh': '6x6 W1.4xW1.4', 'strand_x_in': [5.0, 15.0, 25.0, 35.0, 65.0, 75.0, 85.0, 95.0]}) bar_rows = [ ('#4', 8, 1.5, 'Transverse', None), ('#4', 6, 6.5, 'Transverse', None), ('#5', 4, 7.0, 'Transverse', None), ('#5', 4, 2.0, 'Longitudinal', 12.0), ('#5', 4, 6.0, 'Longitudinal', 12.0), ('#3', 8, 1.5, 'Transverse', None), ('#4', 6, 2.0, 'Longitudinal', 18.0), ] for i, (size, count, depth, direction, spacing) in enumerate(bar_rows, 1): n = str(i) values[sk.row_key('mild_bar', n, 'present')] = 'yes' values[sk.row_key('mild_bar', n, 'size')] = size values[sk.row_key('mild_bar', n, 'count')] = count values[sk.row_key('mild_bar', n, 'depth_in')] = depth values[sk.row_key('mild_bar', n, 'direction')] = direction if spacing is not None: values[sk.row_key('mild_bar', n, 'spacing_in')] = spacing return values def _live_scenario_canvas(values, width_px=700.0): return width_px, width_px * sk.aspect_for(values) def test_no_text_primitive_clipped_by_less_than_4px_on_render_target_scale(): """Every text primitive's anchor point must stay at least 4 px inside the canvas on the side its `anchor` extends toward.""" values = _live_scenario_values() w, h = _live_scenario_canvas(values) prims, _ = sk.section(values, w, h) assert prims for p in prims: if p.get('kind') != 'text': continue anchor = p.get('anchor') x = p.get('x', 0.0) y = p.get('y', 0.0) assert 4.0 <= y <= h - 4.0, p if anchor == 'start': assert x <= w - 4.0, p elif anchor == 'end': assert x >= 4.0, p else: assert 4.0 <= x <= w - 4.0, p def test_no_two_text_primitives_within_12px_vertically_in_the_same_x_band(): """No two text primitives sit close enough in x (within 40 px -- close enough to visually be 'the same column') to plausibly collide, unless they are also >= 12 px apart vertically. This is exactly the collision the live render hit: five bar-row labels stacked on top of each other at the left edge.""" values = _live_scenario_values() w, h = _live_scenario_canvas(values) prims, _ = sk.section(values, w, h) texts = [p for p in prims if p.get('kind') == 'text'] assert len(texts) >= 10 # a real, busy schedule x_band, y_gap = 40.0, 12.0 for i in range(len(texts)): for j in range(i + 1, len(texts)): a, b = texts[i], texts[j] if abs(a.get('x', 0.0) - b.get('x', 0.0)) < x_band: assert abs(a.get('y', 0.0) - b.get('y', 0.0)) >= y_gap, \ (a, b) def test_legend_has_one_entry_per_drawn_group(): """2 strand rows + 7 bar rows (every one carries a depth) + 1 mesh + 1 CG summary == 11 legend lines, no more, no fewer -- every drawn group named exactly once, nothing double-counted, nothing silently dropped.""" values = _live_scenario_values() w, h = _live_scenario_canvas(values) prims, _ = sk.section(values, w, h) legend = _legend_texts(prims) assert len(legend) == 2 + 7 + 1 + 1 assert sum(1 for t in legend if 'strand' in t) == 2 assert sum(1 for t in legend if 'transverse' in t) == 4 assert sum(1 for t in legend if 'longitudinal' in t) == 3 assert sum(1 for t in legend if t.startswith(u'- - Mesh:')) == 1 assert sum(1 for t in legend if 'Aps' in t) == 1 def test_legend_omits_a_bar_row_with_no_depth(): values = _strand_values([{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8'}) values[sk.row_key('mild_bar', '1', 'present')] = 'yes' values[sk.row_key('mild_bar', '1', 'size')] = '#4' values[sk.row_key('mild_bar', '1', 'count')] = 4 # depth_in left blank prims, _ = sk.section(values, _CANVAS_W, _CANVAS_H) legend = _legend_texts(prims) assert not any('#4' in t for t in legend) # ------------------------------------------ strand_x_in is INCHES (2026-09-04) # # The coordinator's own live-render fixture: width_ft 8.375, thickness_in # 8, strand_x_in 8 values between 12.5 and 94.5 INCHES. The bug: dividing # an inches value by `width_ft` (a number ~12x too small) sent every # fraction past 1.0, clamped to the edge -- every dot piled at the right. def test_strand_x_in_is_read_as_inches_not_feet(): strand_x = [12.5, 22.0, 35.0, 47.5, 58.0, 70.5, 82.0, 94.5] values = _strand_values( [{'count': 8, 'depth_in': 4.0, 'dia_in': '1/2'}], {'width_ft': '8.375', 'thickness_in': '8', 'strand_x_in': strand_x}) prims, _ = sk.section(values, 700.0, 300.0) rect = _prims(prims, kind='rect', role='panel-section')[0] dots = _prims(prims, kind='dot', role='strand') assert len(dots) == 8 xs = sorted(d['x'] for d in dots) # 8 DISTINCT x's, every one strictly inside the rect -- not piled at # the right edge, not clamped to it either. assert len(set(round(x, 6) for x in xs)) == 8 for x in xs: assert rect['x'] + 1e-6 < x < rect['x'] + rect['w'] - 1e-6 # And genuinely spread, not still bunched near one edge. assert xs[-1] - xs[0] > 0.5 * rect['w'] def test_strand_x_in_out_of_range_drops_the_real_x_path(): """A value more than 1 in beyond `width_in` is not clamped to the edge -- the whole list is refused and the row falls back to spread evenly (the same quiet fallback an absent list gets).""" strand_x = [12.5, 22.0, 35.0, 47.5, 58.0, 70.5, 82.0, 999.0] # bogus values = _strand_values( [{'count': 8, 'depth_in': 4.0, 'dia_in': '1/2'}], {'width_ft': '8.375', 'thickness_in': '8', 'strand_x_in': strand_x}) assert sk.used_fallback_strand_x(values) is True prims, _ = sk.section(values, 700.0, 300.0) dots = sorted(d['x'] for d in _prims(prims, kind='dot', role='strand')) assert len(dots) == 8 gaps = [dots[i + 1] - dots[i] for i in range(len(dots) - 1)] assert max(gaps) - min(gaps) < 1e-6 # evenly spread, not piled def test_strand_x_in_one_inch_over_is_still_accepted(): """The tolerance is honest about float/measurement slop -- exactly at `width_in + 1.0` is still usable, not refused.""" width_in = 8.375 * 12.0 strand_x = [1.0, width_in + 1.0] values = _strand_values( [{'count': 2, 'depth_in': 4.0, 'dia_in': '1/2'}], {'width_ft': '8.375', 'thickness_in': '8', 'strand_x_in': strand_x}) assert sk.used_fallback_strand_x(values) is False # ------------------------------------------- location chain, section_geometry # # Issue #23 item 9a (2026-09-07): a row's `chain` cell is a dimension chain # from the strand rail. `section_geometry` resolves it (shared rule with # `pa_reinforcement.strand_positions`) into per-row `chain_in` (the parsed # rail-to-strand lengths) and `x_in` (positions from the panel's left). def test_section_geometry_places_chained_rows_from_the_chain(): values = _strand_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], {'width_ft': '8', 'thickness_in': '8', 'strand_rail_location': 'Left edge', 'strand_rail_offset_in': '0'}) geo = sk.section_geometry(values) assert not geo['problems'] assert geo['used_fallback_strand_x'] is False row = geo['strands'][0] assert row['chain_in'] == [2.0, 2.0, 2.0, 2.0] assert row['x_in'] == [2.0, 4.0, 6.0, 8.0] assert geo['strand_x_in'] == [2.0, 4.0, 6.0, 8.0] def test_section_geometry_mirrors_chain_for_right_rail(): values = _strand_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], {'width_ft': '8', 'thickness_in': '8', 'strand_rail_location': 'Right edge', 'strand_rail_offset_in': '0'}) geo = sk.section_geometry(values) assert not geo['problems'] row = geo['strands'][0] assert row['chain_in'] == [2.0, 2.0, 2.0, 2.0] # parsed, as typed assert row['x_in'] == [94.0, 92.0, 90.0, 88.0] # mirrored assert geo['strand_x_in'] == [94.0, 92.0, 90.0, 88.0] def test_section_geometry_mixed_chain_and_unentered_row_falls_back(): """One row chained, one with no chain: the chained row keeps its own x_in; the chain-less row is named in problems and the top-level flat list is None (that row's dots are spread evenly by the caller).""" values = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2'}, {'count': 2, 'depth_in': 6.0, 'dia_in': '1/2'}], {'width_ft': '8', 'thickness_in': '8', 'strand_rail_location': 'Left edge'}) geo = sk.section_geometry(values) row0, row1 = geo['strands'] assert row0['chain_in'] == [2.0, 2.0] assert row0['x_in'] == [2.0, 4.0] assert row1['chain_in'] is None assert row1['x_in'] is None assert geo['strand_x_in'] is None assert geo['used_fallback_strand_x'] is True assert any('no location chain' in p and '2' in p for p in geo['problems']) def test_section_geometry_reports_chain_count_mismatch(): values = _strand_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2'}], {'width_ft': '8', 'thickness_in': '8', 'strand_rail_location': 'Left edge'}) geo = sk.section_geometry(values) assert any('chain has 3 cells for 4 strands' in p for p in geo['problems']) row = geo['strands'][0] assert row['chain_in'] is None assert row['x_in'] is None assert geo['strand_x_in'] is None assert geo['used_fallback_strand_x'] is True def test_section_geometry_without_chain_still_reads_legacy_flat_list(): """A row with no chain keeps today's exact behaviour -- the flat `strand_x_in` list still wins when present and right-length.""" values = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2'}], {'width_ft': '4', 'thickness_in': '8', 'strand_x_in': [1.0, 3.0]}) geo = sk.section_geometry(values) row = geo['strands'][0] assert row['chain_in'] is None assert row['x_in'] is None assert geo['strand_x_in'] == [1.0, 3.0] assert geo['used_fallback_strand_x'] is False def test_used_fallback_false_when_every_row_has_a_resolved_chain(): values = _strand_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], {'width_ft': '8', 'thickness_in': '8', 'strand_rail_location': 'Left edge'}) assert sk.used_fallback_strand_x(values) is False def test_used_fallback_true_for_a_mixed_or_broken_chain_schedule(): broken = _strand_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2'}], {'width_ft': '8', 'thickness_in': '8', 'strand_rail_location': 'Left edge'}) assert sk.used_fallback_strand_x(broken) is True mixed = _strand_values( [{'count': 2, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2'}, {'count': 2, 'depth_in': 6.0, 'dia_in': '1/2'}], {'width_ft': '8', 'thickness_in': '8', 'strand_rail_location': 'Left edge'}) assert sk.used_fallback_strand_x(mixed) is True # --------------------------------------------- thin-section scale (2026-09-04) def test_thin_section_vertical_scale_fills_at_least_60pct_of_the_band(): """The coordinator's own fixture: width_ft 8.375, thickness_in 8, in a ~300 px canvas -- the true-to-scale rect was a ~50 px sliver leaving the top half of a ~250 px band empty.""" values = {'width_ft': '8.375', 'thickness_in': '8'} w, h = 700.0, 300.0 prims, _ = sk.section(values, w, h) rect = _prims(prims, kind='rect', role='panel-section')[0] remaining_h = h - sk._TITLE_H - sk._BOTTOM_H # no legend -- no rows assert rect['h'] / remaining_h >= 0.6 - 1e-6 assert rect['h'] <= remaining_h + 1e-6 # never overflows the band title = [p for p in prims if p.get('role') == 'note'][0] assert 'vertical scale' in title['text'] def test_vertical_scale_never_exceeds_4x(): """An extremely thin section (a large width, a tiny thickness) is capped at `_MAX_VSCALE`, not stretched without bound.""" values = {'width_ft': '40', 'thickness_in': '4'} prims, _ = sk.section(values, 900.0, 300.0) title = [p for p in prims if p.get('role') == 'note'][0] assert 'vertical scale' in title['text'] factor_text = title['text'].split(u'×')[1].rstrip(')') assert float(factor_text) <= sk._MAX_VSCALE + 1e-6 def test_no_vertical_scale_note_when_canvas_sized_by_aspect_for(): """`aspect_for(values)` already reserves the right amount of height for a panel whose thickness/width ratio is inside the UNCLAMPED [0.12, 0.4] range (2/7.2 = 0.278 here) -- fed straight back into `section()`, the rect fills the band on its own and needs no amplification. (A ratio below 0.12 -- the coordinator's own 8.375 ft / 8 in fixture -- still needs it even on an `aspect_for`-sized canvas; that is `test_thin_section_vertical_scale_fills_at_least_60pct_of_the_band`'s case, not this one.)""" values = {'width_ft': '2', 'thickness_in': '7.2'} w = 700.0 h = w * sk.aspect_for(values) prims, _ = sk.section(values, w, h) title = [p for p in prims if p.get('role') == 'note'][0] assert 'vertical scale' not in title['text'] rect = _prims(prims, kind='rect', role='panel-section')[0] remaining_h = h - sk._TITLE_H - sk._BOTTOM_H assert rect['h'] / remaining_h >= 0.6 - 1e-6 def test_vertical_scale_does_not_change_draw_width(): """Amplifying the vertical scale must never touch the rect's width -- only the depth axis stretches.""" values_thin = {'width_ft': '8.375', 'thickness_in': '8'} prims_thin, _ = sk.section(values_thin, 700.0, 300.0) rect_thin = _prims(prims_thin, kind='rect', role='panel-section')[0] # Same width_ft/thickness_in ratio at a canvas tall enough that no # amplification is needed -- the drawn WIDTH should still match # (both are width-constrained letterboxing at the same avail_w). prims_tall, _ = sk.section(values_thin, 700.0, 3000.0) rect_tall = _prims(prims_tall, kind='rect', role='panel-section')[0] assert abs(rect_thin['w'] - rect_tall['w']) < 1e-6 # ------------------------------------ dynamic legend / aspect_for (2026-09-04) def test_aspect_for_grows_with_legend_size(): """A busy schedule (many strand + bar rows) asks for MORE aspect than a bare one -- the legend band is sized off the real entry count, not a flat guess.""" bare = {'width_ft': '8.375', 'thickness_in': '8'} busy = _live_scenario_values() assert sk.aspect_for(busy) > sk.aspect_for(bare) def test_aspect_for_matches_clamp_aspect_plus_fixed_bands_with_no_legend(): values = {'width_ft': '4', 'thickness_in': '8'} base = sk.clamp_aspect('4', '8') expected_extra = (sk._TITLE_H + sk._BOTTOM_H) / sk._ASPECT_REF_WIDTH_PX assert abs(sk.aspect_for(values) - (base + expected_extra)) < 1e-9 def test_legend_entry_count_matches_the_real_legend_length(): """`_legend_entry_count` (what `aspect_for` uses to size the legend band) must never drift from the legend `section()` actually draws.""" values = _live_scenario_values() w, h = _live_scenario_canvas(values) prims, _ = sk.section(values, w, h) assert sk._legend_entry_count(values) == len(_legend_texts(prims)) def test_legend_columns_capped_at_three_even_on_a_very_wide_canvas(): """A wide canvas used to divide the legend into many narrow columns, clipping the longer entries -- capped at 3 now regardless of width.""" values = _live_scenario_values() # 11 legend entries prims, _ = sk.section(values, 2000.0, 400.0) legend_prims = [p for p in prims if p.get('role') == 'legend'] distinct_xs = sorted(set(round(p['x'], 3) for p in legend_prims)) assert len(distinct_xs) <= 3 def test_last_legend_line_is_not_clipped_at_the_bottom(): values = _live_scenario_values() w, h = _live_scenario_canvas(values) prims, _ = sk.section(values, w, h) legend_prims = [p for p in prims if p.get('role') == 'legend'] assert legend_prims assert max(p['y'] for p in legend_prims) <= h - 4.0 # ------------------------------------------------ area table drift (2026-09-07) def test_strand_area_table_offers_every_dropdown_size(): """The drawing keeps its own copy of the published strand areas and looks a row's `dia_in` up by the EXACT dropdown string, so every size the `strand_row` dropdown offers (issue #23, 9d: '1/2 SP' among them) must be a key here with the same area `panelanalysisform` resolves it to.""" from vibe import panelanalysisform as F for choice in F.STRAND_DIA_CHOICES: area, err = F.strand_area_in2(choice) assert err is None, choice assert sk._strand_area(choice) == area, choice # ----------------------------------------------------------------- outline # # Issue #23 item 9i: `outline_geometry` is the reinforcement PLAN view -- # the first of the page's two drawings. It reshapes the SAME parse the # section uses (`_real_geometry`), so strand `chain_in` / `x_in` resolve # identically; bars keep their own position basis; mesh is null when blank. def _outline_values(strand_rows, bar_rows=(), extra=None): """A full `values` for the reinforcement plan: the strand/mild row tables plus `length_ft` / `width_ft` and the rail/mesh scalars, so `outline_geometry` has everything a real render would.""" values = {'length_ft': '10', 'width_ft': '8', 'thickness_in': '8'} values.update(extra or {}) values.update(_strand_values(strand_rows)) values.update(_mild_values(bar_rows)) return values def test_outline_geometry_chains_from_the_left_rail_with_offset(): """`strand_rail_offset_in: 1` moves the whole chain 1 in toward the panel's left: the cumulative chain as typed (`chain_in`) stays [2,2,2,2], the panel-left positions (`x_in`) become [1,3,5,7] -- the owner's "位置就是累加-offset", already computed by the shared parse.""" values = _outline_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}, {'count': 4, 'depth_in': 6.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], extra={'strand_rail_location': 'Left edge', 'strand_rail_offset_in': '1'}) geo = sk.outline_geometry(values) assert geo['length_ft'] == 10.0 assert geo['width_ft'] == 8.0 assert not geo['problems'] row = geo['strands'][0] assert row['chain_in'] == [2.0, 2.0, 2.0, 2.0] assert row['x_in'] == [1.0, 3.0, 5.0, 7.0] # the second row resolves identically, at its own depth assert geo['strands'][1]['depth_in'] == 6.0 assert geo['strands'][1]['x_in'] == [1.0, 3.0, 5.0, 7.0] def test_outline_geometry_mirrors_chain_for_right_rail(): """A right rail mirrors the chain across the width (width 8 ft = 96 in): cumulative [2,4,6,8] -> panel-left [94,92,90,88], exactly as the section geometry mirrors it -- the plan and the section can never disagree.""" values = _outline_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], extra={'strand_rail_location': 'Right edge', 'strand_rail_offset_in': '0'}) geo = sk.outline_geometry(values) assert geo['rail']['location'] == 'right' assert geo['rail']['offset_in'] == 0.0 row = geo['strands'][0] assert row['chain_in'] == [2.0, 2.0, 2.0, 2.0] assert row['x_in'] == [94.0, 92.0, 90.0, 88.0] def test_outline_geometry_strand_row_carries_count_and_dia(): values = _outline_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], extra={'strand_rail_location': 'Left edge', 'strand_rail_offset_in': '0'}) geo = sk.outline_geometry(values) row = geo['strands'][0] assert row['row_id'] == 1 assert row['count'] == 4 assert row['dia_in'] == '1/2' def test_outline_geometry_bar_row_carries_its_own_position_basis(): """A mild-bar row appears in the plan with ITS OWN basis -- `from_ft` / `to_ft` on the length axis and `depth_in` on the width axis -- so the plan can draw that group's dimension chain from its own reference, never shared with the strand chain.""" values = _outline_values( [], bar_rows=[{'size': '#4', 'count': 6, 'depth_in': 1.5, 'direction': 'Longitudinal', 'from_ft': '2', 'to_ft': '8'}], extra={'strand_rail_location': 'Left edge'}) geo = sk.outline_geometry(values) assert len(geo['bars']) == 1 bar = geo['bars'][0] assert bar['row_id'] == 1 assert bar['size'] == '#4' assert bar['count'] == 6 assert bar['depth_in'] == 1.5 assert bar['direction'] == 'Longitudinal' assert bar['from_ft'] == 2.0 assert bar['to_ft'] == 8.0 def test_outline_geometry_mesh_is_null_when_blank(): values = _outline_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], extra={'strand_rail_location': 'Left edge'}) geo = sk.outline_geometry(values) assert geo['mesh'] is None def test_outline_geometry_mesh_carries_designation_layers_and_depth(): values = _outline_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}], extra={'strand_rail_location': 'Left edge', 'mesh': '6x6-W2.9xW2.9', 'mesh_layers': '2', 'mesh_depth_in': '4'}) geo = sk.outline_geometry(values) assert geo['mesh'] == {'designation': '6x6-W2.9xW2.9', 'layers': 2, 'depth_in': 4.0} def test_outline_geometry_problems_carry_the_nine_a_sentences(): """A chain that does not resolve (count mismatch) is named in the plan's `problems` exactly the way `section_geometry` names it -- the plan reuses the shared parse, problems included, never its own re-parse.""" values = _outline_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2'}], extra={'strand_rail_location': 'Left edge'}) geo = sk.outline_geometry(values) assert any('chain has 3 cells for 4 strands' in p for p in geo['problems']) # the row's own chain keys are None -- never a guessed position assert geo['strands'][0]['chain_in'] is None assert geo['strands'][0]['x_in'] is None assert geo['problems'] def test_outline_geometry_rail_is_none_when_location_blank(): values = _outline_values( [{'count': 4, 'depth_in': 2.0, 'dia_in': '1/2', 'chain': '2;2;2;2'}]) geo = sk.outline_geometry(values) assert geo['rail'] is None