WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

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.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
@@ -0,0 +1,700 @@
# -*- coding: utf-8 -*-
"""Host tests for `vibe.panelanalysisform` -- the Panel Analysis input form's
pure logic: pre-fill, the completeness gate, the deck build, the Analyze-All
batch plan, the plain-language section/group structure, the Handling stage
matrix, and the openings / strand-group row parsers.
The WPF form itself (the accordion, the per-panel Run Analysis buttons, the
live geometry read off the framing element) is owner-must-verify in Revit;
everything below the form boundary is here. Loaded by path like the sibling
`test_panelanalysis*` files -- host CPython reaching `tools/` for the schema so
a deck this module builds can be fed straight back through the real
`panelanalysis/1` reader and solve.
"""
from __future__ import annotations
import os
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 panelanalysisform as F # noqa: E402
schema = pytest.importorskip('panelanalysis_schema')
statics = pytest.importorskip('panellift.statics')
# A model-read for a plain solid panel: geometry + materials the framing
# element supplied, supports blank (the user types those). Generic values.
READS = {
'mark': 'PNL101',
'design_number': 'PNL1',
'length_ft': 24.0,
'width_ft': 8.0,
'thickness_in': 8.0,
'fc_psi': 6000.0,
'fci_psi': 3500.0,
'unit_weight_pcf': 150.0,
}
def _complete_values():
"""A fully-fillable form: prefilled reads + the user's supports."""
values = F.prefill(READS)
values['supports_ft'] = '2, 22'
return values
def _with_prestress(values=None, count='4', dia='1/2', pull='28900',
rows='4 @ 2'):
values = dict(values or _complete_values())
values['ps_count'] = count
values['ps_dia_in'] = dia
values['ps_pull_lb'] = pull
values['ps_rows'] = rows
return values
# --------------------------------------------------------------- structure
def test_sections_are_the_five_eriksson_names_and_cover_every_field():
names = [name for name, _groups in F.sections()]
assert names == list(F.SECTION_ORDER)
assert names == ['Design Criteria', 'Concrete Extents',
'Structural Model', 'Handling', 'Reinforcement']
covered = set()
for _name, groups in F.sections():
for _group_name, fields in groups:
for f in fields:
covered.add(f['key'])
field_keys = set(f['key'] for f in F.FIELDS)
assert covered == field_keys
def test_sections_group_fields_and_every_group_is_named_or_none():
for _name, groups in F.sections():
group_names = [g for g, _fields in groups]
# at most one ungrouped (None) bucket, and it comes first if present
assert group_names.count(None) <= 1
if None in group_names:
assert group_names[0] is None
# every named group is non-empty
for g, fields in groups:
assert fields
if g is not None:
assert isinstance(g, str) and g
def test_design_criteria_has_the_four_named_groups():
by_section = dict(F.sections())
dc_groups = [g for g, _f in by_section['Design Criteria']]
for want in ('Material Properties', 'Prestress Losses',
'Load Combinations', 'Stress Limits & Factors'):
assert want in dc_groups
def test_every_field_declares_a_known_section_group_and_drives_flag():
for f in F.FIELDS:
assert f['section'] in F.SECTION_ORDER, f['key']
assert f['drives'] in ('solve', 'stage3', 'info'), f['key']
assert f['kind'] in ('text', 'num', 'int', 'supports', 'openings',
'strands'), f['key']
assert isinstance(f['label'], str) and f['label']
assert isinstance(f['help'], str) and f['help']
# the plain label carries no raw prime/subscript math -- that lives
# in `symbol` instead (the owner's "太丑了" ruling)
assert u'′' not in f['label'], f['key']
def test_wind_fields_are_present_blank_and_collected_not_yet_driving():
wind = [f for f in F.FIELDS if f['key'].startswith('wind_')]
keys = set(f['key'] for f in wind)
for want in ('wind_speed_vult', 'wind_risk_category', 'wind_exposure',
'wind_enclosure', 'wind_asce_version', 'wind_cc_chapter',
'wind_mwfrs_chapter', 'wind_pressure_psf', 'wind_suction_psf'):
assert want in keys
for f in wind:
assert f['section'] == 'Structural Model'
assert f['group'] == 'Wind'
assert f['drives'] == 'stage3' # collected, not yet driving
values = F.prefill(READS)
for k in keys:
assert values[k] == ''
def test_strip_transport_erection_sling_fields_are_gone():
# migrated INTO the stage matrix, not left sitting beside it
keys = set(f['key'] for f in F.FIELDS)
for gone in ('strip_mult', 'transport_mult', 'erection_mult',
'sling_angle', 'ps_ecc_in'):
assert gone not in keys
# ----------------------------------------------------------------- prefill
def test_prefill_fills_readable_and_blanks_the_rest():
values = F.prefill(READS)
assert values['length_ft'] == '24'
assert values['width_ft'] == '8'
assert values['thickness_in'] == '8'
assert values['fc_psi'] == '6000'
assert values['fci_psi'] == '3500'
assert values['mark'] == 'PNL101'
# supports and every stage3 / load field come up blank for the user
assert values['supports_ft'] == ''
assert values['self_weight_factor'] == ''
assert values['ps_count'] == ''
assert values['ps_rows'] == ''
assert values['openings'] == ''
def test_prefill_blanks_a_field_the_model_could_not_read():
reads = dict(READS)
reads['fci_psi'] = None
del reads['unit_weight_pcf']
values = F.prefill(reads)
assert values['fci_psi'] == ''
assert values['unit_weight_pcf'] == ''
def test_prefill_blanks_every_stage_matrix_cell():
values = F.prefill(READS)
for key in F.stage_field_keys():
assert values[key] == ''
# -------------------------------------------------------------- validation
def test_complete_form_validates_and_parses():
errors, parsed = F.validate(_complete_values())
assert errors == []
assert parsed['supports'] == [2.0, 22.0]
assert parsed['length_ft'] == 24.0
assert parsed['fc_psi'] == 6000.0
assert parsed['openings_rows'] == []
assert parsed['strand_rows'] == []
def test_blank_required_field_blocks_and_is_named():
values = _complete_values()
values['fc_psi'] = ''
errors, parsed = F.validate(values)
assert parsed is None
# The label now reads plain English; the symbol (f′c) still rides beside
# it via `display_label`. What is pinned here is that the message names
# the field, in BOTH vocabularies.
assert any('Concrete strength at 28 days' in e and 'required' in e
for e in errors)
assert any(u'f′c' in e for e in errors)
def test_fewer_than_two_supports_is_refused_by_name():
values = _complete_values()
values['supports_ft'] = '2'
errors, parsed = F.validate(values)
assert parsed is None
assert any('at least two support' in e for e in errors)
assert not F.is_complete(values)
def test_no_supports_is_refused():
values = _complete_values()
values['supports_ft'] = ''
errors, parsed = F.validate(values)
assert parsed is None
assert any('support' in e.lower() for e in errors)
def test_garbage_support_token_is_named():
values = _complete_values()
values['supports_ft'] = '2, banana'
errors, parsed = F.validate(values)
assert parsed is None
assert any('banana' in e for e in errors)
def test_stage3_field_never_blocks_the_run():
values = _complete_values()
# a wind value typed in but the solve does not use it -> still runnable,
# and even a non-numeric stray in a stage3 field must not block
values['wind_speed_vult'] = 'not a number'
values['wind_risk_category'] = 'II'
assert F.is_complete(values)
def test_bad_number_in_a_solve_field_blocks():
values = _complete_values()
values['unit_weight_pcf'] = 'heavy'
errors, parsed = F.validate(values)
assert parsed is None
assert any('Concrete unit weight' in e for e in errors)
# -------------------------------------------------------------- prestress
def test_no_prestress_fields_means_no_prestress_block():
_errors, parsed = F.validate(_complete_values())
assert parsed['prestress'] is None
assert parsed['strand_rows'] == []
def test_full_prestress_group_builds_strands():
values = _with_prestress(rows='4 @ 2')
errors, parsed = F.validate(values)
assert errors == []
ps = parsed['prestress']
assert len(ps['strands']) == 4
assert all(s['area_in2'] == 0.153 for s in ps['strands'])
assert all(s['initial_pull_lb'] == 28900.0 for s in ps['strands'])
assert all(s['z_in'] == 2.0 for s in ps['strands'])
def test_prestress_group_with_multiple_rows_and_per_row_pull_pct():
values = _with_prestress(count='8', rows='4 @ 2 @ 75\n4 @ 9.25 @ 56')
values['ps_fpu_ksi'] = '270'
errors, parsed = F.validate(values)
assert errors == []
ps = parsed['prestress']
assert len(ps['strands']) == 8
shallow = [s for s in ps['strands'] if s['z_in'] == 2.0]
deep = [s for s in ps['strands'] if s['z_in'] == 9.25]
assert len(shallow) == 4 and len(deep) == 4
# pull = pct/100 * fpu(ksi)*1000 * area, not the default ps_pull_lb
assert all(abs(s['initial_pull_lb'] - 0.75 * 270.0 * 1000.0 * 0.153) < 1
for s in shallow)
assert all(abs(s['initial_pull_lb'] - 0.56 * 270.0 * 1000.0 * 0.153) < 1
for s in deep)
def test_partial_prestress_group_is_refused_field_by_field():
values = _complete_values()
values['ps_count'] = '4' # started, but no size/pull/rows
errors, parsed = F.validate(values)
assert parsed is None
assert any('diameter' in e for e in errors)
assert any('pull' in e for e in errors)
assert any('Strand groups' in e for e in errors)
def test_unknown_strand_size_is_refused_not_guessed():
values = _with_prestress(count='2', dia='0.99', rows='2 @ 2')
errors, parsed = F.validate(values)
assert parsed is None
assert any('section area' in e for e in errors)
def test_ps_count_disagreement_with_rows_is_reported_not_resolved():
values = _with_prestress(count='5', rows='4 @ 2') # 5 != 4
errors, parsed = F.validate(values)
assert parsed is None
assert any('does not match the sum' in e for e in errors)
def test_ps_count_blank_skips_the_cross_check():
values = _with_prestress(count='', rows='4 @ 2')
errors, parsed = F.validate(values)
assert errors == []
assert len(parsed['prestress']['strands']) == 4
def test_garbage_strand_row_line_is_named_by_line_number():
values = _with_prestress(rows='4 @ 2\nbanana')
errors, parsed = F.validate(values)
assert parsed is None
assert any('line 2' in e for e in errors)
def test_strand_rows_are_kept_in_parsed_even_when_group_incomplete():
values = _complete_values()
values['ps_rows'] = '4 @ 2' # rows typed, but no dia/pull -> refused
errors, parsed = F.validate(values)
assert parsed is None # the whole panel is refused (as before)
# but strand_rows / ps_rows are not something to re-derive here; the
# raw text is preserved off `values` regardless, and validate() does not
# crash trying to reach into it.
# ------------------------------------------------------------- openings
def test_blank_openings_is_no_openings():
values = _complete_values()
values['openings'] = ''
errors, parsed = F.validate(values)
assert errors == []
assert parsed['openings_rows'] == []
assert parsed['openings'] == ''
def test_one_opening_per_line_parses_the_new_grammar():
rows, problems = F.parse_openings(
'Opening, 2, start, 3, 4, left, 5, 1, 6, Door\n'
'Reveal, 1, end, 2, 1, right, 2, 0, 1')
assert problems == []
assert len(rows) == 2
door = rows[0]
assert door == {'type': 'Opening', 'pos_along': 2.0,
'pos_along_from': 'start', 'length': 3.0,
'pos_cross': 4.0, 'pos_cross_from': 'left',
'width': 5.0, 'pos_vert': 1.0, 'height': 6.0,
'label': 'Door'}
reveal = rows[1]
assert reveal['type'] == 'Reveal'
assert reveal['pos_along_from'] == 'end'
assert reveal['pos_cross_from'] == 'right'
assert reveal['label'] == ''
def test_openings_bad_type_is_named_by_line_number():
rows, problems = F.parse_openings('Doorhole, 2, start, 3, 4, left, 5, 1, 6')
assert rows == []
assert any('line 1' in p and 'type' in p for p in problems)
def test_openings_bad_datum_word_is_named():
rows, problems = F.parse_openings(
'Opening, 2, sideways, 3, 4, left, 5, 1, 6')
assert rows == []
assert any('pos_along_from' in p for p in problems)
def test_openings_all_or_nothing_across_lines():
rows, problems = F.parse_openings(
'Opening, 2, start, 3, 4, left, 5, 1, 6\nbanana')
assert rows == [] # the good line is not kept either
assert len(problems) == 1
assert 'line 2' in problems[0]
def test_openings_row_reaches_validate_parsed_output():
values = _complete_values()
values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door'
errors, parsed = F.validate(values)
assert errors == []
assert parsed['openings_rows'] == [{
'type': 'Opening', 'pos_along': 2.0, 'pos_along_from': 'start',
'length': 3.0, 'pos_cross': 4.0, 'pos_cross_from': 'left',
'width': 5.0, 'pos_vert': 1.0, 'height': 6.0, 'label': 'Door'}]
assert parsed['openings'] == values['openings']
def test_bad_openings_line_blocks_the_run_and_names_the_line():
values = _complete_values()
values['openings'] = 'banana'
errors, parsed = F.validate(values)
assert parsed is None
assert any('line 1' in e for e in errors)
# ------------------------------------------------------------ stage matrix
def test_stage_matrix_shape():
m = F.stage_matrix()
assert m['stages'] == ['Stripping', 'Transportation', 'Primary Erection',
'Secondary Erection']
opt_keys = [r['opt_key'] for r in m['rows']]
assert opt_keys == ['include', 'load_multiplier', 'lift_type',
'concrete_strength', 'stress_limits', 'rigging',
'sling_angle', 'ground_reaction', 'min_tilt_deg',
'max_tilt_deg', 'top_crane_carries']
for row in m['rows']:
assert set(row['cells'].keys()) == set(m['stages'])
for stage, key in row['cells'].items():
assert key == F.stage_field_key(stage, row['opt_key'])
def test_stage_matrix_offers_choices_but_never_a_default():
m = F.stage_matrix()
by_key = dict((r['opt_key'], r) for r in m['rows'])
assert by_key['lift_type']['choices'] == ('Static Lift', 'Rolling Block')
assert by_key['include']['choices'] is None
values = F.prefill(READS)
for key in F.stage_field_keys():
assert values[key] == '' # nothing pre-selected
def test_stage_matrix_load_multiplier_rides_into_deck():
values = _complete_values()
values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
values[F.stage_field_key('Transportation', 'load_multiplier')] = '1.5'
values[F.stage_field_key('Primary Erection', 'load_multiplier')] = '1.2'
errors, parsed = F.validate(values)
assert errors == []
deck = F.build_deck(parsed)
assert deck['handling']['stripping_multiplier'] == 1.4
assert deck['handling']['transport_multiplier'] == 1.5
assert deck['handling']['erection_multiplier'] == 1.2
def test_excluded_stage_does_not_gate_the_run_on_its_other_fields():
values = _complete_values()
values[F.stage_field_key('Secondary Erection', 'include')] = 'no'
values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = \
'not a number'
assert F.is_complete(values)
def test_excluded_stage_keeps_the_typed_value_rather_than_dropping_it():
values = _complete_values()
values[F.stage_field_key('Secondary Erection', 'include')] = 'no'
values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = '9.9'
errors, parsed = F.validate(values)
assert errors == []
row = parsed['stage_matrix']['Secondary Erection']
assert row['include'] is False
assert row['load_multiplier'] == 9.9 # kept, just not required/used
def test_excluded_stage_multiplier_does_not_reach_the_deck():
values = _complete_values()
values[F.stage_field_key('Stripping', 'include')] = 'no'
values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
errors, parsed = F.validate(values)
assert errors == []
deck = F.build_deck(parsed)
assert 'stripping_multiplier' not in deck['handling']
def test_included_stage_bad_load_multiplier_blocks_and_names_the_stage():
values = _complete_values()
values[F.stage_field_key('Stripping', 'load_multiplier')] = 'heavy'
errors, parsed = F.validate(values)
assert parsed is None
assert any('Stripping' in e and 'Load multiplier' in e for e in errors)
def test_bad_include_answer_is_named():
values = _complete_values()
values[F.stage_field_key('Stripping', 'include')] = 'maybe'
errors, parsed = F.validate(values)
assert parsed is None
assert any('Stripping' in e for e in errors)
# ------------------------------------------------------------- deck build
def _deck_from(values):
errors, parsed = F.validate(values)
assert errors == [], errors
return F.build_deck(parsed)
def test_build_deck_is_the_panelanalysis_schema():
deck = _deck_from(_complete_values())
assert deck['schema'] == F.SCHEMA == schema.SCHEMA
schema.check_schema(deck) # does not raise
panel = schema.to_panel(deck)
assert panel.mark == 'PNL101'
assert panel.length_ft == 24.0
assert panel.fc_psi == 6000.0
def test_deck_supports_reach_the_schema():
deck = _deck_from(_complete_values())
assert schema.supports(deck) == [(2.0, 'support'), (22.0, 'support')]
def test_deck_solves_and_reactions_sum_to_load():
deck = _deck_from(_complete_values())
panel = schema.to_panel(deck)
stage = schema.handling_stage(deck, panel)
analysis = statics.analyse(panel, stage, schema.handling_rigging())
total_r = sum(line.reaction_lb for line in analysis.rig.lines)
total_load = analysis.profile.total_lb
# the closure that says the solve is real: reactions sum to the load
assert abs(total_r - total_load) <= max(1.0, 1e-6 * abs(total_load))
assert total_load > 0
def test_unbuilt_blocks_are_explicit_unread_not_fabricated():
deck = _deck_from(_complete_values())
for block in (deck['in_place'], deck['loading']['wind'],
deck['structural_model']['restraints']):
assert block['value'] is None
assert block['reason']
def test_openings_never_reach_the_deck_yet():
values = _complete_values()
values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door'
deck = _deck_from(values)
assert deck['concrete_extents']['blockouts'] == []
def test_prestress_deck_reads_as_a_prestress_panel():
values = _with_prestress(count='2', rows='2 @ 2')
deck = _deck_from(values)
panel = schema.to_panel(deck)
assert panel.prestress is not None
assert len(panel.prestress.strands) == 2
# ------------------------------------------------------------------ batch
# ------------------------------------------------------------------- wind
def test_new_asce_fields_are_present_blank_and_stage3():
keys = set(f['key'] for f in F.FIELDS)
for want in ('wind_mean_roof_height_ft', 'wind_effective_wind_area_ft2'):
assert want in keys
spec = F._spec(want)
assert spec['section'] == 'Structural Model'
assert spec['drives'] == 'stage3'
values = F.prefill(READS)
assert values['wind_mean_roof_height_ft'] == ''
assert values['wind_effective_wind_area_ft2'] == ''
def test_wind_gcpi_matches_windload_table_exactly():
# The form's GCpi table is a mirror of windload's; a host test pins it so
# the two published-code tables can never drift.
windload = pytest.importorskip('panellift.windload')
for key, val in windload._GCPI.items():
got, cite = F.wind_gcpi(key)
assert got == val, key
assert cite
def test_wind_gcpi_display_for_each_enclosure_and_spellings():
assert F.wind_gcpi('enclosed')[0] == 0.18
assert F.wind_gcpi('partially_enclosed')[0] == 0.55
assert F.wind_gcpi('Partially Enclosed')[0] == 0.55 # spacing/case
assert F.wind_gcpi('partial-open')[0] == 0.18 # alias + dash
assert F.wind_gcpi('open')[0] == 0.00
def test_wind_gcpi_blank_and_unknown_are_could_not_judge_not_zero():
val, why = F.wind_gcpi('')
assert val is None and why # not 0.0
val, why = F.wind_gcpi('mostly closed')
assert val is None and 'not recognized' in why
def test_resolve_wind_pressure_direct_beats_computed():
d = F.resolve_wind_pressure(31.0, 28.3)
assert d['source'] == 'direct' and d['pressure_psf'] == 31.0
def test_resolve_wind_pressure_computed_when_no_direct():
d = F.resolve_wind_pressure(None, 28.3)
assert d['source'] == 'computed' and d['pressure_psf'] == 28.3
def test_resolve_wind_pressure_not_judged_passes_reason_through():
d = F.resolve_wind_pressure(None, None, 'NOT JUDGED: h > 60 ft ...')
assert d['source'] == 'not_judged'
assert d['pressure_psf'] is None # never fabricated
assert 'NOT JUDGED' in d['reason']
def test_resolve_wind_pressure_direct_wins_even_over_not_judged():
d = F.resolve_wind_pressure(31.0, None, 'NOT JUDGED: h > 60 ft ...')
assert d['source'] == 'direct' and d['pressure_psf'] == 31.0
def test_resolve_wind_pressure_blank_when_nothing_supplied():
d = F.resolve_wind_pressure(None, None, None)
assert d['source'] == 'blank' and d['pressure_psf'] is None
assert d['reason']
def _wind_values():
values = _complete_values()
values['wind_speed_vult'] = '115'
values['wind_risk_category'] = 'II'
values['wind_exposure'] = 'C'
values['wind_enclosure'] = 'enclosed'
values['wind_asce_version'] = '7-16'
values['wind_cc_chapter'] = '30'
values['wind_mean_roof_height_ft'] = '30'
values['wind_effective_wind_area_ft2'] = '100'
return values
def test_build_deck_carries_asce_wind_inputs_when_supplied():
deck = _deck_from(_wind_values())
wind = deck['loading']['wind']
assert 'value' not in wind # no longer unread
a = wind['asce']
assert a['v_ult_mph'] == 115.0
assert a['exposure'] == 'C'
assert a['enclosure'] == 'enclosed'
assert a['code_version'] == '7-16'
assert a['mean_roof_height_ft'] == 30.0
assert a['effective_wind_area_ft2'] == 100.0
def test_build_deck_carries_direct_pressure_override():
values = _wind_values()
values['wind_pressure_psf'] = '45'
values['wind_suction_psf'] = '50'
deck = _deck_from(values)
wind = deck['loading']['wind']
assert wind['direct_pressure_psf'] == 45.0
assert wind['direct_suction_psf'] == 50.0
def test_partial_asce_without_the_required_numbers_is_not_computed():
values = _complete_values()
values['wind_exposure'] = 'C' # something typed, but no Vult/h/area
deck = _deck_from(values)
wind = deck['loading']['wind']
assert 'asce' not in wind
assert 'asce_incomplete' in wind # says what is still needed
def test_no_wind_inputs_keeps_the_explicit_unread_placeholder():
deck = _deck_from(_complete_values())
assert deck['loading']['wind']['value'] is None
assert deck['loading']['wind']['reason']
def test_restraints_field_reaches_the_deck_when_filled():
values = _wind_values()
values['restraints'] = '0, 30'
deck = _deck_from(values)
r = deck['structural_model']['restraints']
assert r == [{'x_ft': 0.0, 'type': 'restraint'},
{'x_ft': 30.0, 'type': 'restraint'}]
def test_no_restraints_keeps_the_explicit_unread_placeholder():
deck = _deck_from(_complete_values())
assert deck['structural_model']['restraints']['value'] is None
def test_batch_plan_runs_complete_and_skips_incomplete_with_reasons():
good = _complete_values()
bad = _complete_values()
bad['supports_ft'] = '5' # only one support
worse = _complete_values()
worse['fc_psi'] = '' # missing material
named = [('PNL101', good), ('PNL102', bad), ('PNL103', worse)]
to_run, skipped = F.batch_plan(named)
run_labels = [lbl for lbl, _p in to_run]
skip_labels = [lbl for lbl, _r in skipped]
assert run_labels == ['PNL101']
assert set(skip_labels) == {'PNL102', 'PNL103'}
reasons = dict((lbl, errs) for lbl, errs in skipped)
assert any('support' in e for e in reasons['PNL102'])
assert any(u'f′c' in e for e in reasons['PNL103'])
# ------------------------------------------------------------ display_label
def test_display_label_carries_both_label_and_symbol():
d = F.display_label('fc_psi')
assert 'Concrete strength at 28 days' in d
assert u'f′c' in d
def test_display_label_falls_back_to_label_when_no_symbol():
d = F.display_label('concrete_type')
assert d == 'Concrete type'