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.
446 lines
18 KiB
Python
446 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for vibe.strandmatrix -- reading the strand matrix off drawing geometry.
|
|
|
|
The three real cases below (numbers only -- see CLAUDE.md: no client/job
|
|
names, filenames or sheet text ever leaves the evidence file) come from
|
|
sample_job's FSA5 (slab, three rows split across two vertical chains), WPA2
|
|
(wall, full grid, two-dimension-run thickness) and WPA3 (wall, full grid,
|
|
single vertical chain) design evidence: symbol counts, dimension text and
|
|
totals match those summary views' `dimensions` / `components` exactly.
|
|
Coordinates are invented but internally consistent -- built the same way
|
|
`_dimension()` in designspec.py actually measures a drafting view (view y is
|
|
up, true scale, so `value_ft` and symbol x/y share units with the printed
|
|
dimension text).
|
|
|
|
FSA5's real, live-measured symbol geometry put its 19 FilledRegion symbols in
|
|
THREE y-clusters, not two: 11 at the bottom, 5 at +4" above it and 3 at +6"
|
|
above it -- the top horizontal dimension chain is a PLAN PROJECTION across
|
|
both of the upper rows, and the two vertical chains only give the row depths
|
|
in UNION ('2"|8"' gives 2", '6"|2"|2"' gives 6" and 8"; together, 2", 6", 8").
|
|
"""
|
|
from vibe import strandmatrix as sm
|
|
|
|
|
|
# --------------------------------------------------------------- fixture helpers
|
|
|
|
def _sym(x, y, kind='FilledRegion'):
|
|
return {'kind': kind, 'x': x, 'y': y, 'w': 0.05, 'h': 0.05}
|
|
|
|
|
|
def _seg(value, value_ft, x):
|
|
return {'value': value, 'value_ft': value_ft, 'x': x}
|
|
|
|
|
|
def _hchain(y, parts):
|
|
"""A multi-segment horizontal dimension: parts = [(text, value_ft, x), ...]."""
|
|
return {'axis': 'horizontal', 'y': y,
|
|
'segments': [_seg(v, vf, x) for (v, vf, x) in parts]}
|
|
|
|
|
|
def _vchain(parts):
|
|
"""A vertical dimension: parts = [(text, value_ft, y), ...], any order --
|
|
_vertical_candidates sorts by y (bottom to top) itself."""
|
|
return {'axis': 'vertical',
|
|
'segments': [{'value': v, 'value_ft': vf, 'x': 4.0, 'y': y}
|
|
for (v, vf, y) in parts]}
|
|
|
|
|
|
IN = 1.0 / 12.0 # one inch, in feet -- makes the fixtures read like the drawing
|
|
|
|
|
|
# ====================================================================== FSA5
|
|
# slab, three rows. qty 19, thickness 10", width 13'-8" = 164".
|
|
#
|
|
# 11 strands @ from_bottom 2" -- every column
|
|
# 5 strands @ from_bottom 6" -- columns 68, 92, 116, 140, 158
|
|
# 3 strands @ from_bottom 8" -- columns 2, 20, 44
|
|
|
|
def _fsa5_view():
|
|
x0 = 5.0
|
|
|
|
row0_cols = [2, 8, 20, 32, 44, 56, 68, 92, 116, 140, 158] # 2" -- all 11
|
|
row1_cols = [68, 92, 116, 140, 158] # 6" -- upper 5
|
|
row2_cols = [2, 20, 44] # 8" -- upper 3
|
|
|
|
y0 = 13.7
|
|
y1 = y0 + 4 * IN
|
|
y2 = y0 + 6 * IN
|
|
|
|
symbols = (
|
|
[_sym(x0 + p * IN, y0) for p in row0_cols] +
|
|
[_sym(x0 + p * IN, y1) for p in row1_cols] +
|
|
[_sym(x0 + p * IN, y2) for p in row2_cols]
|
|
)
|
|
|
|
# bottom (closed) chain: 12 segments across 3 multi-segment dims + 1
|
|
# single, banded at label y ~13.6, concatenated in ascending x. Total
|
|
# 164" -- closes to the far edge, matching the piece width.
|
|
bottom_cum = [2, 8, 20, 32, 44, 56, 68, 92, 116, 140, 158, 164]
|
|
bottom_dim1 = _hchain(13.58, [
|
|
('2"', 2 * IN, x0 + bottom_cum[0] * IN),
|
|
('6"', 6 * IN, x0 + bottom_cum[1] * IN),
|
|
("1' - 0\"", 12 * IN, x0 + bottom_cum[2] * IN),
|
|
("1' - 0\"", 12 * IN, x0 + bottom_cum[3] * IN),
|
|
("1' - 0\"", 12 * IN, x0 + bottom_cum[4] * IN),
|
|
])
|
|
bottom_dim2 = _hchain(13.59, [
|
|
("1' - 0\"", 12 * IN, x0 + bottom_cum[5] * IN),
|
|
("1' - 0\"", 12 * IN, x0 + bottom_cum[6] * IN),
|
|
("2' - 0\"", 24 * IN, x0 + bottom_cum[7] * IN),
|
|
])
|
|
bottom_single = _hchain(13.60, [("2' - 0\"", 24 * IN, x0 + bottom_cum[8] * IN)])
|
|
bottom_dim4 = _hchain(13.61, [
|
|
("2' - 0\"", 24 * IN, x0 + bottom_cum[9] * IN),
|
|
("1' - 6\"", 18 * IN, x0 + bottom_cum[10] * IN),
|
|
('6"', 6 * IN, x0 + bottom_cum[11] * IN),
|
|
])
|
|
|
|
# plan-projection (open) chain: single 8-segment dim, stops at the last
|
|
# strand -- 158" total, NOT the 164" width -- and covers 8 of the 11
|
|
# columns, the union of the 6" and 8" rows.
|
|
top_cum = [2, 20, 44, 68, 92, 116, 140, 158]
|
|
top_dim = _hchain(15.15, [
|
|
('2"', 2 * IN, x0 + top_cum[0] * IN),
|
|
("1' - 6\"", 18 * IN, x0 + top_cum[1] * IN),
|
|
("2' - 0\"", 24 * IN, x0 + top_cum[2] * IN),
|
|
("2' - 0\"", 24 * IN, x0 + top_cum[3] * IN),
|
|
("2' - 0\"", 24 * IN, x0 + top_cum[4] * IN),
|
|
("2' - 0\"", 24 * IN, x0 + top_cum[5] * IN),
|
|
("2' - 0\"", 24 * IN, x0 + top_cum[6] * IN),
|
|
("1' - 6\"", 18 * IN, x0 + top_cum[7] * IN),
|
|
])
|
|
|
|
# vertical: '2"|8"' gives interior point {2}; '6"|2"|2"' (bottom-up
|
|
# order 6, 2, 2) gives interior points {6, 8}. Union {2, 6, 8}.
|
|
vert_a = _vchain([('2"', 2 * IN, 13.50), ('8"', 8 * IN, 13.95)])
|
|
vert_b = _vchain([('6"', 6 * IN, 13.55), ('2"', 2 * IN, 13.75),
|
|
('2"', 2 * IN, 13.90)])
|
|
|
|
# decoys that must be ignored harmlessly: a mild-bar chain (its
|
|
# positions never land on real columns) and an unparseable
|
|
# blocking-diagram chain.
|
|
mild_bar = _hchain(30.0, [
|
|
('5"', 5 * IN, 20.0), ('9"', 9 * IN, 20.5),
|
|
("1' - 0\"", 12 * IN, 21.0), ("1' - 0\"", 12 * IN, 21.5),
|
|
("1' - 0\"", 12 * IN, 22.0), ("1' - 0\"", 12 * IN, 22.5),
|
|
("1' - 0\"", 12 * IN, 23.0), ("1' - 0\"", 12 * IN, 23.5),
|
|
])
|
|
blocking = _hchain(25.0, [
|
|
('.104L', 0.05, 24.0), ('.291L', 0.10, 24.5), ('', 0.05, 25.0),
|
|
('0.291L', 0.10, 25.5), ('.104L', 0.05, 26.0),
|
|
])
|
|
|
|
return {
|
|
'symbols': symbols,
|
|
'dimensions': [bottom_dim1, bottom_dim2, bottom_single, bottom_dim4,
|
|
top_dim, vert_a, vert_b, mild_bar, blocking],
|
|
}
|
|
|
|
|
|
def test_fsa5_slab_three_rows_split_across_two_vertical_chains():
|
|
view = _fsa5_view()
|
|
layout, why = sm.read_layout(view, qty=19, thickness_in=10,
|
|
width_ft=13.0 + 8 * IN)
|
|
assert why is None
|
|
assert layout is not None
|
|
|
|
assert layout['columns_in'] == [2, 8, 20, 32, 44, 56, 68, 92, 116, 140, 158]
|
|
assert layout['total_in'] == 164
|
|
assert len(layout['rows']) == 3
|
|
|
|
row0, row1, row2 = layout['rows']
|
|
assert row0['from_bottom_in'] == 2
|
|
assert row0['strands'] == [True] * 11
|
|
|
|
assert row1['from_bottom_in'] == 6
|
|
assert row1['strands'] == [False, False, False, False, False, False,
|
|
True, True, True, True, True]
|
|
|
|
assert row2['from_bottom_in'] == 8
|
|
assert row2['strands'] == [True, False, True, False, True, False, False,
|
|
False, False, False, False]
|
|
|
|
assert '19 strand symbols in 3 rows' in layout['note']
|
|
assert 'vertical chains' in layout['note']
|
|
assert '"2", 8"' in layout['note'] # the '2"|8"' chain, quoted
|
|
assert '"6", 2", 2"' in layout['note'] # the '6"|2"|2"' chain, quoted
|
|
|
|
|
|
# ====================================================================== WPA2
|
|
# wall, full grid. qty 12, thickness 8", width 14'-1 1/4" = 169.25".
|
|
|
|
def _wpa2_view():
|
|
x0 = 5.0
|
|
positions_in = [6, 16, 52, 88, 124, 160]
|
|
y_bottom, y_top = 10.0, 10.0 + 4 * IN
|
|
|
|
symbols = (
|
|
[_sym(x0 + p * IN, y_bottom) for p in positions_in] +
|
|
[_sym(x0 + p * IN, y_top) for p in positions_in]
|
|
)
|
|
|
|
cum = [6, 16, 52, 88, 124, 160, 169.25]
|
|
chain = _hchain(9.9, [
|
|
('6"', 6 * IN, x0 + cum[0] * IN),
|
|
('10"', 10 * IN, x0 + cum[1] * IN),
|
|
("3' - 0\"", 36 * IN, x0 + cum[2] * IN),
|
|
("3' - 0\"", 36 * IN, x0 + cum[3] * IN),
|
|
("3' - 0\"", 36 * IN, x0 + cum[4] * IN),
|
|
("3' - 0\"", 36 * IN, x0 + cum[5] * IN),
|
|
('9 1/4"', 9.25 * IN, x0 + cum[6] * IN),
|
|
])
|
|
|
|
vert = _vchain([('2"', 2 * IN, 9.5), ('4"', 4 * IN, 9.6),
|
|
('2"', 2 * IN, 9.7)])
|
|
|
|
return {'symbols': symbols, 'dimensions': [chain, vert]}
|
|
|
|
|
|
def test_wpa2_wall_full_grid():
|
|
view = _wpa2_view()
|
|
layout, why = sm.read_layout(view, qty=12, thickness_in=8,
|
|
width_ft=14.0 + 1 * IN + 0.25 * IN)
|
|
assert why is None
|
|
assert layout is not None
|
|
|
|
assert layout['columns_in'] == [6, 16, 52, 88, 124, 160]
|
|
assert layout['total_in'] == 169.25
|
|
assert len(layout['rows']) == 2
|
|
|
|
bottom, top = layout['rows']
|
|
assert bottom['from_bottom_in'] == 2
|
|
assert bottom['strands'] == [True] * 6
|
|
assert top['from_bottom_in'] == 6
|
|
assert top['strands'] == [True] * 6
|
|
|
|
|
|
# ====================================================================== WPA3
|
|
# wall, full grid, single vertical chain. qty 8, thickness 10", width 7'-11".
|
|
|
|
def _wpa3_view():
|
|
x0 = 5.0
|
|
positions_in = [6, 16, 52, 88]
|
|
y_bottom, y_top = 8.0, 8.0 + 6 * IN
|
|
|
|
symbols = (
|
|
[_sym(x0 + p * IN, y_bottom) for p in positions_in] +
|
|
[_sym(x0 + p * IN, y_top) for p in positions_in]
|
|
)
|
|
|
|
cum = [6, 16, 52, 88, 95]
|
|
chain = _hchain(7.4, [
|
|
('6"', 6 * IN, x0 + cum[0] * IN),
|
|
('10"', 10 * IN, x0 + cum[1] * IN),
|
|
("3' - 0\"", 36 * IN, x0 + cum[2] * IN),
|
|
("3' - 0\"", 36 * IN, x0 + cum[3] * IN),
|
|
('7"', 7 * IN, x0 + cum[4] * IN),
|
|
])
|
|
|
|
vert = _vchain([('2"', 2 * IN, 7.9), ('6"', 6 * IN, 8.0),
|
|
('2"', 2 * IN, 8.1)])
|
|
|
|
return {'symbols': symbols, 'dimensions': [chain, vert]}
|
|
|
|
|
|
def test_wpa3_wall_full_grid_single_vertical_chain():
|
|
view = _wpa3_view()
|
|
layout, why = sm.read_layout(view, qty=8, thickness_in=10,
|
|
width_ft=7.0 + 11 * IN)
|
|
assert why is None
|
|
assert layout is not None
|
|
|
|
assert layout['columns_in'] == [6, 16, 52, 88]
|
|
assert layout['total_in'] == 95
|
|
assert len(layout['rows']) == 2
|
|
|
|
bottom, top = layout['rows']
|
|
assert bottom['from_bottom_in'] == 2
|
|
assert bottom['strands'] == [True] * 4
|
|
assert top['from_bottom_in'] == 8
|
|
assert top['strands'] == [True] * 4
|
|
|
|
|
|
# =============================================================== refusals
|
|
|
|
def test_refuses_when_symbol_count_disagrees_with_qty():
|
|
view = _fsa5_view()
|
|
view['symbols'] = view['symbols'][:-1] # drop one -> 18 symbols, qty 19
|
|
layout, why = sm.read_layout(view, qty=19, thickness_in=10,
|
|
width_ft=13.0 + 8 * IN)
|
|
assert layout is None
|
|
assert '18' in why and '19' in why
|
|
|
|
|
|
def test_refuses_when_evidence_has_no_symbols_key():
|
|
view = _fsa5_view()
|
|
del view['symbols']
|
|
layout, why = sm.read_layout(view, qty=19, thickness_in=10, width_ft=None)
|
|
assert layout is None
|
|
assert why == 'evidence carries no symbol positions -- re-run Design Extract'
|
|
|
|
|
|
def test_refuses_when_qty_is_none():
|
|
view = _fsa5_view()
|
|
layout, why = sm.read_layout(view, qty=None)
|
|
assert layout is None
|
|
assert why is not None
|
|
assert 'qty' in why
|
|
|
|
|
|
def test_refuses_when_a_column_has_no_chain():
|
|
# three strands in one row, columns at 0", 10" and 25" relative to the
|
|
# leftmost -- but the only horizontal chain on the drawing measures the
|
|
# first two and never reaches the third.
|
|
x0 = 0.0
|
|
symbols = [_sym(x0, 0.0), _sym(x0 + 10 * IN, 0.0), _sym(x0 + 25 * IN, 0.0)]
|
|
chain = _hchain(-0.1, [('5"', 5 * IN, 0.2), ('10"', 10 * IN, 0.5),
|
|
('8"', 8 * IN, 0.8)])
|
|
view = {'symbols': symbols, 'dimensions': [chain]}
|
|
layout, why = sm.read_layout(view, qty=3, thickness_in=None, width_ft=None)
|
|
assert layout is None
|
|
assert 'no dimension chain measures' in why
|
|
assert '25' in why
|
|
|
|
|
|
def test_refuses_when_closing_chains_disagree_on_total():
|
|
# a single real column (one strand), measured by two differently-banded
|
|
# dimension elements that both close to "the far edge" but disagree on
|
|
# where that edge is -- and no width_ft is given to settle it.
|
|
symbols = [_sym(2 * IN, 0.0)]
|
|
chain_a = _hchain(-0.1, [('2"', 2 * IN, 0.3), ('13"', 13 * IN, 0.6)]) # 15"
|
|
chain_b = _hchain(5.0, [('2"', 2 * IN, 0.3), ('14 1/2"', 14.5 * IN, 0.6)]) # 16.5"
|
|
view = {'symbols': symbols, 'dimensions': [chain_a, chain_b]}
|
|
layout, why = sm.read_layout(view, qty=1, thickness_in=None, width_ft=None)
|
|
assert layout is None
|
|
assert 'disagree' in why and '15' in why and '16.5' in why
|
|
|
|
|
|
def test_refuses_when_a_closing_chain_disagrees_with_known_width():
|
|
# one real column; the only chain closes to the far edge (its position
|
|
# matches the column) but its own total does not match the width_ft
|
|
# given, and it has no columns left over to be read as 'open' instead.
|
|
symbols = [_sym(2 * IN, 0.0)]
|
|
chain = _hchain(-0.1, [('2"', 2 * IN, 0.3), ('13"', 13 * IN, 0.6)]) # 15"
|
|
view = {'symbols': symbols, 'dimensions': [chain]}
|
|
layout, why = sm.read_layout(view, qty=1, thickness_in=None,
|
|
width_ft=20 * IN) # 20", not 15"
|
|
assert layout is None
|
|
assert 'closes to the far edge' in why
|
|
assert '15' in why and '20' in why
|
|
|
|
|
|
def test_refuses_when_no_vertical_chain_present():
|
|
view = _fsa5_view()
|
|
view['dimensions'] = [d for d in view['dimensions']
|
|
if d.get('axis') != 'vertical']
|
|
layout, why = sm.read_layout(view, qty=19, thickness_in=10,
|
|
width_ft=13.0 + 8 * IN)
|
|
assert layout is None
|
|
assert 'vertical chains give 0 depths' in why
|
|
|
|
|
|
def test_refuses_when_vertical_union_spacing_disagrees_with_symbol_rows():
|
|
view = _fsa5_view()
|
|
view['dimensions'] = [d for d in view['dimensions']
|
|
if d.get('axis') != 'vertical']
|
|
# '2"|8"' still gives {2}; perturb the other chain to '5"|3"|2"' so its
|
|
# interior points are {5, 8} -- union {2, 5, 8} has the right COUNT (3)
|
|
# but the wrong spacing (rows are 4" then 2" apart, not 3" then 3").
|
|
vert_a = _vchain([('2"', 2 * IN, 13.50), ('8"', 8 * IN, 13.95)])
|
|
vert_bad = _vchain([('5"', 5 * IN, 13.55), ('3"', 3 * IN, 13.75),
|
|
('2"', 2 * IN, 13.90)])
|
|
view['dimensions'] = view['dimensions'] + [vert_a, vert_bad]
|
|
layout, why = sm.read_layout(view, qty=19, thickness_in=10,
|
|
width_ft=13.0 + 8 * IN)
|
|
assert layout is None
|
|
assert 'spaced' in why
|
|
|
|
|
|
# ============================================================ printed vs drawn
|
|
|
|
def test_printed_wins_over_disagreeing_drawn_value_and_note_says_so():
|
|
# one row, two strands 12" apart; the edge segment (whose value never
|
|
# has to match a gap directly) is printed 1'-0" but drawn as 1.0104 ft
|
|
# (12.1248") -- printed must win, and the note must say the drawing and
|
|
# the label disagree.
|
|
symbols = [_sym(1.0, 0.0), _sym(2.0, 0.0)]
|
|
chain = _hchain(-0.1, [
|
|
("1' - 0\"", 1.0104, 0.5), # edge: disagreement lives here
|
|
("1' - 0\"", 1.0, 1.5), # interior gap: must read as 12" exactly
|
|
("1' - 0\"", 1.0, 2.5), # edge: irrelevant, width_ft is None
|
|
])
|
|
vert = _vchain([('3"', 3 * IN, 0.0), ('3"', 3 * IN, 0.1)])
|
|
view = {'symbols': symbols, 'dimensions': [chain, vert]}
|
|
|
|
layout, why = sm.read_layout(view, qty=2, thickness_in=6, width_ft=None)
|
|
assert why is None
|
|
assert layout is not None
|
|
assert layout['columns_in'] == [12, 24]
|
|
assert 'disagrees with drawn' in layout['note']
|
|
assert 'used printed' in layout['note']
|
|
|
|
|
|
# =================================================================== helpers
|
|
|
|
def test_parse_inches():
|
|
assert sm.parse_inches('2"') == 2
|
|
assert sm.parse_inches('6"') == 6
|
|
assert sm.parse_inches("1' - 0\"") == 12
|
|
assert sm.parse_inches("1'-6\"") == 18
|
|
assert sm.parse_inches('9 1/4"') == 9.25
|
|
assert sm.parse_inches("2' - 0 1/2\"") == 24.5
|
|
assert sm.parse_inches("13' - 8\"") == 164
|
|
assert sm.parse_inches("14' - 1 1/4\"") == 169.25
|
|
assert sm.parse_inches("7' - 11\"") == 95
|
|
assert sm.parse_inches('3/4"') == 0.75
|
|
assert sm.parse_inches('.104L') is None
|
|
assert sm.parse_inches('0.291L') is None
|
|
assert sm.parse_inches('') is None
|
|
assert sm.parse_inches(None) is None
|
|
|
|
|
|
def test_cluster_groups_within_tolerance_and_chains():
|
|
values = [0.0, 0.03, 0.06, 5.0, 5.02]
|
|
groups = sm.cluster(values, 0.05)
|
|
# 0.0-0.03 (0.03 apart) and 0.03-0.06 (0.03 apart) chain into one group
|
|
# even though 0.0 and 0.06 are 0.06 apart -- chaining, not a fixed window.
|
|
assert groups == [[0, 1, 2], [3, 4]]
|
|
|
|
|
|
def test_cluster_sorted_ascending_by_group_mean():
|
|
values = [10.0, 0.0, 10.1, 0.1]
|
|
groups = sm.cluster(values, 0.2)
|
|
means = [sum(values[i] for i in g) / float(len(g)) for g in groups]
|
|
assert means == sorted(means)
|
|
|
|
|
|
def test_band_chains_splices_multiple_dims_and_a_single_by_x():
|
|
view = _fsa5_view()
|
|
chains = sm.band_chains(view['dimensions'])
|
|
bottom = [c for c in chains if abs(c['y'] - 13.595) < 0.05]
|
|
assert len(bottom) == 1
|
|
assert len(bottom[0]['segments']) == 12
|
|
values = [s['value'] for s in bottom[0]['segments']]
|
|
assert values == ['2"', '6"', "1' - 0\"", "1' - 0\"", "1' - 0\"",
|
|
"1' - 0\"", "1' - 0\"", "2' - 0\"", "2' - 0\"",
|
|
"2' - 0\"", "1' - 6\"", '6"']
|
|
|
|
|
|
def test_fsa5_reads_when_multi_segment_chains_carry_no_top_level_origin():
|
|
"""The LIVE scrape shape: a multi-segment dimension has no top-level x/y
|
|
(Dimension.Origin throws on a drafting view -- measured 2026-09-04 on
|
|
three real summaries), only each segment's own label point. The first
|
|
version of band_chains skipped every such dimension and refused all
|
|
three real designs; each segment must band by its OWN y."""
|
|
view = _fsa5_view()
|
|
for d in view['dimensions']:
|
|
if d.get('segments'):
|
|
y = d.pop('y', None)
|
|
for s in d['segments']:
|
|
s.setdefault('y', y if y is not None else 14.0)
|
|
layout, why = sm.read_layout(view, qty=19, thickness_in=10,
|
|
width_ft=13.0 + 8 * IN)
|
|
assert why is None, why
|
|
assert layout['columns_in'] == [2, 8, 20, 32, 44, 56, 68, 92, 116, 140, 158]
|
|
assert [r['from_bottom_in'] for r in layout['rows']] == [2, 6, 8]
|