8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
608 lines
24 KiB
Python
608 lines
24 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The job file is a contract, so it is tested like one.
|
|
|
|
Revit cannot import this package and this package cannot import Revit; the
|
|
JSON between them is the only place the two agree. Every way of getting it
|
|
wrong that would still produce a plausible-looking report is worth a test,
|
|
because a handling report that looks ordinary and is wrong is the failure this
|
|
whole package exists to prevent.
|
|
"""
|
|
import io
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from panellift import checks, cli
|
|
from conftest import ASPIRE, LENGTH_FT, THICKNESS_IN, WIDTH_FT
|
|
from panellift import pci
|
|
|
|
|
|
def aspire_job(**overrides):
|
|
"""The ASPIRE panel, written the way Revit would write it."""
|
|
left, right = pci.optimal_two_line_supports(LENGTH_FT)
|
|
job = {
|
|
'schema': 'panellift/1',
|
|
'panel': {
|
|
'mark': 'ASPIRE-1',
|
|
'length_ft': LENGTH_FT,
|
|
'width_ft': WIDTH_FT,
|
|
'thickness_in': THICKNESS_IN,
|
|
'unit_weight_pcf': 150.0,
|
|
'fci_psi': 4000.0,
|
|
'fc_psi': 5000.0,
|
|
'lifters': [
|
|
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
|
|
'y': 2.5, 'capacity_lb': 12000.0},
|
|
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
|
|
'y': 7.5, 'capacity_lb': 12000.0},
|
|
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
|
|
'y': 2.5, 'capacity_lb': 12000.0},
|
|
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
|
|
'y': 7.5, 'capacity_lb': 12000.0},
|
|
],
|
|
},
|
|
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
|
|
'stages': [
|
|
{'name': 'stripping',
|
|
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
|
|
'fc_psi': 4000.0},
|
|
],
|
|
}
|
|
job.update(overrides)
|
|
return job
|
|
|
|
|
|
# --------------------------------------------------------------- it computes
|
|
|
|
def test_job_reproduces_the_published_stripping_stress():
|
|
"""A job file must reach the same number the fixtures do, or it is a
|
|
second implementation rather than a second front door."""
|
|
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
|
summary = cli.summarise(cases)
|
|
peak = summary['stages'][0]['peak_tension_psi']
|
|
assert peak == pytest.approx(ASPIRE['strip_stress_psi'], abs=1.5)
|
|
|
|
|
|
def test_stage_without_lifters_key_uses_every_insert():
|
|
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
|
assert cases[0][0].rig.points == 4
|
|
|
|
|
|
def test_stage_may_carry_the_piece_on_a_subset():
|
|
"""The whole reason stages name their inserts: a crane that cannot reach
|
|
the top row makes erection a different pick from stripping."""
|
|
job = aspire_job()
|
|
job['stages'][0]['lifters'] = ['L1', 'L2', 'L3']
|
|
_panel, _rig, cases = cli.analyse_job(job)
|
|
assert cases[0][0].rig.points == 3
|
|
|
|
|
|
# ------------------------------------------------------------ it refuses well
|
|
|
|
def test_unknown_lifter_mark_is_an_error_not_a_smaller_pick():
|
|
"""Silently analysing three picks where four were meant is
|
|
unconservative and looks entirely normal in the report."""
|
|
job = aspire_job()
|
|
job['stages'][0]['lifters'] = ['L1', 'L2', 'L9']
|
|
with pytest.raises(cli.JobError) as err:
|
|
cli.analyse_job(job)
|
|
assert 'L9' in str(err.value)
|
|
|
|
|
|
def test_duplicate_lifter_marks_are_refused():
|
|
job = aspire_job()
|
|
job['panel']['lifters'][1]['mark'] = 'L1'
|
|
with pytest.raises(cli.JobError):
|
|
cli.analyse_job(job)
|
|
|
|
|
|
# --------------------------------------------- capacity_lb: the dict shape
|
|
#
|
|
# `knowledge.lifter_capacity()` (private repo `pyrevit-knowledge`, week of
|
|
# 2026-09-01) grew a richer shape for MC hardware: `capacity_lb` and
|
|
# `shear_capacity_lb` can now arrive as a dict instead of a bare number, and
|
|
# `liftexport.export()` started writing that dict straight through. A real
|
|
# MC dry-run (WPA594) hit `read_panel` doing `float({...})` and died with
|
|
# `"capacity_lb" is not a number` -- no report, exit 2, before a single
|
|
# check ran. These pin the fix: `cli._resolve_capacity` reading both the
|
|
# split-number shape and the angle-unresolved stand-down shape, with the
|
|
# existing plain-number shape passing through byte-for-byte unchanged.
|
|
|
|
def test_a_plain_number_capacity_is_unchanged_the_regression_case():
|
|
"""The critical backward-compat: a plain-number deck carries a bare
|
|
number and must read identically to before the dict shape ever
|
|
existed."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'][0]['capacity_lb'] = 12000.0
|
|
job['panel']['lifters'][0]['shear_capacity_lb'] = 9000.0
|
|
panel = cli.read_panel(job['panel'])
|
|
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
|
assert l1.capacity_lb == 12000.0
|
|
assert l1.shear_capacity_lb == 9000.0
|
|
assert l1.capacity_why is None
|
|
assert l1.shear_capacity_why is None
|
|
|
|
|
|
def test_a_split_tension_shear_dict_reads_both_capacities():
|
|
"""{'tension_lb': N, 'shear_lb': M} -- MC's QL589-shaped rating."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'][0]['capacity_lb'] = {
|
|
'tension_lb': 13800, 'shear_lb': 3860}
|
|
job['panel']['lifters'][0]['shear_capacity_lb'] = {
|
|
'tension_lb': 13800, 'shear_lb': 3860}
|
|
panel = cli.read_panel(job['panel'])
|
|
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
|
assert l1.capacity_lb == 13800.0
|
|
assert l1.shear_capacity_lb == 3860.0
|
|
# A dict that resolved to a real number carries no could-not-judge
|
|
# reason of its own.
|
|
assert l1.capacity_why is None
|
|
assert l1.shear_capacity_why is None
|
|
|
|
|
|
def test_a_zero_capacity_inside_a_dict_is_a_real_rating_not_a_missing_one():
|
|
"""MC's LPA4T512 publishes `shear_lb: 0` -- the pin genuinely takes no
|
|
shear. That is a judgeable 0.0, not the same fact as "no rating"
|
|
(which reads as None): a dict carrying the key at all, even as 0, must
|
|
resolve to the number, never fall through to the reason branch."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'][0]['shear_capacity_lb'] = {
|
|
'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680,
|
|
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
|
panel = cli.read_panel(job['panel'])
|
|
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
|
assert l1.shear_capacity_lb == 0.0
|
|
assert l1.shear_capacity_why is None
|
|
|
|
|
|
def test_an_angle_unresolved_dict_leaves_tension_none_with_its_reason():
|
|
"""The MC LPA4T512 shape: no settled `tension_lb`, only 60/75-degree
|
|
columns and a stated reason nobody has confirmed which applies. This
|
|
must NOT pick 60 or 75 -- tension resolves to None, carrying the exact
|
|
reason string, never a silently chosen number."""
|
|
job = aspire_job()
|
|
reason = ('M8 (manufacturer Lifting Anchor Chart published 12.02.22, '
|
|
'footnote b) publishes LPA4T512 tension by sling angle -- '
|
|
'5,990 lb @ 60 deg, 6,680 lb @ 75 deg -- and asks the plant '
|
|
'to confirm which column applies. lifting_anchors.'
|
|
'sling_angle_choice is still null: nobody has confirmed it.')
|
|
job['panel']['lifters'][0]['capacity_lb'] = {
|
|
'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680,
|
|
'tension_lb_unresolved_because': reason}
|
|
panel = cli.read_panel(job['panel'])
|
|
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
|
assert l1.capacity_lb is None
|
|
assert l1.capacity_why == reason
|
|
# Neither 60 nor 75 was silently picked.
|
|
assert '5990' not in (l1.capacity_why or '')
|
|
assert '6680' not in (l1.capacity_why or '')
|
|
|
|
|
|
def test_a_dict_reason_beats_the_generic_top_level_why():
|
|
"""The exporter also writes a generic top-level `capacity_why`
|
|
("taken as stated (<dict repr>)") beside the dict. The SPECIFIC
|
|
per-axis reason inside the dict is what a reader needs, and must win
|
|
over that generic wrapper rather than being shadowed by it."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'][0]['capacity_lb'] = {
|
|
'shear_lb': 0, 'tension_60_lb': 5990,
|
|
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
|
job['panel']['lifters'][0]['capacity_why'] = (
|
|
'TEST_LIFTER is a job- or plant-pinned rating, taken as stated')
|
|
panel = cli.read_panel(job['panel'])
|
|
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
|
assert l1.capacity_why == 'sling angle not confirmed'
|
|
|
|
|
|
def test_a_dict_with_no_number_and_no_reason_is_not_judged_not_crashed():
|
|
"""A malformed dict -- neither a settled number nor a stated reason for
|
|
the axis being read -- must still refuse to guess, but as a named
|
|
could-not-judge rather than `float(dict)` raising TypeError."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'][0]['capacity_lb'] = {'something_else': 1}
|
|
panel = cli.read_panel(job['panel'])
|
|
l1 = [l for l in panel.lifters if l.mark == 'L1'][0]
|
|
assert l1.capacity_lb is None
|
|
assert l1.capacity_why is not None
|
|
assert 'NOT JUDGED' in l1.capacity_why
|
|
|
|
|
|
def test_a_wpa594_shaped_deck_with_empty_stages_refuses_legibly():
|
|
"""The real dry-run failure this whole fix is about: a deck with MC's
|
|
dict-shaped capacities and zero stages (no `handling.lifter_groups`
|
|
mapping yet). Before the fix this died in `read_panel` on
|
|
`float({...})`, exit 2, before the empty-stage guard ever ran. After
|
|
it, `read_panel` succeeds and the ordinary empty-stage refusal fires
|
|
instead -- a clean JobError, not a crash, and not a report."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'][0]['capacity_lb'] = {
|
|
'shear_lb': 0, 'tension_60_lb': 5990,
|
|
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
|
job['stages'] = []
|
|
with pytest.raises(cli.JobError) as err:
|
|
cli.analyse_job(job)
|
|
assert 'not a number' not in str(err.value)
|
|
|
|
|
|
def test_a_wpa594_shaped_deck_with_empty_stages_exits_clean_via_main(
|
|
tmp_path, capsys):
|
|
job = aspire_job()
|
|
job['panel']['lifters'][0]['capacity_lb'] = {
|
|
'shear_lb': 0, 'tension_60_lb': 5990,
|
|
'tension_lb_unresolved_because': 'sling angle not confirmed'}
|
|
job['stages'] = []
|
|
job_file = tmp_path / 'job.json'
|
|
out = tmp_path / 'report.html'
|
|
job_file.write_text(json.dumps(job), encoding='utf-8')
|
|
|
|
assert cli.main([str(job_file), '-o', str(out)]) == 2
|
|
assert not out.exists()
|
|
captured = capsys.readouterr()
|
|
assert 'not a number' not in captured.err
|
|
assert 'Traceback' not in captured.err
|
|
|
|
|
|
def test_a_job_with_no_stages_checks_nothing_and_says_so():
|
|
job = aspire_job()
|
|
job['stages'] = []
|
|
with pytest.raises(cli.JobError):
|
|
cli.analyse_job(job)
|
|
|
|
|
|
# ------------------------------------------- zero lifters: NOT JUDGED, loudly
|
|
#
|
|
# `families.lifter_include` matching zero families on the Revit side is
|
|
# CLAUDE.md's own named cardinal sin, and it has bitten twice already: the
|
|
# shipped defaults matching no real family, and P78 on insulated panels --
|
|
# both times a silent degrade into a report that completed and looked like a
|
|
# clean pass. `liftexport.export` now refuses before writing a deck shaped
|
|
# like this at all, but a HAND-WRITTEN job file skips the exporter entirely,
|
|
# so this package has to refuse on its own too -- defense in depth. See
|
|
# `cli._refuse_zero_lifters`.
|
|
|
|
def test_zero_lifters_is_refused_not_judged():
|
|
job = aspire_job()
|
|
job['panel']['lifters'] = []
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(job)
|
|
assert 'NOT JUDGED' in str(caught.value)
|
|
assert 'panel.lifters' in str(caught.value)
|
|
|
|
|
|
def test_zero_lifters_names_the_panel_wide_fact_not_one_stages_pick_list():
|
|
"""Without the panel-wide guard, the per-stage 'carries the piece on no
|
|
inserts' message would fire first -- once statics reached that stage --
|
|
and read as one stage's narrower pick problem rather than the fact that
|
|
the piece has no lifters modelled at all."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'] = []
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(job)
|
|
assert 'carries the piece on no inserts' not in str(caught.value)
|
|
|
|
|
|
def test_zero_lifters_with_an_explicit_empty_stage_list_is_also_refused():
|
|
"""The exporter writes an explicit `"lifters": []` on a stage, not an
|
|
absent key (see default_stages() on the Revit side) -- the guard must
|
|
catch that shape too, not just a stage with no 'lifters' key at all."""
|
|
job = aspire_job()
|
|
job['panel']['lifters'] = []
|
|
job['stages'][0]['lifters'] = []
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(job)
|
|
assert 'NOT JUDGED' in str(caught.value)
|
|
|
|
|
|
def test_zero_lifters_on_dunnage_only_stages_still_computes():
|
|
"""A piece genuinely carried on dunnage for every stage that runs needs
|
|
no insert at all -- the guard must not refuse a legitimate dunnage-only
|
|
deck just because panel.lifters happens to be empty."""
|
|
job = _supported_job()
|
|
job['panel']['lifters'] = []
|
|
_panel, _rig, cases = cli.analyse_job(job)
|
|
assert cases[0][0].rig.points == 0
|
|
|
|
|
|
def test_zero_lifters_with_every_lift_stage_disabled_computes_the_rest():
|
|
"""A lift stage that is switched off is not going to run, so it does not
|
|
need lifters either -- only an ENABLED, non-dunnage stage does."""
|
|
dunnage_stage = _supported_job()['stages'][0]
|
|
job = aspire_job()
|
|
job['panel']['lifters'] = []
|
|
job['stages'] = [
|
|
dict(job['stages'][0], enabled=False,
|
|
disabled_reason='no crane on this site yet'),
|
|
dunnage_stage,
|
|
]
|
|
_panel, _rig, cases = cli.analyse_job(job)
|
|
assert len(cases) == 1
|
|
assert cases[0][0].stage.supports == dunnage_stage['supports']
|
|
|
|
|
|
def test_a_normal_deck_with_lifters_is_unaffected_by_the_guard():
|
|
"""The other side of the same rule: a piece that DOES have lifters must
|
|
not trip on a guard meant for the piece that has none."""
|
|
_panel, _rig, cases = cli.analyse_job(aspire_job())
|
|
assert len(cases) == 1
|
|
|
|
|
|
def test_main_reports_not_judged_without_a_traceback(tmp_path, capsys):
|
|
job = aspire_job()
|
|
job['panel']['lifters'] = []
|
|
job_file = tmp_path / 'job.json'
|
|
out = tmp_path / 'report.html'
|
|
job_file.write_text(json.dumps(job), encoding='utf-8')
|
|
|
|
assert cli.main([str(job_file), '-o', str(out)]) == 2
|
|
assert not out.exists()
|
|
captured = capsys.readouterr()
|
|
assert 'NOT JUDGED' in captured.err
|
|
assert 'Traceback' not in captured.err
|
|
|
|
|
|
def test_missing_thickness_names_the_field():
|
|
job = aspire_job()
|
|
del job['panel']['thickness_in']
|
|
with pytest.raises(cli.JobError) as err:
|
|
cli.analyse_job(job)
|
|
assert 'thickness_in' in str(err.value)
|
|
|
|
|
|
def test_a_future_schema_is_refused_rather_than_guessed_at():
|
|
with pytest.raises(cli.JobError):
|
|
cli.analyse_job(aspire_job(schema='panellift/99'))
|
|
|
|
|
|
def test_unknown_rigging_mode_is_refused():
|
|
with pytest.raises(cli.JobError):
|
|
cli.analyse_job(aspire_job(rigging={'mode': 'magic'}))
|
|
|
|
|
|
def test_a_stage_with_neither_multiplier_nor_pci_case_is_refused():
|
|
"""Defaulting a missing load factor to 1.0 would produce a report that
|
|
looks complete and is 30% light."""
|
|
job = aspire_job()
|
|
del job['stages'][0]['multiplier']
|
|
with pytest.raises(cli.JobError) as err:
|
|
cli.analyse_job(job)
|
|
assert 'pci_case' in str(err.value)
|
|
|
|
|
|
# --------------------------------------------------------- Table 8.3.1 lookup
|
|
|
|
def test_pci_case_reads_the_table_for_this_panel():
|
|
job = aspire_job()
|
|
job['stages'][0]['multiplier'] = None
|
|
job['stages'][0]['pci_case'] = 'stripping'
|
|
_p, _r, cases = cli.analyse_job(job)
|
|
assert cases[0][0].stage.multiplier == pytest.approx(1.3) # flat, smooth
|
|
|
|
|
|
def test_pci_case_follows_the_finish():
|
|
job = aspire_job()
|
|
job['panel']['product_type'] = 'fluted'
|
|
job['stages'][0]['multiplier'] = None
|
|
job['stages'][0]['pci_case'] = 'stripping'
|
|
_p, _r, cases = cli.analyse_job(job)
|
|
assert cases[0][0].stage.multiplier == pytest.approx(1.6) # fluted, smooth
|
|
|
|
|
|
def test_a_stated_multiplier_beats_the_table():
|
|
"""A plant working to its own number is not overruled by the handbook."""
|
|
job = aspire_job()
|
|
job['stages'][0]['multiplier'] = 1.4
|
|
job['stages'][0]['pci_case'] = 'stripping'
|
|
_p, _r, cases = cli.analyse_job(job)
|
|
assert cases[0][0].stage.multiplier == pytest.approx(1.4)
|
|
|
|
|
|
def test_a_product_type_not_in_the_table_is_refused():
|
|
job = aspire_job()
|
|
job['panel']['product_type'] = 'corrugated'
|
|
job['stages'][0]['multiplier'] = None
|
|
job['stages'][0]['pci_case'] = 'stripping'
|
|
with pytest.raises(cli.JobError):
|
|
cli.analyse_job(job)
|
|
|
|
|
|
# ------------------------------------------------------------------ the main
|
|
|
|
def test_main_writes_one_file_covering_every_stage(tmp_path):
|
|
job = aspire_job()
|
|
job['stages'].append({'name': 'transportation',
|
|
'multiplier': pci.TRANSPORTATION_MULTIPLIER,
|
|
'fc_psi': 5000.0})
|
|
job_file = tmp_path / 'job.json'
|
|
out = tmp_path / 'report.html'
|
|
job_file.write_text(json.dumps(job), encoding='utf-8')
|
|
|
|
assert cli.main([str(job_file), '-o', str(out)]) == 0
|
|
html = io.open(str(out), encoding='utf-8').read()
|
|
assert html.count('<!doctype html>') <= 1
|
|
assert 'stripping' in html and 'transportation' in html
|
|
|
|
|
|
def test_main_exits_nonzero_when_a_stage_fails(tmp_path):
|
|
"""The exit code is what a caller checks when it is not going to read the
|
|
HTML -- it has to mean something."""
|
|
job = aspire_job()
|
|
job['stages'][0]['fc_psi'] = 500.0 # nothing passes at 500 psi
|
|
job_file = tmp_path / 'job.json'
|
|
job_file.write_text(json.dumps(job), encoding='utf-8')
|
|
assert cli.main([str(job_file), '-o', str(tmp_path / 'r.html')]) == 1
|
|
|
|
|
|
def test_main_rejects_a_bad_job_file_without_writing_a_report(tmp_path):
|
|
job_file = tmp_path / 'job.json'
|
|
out = tmp_path / 'report.html'
|
|
job_file.write_text('{"schema": "panellift/1"}', encoding='utf-8')
|
|
assert cli.main([str(job_file), '-o', str(out)]) == 2
|
|
assert not out.exists()
|
|
|
|
|
|
# ------------------------------------------------- a stage may rig its own way
|
|
|
|
def _peak(analysis):
|
|
points = checks.stress_profile(analysis)
|
|
return max(abs(p.stress_psi) for p in points)
|
|
|
|
|
|
def _three_line_job(**stage_extra):
|
|
"""Three support lines, where rolling and static actually disagree.
|
|
|
|
On two lines the two modes are the same problem: statics alone fixes both
|
|
reactions. It takes a third line before stiffness has anything to say.
|
|
"""
|
|
job = aspire_job()
|
|
mid = LENGTH_FT / 2.0
|
|
job['panel']['lifters'].extend([
|
|
{'mark': 'L5', 'family': 'TEST_LIFTER', 'x': mid, 'y': 2.5,
|
|
'capacity_lb': 12000.0},
|
|
{'mark': 'L6', 'family': 'TEST_LIFTER', 'x': mid, 'y': 7.5,
|
|
'capacity_lb': 12000.0},
|
|
])
|
|
job['stages'][0].update(stage_extra)
|
|
return job
|
|
|
|
|
|
def test_a_stage_without_a_rig_mode_inherits_the_job():
|
|
_p, _r, cases = cli.analyse_job(_three_line_job())
|
|
_analysis, rigging, _f = cases[0]
|
|
assert rigging.mode == 'rolling'
|
|
|
|
|
|
def test_a_stage_can_rig_differently_from_the_job():
|
|
"""The setting has to reach the mechanics, not just be stored.
|
|
|
|
This is the whole point of it: a commercial check of one real piece
|
|
stripped on fixed slings and erected on an equalising block, and the
|
|
stripping moment came out 14.1 k-ft against 23.8 for one mode everywhere.
|
|
Whichever is right for a given plant, the two must not produce the same
|
|
answer -- if they do, the setting is decorative.
|
|
"""
|
|
rolling = cli.analyse_job(_three_line_job(rig_mode='rolling'))[2][0]
|
|
static = cli.analyse_job(_three_line_job(rig_mode='static'))[2][0]
|
|
assert rolling[1].mode == 'rolling'
|
|
assert static[1].mode == 'static'
|
|
assert _peak(rolling[0]) != pytest.approx(_peak(static[0]), rel=1e-6)
|
|
|
|
|
|
def test_the_job_rigging_is_left_alone_by_a_stage_override():
|
|
"""One stage overriding must not re-rig the stages after it."""
|
|
job = _three_line_job()
|
|
job['stages'] = [dict(job['stages'][0], name='stripping',
|
|
rig_mode='static'),
|
|
dict(job['stages'][0], name='erection')]
|
|
_p, rigging, cases = cli.analyse_job(job)
|
|
assert [c[1].mode for c in cases] == ['static', 'rolling']
|
|
assert rigging.mode == 'rolling'
|
|
|
|
|
|
def test_a_misspelt_rig_mode_is_refused_rather_than_inherited():
|
|
"""'Static' silently inheriting 'rolling' would change every moment in the
|
|
stage and look exactly like a stage that meant to inherit."""
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(_three_line_job(rig_mode='fixed'))
|
|
assert 'rig_mode' in str(caught.value)
|
|
|
|
|
|
def test_the_summary_says_which_mode_each_stage_used():
|
|
"""A per-stage setting the output does not show is one nobody can check."""
|
|
# The job rigs one way and the stage the other, so reporting the stage's
|
|
# mode is the only way to pass: printing job['rigging']['mode'] would say
|
|
# 'static' here and nobody reading the report would know the difference.
|
|
job = aspire_job(rigging={'mode': 'static', 'sling_angle_deg': 90.0})
|
|
job['stages'][0]['rig_mode'] = 'rolling'
|
|
_p, _r, cases = cli.analyse_job(job)
|
|
assert cli.summarise(cases)['stages'][0]['rig_mode'] == 'rolling'
|
|
|
|
|
|
# ------------------------------------------------------------- supports
|
|
|
|
def _supported_job():
|
|
job = aspire_job()
|
|
job['stages'][0] = {
|
|
'name': 'transportation',
|
|
'pci_case': 'transportation',
|
|
'multiplier': 1.4,
|
|
'fc_psi': 5000.0,
|
|
'supports': [0.2 * LENGTH_FT, 0.8 * LENGTH_FT],
|
|
}
|
|
return job
|
|
|
|
|
|
def test_a_stage_with_supports_is_solved_without_a_pick():
|
|
"""Dunnage is not rigging: no lifters, no sling, no insert findings."""
|
|
_p, _r, cases = cli.analyse_job(_supported_job())
|
|
analysis, rigging, findings = cases[0]
|
|
assert analysis.rig.points == 0
|
|
assert analysis.stage.supports == [0.2 * LENGTH_FT, 0.8 * LENGTH_FT]
|
|
assert analysis.rig.lines[0].lifters == []
|
|
codes = set(f.code for f in findings)
|
|
assert 'INSERT' not in codes and 'INSERT-UNRATED' not in codes
|
|
assert 'PLUMB' not in codes
|
|
assert 'PICK' not in codes
|
|
|
|
|
|
def test_two_symmetrical_supports_share_the_piece_evenly():
|
|
_p, _r, cases = cli.analyse_job(_supported_job())
|
|
total = cases[0][0].profile.total_lb
|
|
reactions = [line.reaction_lb for line in cases[0][0].rig.lines]
|
|
assert reactions[0] == pytest.approx(total / 2.0, rel=1e-6)
|
|
assert reactions[1] == pytest.approx(total / 2.0, rel=1e-6)
|
|
|
|
|
|
def test_the_summary_carries_the_support_positions():
|
|
_p, _r, cases = cli.analyse_job(_supported_job())
|
|
summary = cli.summarise(cases)
|
|
assert summary['stages'][0]['supports'] == \
|
|
[0.2 * LENGTH_FT, 0.8 * LENGTH_FT]
|
|
|
|
|
|
def test_a_stage_cannot_name_both_supports_and_lifters():
|
|
job = _supported_job()
|
|
job['stages'][0]['lifters'] = ['L1', 'L2']
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(job)
|
|
assert 'both supports and lifters' in str(caught.value)
|
|
|
|
|
|
def test_one_support_line_is_refused():
|
|
job = _supported_job()
|
|
job['stages'][0]['supports'] = [0.2 * LENGTH_FT]
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(job)
|
|
assert 'at least two supports' in str(caught.value)
|
|
|
|
|
|
def test_a_support_outside_the_piece_is_refused():
|
|
job = _supported_job()
|
|
job['stages'][0]['supports'] = [0.2 * LENGTH_FT, LENGTH_FT + 1.0]
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(job)
|
|
assert 'outside the piece' in str(caught.value)
|
|
|
|
|
|
def test_an_empty_lifter_list_does_not_contradict_a_support_line():
|
|
"""The Revit exporter writes `lifters: []` on a stage it turned into
|
|
supports, and an empty list names no insert. Refusing it made every job
|
|
file a plant with dunnage configured could write unreadable -- which no
|
|
test caught, because the shape came from the other side of the language
|
|
boundary and the tests here built their own."""
|
|
job = aspire_job()
|
|
haul = dict(job['stages'][0], name='transportation', lifters=[],
|
|
supports=[8.0, 24.0])
|
|
job['stages'] = [job['stages'][0], haul]
|
|
_p, _r, cases = cli.analyse_job(job)
|
|
assert cases[1][0].stage.supports == [8.0, 24.0]
|
|
|
|
|
|
def test_a_support_line_and_a_named_insert_are_still_refused():
|
|
job = aspire_job()
|
|
haul = dict(job['stages'][0], name='transportation', lifters=['L1'],
|
|
supports=[8.0, 24.0])
|
|
job['stages'] = [haul]
|
|
with pytest.raises(cli.JobError) as caught:
|
|
cli.analyse_job(job)
|
|
assert 'one or the other' in str(caught.value)
|