WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user