Files
willtools-demo/panellift/tests/test_cli.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

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)