# -*- coding: utf-8 -*- """Tests for the panelanalysis/1 SOLVE path -- the self-weight Handling model. Loaded by path, exactly like `test_panelanalysis.py` beside it: this is host CPython (it imports `panellift`, dataclasses, typing syntax) sitting in an IronPython 2.7 extension, and it reaches the tool through `tools/`, which the sibling knowledge merge in `deckcheck.py` puts the `vibe` stub in place for. The four things the Stage 0 brief asked these to nail down: 1. the self-weight solve reactions sum to the load, the moment diagram is real, and the per-station capacity comes from `panellift`; 2. editing the deck's supports and re-solving changes the reactions -- the round-trip the owner wants; 3. a deck with fewer than two supports is REFUSED loudly (panellift's own "a supported stage needs at least two supports"), surfaced as NOT JUDGED on the page, never a traceback and never a silent OK; 4. missing prestress dashes with panellift's reason, never a zero; the In-Place sections are explicit placeholders; zero CJK on the page. Everything written at runtime goes under `tmp_path`; nothing touches the working directory. """ from __future__ import annotations import io import json import os import re import sys import pytest ROOT = os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))) TOOLS = os.path.join(ROOT, 'tools') PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src') for p in (TOOLS, PANELLIFT_SRC): if p not in sys.path: sys.path.insert(0, p) from vibe import knowledge # noqa: E402,F401 (registers the vibe stub) from vibe import plantpath # noqa: E402,F401 pa = pytest.importorskip('panelanalysis') schema = pytest.importorskip('panelanalysis_schema') CJK = re.compile(u'[一-鿿]') IN_PLACE_TITLES = [ 'In-Place Load Case (not computed)', 'In-Place Load Combination (not computed)', 'In-Place Strength (not computed)', 'In-Place Service (not computed)', ] # ------------------------------------------------------------------- helpers def _solve(deck): panel = schema.to_panel(deck) return panel, pa.solve_handling(deck, panel) # --------------------------------------------------------------------- solve def test_self_weight_reactions_sum_to_the_load(): deck = schema.new_deck(support_xs=(2.0, 22.0)) panel, (analysis, refusal) = _solve(deck) assert refusal is None total_r = sum(l.reaction_lb for l in analysis.rig.lines) total_load = analysis.profile.total_lb assert total_load > 0 # panellift's consistent-load formulation makes this exact; a loose floor. assert abs(total_r - total_load) <= max(1.0, 1e-6 * total_load) def test_moment_diagram_is_real(): deck = schema.new_deck(support_xs=(2.0, 22.0)) _panel, (analysis, refusal) = _solve(deck) assert refusal is None assert len(analysis.diagram.moment_lbft) > 10 assert max(abs(m) for m in analysis.diagram.moment_lbft) > 0.0 # A simply-supported self-weight beam sags between the supports. _x, mmax = analysis.diagram.max_positive assert mmax > 0.0 def test_capacity_stations_come_from_panellift(): """f_se and phi*Mn at mid-span are real numbers from panellift.capacity.""" from panellift import capacity deck = schema.new_deck(support_xs=(2.0, 22.0)) panel, (analysis, refusal) = _solve(deck) assert refusal is None x = panel.length_ft / 2.0 m = analysis.diagram.moment_at(x) v = analysis.diagram.shear_at(x) stage = schema.handling_stage(deck, panel) cap = capacity.capacity_at(panel, stage, x, m, v) # A prestressed piece with developed strand at mid-span: real f_se and a # real phi*Mn / phi*Vn, not None. assert cap.fse_ksi is not None and cap.fse_ksi > 0 assert cap.phi_mn_lbft is not None and cap.phi_mn_lbft > 0 assert cap.phi_vn_lb is not None and cap.phi_vn_lb > 0 def test_editing_supports_changes_the_reactions(): """The round-trip: move the supports, re-solve, the answer moves.""" # Asymmetric supports so the change is unmistakable: a symmetric panel on # supports symmetric about its centroid gives equal reactions either way, # which would hide the very round-trip this asserts. a = schema.new_deck(support_xs=(2.0, 22.0)) # symmetric: half and half b = schema.new_deck(support_xs=(2.0, 14.0)) # right support inboard _pa, (aa, ra) = _solve(a) _pb, (ab, rb) = _solve(b) assert ra is None and rb is None rx_a = [round(l.reaction_lb, 1) for l in aa.rig.lines] rx_b = [round(l.reaction_lb, 1) for l in ab.rig.lines] assert rx_a != rx_b # and the sum is unchanged -- the load did not move, only how it is shared. assert abs(sum(rx_a) - sum(rx_b)) <= 1.0 def test_three_supports_are_indeterminate_and_still_close(): deck = schema.new_deck(support_xs=(2.0, 12.0, 22.0)) _panel, (analysis, refusal) = _solve(deck) assert refusal is None total_r = sum(l.reaction_lb for l in analysis.rig.lines) total_load = analysis.profile.total_lb assert abs(total_r - total_load) <= max(1.0, 1e-6 * total_load) # ------------------------------------------------------- the loud refusals def test_fewer_than_two_supports_is_refused_loudly(): deck = schema.new_deck(support_xs=(5.0,)) _panel, (analysis, refusal) = _solve(deck) assert analysis is None assert 'at least two supports' in refusal def test_one_support_page_says_not_judged_not_a_traceback(): deck = schema.new_deck(support_xs=(5.0,)) html = pa.build_analysis_page(deck) # must not raise assert 'NOT JUDGED' in html assert 'at least two supports' in html assert 'Traceback' not in html # The In-Place placeholders still render -- the page is whole. for title in IN_PLACE_TITLES: assert '>{0}<'.format(title) in html, title def test_support_off_the_piece_is_refused(): deck = schema.new_deck(length_ft=24.0, support_xs=(2.0, 99.0)) _panel, (analysis, refusal) = _solve(deck) assert analysis is None assert 'outside the piece' in refusal def test_no_prestress_capacity_dashes_with_reason_not_a_zero(): deck = schema.new_deck(strands=[]) panel = schema.to_panel(deck) assert panel.prestress is None html = pa.build_analysis_page(deck) # solve still runs (dunnage) # panellift.capacity's own words for a strand-only tool with no strand -- # a dash carrying this reason, never a fabricated phi*Mn of 0. assert 'no prestressing on this piece' in html # The self-weight solve itself still ran (reactions do not need strand). assert 'Support reactions' in html # ------------------------------------------------------------- the whole page def test_page_has_every_section_and_no_cjk(): deck = schema.new_deck(support_xs=(2.0, 22.0)) html = pa.build_analysis_page(deck) for title in ['General / Project Information', 'Concrete Extents', 'Materials', 'Prestress / Reinforcement', 'Section Properties'] + IN_PLACE_TITLES: assert '>{0}<'.format(title) in html, title assert 'Support reactions' in html assert 'Demand vs capacity' in html m = CJK.search(html) assert not m, 'CJK character found: {0!r}'.format(m.group() if m else '') assert 'WP242' not in html # neutral fixture marks only def test_cli_end_to_end_writes_only_under_tmp_path(tmp_path): deck = schema.new_deck(support_xs=(2.0, 22.0)) deck_path = tmp_path / 'PNL101.pa.json' out_path = tmp_path / 'out.html' with io.open(str(deck_path), 'w', encoding='utf-8') as fh: json.dump(deck, fh) rc = pa.main([str(deck_path), '-o', str(out_path)]) assert rc == 0 assert out_path.exists() html = out_path.read_text(encoding='utf-8') assert 'Support reactions' in html assert 'Handling' in html # ----------------------------------------------------------------- schema def test_unread_fields_carry_an_explicit_reason(): deck = schema.new_deck() # in_place is the whole-block placeholder. assert deck['in_place']['value'] is None assert deck['in_place']['reason'] # loading.wind / combinations are the file contract's null+reason shape. for key in ('wind', 'combinations'): assert deck['loading'][key]['value'] is None assert deck['loading'][key]['reason'] # structural_model restraints/bearing likewise. for key in ('restraints', 'bearing'): assert deck['structural_model'][key]['value'] is None assert deck['structural_model'][key]['reason'] def test_a_foreign_schema_is_refused_by_name(): deck = schema.new_deck() deck['schema'] = 'panellift/1' with pytest.raises(schema.DeckError): schema.check_schema(deck)