# -*- coding: utf-8 -*- """Tests for the Revit-free half of vibe.liftexport. `liftexport.export()` measures a live assembly and needs a real Revit document -- out of scope here, same as the rest of `vibe`'s Revit-shaped code (see conftest.py). Everything below `export()` -- the stage rules, the grouping, the dunnage/edge-snap arithmetic -- takes and returns plain dicts and floats, and that is what is tested here, built directly from `knowledge.DEFAULTS` so nothing needs a knowledge.yaml on disk either. Two of the three bugs found on 2026-08-23 trace to this module: * `default_stages()` used to leave an EMPTY `'lifters': []` on a stage it had turned into dunnage supports (fixed in 37b422c, this repo's history). `test_dunnage_stage_has_no_lifters_key_at_all` pins the fix: the key must be ABSENT, not empty, because that is the contract two callers (`tools/sweep_in_revit.py`, the PanelLiftCheck button) rely on to tell "picked nothing" apart from "not an insert stage at all". * Those two callers then did `len(s['lifters'])` unconditionally and KeyError'd the moment a stage actually took that shape (fixed in 6c41d6e). That fix lives one file away and needs Revit to exercise directly; the regression test for the bug is the one above, which pins the contract those callers must honour. """ import sys import types from vibe import knowledge, liftexport # --------------------------------------------------------------- fixtures def make_know(dunnage=None): """A deep copy of knowledge.DEFAULTS, optionally with handling.dunnage set. DEFAULTS already carries the real three-stage stripping/transportation/ erection schedule (see knowledge.py), so tests exercise the actual production stage rules rather than a hand-trimmed copy of them. """ know = knowledge._deep_copy(knowledge.DEFAULTS) if dunnage is not None: know['handling']['dunnage'] = dunnage return know def make_lifter(mark, family, x, y, group): return {'mark': mark, 'family': family, 'x': x, 'y': y, 'group': group, 'capacity_lb': 8000.0} # Two rows of face lifters (bottom at x=3, top at x=17) plus one edge anchor # at the very bottom (x=0) -- enough to exercise rows_of's grouping, # exclude_top_rows on erection, and both dunnage modes. FACE_AND_EDGE_LIFTERS = [ make_lifter('L1', 'ANCHOR_FACE_4T', 3.0, 2.5, 'face'), make_lifter('L2', 'ANCHOR_FACE_4T', 3.0, 7.5, 'face'), make_lifter('L3', 'ANCHOR_FACE_4T', 17.0, 2.5, 'face'), make_lifter('L4', 'ANCHOR_FACE_4T', 17.0, 7.5, 'face'), make_lifter('E1', 'ANCHOR_EDGE', 0.0, 5.0, 'edge_top'), ] def make_job(lifters, length_ft=20.0, width_ft=10.0, fc=5000.0, fci=3500.0, erected_axis='length'): """A minimal job dict: just enough for default_stages() and friends. `_groups` is carried outside `panel` exactly as liftexport.export() leaves it -- see its docstring -- so default_stages() can look a mark back up to the group it belongs to. """ return { 'panel': { 'length_ft': length_ft, 'width_ft': width_ft, 'fc_psi': fc, 'fci_psi': fci, 'lifters': [dict((k, v) for k, v in l.items() if k != 'group') for l in lifters], }, 'rigging': {'group_tolerance_ft': 0.4}, 'source': {'erected_vertical_axis': erected_axis}, '_groups': dict((l['mark'], l['group']) for l in lifters), } # ------------------------------------------------------------ strand_area_in2 def test_strand_area_in2_known_diameter(): know = knowledge._deep_copy(knowledge.DEFAULTS) assert liftexport.strand_area_in2(know, 0.5) == 0.153 assert liftexport.strand_area_in2(know, 0.375) == 0.085 def test_strand_area_in2_unknown_diameter_is_none(): know = knowledge._deep_copy(knowledge.DEFAULTS) assert liftexport.strand_area_in2(know, 0.25) is None def test_strand_area_in2_empty_table_is_none(): know = {'strands': {'area_in2': {}}} assert liftexport.strand_area_in2(know, 0.5) is None # ------------------------------------------------------------- snap_to_edge def test_snap_to_edge_passthrough_when_not_top_or_bottom(): # 'measured' (the default 'at') and anything else that is not top/bottom # leaves the modelled position exactly alone. x, y, fixed_x, fixed_y = liftexport.snap_to_edge( 'measured', 'length', 4.2, 1.1, 20.0, 10.0) assert (x, y, fixed_x, fixed_y) == (4.2, 1.1, False, False) def test_snap_to_edge_vertical_panel_snaps_x(): # Erected on end (vertical_axis == 'length'): the top/bottom is an END # of the piece, so x snaps to the piece extent and y is left alone. x, y, fixed_x, fixed_y = liftexport.snap_to_edge( 'top', 'length', 4.2, 1.1, 20.0, 10.0) assert (x, y, fixed_x, fixed_y) == (20.0, 1.1, True, False) x, y, fixed_x, fixed_y = liftexport.snap_to_edge( 'bottom', 'length', 4.2, 1.1, 20.0, 10.0) assert (x, y, fixed_x, fixed_y) == (0.0, 1.1, True, False) def test_snap_to_edge_horizontal_panel_snaps_y(): # Erected on its long edge (vertical_axis == 'width'): the top/bottom is # a long EDGE, so y snaps and x -- where along the length -- is a real # choice and stays put. x, y, fixed_x, fixed_y = liftexport.snap_to_edge( 'top', 'width', 4.2, 1.1, 20.0, 10.0) assert (x, y, fixed_x, fixed_y) == (4.2, 10.0, False, True) x, y, fixed_x, fixed_y = liftexport.snap_to_edge( 'bottom', 'width', 4.2, 1.1, 20.0, 10.0) assert (x, y, fixed_x, fixed_y) == (4.2, 0.0, False, True) # ----------------------------------------------------------------- rows_of def test_rows_of_groups_within_tolerance(): lifters = [make_lifter('A', 'F', 3.0, 1.0, 'face'), make_lifter('B', 'F', 3.3, 9.0, 'face'), make_lifter('C', 'F', 17.0, 1.0, 'face')] rows = liftexport.rows_of(lifters, tolerance=0.4, along='x') assert len(rows) == 2 assert rows[0]['marks'] == ['A', 'B'] assert rows[0]['x'] == (3.0 + 3.3) / 2.0 assert rows[1]['marks'] == ['C'] def test_rows_of_grouping_is_against_the_running_average_not_the_first_member(): # Documents the actual grouping rule rather than an idealised one: each # new lifter is compared against the ROW'S RUNNING AVERAGE so far, not # against the first member of the row. Three lifters half a tolerance # apart therefore end up split 2/1, not merged into one row, because the # third is compared against the already-shifted average of the first two. lifters = [make_lifter('A', 'F', 0.0, 0.0, 'face'), make_lifter('B', 'F', 0.35, 0.0, 'face'), make_lifter('C', 'F', 0.70, 0.0, 'face')] rows = liftexport.rows_of(lifters, tolerance=0.4, along='x') assert [r['marks'] for r in rows] == [['A', 'B'], ['C']] def test_rows_of_along_y(): lifters = [make_lifter('A', 'F', 1.0, 2.0, 'face'), make_lifter('B', 'F', 5.0, 2.1, 'face')] rows = liftexport.rows_of(lifters, tolerance=0.4, along='y') assert len(rows) == 1 assert sorted(rows[0]['marks']) == ['A', 'B'] def test_rows_of_group_is_a_set_not_the_first_members_group(): # An edge anchor and a face lifter at the same station must not have the # row stamped with whichever sorted first -- see the docstring in # liftexport.rows_of. Losing this cost a real piece two of eight # inserts in every stage that wanted them. lifters = [make_lifter('L1', 'ANCHOR_FACE_4T', 5.0, 1.0, 'face'), make_lifter('E1', 'ANCHOR_EDGE', 5.1, 9.0, 'edge_top')] rows = liftexport.rows_of(lifters, tolerance=0.4, along='x') assert len(rows) == 1 assert rows[0]['groups'] == set(['face', 'edge_top']) def test_rows_of_counts_per_family(): lifters = [make_lifter('A', 'FAM_A', 1.0, 0.0, 'face'), make_lifter('B', 'FAM_A', 1.1, 0.0, 'face'), make_lifter('C', 'FAM_B', 1.2, 0.0, 'face')] rows = liftexport.rows_of(lifters, tolerance=0.4, along='x') assert len(rows) == 1 assert rows[0]['counts'] == {'FAM_A': 2, 'FAM_B': 1} # ------------------------------------------------------------ dunnage_supports def test_dunnage_supports_mode_none_is_the_old_approximation(): know = make_know(dunnage=None) supports, problems = liftexport.dunnage_supports(know, 20.0, []) assert supports is None assert problems == [] def test_dunnage_supports_lifter_rows_mode(): know = make_know(dunnage={'mode': 'lifter_rows', 'fraction': None}) rows = [{'x': 3.0}, {'x': 17.0}] supports, problems = liftexport.dunnage_supports(know, 20.0, rows) assert supports == [3.0, 17.0] assert problems == [] def test_dunnage_supports_lifter_rows_refuses_fewer_than_two_rows(): know = make_know(dunnage={'mode': 'lifter_rows', 'fraction': None}) supports, problems = liftexport.dunnage_supports(know, 20.0, [{'x': 10.0}]) assert supports is None assert len(problems) == 1 assert 'lifter_rows' in problems[0] assert 'fewer than two' in problems[0] def test_dunnage_supports_fraction_mode(): know = make_know(dunnage={'mode': 'fraction', 'fraction': 0.2}) supports, problems = liftexport.dunnage_supports(know, 20.0, []) assert supports == [4.0, 16.0] assert problems == [] def test_dunnage_supports_fraction_mode_requires_a_fraction(): know = make_know(dunnage={'mode': 'fraction', 'fraction': None}) supports, problems = liftexport.dunnage_supports(know, 20.0, []) assert supports is None assert len(problems) == 1 assert 'fraction is not set' in problems[0] def test_dunnage_supports_fraction_mode_rejects_a_non_number(): know = make_know(dunnage={'mode': 'fraction', 'fraction': 'lots'}) supports, problems = liftexport.dunnage_supports(know, 20.0, []) assert supports is None assert 'is not a number' in problems[0] def test_dunnage_supports_fraction_mode_rejects_out_of_range(): know = make_know(dunnage={'mode': 'fraction', 'fraction': 0.6}) supports, problems = liftexport.dunnage_supports(know, 20.0, []) assert supports is None assert 'must be between 0 and 0.5' in problems[0] # ------------------------------------------------------------- default_stages def test_default_stages_basic_shape(): know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS) stages, all_rows, problems = liftexport.default_stages(job, know) assert [s['name'] for s in stages] == \ ['stripping', 'transportation', 'erection'] assert problems == [] stripping = stages[0] # strength: fci for stripping, fc for the other two -- see DEFAULTS. assert stripping['fc_psi'] == 3500.0 assert sorted(stripping['lifters']) == ['L1', 'L2', 'L3', 'L4'] assert stages[1]['fc_psi'] == 5000.0 assert sorted(stages[1]['lifters']) == ['L1', 'L2', 'L3', 'L4'] def test_default_stages_erection_excludes_top_row_and_keeps_edge_anchor(): # DEFAULTS erection: groups face+edge_top, exclude_top_rows: 1. The row # dropped must be the topmost row that is PURELY the movable ('face') # group -- a row holding the edge anchor too still holds an anchor and # must survive. See the long comment above the drop logic in # default_stages(). know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS) stages, _, _ = liftexport.default_stages(job, know) erection = stages[2] assert sorted(erection['lifters']) == ['E1', 'L1', 'L2'] assert erection['_dropped_marks'] == 2 # L3, L4 (the top row) assert erection['_available'] == 5 # all 4 face + 1 edge def test_default_stages_erection_top_row_merged_with_anchor_keeps_both_lines(): """Reproduces a real refusal: WPA659, a piece only ~4 ft across the axis it erects on. Its two face rows sit 2 ft apart across that axis and the edge_top anchor row sits only 1 ft above the higher face row -- within group_tolerance_ft, so rows_of() merges the anchor row into the face row nearest the erected-up end, leaving ONE mixed row up top and one plain face row at the other end. The old exclude_top_rows code filtered to "purely one group" BEFORE ranking by position, so once the true top row lost its purity by merging with the anchor, the lone surviving pure row -- the one at the OTHER end -- was mistaken for "the top face row" and dropped. That left a single support line, which panellift's group_lifters correctly refuses to analyse as a pick ("all N lifters fall on one line"). The fix ranks ALL rows by position first and only drops one that is both in the top `exclude_top_rows` slots and still purely the movable group, so a row that merged with the anchor is protected and the other end's row is left alone. """ know = make_know() lifters = [ make_lifter('B1', 'ANCHOR_FACE_4T', 5.0, 1.0, 'face'), make_lifter('B2', 'ANCHOR_FACE_4T', 15.0, 1.0, 'face'), make_lifter('T1', 'ANCHOR_FACE_4T', 5.0, 3.0, 'face'), make_lifter('T2', 'ANCHOR_FACE_4T', 15.0, 3.0, 'face'), make_lifter('E1', 'ANCHOR_EDGE', 5.0, 4.0, 'edge_top'), make_lifter('E2', 'ANCHOR_EDGE', 15.0, 4.0, 'edge_top'), ] job = make_job(lifters, width_ft=4.0, erected_axis='width') job['rigging']['group_tolerance_ft'] = 1.0 stages, _, _ = liftexport.default_stages(job, know) erection = next(s for s in stages if s['name'] == 'erection') assert erection['span'] == 'width' assert sorted(erection['lifters']) == ['B1', 'B2', 'E1', 'E2', 'T1', 'T2'] assert erection['_dropped_marks'] == 0 def test_dunnage_stage_has_no_lifters_key_at_all(): """THE regression test. See the module docstring. A stage that becomes dunnage supports must have its 'lifters' key dropped, not emptied -- panellift refuses a stage naming both a support line and an insert, and `sweep_in_revit.py` / PanelLiftCheck used to do `len(s['lifters'])` unconditionally, which is exactly what an empty list would have kept alive silently. """ know = make_know(dunnage={'mode': 'fraction', 'fraction': 0.2}) job = make_job(FACE_AND_EDGE_LIFTERS) stages, _, problems = liftexport.default_stages(job, know) transportation = next(s for s in stages if s['name'] == 'transportation') assert 'lifters' not in transportation assert transportation['supports'] == [4.0, 16.0] assert problems == [] # The other two stages are unaffected -- only the stage whose pci_case is # 'transportation' turns into supports. stripping = next(s for s in stages if s['name'] == 'stripping') erection = next(s for s in stages if s['name'] == 'erection') assert 'lifters' in stripping assert 'lifters' in erection def test_dunnage_stage_falls_back_to_the_old_approximation_on_refusal(): # Fewer than two stripping rows -> dunnage_supports refuses -> the # transportation stage keeps its ordinary 'lifters' key (the old lift # approximation) rather than ending up with neither key. know = make_know(dunnage={'mode': 'lifter_rows', 'fraction': None}) one_row = [make_lifter('L1', 'ANCHOR_FACE_4T', 10.0, 2.5, 'face'), make_lifter('L2', 'ANCHOR_FACE_4T', 10.0, 7.5, 'face')] job = make_job(one_row) stages, _, problems = liftexport.default_stages(job, know) transportation = next(s for s in stages if s['name'] == 'transportation') assert 'lifters' in transportation assert 'supports' not in transportation assert len(problems) == 1 assert 'fewer than two' in problems[0] # ------------------------------------------------------------- strip_private def test_strip_private_drops_underscored_keys_at_every_level(): job = { 'schema': 'panellift/1', '_groups': {'L1': 'face'}, 'stages': [ {'name': 'stripping', 'lifters': ['L1'], '_rows': [{'x': 1.0}], '_available': 1, '_dropped_marks': 0}, {'name': 'transportation', 'supports': [4.0, 16.0], '_rows': [], '_available': 0, '_dropped_marks': 0}, ], } clean = liftexport.strip_private(job) assert '_groups' not in clean assert clean['schema'] == 'panellift/1' for stage in clean['stages']: assert not any(k.startswith('_') for k in stage) assert clean['stages'][0] == {'name': 'stripping', 'lifters': ['L1']} assert clean['stages'][1] == {'name': 'transportation', 'supports': [4.0, 16.0]} def test_strip_private_does_not_mutate_the_original_job(): job = {'_groups': {'L1': 'face'}, 'stages': [{'name': 's', '_rows': []}]} liftexport.strip_private(job) assert '_groups' in job assert '_rows' in job['stages'][0] # --------------------------------------- which run mode gets which measure def test_a_single_piece_is_measured_fast_and_a_batch_precisely(): """The shipped defaults, and they are opposite on purpose: a batch runs unattended so it should be right, one piece is somebody waiting.""" know = knowledge._deep_copy(knowledge.DEFAULTS) assert liftexport._fast_box(know, batch=False) is True assert liftexport._fast_box(know, batch=True) is False def test_each_run_mode_reads_its_own_key(): know = {'handling': {'fast_lifter_box': False, 'fast_lifter_box_batch': True}} assert liftexport._fast_box(know, batch=False) is False assert liftexport._fast_box(know, batch=True) is True def test_a_missing_key_falls_back_to_that_mode_and_not_the_other(): """An empty config must not make a batch fast: nobody chose that, and the two keys mean opposite things.""" assert liftexport._fast_box({}, batch=True) is False assert liftexport._fast_box({}, batch=False) is True # ------------------------------------------------------ stages[].enabled def test_default_stages_writes_no_enabled_key_when_every_stage_is_on(): """The flag is additive at the WRITING end too. Every plant configured today has no `enabled` in its stage specs, and absent means enabled at both ends -- so emitting `"enabled": true` on all of them would rewrite every job file on disk to say nothing.""" stages, _rows, _problems = liftexport.default_stages( make_job(FACE_AND_EDGE_LIFTERS), make_know()) assert all('enabled' not in s for s in stages) assert all('disabled_reason' not in s for s in stages) def test_default_stages_carries_a_switched_off_stage_through(): """It stays in the list. That is the whole reason for having a flag instead of deleting the stage from handling.stages, where its absence would be indistinguishable from a stage that passed.""" know = make_know() know['handling']['stages'][2]['enabled'] = False know['handling']['stages'][2]['disabled_reason'] = \ 'the crane on this site cannot reach the top insert row' stages, _rows, _problems = liftexport.default_stages( make_job(FACE_AND_EDGE_LIFTERS), know) assert [s['name'] for s in stages] == \ ['stripping', 'transportation', 'erection'] assert stages[2]['enabled'] is False assert stages[2]['disabled_reason'] == \ 'the crane on this site cannot reach the top insert row' # And the stages around it are untouched. assert 'enabled' not in stages[0] and 'enabled' not in stages[1] def test_a_switched_off_stage_survives_strip_private(): """strip_private is what actually reaches the job file, and it drops every key of ours. The flag is not one of ours -- panellift reads it.""" know = make_know() know['handling']['stages'][0]['enabled'] = False know['handling']['stages'][0]['disabled_reason'] = 'checked by hand' job = make_job(FACE_AND_EDGE_LIFTERS) job['stages'], _rows, _problems = liftexport.default_stages(job, know) clean = liftexport.strip_private(job) assert clean['stages'][0]['enabled'] is False assert clean['stages'][0]['disabled_reason'] == 'checked by hand' def test_stage_audit_says_nothing_about_a_stage_that_is_switched_off(): """How many inserts a stage was entitled to is not a finding about a stage nobody is analysing, and an audit that reports non-findings is an audit people learn to skim.""" # A stage that lost an insert it was entitled to: four available, none # deliberately dropped, three picked. That IS a finding when the stage # runs, which is what makes the switched-off case worth asserting. losing = {'name': 'erection', 'lifters': ['L1', 'L2', 'L3'], '_available': 4, '_dropped_marks': 0} assert liftexport.stage_audit([dict(losing)]) assert liftexport.stage_audit([dict(losing, enabled=False)]) == [] # ------------------------------------------------- cast extras, member by member # # Checklist 2.4 needs the LIST of cast extras, not their total: a number # cannot be compared against the set of extras a ticket draws. So # `extra_cast_ft3` was split -- `extra_cast_members` is the rule, the total # is its sum -- and these pin that the split did not change the total and # that an unreadable Volume stays None rather than becoming a quiet 0.0. # # The fakes below are duck types: extra_cast_members touches only # GetMemberIds / GetElement / Symbol.Family.Name / Parameters / Id, and the # Mark read is wrapped so it degrades to None off Revit. class _FakeDefinition(object): def __init__(self, name): self.Name = name class _FakeParam(object): def __init__(self, name, value): self.Definition = _FakeDefinition(name) self.HasValue = value is not None self._value = value def AsDouble(self): return self._value class _FakeId(object): def __init__(self, value): self.IntegerValue = value class _FakeFamily(object): def __init__(self, name): self.Name = name class _FakeSymbol(object): def __init__(self, family): self.Family = _FakeFamily(family) class _FakeMember(object): def __init__(self, family, volume, eid=1): self.Symbol = _FakeSymbol(family) self.Parameters = [_FakeParam('Comments', 1.0), _FakeParam('Volume', volume)] self.Id = _FakeId(eid) class _FakeAssembly(object): def __init__(self, members): self._members = members def GetMemberIds(self): return list(range(len(self._members))) class _FakeDoc(object): def __init__(self, members): self._members = members def GetElement(self, mid): return self._members[mid] def _cast_doc(members): return _FakeDoc(members), _FakeAssembly(members) def test_extra_cast_members_lists_what_the_total_sums(): know = make_know() members = [_FakeMember('SLAB SEAT LEDGE', 8.99, 11), _FakeMember('EMBED PLATE', 0.5, 12), _FakeMember('LEDGE B', 1.01, 13)] doc, asm = _cast_doc(members) out = liftexport.extra_cast_members(doc, asm, know) assert [m['family'] for m in out] == ['SLAB SEAT LEDGE', 'LEDGE B'] assert [m['id'] for m in out] == ['11', '13'] assert liftexport.extra_cast_ft3(doc, asm, know) == 10.0 def test_an_unreadable_volume_is_none_not_a_quiet_zero(): """It contributed nothing to 2.2's reconstructed weight, and 2.4 has to be able to SAY that rather than see a 0.0 it cannot tell from a real zero-volume member.""" know = make_know() members = [_FakeMember('LEDGE', None, 21)] doc, asm = _cast_doc(members) out = liftexport.extra_cast_members(doc, asm, know) assert out[0]['volume_ft3'] is None assert liftexport.extra_cast_ft3(doc, asm, know) == 0.0 def test_no_cast_include_list_means_no_extras_not_a_guess(): know = make_know() know['families']['cast_include'] = [] doc, asm = _cast_doc([_FakeMember('LEDGE', 8.99)]) assert liftexport.extra_cast_members(doc, asm, know) == [] assert liftexport.extra_cast_ft3(doc, asm, know) == 0.0 # ------------------------------------- what the calculation is actually asked # `run()` imports System.Diagnostics and cannot be called off IronPython at # all, so the one string that decides what the subprocess is asked to do had # no test on it. `runner_script` is that string, split out so it does. # # The batch passes recommend=False and gets 40 seconds instead of 41 minutes; # a flag that silently never reached the command line would look exactly like # a batch that is still slow, and a setting that does nothing is the trap # CLAUDE.md names for the project config file. def test_the_runner_asks_for_the_position_search_by_default(): text = liftexport.runner_script('py.exe', 'j.json', 'r.html', 's.json', 'r.log') assert '--no-recommend' not in text assert '-m panellift "j.json" -o "r.html" --json > "s.json" 2> "r.log"' \ in text def test_the_runner_passes_no_recommend_when_told_to(): text = liftexport.runner_script('py.exe', 'j.json', 'r.html', 's.json', 'r.log', recommend=False) assert '--json --no-recommend > "s.json"' in text def test_the_runner_still_redirects_both_streams_to_files(): """Two pipes deadlock the moment the child fills one of them; the whole reason this is a cmd wrapper rather than two Popen pipes.""" for flag in (True, False): text = liftexport.runner_script('py.exe', 'j.json', 'r.html', 's.json', 'r.log', recommend=flag) assert '> "s.json"' in text and '2> "r.log"' in text assert text.startswith('@echo off\r\n') assert text.endswith('exit /b %ERRORLEVEL%\r\n') assert 'PYTHONIOENCODING=utf-8' in text assert 'chcp 65001' in text # ------------------------------------------------- what the solid sum is OF # # THE DEFECT THESE PIN (measured 2026-08-31, live runs). On the job's # insulated 16 in. panels the modelled-solid weight came out at about 65% of # the printed weight, consistently -- WP115 printed 18,573 lb, its own stored # weight parameter 18,376 lb, the modelled solid 11,983 lb; WP109 4,189 lb # against 2,880 lb. The two independent statements about the piece agreed # with each other to ~1% and the solid sum was the outlier. # # The cause was one line: `concrete_solid` took the LARGEST solid the body # draws and called it the piece. That is right for an 8 in. solid panel, # whose body draws one. A 16 in. insulated body draws several, and the # largest of those is one wythe. The weight gate then refused -- correctly, # on a number that was wrong -- so 2.2 warned and 6.3 reported that the # handling check could not run, and the whole handling analysis computed # nothing on 27 pieces, 15 of them with issued tickets. # # `choose_concrete` is the repair and it is PURE, which is the point: the # geometry walk needs Revit, the decision does not, so the decision is # tested here on host CPython with no model and no licence. What only a live # press can settle is what `solid_entries` actually reads off an insulated # body -- how many solids it draws, and whether their materials publish a # density. def entry(volume, material=None, density=None, mid=None): """One solid, as `solid_entries` hands it to `choose_concrete`.""" return {'volume_ft3': volume, 'material_name': material, 'material_id': mid if mid is not None else material, 'density_pcf': density, 'mixed_material': False} def test_one_solid_is_the_whole_piece_and_says_so(): """The 8 in. solid panel -- 184 of the 184 pieces in the reference sweep. Behaviour here must not move.""" out = liftexport.choose_concrete([entry(88.4132, 'CONCRETE', 150.0)]) assert abs(out['volume_ft3'] - 88.4132) < 1e-9 assert out['basis'] == 'one material' assert 'one solid' in out['why'] def test_two_wythes_of_the_same_concrete_are_both_counted(): """The bug at its simplest: two solids, and the old code weighed the bigger one. 65% of a piece is not the piece.""" out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0), entry(44.0, 'CONCRETE', 150.0)]) assert out['volume_ft3'] == 124.0 assert out['basis'] == 'one material' assert len(out['included']) == 2 def test_insulation_is_left_out_by_its_density_not_by_its_name(): """No product name anywhere in this. Concrete is 145-150 pcf and the board between the wythes is under 5, so a threshold in the middle tells them apart without knowing what the plant buys.""" out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0), entry(44.0, 'CONCRETE', 150.0), entry(70.0, 'RIGID BOARD', 2.0)]) assert out['volume_ft3'] == 124.0 assert out['basis'] == 'material density' assert [e['volume_ft3'] for e in out['excluded']] == [70.0] def test_the_sum_says_what_went_into_it_and_what_did_not(): """Rule 4 of this repair: the weight may not travel as a bare number. Both wythes with the insulation left out is a different claim from 18,500 lb.""" out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0), entry(44.0, 'CONCRETE', 150.0), entry(70.0, 'RIGID BOARD', 2.0)]) why = out['why'] assert 'summed 2 of the 3 solids' in why assert 'CONCRETE' in why and 'RIGID BOARD' in why assert '70.00 ft3' in why and 'under 50 pcf' in why def test_a_body_of_one_unnamed_material_is_summed_whole_and_says_so(): """Materials unread is not a licence to pick one solid. Everything counts, and the sentence says the weight is too heavy by whatever of it is not concrete -- which the weight gate then catches.""" out = liftexport.choose_concrete([entry(80.0), entry(44.0)]) assert out['volume_ft3'] == 124.0 assert 'ALL counted as concrete' in out['why'] assert 'too heavy' in out['why'] def test_several_materials_none_priced_refuses_rather_than_guessing(): """The one case with no honest answer: several materials, no density on any of them, and no layer naming which is concrete. Summing them weighs the insulation; taking the largest is the defect being repaired. So it stands down, and names the two wrong answers it declined.""" out = liftexport.choose_concrete([entry(80.0, 'MIX A'), entry(70.0, 'MIX B')]) assert out['volume_ft3'] is None assert out['basis'] is None assert 'no material on them publishes a density' in out['why'] assert 'drop a wythe' in out['why'] def test_a_plant_may_name_its_concrete_when_no_density_is_published(): know = make_know() know['materials'] = {'concrete_names': ['MIX A']} out = liftexport.choose_concrete([entry(80.0, 'MIX A'), entry(70.0, 'MIX B')], know) assert out['volume_ft3'] == 80.0 assert out['basis'] == 'material name' assert 'Left out 70.00 ft3' in out['why'] def test_a_named_list_still_refuses_where_a_solid_has_no_material_name(): """A solid nobody can name is not a solid that is not concrete. Leaving it out quietly is a piece missing a wythe.""" know = make_know() know['materials'] = {'concrete_names': ['MIX A']} out = liftexport.choose_concrete([entry(80.0, 'MIX A'), entry(70.0)], know) assert out['volume_ft3'] is None assert 'no readable material name' in out['why'] def test_the_density_threshold_is_a_knowledge_key(): know = make_know() know['materials'] = {'concrete_min_pcf': 100.0} solids = [entry(80.0, 'HEAVY', 150.0), entry(44.0, 'LIGHT', 90.0)] assert liftexport.choose_concrete(solids, know)['volume_ft3'] == 80.0 assert liftexport.choose_concrete(solids)['volume_ft3'] == 124.0 def test_nothing_to_weigh_is_none_and_never_zero(): """0.0 ft3 reads downstream as a piece that weighs nothing, which the weight gate would report as a model that is wrong rather than as a read that never happened.""" out = liftexport.choose_concrete([]) assert out['volume_ft3'] is None assert 'no solid' in out['why'] def test_a_body_that_is_all_insulation_is_none_not_zero(): out = liftexport.choose_concrete([entry(70.0, 'RIGID BOARD', 2.0), entry(10.0, 'FOAM', 1.5)]) assert out['volume_ft3'] is None assert 'none of them is concrete' in out['why'] def test_zero_volume_solids_are_not_counted_as_a_material(): """A degenerate sliver must not be able to make a one-material body look like a two-material one and push it down the refusing branch.""" out = liftexport.choose_concrete([entry(80.0, 'CONCRETE', 150.0), entry(0.0, 'AIR', 0.0)]) assert out['volume_ft3'] == 80.0 assert out['basis'] == 'one material' def test_two_solids_of_one_material_are_two_entries_not_one(): """Equal dicts. A split done with `in` would put both on whichever side the first one landed, and the second wythe would vanish or double.""" know = make_know() know['materials'] = {'concrete_names': ['CONCRETE']} out = liftexport.choose_concrete([entry(80.0, 'CONCRETE'), entry(80.0, 'CONCRETE'), entry(70.0, 'BOARD')], know) assert out['volume_ft3'] == 160.0 assert len(out['included']) == 2 # --------------------------------- the gate prints what the sum was OF def _weight_job(solid_ft3, note, basis='one material'): return { 'panel': {'length_ft': 20.0, 'width_ft': 10.0, 'thickness_in': 8.0, 'unit_weight_pcf': 150.0, 'weight_lb': 20000.0, 'blockouts': [], 'extra_cast_ft3': 0.0}, 'source': {'solid_ft3': solid_ft3, 'solid_basis': basis, 'solid_note': note}, } def test_the_gate_prints_the_note_beside_the_solid_weight(): ok, message, numbers = liftexport.weight_check( _weight_job(100.0, 'summed 2 of the 3 solids the body draws')) assert not ok assert 'summed 2 of the 3 solids the body draws' in message assert numbers['solid_basis'] == 'one material' def test_the_gate_says_why_there_is_no_solid_weight_at_all(): """A missing measured weight used to leave a silent gap: the gate fell back to the two dimension-derived weights, they agreed with each other because they are built from the same dimensions, and the piece passed a check that never ran. Note the job below: stated 20,000 lb and reconstructed 20,000 lb agree exactly, and it still refuses.""" ok, message, _numbers = liftexport.weight_check( _weight_job(None, 'the body draws 2 solids of 2 different materials', basis=None)) assert not ok assert 'not established' in message assert 'the body draws 2 solids of 2 different materials' in message assert 'never weighed' in message def test_the_gate_still_passes_a_piece_whose_three_weights_agree(): """The other side of the rule above: nothing here makes the gate fussier about a piece it can actually measure.""" ok, message, _numbers = liftexport.weight_check( _weight_job(133.3333, 'the body draws one solid')) assert ok and message == '' # ------------------------------------------------------- the sandwich layup # # `choose_concrete` above answers WHAT THE BODY WEIGHS. These answer WHERE # ITS SOLIDS SIT, which is a different question and the one the section needs. # Volumes alone are enough to say "this is a sandwich" and refuse; they are # not enough to compute a section, and the difference between those two is # the difference between a refusal and a wrong number. # # `layup_of` and `_solid_parts` are pure -- plain dicts in, plain dicts out -- # for the same reason `choose_concrete` is: the geometry walk needs Revit, # the arrangement does not. # # The dimensions are a real 16 in. insulated piece measured 2026-08-31: # 8 in. structural wythe, 4 in. EPS, 4 in. architectural wythe. L_AXIS, W_AXIS, T_AXIS = 0, 1, 2 AXES = (L_AXIS, W_AXIS, T_AXIS) def placed(volume, material, density, z0, z1): """One solid as `solid_entries` hands it over, positioned. lo_in / hi_in are (length, width, thickness) in inches in the panel's local axes; only the thickness component is exercised here, and it is deliberately offset from zero so a test cannot pass by accident on an origin the code happened to assume. """ return {'volume_ft3': volume, 'material_name': material, 'material_id': material, 'density_pcf': density, 'mixed_material': False, 'lo_in': [0.0, 0.0, 100.0 + z0], 'hi_in': [335.3, 64.0, 100.0 + z1]} def wp_entries(): """The real piece, largest solid first, as panel_solids sorts them.""" return [placed(79.885, 'STRUCT WYTHE', 150.28, 8.0, 16.0), placed(39.8, 'EPS', 1.5, 4.0, 8.0), placed(39.557, 'ARCH WYTHE', 150.28, 0.0, 4.0)] def test_the_layup_comes_out_ordered_through_the_thickness(): """Solids arrive largest-first, which is the order the WEIGHT wants and is meaningless to a section. The layup is bottom-up.""" out = liftexport.layup_of(wp_entries(), AXES) assert out['measured'] is True assert [l['material'] for l in out['layers']] == \ ['ARCH WYTHE', 'EPS', 'STRUCT WYTHE'] assert [(l['z0_in'], l['z1_in']) for l in out['layers']] == \ [(0.0, 4.0), (4.0, 8.0), (8.0, 16.0)] def test_the_envelope_is_measured_over_every_solid_not_the_largest(): """The whole point. `panel.thickness_in` comes off the largest solid, so on this piece it is the 8 in. wythe; the envelope is 16 in. Reporting the first as the section depth is the unconservative reading, and reporting it as the envelope is the wrong number in the other direction -- so both are written and neither is inferred from the other.""" out = liftexport.layup_of(wp_entries(), AXES) assert out['envelope_thickness_in'] == 16.0 largest = max(wp_entries(), key=lambda e: e['volume_ft3']) assert largest['hi_in'][T_AXIS] - largest['lo_in'][T_AXIS] == 8.0 def test_an_unmeasured_solid_makes_the_whole_layup_unmeasured(): """An incomplete layup is not a thinner panel. One solid nobody could place makes the arrangement unknown, and a partial answer here would read as a section with a wythe missing.""" entries = wp_entries() entries[1] = dict(entries[1], lo_in=None, hi_in=None) out = liftexport.layup_of(entries, AXES) assert out['measured'] is False assert out['layers'] == [] assert out['envelope_thickness_in'] is None assert 'nobody measured' in out['why'] def test_no_frame_means_no_layup_and_says_so(): out = liftexport.layup_of(wp_entries(), None) assert out['measured'] is False assert 'frame' in out['why'] def test_an_empty_body_stands_the_layup_down_rather_than_returning_nothing(): out = liftexport.layup_of([], AXES) assert out['measured'] is False assert out['why'] # Every key present whether or not it could be answered -- rule 3 of the # file contract. A missing key reads downstream as "nothing to check". for key in ('layers', 'envelope_thickness_in', 'z_origin_offset_in', 'measured', 'why', 'z_origin'): assert key in out def test_solid_parts_carry_their_place_through_the_thickness(): """This is the signal panellift detects a sandwich on, and it has to travel IN the job file: the file is the boundary, and re-deriving a geometry fact on the far side of it would be a guess with an accent.""" concrete = liftexport.choose_concrete(wp_entries()) parts = liftexport._solid_parts(concrete, AXES) assert [p['counted'] for p in parts] == [True, True, False] by_material = dict((p['material'], (p['z0_in'], p['z1_in'])) for p in parts) assert by_material['ARCH WYTHE'] == (0.0, 4.0) assert by_material['EPS'] == (4.0, 8.0) assert by_material['STRUCT WYTHE'] == (8.0, 16.0) def test_solid_parts_are_rebased_onto_the_envelope_not_the_largest_solid(): """`concrete_extents`' origin is the largest solid's, under which the other wythe of a sandwich sits at a NEGATIVE z. A negative depth would look like a modelling error rather than an origin choice, so the list is rebased onto the lowest face of any solid the body draws.""" parts = liftexport._solid_parts( liftexport.choose_concrete(wp_entries()), AXES) assert min(p['z0_in'] for p in parts) == 0.0 assert max(p['z1_in'] for p in parts) == 16.0 def test_an_unplaced_solid_leaves_z_null_and_never_zero(): """Null is 'not measured'. Zero is 'the form face', and the two must never be written with the same character.""" entries = wp_entries() entries[0] = dict(entries[0], lo_in=None, hi_in=None) parts = liftexport._solid_parts(liftexport.choose_concrete(entries), AXES) unplaced = [p for p in parts if p['material'] == 'STRUCT WYTHE'][0] assert unplaced['z0_in'] is None assert unplaced['z1_in'] is None # and nothing is rebased off a partial set, because the floor of a set # missing a member is not the floor. assert [p['z0_in'] for p in parts if p['z0_in'] is not None] == \ [100.0, 104.0] def test_a_solid_panel_gains_z_and_nothing_else_changes(): """184 of the 184 pieces in the reference sweep draw one solid. The new keys ride along; the weight, the basis and the sentence do not move.""" one = [placed(88.4132, 'CONCRETE', 150.0, 0.0, 8.0)] concrete = liftexport.choose_concrete(one) assert abs(concrete['volume_ft3'] - 88.4132) < 1e-9 assert concrete['basis'] == 'one material' parts = liftexport._solid_parts(concrete, AXES) assert len(parts) == 1 assert parts[0]['counted'] is True assert (parts[0]['z0_in'], parts[0]['z1_in']) == (0.0, 8.0) # ------------------------------------------------------------- the connectors def test_the_connector_block_is_written_null_with_a_reason(): """A MISSING `connectors` key would read downstream as 'this piece has no connectors', which on an insulated panel is the one thing it certainly is not. So the block is written whether or not anything could be read, every field null, with the sentence that says why.""" block = liftexport._connectors(make_know()) for key in ('count', 'spacing_in', 'per_row', 'family', 'capacity_shear_lb', 'capacity_tension_lb', 'stiffness_lb_per_in'): assert block[key] is None, key assert 'not read' in block['why'] # and it asks for capacity PER FAILURE MODE, not one number assert 'FAILURE MODE' in block['why'] assert 'slip stiffness' in block['why'] def test_the_plants_declaration_travels_when_a_layer_makes_one(): """The plant may describe its pins as non-composite. That is evidence about design intent, it is carried verbatim, and nothing in the mechanics branches on it.""" know = make_know() know.setdefault('handling', {})['connectors'] = { 'declared_action': 'the plant describes these pins as non-composite'} block = liftexport._connectors(know) assert block['declared_action'] == \ 'the plant describes these pins as non-composite' def test_no_declaration_is_null_and_not_a_guess(): assert liftexport._connectors(make_know())['declared_action'] is None # ---------------------------------------- the erected axis is never assumed # # G10 of the overfitting audit, and it is the same shape as the up-end # fallback one step further on: `default_stages` used to read the erected # axis as `... or 'length'`, so a piece whose erected axis nobody # established was quietly taken to span its length. Right for a wall panel; # it puts the anchors on the wrong edge of a horizontal one. def test_an_absent_erected_axis_is_said_out_loud(): know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS) job['source'] = {} _stages, _rows, problems = liftexport.default_stages(job, know) said = [p for p in problems if 'erected_vertical_axis' in p] assert said, problems assert 'taken as the LENGTH' in said[0] assert 'wrong edge of a horizontal one' in said[0] def test_a_stated_erected_axis_says_nothing(): """No noise on the ordinary case -- a warning that fires every run is a warning nobody reads.""" know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS) job['source'] = {'erected_vertical_axis': 'length'} _stages, _rows, problems = liftexport.default_stages(job, know) assert [p for p in problems if 'erected_vertical_axis' in p] == [] def test_the_assumption_only_touches_the_stage_that_uses_it(): """Stripping and transportation are analysed FLAT and span the long direction whatever the piece ends up doing, so the erected axis decides nothing for them. Only the erected stage is named.""" know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS) job['source'] = {} _stages, _rows, problems = liftexport.default_stages(job, know) said = [p for p in problems if 'erected_vertical_axis' in p] assert len(said) == 1 assert 'erection' in said[0] assert 'stripping' not in said[0] def test_the_solid_parts_keys_are_the_ones_panellift_reads(): """THE CONTRACT, pinned from this side. `panellift.sandwich.layup_from_source` reads exactly these keys off each entry of `source.solid_parts`, and neither package can import the other, so nothing but a pin at each end stops one being renamed under the other. The far half is `test_sandwich.py:: test_the_solid_parts_keys_are_the_ones_the_exporter_writes`. """ parts = liftexport._solid_parts( liftexport.choose_concrete(wp_entries()), AXES) assert sorted(parts[0]) == ['counted', 'density_pcf', 'material', 'volume_ft3', 'z0_in', 'z1_in'] # ------------------------------------------- through openings vs blind ones # # `_openings` reads rectangles off the side faces of the solid and CANNOT tell # a hole from a pocket -- a corner pocket has side faces too. `split_openings` # is where that is decided, and it is the seam these tests sit on: the # geometry solve (`opening_penetration`, which shoots a line through the solid # and needs a live Revit) is stubbed by handing in the list it would have # returned, exactly as `export()` does. # # The defect this pins, fixed 2026-09-01: every rectangle went out as a # blockout, panellift reads a blockout as a void through the thickness, and a # 0.29 x 0.67 ft corner pocket with 3.5 in of concrete behind it therefore cut # every strand it stood over and restarted their development at its edges -- # one foot from the end of a piece whose model has no opening in it at all. def _inches_to_feet(value_in): """The export's `to_x` / `to_y` for a piece whose x and y ascend from 0.""" return value_in / 12.0 AXES_LWT = (0, 1, 2) ORIGIN_IN = [0.0, 0.0, 0.0] def split(rects, penetration, thickness_in=8.0): return liftexport.split_openings( rects, penetration, _inches_to_feet, _inches_to_feet, ORIGIN_IN, AXES_LWT, thickness_in) def pen(through, concrete_in): """One entry of `opening_penetration`'s list, as it really writes it.""" return {'through': through, 'concrete_in': concrete_in, 'x0': 0.0, 'x1': 0.0, 'y0': 0.0, 'y1': 0.0} def test_a_through_opening_goes_out_as_a_blockout(): blockouts, recesses, unjudged = split( [[1.0, 4.0, 2.0, 5.0]], [pen(True, 0.0)]) assert recesses == [] and unjudged == 0 assert blockouts == [{'name': 'OPENING 1', 'x0': 1.0, 'y0': 2.0, 'length': 3.0, 'width': 3.0, 'removed_ft3': None, 'removed_err': None}] def test_a_blind_recess_is_not_a_blockout(): """The WP128 shape: 0.2917 x 0.6667 ft at x0 11.2083, with 3.5 in of concrete left behind it in an 8 in panel. It emitted OPENING 1 -- a hole through the piece -- until 2026-09-01.""" blockouts, recesses, unjudged = split( [[11.2083, 11.5, 5.0, 5.6667]], [pen(False, 3.5)]) assert blockouts == [] and unjudged == 0 assert recesses == [{'name': 'RECESS 1', 'x0': 11.2083, 'y0': 5.0, 'length': 0.2917, 'width': 0.6667, 'concrete_in': 3.5, 'depth_in': 4.5, 'removed_ft3': None, 'removed_err': None}] def test_the_blockout_shape_is_exactly_what_it_always_was(): """The far side reads these five keys and this package cannot import it. A recess key leaking into a blockout would be read as a hole that has a depth, which is a third thing again.""" blockouts, _recesses, _unjudged = split( [[1.0, 4.0, 2.0, 5.0]], [pen(True, 0.0)]) assert sorted(blockouts[0]) == ['length', 'name', 'removed_err', 'removed_ft3', 'width', 'x0', 'y0'] def test_the_recess_keys_are_the_ones_panellift_reads(): """THE CONTRACT, pinned from this side the way `solid_parts` is. `panellift.cli.read_panel` reads exactly these off each `recesses` entry. The far half is `test_cli.py:: test_a_recess_round_trips_through_the_job_file`.""" _blockouts, recesses, _unjudged = split( [[11.2083, 11.5, 5.0, 5.6667]], [pen(False, 3.5)]) assert sorted(recesses[0]) == ['concrete_in', 'depth_in', 'length', 'name', 'removed_err', 'removed_ft3', 'width', 'x0', 'y0'] def test_each_list_is_numbered_on_its_own(): """A piece with one hole and two pockets gets OPENING 1, RECESS 1, RECESS 2 -- not OPENING 1, RECESS 2, RECESS 3. The number is an index into the list it is printed in, and a gap in it reads as a lost feature. """ blockouts, recesses, _unjudged = split( [[1.0, 2.0, 1.0, 2.0], [3.0, 4.0, 1.0, 2.0], [5.0, 6.0, 1.0, 2.0]], [pen(False, 3.5), pen(True, 0.0), pen(False, 3.5)]) assert [b['name'] for b in blockouts] == ['OPENING 1'] assert [r['name'] for r in recesses] == ['RECESS 1', 'RECESS 2'] def test_an_unsolved_penetration_stays_a_blockout_and_is_counted(): """"Could not tell" is not "blind". The through reading is the one that takes section and strand away, it is what this file emitted before, and standing it down on a failed solve would quietly make a piece pass. The count comes back so the export can SAY so rather than only do it.""" blockouts, recesses, unjudged = split( [[1.0, 4.0, 2.0, 5.0]], [pen(None, None)]) assert recesses == [] assert [b['name'] for b in blockouts] == ['OPENING 1'] assert unjudged == 1 def test_a_penetration_solve_that_failed_outright_leaves_every_rect_alone(): """`opening_penetration` raising hands the export an EMPTY list, not a short one per rect. Same answer, and every rectangle is counted.""" blockouts, recesses, unjudged = split( [[1.0, 2.0, 1.0, 2.0], [3.0, 4.0, 1.0, 2.0]], []) assert len(blockouts) == 2 and recesses == [] and unjudged == 2 def test_a_recess_with_no_thickness_is_still_a_recess(): """depth_in is the thickness less the concrete behind the pocket, so it cannot be had without a thickness. That makes the depth None -- never a 0 that reads as a pocket of no depth, and never a reason to call the pocket a hole instead.""" _blockouts, recesses, _unjudged = split( [[11.2083, 11.5, 5.0, 5.6667]], [pen(False, 3.5)], thickness_in=None) assert recesses[0]['concrete_in'] == 3.5 assert recesses[0]['depth_in'] is None def test_nothing_read_is_two_empty_lists(): assert split([], []) == ([], [], 0) # ------------------------------ the weight gate, now that recesses are apart def _pocket_job(recesses): """The reconciling 20 x 10 ft x 8 in job above, with pockets on it.""" job = _weight_job(133.3333, 'the body draws one solid') job['panel']['recesses'] = recesses return job def _recess(depth_in, concrete_in): return {'name': 'RECESS 1', 'x0': 1.0, 'y0': 1.0, 'length': 2.0, 'width': 3.0, 'concrete_in': concrete_in, 'depth_in': depth_in} def test_a_recess_comes_out_of_the_reconstruction_at_its_own_depth(): """A pocket stopped being in `blockouts` on 2026-09-01, so without this the reconstruction would start calling every pocketed piece heavier than its own measured solid. It comes out at the POCKET's depth and not at the thickness: subtracting a 4.5 in pocket as if it went through was the other half of the same error.""" _ok, _message, numbers = liftexport.weight_check( _pocket_job([_recess(4.5, 3.5)])) # 2 x 3 ft x 4.5 in = 2.25 ft3, at 150 pcf. assert abs(numbers['blockout_lb'] - 337.5) < 0.01 assert numbers['recesses'] == 1 def test_a_recess_whose_depth_was_never_solved_is_left_in_the_concrete(): """An unmeasured pocket is not guessed at. The gate then reads high, which is a refusal somebody has to explain -- not a piece quietly blessed.""" _ok, _message, numbers = liftexport.weight_check( _pocket_job([_recess(None, None)])) assert numbers['blockout_lb'] == 0.0 assert numbers['recesses'] == 1 def test_a_job_file_with_no_recesses_key_weighs_what_it_always_did(): ok, message, numbers = liftexport.weight_check( _weight_job(133.3333, 'the body draws one solid')) assert ok and message == '' assert numbers['recesses'] == 0 # ------------------------------------------------- the cardinal sin, refused # # `families.lifter_include` matching zero families used to leave `export()` # returning a job with `panel.lifters: []` and a soft note buried in # `problems` -- a report that completes and looks like a clean pass, the # exact shape CLAUDE.md's error-pattern record calls this project's cardinal # sin (bitten twice: the shipped defaults matching no real family, and P78 on # insulated panels). `no_lifters_message` is the pure half of the fix -- the # three-state sentence -- and is what these pin. The Revit-shaped half, # `_lifter_include_provenance`, needs a live doc and is out of scope here, # same as `export()` itself. def test_unset_state_names_the_defaults_and_says_it_is_expected(): msg = liftexport.no_lifters_message( ['LIFT', 'LPA', 'ANCHOR'], [], ['EMBED_PLATE_X4'], r'C:\plants\sample_plant\knowledge.yaml') assert 'no modelled family matched families.lifter_include' in msg assert 'the plant layer maps LIFT, LPA, ANCHOR' in msg assert 'nothing was lifted-checked' in msg assert 'UNSET' in msg assert 'expected shape for a brand-new plant' in msg assert 'EMBED_PLATE_X4' in msg assert r'C:\plants\sample_plant\knowledge.yaml' in msg # Must not claim a mapping went stale -- nobody has customised anything. assert 'gone stale' not in msg def test_stale_state_names_the_layer_and_the_incident_it_matches(): msg = liftexport.no_lifters_message( ['LIFT_OLD'], ['plant'], ['EMBED_PLATE_X4'], r'C:\plants\sample_client\knowledge.yaml') assert 'the plant layer maps LIFT_OLD' in msg assert 'IS SET' in msg assert 'from the plant layer' in msg assert 'gone stale' in msg # Must not claim this is the expected new-plant shape. assert 'expected shape for a brand-new plant' not in msg def test_stale_state_names_every_declaring_layer(): msg = liftexport.no_lifters_message( ['LIFT_OLD'], ['plant', 'job'], [], None) assert 'from the plant + job layer' in msg def test_provenance_unknown_still_refuses_but_hedges(): msg = liftexport.no_lifters_message(['LIFT'], None, [], None) assert 'could not be re-checked' in msg assert 'UNSET' not in msg assert 'IS SET' not in msg def test_an_empty_include_list_is_named_as_none_not_an_empty_string(): msg = liftexport.no_lifters_message([], [], [], None) assert 'the plant layer maps none' in msg def test_no_other_family_on_the_assembly_says_so_rather_than_nothing(): msg = liftexport.no_lifters_message(['LIFT'], [], [], None) assert 'No other family names were read off this assembly either' in msg def test_the_families_actually_on_the_piece_are_named_and_deduplicated(): msg = liftexport.no_lifters_message( ['LIFT'], [], ['EMBED_A', 'EMBED_B', 'EMBED_A'], None) assert 'EMBED_A, EMBED_B' in msg assert msg.count('EMBED_A') == 1 def test_no_plant_path_says_it_could_not_be_resolved(): msg = liftexport.no_lifters_message(['LIFT'], [], [], None) assert 'could not be resolved to a knowledge file path' in msg # ------------------------------------------------ f'c: summary vs parameter # # `export()`'s Revit-shaped half wires two sources into # `resolve_concrete_strength` -- the drawing summary (`designspec` + # `ticketqc.parse_design_summary`) and the sheet.fc/fci MODEL parameter # (`ticketqc.read_ticket`'s `tkt_fc`/`tkt_fci`, read through the same # `_sheet_param(sheets, know, 'sheet.fc')` Ticket QC's own comparison row # uses) -- and cannot be exercised without a live document. This is the pure # resolution rule itself, host-testable in isolation. def test_resolve_concrete_strength_summary_only_unchanged_historical_case(): """A job whose drawing carries a printed summary and no model parameter role (the historical case, before this fallback existed) must resolve exactly as before -- the summary value, no problem.""" value, conflict = liftexport.resolve_concrete_strength(6000.0, None, "f'c") assert value == 6000.0 assert conflict is None def test_resolve_concrete_strength_parameter_only_the_sample_job_case(): """A job whose drawing carries NO summary but whose model has the sheet.fc parameter mapped (the sample_job gap this fix closes) must now resolve off the parameter alone.""" value, conflict = liftexport.resolve_concrete_strength(None, 6000.0, "f'c") assert value == 6000.0 assert conflict is None def test_resolve_concrete_strength_both_agree_uses_it(): value, conflict = liftexport.resolve_concrete_strength(6000.0, 6000.0, "f'c") assert value == 6000.0 assert conflict is None def test_resolve_concrete_strength_both_present_and_disagree_is_not_judged(): """Mirrors `weight_check`'s rule for a printed-vs-modelled mismatch: neither number is silently preferred or averaged. The value comes back None and the conflict names BOTH numbers so a human resolves it.""" value, conflict = liftexport.resolve_concrete_strength(5000.0, 6000.0, "f'c") assert value is None assert conflict is not None assert '5000' in conflict or '5,000' in conflict assert '6000' in conflict or '6,000' in conflict assert "f'c" in conflict def test_resolve_concrete_strength_neither_present_is_plain_none(): value, conflict = liftexport.resolve_concrete_strength(None, None, "f'c") assert value is None assert conflict is None def test_resolve_concrete_strength_tiny_float_noise_still_agrees(): """Values within 1 psi are treated as the same reading, not a conflict -- these are whole-number prints, and float/str round-tripping should not manufacture a disagreement out of nothing.""" value, conflict = liftexport.resolve_concrete_strength(6000.0, 6000.4, "f'c") assert value == 6000.0 assert conflict is None # ---------------------------------------- the reconstruction sums every wythe # # THE DEFECT (measured live 2026-09-02, WP113, an insulated sandwich). The # reconstruction leg -- gross-from-the-drawing, the self-check that catches a # blockout read wrong -- rebuilt only the SINGLE LARGEST solid and so on a # sandwich rebuilt ONE WYTHE. WP113: 68.0 ft3 rebuilt against 102.2 ft3 of # actual solid, a 33% gap that refused a piece whose model is right, on every # insulated piece in the job. # # `_wythe_gross_ft3` is the per-wythe unit and `_pick_concrete_solids` is the # seam that hands the reconstruction EVERY concrete solid rather than the # largest. Both are pure -- the geometry walk is Revit's, the arithmetic and # the mapping are not -- so the fix is pinned here on host CPython. def test_one_wythe_gross_is_length_x_width_x_its_own_thickness(): """The smallest span is the thickness, exactly as `_axes` picks it, so a thin wythe is measured across its OWN 4 in and not the envelope.""" # 20 ft x 10 ft x 4 in, no openings. gross = liftexport._wythe_gross_ft3([0.0, 0.0, 0.0], [240.0, 120.0, 4.0], []) assert abs(gross - (20.0 * 10.0 * (4.0 / 12.0))) < 1e-9 def test_one_wythe_subtracts_its_own_openings_at_its_own_thickness(): """A blockout takes its footprint x the wythe's thickness, not the envelope's -- the same rule the old single-solid leg used, kept.""" # A 2 ft x 2 ft = 4 ft2 hole through a 4 in wythe removes 4 x 4/12 ft3. gross = liftexport._wythe_gross_ft3([0.0, 0.0, 0.0], [240.0, 120.0, 4.0], [[2.0, 4.0, 3.0, 5.0]]) expected = 20.0 * 10.0 * (4.0 / 12.0) - 4.0 * (4.0 / 12.0) assert abs(gross - expected) < 1e-9 def test_the_reconstruction_sums_all_wythes_not_just_the_largest(): """The whole repair. Two concrete wythes of one body: the reconstruction is their sum, and the largest one alone -- what the leg used to return -- is short by the other wythe.""" thick = liftexport._wythe_gross_ft3( [0.0, 0.0, 0.0], [240.0, 120.0, 4.0], []) # 66.67 ft3, largest thin = liftexport._wythe_gross_ft3( [0.0, 0.0, 0.0], [240.0, 120.0, 2.0], []) # 33.33 ft3 both = thick + thin assert abs(both - 100.0) < 1e-6 # The defect: the largest alone is ~33% short of the piece, which is the # gap that fired the false refusal. assert abs((both - thick) / both - 0.3333) < 0.01 # ------------------------------- a stepped/tapered wythe stands down, named # # WP100, 2026-09-08 (`_scratch/wp100_weight.md`). The backup wythe steps down # in thickness over its last ~0.5 ft, a face `_openings` has no way to close # into a plan-view hole at all -- its normal has no thickness-axis component # (so `_openings` treats it as a hole side, not the outside of the piece) but # nothing else is anywhere near it to cluster against, so it comes back as a # DEGENERATE rectangle (zero width in one plan direction) that already # deducts 0.0 ft3, silently. Before this fix that silence let the gross # estimate run 19.37 ft3 (47%) heavier than the wythe's own 41.22 ft3 solid, # which fired a WARN blaming a fine piece (`WGT-READ`, "suspect the tool # first") for a hole in the tool. # # The OTHER failure this leg exists to catch -- a hole `_openings` misses # ENTIRELY, the classic door-jamb-read-as-outside bug its own docstring # describes -- must NOT be swept into the same "not applicable" answer: it # leaves no rect at all, degenerate or otherwise, so the reconstruction still # comes back a real (wrong, overstated) number and the caller still warns. def _patch_ticketqc(monkeypatch, extents_of_solid): """Stand up a minimal fake `vibe.ticketqc` with just `extents_of_solid`. `_reconstructed_ft3` does `from vibe import ticketqc` INSIDE its own body -- the real module imports the Revit API at module scope (conftest.py's docstring), which is exactly the dependency this test must not need. Patching both `sys.modules['vibe.ticketqc']` and the `vibe` package's own `ticketqc` attribute (when one already exists, set by an earlier real import elsewhere in the same pytest session) covers both of CPython's `from X import Y` resolution paths. """ fake = types.ModuleType('vibe.ticketqc') fake.extents_of_solid = extents_of_solid monkeypatch.setitem(sys.modules, 'vibe.ticketqc', fake) vibe_pkg = sys.modules.get('vibe') if vibe_pkg is not None: monkeypatch.setattr(vibe_pkg, 'ticketqc', fake, raising=False) return fake class _FakeSolid(object): def __init__(self, volume): self.Volume = volume # The backup wythe's real envelope, in feet: 11.5417 (L) x 5.25 (W) x 1.00 # (T) -- taken straight from the diagnosis so the numbers in these tests are # the numbers that were actually measured live, not round ones invented for # the test. _STEPPED_LO = (0.0, 0.0, 0.0) _STEPPED_HI = (11.5417, 5.25, 1.0) _STEPPED_GROSS_FT3 = 11.5417 * 5.25 * 1.0 # 60.59 ft3, no holes found _STEPPED_ACTUAL_FT3 = 41.22 # the wythe's own Volume def test_degenerate_rects_finds_the_unpaired_side_face(): """A real hole clusters into a rect with positive extent in BOTH plan directions (see WP100's own small corner notch, which measured fine); a lone side face with nothing to pair against -- the step's signature -- stays zero-width in one of them.""" rects = [[2.0, 4.0, 3.0, 5.0], [11.04, 11.04, 0.0, 5.25]] assert liftexport._degenerate_rects(rects) == [[11.04, 11.04, 0.0, 5.25]] def test_degenerate_rects_empty_when_every_rect_closed(): rects = [[2.0, 4.0, 3.0, 5.0], [0.0, 1.0, 0.0, 2.0]] assert liftexport._degenerate_rects(rects) == [] def test_reconstructed_ft3_stands_a_stepped_wythe_down_by_name(monkeypatch): solid = _FakeSolid(_STEPPED_ACTUAL_FT3) _patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI)) monkeypatch.setattr( liftexport, '_openings', lambda s, f, lo, hi, axes: [[11.04, 11.04, 0.0, 5.25]]) total, reason = liftexport._reconstructed_ft3([solid], ('frame',), make_know()) assert total is None assert reason is not None assert 'wythe 1 of 1' in reason assert 'stepped or tapered' in reason # The excess it names is the real, measured gap -- not an approximation. assert '{0:,.2f}'.format(_STEPPED_GROSS_FT3 - _STEPPED_ACTUAL_FT3) \ in reason def test_reconstructed_ft3_a_genuinely_missed_hole_still_reads_wrong( monkeypatch): """The classic door-jamb bug -- a hole `_openings` misses OUTRIGHT -- leaves no rect at all, degenerate or otherwise. That must keep coming back as a real (overstated) number, exactly as it did before this fix, so the caller still reports WGT-READ rather than waving the piece through as 'not applicable' just because a gap exists.""" solid = _FakeSolid(_STEPPED_ACTUAL_FT3) _patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI)) monkeypatch.setattr(liftexport, '_openings', lambda s, f, lo, hi, axes: []) total, reason = liftexport._reconstructed_ft3([solid], ('frame',), make_know()) assert reason is None assert total is not None assert abs(total - _STEPPED_GROSS_FT3) < 1e-6 def test_reconstructed_ft3_a_degenerate_rect_within_tolerance_is_not_flagged( monkeypatch): """Condition 1 (a degenerate rect) alone is not enough to stand a wythe down -- only a degenerate rect THAT ALSO costs more than the check's own tolerance does.""" solid = _FakeSolid(_STEPPED_GROSS_FT3 * 0.999) # within 2% _patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI)) monkeypatch.setattr( liftexport, '_openings', lambda s, f, lo, hi, axes: [[11.04, 11.04, 0.0, 5.25]]) total, reason = liftexport._reconstructed_ft3([solid], ('frame',), make_know()) assert reason is None assert total is not None def test_reconstructed_ft3_extents_unreadable_is_the_old_none_none( monkeypatch): """The pre-existing failure -- a wythe's extents could not be read at all -- must still come back `(None, None)`, not a reason: this is the case `weight_volumes` still treats as a full stand-down.""" _patch_ticketqc(monkeypatch, lambda s, f: None) total, reason = liftexport._reconstructed_ft3( ['solid'], ('frame',), make_know()) assert total is None assert reason is None def test_reconstructed_ft3_reads_the_tolerance_from_knowledge(monkeypatch): """The tolerance is `handling.weight_tolerance`, read off `know` like every other caller of it (`check_weight_three_way`, `weight_check`) -- never a hardcoded 2%. A plant that widens it should stop flagging the same gap.""" solid = _FakeSolid(_STEPPED_ACTUAL_FT3) _patch_ticketqc(monkeypatch, lambda s, f: (_STEPPED_LO, _STEPPED_HI)) monkeypatch.setattr( liftexport, '_openings', lambda s, f, lo, hi, axes: [[11.04, 11.04, 0.0, 5.25]]) loose = make_know() loose['handling']['weight_tolerance'] = 0.60 # wider than the 47% gap total, reason = liftexport._reconstructed_ft3([solid], ('frame',), loose) assert reason is None assert total is not None # ---------------------------- weight_volumes wires the not-applicable state # # `weight_volumes` must stand down ONLY the reconstruction leg when # `_reconstructed_ft3` names a reason, leaving `solid_ft3` (the measured # leg, which never called `_reconstructed_ft3` at all) untouched -- and must # still fully stand both legs down on the OLDER failure (extents # unreadable, no reason). `_reconstructed_ft3` itself is stubbed out here; # its own decision is pinned by the tests just above. def _patch_weight_volumes_geometry(monkeypatch, concrete, reconstructed): """Stand up everything `weight_volumes` reads before it ever reaches `_reconstructed_ft3`, off host fakes -- `reconstructed` is `_reconstructed_ft3`'s own `(total, reason)` return, injected directly so this test is only about `weight_volumes`'s wiring, not the decision `_reconstructed_ft3` makes.""" monkeypatch.setattr(liftexport, 'panel_solids', lambda p: ['A', 'B'][:len(concrete['included'])]) monkeypatch.setattr(liftexport, 'concrete_volume', lambda doc, p, know, s: concrete) monkeypatch.setattr(liftexport, 'extra_cast_ft3', lambda *a: 0.0) monkeypatch.setattr(liftexport, 'solid_zone_ft3', lambda *a: 0.0) monkeypatch.setattr(liftexport, '_reconstructed_ft3', lambda solids, frame, know=None: reconstructed) _patch_ticketqc(monkeypatch, lambda s, f: None) vibe_pkg = sys.modules.get('vibe') if vibe_pkg is not None: monkeypatch.setattr(vibe_pkg.ticketqc, '_panel_frame', lambda p: ('frame',), raising=False) def test_weight_volumes_stands_down_only_the_reconstruction_leg(monkeypatch): concrete = {'volume_ft3': 60.32, 'basis': 'material density', 'why': 'both wythes', 'included': [{'_ix': 0}, {'_ix': 1}]} _patch_weight_volumes_geometry( monkeypatch, concrete, (None, 'wythe 1 of 2 ... stepped or tapered wythe')) out = liftexport.weight_volumes(None, None, object(), make_know()) assert out['solid_ft3'] == 60.32 assert out['reconstructed_ft3'] is None assert out['reason'] is None assert 'stepped or tapered' in out['reconstruction_reason'] assert out['solid_basis'] == 'material density' def test_weight_volumes_still_stands_down_fully_when_extents_unreadable( monkeypatch): concrete = {'volume_ft3': 41.22, 'basis': 'material density', 'why': 'one wythe', 'included': [{'_ix': 0}]} _patch_weight_volumes_geometry(monkeypatch, concrete, (None, None)) out = liftexport.weight_volumes(None, None, object(), make_know()) assert out['solid_ft3'] is None assert out['reconstructed_ft3'] is None assert out['reconstruction_reason'] is None assert 'extents could not be solved' in out['reason'] def test_weight_volumes_normal_path_is_unchanged_when_reconstruction_works( monkeypatch): concrete = {'volume_ft3': 60.32, 'basis': 'material density', 'why': 'both wythes', 'included': [{'_ix': 0}, {'_ix': 1}]} _patch_weight_volumes_geometry(monkeypatch, concrete, (55.0, None)) out = liftexport.weight_volumes(None, None, object(), make_know()) assert out['reconstructed_ft3'] == 55.0 assert out['solid_ft3'] == 60.32 assert out['reason'] is None assert out['reconstruction_reason'] is None def test_pick_concrete_solids_returns_every_counted_solid_by_index(): """`choose_concrete` decides which solids are concrete on plain dicts; `_pick_concrete_solids` maps its `included` set back to the Solid OBJECTS by the `_ix` stamped before the choice. Equal wythes make equal dicts, so only the index can tell them apart -- and the map must return BOTH concrete solids, never just the biggest.""" solids = ['WYTHE_A', 'INSULATION', 'WYTHE_B'] concrete = {'included': [{'_ix': 0}, {'_ix': 2}]} assert liftexport._pick_concrete_solids(solids, concrete) == \ ['WYTHE_A', 'WYTHE_B'] def test_pick_concrete_solids_skips_a_missing_or_out_of_range_index(): """Defensive: a decision with no index, or one past the end, drops that entry rather than raising -- the reconstruction stands down elsewhere on an empty pick, it does not crash here.""" solids = ['A', 'B'] concrete = {'included': [{'_ix': 0}, {'_ix': None}, {'_ix': 5}, {}]} assert liftexport._pick_concrete_solids(solids, concrete) == ['A'] def test_solid_entries_index_survives_the_concrete_choice(): """The stamp and the map are two halves of one mechanism, so pin that the index `solid_entries` writes is the one `choose_concrete` hands back on the included entries -- built here as plain dicts, the same shape the real walk produces.""" entries = [dict(entry(80.0, 'CONCRETE', 150.0), _ix=0), dict(entry(2.0, 'RIGID BOARD', 2.0), _ix=1), dict(entry(40.0, 'CONCRETE', 150.0), _ix=2)] concrete = liftexport.choose_concrete(entries) assert [e['_ix'] for e in concrete['included']] == [0, 2] assert liftexport._pick_concrete_solids(['a', 'b', 'c'], concrete) == \ ['a', 'c'] # ---------------------------------------------- solid-zone concrete, by knowledge # # The thickened concrete zones of an insulated sandwich are their OWN family # members inside the assembly -- separate Generic Models, not geometry of the # panel element -- so the panel-body solid sum and the reconstruction, both of # which walk only the panel body, never see them. On WP113 that was 9.63 ft3, # ~7.8% of the piece, silently missing from every weight. Which family they are # is plant knowledge (`insulation.solid_zone.family`, a placed example being # SOLID_ZONE_RECTANGULAR); with no such key the walk does nothing and a piece # without solid zones is unchanged. # # The fakes are duck types: solid_zone_members touches only GetMemberIds / # GetElement / Symbol.Family.Name / Id, and the per-member concrete goes # through `solid_entries` (stubbed here so the REAL `choose_concrete` density # discipline runs with no Revit). class _FakeZoneMember(object): def __init__(self, family, entries, eid=1): self.Symbol = _FakeSymbol(family) self.Id = _FakeId(eid) self.Parameters = [] self._entries = entries def _zone_know(family='SOLID_ZONE_RECTANGULAR'): know = make_know() know['insulation'] = {'solid_zone': {'family': family}} return know def _stub_solid_entries(monkeypatch): """`solid_entries` reads Revit geometry; here each member simply carries the entries it would have produced, so the real `choose_concrete` runs.""" monkeypatch.setattr(liftexport, 'solid_entries', lambda doc, member, *a, **k: member._entries) def test_solid_zone_members_are_counted_when_the_family_is_named(monkeypatch): _stub_solid_entries(monkeypatch) members = [_FakeZoneMember('SOLID_ZONE_RECTANGULAR', [entry(6.0, 'CONCRETE', 150.0)], 11), _FakeZoneMember('SOLID_ZONE_RECTANGULAR', [entry(3.628, 'CONCRETE', 150.0)], 12)] doc, asm = _cast_doc(members) know = _zone_know() out = liftexport.solid_zone_members(doc, asm, know) assert [m['id'] for m in out] == ['11', '12'] assert abs(liftexport.solid_zone_ft3(doc, asm, know) - 9.628) < 1e-9 def test_no_solid_zone_key_is_a_no_op_not_a_guess(monkeypatch): """A plant that casts no solid zones names no family. The walk must return nothing and add nothing -- the weight of every piece without solid zones is exactly what it was before this existed.""" _stub_solid_entries(monkeypatch) members = [_FakeZoneMember('SOLID_ZONE_RECTANGULAR', [entry(6.0, 'CONCRETE', 150.0)])] doc, asm = _cast_doc(members) know = make_know() # no 'insulation' key at all assert liftexport.solid_zone_members(doc, asm, know) == [] assert liftexport.solid_zone_ft3(doc, asm, know) == 0.0 def test_only_the_named_family_is_a_solid_zone_steel_is_not(monkeypatch): """The concrete/steel split is the family filter: an embed or lifter is a DIFFERENT family and is never reached, so its steel never lands in the concrete weight.""" _stub_solid_entries(monkeypatch) members = [_FakeZoneMember('SOLID_ZONE_RECTANGULAR', [entry(6.0, 'CONCRETE', 150.0)], 11), _FakeZoneMember('EMBED_PLATE', [entry(0.4, 'STEEL', 490.0)], 12), _FakeZoneMember('ANCHOR_FACE_4T', [entry(0.1, 'STEEL', 490.0)], 13)] doc, asm = _cast_doc(members) know = _zone_know() out = liftexport.solid_zone_members(doc, asm, know) assert [m['family'] for m in out] == ['SOLID_ZONE_RECTANGULAR'] assert liftexport.solid_zone_ft3(doc, asm, know) == 6.0 def test_a_solid_zone_members_insulation_is_left_out_by_density(monkeypatch): """WITHIN a matched member the density discipline still applies exactly as it does on the panel body: where a member draws both concrete and sub-50 pcf board, only the concrete is counted -- `choose_concrete`'s own rule, reused, not a second copy of it.""" _stub_solid_entries(monkeypatch) members = [_FakeZoneMember( 'SOLID_ZONE_RECTANGULAR', [entry(6.0, 'CONCRETE', 150.0), entry(2.0, 'RIGID BOARD', 2.0)], 11)] doc, asm = _cast_doc(members) know = _zone_know() out = liftexport.solid_zone_members(doc, asm, know) assert out[0]['volume_ft3'] == 6.0 # the board is left out assert liftexport.solid_zone_ft3(doc, asm, know) == 6.0 def test_solid_zones_are_not_double_counted_by_cast_include(monkeypatch): """One mechanism owns the solid zones. `extra_cast` is a SUBSTRING match, so a plant that named its zone family in `families.cast_include` ('SOLID' would catch it) would otherwise weigh that concrete twice. The extra-cast walk must skip the zone family, and the zone walk must count it -- exactly once, between them.""" _stub_solid_entries(monkeypatch) zone = _FakeZoneMember('SOLID_ZONE_RECTANGULAR', [entry(6.0, 'CONCRETE', 150.0)], 11) # A real ledge, and a Volume param the way extra_cast reads it. ledge = _FakeMember('SLAB SEAT LEDGE', 8.99, 12) doc, asm = _cast_doc([zone, ledge]) know = _zone_know() know['families']['cast_include'] = ['SOLID', 'LEDGE'] # 'SOLID' would hit # extra_cast sees the ledge only -- the zone is claimed by the zone walk. extras = liftexport.extra_cast_members(doc, asm, know) assert [m['family'] for m in extras] == ['SLAB SEAT LEDGE'] # ...and the zone walk sees the zone only. zones = liftexport.solid_zone_members(doc, asm, know) assert [m['family'] for m in zones] == ['SOLID_ZONE_RECTANGULAR'] # Between the two, the concrete is counted once each and never together. assert liftexport.extra_cast_ft3(doc, asm, know) == 8.99 assert liftexport.solid_zone_ft3(doc, asm, know) == 6.0 # --------------------------------- the gate reconciles a sandwich, on its merits # # `weight_check` is where the false refusal actually fired: it rebuilds the # reconstruction from `panel.thickness_in`, which is the LARGEST solid's extent # and so ONE WYTHE of a sandwich. The exporter now hands it a wythe-summed # `source.reconstructed_ft3` for a multi-solid body, and adds the solid-zone # concrete to both legs. These pin that the gate USES those, without losing the # single-solid dimension-and-recess reconstruction or its teeth. def _sandwich_job(reconstructed_ft3, solid_ft3, zone_ft3=0.0, weight_lb=None, thickness_in=8.0): """A job as the exporter writes it for a sandwich: the panel dims are the LARGEST wythe (thickness_in), and the wythe-summed reconstruction and the solid sum ride in `source`.""" return { 'panel': {'length_ft': 20.0, 'width_ft': 10.0, 'thickness_in': thickness_in, 'unit_weight_pcf': 150.0, 'weight_lb': weight_lb, 'blockouts': [], 'extra_cast_ft3': 0.0}, 'source': {'solid_ft3': solid_ft3, 'solid_basis': 'material density', 'solid_note': 'summed 2 of the 3 solids the body draws', 'reconstructed_ft3': reconstructed_ft3, 'solid_zone_ft3': zone_ft3}, } def test_the_gate_uses_the_wythe_summed_reconstruction_for_a_sandwich(): """WP113 in miniature. Reconstruction and solid both ~111.8 ft3 and the stated weight ~16,634 lb: the piece passes on its merits, where the old one-wythe reconstruction (~68 ft3) refused it. The panel thickness_in is still one wythe -- the gate must NOT be rebuilding from it.""" # 102.2 ft3 of wythes + 9.63 ft3 of solid zone = 111.83 ft3. job = _sandwich_job(reconstructed_ft3=111.83, solid_ft3=111.83, zone_ft3=9.63, weight_lb=111.83 * 150.0) ok, message, numbers = liftexport.weight_check(job) assert ok and message == '' # It really used the wythe-summed number, not length x width x one wythe. assert abs(numbers['reconstructed_lb'] - 111.83 * 150.0) < 1.0 def test_the_gate_still_catches_a_misread_blockout_on_a_sandwich(): """The leg keeps its teeth. If the wythe-summed reconstruction disagrees with the actual solid -- a blockout read wrong -- the gate still refuses, now on the correct, wythe-aware pair rather than on a dropped wythe.""" # Reconstruction says 111.8, the solid it is checked against says 90. job = _sandwich_job(reconstructed_ft3=111.83, solid_ft3=90.0, weight_lb=111.83 * 150.0) ok, message, _ = liftexport.weight_check(job) assert not ok assert 'blockouts were read wrong' in message def test_a_single_solid_piece_keeps_the_dimension_reconstruction(): """No `source.reconstructed_ft3` on a one-solid body, so the gate keeps its own length x width x thickness (and recess-depth) reconstruction -- the 184 solid panels in the reference sweep must not move.""" job = _sandwich_job(reconstructed_ft3=None, solid_ft3=133.3333, weight_lb=133.3333 * 150.0) # length x width x thickness = 20 x 10 x 8/12 = 133.33 ft3. ok, message, numbers = liftexport.weight_check(job) assert ok and message == '' assert abs(numbers['reconstructed_lb'] - 133.3333 * 150.0) < 5.0 def test_solid_zone_concrete_reconciles_a_single_solid_piece_too(): """A single-solid piece can still carry solid-zone members. With no wythe-summed reconstruction, the gate adds the zone concrete to its dimension reconstruction so it still matches a solid sum that includes the zones -- otherwise the zone would look like a blockout misread.""" # 133.33 ft3 body + 6 ft3 of solid zone in BOTH legs. job = _sandwich_job(reconstructed_ft3=None, solid_ft3=133.3333 + 6.0, zone_ft3=6.0, weight_lb=(133.3333 + 6.0) * 150.0) ok, message, numbers = liftexport.weight_check(job) assert ok and message == '' assert abs(numbers['solid_zone_lb'] - 6.0 * 150.0) < 1e-6 # ------------------------------------------------- default_stages: flat piece def test_default_stages_flat_piece_switches_erection_off(): """A slab: its thickness points up. Stripping and transportation run as the flat lifts they are; erection is switched off with the reason on the stage, never solved with the thickness as a span (owner, 2026-09-03).""" know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='thickness') stages, all_rows, problems = liftexport.default_stages(job, know) by_name = dict((s['name'], s) for s in stages) assert by_name['stripping'].get('enabled', True) is True assert by_name['transportation'].get('enabled', True) is True erection = by_name['erection'] assert erection['enabled'] is False assert 'lies flat' in erection['disabled_reason'] assert erection['span'] != 'thickness' def test_default_stages_flat_attitude_alone_switches_erection_off(): know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='width') job['source']['vertical_share'] = {'attitude': 'flat'} stages, _, _ = liftexport.default_stages(job, know) assert dict((s['name'], s) for s in stages)['erection']['enabled'] is False def test_default_stages_wall_panel_keeps_erection(): know = make_know() job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='length') stages, _, _ = liftexport.default_stages(job, know) erection = dict((s['name'], s) for s in stages)['erection'] assert erection.get('enabled', True) is True # ------------------------------------------------- handling_view: when_flat def make_know_with_flat_overlay(): know = make_know() know['handling']['tension_limit_sqrt_fc'] = 5.0 know['handling']['when_flat'] = { 'tension_limit_sqrt_fc': 6.0, 'compression_limit_ratio': 0.7, 'ignore_lateral_prestress': True, } return know def test_handling_view_overlays_only_when_flat(): know = make_know_with_flat_overlay() assert liftexport.handling_view(know, False) is know view = liftexport.handling_view(know, True) assert view is not know assert view['handling']['tension_limit_sqrt_fc'] == 6.0 assert view['handling']['compression_limit_ratio'] == 0.7 assert view['handling']['ignore_lateral_prestress'] is True assert 'stages' in view['handling'] def test_handling_view_leaves_the_original_untouched(): know = make_know_with_flat_overlay() liftexport.handling_view(know, True) assert know['handling']['tension_limit_sqrt_fc'] == 5.0 assert 'ignore_lateral_prestress' not in know['handling'] assert liftexport.handling_view(make_know(), True) is not None def test_default_stages_flat_piece_runs_on_the_flat_overlay(): know = make_know_with_flat_overlay() job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='thickness') stages, _, _ = liftexport.default_stages(job, know) stripping = dict((s['name'], s) for s in stages)['stripping'] assert stripping['tension_limit_sqrt_fc'] == 6.0 assert stripping['compression_limit_ratio'] == 0.7 def test_default_stages_wall_panel_keeps_the_plant_wide_limits(): know = make_know_with_flat_overlay() job = make_job(FACE_AND_EDGE_LIFTERS, erected_axis='length') stages, _, _ = liftexport.default_stages(job, know) stripping = dict((s['name'], s) for s in stages)['stripping'] assert stripping['tension_limit_sqrt_fc'] == 5.0 assert stripping['compression_limit_ratio'] == 0.6