WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
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.
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Host tests for `vibe.pa_sketch_handling` -- the Panel Analysis Handling
|
||||
tab's "Lifter and Dunnage Detail" plan drawing and the panel weight
|
||||
centroid arithmetic behind it.
|
||||
|
||||
Loaded the way every sibling `pa_*` / `panelsketch` test is -- `conftest.py`
|
||||
registers a bare `vibe` package pointed at `lib/vibe`, so this imports on
|
||||
plain host CPython, no Revit, no `pyrevit`, no WPF. Covers what the module's
|
||||
own docstring promises: the length always fills the drawing's full width
|
||||
(never letterboxed to whichever axis is tighter), one drawn marker per
|
||||
lifter (circle for face, triangle for edge, on the edge its fraction is
|
||||
closer to), dimension text for both chains, the centroid arithmetic with and
|
||||
without openings and for a Sandwich section, and the missing-length/width
|
||||
hard stop -- never a raised exception, `values` never mutated.
|
||||
"""
|
||||
import copy
|
||||
|
||||
from vibe import knowledge # noqa: F401 (registers the vibe stub)
|
||||
from vibe import pa_sketch_handling as S
|
||||
|
||||
|
||||
def _values(**over):
|
||||
base = {
|
||||
'length_ft': '24',
|
||||
'width_ft': '8',
|
||||
'thickness_in': '8',
|
||||
'mark': 'PNL101',
|
||||
'design_number': 'PNL1',
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _within_box(primitives, width_px, height_px, tol=1e-6):
|
||||
for p in primitives:
|
||||
if p['kind'] == 'rect':
|
||||
assert -tol <= p['x'] <= width_px + tol, p
|
||||
assert -tol <= p['y'] <= height_px + tol, p
|
||||
assert -tol <= p['x'] + p['w'] <= width_px + tol, p
|
||||
assert -tol <= p['y'] + p['h'] <= height_px + tol, p
|
||||
elif p['kind'] == 'line':
|
||||
for kx, ky in (('x1', 'y1'), ('x2', 'y2')):
|
||||
assert -tol <= p[kx] <= width_px + tol, p
|
||||
assert -tol <= p[ky] <= height_px + tol, p
|
||||
else:
|
||||
if 'x' in p:
|
||||
assert -tol <= p['x'] <= width_px + tol, p
|
||||
if 'y' in p:
|
||||
assert -tol <= p['y'] <= height_px + tol, p
|
||||
|
||||
|
||||
def _panel_rect(primitives):
|
||||
rects = [p for p in primitives
|
||||
if p['kind'] == 'rect' and p['role'] == 'panel-plan']
|
||||
assert len(rects) == 1
|
||||
return rects[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------- lifter_dunnage()
|
||||
|
||||
# --------------------------------------------------- length-fills-the-width
|
||||
|
||||
def test_length_always_fills_the_drawing_width_even_for_a_squat_panel():
|
||||
"""A panel much WIDER than it is long used to letterbox (panelsketch.plan
|
||||
fits whichever axis is tighter) and end up far short of the column
|
||||
width. The 2026-09-04 ruling is explicit: the length always fills the
|
||||
window's full width. A 6 x 20 ft panel drawn at 400x400 must still
|
||||
stretch its length across (nearly) the full available width."""
|
||||
primitives, problems = S.lifter_dunnage(
|
||||
_values(length_ft='6', width_ft='20'), 400, 400)
|
||||
# No lifters / dunnage typed in this fixture -- that is reported (see
|
||||
# the "no lifters" / "no dunnage" tests below), but it is not a hard
|
||||
# stop: the panel itself still draws, scale-true, full width.
|
||||
rect = _panel_rect(primitives)
|
||||
margin = S._margin(400, 400)
|
||||
left_margin = S._left_margin(400, 400)
|
||||
avail_w = 400 - left_margin - margin
|
||||
assert abs(rect['w'] - avail_w) < 1e-6
|
||||
|
||||
|
||||
def test_length_fills_width_for_a_normal_long_panel_too():
|
||||
primitives, _problems = S.lifter_dunnage(_values(), 380, 190)
|
||||
rect = _panel_rect(primitives)
|
||||
margin = S._margin(380, 190)
|
||||
left_margin = S._left_margin(380, 190)
|
||||
avail_w = 380 - left_margin - margin
|
||||
assert abs(rect['w'] - avail_w) < 1e-6
|
||||
# scale-true: draw_h / draw_w == width_ft / length_ft
|
||||
assert abs((rect['h'] / rect['w']) - (8.0 / 24.0)) < 1e-6
|
||||
|
||||
|
||||
def test_within_box_for_a_squat_panel():
|
||||
primitives, _problems = S.lifter_dunnage(
|
||||
_values(length_ft='6', width_ft='20'), 400, 300)
|
||||
_within_box(primitives, 400, 300)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- hard stop
|
||||
|
||||
def test_missing_length_is_a_hard_stop():
|
||||
primitives, problems = S.lifter_dunnage(_values(length_ft=''), 380, 190)
|
||||
assert primitives == []
|
||||
assert any('length' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_missing_width_is_a_hard_stop():
|
||||
primitives, problems = S.lifter_dunnage(_values(width_ft='0'), 380, 190)
|
||||
assert primitives == []
|
||||
assert any('width' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_none_values_never_raises():
|
||||
primitives, problems = S.lifter_dunnage(None, 380, 190)
|
||||
assert primitives == []
|
||||
assert problems
|
||||
|
||||
|
||||
# ----------------------------------------------------------- lifter markers
|
||||
|
||||
def test_one_dot_per_face_lifter():
|
||||
values = _values(face_lifters='2.00 @ 0.25\n10.00 @ 0.75',
|
||||
edge_lifters='')
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
dots = [p for p in primitives if p['kind'] == 'dot'
|
||||
and p['role'] == 'face-lifter']
|
||||
assert len(dots) == 2
|
||||
_within_box(primitives, 380, 190)
|
||||
|
||||
|
||||
def test_one_triangle_per_edge_lifter():
|
||||
values = _values(face_lifters='', edge_lifters='2.00 @ 0.05\n10.00 @ 0.95')
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
tris = [p for p in primitives if p['kind'] == 'support'
|
||||
and p['role'] == 'edge-lifter']
|
||||
assert len(tris) == 2
|
||||
_within_box(primitives, 380, 190)
|
||||
|
||||
|
||||
def test_edge_lifter_drawn_on_the_edge_its_fraction_is_closer_to():
|
||||
values = _values(face_lifters='',
|
||||
edge_lifters='2.00 @ 0.05\n2.00 @ 0.95')
|
||||
primitives, _problems = S.lifter_dunnage(values, 380, 190)
|
||||
tris = [p for p in primitives if p['kind'] == 'support'
|
||||
and p['role'] == 'edge-lifter']
|
||||
edges = sorted(t['edge'] for t in tris)
|
||||
assert edges == ['bottom', 'top']
|
||||
bottom = next(t for t in tris if t['edge'] == 'bottom')
|
||||
top = next(t for t in tris if t['edge'] == 'top')
|
||||
# bottom (frac near 0) sits at the panel's bottom edge (larger pixel y,
|
||||
# since y increases downward); top sits at the panel's top edge.
|
||||
assert bottom['y'] > top['y']
|
||||
|
||||
|
||||
def test_top_end_edge_lifters_classified_and_drawn_on_the_right_edge():
|
||||
"""Four edge lifters all at x == length_ft (the real render bug this
|
||||
is regression coverage for): each must classify as an END/top lifter
|
||||
-- a triangle pinned to the panel's RIGHT edge (px ~= ox + draw_w),
|
||||
NOT collapsed onto the panel outline the way a face lifter is
|
||||
positioned, with y varying continuously by fraction."""
|
||||
values = _values(length_ft='37.58', width_ft='8.375', face_lifters='',
|
||||
edge_lifters=('37.58 @ 0.15\n37.58 @ 0.36\n'
|
||||
'37.58 @ 0.64\n37.58 @ 0.85'))
|
||||
primitives, problems = S.lifter_dunnage(values, 760, 380)
|
||||
tris = [p for p in primitives if p['kind'] == 'support'
|
||||
and p['role'] == 'edge-lifter']
|
||||
assert len(tris) == 4
|
||||
for t in tris:
|
||||
assert t['edge_kind'] == 'end'
|
||||
assert t['edge'] == 'top'
|
||||
assert t['ambiguous'] is False
|
||||
# all four sit on the panel's RIGHT edge, at distinct y positions.
|
||||
xs = set(round(t['x'], 3) for t in tris)
|
||||
assert len(xs) == 1
|
||||
ys = sorted(round(t['y'], 3) for t in tris)
|
||||
assert len(set(ys)) == 4
|
||||
assert not any('sits neither near' in p for p in problems)
|
||||
|
||||
|
||||
def test_bottom_end_edge_lifter_drawn_on_the_left_edge():
|
||||
values = _values(face_lifters='', edge_lifters='0.00 @ 0.50')
|
||||
primitives, _problems = S.lifter_dunnage(values, 380, 190)
|
||||
tris = [p for p in primitives if p['kind'] == 'support']
|
||||
assert len(tris) == 1
|
||||
assert tris[0]['edge_kind'] == 'end'
|
||||
assert tris[0]['edge'] == 'bottom'
|
||||
left_margin = S._left_margin(380, 190)
|
||||
assert abs(tris[0]['x'] - left_margin) < 1e-6
|
||||
|
||||
|
||||
def test_left_margin_is_at_least_90px_even_on_a_small_canvas():
|
||||
"""2026-09-04 THIRD ruling: the left margin reserved for the transverse
|
||||
dimension chain must be >= 90 px regardless of how small the ordinary
|
||||
`_margin()` figure comes out for a given canvas."""
|
||||
assert S._left_margin(380, 190) >= 90.0
|
||||
assert S._left_margin(200, 150) >= 90.0
|
||||
# never smaller than the ordinary margin either, for a huge canvas.
|
||||
assert S._left_margin(4000, 4000) >= S._margin(4000, 4000)
|
||||
|
||||
|
||||
def test_edge_lifter_neither_near_end_nor_side_is_ambiguous_but_drawn():
|
||||
values = _values(face_lifters='', edge_lifters='12.00 @ 0.50')
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
tris = [p for p in primitives if p['kind'] == 'support']
|
||||
assert len(tris) == 1
|
||||
assert tris[0]['edge_kind'] == 'side'
|
||||
assert tris[0]['ambiguous'] is True
|
||||
assert any('sits neither near' in p for p in problems)
|
||||
|
||||
|
||||
def test_top_and_side_edge_lifter_fields_preferred_over_edge_lifters():
|
||||
"""When `top_edge_lifters` / `side_edge_lifters` carry text, they are
|
||||
used outright and `edge_lifters` is ignored entirely, even when it is
|
||||
unparseable garbage."""
|
||||
values = _values(face_lifters='', edge_lifters='not @ parseable @ at @ all')
|
||||
values['top_edge_lifters'] = '24.00 @ 0.20'
|
||||
values['side_edge_lifters'] = '10.00 @ 0.05'
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
tris = [p for p in primitives if p['kind'] == 'support']
|
||||
assert len(tris) == 2
|
||||
kinds = sorted((t['edge_kind'], t['edge']) for t in tris)
|
||||
assert kinds == [('end', 'top'), ('side', 'bottom')]
|
||||
assert not any('expected' in p for p in problems)
|
||||
|
||||
|
||||
def test_top_edge_lifters_field_still_splits_bottom_and_top_by_position():
|
||||
"""`pa_lifters.edge_lifter_lines` (the reads sibling) files BOTH a
|
||||
panel's bottom-end AND top-end lifters into `top_edge_lifters` -- the
|
||||
field name is a holdover from an earlier undivided grouping, not a
|
||||
promise every row in it is a TOP lifter. A row near x=0 in that field
|
||||
must still come back `side == 'bottom'`, not blindly 'top'."""
|
||||
values = _values(length_ft='37.58', width_ft='8.375', face_lifters='',
|
||||
edge_lifters='')
|
||||
values['top_edge_lifters'] = '0.00 @ 0.50\n37.58 @ 0.50'
|
||||
rows, problems = S.parse_edge_lifters(values, 37.58, 8.375)
|
||||
assert len(rows) == 2
|
||||
by_x = dict((round(r['x_ft'], 2), r) for r in rows)
|
||||
assert by_x[0.0]['kind'] == 'end'
|
||||
assert by_x[0.0]['side'] == 'bottom'
|
||||
assert by_x[37.58]['kind'] == 'end'
|
||||
assert by_x[37.58]['side'] == 'top'
|
||||
assert not any(r['ambiguous'] for r in rows)
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_parse_edge_lifters_direct():
|
||||
rows, problems = S.parse_edge_lifters(
|
||||
{'edge_lifters': '37.58 @ 0.15'}, 37.58, 8.375)
|
||||
assert problems == []
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['kind'] == 'end'
|
||||
assert rows[0]['side'] == 'top'
|
||||
assert rows[0]['source'] == 'edge_lifters'
|
||||
|
||||
|
||||
def test_no_lifters_typed_still_draws_the_outline_and_says_so():
|
||||
primitives, problems = S.lifter_dunnage(
|
||||
_values(face_lifters='', edge_lifters=''), 380, 190)
|
||||
assert _panel_rect(primitives)
|
||||
assert any('no face or edge lifter' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_out_of_range_lifter_is_flagged_and_still_drawn():
|
||||
values = _values(face_lifters='100.00 @ 0.50', edge_lifters='')
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
dots = [p for p in primitives if p['kind'] == 'dot']
|
||||
assert len(dots) == 1
|
||||
assert dots[0]['flagged'] is True
|
||||
assert any('outside the panel' in p for p in problems)
|
||||
|
||||
|
||||
def test_unparseable_lifter_text_reported_not_raised():
|
||||
values = _values(face_lifters='garbage', edge_lifters='')
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
assert _panel_rect(primitives)
|
||||
assert any('x_ft @ frac' in p or 'not a number' in p for p in problems)
|
||||
|
||||
|
||||
# ---------------------------------------------------------- dimension chains
|
||||
|
||||
def test_dimension_text_present_for_lifter_x_positions():
|
||||
values = _values(face_lifters='2.00 @ 0.25\n10.00 @ 0.75',
|
||||
edge_lifters='')
|
||||
primitives, _problems = S.lifter_dunnage(values, 380, 190)
|
||||
dim_texts = [p['text'] for p in primitives
|
||||
if p['kind'] == 'text' and p.get('role') == 'dimension']
|
||||
assert '2.00 ft' in dim_texts
|
||||
assert '10.00 ft' in dim_texts
|
||||
|
||||
|
||||
def test_dimension_text_present_for_transverse_positions():
|
||||
values = _values(face_lifters='2.00 @ 0.25\n10.00 @ 0.75',
|
||||
edge_lifters='')
|
||||
primitives, _problems = S.lifter_dunnage(values, 380, 190)
|
||||
dim_texts = [p['text'] for p in primitives
|
||||
if p['kind'] == 'text' and p.get('role') == 'dimension'
|
||||
and p.get('orientation') == 'vertical']
|
||||
# 0.25 * 8 = 2.00 ft, 0.75 * 8 = 6.00 ft
|
||||
assert '2.00 ft' in dim_texts
|
||||
assert '6.00 ft' in dim_texts
|
||||
|
||||
|
||||
def test_dimension_text_carries_a_readable_font_hint():
|
||||
"""2026-09-04: every dimension-chain label carries `font_px >= 11` --
|
||||
a rendering hint (the current renderer does not read it yet, same
|
||||
status as `dashed` on a dunnage line), regression coverage for the
|
||||
'squashed stack of ticks' render bug."""
|
||||
values = _values(length_ft='37.58', width_ft='8.375', face_lifters='',
|
||||
edge_lifters=('37.58 @ 0.15\n37.58 @ 0.36\n'
|
||||
'37.58 @ 0.64\n37.58 @ 0.85'))
|
||||
primitives, _problems = S.lifter_dunnage(values, 760, 380)
|
||||
dim_texts = [p for p in primitives if p['kind'] == 'text'
|
||||
and p.get('role') == 'dimension']
|
||||
assert dim_texts
|
||||
for p in dim_texts:
|
||||
assert p.get('font_px', 0) >= 11, p
|
||||
|
||||
|
||||
def test_transverse_chain_staggers_close_labels_inner_and_outer():
|
||||
"""The coordinator's exact live-render fixture: 37.58 x 8.375 ft, 8
|
||||
face lifters at x 3.50/14.00/23.58/34.08 ft (fractions 0.18/0.82 ->
|
||||
transverse 1.51 / 6.87 ft) and 4 end-edge lifters at x = 37.58 ft
|
||||
(fractions 0.15/0.36/0.64/0.85 -> transverse 1.26 / 3.02 / 5.36 / 7.12
|
||||
ft). At 760x380 the two close pairs (1.26/1.51 and 6.87/7.12, each
|
||||
only 0.25 ft apart) must stagger into different columns; the two
|
||||
isolated middle values (3.02, 5.36) must not."""
|
||||
values = _values(length_ft='37.58', width_ft='8.375',
|
||||
face_lifters=('3.50 @ 0.18\n14.00 @ 0.18\n'
|
||||
'23.58 @ 0.18\n34.08 @ 0.18\n'
|
||||
'3.50 @ 0.82\n14.00 @ 0.82\n'
|
||||
'23.58 @ 0.82\n34.08 @ 0.82'),
|
||||
edge_lifters=('37.58 @ 0.15\n37.58 @ 0.36\n'
|
||||
'37.58 @ 0.64\n37.58 @ 0.85'))
|
||||
primitives, _problems = S.lifter_dunnage(values, 760, 380)
|
||||
dim_texts = [p for p in primitives if p['kind'] == 'text'
|
||||
and p.get('role') == 'dimension'
|
||||
and p.get('orientation') == 'vertical']
|
||||
by_value = dict((p['text'], p) for p in dim_texts)
|
||||
expected = ['1.26 ft', '1.51 ft', '3.02 ft', '5.36 ft', '6.87 ft',
|
||||
'7.12 ft']
|
||||
for text in expected:
|
||||
assert text in by_value, sorted(by_value)
|
||||
# the two close pairs stagger to different columns...
|
||||
assert by_value['1.26 ft']['stagger'] != by_value['1.51 ft']['stagger']
|
||||
assert by_value['1.26 ft']['x'] != by_value['1.51 ft']['x']
|
||||
assert by_value['6.87 ft']['stagger'] != by_value['7.12 ft']['stagger']
|
||||
# ...and the isolated middle values stay in the default inner column.
|
||||
assert by_value['3.02 ft']['stagger'] == 'inner'
|
||||
assert by_value['5.36 ft']['stagger'] == 'inner'
|
||||
|
||||
|
||||
def test_no_dimension_chain_when_no_lifters():
|
||||
values = _values(face_lifters='', edge_lifters='')
|
||||
primitives, _problems = S.lifter_dunnage(values, 380, 190)
|
||||
dims = [p for p in primitives if p.get('role') == 'dimension']
|
||||
assert dims == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- openings
|
||||
|
||||
def test_opening_drawn_as_hollow_rect():
|
||||
values = _values()
|
||||
values['openings_rows'] = [
|
||||
{'type': 'Opening', 'pos_along': 4.0, 'pos_along_from': 'start',
|
||||
'length': 3.0, 'pos_vert': 1.0, 'height': 4.0, 'label': 'Door'},
|
||||
]
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
openings = [p for p in primitives if p['kind'] == 'rect'
|
||||
and p['role'] == 'opening']
|
||||
assert len(openings) == 1
|
||||
labels = [p for p in primitives if p.get('role') == 'opening-label']
|
||||
assert labels and labels[0]['text'] == 'Door'
|
||||
_within_box(primitives, 380, 190)
|
||||
|
||||
|
||||
def test_opening_row_with_no_vertical_extent_reported_not_drawn():
|
||||
values = _values()
|
||||
values['openings_rows'] = [
|
||||
{'type': 'Opening', 'pos_along': 4.0, 'pos_along_from': 'start',
|
||||
'length': 3.0},
|
||||
]
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
openings = [p for p in primitives if p['kind'] == 'rect'
|
||||
and p['role'] == 'opening']
|
||||
assert openings == []
|
||||
assert any('vertical' in p for p in problems)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- dunnage
|
||||
|
||||
def test_dunnage_lines_drawn_when_positions_given():
|
||||
values = _values(dunnage='6, 18')
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
dunnage = [p for p in primitives if p.get('role') == 'dunnage']
|
||||
assert len(dunnage) == 2
|
||||
for d in dunnage:
|
||||
assert d['kind'] == 'line'
|
||||
assert d.get('dashed') is True
|
||||
|
||||
|
||||
def test_dunnage_omitted_and_quiet_when_not_entered():
|
||||
"""2026-09-04: no dunnage data means no dunnage lines AND no problems
|
||||
entry from this function -- `PanelAnalysis.pushbutton/script.py` prints
|
||||
every `problems` entry a drawing returns as a permanent RED note under
|
||||
the picture, so a panel with genuinely no dunnage data must not carry
|
||||
one any more. `pa_handling.page()` says so once, quietly, elsewhere."""
|
||||
primitives, problems = S.lifter_dunnage(_values(), 380, 190)
|
||||
dunnage = [p for p in primitives if p.get('role') == 'dunnage']
|
||||
assert dunnage == []
|
||||
assert not any('dunnage' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_dunnage_positions_ft_list_preferred_over_text():
|
||||
"""A '999' in the text field is out of range (length_ft is 24 in
|
||||
_values()) and would flag -- the list [6, 18] must win outright, so
|
||||
nothing here is flagged and the '999' text is never touched."""
|
||||
values = _values(dunnage='999')
|
||||
values['dunnage_positions_ft'] = [6.0, 18.0]
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
dunnage_lines = [p for p in primitives if p.get('role') == 'dunnage']
|
||||
assert len(dunnage_lines) == 2
|
||||
assert not any(p.get('flagged') for p in dunnage_lines)
|
||||
assert not any('999' in p for p in problems)
|
||||
|
||||
|
||||
def test_dunnage_from_positions_ft_list_with_no_text():
|
||||
values = _values(dunnage='')
|
||||
values['dunnage_positions_ft'] = [6.0, 18.0]
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
dunnage_lines = [p for p in primitives if p.get('role') == 'dunnage']
|
||||
assert len(dunnage_lines) == 2
|
||||
assert not any('dunnage' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_dunnage_lines_carry_a_label():
|
||||
values = _values(dunnage='6, 18')
|
||||
primitives, _problems = S.lifter_dunnage(values, 380, 190)
|
||||
labels = [p for p in primitives if p.get('role') == 'dunnage-label']
|
||||
assert len(labels) == 2
|
||||
assert all(p['text'] == 'Dunnage' for p in labels)
|
||||
|
||||
|
||||
def test_dunnage_positions_ft_bad_entries_falls_back_to_text():
|
||||
values = _values(dunnage='6, 18')
|
||||
values['dunnage_positions_ft'] = ['not-a-number']
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
dunnage_lines = [p for p in primitives if p.get('role') == 'dunnage']
|
||||
assert len(dunnage_lines) == 2 # fell back to the '6, 18' text
|
||||
assert any('not a number' in p for p in problems)
|
||||
|
||||
|
||||
def test_dunnage_positions_function_quiet_when_nothing_present():
|
||||
positions, problems = S.dunnage_positions({})
|
||||
assert positions is None
|
||||
assert problems == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------- centroid
|
||||
|
||||
def test_centroid_marker_present_and_inside_panel():
|
||||
values = _values(face_lifters='', edge_lifters='')
|
||||
primitives, _problems = S.lifter_dunnage(values, 380, 190)
|
||||
markers = [p for p in primitives if p['kind'] == 'marker'
|
||||
and p['role'] == 'centroid']
|
||||
assert len(markers) == 1
|
||||
labels = [p for p in primitives if p['kind'] == 'text'
|
||||
and p.get('text') == 'CG']
|
||||
assert labels
|
||||
_within_box(primitives, 380, 190)
|
||||
|
||||
|
||||
def test_values_never_mutated_by_lifter_dunnage():
|
||||
values = _values(face_lifters='2.00 @ 0.25', edge_lifters='2.00 @ 0.10')
|
||||
before = copy.deepcopy(values)
|
||||
S.lifter_dunnage(values, 380, 190)
|
||||
assert values == before
|
||||
|
||||
|
||||
def test_never_raises_on_malformed_openings_rows():
|
||||
values = _values()
|
||||
values['openings_rows'] = 'not a list'
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
assert _panel_rect(primitives) # degrades, does not raise
|
||||
values['openings_rows'] = ['not a dict']
|
||||
primitives, problems = S.lifter_dunnage(values, 380, 190)
|
||||
assert _panel_rect(primitives)
|
||||
assert any('not a recognised row' in p for p in problems)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- centroid()
|
||||
|
||||
def test_centroid_solid_rectangle():
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(_values())
|
||||
assert abs(cg_x - 12.0) < 1e-9
|
||||
assert abs(cg_z - 4.0) < 1e-9
|
||||
assert abs(cg_y - (8.0 / 24.0)) < 1e-9
|
||||
assert xz_basis == 'solid rectangle'
|
||||
assert y_basis == 'solid rectangle, half thickness'
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_centroid_with_one_opening_shifts_away_from_it():
|
||||
values = _values()
|
||||
values['openings_rows'] = [
|
||||
{'type': 'Opening', 'pos_along': 0.0, 'pos_along_from': 'start',
|
||||
'length': 4.0, 'pos_vert': 0.0, 'height': 8.0},
|
||||
]
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values)
|
||||
assert cg_x > 12.0
|
||||
assert '1 opening(s) deducted' in xz_basis
|
||||
|
||||
|
||||
def test_centroid_with_opening_covering_everything_falls_back_to_center():
|
||||
values = _values()
|
||||
values['openings_rows'] = [
|
||||
{'type': 'Opening', 'pos_along': 0.0, 'pos_along_from': 'start',
|
||||
'length': 24.0, 'pos_vert': 0.0, 'height': 8.0},
|
||||
]
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values)
|
||||
assert abs(cg_x - 12.0) < 1e-9
|
||||
assert 'geometric center' in xz_basis
|
||||
assert any('geometric center' in p for p in problems)
|
||||
|
||||
|
||||
def test_centroid_reveal_and_solid_zone_do_not_reduce_plan_area():
|
||||
values = _values()
|
||||
values['openings_rows'] = [
|
||||
{'type': 'Reveal', 'pos_along': 0.0, 'pos_along_from': 'start',
|
||||
'length': 4.0, 'pos_vert': 0.0, 'height': 8.0},
|
||||
{'type': 'Solid Zone', 'pos_along': 10.0, 'pos_along_from': 'start',
|
||||
'length': 4.0, 'pos_vert': 0.0, 'height': 8.0},
|
||||
]
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values)
|
||||
assert abs(cg_x - 12.0) < 1e-9
|
||||
assert xz_basis == 'solid rectangle'
|
||||
|
||||
|
||||
def test_centroid_missing_length_or_width():
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(
|
||||
_values(length_ft='', width_ft=''))
|
||||
assert cg_x is None and cg_z is None and xz_basis is None
|
||||
assert any('length' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_centroid_y_solid_missing_thickness():
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(
|
||||
_values(thickness_in=''))
|
||||
assert cg_y is None and y_basis is None
|
||||
assert any('thickness' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_centroid_y_sandwich_full_wythes():
|
||||
values = _values(section_type='sandwich', wythe_top_in='2',
|
||||
insulation_in='2', wythe_bottom_in='4')
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values)
|
||||
assert abs(cg_y - (8.0 / 24.0)) < 1e-9
|
||||
assert 'sandwich wythes' in y_basis
|
||||
|
||||
|
||||
def test_centroid_y_sandwich_incomplete_wythes_falls_back_to_thickness():
|
||||
values = _values(section_type='sandwich', wythe_top_in='2',
|
||||
insulation_in='', wythe_bottom_in='4')
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values)
|
||||
assert abs(cg_y - (8.0 / 24.0)) < 1e-9
|
||||
assert 'sandwich wythe thicknesses not fully entered' in y_basis
|
||||
|
||||
|
||||
def test_centroid_y_sandwich_no_thickness_and_no_wythes_reports_gap():
|
||||
values = _values(section_type='sandwich', thickness_in='',
|
||||
wythe_top_in='', insulation_in='', wythe_bottom_in='')
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(values)
|
||||
assert cg_y is None and y_basis is None
|
||||
assert any('through-thickness centroid' in p for p in problems)
|
||||
|
||||
|
||||
def test_centroid_never_raises_on_none_values():
|
||||
cg_x, cg_z, cg_y, xz_basis, y_basis, problems = S.centroid(None)
|
||||
assert cg_x is None and cg_z is None and cg_y is None
|
||||
|
||||
|
||||
def test_centroid_never_mutates_values():
|
||||
values = _values()
|
||||
before = copy.deepcopy(values)
|
||||
S.centroid(values)
|
||||
assert values == before
|
||||
Reference in New Issue
Block a user