WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
@@ -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