# -*- coding: utf-8 -*- """Insulated (sandwich) panels: the refusal, and the section it refuses over. Two kinds of test here and they are kept apart on purpose. **The mechanics** are checked against arithmetic done by hand, written out in a comment beside every number, on the dimensions of a real 16 in. insulated piece. A symmetric-enough sandwich has closed-form section properties and there is no excuse for a test that recomputes the code's own expression -- a pin that recomputes both sides pins nothing and agrees with any refactor, including a wrong one. **The refusal** is pinned as behaviour: which job files trip it, which do not, that it produces no report, and that it never returns one of OK/WARN/FAIL. THE PIECE THE FIXTURE IS. Measured on the live model 2026-08-31: envelope 16.0 in, 4 in. EPS between the wythes, two concrete solids of 79.885 ft3 and 39.557 ft3, both at 150.28 pcf, length 27.94 ft, width across 5.33 ft. the wythes are in the ratio 79.885 / 39.557 = 2.0195 they share 16 - 4 = 12 in. of concrete 12 * 2.0195 / 3.0195 = 8.025 -> 8 in. structural 12 - 8 = 4 -> 4 in. architectural so the layup is 4 in. concrete + 4 in. EPS + 8 in. concrete = 16 in., which is a textbook layup and is what the fixture below is built from. Everything is worked at b = 64 in. (the 5.33 ft width, no opening), because a clean width makes the hand arithmetic checkable by eye. """ import json import pytest from panellift import cli, report, sandwich # The layup of the real piece, and the width it is worked at. T_ARCH = 4.0 T_STRUCT = 8.0 ENVELOPE = 16.0 B = 64.0 def wp_layup(): """4 in. concrete, 4 in. EPS, 8 in. concrete, form face at z = 0.""" return sandwich.Layup( layers=[ sandwich.Layer(sandwich.CONCRETE, 0.0, 4.0, 'ARCH WYTHE', 150.28, 39.557), sandwich.Layer(sandwich.NOT_CONCRETE, 4.0, 8.0, 'EPS', 1.5, 39.8), sandwich.Layer(sandwich.CONCRETE, 8.0, 16.0, 'STRUCT WYTHE', 150.28, 79.885), ], envelope_in=ENVELOPE, measured=True, why='test fixture') def sandwich_source(positioned=True): """`source` as the exporter writes it for this piece.""" parts = [ {'volume_ft3': 79.885, 'material': 'STRUCT WYTHE', 'density_pcf': 150.28, 'counted': True}, {'volume_ft3': 39.557, 'material': 'ARCH WYTHE', 'density_pcf': 150.28, 'counted': True}, {'volume_ft3': 39.8, 'material': 'EPS', 'density_pcf': 1.5, 'counted': False}, ] if positioned: for part, (z0, z1) in zip(parts, [(8.0, 16.0), (0.0, 4.0), (4.0, 8.0)]): part['z0_in'], part['z1_in'] = z0, z1 return {'solid_parts': parts, 'envelope_thickness_in': ENVELOPE, 'solid_basis': 'material density'} # ==================================================================== section # # Every expected number below is arithmetic, written out, on b = 64 in. def test_non_composite_inertia_is_the_sum_about_each_wythes_own_axis(): # I = b*t1^3/12 + b*t2^3/12 # = 64*4^3/12 + 64*8^3/12 # = 64*64/12 + 64*512/12 = 341.3333 + 2730.6667 = 3072.0 in^4 bound = sandwich.non_composite(B, wp_layup()) assert bound.i_in4 == pytest.approx(3072.0, abs=1e-9) def test_non_composite_section_modulus_is_governed_by_the_thicker_wythe(): # Wythes tied by the connectors deflect together, so each takes the # moment in proportion to its own stiffness: # M_i = M * I_i / sum(I), f_i = M_i*(t_i/2)/I_i = M*(t_i/2)/sum(I) # so the extreme fibre stress is set by the THICKEST wythe: # S = sum(I) / (t_max/2) = 3072 / 4 = 768.0 in^3 # and the 4 in. wythe's own fibres are at 3072/2 = 1536 in^3. bound = sandwich.non_composite(B, wp_layup()) assert bound.s_governing_in3 == pytest.approx(768.0, abs=1e-9) moduli = sorted(set(round(f.s_in3, 6) for f in bound.fibres)) assert moduli == [768.0, 1536.0] def test_fully_composite_neutral_axis_and_inertia(): # A1 = 64*4 = 256 in^2 at z = 2.0; A2 = 64*8 = 512 in^2 at z = 12.0 # zbar = (256*2 + 512*12)/768 = (512 + 6144)/768 = 8.666667 in # I = 64*4^3/12 + 256*(2 - 8.666667)^2 # + 64*8^3/12 + 512*(12 - 8.666667)^2 # = 341.3333 + 11377.7778 + 2730.6667 + 5688.8889 = 20138.6667 in^4 bound = sandwich.fully_composite(B, wp_layup()) assert bound.axis_z_in == pytest.approx(8.6666667, abs=1e-6) assert bound.i_in4 == pytest.approx(20138.6667, abs=1e-3) def test_fully_composite_section_moduli_at_both_faces(): # form face c = 8.666667 in -> S = 20138.6667/8.666667 = 2323.6923 # far face c = 7.333333 in -> S = 20138.6667/7.333333 = 2746.1818 bound = sandwich.fully_composite(B, wp_layup()) by_label = dict((f.label, f.s_in3) for f in bound.fibres) assert by_label['form face'] == pytest.approx(2323.6923, abs=1e-3) assert by_label['far face'] == pytest.approx(2746.1818, abs=1e-3) assert bound.s_governing_in3 == pytest.approx(2323.6923, abs=1e-3) def test_structural_wythe_alone_is_the_smallest_section_of_the_three(): # The conservative reading of "is the face wythe structural": the 8 in. # wythe alone, S = b*t^2/6 = 64*64/6 = 682.6667 in^3. bound = sandwich.structural_wythe_alone(B, wp_layup()) assert bound.i_in4 == pytest.approx(2730.6667, abs=1e-3) assert bound.s_governing_in3 == pytest.approx(682.6667, abs=1e-3) def test_the_solid_idealisation_is_unconservative_against_BOTH_bounds(): """The whole reason this work exists, as a number. solid at 16 in.: I = 64*16^3/12 = 21845.333, S = 64*256/6 = 2730.667 against fully composite 2730.667/2323.692 = 1.1751 against non-composite 2730.667/768.0 = 3.5556 Both above 1.0 -- so there is no reading of a sandwich under which treating it as solid concrete at the envelope depth is the safe direction. That is the owner's "把夹芯板当实心算并不 conservative", and if this assertion ever flips the refusal has lost its argument. """ bounds = sandwich.section_bounds(wp_layup(), B) assert bounds.solid.i_in4 == pytest.approx(21845.3333, abs=1e-3) assert bounds.solid.s_governing_in3 == pytest.approx(2730.6667, abs=1e-3) against_composite, against_non = bounds.solid_overstates_by assert against_composite == pytest.approx(1.1751, abs=1e-4) assert against_non == pytest.approx(3.5556, abs=1e-4) assert against_composite > 1.0 and against_non > 1.0 def test_shear_flow_is_the_same_from_either_wythe_as_equilibrium_requires(): # Q(bottom) = 256*(8.666667 - 2.0) = 256*6.666667 = 1706.6667 in^3 # Q(top) = 512*(12.0 - 8.666667) = 512*3.333333 = 1706.6667 in^3 # q/V = Q/I = 1706.6667/20138.6667 = 0.0847458 per inch layup = wp_layup() comp = sandwich.fully_composite(B, layup) flow = sandwich.shear_flow(comp, layup, B) assert flow.q_in3 == pytest.approx(1706.6667, abs=1e-3) assert flow.per_lb_of_shear == pytest.approx(0.0847458, abs=1e-7) top = B * T_STRUCT * abs(comp.axis_z_in - 12.0) assert top == pytest.approx(flow.q_in3, abs=1e-6) def test_connector_demand_at_the_measured_spacing(): # The design summary says HK ST100'S @ 1'-4" o.c., and the model has # 5 rows across the width. Per connector, per pound of shear: # 0.0847458 * 16.0 / 5 = 0.271186 lb layup = wp_layup() flow = sandwich.shear_flow(sandwich.fully_composite(B, layup), layup, B, v_lb=10000.0) demand = sandwich.connector_demand(flow, spacing_in=16.0, per_row=5) # q = 0.0847458 * 10000 = 847.458 lb/in; *16/5 = 2711.86 lb assert demand.per_connector_lb == pytest.approx(2711.864, abs=1e-2) # And with no capacity anywhere it is a DEMAND, never a verdict. assert demand.capacity_lb is None assert demand.verdict is None assert 'NOT KNOWN' in demand.why def test_a_demand_with_no_flow_stands_down_rather_than_reading_zero(): demand = sandwich.connector_demand(None, spacing_in=16.0, per_row=5) assert demand.per_connector_lb is None assert demand.verdict is None # ================================================================= detection def test_no_solid_parts_means_could_not_tell_and_never_solid(): said = sandwich.detect({}) assert said.sandwich is None assert said.sandwich is not False assert 'solid_parts' in said.why def test_one_concrete_solid_and_nothing_left_out_is_a_solid_panel(): said = sandwich.detect({'solid_parts': [ {'volume_ft3': 20.0, 'material': 'CONC', 'density_pcf': 150.0, 'counted': True}]}) assert said.sandwich is False def test_a_solid_left_out_as_not_concrete_is_a_sandwich(): said = sandwich.detect(sandwich_source()) assert said.sandwich is True assert said.basis == 'solid_parts' assert 'EPS' in said.why def test_two_concrete_solids_with_nothing_left_out_still_refuses(): """Over-refusing costs an answer; under-refusing prints a third of the real stress. Nothing in the file can tell a wythe pair from a panel and a ledge, so the safe direction is taken and said out loud.""" said = sandwich.detect({'solid_parts': [ {'volume_ft3': 79.9, 'material': 'CONC', 'counted': True}, {'volume_ft3': 39.6, 'material': 'CONC', 'counted': True}]}) assert said.sandwich is True assert 'ledge' in said.why def test_solid_parts_without_positions_knows_it_is_a_sandwich_and_no_more(): said = sandwich.detect(sandwich_source(positioned=False)) assert said.sandwich is True assert said.layup.measured is False assert said.layup.complete is False bounds = sandwich.section_bounds(said.layup, B) assert bounds.usable is False assert 'z0_in' in bounds.why def test_a_layup_that_was_not_measured_computes_no_section(): """The state a job file written before the exporter measured positions is in. It must refuse, not invent a layup that would look ordinary.""" bounds = sandwich.section_bounds( sandwich.layup_from_source(sandwich_source(positioned=False)), B) assert bounds.usable is False assert bounds.non_composite is None assert bounds.composite is None # ========================================================== composite ratio def test_the_composite_ratio_stands_down_and_names_both_definitions(): """A computed ratio is impossible without connector data -- not hard, impossible -- and an unlabelled percentage is a number nobody can check. Two definitions, both named, both None.""" ratio = sandwich.composite_ratio( sandwich.non_composite(B, wp_layup()), sandwich.fully_composite(B, wp_layup())) assert ratio.stiffness is None assert ratio.strength is None assert 'I_eff' in ratio.definition_stiffness assert 'DEFLECTION' in ratio.definition_stiffness assert 'STRENGTH' in ratio.definition_strength # The two bounding inertias ARE printed: the width of the band is on the # page even though the position in it is not. assert ratio.i_non_composite_in4 == pytest.approx(3072.0) assert ratio.i_full_composite_in4 == pytest.approx(20138.6667, abs=1e-3) # And what would unblock each is stated per number, not as one shrug. joined = ' '.join(ratio.unblocked_by) assert 'stiffness' in joined and 'failure mode' in joined def test_the_ratio_never_prints_a_percentage_anywhere_in_its_own_text(): ratio = sandwich.composite_ratio() assert '%' not in ratio.why # ================================================================== concerns def test_the_two_bounds_bracket_flexure_and_shear_flow_and_nothing_else(): """可能出现的问题, enumerated. Exactly three quantities are bracketed; everything else about a sandwich panel is not a bounds question, and a reader who takes the two-bound report as an envelope over all of it is the failure this list exists to prevent.""" items = sandwich.concerns(wp_layup()) bracketed = [c.title for c in items if c.bracketed] assert bracketed == ['wythe flexural tension', 'interface shear flow', 'deflection and bowing'] not_bracketed = [c.title for c in items if not c.bracketed] assert 'connector force distribution along the span' in not_bracketed assert 'connector NORMAL (tension) demand' in not_bracketed assert ('local flexure of the face wythe between connectors' in not_bracketed) def test_unequal_wythes_get_their_own_concern(): items = [c.title for c in sandwich.concerns(wp_layup())] assert 'the wythes are unequal' in items def test_equal_wythes_do_not(): even = sandwich.Layup( layers=[sandwich.Layer(sandwich.CONCRETE, 0.0, 6.0), sandwich.Layer(sandwich.NOT_CONCRETE, 6.0, 8.0), sandwich.Layer(sandwich.CONCRETE, 8.0, 14.0)], envelope_in=14.0, measured=True) assert 'the wythes are unequal' not in [c.title for c in sandwich.concerns(even)] def test_the_sign_flip_at_the_inner_face_is_written_down(): """The trap a two-fibre check misses: the inner face of the outer wythe is in compression non-composite and in tension fully composite, so it changes sign between the bounds.""" layup = wp_layup() nc = sandwich.non_composite(B, layup) fc = sandwich.fully_composite(B, layup) # M = 1,000,000 lb-in, sagging. m = 1.0e6 # non-composite, the 4 in. wythe's own top fibre at z = 4: # f = -M*(+2.0)/3072 = -651.0 psi, COMPRESSION inner_nc = -m * (T_ARCH / 2.0) / nc.i_in4 # fully composite at z = 4, which is 4.6667 in BELOW the axis: # f = -M*(4 - 8.666667)/20138.667 = +231.7 psi, TENSION inner_fc = -m * (4.0 - fc.axis_z_in) / fc.i_in4 assert inner_nc < 0.0 and inner_fc > 0.0 said = [c.sentence for c in sandwich.concerns(layup) if c.title == 'wythe flexural tension'][0] assert 'changes sign between the bounds' in said # ================================================================== refusal def sandwich_job(): """A real-shaped job file for an insulated piece, layup MEASURED. Deliberately an OTHERWISE VALID job: everything here analyses fine, on the CONCRETE-WYTHE composite section (insulation excluded) that `section.py` now builds. Before 2026-09-02 this was refused outright; the point of the refusal was never "a sandwich cannot be analysed", it was "a sandwich must not be analysed as one SOLID prism". Now that it is not, a measured sandwich runs. """ from test_cli import aspire_job job = aspire_job() job['source'] = sandwich_source() job['panel']['thickness_in'] = ENVELOPE return job def unmeasured_sandwich_job(): """The same piece, but the exporter could not measure the layup positions. This is the makeup that STILL refuses: without z0/z1 there is no centroid, no I and no d_v, so there is no section to build and no solid guess is allowed to stand in for one. """ from test_cli import aspire_job job = aspire_job() job['source'] = sandwich_source(positioned=False) job['panel']['thickness_in'] = ENVELOPE return job def test_a_measured_sandwich_now_analyses_instead_of_being_refused(): """The defect this closes: a measured sandwich must be analysed, and on the concrete-wythe composite section, not refused and not treated solid.""" from panellift import section panel, _rig, cases = cli.analyse_job(sandwich_job()) assert len(cases) >= 1 net = section.NetSection(cases[0][0].panel, panel.length_ft / 2.0) assert net.is_sandwich is True # the section is the concrete only: 4 + 8 = 12 in of concrete, NOT the # 16 in envelope, and the shear depth follows the concrete. assert net.shear_thickness_in == pytest.approx(T_ARCH + T_STRUCT) assert net.thickness_in == pytest.approx(ENVELOPE) # envelope still drawn # composite centroid off mid-depth, so the two faces have unequal S. assert net.zbar_in == pytest.approx(8.6666667, abs=1e-6) assert net.c_form_in != pytest.approx(net.c_far_in) # and it is NOT the solid idealisation: area is both wythes, insulation out b_in = panel.width_ft * 12.0 assert net.area_in2 == pytest.approx(b_in * (T_ARCH + T_STRUCT), abs=1e-6) def test_a_measured_sandwich_writes_a_report_and_does_not_exit_two(tmp_path): path = tmp_path / 'job.json' out = tmp_path / 'out.html' path.write_text(json.dumps(sandwich_job()), encoding='utf-8') code = cli.main([str(path), '-o', str(out)]) assert code in (0, 1) # a verdict, not a refusal (2) assert out.exists() def test_an_unmeasured_sandwich_still_refuses_before_anything_is_calculated(): with pytest.raises(cli.SandwichSection) as caught: cli.analyse_job(unmeasured_sandwich_job()) message = str(caught.value) assert sandwich.REFUSAL_CODE in message assert 'overstates the section modulus' in message def test_the_refusal_is_a_joberror_so_every_existing_caller_still_refuses(): """`tools/sweep.py`, the button and the CLI all catch JobError. A new exception class they have never heard of would escape as a traceback.""" assert issubclass(cli.SandwichSection, cli.JobError) with pytest.raises(cli.JobError): cli.analyse_job(unmeasured_sandwich_job()) def test_the_refusal_carries_the_evidence_it_can_still_give(tmp_path): """An unmeasured layup cannot be bounded, but the refusal still says WHY -- the missing positions -- rather than one bare word.""" with pytest.raises(cli.SandwichSection) as caught: cli.analyse_job(unmeasured_sandwich_job()) bounds = caught.value.bounds assert bounds.usable is False assert 'z0_in' in bounds.why def test_the_cli_writes_no_report_and_exits_two_on_an_unmeasured_sandwich( tmp_path): path = tmp_path / 'job.json' out = tmp_path / 'out.html' path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8') code = cli.main([str(path), '-o', str(out)]) assert code == 2 assert not out.exists() def test_the_refusal_json_never_says_ok_warn_or_fail(tmp_path, capsys): path = tmp_path / 'job.json' out = tmp_path / 'out.html' path.write_text(json.dumps(unmeasured_sandwich_job()), encoding='utf-8') assert cli.main([str(path), '-o', str(out), '--json']) == 2 payload = json.loads(capsys.readouterr().out) assert payload['verdict'] == 'REFUSED' assert payload['verdict'] not in ('OK', 'WARN', 'FAIL') assert payload['complete'] is False assert payload['composite_ratio']['stiffness'] is None assert payload['connector_sufficiency']['verdict'] is None def test_the_refusal_text_prints_the_evidence_not_just_the_word_no(): detection = sandwich.detect(sandwich_source()) bounds = sandwich.section_bounds(detection.layup, B) text = sandwich.refusal_text(bounds, detection, 'PNL115', 16.0, 5) assert 'NO STAGE WAS ANALYSED' in text assert 'non-composite' in text and 'fully composite' in text assert 'NOT COMPUTED' in text # the composite ratio assert 'NOT CHECKED' in text # connector sufficiency assert 'NOT BRACKETED' in text # every bound's assumption travels with its numbers assert 'no shear is transferred across the insulation' in text def test_the_refusal_text_survives_a_layup_it_could_not_measure(): detection = sandwich.detect(sandwich_source(positioned=False)) bounds = sandwich.section_bounds(detection.layup, B) text = sandwich.refusal_text(bounds, detection, 'PNL115') assert 'could not be bounded' in text assert 'NOT BRACKETED' in text # ============================================== the solid path does not move SOLID_GOLDEN = { # Typed in, not recomputed. A pin that recomputes both sides agrees with # any refactor, including a wrong one -- the rule the takeoff count was # pinned under. These are the ASPIRE four-point pick as it stood before # any of the sandwich work. 'verdict': 'WARN', 'complete': True, 'multiplier': 1.3, 'fc_psi': 4000.0, 'peak_tension_psi': 244.71194093343502, 'peak_at_ft': 15.992, 'allowable_psi': 316.2277660168379, 'ratio': 0.7738471039902456, 'as_built_ratio': 0.7738471039902419, 'recommended_ratio': 0.7682552280488836, } def test_a_solid_panel_is_byte_for_byte_the_number_it_always_was(): from test_cli import aspire_job _panel, _rig, cases = cli.analyse_job(aspire_job()) summary = cli.summarise(cases) stage = summary['stages'][0] assert summary['verdict'] == SOLID_GOLDEN['verdict'] assert summary['complete'] is SOLID_GOLDEN['complete'] for key in ('multiplier', 'fc_psi', 'peak_tension_psi', 'peak_at_ft', 'allowable_psi', 'ratio'): assert repr(stage[key]) == repr(SOLID_GOLDEN[key]), key rec = summary['recommendation'] assert repr(rec['as_built_ratio']) == repr(SOLID_GOLDEN['as_built_ratio']) assert repr(rec['recommended_ratio']) == \ repr(SOLID_GOLDEN['recommended_ratio']) def test_a_solid_job_gains_no_new_summary_keys(): """`basis` is written only when the up-end rule did NOT answer, the same "only when it is not the ordinary case" rule `searched` and `enabled` follow. A job file with no source block must summarise exactly as it did before this existed.""" from test_cli import aspire_job _panel, _rig, cases = cli.analyse_job(aspire_job()) assert sorted(cli.summarise(cases)) == [ 'complete', 'not_run', 'recommendation', 'stages', 'verdict'] # ==================================================== which end is up (G2) def test_the_up_end_fallback_is_disclosed_on_the_page_and_in_the_summary(): """The refusal `knowledge.up_end_from_origin` makes is the project's flagship declared-coverage refusal, and until now `erected_orientation` could override it with a fallback whose reason nothing read. If the best-argued refusal in the tree can be undone by an `or` in its caller, the idiom is a comment.""" from test_cli import aspire_job job = aspire_job() job['source'] = { 'up_end_basis': 'global elevation', 'up_end_basis_why': 'assembly X is not horizontal, so the rule ' 'stands down. Which end is up was therefore read ' 'from GLOBAL ELEVATION instead.'} _panel, _rig, cases = cli.analyse_job(job) summary = cli.summarise(cases) assert summary['basis']['basis'] == 'global elevation' assert 'stands down' in summary['basis']['why'] html = report.build_many(cases) # Shortened 2026-09-01 (owner: "把这一段从lift的结果里面删掉,或者缩短 # ...太长了压根不会看的") -- the always-visible line states the fact in # a few words; the producer's own reasoning (which still names GLOBAL # ELEVATION) is folded, not deleted. assert 'read from global elevation, not the assembly origin' in html assert 'GLOBAL ELEVATION' in html # and it says which stage it actually decides assert 'erection' in html def test_the_ruled_basis_is_not_shouted_about(): """A page that shouts about a rule that worked teaches the reader to skip the block on the day it did not.""" from test_cli import aspire_job job = aspire_job() job['source'] = {'up_end_basis': report.RULED_UP_END_BASIS, 'up_end_basis_why': 'the origin answered'} _panel, _rig, cases = cli.analyse_job(job) assert 'basis' not in cli.summarise(cases) assert 'came from a fallback' not in report.build_many(cases) def test_the_job_level_source_actually_reaches_the_panel(): """It did not, for the whole life of the exporter: `read_panel` looks for `source` inside `panel` and the exporter writes it at job level, so `panel.source` came out empty on every real job file and everything the Revit side recorded was unreadable from here.""" from test_cli import aspire_job job = aspire_job() job['source'] = {'up_end_basis': 'global elevation', 'model': 'sample_job'} panel, _rig, _cases = cli.analyse_job(job) assert panel.source['up_end_basis'] == 'global elevation' def test_transpose_still_wins_over_the_job_source_for_its_own_key(): """`transpose` writes `prestress_not_credited` about the TURNED copy; a job-level key of the same name must not overwrite it.""" from panellift.model import Panel, Prestress, Strand panel = Panel(mark='T', length_ft=20.0, width_ft=8.0, thickness_in=8.0, prestress=Prestress(strands=[Strand(y=1.0, z_in=4.0, initial_pull_lb=1000.0)]), source={'model': 'sample_job'}) turned = cli.transpose(panel) assert 'prestress_not_credited' in turned.source assert turned.source['model'] == 'sample_job' def test_the_solid_parts_keys_are_the_ones_the_exporter_writes(): """THE CONTRACT, pinned from this side. `vibe.liftexport._solid_parts` writes exactly these keys on each entry of `source.solid_parts`. Neither package can import the other -- that is the whole design -- so nothing but a pin at each end stops one being renamed under the other. The far half is `test_liftexport.py:: test_the_solid_parts_keys_are_the_ones_panellift_reads`. """ part = sandwich_source()['solid_parts'][0] assert sorted(part) == ['counted', 'density_pcf', 'material', 'volume_ft3', 'z0_in', 'z1_in'] # and every one of them is actually consumed layer = sandwich._layer_from_part(part) assert layer.kind == sandwich.CONCRETE assert layer.z0_in == 8.0 and layer.z1_in == 16.0 assert layer.material == 'STRUCT WYTHE' assert layer.density_pcf == 150.28 assert layer.volume_ft3 == 79.885 def test_the_layup_block_the_exporter_writes_is_not_needed_to_detect(): """`source.layup` is the same facts arranged for the section; the DETECTION runs off `solid_parts` alone, so a job file from an exporter that could not build a layup still refuses.""" source = sandwich_source(positioned=False) source['layup'] = {'measured': False, 'layers': [], 'why': 'x'} assert sandwich.detect(source).sandwich is True