Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

1031 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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