# -*- 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]