8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
1031 lines
44 KiB
Python
1031 lines
44 KiB
Python
# -*- 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
|