52d94303bc
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.
4548 lines
202 KiB
Python
4548 lines
202 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for the Revit-free half of vibe.knowledge.
|
|
|
|
`load()`, `sources()`, `write_starter()` and `write_plant_starter()` all need
|
|
`vibe.designspec` (imported inside those functions on purpose -- see the note
|
|
at the top of knowledge.py) and, through it, a live Revit document. Out of
|
|
scope here.
|
|
|
|
Everything else -- merging plain dicts, coercing YAML scalars, mapping the
|
|
`decisions:` block back onto `handling.*`, and the sentences `config_problems`
|
|
produces -- takes and returns plain dicts, which is what is tested below,
|
|
mostly built from `knowledge.DEFAULTS` directly so a test failure means the
|
|
real merge logic is wrong, not that a fixture drifted from it.
|
|
"""
|
|
from vibe import knowledge
|
|
from vibe import ticketpage
|
|
|
|
|
|
# ------------------------------------------------------------- _deep_update
|
|
|
|
def test_deep_update_merges_nested_dicts_key_by_key():
|
|
base = {'a': {'x': 1, 'y': 2}, 'b': 1}
|
|
override = {'a': {'y': 3, 'z': 4}}
|
|
knowledge._deep_update(base, override)
|
|
assert base == {'a': {'x': 1, 'y': 3, 'z': 4}, 'b': 1}
|
|
|
|
|
|
def test_deep_update_adds_new_keys():
|
|
base = {'a': 1}
|
|
knowledge._deep_update(base, {'b': 2})
|
|
assert base == {'a': 1, 'b': 2}
|
|
|
|
|
|
def test_deep_update_replaces_lists_wholesale_rather_than_merging():
|
|
base = {'a': [1, 2, 3]}
|
|
knowledge._deep_update(base, {'a': [9]})
|
|
assert base['a'] == [9]
|
|
|
|
|
|
def test_deep_update_a_non_dict_override_replaces_a_dict_value_entirely():
|
|
# Only two DICTS recurse. An override that hands a scalar where the base
|
|
# had a dict does not get merged into it -- it replaces the whole branch.
|
|
base = {'handling': {'rig_mode': 'rolling', 'sling_angle_deg': 60}}
|
|
knowledge._deep_update(base, {'handling': 'disabled'})
|
|
assert base['handling'] == 'disabled'
|
|
|
|
|
|
def test_deep_update_returns_the_mutated_base():
|
|
base = {'a': 1}
|
|
result = knowledge._deep_update(base, {'b': 2})
|
|
assert result is base
|
|
|
|
|
|
# ---------------------------------------------------------- _merge_decisions
|
|
|
|
def test_merge_decisions_maps_the_simple_keys_onto_handling():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {
|
|
'report_dir': 'C:/reports',
|
|
'open_report_when_done': False,
|
|
'prestress_loss_pct': 6.5,
|
|
'transfer_length_in': 30,
|
|
})
|
|
handling = merged['handling']
|
|
assert handling['report_dir'] == 'C:/reports'
|
|
assert handling['open_report'] is False
|
|
assert handling['prestress_loss_pct'] == 6.5
|
|
assert handling['transfer_length_in'] == 30
|
|
|
|
|
|
def test_merge_decisions_coerces_yaml_style_strings():
|
|
# load() reads a project config through _merge_file first, which coerces
|
|
# every scalar with _coerce() before _merge_decisions ever sees it -- but
|
|
# _merge_decisions coerces the four scalar keys itself too, so it is
|
|
# correct even fed a raw, uncoerced dict directly.
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {
|
|
'open_report_when_done': 'false',
|
|
'prestress_loss_pct': '6.5',
|
|
})
|
|
assert merged['handling']['open_report'] is False
|
|
assert merged['handling']['prestress_loss_pct'] == 6.5
|
|
|
|
|
|
def test_merge_decisions_default_rig_mode_applies_to_the_whole_job():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {'rig_mode': {'default': 'static'}})
|
|
assert merged['handling']['rig_mode'] == 'static'
|
|
# None of the stages named a per-stage override, so every stage's own
|
|
# rig_mode is left at whatever DEFAULTS had (None -- inherit).
|
|
for stage in merged['handling']['stages']:
|
|
assert stage['rig_mode'] is None
|
|
|
|
|
|
def test_merge_decisions_per_stage_rig_mode_overrides_only_that_stage():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {
|
|
'rig_mode': {'default': 'static', 'stripping': 'rolling',
|
|
'transportation': None, 'erection': None},
|
|
})
|
|
by_name = dict((s['name'], s['rig_mode']) for s in merged['handling']['stages'])
|
|
assert merged['handling']['rig_mode'] == 'static'
|
|
assert by_name['stripping'] == 'rolling'
|
|
# Left blank -- inherits the job default rather than being forced.
|
|
assert by_name['transportation'] is None
|
|
assert by_name['erection'] is None
|
|
|
|
|
|
def test_merge_decisions_blank_default_does_not_clobber_the_existing_one():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
original = merged['handling']['rig_mode']
|
|
knowledge._merge_decisions(merged, {'rig_mode': {'default': None}})
|
|
assert merged['handling']['rig_mode'] == original
|
|
|
|
|
|
def test_merge_decisions_ignores_a_rig_mode_that_is_not_a_dict():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
original = knowledge._deep_copy(merged['handling'])
|
|
knowledge._merge_decisions(merged, {'rig_mode': 'static'})
|
|
assert merged['handling'] == original
|
|
|
|
|
|
def test_merge_decisions_with_no_decisions_block_changes_nothing():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
original = knowledge._deep_copy(merged)
|
|
result = knowledge._merge_decisions(merged, None)
|
|
assert merged == original
|
|
assert result is merged
|
|
|
|
|
|
def test_merge_decisions_falsy_report_dir_is_left_alone():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
merged['handling']['report_dir'] = '/existing/path'
|
|
knowledge._merge_decisions(merged, {'report_dir': ''})
|
|
assert merged['handling']['report_dir'] == '/existing/path'
|
|
|
|
|
|
def test_merge_decisions_group_tolerance_maps_onto_handling():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {'group_tolerance_ft': 1.25})
|
|
assert merged['handling']['group_tolerance_ft'] == 1.25
|
|
|
|
|
|
def test_merge_decisions_a_blank_group_tolerance_leaves_the_default_alone():
|
|
"""Blank means 'not said' -- the layered default stands, never zero."""
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {'group_tolerance_ft': None})
|
|
assert merged['handling']['group_tolerance_ft'] == \
|
|
knowledge.DEFAULTS['handling']['group_tolerance_ft']
|
|
|
|
|
|
def test_merge_decisions_an_unparsable_group_tolerance_is_not_guessed_at():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
original = merged['handling']['group_tolerance_ft']
|
|
knowledge._merge_decisions(merged, {'group_tolerance_ft': 'one foot'})
|
|
assert merged['handling']['group_tolerance_ft'] == original
|
|
|
|
|
|
def test_a_bad_group_tolerance_is_reported_not_inherited():
|
|
# Judged where it is dropped, in the merge -- NOT re-read afterwards from
|
|
# `decisions.group_tolerance_ft`, which no merge ever puts in the tree.
|
|
for bad in ('one foot', -1.0):
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {'group_tolerance_ft': bad})
|
|
problems = knowledge.config_problems(None, merged)
|
|
assert any('group_tolerance_ft' in p for p in problems), bad
|
|
|
|
|
|
def test_a_good_group_tolerance_is_not_reported():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._merge_decisions(merged, {'group_tolerance_ft': 1.0})
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
# ------------------------------------------------------------- config_problems
|
|
|
|
def test_config_problems_empty_knowledge_is_silent():
|
|
assert knowledge.config_problems(None, None) == []
|
|
assert knowledge.config_problems(None, {}) == []
|
|
|
|
|
|
def test_config_problems_reports_a_missing_config_file():
|
|
problems = knowledge.config_problems(
|
|
None, {'_config_missing': 'C:/job/active-config.yaml'})
|
|
assert len(problems) == 1
|
|
assert 'C:/job/active-config.yaml' in problems[0]
|
|
assert 'is not there' in problems[0]
|
|
|
|
|
|
def test_config_problems_bad_dunnage_mode():
|
|
problems = knowledge.config_problems(
|
|
None, {'handling': {'dunnage': {'mode': 'bogus'}}})
|
|
assert len(problems) == 1
|
|
assert 'bogus' in problems[0]
|
|
assert 'lifter_rows' in problems[0]
|
|
assert 'fraction' in problems[0]
|
|
|
|
|
|
def test_config_problems_fraction_mode_needs_a_fraction():
|
|
problems = knowledge.config_problems(
|
|
None, {'handling': {'dunnage': {'mode': 'fraction', 'fraction': None}}})
|
|
assert len(problems) == 1
|
|
assert 'between 0 and 0.5' in problems[0]
|
|
|
|
|
|
def test_config_problems_fraction_mode_rejects_out_of_range():
|
|
problems = knowledge.config_problems(
|
|
None, {'handling': {'dunnage': {'mode': 'fraction', 'fraction': 0.6}}})
|
|
assert len(problems) == 1
|
|
assert '0.6' in problems[0]
|
|
|
|
|
|
def test_config_problems_fraction_mode_rejects_a_non_number():
|
|
problems = knowledge.config_problems(
|
|
None,
|
|
{'handling': {'dunnage': {'mode': 'fraction', 'fraction': 'lots'}}})
|
|
assert len(problems) == 1
|
|
assert 'lots' in problems[0]
|
|
|
|
|
|
def test_config_problems_valid_dunnage_settings_are_silent():
|
|
assert knowledge.config_problems(
|
|
None, {'handling': {'dunnage': {'mode': None}}}) == []
|
|
assert knowledge.config_problems(
|
|
None, {'handling': {'dunnage': {'mode': 'lifter_rows'}}}) == []
|
|
assert knowledge.config_problems(
|
|
None,
|
|
{'handling': {'dunnage': {'mode': 'fraction', 'fraction': 0.2}}}) == []
|
|
|
|
|
|
# ------------------------------------------- insert_position, per run mode
|
|
|
|
def test_insert_position_maps_onto_both_handling_keys():
|
|
"""One decision, two keys, opposite senses. If this mapping is missing the
|
|
section is decoration: editing the obvious place would do nothing."""
|
|
merged = {'handling': {}}
|
|
knowledge._merge_decisions(merged, {
|
|
'insert_position': {'one_piece': 'precise', 'batch': 'fast'}})
|
|
assert merged['handling']['fast_lifter_box'] is False
|
|
assert merged['handling']['fast_lifter_box_batch'] is True
|
|
|
|
|
|
def test_insert_position_accepts_the_shipped_defaults_round_trip():
|
|
merged = {'handling': {}}
|
|
knowledge._merge_decisions(merged, {
|
|
'insert_position': {'one_piece': 'fast', 'batch': 'precise'}})
|
|
assert merged['handling']['fast_lifter_box'] is True
|
|
assert merged['handling']['fast_lifter_box_batch'] is False
|
|
|
|
|
|
def test_insert_position_leaves_a_blank_mode_alone():
|
|
merged = {'handling': {'fast_lifter_box': True}}
|
|
knowledge._merge_decisions(merged, {
|
|
'insert_position': {'one_piece': None, 'batch': 'precise'}})
|
|
assert merged['handling']['fast_lifter_box'] is True
|
|
assert merged['handling']['fast_lifter_box_batch'] is False
|
|
|
|
|
|
def test_a_misspelled_insert_position_is_not_guessed_at():
|
|
"""'quick' is not 'fast'. Guessing which one a typo meant is how a setting
|
|
nobody checked ends up deciding a moment arm."""
|
|
merged = {'handling': {'fast_lifter_box': True}}
|
|
knowledge._merge_decisions(merged, {
|
|
'insert_position': {'one_piece': 'quick'}})
|
|
assert merged['handling']['fast_lifter_box'] is True
|
|
|
|
|
|
def test_a_misspelled_insert_position_is_reported():
|
|
merged = {'handling': {}}
|
|
knowledge._merge_decisions(merged, {'insert_position': {'batch': 'quick'}})
|
|
problems = knowledge.config_problems(None, merged)
|
|
assert any('insert_position.batch' in p for p in problems)
|
|
assert any('quick' in p for p in problems)
|
|
|
|
|
|
def test_a_good_insert_position_is_not_reported():
|
|
merged = {'handling': {}}
|
|
knowledge._merge_decisions(merged, {
|
|
'insert_position': {'one_piece': 'fast', 'batch': 'precise'}})
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
# ------------------------- _coerce keeps a label a label (measured 08-30)
|
|
|
|
def test_a_digit_string_with_a_leading_zero_stays_a_string():
|
|
"""A part mark like 0010001 loaded as the int 10001: three characters
|
|
deleted and the type changed. Quoting in the YAML does not help --
|
|
coercion happens after parsing, so the quotes are long gone by here.
|
|
The damage was invisible because it is self-consistent; it only shows
|
|
when the value meets a string read from the model."""
|
|
assert knowledge._coerce('0010001') == '0010001'
|
|
assert knowledge._coerce('03') == '03'
|
|
assert knowledge._coerce('00') == '00'
|
|
|
|
|
|
def test_two_marks_in_one_field_keep_one_type():
|
|
"""'03' arriving as an int while its companion '03A' stayed a string is
|
|
what forced a whole `number` field to be dropped from the catalogue."""
|
|
out = knowledge._coerce({'parts': ['03', '03A', '0010001']})
|
|
assert [type(v) for v in out['parts']] == [type(u'')] * 3
|
|
|
|
|
|
def test_numbers_are_still_numbers():
|
|
"""The coercion exists for a real reason: pyRevit's YAML reader hands
|
|
back every scalar as a string, so this must not stop."""
|
|
assert knowledge._coerce('12') == 12
|
|
assert knowledge._coerce('0') == 0
|
|
assert knowledge._coerce('-3') == -3
|
|
assert knowledge._coerce('1.5') == 1.5
|
|
assert knowledge._coerce('0.5') == 0.5
|
|
assert knowledge._coerce('-0.25') == -0.25
|
|
assert knowledge._coerce('1e3') == 1000.0
|
|
assert knowledge._coerce('null') is None
|
|
assert knowledge._coerce('true') is True
|
|
assert knowledge._coerce('no') is False
|
|
|
|
|
|
def test_a_leading_zero_label_survives_a_whole_merged_tree():
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._deep_update(merged, knowledge._coerce(
|
|
{'details': {'13/X4.2': {'parts': ['0010001'], 'qty': '2'}}}))
|
|
entry = merged['details']['13/X4.2']
|
|
assert entry['parts'] == ['0010001']
|
|
assert entry['qty'] == 2
|
|
|
|
|
|
# ------------------- 6.5 note views: the declared shape (measured 08-30)
|
|
|
|
def test_note_defaults_declare_nothing_so_the_check_reports_not_run():
|
|
assert knowledge.DEFAULTS['notes']['instruction_views'] == []
|
|
assert knowledge.DEFAULTS['notes']['instruction_view_sets'] == {}
|
|
|
|
|
|
def test_plant_starter_names_no_plant():
|
|
"""The starter ships in the repo; every name in it must be a
|
|
placeholder, never a real view from a real plant."""
|
|
text = knowledge.PLANT_STARTER
|
|
for real in ('PANEL - DUNNAGE', 'PICKING/STRIPPING', '(V3)'):
|
|
assert real not in text
|
|
|
|
|
|
def test_full_length_bar_fraction_defaults_to_null_and_stands_52_down():
|
|
"""5.2's bar classification needs a number the standard never states.
|
|
Like rails.max_bed_width_in it defaults to None, so a plant that has
|
|
not recorded it gets a stood-down check rather than a guessed one."""
|
|
from vibe import knowledge as K
|
|
assert K.DEFAULTS['drawings']['full_length_bar_fraction'] is None
|
|
assert K.get({}, 'drawings.full_length_bar_fraction') is None
|
|
assert K.DEFAULTS['drawings']['reinforcing_families'] == []
|
|
|
|
|
|
# ------------------------------------ lifter safety factors (public layer)
|
|
#
|
|
# tension_fos / shear_fos were live inputs in panellift that NOTHING could
|
|
# set. They are lifter properties (Will 2026-08-30), so they belong beside
|
|
# the lifter ratings in the one knowledge layer that ships in the repo.
|
|
#
|
|
# AND BESIDE THE RATING MEANS BESIDE THE PART, which is the correction made
|
|
# 2026-08-31. They landed here as ONE file-wide pair, 4.0 / 2.67, and both
|
|
# halves of that were defects. The 2.67 had no published source; the shape
|
|
# meant every manufacturer's insert in the file drew on one number. ALP
|
|
# catalog p53 (02/2024) heads its capacity table "Capacity in Concrete 4:1
|
|
# SWL" across BOTH load columns, so the parts the pair actually reached
|
|
# publish 4:1 in shear, and they now say so in their own entries.
|
|
|
|
def test_no_file_wide_safety_factor_survives_in_the_defaults():
|
|
"""Null, and deliberately not 4.0. A global 4.0 is the same defect with
|
|
a better number: the next manufacturer's insert inherits a factor read
|
|
off ALP's page."""
|
|
from vibe import knowledge
|
|
assert knowledge.get(knowledge.DEFAULTS,
|
|
'catalogue.safety_factors.tension_fos') is None
|
|
assert knowledge.get(knowledge.DEFAULTS,
|
|
'catalogue.safety_factors.shear_fos') is None
|
|
|
|
|
|
def test_the_shipped_catalogue_file_agrees_with_the_defaults():
|
|
"""The YAML and the built-in default must not drift apart -- a value
|
|
that differs between the file and the code moves a computed number
|
|
depending on whether the file was found."""
|
|
import io
|
|
from vibe import knowledge, yamlkit
|
|
data = yamlkit.loads(
|
|
io.open(knowledge.catalogue_path(), encoding='utf-8').read())
|
|
factors = data['catalogue']['safety_factors']
|
|
assert factors['tension_fos'] is None
|
|
assert factors['shear_fos'] is None
|
|
assert factors['unknown_because'], (
|
|
'a null that decides a safety check owes a written reason -- '
|
|
'the layer law, and the reason 2.67 lasted twelve days')
|
|
# and the catalogue's own insert ratings are still readable past the
|
|
# new block -- it is inserted BEFORE `inserts`, so nothing shifted
|
|
assert 'QLPD4T5WG' in data['catalogue']['inserts']
|
|
|
|
|
|
def test_the_factor_resolves_per_part_and_is_never_borrowed():
|
|
"""The resolution `lifter_safety_factor` exists for. QLPD4T5WG publishes
|
|
4:1 in both directions; QL589SPG, in the same file, publishes 4,010 lb
|
|
shear against 7,690 lb tension -- nothing like 1:1 -- and has no factor
|
|
of its own. It must come back unjudged, not carrying the disk anchor's
|
|
number."""
|
|
from vibe import knowledge as K
|
|
know = {'lifters': {'catalogue_alias': {'ANCHOR_A': 'QLPD4T5WG',
|
|
'ANCHOR_B': 'QL589SPG'}},
|
|
'catalogue': _shipped_catalogue()}
|
|
value, source, why = K.lifter_safety_factor(know, 'ANCHOR_A',
|
|
kind='shear')
|
|
assert (value, source) == (4.0, 'published')
|
|
assert 'p53' in why and '02/2024' in why
|
|
|
|
value, source, why = K.lifter_safety_factor(know, 'ANCHOR_B',
|
|
kind='shear')
|
|
assert (value, source) == (None, None)
|
|
assert 'borrowed' in why
|
|
|
|
|
|
def _shipped_catalogue():
|
|
import io
|
|
from vibe import knowledge, yamlkit
|
|
return yamlkit.loads(
|
|
io.open(knowledge.catalogue_path(), encoding='utf-8').read()
|
|
)['catalogue']
|
|
|
|
|
|
# ---------------------------------------- minimum anchor spacing (6.7)
|
|
#
|
|
# ALP catalog p53, 02/2024, footnote: "Minimum anchor spacing is 2x the
|
|
# published edge distance". The owner handed over that page and this half
|
|
# of it never landed -- nothing in the tool had ever asked how far apart two
|
|
# anchors must sit, so two 8T anchors 20 in apart passed every check while
|
|
# the page asked for 28.
|
|
|
|
def test_the_spacing_minimum_is_derived_from_the_part_s_own_edge_distance():
|
|
from vibe import knowledge as K
|
|
know = {'lifters': {'catalogue_alias': {'ANCHOR_8T': 'QLPD8T8WG'}},
|
|
'catalogue': _shipped_catalogue()}
|
|
value, source, why = K.lifter_spacing_minimum(know, 'ANCHOR_8T')
|
|
assert (value, source) == (28.0, 'derived') # 2 x 14"
|
|
assert '2x' in why and '14"' in why, why
|
|
assert 'p53' in why
|
|
|
|
|
|
def test_a_published_spacing_overrides_the_multiple():
|
|
"""The override the shape exists for: a sheet stating a spacing outright
|
|
beats a rule of thumb about the same part."""
|
|
from vibe import knowledge as K
|
|
catalogue = _shipped_catalogue()
|
|
catalogue['inserts']['QLPD8T8WG'] = dict(
|
|
catalogue['inserts']['QLPD8T8WG'])
|
|
catalogue['inserts']['QLPD8T8WG']['min_spacing_rule'] = {
|
|
'published_in': 24, 'multiple_of_edge_distance': 2.0,
|
|
'source': 'a datasheet that states it outright'}
|
|
know = {'lifters': {'catalogue_alias': {'ANCHOR_8T': 'QLPD8T8WG'}},
|
|
'catalogue': catalogue}
|
|
value, source, _why = K.lifter_spacing_minimum(know, 'ANCHOR_8T')
|
|
assert (value, source) == (24.0, 'published')
|
|
|
|
|
|
def test_a_part_with_no_edge_distance_to_double_stands_down_naming_it():
|
|
"""The honest silence. QL589SPG carries no published edge distance, so
|
|
2x it is not a number -- and the answer is a sentence, never another
|
|
part's minimum."""
|
|
from vibe import knowledge as K
|
|
catalogue = _shipped_catalogue()
|
|
entry = dict(catalogue['inserts']['QL589SPG'])
|
|
entry['min_spacing_rule'] = {'multiple_of_edge_distance': 2.0}
|
|
catalogue['inserts']['QL589SPG'] = entry
|
|
know = {'lifters': {'catalogue_alias': {'ANCHOR_E': 'QL589SPG'}},
|
|
'catalogue': catalogue}
|
|
value, source, why = K.lifter_spacing_minimum(know, 'ANCHOR_E')
|
|
assert (value, source) == (None, None)
|
|
assert 'nothing to double' in why
|
|
|
|
|
|
def test_a_part_the_catalogue_does_not_cover_is_not_given_a_spacing():
|
|
from vibe import knowledge as K
|
|
value, source, why = K.lifter_spacing_minimum({}, 'SOMETHING_ELSE')
|
|
assert (value, source) == (None, None)
|
|
assert 'not written down' in why
|
|
|
|
|
|
# ============================================================================
|
|
# The shape a real project config file has
|
|
# ============================================================================
|
|
#
|
|
# Every config_problems test above hands the function a knowledge dict with a
|
|
# `decisions` branch already in it. NO PRODUCTION PATH EVER BUILDS THAT TREE:
|
|
# `_merge_config_file` merges only `holder['knowledge']`, and `_merge_decisions`
|
|
# maps the decisions block into `handling` without leaving a `decisions` branch
|
|
# behind. So those tests were green while the same typo in a real file was
|
|
# accepted in silence.
|
|
#
|
|
# These tests go through `_merge_config_file` on a file written in the shape
|
|
# `configdump.build()` writes -- `decisions:` at the top, one `knowledge:`
|
|
# block underneath -- and the text is produced by configdump's OWN writer, so
|
|
# the fixture cannot drift away from what the tool writes.
|
|
|
|
import io
|
|
import os
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def yaml_on_host(monkeypatch):
|
|
"""`pyrevit.coreutils.yaml` for host CPython -- as UNFORGIVING as the real one.
|
|
|
|
`_merge_file` imports the pyrevit reader and swallows every exception, so
|
|
without this the merge quietly does nothing and a test of it would pass
|
|
for the wrong reason. Every test below also asserts that a GOOD value
|
|
landed, which is what proves the file was really read.
|
|
|
|
THE PARSE IS yamlkit's; THE TYPES ARE YamlDotNet's. This fixture used to
|
|
hand back yamlkit's output directly, and yamlkit TYPES its scalars, so it
|
|
was strictly kinder than production -- see `pyrevit_yaml_fake.py`, where the
|
|
stringifier and the reasons live. `knowledge._merge_file` runs `_coerce()`
|
|
on what it gets and so is unaffected, which is precisely the property
|
|
these tests should be PROVING rather than assuming.
|
|
"""
|
|
from pyrevit_yaml_fake import (install_pyrevit_yaml,
|
|
strings_only_yaml_reader)
|
|
install_pyrevit_yaml(monkeypatch, strings_only_yaml_reader())
|
|
|
|
|
|
def _write_config(tmp_path, decisions, know=None, wrapped=True):
|
|
"""A project config file, written the way configdump.build() writes one."""
|
|
from vibe import configdump
|
|
know = know if know is not None else {'handling': {'rig_mode': 'rolling'}}
|
|
lines = list(configdump.emit({'decisions': decisions}))
|
|
lines.append(u'# the merged configuration, as build() writes it')
|
|
if wrapped:
|
|
lines.extend(configdump.emit({'knowledge': know}))
|
|
else:
|
|
lines.extend(configdump.emit(know))
|
|
path = tmp_path / 'active-config.yaml'
|
|
path.write_text(u'\n'.join(lines) + u'\n', encoding='utf-8')
|
|
return str(path)
|
|
|
|
|
|
def _merged(path):
|
|
merged = {'handling': {}}
|
|
knowledge._merge_config_file(merged, path)
|
|
return merged
|
|
|
|
|
|
def test_a_misspelled_insert_position_in_a_real_config_file_is_reported(
|
|
tmp_path, yaml_on_host):
|
|
"""The failure this whole section exists for: `fastt` in the file the
|
|
tool writes was accepted in silence and the run looked clean."""
|
|
path = _write_config(tmp_path, {
|
|
'insert_position': {'one_piece': 'fastt', 'batch': 'precise'}})
|
|
merged = _merged(path)
|
|
# the file really was read: the good half of the setting landed
|
|
assert merged['handling']['fast_lifter_box_batch'] is False
|
|
# and the typo was neither guessed at nor swallowed
|
|
assert 'fast_lifter_box' not in merged['handling']
|
|
problems = knowledge.config_problems(None, merged)
|
|
assert any('insert_position.one_piece' in p for p in problems), problems
|
|
assert any('fastt' in p for p in problems), problems
|
|
|
|
|
|
def test_a_bad_group_tolerance_in_a_real_config_file_is_reported(
|
|
tmp_path, yaml_on_host):
|
|
path = _write_config(tmp_path, {'group_tolerance_ft': 'one foot',
|
|
'prestress_loss_pct': 6.5})
|
|
merged = _merged(path)
|
|
assert merged['handling']['prestress_loss_pct'] == 6.5 # file was read
|
|
assert 'group_tolerance_ft' not in merged['handling']
|
|
problems = knowledge.config_problems(None, merged)
|
|
assert any('group_tolerance_ft' in p for p in problems), problems
|
|
|
|
|
|
def test_a_zero_group_tolerance_in_a_real_config_file_is_reported(
|
|
tmp_path, yaml_on_host):
|
|
path = _write_config(tmp_path, {'group_tolerance_ft': 0})
|
|
problems = knowledge.config_problems(None, _merged(path))
|
|
assert any('group_tolerance_ft' in p for p in problems), problems
|
|
|
|
|
|
def test_a_good_config_file_is_silent(tmp_path, yaml_on_host):
|
|
path = _write_config(tmp_path, {
|
|
'group_tolerance_ft': 1.25,
|
|
'insert_position': {'one_piece': 'fast', 'batch': 'precise'}})
|
|
merged = _merged(path)
|
|
assert merged['handling']['group_tolerance_ft'] == 1.25
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
def test_the_undocumented_unwrapped_file_shape_is_still_validated(
|
|
tmp_path, yaml_on_host):
|
|
"""A file with no `knowledge:` wrapper is merged whole. Undocumented, but
|
|
it is what the older tests exercised, so it keeps working -- and the
|
|
checks have to fire on it too, or fixing one shape breaks the other."""
|
|
path = _write_config(tmp_path, {
|
|
'insert_position': {'batch': 'quick'}}, wrapped=False)
|
|
merged = _merged(path)
|
|
assert merged['handling']['rig_mode'] == 'rolling' # merged whole
|
|
problems = knowledge.config_problems(None, merged)
|
|
assert any('insert_position.batch' in p for p in problems), problems
|
|
# reported once, not once per shape
|
|
assert len([p for p in problems if 'insert_position.batch' in p]) == 1
|
|
|
|
|
|
def test_a_misspelled_dunnage_mode_in_the_decisions_block_is_reported(
|
|
tmp_path, yaml_on_host):
|
|
"""`decisions.dunnage` is written into every config file with its own
|
|
VALID VALUES comment, and nothing mapped it back: editing the obvious
|
|
place did nothing, and the typo was never reported either."""
|
|
path = _write_config(tmp_path, {'dunnage': {'mode': 'lifter_row'}})
|
|
merged = _merged(path)
|
|
assert merged['handling']['dunnage']['mode'] == 'lifter_row'
|
|
assert any('dunnage.mode' in p
|
|
for p in knowledge.config_problems(None, merged))
|
|
|
|
|
|
def test_a_dunnage_decision_reaches_the_key_that_is_read(
|
|
tmp_path, yaml_on_host):
|
|
path = _write_config(tmp_path, {'dunnage': {'mode': 'fraction',
|
|
'fraction': 0.2}},
|
|
know={'handling': {'dunnage': {'mode': None}}})
|
|
merged = _merged(path)
|
|
assert merged['handling']['dunnage'] == {'mode': 'fraction',
|
|
'fraction': 0.2}
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
# ============================================================================
|
|
# FACE DESIGNATIONS, and the per-plant knowledge layer that selects them
|
|
# ============================================================================
|
|
#
|
|
# `SR324 (F.F.)`, `SR310 (E.L.)`, `P76 W/ SR5602 (S.I.F.)` -- the parenthetical
|
|
# suffix says which face of the piece the part goes on, and checklist 5.3 asks
|
|
# whether it agrees with the model. The abbreviations are the trade's and are
|
|
# published in knowledge/catalogue.yaml; WHICH SET a producer uses is a house
|
|
# habit and is selected, by id, in the plant layer.
|
|
#
|
|
# The thing these tests exist to defend is the FRAME OF REFERENCE. near/far is
|
|
# measured from the viewing direction and flips when the view flips; in-form is
|
|
# measured from the casting bed and does not. Flatten the two into one
|
|
# token -> meaning map and 5.3 stops being decidable while every individual
|
|
# call-out still reads perfectly normal. So every assertion below that touches
|
|
# a token also asserts the frame it came back with.
|
|
|
|
|
|
def _catalogue_face_designations():
|
|
"""The shipped public layer, read the way `_merge_file` reads it."""
|
|
import io
|
|
from vibe import knowledge as K, yamlkit
|
|
data = yamlkit.loads(
|
|
io.open(K.catalogue_path(), encoding='utf-8').read())
|
|
return K._coerce(data['face_designations'])
|
|
|
|
|
|
def test_the_shipped_catalogue_publishes_both_face_vocabularies():
|
|
branch = _catalogue_face_designations()
|
|
assert set(branch['vocabularies']) >= {'near_far', 'in_form'}
|
|
assert branch['frames']
|
|
|
|
|
|
def test_each_vocabulary_declares_a_frame_the_catalogue_also_publishes():
|
|
"""A frame is not free text. A vocabulary naming one that is not in
|
|
`frames` cannot be resolved against anything, and the check that reads it
|
|
would have to guess -- which is the one thing it must not do."""
|
|
branch = _catalogue_face_designations()
|
|
for vocab_id, vocab in branch['vocabularies'].items():
|
|
assert vocab['frame'] in branch['frames'], vocab_id
|
|
|
|
|
|
def test_the_two_families_are_in_different_frames_and_not_one_flat_table():
|
|
branch = _catalogue_face_designations()
|
|
assert branch['vocabularies']['near_far']['frame'] == 'view'
|
|
assert branch['vocabularies']['in_form']['frame'] == 'form'
|
|
|
|
|
|
def test_the_published_tokens_are_the_ones_printed_on_real_tickets():
|
|
branch = _catalogue_face_designations()
|
|
|
|
def tokens(vocab_id):
|
|
return dict([(e['token'], e['side'])
|
|
for e in branch['vocabularies'][vocab_id]['tokens']])
|
|
|
|
assert tokens('near_far') == {'N.F.': 'near', 'F.F.': 'far',
|
|
'E.L.': 'both'}
|
|
assert tokens('in_form') == {'T.I.F.': 'top', 'B.I.F.': 'bottom',
|
|
'S.I.F.': 'side'}
|
|
|
|
|
|
def test_each_layer_is_named_by_a_token_that_does_not_flip_with_the_view():
|
|
"""E.L. is why `side` is a symbol and not the English of `means`.
|
|
"each layer" names BOTH faces, so it is symmetric: flipping the view does
|
|
not make it wrong. A check that treated it as one face and flipped it
|
|
would invent a finding on every drawing."""
|
|
branch = _catalogue_face_designations()
|
|
entry = [e for e in branch['vocabularies']['near_far']['tokens']
|
|
if e['token'] == 'E.L.'][0]
|
|
assert entry['side'] == 'both'
|
|
|
|
|
|
# ------------------------------------------------- selecting, and resolving
|
|
|
|
def _know(use, extra_vocabularies=None):
|
|
"""A merged tree with the shipped catalogue's vocabularies in it."""
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._deep_update(merged,
|
|
{'face_designations': _catalogue_face_designations()})
|
|
knowledge._deep_update(merged, {'face_designations': {'use': use}})
|
|
if extra_vocabularies:
|
|
knowledge._deep_update(
|
|
merged, {'face_designations':
|
|
{'vocabularies': extra_vocabularies}})
|
|
return merged
|
|
|
|
|
|
def test_nothing_is_in_force_until_a_plant_selects_it():
|
|
"""The catalogue publishes both vocabularies to every plant. Publishing
|
|
is not selecting: a plant that has said nothing uses nothing, and the 5.3
|
|
check has to stand down rather than pick a set for it."""
|
|
merged = _know([])
|
|
assert knowledge.face_vocabularies(merged) == []
|
|
assert knowledge.face_designation(merged, 'F.F.') is None
|
|
# ...and silently, because saying nothing is not an error
|
|
assert knowledge.face_designation_problems(merged) == []
|
|
|
|
|
|
def test_a_plant_selecting_two_vocabularies_gets_the_tokens_of_both():
|
|
"""One ticket prints a layer position on one line and a casting
|
|
orientation on the next, so the selection is a list and a plant may use
|
|
more than one."""
|
|
merged = _know(['near_far', 'in_form'])
|
|
assert [v for v, _ in knowledge.face_vocabularies(merged)] == \
|
|
['near_far', 'in_form']
|
|
assert knowledge.face_designation(merged, 'F.F.')['means'] == 'far face'
|
|
assert knowledge.face_designation(merged, 'S.I.F.')['means'] == \
|
|
'side in form'
|
|
assert knowledge.face_designation_problems(merged) == []
|
|
|
|
|
|
def test_a_resolved_designation_always_carries_what_it_is_measured_from():
|
|
"""`side` alone is not an answer. 'near' is only meaningful once you know
|
|
it is read off the view and therefore flips with it; 'top' is read off
|
|
the bed and does not. A caller that got `side` without `frame` would have
|
|
no way to tell those apart, which is exactly how a flipped elevation
|
|
passes 5.3."""
|
|
merged = _know(['near_far', 'in_form'])
|
|
near = knowledge.face_designation(merged, 'N.F.')
|
|
top = knowledge.face_designation(merged, 'T.I.F.')
|
|
assert (near['side'], near['frame']) == ('near', 'view')
|
|
assert (top['side'], top['frame']) == ('top', 'form')
|
|
assert near['vocabulary'] == 'near_far'
|
|
assert near['measured_from']
|
|
|
|
|
|
def test_a_selection_replaces_rather_than_adds_to_the_layer_below():
|
|
"""`use` is a LIST, and lists replace. That is the right semantics here:
|
|
what a layer names is exactly what is in force, and a vocabulary left
|
|
quietly selected from underneath would resolve tokens in a frame nobody
|
|
chose."""
|
|
merged = _know(['near_far', 'in_form'])
|
|
knowledge._deep_update(merged, {'face_designations': {'use': ['in_form']}})
|
|
assert [v for v, _ in knowledge.face_vocabularies(merged)] == ['in_form']
|
|
assert knowledge.face_designation(merged, 'F.F.') is None
|
|
|
|
|
|
def test_a_plant_may_state_a_token_nobody_published_without_touching_the_catalogue():
|
|
"""`vocabularies` is a MAP, so it deep-merges: a plant adds its own and
|
|
the published ones are still there."""
|
|
merged = _know(['near_far', 'house_bed'], extra_vocabularies={
|
|
'house_bed': {
|
|
'frame': 'form',
|
|
'measured_from': 'the casting bed',
|
|
'tokens': [{'token': 'U.S.', 'means': 'up side', 'side': 'top'}],
|
|
}})
|
|
assert knowledge.face_designation(merged, 'U.S.')['frame'] == 'form'
|
|
assert knowledge.face_designation(merged, 'N.F.')['frame'] == 'view'
|
|
assert knowledge.face_designation_problems(merged) == []
|
|
|
|
|
|
def test_an_unknown_vocabulary_id_is_reported_and_not_skipped_in_silence():
|
|
"""The plant thinks it declared a vocabulary. Every token in it would
|
|
come back unknown, one call-out at a time, with nothing anywhere saying
|
|
the selection itself was wrong."""
|
|
merged = _know(['near_far', 'nera_far'])
|
|
problems = knowledge.face_designation_problems(merged)
|
|
assert len(problems) == 1
|
|
assert 'nera_far' in problems[0]
|
|
assert 'near_far' in problems[0] # names what IS defined
|
|
# the good half still resolves -- one bad id does not disarm the rest
|
|
assert knowledge.face_designation(merged, 'N.F.')['side'] == 'near'
|
|
assert knowledge.config_problems(None, merged) == problems
|
|
|
|
|
|
def test_a_vocabulary_with_an_unpublished_frame_is_reported():
|
|
merged = _know(['bed'], extra_vocabularies={
|
|
'bed': {'frame': 'bed', 'tokens': []}})
|
|
problems = knowledge.face_designation_problems(merged)
|
|
assert len(problems) == 1
|
|
assert "'bed'" in problems[0] and 'view' in problems[0]
|
|
|
|
|
|
def test_two_selected_vocabularies_claiming_one_token_in_different_frames():
|
|
"""The one genuinely undecidable case. `S.I.F.` measured from the bed and
|
|
`S.I.F.` measured from the view are different questions, and whichever
|
|
vocabulary happened to be looked at first would answer it."""
|
|
merged = _know(['in_form', 'house'], extra_vocabularies={
|
|
'house': {
|
|
'frame': 'view',
|
|
'tokens': [{'token': 'S.I.F.', 'means': 'something else',
|
|
'side': 'near'}],
|
|
}})
|
|
problems = knowledge.face_designation_problems(merged)
|
|
assert len(problems) == 1
|
|
assert 'S.I.F.' in problems[0]
|
|
assert 'in_form' in problems[0] and 'house' in problems[0]
|
|
|
|
|
|
def test_the_same_token_in_two_vocabularies_of_the_same_frame_is_fine():
|
|
"""Two form-relative vocabularies both spelling `S.I.F.` agree about what
|
|
it is measured from, so there is nothing undecidable and nothing to say."""
|
|
merged = _know(['in_form', 'house'], extra_vocabularies={
|
|
'house': {'frame': 'form',
|
|
'tokens': [{'token': 'S.I.F.', 'means': 'side in form',
|
|
'side': 'side'}]}})
|
|
assert knowledge.face_designation_problems(merged) == []
|
|
|
|
|
|
# ============================================================================
|
|
# Choosing the plant layer: one folder per plant, named by the JOB
|
|
# ============================================================================
|
|
#
|
|
# `designspec` cannot be imported on host CPython -- it pulls in the Revit API
|
|
# at module scope -- so the path arithmetic lives in `vibe.plantpath`, which
|
|
# has no Revit dependency and is imported here for real. The `load()` tests
|
|
# below stub `vibe.designspec` with a module that delegates the plant path to
|
|
# that same real function and fakes only the two things that genuinely need a
|
|
# document: where the job file is, and whether a project config is pointed at.
|
|
|
|
def test_no_plant_named_reads_the_machine_wide_file_exactly_as_before(
|
|
monkeypatch, tmp_path):
|
|
from vibe import plantpath
|
|
monkeypatch.delenv(plantpath.PLANT_ENV, raising=False)
|
|
monkeypatch.setenv('APPDATA', str(tmp_path))
|
|
assert plantpath.plant_knowledge_path() == \
|
|
os.path.join(str(tmp_path), plantpath.PLANT_FOLDER, 'knowledge.yaml')
|
|
|
|
|
|
def test_a_plant_named_is_a_folder_beside_the_machine_wide_file(
|
|
monkeypatch, tmp_path):
|
|
"""A SECOND PLANT IS A NEW FOLDER, not an edit to the first one's file --
|
|
which is the whole point of the rename."""
|
|
from vibe import plantpath
|
|
monkeypatch.delenv(plantpath.PLANT_ENV, raising=False)
|
|
monkeypatch.setenv('APPDATA', str(tmp_path))
|
|
root = os.path.join(str(tmp_path), plantpath.PLANT_FOLDER)
|
|
assert plantpath.plant_knowledge_path('northgate') == \
|
|
os.path.join(root, 'northgate', 'knowledge.yaml')
|
|
assert plantpath.plant_knowledge_path('southbay') == \
|
|
os.path.join(root, 'southbay', 'knowledge.yaml')
|
|
|
|
|
|
def test_the_environment_variable_naming_one_file_still_wins_untouched(
|
|
monkeypatch, tmp_path):
|
|
"""The single-file arrangement is what every machine but the author's is
|
|
set up for. A silent break there would be worse than the feature."""
|
|
from vibe import plantpath
|
|
shared = tmp_path / 'share' / 'knowledge.yaml'
|
|
shared.parent.mkdir()
|
|
shared.write_text(u'nomenclature:\n design_folder: X\n', encoding='utf-8')
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(shared))
|
|
assert plantpath.plant_knowledge_path() == str(shared)
|
|
|
|
|
|
def test_a_machine_pointed_at_one_file_gets_per_plant_folders_beside_it(
|
|
monkeypatch, tmp_path):
|
|
"""The root is defined as "the folder the machine-wide knowledge.yaml is
|
|
in", so reading it backwards off the variable is the definition rather
|
|
than a guess -- and a machine already pointing at a file picks up
|
|
per-plant folders with no change to the variable."""
|
|
from vibe import plantpath
|
|
shared = tmp_path / 'share' / 'knowledge.yaml'
|
|
shared.parent.mkdir()
|
|
shared.write_text(u'x: 1\n', encoding='utf-8')
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(shared))
|
|
assert plantpath.plant_knowledge_path('northgate') == \
|
|
os.path.join(str(tmp_path / 'share'), 'northgate', 'knowledge.yaml')
|
|
|
|
|
|
def test_the_environment_variable_may_also_name_the_folder(
|
|
monkeypatch, tmp_path):
|
|
from vibe import plantpath
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(tmp_path))
|
|
assert plantpath.plant_knowledge_path('northgate') == \
|
|
os.path.join(str(tmp_path), 'northgate', 'knowledge.yaml')
|
|
assert plantpath.plant_knowledge_path() == \
|
|
os.path.join(str(tmp_path), 'knowledge.yaml')
|
|
|
|
|
|
def test_a_plant_id_that_is_not_a_folder_name_resolves_to_nothing(monkeypatch,
|
|
tmp_path):
|
|
"""It arrives from a YAML file somebody edits by hand, so it is untrusted
|
|
input used as a path component. Refused outright rather than scrubbed:
|
|
a name quietly turned into a different folder is how a run ends up on
|
|
another plant's rules looking entirely normal.
|
|
|
|
`None` is NOT in this list. None is the absence of a plant, not a bad
|
|
one, and it resolves to the machine-wide file -- see the test above."""
|
|
from vibe import plantpath
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(tmp_path))
|
|
for bad in ('..', '../evil', 'a/b', 'a\\b', 'C:', '', ' ', 3):
|
|
assert plantpath.valid_plant_id(bad) is False, bad
|
|
assert plantpath.plant_knowledge_path(bad) is None, bad
|
|
assert plantpath.valid_plant_id(None) is False
|
|
assert plantpath.plant_knowledge_path(None) == \
|
|
os.path.join(str(tmp_path), 'knowledge.yaml')
|
|
assert plantpath.valid_plant_id('northgate') is True
|
|
assert plantpath.valid_plant_id('plant-2.east_1') is True
|
|
|
|
|
|
# -------------------------------------------------- load(), and the pre-pass
|
|
|
|
@pytest.fixture
|
|
def fake_designspec(monkeypatch, tmp_path):
|
|
"""`vibe.designspec` with the Revit half faked and the paths REAL.
|
|
|
|
Only two things here need a live document -- where the job knowledge file
|
|
is, and whether a project config is pointed at -- so only those are faked.
|
|
`plant_knowledge_path` is `vibe.plantpath`'s own, so these tests exercise
|
|
the real resolution rather than a second copy of it that could drift.
|
|
"""
|
|
from vibe import plantpath
|
|
|
|
job_dir = tmp_path / 'job'
|
|
job_dir.mkdir()
|
|
plant_root = tmp_path / 'plants'
|
|
plant_root.mkdir()
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(plant_root))
|
|
|
|
module = types.ModuleType('vibe.designspec')
|
|
module.PLANT_ENV = plantpath.PLANT_ENV
|
|
module.plant_knowledge_path = plantpath.plant_knowledge_path
|
|
module.knowledge_path = lambda doc: str(job_dir / 'knowledge.yaml')
|
|
module.active_config_path = lambda doc: module._config
|
|
module._config = None
|
|
module._job_dir = job_dir
|
|
module._plant_root = plant_root
|
|
monkeypatch.setitem(sys.modules, 'vibe.designspec', module)
|
|
return module
|
|
|
|
|
|
def _write(path, text):
|
|
path.write_text(text, encoding='utf-8')
|
|
return str(path)
|
|
|
|
|
|
def test_load_reads_the_plant_folder_the_job_file_names(
|
|
fake_designspec, yaml_on_host):
|
|
"""THE ORDERING TRAP. `job.plant` chooses the plant layer, and it lives in
|
|
the layer that merges AFTER it. Without the pre-pass this reads the
|
|
machine-wide file while the job file plainly says which plant it is for,
|
|
and every report still looks normal."""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml',
|
|
u'nomenclature:\n design_folder: NORTHGATE PRODUCT\n')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_id'] == 'northgate'
|
|
assert merged['nomenclature']['design_folder'] == 'NORTHGATE PRODUCT'
|
|
assert '_plant_missing' not in merged
|
|
assert '_plant_layer_stale' not in merged
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
def test_load_with_no_plant_named_reads_the_machine_wide_file_and_says_nothing(
|
|
fake_designspec, yaml_on_host):
|
|
"""The state every install that predates this key is in. It is legitimate,
|
|
so it is quiet -- a warning on every run of every install is a warning
|
|
that stops being read. It is still on the record: `_plant_id` is None and
|
|
`sources()` names the file that was read."""
|
|
_write(fake_designspec._plant_root / 'knowledge.yaml',
|
|
u'nomenclature:\n design_folder: HOUSE\n')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'scope:\n declared_by: Will\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_id'] is None
|
|
assert merged['nomenclature']['design_folder'] == 'HOUSE'
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
def test_a_job_naming_a_plant_with_no_file_is_loud_and_does_not_fall_back(
|
|
fake_designspec, yaml_on_host):
|
|
"""Same discipline as `_config_missing`. Falling back to the machine-wide
|
|
file would run one plant's job on another plant's rules and look
|
|
completely normal doing it."""
|
|
_write(fake_designspec._plant_root / 'knowledge.yaml',
|
|
u'nomenclature:\n design_folder: HOUSE\n')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: nrothgate\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_missing'][0] == 'nrothgate'
|
|
# NOT the machine-wide file's value -- nothing was read in its place
|
|
assert merged['nomenclature']['design_folder'] != 'HOUSE'
|
|
problems = knowledge.config_problems(None, merged)
|
|
assert len(problems) == 1
|
|
assert 'nrothgate' in problems[0]
|
|
|
|
|
|
def test_a_plant_id_that_is_not_a_folder_name_is_reported_by_load(
|
|
fake_designspec, yaml_on_host):
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: ../elsewhere\n')
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_missing'][0] == '../elsewhere'
|
|
assert merged['_plant_missing'][1] is None
|
|
assert 'not a usable folder name' in \
|
|
knowledge.config_problems(None, merged)[0]
|
|
|
|
|
|
def test_the_plant_named_in_a_project_config_still_chooses_the_plant_file(
|
|
fake_designspec, yaml_on_host, tmp_path):
|
|
"""The config file merges last, so it may override `job.plant` -- and the
|
|
pre-pass reads it for exactly that reason. If it did not, the tripwire
|
|
below would fire instead of the right file being read."""
|
|
for name in ('northgate', 'southbay'):
|
|
folder = fake_designspec._plant_root / name
|
|
folder.mkdir()
|
|
_write(folder / 'knowledge.yaml',
|
|
u'nomenclature:\n design_folder: {0}\n'.format(name.upper()))
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
fake_designspec._config = _write(
|
|
tmp_path / 'active-config.yaml',
|
|
u'knowledge:\n job:\n plant: southbay\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_id'] == 'southbay'
|
|
assert merged['nomenclature']['design_folder'] == 'SOUTHBAY'
|
|
assert '_plant_layer_stale' not in merged
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
def test_the_tripwire_fires_if_the_pre_pass_and_the_merge_ever_disagree(
|
|
fake_designspec, yaml_on_host, monkeypatch):
|
|
"""The guard against somebody "simplifying" the two-pass away, or adding
|
|
a layer that sets `job.plant` and forgetting the pre-pass reads only two.
|
|
Simulated by making the pre-pass answer differently from the merge."""
|
|
folder = fake_designspec._plant_root / 'northgate'
|
|
folder.mkdir()
|
|
_write(folder / 'knowledge.yaml', u'nomenclature:\n design_folder: N\n')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
monkeypatch.setattr(knowledge, 'plant_id', lambda doc: None)
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_layer_stale'] == (None, 'northgate')
|
|
assert any('wrong one' in p
|
|
for p in knowledge.config_problems(None, merged))
|
|
|
|
|
|
def test_an_environment_variable_pointing_at_a_file_that_is_gone_is_loud(
|
|
fake_designspec, yaml_on_host, monkeypatch):
|
|
"""Not about the plant name: somebody explicitly pointed the tool at a
|
|
file and the tool did not read it. Nothing else would ever say so."""
|
|
from vibe import plantpath
|
|
monkeypatch.setenv(plantpath.PLANT_ENV,
|
|
str(fake_designspec._plant_root / 'gone.yaml'))
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'scope:\n declared_by: Will\n')
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_env_missing'].endswith('gone.yaml')
|
|
assert 'WILLTOOLS_KNOWLEDGE' in knowledge.config_problems(None, merged)[0]
|
|
|
|
|
|
def test_the_plant_layer_can_select_a_vocabulary_the_catalogue_publishes(
|
|
fake_designspec, yaml_on_host):
|
|
"""End to end: the catalogue publishes the vocabularies, the plant folder
|
|
selects two of them by id, and a token resolves with its frame."""
|
|
folder = fake_designspec._plant_root / 'northgate'
|
|
folder.mkdir()
|
|
_write(folder / 'knowledge.yaml',
|
|
u'face_designations:\n use: [near_far, in_form]\n')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert [v for v, _ in knowledge.face_vocabularies(merged)] == \
|
|
['near_far', 'in_form']
|
|
assert knowledge.face_designation(merged, 'E.L.')['side'] == 'both'
|
|
assert knowledge.face_designation(merged, 'B.I.F.')['frame'] == 'form'
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
|
|
def test_sources_names_the_per_plant_file_the_run_really_read(
|
|
fake_designspec, yaml_on_host):
|
|
"""A listing of layers that names the machine-wide file while the run used
|
|
a per-plant one is a listing that lies."""
|
|
folder = fake_designspec._plant_root / 'northgate'
|
|
folder.mkdir()
|
|
_write(folder / 'knowledge.yaml', u'x: 1\n')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
|
|
rows = dict([(label, (path, exists))
|
|
for label, path, exists in knowledge.sources(None)])
|
|
assert rows['plant'][0] == str(folder / 'knowledge.yaml')
|
|
assert rows['plant'][1] is True
|
|
|
|
|
|
def test_load_puts_the_plant_layer_between_the_catalogue_and_the_job(
|
|
fake_designspec, yaml_on_host):
|
|
"""Job beats plant beats public, and the per-plant folder did not move
|
|
the plant layer anywhere in that order."""
|
|
folder = fake_designspec._plant_root / 'northgate'
|
|
folder.mkdir()
|
|
_write(folder / 'knowledge.yaml',
|
|
u'catalogue:\n rebar:\n stock_length_ft: 60.0\n')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\ntolerances:\n default_in: 0.5\n')
|
|
|
|
merged = knowledge.load(None)
|
|
# the plant overrode the catalogue's published 40 ft stock
|
|
assert merged['catalogue']['rebar']['stock_length_ft'] == 60.0
|
|
# and the job's own value is on top of both
|
|
assert merged['tolerances']['default_in'] == 0.5
|
|
|
|
|
|
# ------------------------------------- a layer file that exists and will not
|
|
# parse
|
|
#
|
|
# THE INCIDENT, 2026-09-01, measured live. One key stated twice inside one
|
|
# nested mapping of a plant file. pyRevit's reader is strict and raises
|
|
# `Duplicate key`, `_merge_file` swallowed it with a bare `except Exception`,
|
|
# and the ENTIRE 255KB plant layer was discarded: every parameter role mapped
|
|
# to None, the sweep enumerated 0 assemblies, and every report rendered
|
|
# normally. `sources()` and `config_problems()` both said healthy, because
|
|
# A LAYER THAT FAILS TO LOAD LOOKS EXACTLY LIKE A LAYER THAT AGREES WITH THE
|
|
# DEFAULTS.
|
|
#
|
|
# The host could not reproduce it, either, because the fake reader took the
|
|
# last of the two keys and said nothing -- the same "double kinder than
|
|
# production" that the null-typing incident already cost a press over. So
|
|
# these tests are only worth anything BECAUSE `pyrevit_yaml_fake` now raises
|
|
# on a duplicate the way YamlDotNet does: the fixtures below are real
|
|
# duplicate keys, not a monkeypatched exception.
|
|
|
|
_DUPLICATE = (u'nomenclature:\n'
|
|
u' design_folder: NORTHGATE PRODUCT\n'
|
|
u' rules:\n'
|
|
u' cap_label_mismatch_is_view_specific: true\n'
|
|
u' something_else: 1\n'
|
|
u' cap_label_mismatch_is_view_specific: false\n')
|
|
|
|
|
|
def test_a_plant_file_that_will_not_parse_is_recorded_and_not_swallowed(
|
|
fake_designspec, yaml_on_host):
|
|
"""The layer is still skipped -- a half-parsed layer must never merge --
|
|
but the skip leaves a marker, which is the whole difference between this
|
|
and the incident."""
|
|
folder = fake_designspec._plant_root / 'northgate'
|
|
folder.mkdir()
|
|
path = _write(folder / 'knowledge.yaml', _DUPLICATE)
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_unreadable'][0] == path
|
|
assert 'Duplicate key' in merged['_plant_unreadable'][1]
|
|
# NOT half-merged: nothing from the file reached the tree, and what is
|
|
# left in its place is the DEFAULT -- which is the whole trap. Reading
|
|
# the tree cannot tell you this layer failed; only the marker can.
|
|
assert merged['nomenclature']['design_folder'] == \
|
|
knowledge.DEFAULTS['nomenclature']['design_folder']
|
|
assert 'rules' not in merged['nomenclature']
|
|
assert knowledge.unreadable_layers(merged) == \
|
|
[('plant', path, merged['_plant_unreadable'][1])]
|
|
|
|
|
|
def test_a_missing_layer_and_an_unreadable_one_are_different_facts(
|
|
fake_designspec, yaml_on_host):
|
|
"""Two different things went wrong and they need two different sentences:
|
|
one file is not there, the other is there and lying still. Collapsing them
|
|
would send whoever reads the message to create a file that exists."""
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
|
|
absent = knowledge.load(None)
|
|
assert absent['_plant_missing'][0] == 'northgate'
|
|
assert '_plant_unreadable' not in absent
|
|
|
|
folder = fake_designspec._plant_root / 'northgate'
|
|
folder.mkdir()
|
|
_write(folder / 'knowledge.yaml', _DUPLICATE)
|
|
|
|
broken = knowledge.load(None)
|
|
assert '_plant_missing' not in broken
|
|
assert broken['_plant_unreadable'][0].endswith('knowledge.yaml')
|
|
|
|
|
|
def test_the_job_layer_gets_the_same_marker(fake_designspec, yaml_on_host):
|
|
path = _write(fake_designspec._job_dir / 'knowledge.yaml', _DUPLICATE)
|
|
merged = knowledge.load(None)
|
|
assert merged['_job_unreadable'][0] == path
|
|
assert 'Duplicate key' in merged['_job_unreadable'][1]
|
|
|
|
|
|
def test_the_project_config_gets_the_same_marker(fake_designspec,
|
|
yaml_on_host, tmp_path):
|
|
"""The config file merges through `_merge_config_file`, which reads into a
|
|
holder of its own -- so the marker has to be routed onto the real tree or
|
|
it dies with the holder."""
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml', u'x: 1\n')
|
|
path = _write(tmp_path / 'active-config.yaml',
|
|
u'knowledge:\n handling:\n rig_mode: rolling\n'
|
|
u' rig_mode: spreader\n')
|
|
fake_designspec._config = path
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_config_unreadable'][0] == path
|
|
assert '_config_missing' not in merged # it IS there; it will not read
|
|
|
|
|
|
def test_the_catalogue_gets_the_same_marker(fake_designspec, yaml_on_host,
|
|
tmp_path, monkeypatch):
|
|
"""The layer that ships with the tool is no exception. A catalogue that
|
|
will not parse means every published rating is silently back to
|
|
DEFAULTS."""
|
|
path = _write(tmp_path / 'catalogue.yaml', _DUPLICATE)
|
|
monkeypatch.setattr(knowledge, 'catalogue_path', lambda: path)
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml', u'x: 1\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_catalogue_unreadable'][0] == path
|
|
|
|
|
|
def test_unreadable_layers_reads_the_markers_back_in_merge_order():
|
|
know = {'_job_unreadable': ('J', 'boom'),
|
|
'_catalogue_unreadable': ('C', 'bang')}
|
|
assert knowledge.unreadable_layers(know) == [('catalogue', 'C', 'bang'),
|
|
('job', 'J', 'boom')]
|
|
assert knowledge.unreadable_layers({}) == []
|
|
assert knowledge.unreadable_layers(None) == []
|
|
|
|
|
|
def test_config_problems_refuses_on_an_unreadable_layer():
|
|
"""Refusal-grade: the file, the layer, what the parser said, and what to
|
|
do about it. A button prints this instead of running."""
|
|
problems = knowledge.config_problems(
|
|
None, {'_plant_unreadable': ('C:/plants/northgate/knowledge.yaml',
|
|
'Duplicate key: cap_label at line 812')})
|
|
assert len(problems) == 1
|
|
said = problems[0]
|
|
assert 'plant knowledge layer' in said
|
|
assert 'C:/plants/northgate/knowledge.yaml' in said
|
|
assert 'Duplicate key: cap_label at line 812' in said
|
|
assert 'will not guess at what it said' in said
|
|
# the sentence the incident is named by, so nobody reads a clean report
|
|
# and concludes the layer agreed with the defaults
|
|
assert 'looks exactly like a layer that agrees with the defaults' in said
|
|
|
|
|
|
def test_config_problems_names_every_unreadable_layer_not_just_the_first():
|
|
problems = knowledge.config_problems(
|
|
None, {'_plant_unreadable': ('P', 'a'), '_job_unreadable': ('J', 'b')})
|
|
assert len(problems) == 2
|
|
assert 'plant' in problems[0] and 'job' in problems[1]
|
|
|
|
|
|
# ============================================================================
|
|
# QL589: THE PLANT-OVERRIDES-CATALOGUE SCENARIO, PINNED
|
|
# ============================================================================
|
|
#
|
|
# Owner, 2026-09-02, verbatim: "M8是厂知识。ALP是公共知识。在MC的job里面,MC的
|
|
# 厂知识override公共知识。" -- M8 is plant knowledge, ALP is public knowledge,
|
|
# and in an MC job the plant's knowledge overrides the public one, BY THE
|
|
# LAYER MERGE, automatically. This section pins exactly that: a merged
|
|
# knowledge dict carrying BOTH the shipped catalogue's QL589SPG (16,000 lb,
|
|
# ALP's number) and the northgate plant layer's own QL589 override (13,800
|
|
# lb, M8's number) must resolve to the plant's number, not the public one.
|
|
#
|
|
# `lifter_capacity()` reaches the override through `lifters.capacity_lb`,
|
|
# checked BEFORE the catalogue -- see its docstring, 2026-09-02 addition, for
|
|
# the dict shape (`tension_lb` / `shear_lb`) a single family needs when the
|
|
# two kinds are not the same number, which QL589 is the case this exists
|
|
# for: 13,800 tension against 3,860 shear are not interchangeable, and a
|
|
# flat scalar entry would silently hand the tension figure back on a shear
|
|
# query.
|
|
|
|
def test_qc589_plant_override_beats_the_public_catalogue(
|
|
fake_designspec, yaml_on_host):
|
|
"""The owner's exact MC scenario. `knowledge.load()` always reads the
|
|
real, shipped `knowledge/catalogue.yaml` (`load()`'s first merged
|
|
layer) -- so this is not a stand-in catalogue, it is the one the tool
|
|
actually ships, and it really does publish QL589SPG at 16,000 lb."""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml', u'''
|
|
lifters:
|
|
capacity_lb:
|
|
QL589:
|
|
tension_lb: 13800
|
|
shear_lb: 3860
|
|
''')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_id'] == 'northgate'
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
# Sanity check on the premise: the SHIPPED catalogue really does publish
|
|
# QL589SPG at 16,000 lb, reachable UNGATED (no plant tension-bar claim
|
|
# in this scenario) -- so an override that failed to wire would fall
|
|
# through to exactly this number in silence.
|
|
catalogue_only = {'lifters': {'catalogue_alias': {'QL589': 'QL589SPG'}},
|
|
'catalogue': merged['catalogue']}
|
|
cap, basis, why = knowledge.lifter_capacity(catalogue_only, 'QL589')
|
|
assert (cap, basis) == (16000, 'catalogue'), why
|
|
|
|
# The real scenario: MC job, both layers merged, plant override present.
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'QL589')
|
|
assert (cap, basis) == (13800, 'direct'), why
|
|
assert 'QL589' in why
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'QL589', kind='shear')
|
|
assert (cap, basis) == (3860, 'direct'), why
|
|
|
|
|
|
def test_lpa4t512_shear_is_zero_and_tension_stands_down_on_the_angle(
|
|
fake_designspec, yaml_on_host):
|
|
"""LPA4T512's shear is unconditionally zero (back-stripping anchors
|
|
carry no shear) and resolves. Its tension is NOT pinned: M8 publishes
|
|
it by sling angle (5,990 @ 60 deg / 6,680 @ 75 deg) and the plant has
|
|
never confirmed which column applies, so a tension query must stand
|
|
down naming the unresolved angle rather than silently choosing one --
|
|
the same discipline `lifting_anchors.sling_angle_choice_unknown_because`
|
|
states for the raw M8 table this override was built from."""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml', u'''
|
|
lifters:
|
|
capacity_lb:
|
|
LPA4T512:
|
|
shear_lb: 0
|
|
tension_60_lb: 5990
|
|
tension_75_lb: 6680
|
|
tension_lb_unresolved_because: "sling angle not confirmed at this plant"
|
|
''')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
merged = knowledge.load(None)
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(
|
|
merged, 'LPA4T512', kind='shear')
|
|
assert (cap, basis) == (0, 'direct'), why
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'LPA4T512')
|
|
assert (cap, basis) == (None, None)
|
|
assert 'sling angle not confirmed' in why
|
|
|
|
|
|
def test_lpa4t512_tension_resolves_once_the_plant_names_a_sling_angle(
|
|
fake_designspec, yaml_on_host):
|
|
"""Added 2026-09-02: once `lifting_anchors.sling_angle_choice` names one
|
|
of the published angles, the same angle-split dict resolves to that
|
|
column instead of standing down -- `why` naming the chosen angle. This
|
|
must not disturb shear (still 0, unconditional) or the plain-number /
|
|
`{tension_lb, shear_lb}` shapes (covered by the QL589 test above)."""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml', u'''
|
|
lifting_anchors:
|
|
sling_angle_choice: 60
|
|
sling_angles_published: [60, 75]
|
|
lifters:
|
|
capacity_lb:
|
|
LPA4T512:
|
|
shear_lb: 0
|
|
tension_60_lb: 5990
|
|
tension_75_lb: 6680
|
|
tension_lb_unresolved_because: "sling angle not confirmed at this plant"
|
|
''')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
merged = knowledge.load(None)
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'LPA4T512')
|
|
assert (cap, basis) == (5990, 'direct'), why
|
|
assert '60' in why and 'sling_angle_choice' in why
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(
|
|
merged, 'LPA4T512', kind='shear')
|
|
assert (cap, basis) == (0, 'direct'), why
|
|
|
|
|
|
def test_lpa4t512_tension_stands_down_naming_an_unpublished_sling_angle(
|
|
fake_designspec, yaml_on_host):
|
|
"""A `sling_angle_choice` that names an angle this entry has no column
|
|
for (typo, or a choice made before this part's chart was landed) must
|
|
stand down naming the mismatch -- never silently take the nearer or the
|
|
smaller column."""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml', u'''
|
|
lifting_anchors:
|
|
sling_angle_choice: 45
|
|
sling_angles_published: [60, 75]
|
|
lifters:
|
|
capacity_lb:
|
|
LPA4T512:
|
|
shear_lb: 0
|
|
tension_60_lb: 5990
|
|
tension_75_lb: 6680
|
|
tension_lb_unresolved_because: "sling angle not confirmed at this plant"
|
|
''')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
merged = knowledge.load(None)
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'LPA4T512')
|
|
assert (cap, basis) == (None, None)
|
|
assert '45' in why
|
|
assert '60' in why and '75' in why
|
|
|
|
|
|
def test_strand_loop_family_needs_a_lifters_capacity_lb_entry_to_resolve(
|
|
fake_designspec, yaml_on_host):
|
|
"""`lifting_loops.strand_loop_half_inch` (mined M6 data, already in the
|
|
northgate layer) is NOT under `lifters.*`, the only tree
|
|
`lifter_capacity()` reads -- so it cannot reach a strand-loop family on
|
|
its own, override or no override. Confirms the gap, then confirms the
|
|
mirrored `lifters.capacity_lb` entry closes it."""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml', u'''
|
|
lifting_loops:
|
|
strand_loop_half_inch:
|
|
double: {capacity_lb: 12000, embedment_per_leg_in: 24}
|
|
lifters:
|
|
capacity_lb:
|
|
STRAND_LOOP_DOUBLE: 12000
|
|
''')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
merged = knowledge.load(None)
|
|
|
|
# the raw mined branch really is there, and really is unreachable by name
|
|
assert knowledge.get(
|
|
merged, 'lifting_loops.strand_loop_half_inch.double.capacity_lb') \
|
|
== 12000
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'lifting_loops')
|
|
assert cap is None, why # lifting_loops.* is not lifters.*
|
|
|
|
# the mirrored entry is what a real family name resolves against
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'STRAND_LOOP_DOUBLE')
|
|
assert (cap, basis) == (12000, 'direct'), why
|
|
# a plain scalar entry answers either kind the same way, same as every
|
|
# other legacy capacity_lb pin -- M6 publishes one number, not two.
|
|
cap, basis, why = knowledge.lifter_capacity(
|
|
merged, 'STRAND_LOOP_DOUBLE', kind='shear')
|
|
assert (cap, basis) == (12000, 'direct'), why
|
|
|
|
|
|
# ============================================================================
|
|
# LIFT_QL589G_8T: THE DOUBLE-COUNT GUARD, PINNED
|
|
# ============================================================================
|
|
#
|
|
# Owner, 2026-09-02, on the live EMI model, verbatim: "只有带LIFT的才是吊装
|
|
# 件。" -- only LIFT-prefixed families are lifting components. A bare
|
|
# `QL589G` family ALSO appears in the model, and the owner ruled it out by
|
|
# name, same date: "项目里面没有裸QL589,所有的QL589都在LIFT_QL589里面,只不
|
|
# 过因为加载family的方式,让你以为它存在。" -- there is no bare QL589 in the
|
|
# project; every QL589 lives inside `LIFT_QL589G_8T`, and the bare family is
|
|
# a nested/shared sub-family artifact of how that family loads, not a
|
|
# second lifter. Counting it would double-count one physical anchor as two.
|
|
#
|
|
# This section pins the guard two ways: `families.lifter_include` must
|
|
# treat the real family as a lifter and the bare artifact as NOT one
|
|
# (`is_lifter`), and `lifters.capacity_lb` -- keyed off the SAME string --
|
|
# must resolve a capacity for the real family and refuse one for the bare
|
|
# artifact (`lifter_capacity`, which does its own substring lookup and does
|
|
# not consult `is_lifter` at all).
|
|
|
|
def test_lift_include_matches_the_real_family_not_the_bare_artifact():
|
|
"""`is_lifter` is a plain substring match (`matches()` in knowledge.py):
|
|
`LIFT` has to appear somewhere in the family name, not as a prefix.
|
|
Pinned against both the real family and the artifact the owner named."""
|
|
know = {'families': {'lifter_include': ['LIFT'], 'lifter_exclude': []}}
|
|
assert knowledge.is_lifter(know, 'LIFT_QL589G_8T') is True
|
|
# the bare artifact the owner ruled out -- no `LIFT` substring in it
|
|
assert knowledge.is_lifter(know, 'QL589G') is False
|
|
|
|
|
|
def test_lift_ql589g_8t_capacity_key_does_not_leak_onto_the_bare_artifact(
|
|
fake_designspec, yaml_on_host):
|
|
"""The substring-match risk the owner's ruling exists to avoid.
|
|
|
|
`_longest_substring_match` (what `lifter_capacity` reads
|
|
`lifters.capacity_lb` through) matches ANYWHERE in the family name, not
|
|
as a prefix. A key of bare `QL589` would be a substring of BOTH
|
|
`LIFT_QL589G_8T` (the real lifter) and bare `QL589G` (the artifact
|
|
`families.lifter_include` rules out above) -- so it would silently
|
|
resolve a plant capacity for the artifact too. Keying on the full
|
|
confirmed family string closes that: `LIFT_QL589G_8T` is longer than
|
|
`QL589G` and so can never be a substring of it.
|
|
"""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml', u'''
|
|
lifters:
|
|
capacity_lb:
|
|
LIFT_QL589G_8T:
|
|
tension_lb: 13800
|
|
shear_lb: 3860
|
|
''')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
merged = knowledge.load(None)
|
|
assert merged['_plant_id'] == 'northgate'
|
|
assert knowledge.config_problems(None, merged) == []
|
|
|
|
# the real lifter resolves to the plant-pinned M8 numbers
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'LIFT_QL589G_8T')
|
|
assert (cap, basis) == (13800, 'direct'), why
|
|
cap, basis, why = knowledge.lifter_capacity(
|
|
merged, 'LIFT_QL589G_8T', kind='shear')
|
|
assert (cap, basis) == (3860, 'direct'), why
|
|
|
|
# the bare artifact must NOT pick up the same override -- no key in
|
|
# capacity_lb is a substring of it, and it has no catalogue_alias
|
|
# either, so it stands down naming both rather than resolving anything
|
|
cap, basis, why = knowledge.lifter_capacity(merged, 'QL589G')
|
|
assert (cap, basis) == (None, None), (cap, basis, why)
|
|
assert 'no catalogue alias' in why
|
|
|
|
|
|
def test_lpa4t512_capacity_key_is_the_confirmed_family_string(
|
|
fake_designspec, yaml_on_host):
|
|
"""LPA4T512's Revit family is `LPA4T512_LIFTING PIN ANCHOR` (type
|
|
`LPA4T512`), confirmed against the live EMI model, 2026-09-02.
|
|
`lifter_capacity()` is always called with the FAMILY name, not the
|
|
type, so the key has to be the family string for the override to
|
|
reach it from a real model."""
|
|
plant_dir = fake_designspec._plant_root / 'northgate'
|
|
plant_dir.mkdir()
|
|
_write(plant_dir / 'knowledge.yaml', u'''
|
|
lifters:
|
|
capacity_lb:
|
|
'LPA4T512_LIFTING PIN ANCHOR':
|
|
shear_lb: 0
|
|
tension_60_lb: 5990
|
|
tension_75_lb: 6680
|
|
tension_lb_unresolved_because: "sling angle not confirmed at this plant"
|
|
''')
|
|
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
|
u'job:\n plant: northgate\n')
|
|
merged = knowledge.load(None)
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(
|
|
merged, 'LPA4T512_LIFTING PIN ANCHOR', kind='shear')
|
|
assert (cap, basis) == (0, 'direct'), why
|
|
|
|
cap, basis, why = knowledge.lifter_capacity(
|
|
merged, 'LPA4T512_LIFTING PIN ANCHOR')
|
|
assert (cap, basis) == (None, None)
|
|
assert 'sling angle not confirmed' in why
|
|
|
|
|
|
# ============================================================================
|
|
# REBAR STOCK: per size, with "not stocked" as its own fact
|
|
# ============================================================================
|
|
#
|
|
# THE BUG THIS CLOSES. `catalogue.rebar.stock_length_ft` is one number for
|
|
# every size, and checklist 5.7 ("no bar longer than the stock it is cut
|
|
# from") compared against it. A plant that stocks #4 in 20 ft lengths would
|
|
# pass a 30 ft #4 -- a false OK on a real defect class, which is the failure
|
|
# this whole checklist exists to prevent.
|
|
#
|
|
# The per-size table is PLANT knowledge (owner, 2026-08-30) and is therefore
|
|
# never in this repository, so these tests build the tree by hand rather than
|
|
# reading a plant file. What IS shipped, and IS asserted against the real
|
|
# file, is the generic fallback and the published ASTM grade table.
|
|
|
|
|
|
def _rebar(by_size=None, generic=40.0, **rest):
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
branch = {'stock_length_ft': generic}
|
|
if by_size is not None:
|
|
branch['stock_by_size'] = by_size
|
|
branch.update(rest)
|
|
knowledge._deep_update(merged, {'catalogue': {'rebar': branch}})
|
|
return merged
|
|
|
|
|
|
PLANT_STOCK = {
|
|
'#3': {'stock_length_in': 280, 'stocked': True, 'astm': 'A615'},
|
|
'#4': {'stock_length_in': 240, 'stocked': True, 'astm': 'A615'},
|
|
'#5': {'stock_length_in': 480, 'stocked': True, 'astm': 'A615'},
|
|
'#9': {'stock_length_in': 480, 'stocked': True, 'astm': 'A615'},
|
|
'#10': {'stock_length_in': 480, 'stocked': False, 'astm': 'A615'},
|
|
}
|
|
|
|
|
|
def test_a_size_the_plant_lists_uses_that_length_and_not_the_generic_one():
|
|
"""The whole point. 20 ft #4 stock, and the check no longer measures a #4
|
|
against 40 ft."""
|
|
stock = knowledge.rebar_stock(_rebar(PLANT_STOCK), '#4')
|
|
assert stock['length_in'] == 240
|
|
assert stock['length_ft'] == 20.0
|
|
assert stock['basis'] == 'by_size'
|
|
assert stock['stocked'] is True
|
|
|
|
|
|
def test_a_stock_length_that_is_not_a_whole_number_of_feet_stays_exact():
|
|
"""23'-4" is 23.3333... ft. Stored as 280 inches, a bar cut to exactly
|
|
23'-4" is exactly its own stock length; stored as a decimal foot it is
|
|
fractionally longer and fails against itself."""
|
|
stock = knowledge.rebar_stock(_rebar(PLANT_STOCK), '#3')
|
|
assert stock['length_in'] == 280
|
|
assert stock['length_in'] == 23 * 12 + 4
|
|
|
|
|
|
def test_a_size_only_the_catalogue_covers_falls_back_and_says_it_did():
|
|
"""Not silence: the sentence names the size and says the number is not
|
|
size specific, because that is the difference between this answer and a
|
|
real one."""
|
|
stock = knowledge.rebar_stock(_rebar(PLANT_STOCK), '#7')
|
|
assert stock['basis'] == 'generic'
|
|
assert stock['length_ft'] == 40.0
|
|
assert stock['length_in'] == 480.0
|
|
assert '#7' in stock['why'] and 'not size specific' in stock['why']
|
|
|
|
|
|
def test_a_size_nothing_knows_about_makes_the_check_stand_down():
|
|
"""No per-size entry and no generic number. `basis` is None, the lengths
|
|
are None, and nothing is assumed -- which is what makes 5.7 report NOT RUN
|
|
instead of a pass it has no basis for."""
|
|
stock = knowledge.rebar_stock(_rebar(None, generic=None), '#7')
|
|
assert stock['basis'] is None
|
|
assert stock['length_in'] is None and stock['length_ft'] is None
|
|
assert 'Nothing was assumed' in stock['why']
|
|
|
|
|
|
def test_not_stocked_is_its_own_fact_and_not_a_missing_length():
|
|
"""#10 is listed at 40' AND marked not stocked. Both are true: the length
|
|
is what would arrive if it were ordered, and 'not stocked' is why a ticket
|
|
calling for one deserves a human. Modelling either as the absence of the
|
|
other loses one of them."""
|
|
stock = knowledge.rebar_stock(_rebar(PLANT_STOCK), '#10')
|
|
assert stock['stocked'] is False
|
|
assert stock['length_in'] == 480 # still known
|
|
assert stock['basis'] == 'by_size' # still a real answer
|
|
assert 'NOT STOCKED' in stock['why']
|
|
|
|
|
|
def test_a_stocked_size_and_an_unlisted_size_are_told_apart_from_not_stocked():
|
|
know = _rebar(PLANT_STOCK)
|
|
assert knowledge.rebar_stock(know, '#5')['stocked'] is True
|
|
assert knowledge.rebar_stock(know, '#10')['stocked'] is False
|
|
assert knowledge.rebar_stock(know, '#7')['stocked'] is None # unlisted
|
|
|
|
|
|
def test_a_size_that_does_not_read_as_one_is_refused_rather_than_guessed():
|
|
stock = knowledge.rebar_stock(_rebar(PLANT_STOCK), 'SR324')
|
|
assert stock['size'] is None and stock['basis'] is None
|
|
assert 'does not read as a bar size' in stock['why']
|
|
|
|
|
|
def test_bar_sizes_are_normalised_the_same_way_wherever_they_come_from():
|
|
for raw in ('#4', '4', 4, ' #4 ', 'No. 4', 'no4', '#04'):
|
|
assert knowledge.rebar_size(raw) == '#4', raw
|
|
for raw in (None, '', 'SR324', '#', 'four', '#123'):
|
|
assert knowledge.rebar_size(raw) is None, raw
|
|
|
|
|
|
def test_the_per_size_table_deep_merges_so_a_job_adds_without_deleting():
|
|
"""THE MERGE TRAP, asserted. `stock_by_size` is a map: a job that special
|
|
orders one size adds one key and the plant's other sizes survive. Had it
|
|
been a list, this would leave exactly one size in force and every check
|
|
would go on looking entirely normal."""
|
|
merged = _rebar(PLANT_STOCK)
|
|
knowledge._deep_update(merged, {'catalogue': {'rebar': {'stock_by_size': {
|
|
'#4': {'stock_length_in': 480, 'stocked': True, 'astm': 'A615'}}}}})
|
|
assert knowledge.rebar_stock(merged, '#4')['length_in'] == 480 # overridden
|
|
assert knowledge.rebar_stock(merged, '#3')['length_in'] == 280 # inherited
|
|
assert knowledge.rebar_stock(merged, '#10')['stocked'] is False # inherited
|
|
|
|
|
|
def test_a_size_listed_with_no_length_still_reports_whether_it_is_stocked():
|
|
"""An entry that says nothing about length is not a statement that the
|
|
length is unknown, so the length falls through to the generic number --
|
|
but `stocked`, which the entry DID state, is still returned."""
|
|
know = _rebar({'#6': {'stocked': False, 'astm': 'A615'}})
|
|
stock = knowledge.rebar_stock(know, '#6')
|
|
assert stock['stocked'] is False
|
|
assert stock['basis'] == 'generic'
|
|
assert stock['length_ft'] == 40.0
|
|
|
|
|
|
# ---------------------------------------------------- ASTM, and yield strength
|
|
|
|
def test_the_shipped_catalogue_publishes_the_astm_grade_table():
|
|
import io
|
|
from vibe import yamlkit
|
|
data = yamlkit.loads(
|
|
io.open(knowledge.catalogue_path(), encoding='utf-8').read())
|
|
rebar = knowledge._coerce(data['catalogue']['rebar'])
|
|
assert rebar['stock_length_ft'] == 40.0
|
|
assert rebar['astm_grades']['A706']['default_fy_ksi'] == 60
|
|
# ...and A615 has NO default, on purpose
|
|
assert rebar['astm_grades']['A615']['default_fy_ksi'] is None
|
|
assert 100 in rebar['astm_grades']['A615']['grades_ksi']
|
|
# nothing in the public layer states what a producer stocks
|
|
assert 'stock_by_size' not in rebar
|
|
|
|
|
|
def test_a706_defaults_to_60_and_says_out_loud_that_it_is_a_default():
|
|
""""A706 is 60 ksi, isn't it?" -- usually, and it is not a definition.
|
|
The current spec also defines Grade 80, so the answer comes back WITH its
|
|
basis and a default can never be read as a stated fact."""
|
|
know = _rebar(None, astm_grades={
|
|
'A706': {'grades_ksi': [60, 80], 'default_fy_ksi': 60},
|
|
'A615': {'grades_ksi': [60, 80, 100], 'default_fy_ksi': None}})
|
|
fy, basis, why = knowledge.rebar_fy(know, astm='A706')
|
|
assert (fy, basis) == (60.0, 'astm default')
|
|
assert 'DEFAULT' in why and '80 ksi' in why
|
|
|
|
|
|
def test_a615_gives_no_yield_strength_at_all_and_never_defaults_to_60():
|
|
"""A615 covers Grades 60, 80 and 100. A check that assumed 60 would be
|
|
confidently wrong on this very job, which carries a #9 at 80 ksi."""
|
|
know = _rebar(None, astm_grades={
|
|
'A615': {'grades_ksi': [60, 80, 100], 'default_fy_ksi': None}})
|
|
fy, basis, why = knowledge.rebar_fy(know, astm='A615')
|
|
assert fy is None and basis is None
|
|
assert 'does not imply one' in why and '100 ksi' in why
|
|
|
|
|
|
def test_a_stated_grade_beats_any_default_and_is_reported_as_stated():
|
|
know = _rebar(None, astm_grades={
|
|
'A706': {'grades_ksi': [60, 80], 'default_fy_ksi': 60}})
|
|
fy, basis, _why = knowledge.rebar_fy(know, astm='A706', stated_fy_ksi=80)
|
|
assert (fy, basis) == (80.0, 'stated')
|
|
|
|
|
|
def test_an_unknown_or_absent_designation_gives_nothing_and_says_so():
|
|
know = _rebar(None, astm_grades={'A706': {'default_fy_ksi': 60}})
|
|
assert knowledge.rebar_fy(know, astm='A1035')[0] is None
|
|
assert knowledge.rebar_fy(know)[0] is None
|
|
assert 'no ASTM designation' in knowledge.rebar_fy(know)[2]
|
|
|
|
|
|
def test_a_heading_named_with_nothing_under_it_is_not_the_same_as_absent():
|
|
"""A plant's stock section names A706 and the page ends there. Three
|
|
states, all distinguishable: sizes listed, named-but-empty, never
|
|
mentioned. Collapsing the middle one into either neighbour invents a
|
|
fact."""
|
|
know = _rebar(PLANT_STOCK, astm_designations={
|
|
'A615 - weldable': {'astm': 'A615', 'sizes_listed': True},
|
|
'A706': {'astm': 'A706', 'sizes_listed': False},
|
|
})
|
|
assert knowledge.rebar_astm(know, 'A615 - weldable')['sizes_listed'] is True
|
|
assert knowledge.rebar_astm(know, 'A706')['sizes_listed'] is False
|
|
assert knowledge.rebar_astm(know, 'A1035') is None
|
|
|
|
|
|
def test_a_job_declared_grade_is_an_existence_fact_with_the_astm_left_unknown():
|
|
"""A call-out reading `#9 (80ksi) x CONT.` names a STRENGTH, not a
|
|
SPECIFICATION, and both A615 and A706 define an 80 ksi grade -- so the
|
|
ASTM is recorded as unknown with a reason rather than guessed."""
|
|
know = _rebar(PLANT_STOCK, grades_present={'no9_80ksi': {
|
|
'size': '#9', 'fy_ksi': 80, 'astm': None,
|
|
'astm_unknown_because': 'the call-out states a strength',
|
|
'declared_by': 'owner'}})
|
|
rows = knowledge.rebar_grades_present(know)
|
|
assert len(rows) == 1
|
|
assert (rows[0]['size'], rows[0]['fy_ksi']) == ('#9', 80)
|
|
assert rows[0]['astm'] is None
|
|
assert rows[0]['astm_unknown_because']
|
|
# ...and it did NOT become a property of the SIZE in the stock table
|
|
assert 'fy_ksi' not in \
|
|
knowledge.get(know, 'catalogue.rebar.stock_by_size')['#9']
|
|
|
|
|
|
def test_declaring_a_job_grade_does_not_disturb_the_plants_stock_table():
|
|
"""The shape has to make "the job adds one bar" impossible to become "the
|
|
job deleted the plant's stock list"."""
|
|
merged = _rebar(PLANT_STOCK)
|
|
knowledge._deep_update(merged, {'catalogue': {'rebar': {
|
|
'grades_present': {'no9_80ksi': {'size': '#9', 'fy_ksi': 80}}}}})
|
|
assert knowledge.rebar_stock(merged, '#3')['length_in'] == 280
|
|
assert knowledge.rebar_stock(merged, '#9')['length_in'] == 480
|
|
assert len(knowledge.rebar_grades_present(merged)) == 1
|
|
|
|
|
|
def test_a_second_job_grade_is_an_addition_not_a_replacement():
|
|
merged = _rebar(PLANT_STOCK, grades_present={
|
|
'no9_80ksi': {'size': '#9', 'fy_ksi': 80}})
|
|
knowledge._deep_update(merged, {'catalogue': {'rebar': {
|
|
'grades_present': {'no5_60ksi': {'size': '#5', 'fy_ksi': 60}}}}})
|
|
assert [r['id'] for r in knowledge.rebar_grades_present(merged)] == \
|
|
['no5_60ksi', 'no9_80ksi']
|
|
|
|
|
|
# ============================================================================
|
|
# WHICH MEMBERS ARE REINFORCING: a rule, not an enumeration
|
|
# ============================================================================
|
|
#
|
|
# Checklist 5.6 and 5.7 rest entirely on this. As a LIST of family names it
|
|
# goes stale in silence -- a bar family is added, nobody re-lists it, and both
|
|
# checks report nothing on those bars, which reads exactly like nothing being
|
|
# wrong. Owner, 2026-08-30: the ones with `rebar` written in them.
|
|
#
|
|
# THE FIRST READING OF THAT WAS WRONG, and the way it was wrong is the reason
|
|
# half these tests exist. It was built as a Revit CATEGORY plus a FAMILY-NAME
|
|
# token, on a census that read `FamilySymbol` TYPES -- and this plant writes
|
|
# the value on the INSTANCE, so the census saw no bars at all and the category
|
|
# could never be verified. A parameter, read instance-first-then-type, with a
|
|
# token in its VALUE, is what the plant's own view filters already do.
|
|
|
|
|
|
def _reinf(families=None, rule=None, overrides=None, patterns=None):
|
|
merged = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
branch = {}
|
|
if families is not None:
|
|
branch['reinforcing_families'] = families
|
|
if rule is not None:
|
|
branch['reinforcing_rule'] = rule
|
|
if overrides is not None:
|
|
branch['reinforcing_family_overrides'] = overrides
|
|
if patterns is not None:
|
|
branch['reinforcing_family_patterns'] = patterns
|
|
knowledge._deep_update(merged, {'drawings': branch})
|
|
return merged
|
|
|
|
|
|
RULE = {'parameter': 'member.manufacture_component',
|
|
'parameter_verified': True,
|
|
'read_from': ['instance', 'type'],
|
|
'value_contains': ['rebar']}
|
|
|
|
BAR = 'REBAR CUSTOM REINFORCING'
|
|
NOT_BAR = 'BLOCKOUT'
|
|
|
|
|
|
def test_with_nothing_declared_the_answer_is_cannot_judge_not_no():
|
|
"""None is a real answer and it is NOT False. Reporting 'not a bar' for a
|
|
member nobody has described is how 5.6 and 5.7 went quiet in the first
|
|
place."""
|
|
know = _reinf()
|
|
mode, why = knowledge.reinforcing_mode(know)
|
|
assert mode is None
|
|
assert 'Nothing was assumed' in why
|
|
verdict, vwhy = knowledge.reinforcing_verdict(know, 'ANYTHING', BAR)
|
|
assert verdict is None
|
|
assert vwhy == why
|
|
|
|
|
|
def test_the_rule_recognises_a_family_nobody_enumerated():
|
|
"""The whole point: a family added tomorrow is a bar without an edit."""
|
|
know = _reinf(rule=RULE)
|
|
assert knowledge.reinforcing_mode(know)[0] == 'rule'
|
|
verdict, why = knowledge.reinforcing_verdict(
|
|
know, 'ACME_STRAIGHT_2027', BAR)
|
|
assert verdict is True
|
|
assert "'REBAR'" in why
|
|
|
|
|
|
def test_the_value_is_read_from_the_instance_first_then_the_type():
|
|
"""MEASURED: the parameter sits on the instance for some elements and on
|
|
the type for others, NEVER both. A reader that takes one of the two finds
|
|
nothing on half the model and returns a result that looks clean -- which
|
|
is exactly how the first census missed every bar in the job."""
|
|
know = _reinf(rule=RULE)
|
|
on_instance, why = knowledge.reinforcing_verdict(know, 'F', BAR, None)
|
|
assert on_instance is True and 'instance' in why
|
|
on_type, why = knowledge.reinforcing_verdict(know, 'F', None, BAR)
|
|
assert on_type is True and 'type' in why
|
|
# instance wins where both somehow carry one
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'F', NOT_BAR, BAR)
|
|
assert verdict is False and 'instance' in why
|
|
|
|
|
|
def test_read_from_is_honoured_when_a_layer_reverses_it():
|
|
know = _reinf(rule=dict(RULE, read_from=['type', 'instance']))
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'F', NOT_BAR, BAR)
|
|
assert verdict is True and 'type' in why
|
|
|
|
|
|
def test_the_token_is_a_substring_because_that_is_what_the_plant_does():
|
|
"""Thirteen of the plant's own view filters key on this parameter and
|
|
every string rule among them is `contains`. Equality would match none of
|
|
REBAR CUSTOM REINFORCING / STRIPPING / ERECTION."""
|
|
know = _reinf(rule=RULE)
|
|
for value in ('REBAR CUSTOM REINFORCING', 'REBAR CUSTOM STRIPPING',
|
|
'rebar custom cip'):
|
|
assert knowledge.reinforcing_verdict(know, 'F', value)[0] is True
|
|
|
|
|
|
def test_a_member_whose_value_carries_no_token_is_not_a_bar():
|
|
know = _reinf(rule=RULE)
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'EMBED_P76', NOT_BAR)
|
|
assert verdict is False
|
|
assert 'carries none of' in why
|
|
|
|
|
|
def test_a_member_with_no_value_anywhere_cannot_be_judged():
|
|
"""Not a match, and not "not a bar". Nothing read is nothing known."""
|
|
know = _reinf(rule=RULE)
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'X', None, None)
|
|
assert verdict is None
|
|
assert 'not the same as it not being a bar' in why
|
|
|
|
|
|
def test_a_rule_with_an_unverified_parameter_does_not_run_and_says_why():
|
|
"""`parameter_verified: false` is NOT 'there is no such parameter' -- it
|
|
is 'nobody has checked it against a model'. Guessing it un-recognises
|
|
every bar at once, silently, which is the exact dormancy the rule exists
|
|
to end. So it stays dormant OUT LOUD."""
|
|
know = _reinf(rule={
|
|
'parameter': 'member.manufacture_component',
|
|
'parameter_verified': False,
|
|
'parameter_unverified_because': 'never read off a live model.',
|
|
'value_contains': ['rebar']})
|
|
mode, why = knowledge.reinforcing_mode(know)
|
|
assert mode is None
|
|
assert 'member.manufacture_component' in why
|
|
assert 'never read off a live model' in why
|
|
assert knowledge.reinforcing_verdict(know, 'X', BAR)[0] is None
|
|
|
|
|
|
def test_a_token_with_no_parameter_behind_it_does_not_run():
|
|
"""A token with nothing to read it out of has nothing to match against."""
|
|
know = _reinf(rule={'parameter': None, 'parameter_verified': True,
|
|
'value_contains': ['rebar']})
|
|
mode, why = knowledge.reinforcing_mode(know)
|
|
assert mode is None
|
|
assert 'no PARAMETER' in why
|
|
|
|
|
|
def test_an_enumeration_still_works_and_still_wins_over_a_rule():
|
|
"""A plant that would rather name its families one by one must be able
|
|
to, and being overtaken by a pattern it did not write would be worse than
|
|
the pattern is worth."""
|
|
know = _reinf(families=['SR', 'TRIM_BAR'], rule=RULE)
|
|
assert knowledge.reinforcing_mode(know)[0] == 'list'
|
|
assert knowledge.reinforcing_verdict(know, 'SR324', None)[0] is True
|
|
# carrying the rule's token in its value -- and still not a bar, because
|
|
# the list is what is in force
|
|
assert knowledge.reinforcing_verdict(know, 'ACME_2027', BAR)[0] is False
|
|
|
|
|
|
def test_emptying_the_list_hands_over_to_the_rule():
|
|
know = _reinf(families=['SR'], rule=RULE)
|
|
assert knowledge.reinforcing_mode(know)[0] == 'list'
|
|
knowledge._deep_update(know, {'drawings': {'reinforcing_families': []}})
|
|
assert knowledge.reinforcing_mode(know)[0] == 'rule'
|
|
|
|
|
|
def test_a_job_adds_or_excludes_one_family_without_restating_anything():
|
|
"""The overrides map is checked FIRST and beats both the list and the
|
|
rule, so a job corrects one family without copying a pattern it would
|
|
then have to keep in step."""
|
|
know = _reinf(rule=RULE, overrides={'ODD_BAR': True,
|
|
'ACME_JIG': False})
|
|
assert knowledge.reinforcing_verdict(know, 'ODD_BAR', NOT_BAR)[0] is True
|
|
assert knowledge.reinforcing_verdict(know, 'ACME_JIG', BAR)[0] is False
|
|
# everything else still resolves by the rule
|
|
assert knowledge.reinforcing_verdict(know, 'ACME_STRAIGHT', BAR)[0] is True
|
|
|
|
|
|
def test_an_override_is_matched_exactly_and_never_as_a_substring():
|
|
"""An entry for `SR1` matched loosely would swallow `SR1101`, the same
|
|
trap drawings.called_out_on_detail already documents."""
|
|
know = _reinf(rule=RULE, overrides={'SR1': False})
|
|
assert knowledge.reinforcing_verdict(know, 'sr1', BAR)[0] is False
|
|
# SR1101 is NOT swallowed by the SR1 entry -- it falls through to the rule
|
|
# and is judged on its own value, which is what the exact match buys.
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'SR1101', BAR)
|
|
assert verdict is True
|
|
assert 'overrides' not in why
|
|
|
|
|
|
def test_the_overrides_map_deep_merges_so_the_job_keeps_the_plants():
|
|
"""A map, not a list, for exactly this: as a list the job's one
|
|
correction would replace the plant's."""
|
|
know = _reinf(rule=RULE, overrides={'PLANT_ODD': True})
|
|
knowledge._deep_update(know, {'drawings': {
|
|
'reinforcing_family_overrides': {'JOB_ODD': True}}})
|
|
assert knowledge.reinforcing_verdict(know, 'PLANT_ODD', NOT_BAR)[0] is True
|
|
assert knowledge.reinforcing_verdict(know, 'JOB_ODD', NOT_BAR)[0] is True
|
|
|
|
|
|
def test_the_list_form_still_ignores_the_parameter_as_it_always_did():
|
|
"""The enumeration is a family-name match and nothing else -- unchanged,
|
|
because a plant relying on it has not been told a parameter matters."""
|
|
know = _reinf(families=['SR'])
|
|
assert knowledge.reinforcing_verdict(know, 'SR324', None)[0] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reinforcing_family_patterns -- the SAME enumeration written as regexes,
|
|
# for a plant whose bar-family name wraps an OPEN-ENDED CODE no fixed list
|
|
# could keep up with: a name like `ACME-17_(SCHEDULED)`, `ACME-S4_
|
|
# (SCHEDULED)`, `ACME-T2_(SCHEDULED)`, where the middle code is a growing
|
|
# series. Neither a substring wide enough to catch every code (bare `ACME-`)
|
|
# nor one narrow enough to be safe alone (bare `_(SCHEDULED)`) can say both
|
|
# ends of that wrapper at once -- a regex is the only one of the existing
|
|
# mechanisms that can.
|
|
|
|
PATTERN = r'^ACME-.+_\(SCHEDULED\)$'
|
|
|
|
|
|
def test_a_pattern_recognises_the_open_ended_code_inside_a_fixed_wrapper():
|
|
know = _reinf(patterns=[PATTERN])
|
|
for fam in ('ACME-17_(SCHEDULED)', 'ACME-17A_(SCHEDULED)',
|
|
'ACME-S4_(SCHEDULED)', 'ACME-T2_(SCHEDULED)'):
|
|
verdict, why = knowledge.reinforcing_verdict(know, fam)
|
|
assert verdict is True, (fam, why)
|
|
assert 'reinforcing_family_patterns' in why
|
|
|
|
|
|
def test_a_pattern_does_not_over_match_a_bare_prefix_or_a_bare_suffix():
|
|
"""The whole reason a pattern is needed rather than one wide substring:
|
|
a family that only shares the wrapper's PREFIX (no bar) and a family
|
|
that only shares its SUFFIX (a different product, seen on a real plant
|
|
sharing exactly this `_(SCHEDULED)` tail) must both stay out."""
|
|
know = _reinf(patterns=[PATTERN])
|
|
for fam in ('ACME-CONNECTOR', 'STRAND_(SCHEDULED)', 'LIFT_HOOK_8T'):
|
|
verdict, why = knowledge.reinforcing_verdict(know, fam)
|
|
assert verdict is False, (fam, why)
|
|
|
|
|
|
def test_patterns_are_the_same_evidence_class_as_the_plain_list():
|
|
"""Either one being non-empty is 'list' mode, and list mode still wins
|
|
over the rule -- patterns are not a fourth way of knowing."""
|
|
know = _reinf(patterns=[PATTERN], rule=RULE)
|
|
assert knowledge.reinforcing_mode(know)[0] == 'list'
|
|
# carries the rule's own token and is still not a bar -- the rule is not
|
|
# consulted while list-mode evidence (patterns included) is in force
|
|
assert knowledge.reinforcing_verdict(know, 'UNRELATED', BAR)[0] is False
|
|
|
|
|
|
def test_the_plain_list_is_still_checked_before_the_patterns():
|
|
know = _reinf(families=['SR'], patterns=[PATTERN])
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'SR324')
|
|
assert verdict is True
|
|
assert 'reinforcing_families' in why
|
|
assert 'reinforcing_family_patterns' not in why
|
|
|
|
|
|
def test_a_pattern_that_does_not_compile_is_inert_not_fatal():
|
|
"""A typo in a regex must not crash the checklist -- it behaves like a
|
|
plain-list token that never appears in any family name: silently
|
|
inert, exactly the same fate, because nothing here can tell a
|
|
deliberately-inert entry from a mistake."""
|
|
know = _reinf(patterns=['(unclosed'])
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'ACME-17_(SCHEDULED)')
|
|
assert verdict is False
|
|
|
|
|
|
def test_an_override_still_beats_a_pattern():
|
|
know = _reinf(patterns=[PATTERN],
|
|
overrides={'ACME-17_(SCHEDULED)': False})
|
|
verdict, why = knowledge.reinforcing_verdict(know, 'ACME-17_(SCHEDULED)')
|
|
assert verdict is False
|
|
assert 'overrides' in why
|
|
|
|
|
|
def test_emptying_the_families_list_but_keeping_a_pattern_stays_in_list_mode():
|
|
know = _reinf(families=['SR'], patterns=[PATTERN])
|
|
knowledge._deep_update(know, {'drawings': {'reinforcing_families': []}})
|
|
assert knowledge.reinforcing_mode(know)[0] == 'list'
|
|
assert knowledge.reinforcing_verdict(know, 'ACME-S5_(SCHEDULED)')[0] is True
|
|
|
|
|
|
def test_with_no_patterns_and_no_list_a_rule_still_takes_over():
|
|
know = _reinf(rule=RULE)
|
|
assert knowledge.reinforcing_mode(know)[0] == 'rule'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PER-ROW SCOPE, and why it ships EMPTY
|
|
#
|
|
# The token is not a clean boundary in either direction and both exceptions
|
|
# are real: REBAR CUSTOM LIFTING / ERECTION carry it and are bar-formed
|
|
# HARDWARE (one is a strand loop -- no bar mark, no stock length), while
|
|
# BEARING TIE / RA EMBED CUSTOM / TRUSS CUSTOM are reinforcement and carry no
|
|
# token at all. Whether 5.6 and 5.7 want the same population is an open
|
|
# question with the owner, so the lists are empty and the behaviour with them
|
|
# empty is today's behaviour exactly.
|
|
|
|
|
|
def test_with_no_scope_declared_every_checklist_row_sees_one_population():
|
|
know = _reinf(rule=RULE)
|
|
for item in (None, '5.6', '5.7'):
|
|
assert knowledge.reinforcing_verdict(
|
|
know, 'F', 'REBAR CUSTOM LIFTING', item=item)[0] is True
|
|
assert knowledge.reinforcing_verdict(
|
|
know, 'F', 'BEARING TIE', item=item)[0] is False
|
|
|
|
|
|
def test_the_shipped_scopes_are_empty_and_say_why_they_are_empty():
|
|
"""Dormant out loud, not dormant by omission. An empty scope with no
|
|
reason beside it is indistinguishable from nobody having thought about
|
|
it -- and somebody has."""
|
|
rule = knowledge.DEFAULTS['drawings']['reinforcing_rule']
|
|
assert rule['scopes'] == {}
|
|
assert 'scopes_unresolved_because' in rule
|
|
|
|
|
|
def test_one_row_can_exclude_bar_formed_hardware_the_other_still_counts():
|
|
"""The shape has to be able to express the open question, or answering it
|
|
later means a code change instead of an edit."""
|
|
know = _reinf(rule=dict(RULE, scopes={
|
|
'5.7': {'except': ['REBAR CUSTOM LIFTING']}}))
|
|
excluded, why = knowledge.reinforcing_verdict(
|
|
know, 'F', 'REBAR CUSTOM LIFTING', item='5.7')
|
|
assert excluded is False and '5.7' in why
|
|
assert knowledge.reinforcing_verdict(
|
|
know, 'F', 'REBAR CUSTOM LIFTING', item='5.6')[0] is True
|
|
|
|
|
|
def test_one_row_can_admit_reinforcement_that_carries_no_token():
|
|
know = _reinf(rule=dict(RULE, scopes={
|
|
'5.6': {'also': ['BEARING TIE', 'TRUSS CUSTOM']}}))
|
|
admitted, why = knowledge.reinforcing_verdict(
|
|
know, 'F', 'BEARING TIE', item='5.6')
|
|
assert admitted is True and 'even though it carries no token' in why
|
|
assert knowledge.reinforcing_verdict(
|
|
know, 'F', 'BEARING TIE', item='5.7')[0] is False
|
|
|
|
|
|
def test_an_exclusion_beats_the_token_and_an_addition_beats_its_absence():
|
|
"""Order matters: the scope is a CORRECTION to the token test, so it has
|
|
to be read before it, not after."""
|
|
know = _reinf(rule=dict(RULE, scopes={
|
|
'5.7': {'also': ['BEARING TIE'], 'except': ['REBAR CUSTOM LIFTING']}}))
|
|
assert knowledge.reinforcing_verdict(
|
|
know, 'F', 'REBAR CUSTOM LIFTING', item='5.7')[0] is False
|
|
assert knowledge.reinforcing_verdict(
|
|
know, 'F', 'BEARING TIE', item='5.7')[0] is True
|
|
|
|
|
|
def test_the_parameter_role_is_declared_and_optional():
|
|
"""Declared so Self Check can report it unmapped and a starter offers it;
|
|
OPTIONAL so a plant that does not carry it is not nagged on every run --
|
|
the rule stands down with its own sentence instead."""
|
|
assert 'member.manufacture_component' in knowledge.PARAMETER_ROLES
|
|
assert 'member.manufacture_component' in knowledge.OPTIONAL_ROLES
|
|
|
|
|
|
def test_the_repository_names_the_role_and_never_the_parameter():
|
|
"""The role indirection is what keeps one office's parameter name out of
|
|
this repository. DEFAULTS names a ROLE; the plant layer maps it."""
|
|
rule = knowledge.DEFAULTS['drawings']['reinforcing_rule']
|
|
assert rule['parameter'] == 'member.manufacture_component'
|
|
assert knowledge.get(knowledge.DEFAULTS,
|
|
'parameters.member.manufacture_component') is None
|
|
|
|
|
|
# ============================================================================
|
|
# The plant's STATED stock material, and the job-level exception
|
|
# ============================================================================
|
|
#
|
|
# Owner, 2026-08-30: the stock is A706, 60 ksi, and A615 is gone in practice
|
|
# -- "如果又有了,那也是特例了,应该是 job 级别知识". So the material is stated
|
|
# once for the plant, the stock lengths are NOT bound to a designation any
|
|
# more, and an odd bar is a job-layer addition that must not wipe the plant's
|
|
# table.
|
|
|
|
MATERIAL = {'astm': 'A706', 'fy_ksi': 60, 'basis': 'stated',
|
|
'declared_by': 'Will'}
|
|
|
|
GRADES = {'A706': {'grades_ksi': [60, 80], 'default_fy_ksi': 60},
|
|
'A615': {'grades_ksi': [60, 80, 100], 'default_fy_ksi': None}}
|
|
|
|
# The stock rows as they are now: lengths and stocked flags, NO astm. Binding
|
|
# a live stock length to a retired designation is how a stale label outlives
|
|
# the page it came from.
|
|
STOCK = {
|
|
'#3': {'stock_length_in': 280, 'stocked': True},
|
|
'#4': {'stock_length_in': 240, 'stocked': True},
|
|
'#9': {'stock_length_in': 480, 'stocked': True},
|
|
'#10': {'stock_length_in': 480, 'stocked': False},
|
|
}
|
|
|
|
|
|
def test_the_plants_stated_stock_material_answers_the_grade():
|
|
know = _rebar(STOCK, stock_material=MATERIAL, astm_grades=GRADES)
|
|
fy, basis, why = knowledge.rebar_fy(know)
|
|
assert (fy, basis) == (60.0, 'plant stock')
|
|
assert 'A706' in why and 'stated fact' in why
|
|
|
|
|
|
def test_a_stated_plant_fact_outranks_the_astm_default_for_the_same_number():
|
|
"""Same 60, different strength of claim. "This plant stocks A706 Grade
|
|
60" is an assertion about the plant; "A706 usually means Grade 60" is
|
|
what the designation tends to mean. The basis is what keeps them apart,
|
|
and a stated fact must never be quietly downgraded to a default."""
|
|
know = _rebar(STOCK, stock_material=MATERIAL, astm_grades=GRADES)
|
|
assert knowledge.rebar_fy(know, astm='A706')[1] == 'plant stock'
|
|
# ...and with no stock material stated, the same question falls back to
|
|
# the published default, and says so
|
|
bare = _rebar(STOCK, astm_grades=GRADES)
|
|
assert knowledge.rebar_fy(bare, astm='A706')[1] == 'astm default'
|
|
|
|
|
|
def test_asking_about_another_designation_is_a_different_question():
|
|
"""A615 is a job-level exception at this plant now. Asking about it must
|
|
not be answered with the plant's A706 stock grade -- and A615 publishes
|
|
no default, so the honest answer is that nothing is known."""
|
|
know = _rebar(STOCK, stock_material=MATERIAL, astm_grades=GRADES)
|
|
fy, basis, why = knowledge.rebar_fy(know, astm='A615')
|
|
assert fy is None and basis is None
|
|
assert 'does not imply one' in why and '100 ksi' in why
|
|
|
|
|
|
def test_a_size_row_inherits_the_plants_material_and_says_where_it_came_from():
|
|
"""The rows carry no astm -- what the stock is made of is stated once.
|
|
`astm_basis` is what makes an inherited designation distinguishable from
|
|
one a row overrode."""
|
|
know = _rebar(STOCK, stock_material=MATERIAL)
|
|
stock = knowledge.rebar_stock(know, '#4')
|
|
assert stock['astm'] == 'A706'
|
|
assert stock['astm_basis'] == 'plant stock'
|
|
assert stock['length_in'] == 240
|
|
|
|
|
|
def test_a_job_can_add_a_bar_the_plant_does_not_stock_without_wiping_it():
|
|
"""The concrete case behind the map-not-list choice: an A615 bar turns
|
|
up, it is a job-level exception, and declaring it must not delete the
|
|
plant's stock table."""
|
|
merged = _rebar(STOCK, stock_material=MATERIAL, astm_grades=GRADES)
|
|
knowledge._deep_update(merged, {'catalogue': {'rebar': {
|
|
'stock_by_size': {'#5': {'stock_length_in': 480, 'stocked': True,
|
|
'astm': 'A615',
|
|
'note': 'special order, this job only'}}}}})
|
|
# the exception is in force, and says the designation is its OWN, not the
|
|
# plant's
|
|
five = knowledge.rebar_stock(merged, '#5')
|
|
assert five['astm'] == 'A615'
|
|
assert five['astm_basis'] == 'by_size'
|
|
# and every plant size survived
|
|
assert knowledge.rebar_stock(merged, '#3')['length_in'] == 280
|
|
assert knowledge.rebar_stock(merged, '#4')['length_in'] == 240
|
|
assert knowledge.rebar_stock(merged, '#10')['stocked'] is False
|
|
assert knowledge.rebar_stock(merged, '#4')['astm'] == 'A706'
|
|
|
|
|
|
def test_a_superseded_heading_is_provenance_and_not_a_live_fact():
|
|
"""A706 was NAMED WITH NOTHING UNDER IT on the page, and the owner's
|
|
correction is that the manual was out of date, not that the plant has no
|
|
A706. That is exactly why `sizes_listed: false` had to mean "nothing is
|
|
known" and never "the plant has none" -- read the second way it would
|
|
have been wrong."""
|
|
know = _rebar(STOCK, stock_material=MATERIAL, astm_designations={
|
|
'A615 - weldable': {'astm': 'A615', 'sizes_listed': True,
|
|
'superseded': True},
|
|
'A706': {'astm': 'A706', 'sizes_listed': False, 'superseded': True}})
|
|
a706 = knowledge.rebar_astm(know, 'A706')
|
|
assert a706['sizes_listed'] is False # the page listed no sizes
|
|
assert a706['superseded'] is True # and the page is out of date
|
|
# ...while the live answer, from stock_material, is that A706 IS stocked
|
|
assert knowledge.rebar_fy(know)[0] == 60.0
|
|
assert knowledge.rebar_stock(know, '#3')['astm'] == 'A706'
|
|
|
|
|
|
# ============================================================================
|
|
# Near/far anchored to the FORM, and two names for one face
|
|
# ============================================================================
|
|
#
|
|
# Correction, 2026-08-30. The published `near_far` vocabulary is the
|
|
# VIEW-anchored reading and is a real convention, but it is not universal:
|
|
# one plant's N.F. IS top-in-form. So which anchor a producer uses is plant
|
|
# knowledge, the frame field is what records it, and a face's identity is the
|
|
# pair (frame, side) -- which is how two vocabularies can name one face
|
|
# without either mentioning the other.
|
|
|
|
FORM_NEAR_FAR = {
|
|
'frame': 'form',
|
|
'measured_from': 'the casting bed',
|
|
'tokens': [{'token': 'N.F.', 'means': 'near face', 'side': 'top'},
|
|
{'token': 'F.F.', 'means': 'far face', 'side': 'bottom'},
|
|
{'token': 'E.L.', 'means': 'each layer', 'side': 'both'}],
|
|
}
|
|
|
|
|
|
def test_a_plant_may_anchor_near_far_to_the_form_instead_of_the_view():
|
|
know = _know(['near_far_in_form', 'in_form'],
|
|
extra_vocabularies={'near_far_in_form': FORM_NEAR_FAR})
|
|
near = knowledge.face_designation(know, 'N.F.')
|
|
assert (near['frame'], near['side']) == ('form', 'top')
|
|
assert knowledge.face_designation_problems(know) == []
|
|
|
|
|
|
def test_two_tokens_with_the_same_frame_and_side_are_the_same_face():
|
|
"""The equivalence is structural, not a comment: N.F. is (form, top) and
|
|
T.I.F. is (form, top), so they are one face and nothing had to be told
|
|
so."""
|
|
know = _know(['near_far_in_form', 'in_form'],
|
|
extra_vocabularies={'near_far_in_form': FORM_NEAR_FAR})
|
|
assert knowledge.same_face(know, 'N.F.', 'T.I.F.') is True
|
|
assert knowledge.same_face(know, 'F.F.', 'B.I.F.') is True
|
|
assert knowledge.same_face(know, 'N.F.', 'B.I.F.') is False
|
|
|
|
|
|
def test_the_view_anchored_vocabulary_is_a_different_face_question_entirely():
|
|
"""With the published, view-anchored near_far selected, N.F. is (view,
|
|
near) and T.I.F. is (form, top). Two frames, so they are not the same
|
|
face -- and that is the right answer, not a bug: the question has no
|
|
answer until something resolves the view against the form."""
|
|
know = _know(['near_far', 'in_form'])
|
|
near = knowledge.face_designation(know, 'N.F.')
|
|
assert (near['frame'], near['side']) == ('view', 'near')
|
|
assert knowledge.same_face(know, 'N.F.', 'T.I.F.') is False
|
|
|
|
|
|
def test_same_face_says_it_cannot_tell_rather_than_saying_no():
|
|
"""An unknown abbreviation is not "those are different faces"."""
|
|
know = _know(['in_form'])
|
|
assert knowledge.same_face(know, 'N.F.', 'T.I.F.') is None
|
|
assert knowledge.same_face(know, 'ZZZ', 'T.I.F.') is None
|
|
|
|
|
|
def test_a_side_that_does_not_belong_to_its_frame_is_reported():
|
|
"""The half-edit: flip a published vocabulary's frame to `form` and leave
|
|
`side: near` behind, and you get a vocabulary that parses, resolves, and
|
|
answers nonsense."""
|
|
know = _know(['bad'], extra_vocabularies={'bad': {
|
|
'frame': 'form',
|
|
'tokens': [{'token': 'N.F.', 'means': 'near face', 'side': 'near'}]}})
|
|
problems = knowledge.face_designation_problems(know)
|
|
assert len(problems) == 1
|
|
assert "'near'" in problems[0] and "'form'" in problems[0]
|
|
|
|
|
|
def test_the_shipped_catalogue_publishes_the_sides_each_frame_can_name():
|
|
branch = _catalogue_face_designations()
|
|
assert set(branch['frames']['view']['sides']) == {'near', 'far', 'both'}
|
|
assert set(branch['frames']['form']['sides']) == {'top', 'bottom', 'side',
|
|
'both'}
|
|
# every published token's side belongs to its own vocabulary's frame
|
|
for vocab_id, vocab in branch['vocabularies'].items():
|
|
allowed = branch['frames'][vocab['frame']]['sides']
|
|
for entry in vocab['tokens']:
|
|
assert entry['side'] in allowed, (vocab_id, entry['token'])
|
|
|
|
|
|
def test_a_frame_published_as_a_plain_sentence_is_tolerated_not_flagged():
|
|
"""An older plant file may carry `frames: {form: "a sentence"}`. It
|
|
publishes no side list, so the side check stands down rather than calling
|
|
every side invalid -- an older file must not turn into a page of
|
|
findings."""
|
|
know = _know(['in_form'])
|
|
knowledge._deep_update(know, {'face_designations': {'frames': {
|
|
'form': 'Relative to the casting bed.'}}})
|
|
assert knowledge.face_designation_problems(know) == []
|
|
|
|
|
|
# ==========================================================================
|
|
# WHICH END OF A PIECE IS UP ONCE ERECTED (owner ruling, 2026-08-30)
|
|
# ==========================================================================
|
|
#
|
|
# "哪头朝上取决于 assembly origin。也就是 TOT。写入公共知识。" -- which end is
|
|
# up comes from the ASSEMBLY ORIGIN, and it is PUBLIC knowledge.
|
|
#
|
|
# Two things are pinned below and they are different in kind:
|
|
#
|
|
# * the PLACEMENT -- the rule ships in `knowledge/catalogue.yaml`, the layer
|
|
# that identifies nobody, and NOT in a plant file. That placement is the
|
|
# substance of the ruling: a producer does not have to declare how a
|
|
# precast assembly is built.
|
|
# * the DORMANCY -- the half of the rule that needs a live model is null,
|
|
# unverified, and says why. It is the `drawings.reinforcing_rule.category`
|
|
# idiom, deliberately reused rather than reinvented, and the tests below
|
|
# exist so that the day somebody fills it in they are told what changed.
|
|
|
|
|
|
def _catalogue_orientation():
|
|
"""The shipped public layer, read the way `_merge_file` reads it."""
|
|
import io
|
|
from vibe import knowledge as K, yamlkit
|
|
data = yamlkit.loads(
|
|
io.open(K.catalogue_path(), encoding='utf-8').read())
|
|
return K._coerce(data['orientation'])
|
|
|
|
|
|
def test_the_up_end_rule_ships_in_the_public_layer_not_a_plant_file():
|
|
"""The ruling is that this is PUBLIC. If it ever moves to a plant file,
|
|
every plant has to declare a fact about precast assemblies in general --
|
|
which is the shape the two deleted `orientation.*` keys had."""
|
|
branch = _catalogue_orientation()
|
|
assert branch['up_end']['source'] == knowledge.UP_END_SOURCE
|
|
assert branch['up_end']['also_called'] == 'TOT'
|
|
assert branch['up_end']['stated_on'] == '2026-08-30'
|
|
|
|
|
|
def test_the_shipped_rule_is_live_and_names_what_it_reads():
|
|
"""It shipped dormant for as long as nobody had read an origin, and went
|
|
live as DATA: `origin_up` and the verified flag, no code change. That is
|
|
the whole return on having written it as a shape rather than an `if`."""
|
|
up_end = _catalogue_orientation()['up_end']
|
|
assert up_end['origin_up'] == 'BasisY'
|
|
assert up_end['origin_up_verified'] is True
|
|
assert up_end['origin_up_verified_on'] == '2026-08-30'
|
|
# The words the owner used survive alongside the measurement, because the
|
|
# measurement is what CONFIRMED them, not what replaced them.
|
|
assert up_end['origin_up_as_stated']
|
|
|
|
|
|
def test_the_colour_mapping_is_two_separate_frames_not_one_widened_rule():
|
|
"""2026-09-01: 'horizontal panel跟vertical panel,三个箭头的方向本来就
|
|
是不一样的' -- the owner separated this into two frames rather than
|
|
widening one. also_seen_as carries both, and it lives in its own field
|
|
and never in the sentence that says what origin_up measured."""
|
|
up_end = _catalogue_orientation()['up_end']
|
|
also = up_end['also_seen_as']
|
|
assert set(also) == {'horizontal_panels', 'vertical_wall_panels'}
|
|
assert 'colour' not in (up_end.get('origin_up') or '')
|
|
|
|
horiz = also['horizontal_panels']
|
|
assert horiz['green']['axis'] == 'BasisY'
|
|
assert horiz['purple']['axis'] == 'BasisZ'
|
|
assert horiz['red']['axis'] == 'BasisX'
|
|
assert 'confirmed' in horiz['status']
|
|
|
|
vert = also['vertical_wall_panels']
|
|
assert vert['green']['axis'] == 'BasisY'
|
|
assert vert['green']['confirmed'] is True
|
|
assert 'world up' in vert['green']['means']
|
|
# Purple and red are a stated GAP, not an inferred borrow from the
|
|
# horizontal frame -- the owner's own words are that the arrows differ
|
|
# between the two frames to begin with.
|
|
assert vert['purple']['confirmed'] is False
|
|
assert vert['purple']['axis'] is None
|
|
assert vert['red']['confirmed'] is False
|
|
assert vert['red']['axis'] is None
|
|
assert vert['purple']['open_question']
|
|
assert vert['red']['open_question']
|
|
|
|
|
|
def test_up_end_rule_runs_on_the_shipped_catalogue():
|
|
source, why = knowledge.up_end_rule(
|
|
{'orientation': _catalogue_orientation()})
|
|
assert source == knowledge.UP_END_SOURCE
|
|
assert 'TOT' in why and 'BasisY' in why
|
|
|
|
|
|
def test_up_end_rule_goes_back_to_sleep_if_the_flag_is_cleared():
|
|
"""The verified flag is load-bearing in both directions -- clearing it
|
|
must stand the rule down, or it is decoration."""
|
|
branch = _catalogue_orientation()
|
|
branch['up_end']['origin_up_verified'] = False
|
|
source, why = knowledge.up_end_rule({'orientation': branch})
|
|
assert source is None
|
|
assert 'origin_up_verified is false' in why
|
|
|
|
|
|
def test_a_stated_but_unverified_origin_property_still_stands_down():
|
|
"""Writing down what somebody said is not the same as checking it, and
|
|
the flag is what keeps them apart. Reversed, this is how a guess about a
|
|
Revit property would quietly become the rule."""
|
|
branch = _catalogue_orientation()
|
|
branch['up_end']['origin_up'] = 'the red axis, probably'
|
|
branch['up_end']['origin_up_verified'] = False
|
|
source, why = knowledge.up_end_rule({'orientation': branch})
|
|
assert source is None
|
|
assert 'the red axis, probably' in why
|
|
assert 'origin_up_verified is false' in why
|
|
|
|
|
|
def test_a_layer_may_not_invent_a_mechanism_this_tool_does_not_implement():
|
|
"""A higher layer may override the ANSWER; it may not name a different
|
|
MECHANISM, because a mechanism is code. An unimplemented source stands
|
|
down and quotes itself back rather than falling through to a guess."""
|
|
source, why = knowledge.up_end_rule(
|
|
{'orientation': {'up_end': {'source': 'whichever_end_is_higher',
|
|
'origin_up': 'anything',
|
|
'origin_up_verified': True}}})
|
|
assert source is None
|
|
assert 'whichever_end_is_higher' in why
|
|
assert knowledge.UP_END_SOURCE in why
|
|
|
|
|
|
def test_nothing_declared_is_not_a_licence_to_assume():
|
|
for know in ({}, {'orientation': {}}, {'orientation': {'up_end': {}}}):
|
|
source, why = knowledge.up_end_rule(know)
|
|
assert source is None
|
|
assert why
|
|
|
|
|
|
def test_the_two_superseded_orientation_keys_are_not_declared_anywhere():
|
|
"""`top_is_higher_global_z` and `top_end_families` were deleted, not
|
|
renamed. They asked a producer to declare a fact about precast in
|
|
general, and the second was an inert cross-check with no check -- the
|
|
exact shape D3 flagged. A default reintroducing either would put the
|
|
question back."""
|
|
assert 'orientation' not in knowledge.DEFAULTS
|
|
branch = _catalogue_orientation()
|
|
assert 'top_is_higher_global_z' not in branch
|
|
assert 'top_end_families' not in branch
|
|
assert set(branch) == {'up_end'}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# ... and the three ways the origin is wrong on real pieces.
|
|
#
|
|
# Every one of these is a piece in front of us, not a hypothetical. The
|
|
# refusals are the reason the rule needed code at all -- turning it ON needed
|
|
# none.
|
|
|
|
UP = (0.0, 0.0, 1.0)
|
|
|
|
|
|
def _live_orientation():
|
|
return {'orientation': _catalogue_orientation()}
|
|
|
|
|
|
def test_the_origin_answers_a_clean_horizontal_piece():
|
|
ok, why = knowledge.up_end_from_origin(
|
|
_live_orientation(), UP, 'width', 30.0, 10.0, label='PNL101')
|
|
assert ok is True
|
|
assert 'PNL101' in why and 'BasisY' in why
|
|
|
|
|
|
def test_an_origin_that_does_not_stand_the_piece_up_is_refused_by_name():
|
|
"""One assembly in 282 has its up basis pointing sideways and its FACE
|
|
NORMAL up instead. Clamping to the nearest axis is exactly how a single
|
|
odd piece becomes a silently mis-erected one, so this asserts."""
|
|
ok, why = knowledge.up_end_from_origin(
|
|
_live_orientation(), (0.0, 1.0, 0.0), 'width', 30.0, 10.0,
|
|
label='PNL371')
|
|
assert ok is None # never False -- see the docstring
|
|
assert 'PNL371' in why
|
|
assert 'does not stand the piece up' in why
|
|
|
|
|
|
def test_a_near_square_piece_declines_rather_than_picking_a_side():
|
|
"""11.58 x 11.63 and 10.71 x 11.63 are real; every clean case is 1.7 and
|
|
up. In that band long-versus-up is a coin toss."""
|
|
for long_ft, short_ft in ((11.63, 11.58), (11.63, 10.71)):
|
|
ok, why = knowledge.up_end_from_origin(
|
|
_live_orientation(), UP, 'width', long_ft, short_ft, label='PNL127')
|
|
assert ok is None
|
|
assert 'near square' in why
|
|
|
|
|
|
def test_the_declining_band_sits_clear_of_both_edges_of_the_evidence():
|
|
"""1.25 was chosen, not inherited: above the near-square pair (1.00, 0.92)
|
|
and below the tightest real horizontal piece (1.7). A test, so moving it
|
|
is a decision somebody has to make on purpose."""
|
|
assert _catalogue_orientation()['up_end']['near_square_ratio'] == 1.25
|
|
know = _live_orientation()
|
|
assert knowledge.up_end_from_origin(know, UP, 'width', 1.24, 1.0)[0] is None
|
|
assert knowledge.up_end_from_origin(know, UP, 'width', 1.7, 1.0)[0] is True
|
|
|
|
|
|
def test_a_vertical_panel_stands_the_rule_down_rather_than_assuming_it():
|
|
"""The frame is confirmed for horizontal panels. The owner has said
|
|
vertical ones work differently and has not yet said how; standing down
|
|
costs one fallback, assuming costs an erection."""
|
|
ok, why = knowledge.up_end_from_origin(
|
|
_live_orientation(), UP, 'length', 30.0, 10.0, label='PNL1')
|
|
assert ok is None
|
|
assert 'HORIZONTAL panels only' in why
|
|
assert 'PNL1' in why
|
|
|
|
|
|
def test_a_piece_with_no_readable_dimensions_declines():
|
|
for spans in ((None, 10.0), (30.0, 0.0), (30.0, None)):
|
|
ok, why = knowledge.up_end_from_origin(
|
|
_live_orientation(), UP, 'width', spans[0], spans[1])
|
|
assert ok is None and why
|
|
|
|
|
|
def test_up_end_from_origin_never_answers_false():
|
|
"""False would have to mean "the origin says the piece is upside down",
|
|
and nothing here can mean that. A caller reading a refusal as False puts
|
|
the anchors on the wrong edge -- the exact failure this all guards."""
|
|
know = _live_orientation()
|
|
for args in ((UP, 'width', 30.0, 10.0),
|
|
((0.0, 1.0, 0.0), 'width', 30.0, 10.0),
|
|
(UP, 'length', 30.0, 10.0),
|
|
(UP, 'width', 11.63, 11.58),
|
|
(UP, 'thickness', 30.0, 10.0),
|
|
(UP, None, 30.0, 10.0),
|
|
((0.0, 0.0, 0.0), 'width', 30.0, 10.0),
|
|
(None, 'width', 30.0, 10.0)):
|
|
ok, why = knowledge.up_end_from_origin(know, *args)
|
|
assert ok is True or ok is None, args
|
|
assert why, args
|
|
|
|
|
|
def test_the_origin_point_question_is_recorded_rather_than_decided():
|
|
"""`BasisY` is asserted to be world up before it is used, so where the
|
|
rule RUNS it agrees with the fallback by construction -- its value on
|
|
those pieces is the three refusals, not an independent signal. The owner
|
|
said the origin has an up END in it; BasisY is a DIRECTION. Whether
|
|
Transform.Origin also SITS AT that end has not been asked or measured, and
|
|
an unasked question left null with a reason is not the same as an answer
|
|
of no."""
|
|
up_end = _catalogue_orientation()['up_end']
|
|
assert up_end['origin_point_meaning'] is None
|
|
assert up_end['origin_point_unverified_because']
|
|
|
|
|
|
# ============================================================================
|
|
# THE FACE THE MODEL RECORDS, as against the face the TICKET prints
|
|
# ============================================================================
|
|
#
|
|
# Landed 2026-08-31 on two censuses -- 282 panels / 38,662 members in the
|
|
# model, and 180 real tickets on paper. A face designation has TWO carriers
|
|
# and 5.3 asks whether they agree, so the model-side half had to exist before
|
|
# the check could. It is a String parameter on the element INSTANCE.
|
|
#
|
|
# What these tests defend is the shape of the join, and there are four things
|
|
# in it that a simpler design would have got wrong:
|
|
#
|
|
# * the join is a SEPARATE KEY, not an alias on a published token. `tokens`
|
|
# is a list and lists replace, so an alias would replace the whole
|
|
# published vocabulary -- the half-edit `face_designation_problems`
|
|
# already refuses.
|
|
# * it reads the INSTANCE only. The same part code carries different tokens
|
|
# on different sheets, so a type read is wrong, not weaker.
|
|
# * a MODIFIER stacks after a face token in the same call-out and names no
|
|
# face at all. A reader expecting one parenthesised group mis-parses it.
|
|
# * absence is never a value, on either side.
|
|
|
|
|
|
def _model_know(model_tokens, use=None, extra_vocabularies=None, **extra):
|
|
know = _know(['near_far', 'in_form'] if use is None else use,
|
|
extra_vocabularies=extra_vocabularies)
|
|
branch = {'model_tokens': model_tokens}
|
|
branch.update(extra)
|
|
knowledge._deep_update(know, {'face_designations': branch})
|
|
knowledge._deep_update(
|
|
know, {'parameters': {'member': {'face_designation': 'A_PARAM'}}})
|
|
return know
|
|
|
|
|
|
def test_a_model_value_resolves_to_a_printed_designation_with_its_frame():
|
|
know = _model_know({'FF': 'F.F.', 'SIF': 'S.I.F.'})
|
|
got = knowledge.face_from_model_token(know, 'FF')
|
|
assert got['token'] == 'F.F.'
|
|
assert (got['face']['frame'], got['face']['side']) == ('view', 'far')
|
|
assert got['why']
|
|
|
|
|
|
def test_the_model_value_is_matched_case_and_whitespace_insensitively():
|
|
know = _model_know({'FF': 'F.F.'})
|
|
for raw in ('FF', 'ff', ' Ff ', '\tFF\n'):
|
|
assert knowledge.face_from_model_token(know, raw)['token'] == 'F.F.'
|
|
|
|
|
|
def test_the_face_is_read_off_the_instance_and_never_off_the_type():
|
|
"""Not a default that could be widened later. Measured over 180 tickets,
|
|
the SAME part code carries different face tokens on different sheets --
|
|
24 codes do -- so the face is a property of the placement. A type read
|
|
collapses distinct facts onto one answer and looks clean doing it."""
|
|
assert knowledge.face_read_from(_know([])) == ['instance']
|
|
know = _model_know({'FF': 'F.F.'}, read_from=['instance'])
|
|
assert knowledge.face_read_from(know) == ['instance']
|
|
|
|
|
|
def test_an_element_with_no_value_is_a_fact_about_the_element():
|
|
"""Blank on the kinds that do not use the field -- that is not a broken
|
|
mapping and the sentence must not send anyone to the config."""
|
|
know = _model_know({'FF': 'F.F.'})
|
|
for raw in (None, '', ' '):
|
|
got = knowledge.face_from_model_token(know, raw)
|
|
assert got['face'] is None and got['token'] is None
|
|
assert 'not a face' in got['why']
|
|
|
|
|
|
def test_a_blank_is_told_apart_from_a_field_that_does_not_apply():
|
|
"""Two blanks, two sentences. Within the kinds that record a face at all
|
|
the model writes one explicitly 163 times AND still holds 1,304 blanks --
|
|
if blank meant near face, near face would never need writing."""
|
|
know = _model_know({'FF': 'F.F.'}, recorded_on=['EMBED'])
|
|
unknown = knowledge.face_from_model_token(know, '', component='EMBED 4T')
|
|
assert 'UNKNOWN' in unknown['why'] and 'not near' in unknown['why']
|
|
na = knowledge.face_from_model_token(know, '', component='REBAR CUSTOM')
|
|
assert 'does not apply' in na['why']
|
|
assert unknown['face'] is None and na['face'] is None
|
|
|
|
|
|
def test_which_kinds_record_a_face_ships_empty_and_says_why():
|
|
"""Empty is "nobody has written it down", never "no kind records one" --
|
|
and with nothing there a blank cannot be told from a field that does not
|
|
apply, which the sentence has to admit rather than pick one."""
|
|
know = _model_know({'FF': 'F.F.'})
|
|
kinds, why = knowledge.face_recorded_on(know)
|
|
assert kinds == [] and why
|
|
got = knowledge.face_from_model_token(know, '', component='EMBED 4T')
|
|
assert got['face'] is None
|
|
assert 'not a face' in got['why']
|
|
assert why in got['why'] # the reason travels into the sentence
|
|
|
|
|
|
def test_lifting_hardware_with_no_face_is_its_own_stand_down():
|
|
"""Not a rare corner: the ticket prints the near-face token more than any
|
|
other, and the part those call-outs most often name is the face lifter --
|
|
blank in the model on every one of its instances. A part that is on a
|
|
face by definition, with a model that does not say which, is evidence to
|
|
decline on and never a disagreement."""
|
|
know = _model_know({'FF': 'F.F.'})
|
|
knowledge._deep_update(know, {'families': {'lifter_include': ['XLIFT']}})
|
|
got = knowledge.face_from_model_token(know, '', family='SAMPLE_XLIFT_4T')
|
|
assert got['face'] is None
|
|
assert 'lifting hardware' in got['why'] and 'Stand down' in got['why']
|
|
|
|
|
|
def test_a_value_nobody_mapped_is_refused_and_the_known_values_are_named():
|
|
"""A face vocabulary has no "other", so a guessed token resolves silently
|
|
to a face nobody meant. Refuse, and name what IS known."""
|
|
know = _model_know({'FF': 'F.F.', 'SIF': 'S.I.F.'})
|
|
got = knowledge.face_from_model_token(know, 'ZZ')
|
|
assert got['face'] is None and got['token'] is None
|
|
assert "'ZZ'" in got['why'] and 'FF, SIF' in got['why']
|
|
|
|
|
|
def test_no_mapping_at_all_stands_down_rather_than_reading_the_raw_value():
|
|
"""The raw value is not a printed token: `FF` is not `F.F.`, and
|
|
resolving it by luck at one plant is how it resolves wrongly at the
|
|
next."""
|
|
know = _know(['near_far', 'in_form'])
|
|
got = knowledge.face_from_model_token(know, 'FF')
|
|
assert got['face'] is None and got['token'] is None
|
|
assert 'model_tokens' in got['why']
|
|
assert knowledge.face_model_tokens(know) == {}
|
|
|
|
|
|
def test_a_mapping_onto_a_token_no_selected_vocabulary_carries_is_reported():
|
|
know = _model_know({'XX': 'Z.Z.'})
|
|
got = knowledge.face_from_model_token(know, 'XX')
|
|
assert got['token'] == 'Z.Z.' and got['face'] is None
|
|
assert 'disagree' in got['why']
|
|
problems = knowledge.face_designation_problems(know)
|
|
assert len(problems) == 1 and "'Z.Z.'" in problems[0]
|
|
|
|
|
|
def test_mapping_model_values_while_selecting_nothing_is_reported_too():
|
|
"""Selecting no vocabulary is normally silent. Saying what the MODEL's
|
|
values mean while selecting nothing to resolve them into is a different
|
|
statement, and a contradictory one."""
|
|
know = _model_know({'FF': 'F.F.'}, use=[])
|
|
problems = knowledge.face_designation_problems(know)
|
|
assert len(problems) == 1 and 'selects no vocabulary' in problems[0]
|
|
|
|
|
|
def test_a_healthy_mapping_reports_no_problems():
|
|
assert knowledge.face_designation_problems(
|
|
_model_know({'FF': 'F.F.', 'SIF': 'S.I.F.'})) == []
|
|
|
|
|
|
def test_the_model_field_meets_the_vocabulary_a_plant_already_declared():
|
|
"""The point of landing this as a role plus a map rather than as a check:
|
|
a plant that anchors near/far to the FORM gets its model's `FF` resolved
|
|
to (form, bottom), and `same_face` answers against the trade's B.I.F.
|
|
from the data. No code knows about either token."""
|
|
know = _model_know({'FF': 'F.F.', 'SIF': 'S.I.F.'},
|
|
use=['near_far_in_form', 'in_form'],
|
|
extra_vocabularies={'near_far_in_form': FORM_NEAR_FAR})
|
|
got = knowledge.face_from_model_token(know, 'FF')
|
|
assert (got['face']['frame'], got['face']['side']) == ('form', 'bottom')
|
|
assert knowledge.same_face(know, got['token'], 'B.I.F.') is True
|
|
assert knowledge.same_face(know, got['token'], 'T.I.F.') is False
|
|
|
|
|
|
def test_the_face_role_is_declared_and_optional():
|
|
assert knowledge.FACE_ROLE == 'member.face_designation'
|
|
assert knowledge.FACE_ROLE in knowledge.PARAMETER_ROLES
|
|
assert knowledge.FACE_ROLE in knowledge.OPTIONAL_ROLES
|
|
|
|
|
|
# ---------------------------------------------- a call-out's other groups
|
|
|
|
MODIFIERS = {'E.S.': {'means_inferred': 'each side', 'means_verified': False}}
|
|
NOT_FACES = {'O.H.': {'why': 'qualifies a dimension, not a part'}}
|
|
|
|
|
|
def _callout_know():
|
|
return _model_know({'FF': 'F.F.'}, modifiers=MODIFIERS,
|
|
not_face_tokens=NOT_FACES)
|
|
|
|
|
|
def test_a_modifier_is_stripped_and_the_face_underneath_still_resolves():
|
|
"""`P106 (S.I.F.) (E.S.)` -- 35 of them, always after a face token and
|
|
never instead of one. A reader expecting exactly one parenthesised group
|
|
mis-parses the pair, which is why modifiers are a set of their own."""
|
|
got = knowledge.split_face_callout(_callout_know(), ['S.I.F.', 'E.S.'])
|
|
assert got['token'] == 'S.I.F.'
|
|
assert got['face']['frame'] == 'form'
|
|
assert [m['token'] for m in got['modifiers']] == ['E.S.']
|
|
assert got['unknown'] == []
|
|
|
|
|
|
def test_a_modifier_carries_no_side_and_its_meaning_is_marked_unverified():
|
|
""""each side" is a reading of the letters, not a fact anyone stated, so
|
|
nothing may act on it. Stripping the group needs no meaning at all."""
|
|
entry = knowledge.face_modifier(_callout_know(), 'e.s.')
|
|
assert entry['token'] == 'E.S.'
|
|
assert 'side' not in entry # no (frame, side): not a face
|
|
assert entry['means_verified'] is False
|
|
assert knowledge.face_designation(_callout_know(), 'E.S.') is None
|
|
|
|
|
|
def test_a_group_declared_not_to_be_a_face_comes_back_as_that():
|
|
"""It pattern-matches a face designation exactly and qualifies a
|
|
dimension. Without somewhere to record the negative it returns forever as
|
|
a false positive or an unexplained unknown."""
|
|
got = knowledge.split_face_callout(_callout_know(), ['O.H.'])
|
|
assert got['face'] is None
|
|
assert [n['token'] for n in got['not_faces']] == ['O.H.']
|
|
assert got['unknown'] == []
|
|
|
|
|
|
def test_an_unrecognised_group_is_kept_and_never_silently_dropped():
|
|
""""names no face" and "names a face in words nobody wrote down" want
|
|
opposite actions, and a reader that ignores what it does not recognise
|
|
cannot tell them apart."""
|
|
got = knowledge.split_face_callout(_callout_know(), ['Q.Q.'])
|
|
assert got['unknown'] == ['Q.Q.']
|
|
assert 'NOT RECOGNISED' in got['why']
|
|
|
|
|
|
def test_two_face_tokens_in_one_callout_are_refused_rather_than_ranked():
|
|
"""A part is on one face. Taking the first would decide it by print
|
|
order."""
|
|
got = knowledge.split_face_callout(_callout_know(), ['N.F.', 'F.F.'])
|
|
assert got['face'] is None and got['token'] is None
|
|
assert '2 face designations' in got['why']
|
|
|
|
|
|
# ------------------------------------------- which member kinds a token fits
|
|
|
|
ATTACHES = {'N.F.': ['embed', 'bar'], 'F.F.': ['embed', 'bar'],
|
|
'S.I.F.': ['embed'], 'E.L.': ['bar']}
|
|
|
|
|
|
def test_a_designation_on_the_wrong_kind_of_member_is_a_real_finding():
|
|
"""Measured over a whole ticket corpus without exception: the
|
|
side-in-form token attaches to an embed 177 times out of 177 and never to
|
|
a bar. So one on a bar is wrong by construction, and that is data here
|
|
rather than a rule buried inside a check that has not been written."""
|
|
know = _model_know({'FF': 'F.F.'}, attaches_to=ATTACHES)
|
|
assert knowledge.face_attachment_verdict(know, 'S.I.F.', 'embed')[0] is True
|
|
assert knowledge.face_attachment_verdict(know, 'S.I.F.', 'bar')[0] is False
|
|
assert knowledge.face_attachment_verdict(know, 'E.L.', 'bar')[0] is True
|
|
assert knowledge.face_attachment_verdict(know, 'E.L.', 'embed')[0] is False
|
|
assert knowledge.face_attachment_verdict(know, 'N.F.', 'bar')[0] is True
|
|
assert knowledge.face_attachment_verdict(know, 'N.F.', 'embed')[0] is True
|
|
|
|
|
|
def test_nothing_written_down_is_none_and_never_false():
|
|
""""no rule recorded" and "that is not allowed" are opposite answers, and
|
|
False would assert the second."""
|
|
know = _model_know({'FF': 'F.F.'}, attaches_to=ATTACHES)
|
|
for args in (('T.I.F.', 'bar'), ('S.I.F.', None), ('S.I.F.', '')):
|
|
verdict, why = knowledge.face_attachment_verdict(know, *args)
|
|
assert verdict is None and why, args
|
|
bare = _model_know({'FF': 'F.F.'})
|
|
assert knowledge.face_attachment_verdict(bare, 'S.I.F.', 'bar')[0] is None
|
|
|
|
|
|
# ------------------------------- which BUILDING face is top in form: per piece
|
|
|
|
TOP_IN_FORM = {'label': 'Top in Form :', 'source': 'design summary',
|
|
'values': ['Interior', 'Exterior'], 'verified': False,
|
|
'unverified_because': 'nothing reads it yet'}
|
|
|
|
|
|
def _handed_know():
|
|
return _model_know({'NF': 'N.F.', 'FF': 'F.F.'},
|
|
use=['near_far_in_form', 'in_form'],
|
|
extra_vocabularies={'near_far_in_form': FORM_NEAR_FAR},
|
|
top_in_form=TOP_IN_FORM)
|
|
|
|
|
|
def test_top_in_form_ships_dormant_and_names_the_reason():
|
|
rule = knowledge.top_in_form_rule(_handed_know())
|
|
assert rule['label'] == 'Top in Form :' # the space is how it prints
|
|
assert rule['verified'] is False
|
|
assert 'nothing reads it yet' in rule['why']
|
|
|
|
|
|
def test_a_form_anchored_token_refuses_to_name_a_building_face_unread():
|
|
"""THE ONE THAT MATTERS. The design output states which building face is
|
|
top in form PER PANEL and it varies panel to panel, so a check that
|
|
resolves a printed designation to interior or exterior without reading
|
|
the piece's own value is inverted on every panel of the opposite
|
|
handedness -- one normal-looking verdict at a time."""
|
|
know = _handed_know()
|
|
face, why = knowledge.building_face_of(know, 'N.F.')
|
|
assert face is None
|
|
assert 'varies piece by piece' in why and 'Top in Form :' in why
|
|
|
|
|
|
def test_with_the_piece_read_the_top_face_resolves_and_only_that_one():
|
|
"""Top in form is the face the piece names. The OPPOSITE of a stated
|
|
building face is not derivable from that statement -- a piece has more
|
|
than two faces -- so the bottom one refuses instead of inverting."""
|
|
know = _handed_know()
|
|
assert knowledge.building_face_of(know, 'N.F.', 'Interior')[0] == 'Interior'
|
|
assert knowledge.building_face_of(know, 'N.F.', 'Exterior')[0] == 'Exterior'
|
|
face, why = knowledge.building_face_of(know, 'F.F.', 'Interior')
|
|
assert face is None and 'do not invert it' in why
|
|
|
|
|
|
def test_a_view_framed_token_says_nothing_about_the_form():
|
|
"""Near/far measured from the drawing is a different question, and it has
|
|
no answer until something resolves the view against the form."""
|
|
know = _model_know({'FF': 'F.F.'}, top_in_form=TOP_IN_FORM)
|
|
face, why = knowledge.building_face_of(know, 'N.F.', 'Interior')
|
|
assert face is None and 'view frame' in why
|
|
|
|
|
|
def test_a_token_naming_every_face_does_not_pick_one():
|
|
know = _handed_know()
|
|
face, why = knowledge.building_face_of(know, 'E.L.', 'Interior')
|
|
assert face is None and 'every face' in why
|
|
|
|
|
|
# ============================================================================
|
|
# A BAR'S SIZE: two structured fields, two populations
|
|
# ============================================================================
|
|
#
|
|
# Landed 2026-08-31 (open question A3). Census over all 282 wall panels,
|
|
# 24,539 rebar members in 13 families: the size parameter is carried by the
|
|
# one STRAIGHT family and by none of the twelve bent ones, which hold their
|
|
# size in the FAMILY's part description instead -- 7,405 of 7,405 bent members
|
|
# parse, in one uniform shape, with no exceptions.
|
|
#
|
|
# THE TWO SETS ARE DISJOINT, so there is no precedence question. The order in
|
|
# `bar_size_of` is arbitrary and must never be read as a ranking: the
|
|
# family-level description is structured plant data, not notes. (The
|
|
# ASSEMBLY's identically named field IS notes. Two fields, two kinds of
|
|
# element, one name.)
|
|
|
|
|
|
def test_the_bar_size_role_is_declared_and_optional():
|
|
assert knowledge.BAR_SIZE_ROLE == 'member.bar_size'
|
|
assert knowledge.BAR_SIZE_ROLE in knowledge.PARAMETER_ROLES
|
|
assert knowledge.BAR_SIZE_ROLE in knowledge.OPTIONAL_ROLES
|
|
|
|
|
|
def _sized(mapped='A_PARAM', description_fallback=True,
|
|
bent_tokens=('BENT BAR',)):
|
|
know = _rebar(PLANT_STOCK)
|
|
if mapped:
|
|
knowledge._deep_update(
|
|
know, {'parameters': {'member': {'bar_size': mapped}}})
|
|
knowledge._deep_update(know, {'drawings': {'bar_size_rule': {
|
|
'description_fallback': description_fallback,
|
|
'bent_bar_tokens': list(bent_tokens)}}})
|
|
return know
|
|
|
|
|
|
def test_a_size_with_a_parenthesised_qualifier_is_still_that_size():
|
|
"""THE DEFECT THIS CLOSES. The pattern required the whole string to be
|
|
the size, so a real value in a real model -- the one bar size the owner
|
|
had specifically named -- came back None and every bar of it went
|
|
unjudged by 5.7. Silently, because an unreadable size is a stand-down and
|
|
a stand-down on one value in six looks like a working check."""
|
|
assert knowledge.rebar_size('#9 (80 KSI)') == '#9'
|
|
assert knowledge.rebar_size_parts('#9 (80 KSI)') == ('#9', '80 KSI')
|
|
assert knowledge.rebar_stock(_rebar(PLANT_STOCK),
|
|
'#9 (80 KSI)')['basis'] == 'by_size'
|
|
|
|
|
|
def test_the_tolerance_is_parentheses_and_nothing_else():
|
|
"""A qualifier is parenthesised; anything else trailing is not a size at
|
|
all. Widening this to "a size followed by whatever" would make a part
|
|
DESCRIPTION read as a bare size, and a plant that mapped the size role at
|
|
that field would then be parsing prose with the strong reader."""
|
|
for raw in ('#4 BENT BAR (17)', '#4 X 8-8', '#4 EXTRA', 'SR324'):
|
|
assert knowledge.rebar_size(raw) is None, raw
|
|
assert knowledge.rebar_size_parts('#4 BENT BAR (17)') == (None, None)
|
|
|
|
|
|
def test_a_grade_stated_in_a_qualifier_is_captured_rather_than_discarded():
|
|
"""`rebar_fy` has a provenance chain whose strongest rung, 'stated', had
|
|
nowhere to be filled from -- nothing in the model was ever read as a
|
|
grade. A bar whose own size field says 80 KSI has stated one."""
|
|
assert knowledge.rebar_size_qualifier_fy('80 KSI') == 80.0
|
|
assert knowledge.rebar_size_qualifier_fy('GRADE 80') == 80.0
|
|
got = knowledge.bar_size_of(_sized(), '#9 (80 KSI)')
|
|
assert got['size'] == '#9' and got['qualifier_fy_ksi'] == 80.0
|
|
fy, basis, _why = knowledge.rebar_fy(
|
|
_sized(), stated_fy_ksi=got['qualifier_fy_ksi'])
|
|
assert (fy, basis) == (80.0, 'stated')
|
|
|
|
|
|
def test_a_qualifier_that_is_not_a_grade_is_not_read_as_one():
|
|
"""A shape code in parentheses is not a strength, and a number outside
|
|
every published rebar grade is something else in the brackets."""
|
|
for qualifier in ('17', 'R26A', 'EPOXY', '5', '400'):
|
|
assert knowledge.rebar_size_qualifier_fy(qualifier) is None, qualifier
|
|
|
|
|
|
def test_a_straight_bar_is_sized_from_the_size_field():
|
|
got = knowledge.bar_size_of(_sized(), '#4')
|
|
assert (got['size'], got['basis']) == ('#4', 'parameter')
|
|
assert 'A_PARAM' in got['why']
|
|
|
|
|
|
def test_a_bent_bar_is_sized_from_the_part_description():
|
|
"""Not a fallback: the bent families carry no size field at all, so this
|
|
is the reader for that population."""
|
|
for raw, size in (('#4 BENT BAR (17)', '#4'), ('#3 BENT BAR (17)', '#3'),
|
|
('#6 BENT BAR (53)', '#6'),
|
|
('#4 BENT BAR (R26A)', '#4')):
|
|
got = knowledge.bar_size_of(_sized(), None, raw)
|
|
assert (got['size'], got['basis']) == (size, 'description'), raw
|
|
assert 'rather than a fallback' in got['why']
|
|
|
|
|
|
def test_the_description_parse_is_gated_on_the_shape_that_makes_it_evidence():
|
|
"""A description that no longer reads as a bent bar leaves the bar
|
|
UNJUDGED rather than mis-sized. The shape IS the evidence that the
|
|
leading token is a bar size, so a shape that changes must stop the read
|
|
-- it must not be parsed through."""
|
|
got = knowledge.bar_size_of(_sized(), None, '#3 X 8-8')
|
|
assert got['size'] is None and got['basis'] is None
|
|
assert 'does not have the shape' in got['why']
|
|
|
|
|
|
def test_a_leading_bare_number_in_a_description_is_refused():
|
|
"""Nothing marks it as a size."""
|
|
got = knowledge.bar_size_of(_sized(), None, '4 BENT BAR (17)')
|
|
assert got['size'] is None
|
|
assert 'bare number' in got['why']
|
|
|
|
|
|
def test_a_bent_bar_is_recognised_in_three_states_and_never_two():
|
|
know = _sized()
|
|
assert knowledge.bent_bar_verdict(know, '#4 BENT BAR (17)')[0] is True
|
|
assert knowledge.bent_bar_verdict(know, '#3 X 8-8')[0] is False
|
|
assert knowledge.bent_bar_verdict(know, None)[0] is None
|
|
bare = _sized(bent_tokens=())
|
|
verdict, why = knowledge.bent_bar_verdict(bare, '#4 BENT BAR (17)')
|
|
assert verdict is None and 'bent_bar_tokens is empty' in why
|
|
|
|
|
|
def test_a_bar_nothing_can_size_says_which_of_the_states_it_is_in():
|
|
"""Three states, three actions. "No size parameter is mapped" said to
|
|
somebody who has just mapped one sends them to re-check a file that is
|
|
already right."""
|
|
mapped_off = knowledge.rebar_stock(
|
|
_sized(description_fallback=False), None)['why']
|
|
assert 'IS mapped' not in mapped_off # phrasing may vary; check facts
|
|
assert 'A_PARAM' in mapped_off and 'description_fallback' in mapped_off
|
|
|
|
nothing = knowledge.rebar_stock(
|
|
_sized(mapped=None, description_fallback=False), None)['why']
|
|
assert 'on any bar' in nothing
|
|
|
|
junk = knowledge.rebar_stock(_sized(), 'SR324')['why']
|
|
assert 'does not read as a bar size' in junk
|
|
|
|
|
|
def test_a_sizeless_bar_is_never_judged_against_the_generic_length():
|
|
"""A bar with no resolvable size is not judged at all: "40 ft" and "40 ft
|
|
for this size" are different claims and the generic one is wrong in the
|
|
direction that passes a defect."""
|
|
for know in (_sized(), _sized(mapped=None, description_fallback=False)):
|
|
stock = knowledge.rebar_stock(know, None)
|
|
assert stock['basis'] is None
|
|
assert stock['length_in'] is None and stock['length_ft'] is None
|
|
|
|
|
|
def test_the_sizes_the_model_holds_resolve_straight_into_the_stock_table():
|
|
"""The measured values are already the printed form the table is keyed
|
|
by, so nothing converts anything."""
|
|
know = _sized()
|
|
for printed in ('#3', '#4', '#5'):
|
|
stock = knowledge.rebar_stock(know, printed)
|
|
assert stock['basis'] == 'by_size' and stock['size'] == printed
|
|
|
|
|
|
# ============================================================================
|
|
# WHICH PART PLAYS A ROLE DEPENDS ON THE PANEL TYPE
|
|
# ============================================================================
|
|
#
|
|
# Landed 2026-08-31, urgently. The owner said the face lifter on an insulated
|
|
# wall is a different part from the one on a solid wall; that part was named
|
|
# in no knowledge layer; so on every insulated panel the tool found ZERO face
|
|
# lifters and every lifting check -- capacity, edge distance, handling, and
|
|
# Panel Lift Check's face stages -- passed in silence. On safety checks.
|
|
#
|
|
# It is the second time that exact failure has happened here, which is why the
|
|
# fix is a SHAPE and not two more list entries: a third panel type must be an
|
|
# added key, not a redesign.
|
|
#
|
|
# The asymmetry these tests exist to hold:
|
|
#
|
|
# RECOGNISING a part a family left out = a silent pass. A family wrongly
|
|
# in = a visible finding about a real insert. So an
|
|
# unclassified piece gets the UNION.
|
|
# COUNTING one the union is a wrong count with nothing on the
|
|
# report to show for it. So a counting caller must
|
|
# refuse the union basis.
|
|
|
|
PANEL_TYPES = {'types': {'solid': {}, 'insulated': {}}, 'default': None}
|
|
FACE_PATH = 'handling.lifter_groups.face.families'
|
|
|
|
|
|
def _panels(base, by_type=None, **branch):
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
types = dict(PANEL_TYPES)
|
|
types.update(branch)
|
|
knowledge._deep_update(know, {'panel_types': types})
|
|
face = {'families': list(base)}
|
|
if by_type is not None:
|
|
face['families_by_panel_type'] = by_type
|
|
knowledge._deep_update(
|
|
know, {'handling': {'lifter_groups': {'face': face}}})
|
|
return know
|
|
|
|
|
|
def test_an_unclassified_piece_gets_every_type_s_families_and_says_so():
|
|
"""THE ONE THAT CLOSES THE SILENT PASS. Nothing can yet tell an insulated
|
|
panel from a solid one, and a role that resolved to nothing -- or to only
|
|
the ordinary type's part -- is exactly how every lifting check on an
|
|
insulated panel passed while finding no lifters."""
|
|
know = _panels(['P136'], {'solid': ['P136'], 'insulated': ['P78']})
|
|
families, basis, why = knowledge.families_for(know, FACE_PATH)
|
|
assert set(families) == {'P136', 'P78'}
|
|
assert basis == 'union'
|
|
assert 'NOT safe for counting' in why
|
|
|
|
|
|
def test_a_classified_piece_is_narrowed_to_its_own_type():
|
|
know = _panels(['P136'], {'solid': ['P136'], 'insulated': ['P78']})
|
|
assert knowledge.families_for(know, FACE_PATH, 'insulated')[:2] \
|
|
== (['P78'], 'panel_type')
|
|
assert knowledge.families_for(know, FACE_PATH, 'solid')[:2] \
|
|
== (['P136'], 'panel_type')
|
|
|
|
|
|
def test_the_basis_is_returned_so_a_counter_can_refuse_what_a_finder_takes():
|
|
"""Same call, same data, two callers with opposite needs. The only thing
|
|
that lets them differ is the basis, so it is returned with every answer
|
|
rather than left for a caller to infer."""
|
|
know = _panels(['P136'], {'solid': ['P136'], 'insulated': ['P78']})
|
|
for panel_type, expected in ((None, 'union'), ('insulated', 'panel_type')):
|
|
assert knowledge.families_for(know, FACE_PATH, panel_type)[1] \
|
|
== expected
|
|
|
|
|
|
def test_a_panel_type_nobody_declared_falls_back_to_the_base_and_says_so():
|
|
know = _panels(['P136'], {'solid': ['P136'], 'insulated': ['P78']})
|
|
families, basis, why = knowledge.families_for(know, FACE_PATH, 'sandwich')
|
|
assert families == ['P136'] and basis == 'base'
|
|
assert 'does not cover' in why and 'nothing was narrowed' in why
|
|
|
|
|
|
def test_a_role_with_no_breakdown_is_unchanged_by_the_panel_type():
|
|
"""The shape is additive: a role nobody has split reads exactly as it
|
|
always did, whether or not a panel type is known."""
|
|
know = _panels(['EDGE_ANCHOR'])
|
|
for panel_type in (None, 'solid', 'insulated'):
|
|
families, basis, _why = knowledge.families_for(
|
|
know, FACE_PATH, panel_type)
|
|
assert (families, basis) == (['EDGE_ANCHOR'], 'base'), panel_type
|
|
|
|
|
|
def test_a_third_panel_type_is_one_added_key_and_not_a_redesign():
|
|
"""The whole reason this is a map. It has already cost twice to discover
|
|
a population nobody had separated."""
|
|
know = _panels(['P136'], {'solid': ['P136'], 'insulated': ['P78'],
|
|
'sandwich': ['P200']})
|
|
assert knowledge.families_for(know, FACE_PATH, 'sandwich')[:2] \
|
|
== (['P200'], 'panel_type')
|
|
assert set(knowledge.families_for(know, FACE_PATH)[0]) \
|
|
== {'P136', 'P78', 'P200'}
|
|
|
|
|
|
def test_an_unclassified_piece_is_never_read_as_the_ordinary_type():
|
|
"""`panel_types.default` ships null and should stay null. Reading an
|
|
unclassified piece as solid is precisely how an insulated panel came to
|
|
be checked for a lifter it does not carry."""
|
|
know = _panels(['P136'], {'solid': ['P136'], 'insulated': ['P78']})
|
|
assert knowledge.get(know, 'panel_types.default') is None
|
|
got, why = knowledge.panel_type_of(know)
|
|
assert got is None and 'not the same as being the ordinary type' in why
|
|
|
|
|
|
def test_a_stated_panel_type_is_matched_and_an_unknown_one_is_refused():
|
|
know = _panels(['P136'], {'solid': ['P136'], 'insulated': ['P78']})
|
|
assert knowledge.panel_type_of(know, 'INSULATED')[0] == 'insulated'
|
|
got, why = knowledge.panel_type_of(know, 'sandwich')
|
|
assert got is None and 'not one of the declared panel types' in why
|
|
|
|
|
|
def test_no_panel_types_declared_is_not_a_claim_that_there_is_one_kind():
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
assert knowledge.panel_types(know) == {}
|
|
got, why = knowledge.panel_type_of(know)
|
|
assert got is None and 'no layer declares any panel type' in why
|
|
|
|
|
|
def test_a_default_is_refused_while_nothing_can_classify_a_piece_at_all():
|
|
"""The two keys only mean something together. While detection is
|
|
unverified, a default is not "what to assume for the pieces classification
|
|
could not place" -- it is a blanket assertion that every piece in the job
|
|
is one type, which is the failure verbatim."""
|
|
know = _panels(['P136'], {'solid': ['P136']}, default='solid')
|
|
got, why = knowledge.panel_type_of(know)
|
|
assert got is None
|
|
assert 'would make EVERY piece' in why
|
|
|
|
|
|
def test_a_default_is_used_and_named_once_detection_is_verified():
|
|
"""A plant that genuinely has one ordinary type may say so, and the
|
|
sentence then says the answer came from the default rather than from the
|
|
piece."""
|
|
know = _panels(['P136'], {'solid': ['P136']}, default='solid',
|
|
detect_verified=True)
|
|
got, why = knowledge.panel_type_of(know)
|
|
assert got == 'solid' and 'panel_types.default' in why
|
|
|
|
|
|
def test_the_parity_rule_carries_a_per_panel_type_breakdown_of_its_own():
|
|
"""6.6 counts a face lifter, so it needs the narrowed list and not the
|
|
union -- a piece carrying two families where one is the lifter counts
|
|
wrong, and a wrong parity is a wrong verdict with nothing to show for
|
|
it."""
|
|
rule = knowledge.get(knowledge.DEFAULTS, 'drawings.parity_note_rule')
|
|
assert rule['family_names'] == []
|
|
assert rule['family_names_by_panel_type'] == {}
|
|
assert rule['family_verified'] is False
|
|
|
|
|
|
# ============================================================================
|
|
# THE PREFIX ON A PART NUMBER SAYS WHICH LAYER DEFINES IT
|
|
# ============================================================================
|
|
#
|
|
# Owner, 2026-08-31: P is a standard plate and is looked up in plant
|
|
# knowledge; SP is a special plate made for one job and is job knowledge. A
|
|
# layer-placement rule expressed in the part number itself -- everything else
|
|
# in these files has to be TOLD which layer owns a fact, and here the name
|
|
# says it.
|
|
#
|
|
# What it makes checkable: a part on a ticket that nothing can resolve is two
|
|
# different problems with two different owners. A P nobody can look up is a
|
|
# gap in plant knowledge; an SP nobody can look up is a gap in job knowledge.
|
|
# "Unknown part" is right half the time.
|
|
|
|
PREFIXES = {
|
|
'P': {'means': 'standard plate', 'layer': 'plant', 'verified': True},
|
|
'SP': {'means': 'special plate, made for this job', 'layer': 'job',
|
|
'verified': True},
|
|
'MK': {'means': 'erection / site mark', 'layer': 'job',
|
|
'verified': False, 'unverified_because': 'read off a manual'},
|
|
}
|
|
|
|
|
|
def _prefixed(table=None):
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
knowledge._deep_update(
|
|
know, {'part_numbers': {'prefixes': PREFIXES if table is None
|
|
else table}})
|
|
return know
|
|
|
|
|
|
def test_the_longest_prefix_wins_and_that_is_the_whole_safety():
|
|
"""THE ONE THIS EXISTS FOR. Resolved the short way round, a job's special
|
|
plate is filed in the plant's standard-plate namespace -- the same merge a
|
|
substring matcher makes on a bare part number, which has already cost once
|
|
here on a lifting check."""
|
|
know = _prefixed()
|
|
assert knowledge.part_prefix(know, 'SP78')[0] == 'SP'
|
|
assert knowledge.part_prefix(know, 'P78')[0] == 'P'
|
|
assert knowledge.part_defining_layer(know, 'SP78')[0] == 'job'
|
|
assert knowledge.part_defining_layer(know, 'P78')[0] == 'plant'
|
|
|
|
|
|
def test_the_two_parts_that_share_a_number_land_in_different_layers():
|
|
"""`P78` and `SP78` are different parts on different piece types. The
|
|
grammar is what says so, rather than anyone having to notice."""
|
|
know = _prefixed()
|
|
assert knowledge.part_defining_layer(know, 'P78')[0] \
|
|
!= knowledge.part_defining_layer(know, 'SP78')[0]
|
|
|
|
|
|
def test_a_prefix_nobody_declared_is_refused_rather_than_guessed():
|
|
"""An entry in this table is a claim about WHOSE DESK a missing
|
|
definition belongs on, so a guessed one sends the question to the wrong
|
|
person. None is the answer, and it is a real one."""
|
|
know = _prefixed()
|
|
for part in ('IP4', 'QLPD4T5WG', 'ZZ9'):
|
|
prefix, entry, why = knowledge.part_prefix(know, part)
|
|
assert prefix is None and entry is None, part
|
|
assert 'none of the declared' in why and 'Nothing was guessed' in why
|
|
assert knowledge.part_defining_layer(know, part)[0] is None
|
|
|
|
|
|
def test_an_unverified_meaning_says_so_in_the_sentence():
|
|
"""A meaning read off a manual is a reading, not a ruling, and anything
|
|
printing it has to be able to say which."""
|
|
know = _prefixed()
|
|
why = knowledge.part_prefix(know, 'MK12')[2]
|
|
assert 'UNVERIFIED' in why and 'read off a manual' in why
|
|
# ...and it still routes: unverified is not unusable
|
|
assert knowledge.part_defining_layer(know, 'MK12')[0] == 'job'
|
|
|
|
|
|
def test_no_grammar_declared_is_not_permission_to_read_the_letters():
|
|
know = knowledge._deep_copy(knowledge.DEFAULTS)
|
|
assert knowledge.part_prefixes(know) == {}
|
|
prefix, _entry, why = knowledge.part_prefix(know, 'SP78')
|
|
assert prefix is None and 'part_numbers.prefixes is empty' in why
|
|
|
|
|
|
def test_a_prefix_with_no_layer_recorded_does_not_pick_one():
|
|
""""Nothing says which layer" and "this layer" are different answers, and
|
|
only one of them is safe to route a finding on."""
|
|
know = _prefixed({'XX': {'means': 'something', 'verified': False}})
|
|
prefix, _entry, _why = knowledge.part_prefix(know, 'XX9')
|
|
assert prefix == 'XX'
|
|
layer, why = knowledge.part_defining_layer(know, 'XX9')
|
|
assert layer is None and 'no layer is recorded' in why
|
|
|
|
|
|
def test_a_third_prefix_is_one_added_key():
|
|
table = dict(PREFIXES)
|
|
table['SR'] = {'means': 'reinforcing mark', 'layer': 'job',
|
|
'verified': False}
|
|
know = _prefixed(table)
|
|
assert knowledge.part_defining_layer(know, 'SR4996')[0] == 'job'
|
|
assert knowledge.part_prefix(know, 'SP78')[0] == 'SP' # unchanged
|
|
|
|
|
|
def test_matching_is_case_insensitive_and_tolerates_surrounding_space():
|
|
know = _prefixed()
|
|
for raw in ('sp78', ' SP78 ', 'Sp78'):
|
|
assert knowledge.part_prefix(know, raw)[0] == 'SP', raw
|
|
|
|
|
|
def test_no_part_number_is_not_an_unknown_prefix():
|
|
"""Two different silences: nobody asked, and nobody knows."""
|
|
know = _prefixed()
|
|
for raw in (None, '', ' '):
|
|
_prefix, _entry, why = knowledge.part_prefix(know, raw)
|
|
assert 'no part number was given' in why, raw
|
|
|
|
|
|
# ------------------------------- a note that is present and worded differently
|
|
|
|
def test_a_near_miss_wording_is_reported_and_never_accepted():
|
|
"""The owner named ONE phrase. Widening the rule to accept a near-miss
|
|
would quietly overrule him -- so a piece carrying one of these still
|
|
fails. But a note worded wrongly is a different defect from no note at
|
|
all: one needs a wording corrected, the other needs a note written, and a
|
|
report that reads the same for both sends the reader to the wrong job."""
|
|
rule = knowledge.get(knowledge.DEFAULTS, 'drawings.parity_note_rule')
|
|
assert rule['require_text'] is None
|
|
assert rule['require_text_also_seen'] == []
|
|
# it is a separate key from the accepted phrase, and it must stay separate
|
|
assert 'require_text_also_seen' in rule and 'require_text' in rule
|
|
|
|
|
|
# ============================================================================
|
|
# PANEL-TYPE CLASSIFICATION, AND WHAT A PLANT MAY DECLARE BECAUSE OF IT
|
|
# ============================================================================
|
|
#
|
|
# Commissioned 2026-08-31: which Ticket QC checks apply is itself plant
|
|
# knowledge, conditioned on what the piece is. Two halves, tested apart
|
|
# because they fail apart -- a classifier that cannot tell must not be able
|
|
# to switch anything off, and a declaration nobody can parse must not be able
|
|
# to hide behind a classification that worked.
|
|
|
|
|
|
def _typed(**signals):
|
|
return {'panel_types': {'types': {'insulated': {'signals': signals}},
|
|
'detect_verified': True}}
|
|
|
|
|
|
def test_panel_facts_names_its_unit_in_the_key():
|
|
"""The one place a unit error would be silent and total.
|
|
|
|
The ticket carries Revit's internal FEET; a 16 that meant feet would
|
|
match no band any plant ever writes, on every piece, and the tool would
|
|
report a whole job as unclassifiable without a single error.
|
|
"""
|
|
facts = knowledge.panel_facts(body_family='X', thickness_in=16.0)
|
|
assert facts['thickness_in'] == 16.0
|
|
assert 'thickness_in' in knowledge.FACT_KEYS
|
|
|
|
|
|
def test_a_fact_nobody_measured_is_none_and_never_a_no():
|
|
facts = knowledge.panel_facts()
|
|
assert facts['thickness_in'] is None
|
|
assert facts['body_family'] is None
|
|
assert facts['member_families'] == []
|
|
|
|
|
|
def test_two_signals_agreeing_classify_the_piece():
|
|
know = _typed(body_family_contains=['INSULATED'],
|
|
thickness_in={'min': 15.5, 'max': 16.5})
|
|
got = knowledge.classify_panel(know, knowledge.panel_facts(
|
|
body_family='WALL INSULATED 16', thickness_in=16.0))
|
|
assert got['panel_type'] == 'insulated'
|
|
assert got['basis'] == 'signals'
|
|
assert got['candidates'] == ['insulated']
|
|
|
|
|
|
def test_one_signal_alone_is_not_enough():
|
|
"""The plant file's own reasoning, enforced.
|
|
|
|
Any single signal could be a coincidence of how one job was modelled;
|
|
three that never disagree is a convention. The default floor is two, and
|
|
a piece that clears only one is UNCLASSIFIED -- which means the full
|
|
checklist, not the ordinary type.
|
|
"""
|
|
know = _typed(body_family_contains=['INSULATED'],
|
|
thickness_in={'min': 15.5, 'max': 16.5})
|
|
got = knowledge.classify_panel(know, knowledge.panel_facts(
|
|
body_family='WALL INSULATED 16')) # no thickness measured
|
|
assert got['panel_type'] is None
|
|
assert 'only 1 of the 2 signals' in got['why']
|
|
|
|
|
|
def test_signals_that_disagree_decline_to_classify():
|
|
know = _typed(body_family_contains=['INSULATED'],
|
|
thickness_in={'min': 15.5, 'max': 16.5})
|
|
got = knowledge.classify_panel(know, knowledge.panel_facts(
|
|
body_family='WALL INSULATED 16', thickness_in=8.0))
|
|
assert got['panel_type'] is None
|
|
assert got['basis'] == 'no_match'
|
|
assert 'thickness_in' in got['why']
|
|
assert 'UNCLASSIFIED' in got['why']
|
|
|
|
|
|
def test_a_type_declaring_no_signals_can_never_be_classified_into():
|
|
"""`solid` is not "whatever is not insulated".
|
|
|
|
Reading an unclassified piece as the ordinary type is precisely how an
|
|
insulated panel came to be checked for a lifter it does not carry
|
|
(2026-08-31). Not being another type is not evidence of being this one,
|
|
so a type with no signals of its own is never the answer.
|
|
"""
|
|
know = {'panel_types': {'detect_verified': True, 'types': {
|
|
'solid': {},
|
|
'insulated': {'signals': {
|
|
'body_family_contains': ['INSULATED'],
|
|
'thickness_in': {'min': 15.5, 'max': 16.5}}}}}}
|
|
got = knowledge.classify_panel(know, knowledge.panel_facts(
|
|
body_family='WALL SOLID 8', thickness_in=8.0))
|
|
assert got['panel_type'] is None
|
|
assert 'solid declares no signals' in got['why']
|
|
|
|
|
|
def test_two_types_matching_at_once_is_shouted_not_picked():
|
|
know = {'panel_types': {'detect_verified': True, 'types': {
|
|
'a': {'signals': {'body_family_contains': ['WALL'],
|
|
'thickness_in': {'min': 1, 'max': 99}}},
|
|
'b': {'signals': {'body_family_contains': ['WALL'],
|
|
'thickness_in': {'min': 1, 'max': 99}}}}}}
|
|
got = knowledge.classify_panel(know, knowledge.panel_facts(
|
|
body_family='WALL', thickness_in=16.0))
|
|
assert got['panel_type'] is None
|
|
assert got['basis'] == 'ambiguous'
|
|
assert 'CONTRADICT' in got['why']
|
|
assert got['candidates'] == ['a', 'b']
|
|
|
|
|
|
def test_a_signal_name_this_code_does_not_know_is_named_not_ignored():
|
|
know = _typed(body_family_contains=['INSULATED'], colour='blue')
|
|
got = knowledge.classify_panel(know, knowledge.panel_facts(
|
|
body_family='INSULATED', thickness_in=16.0))
|
|
assert got['panel_type'] is None
|
|
assert 'colour' in got['why']
|
|
|
|
|
|
def test_no_declared_types_classifies_nothing_and_says_so():
|
|
got = knowledge.classify_panel({}, knowledge.panel_facts(body_family='X'))
|
|
assert got['panel_type'] is None
|
|
assert got['basis'] == 'undeclared'
|
|
|
|
|
|
def test_the_classifier_reports_while_unverified_but_flags_it():
|
|
know = {'panel_types': {'detect_verified': False, 'types': {
|
|
'insulated': {'signals': {'body_family_contains': ['INSULATED'],
|
|
'thickness_in': {'min': 15, 'max': 17}}}}}}
|
|
got = knowledge.classify_panel(know, knowledge.panel_facts(
|
|
body_family='INSULATED', thickness_in=16.0))
|
|
assert got['panel_type'] == 'insulated'
|
|
assert got['verified'] is False
|
|
|
|
|
|
# ------------------------------------------ what a plant declares is not asked
|
|
|
|
def _decl(**over):
|
|
entry = {'applies': False,
|
|
'not_applicable_because': 'no such thing on these',
|
|
'not_applicable_since': '2026-08-31'}
|
|
entry.update(over)
|
|
return entry
|
|
|
|
|
|
def test_nothing_ships_switched_off():
|
|
"""The seeding rule, as a test. Nothing is switched off by default, in
|
|
any layer this repository controls."""
|
|
assert knowledge.DEFAULTS['checks']['not_applicable'] == {}
|
|
assert knowledge.DEFAULTS['checks']['only_applies_to'] == {}
|
|
|
|
|
|
def test_no_declaration_means_the_row_is_asked():
|
|
got = knowledge.check_not_applicable({}, '3.3')
|
|
assert got['not_applicable'] is False
|
|
assert got['declared'] is False
|
|
assert got['problem'] is None
|
|
|
|
|
|
def test_the_any_axis_needs_no_classification_at_all():
|
|
"""The PLANT axis: "we do not do that here". It works the day it is
|
|
written, with no classifier and no verified flag."""
|
|
know = {'checks': {'not_applicable': {'3.3': {'any': _decl()}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3')
|
|
assert got['not_applicable'] is True
|
|
assert got['condition'] == 'any'
|
|
assert got['reason'] == 'no such thing on these'
|
|
assert got['since'] == '2026-08-31'
|
|
|
|
|
|
def test_a_piece_conditioned_rule_is_refused_on_an_unclassified_piece():
|
|
"""The asymmetry, as a test. Running a check that turns out not to apply
|
|
is noise; skipping one that did apply is a silent pass."""
|
|
know = {'checks': {'not_applicable': {'3.3': {'insulated': _decl()}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3', panel_type=None,
|
|
verified=True)
|
|
assert got['not_applicable'] is False
|
|
assert 'REFUSED' in got['problem']
|
|
assert 'full checklist' in got['problem']
|
|
|
|
|
|
def test_a_piece_conditioned_rule_is_refused_while_detection_is_unverified():
|
|
know = {'panel_types': {'detect_unverified_because': 'nothing reads them'},
|
|
'checks': {'not_applicable': {'3.3': {'insulated': _decl()}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3', panel_type='insulated',
|
|
verified=False)
|
|
assert got['not_applicable'] is False
|
|
assert 'detect_verified is false' in got['problem']
|
|
assert 'nothing reads them' in got['problem']
|
|
|
|
|
|
def test_a_piece_conditioned_rule_fires_on_a_matching_verified_piece():
|
|
know = {'checks': {'not_applicable': {'3.3': {'insulated': _decl()}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3', panel_type='insulated',
|
|
verified=True)
|
|
assert got['not_applicable'] is True
|
|
assert got['condition'] == 'insulated'
|
|
|
|
|
|
def test_a_rule_about_another_type_says_nothing_about_this_piece():
|
|
know = {'checks': {'not_applicable': {'3.3': {'insulated': _decl()}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3', panel_type='solid',
|
|
verified=True)
|
|
assert got['not_applicable'] is False
|
|
assert got['problem'] is None
|
|
|
|
|
|
def test_a_declaration_with_no_reason_is_refused_and_the_row_runs():
|
|
know = {'checks': {'not_applicable': {'3.3': {
|
|
'any': _decl(not_applicable_because='')}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3')
|
|
assert got['not_applicable'] is False
|
|
assert 'A reason is REQUIRED' in got['problem']
|
|
|
|
|
|
def test_a_declaration_with_no_usable_date_is_refused():
|
|
for bad in (None, '', 'yesterday', '31/08/2026'):
|
|
know = {'checks': {'not_applicable': {'3.3': {
|
|
'any': _decl(not_applicable_since=bad)}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3')
|
|
assert got['not_applicable'] is False, bad
|
|
assert 'YYYY-MM-DD' in got['problem'], bad
|
|
|
|
|
|
def test_a_declaration_that_does_not_say_applies_switches_nothing_off():
|
|
know = {'checks': {'not_applicable': {'3.3': {'any': {
|
|
'not_applicable_because': 'because',
|
|
'not_applicable_since': '2026-08-31'}}}}}
|
|
got = knowledge.check_not_applicable(know, '3.3')
|
|
assert got['not_applicable'] is False
|
|
assert 'does not say `applies`' in got['problem']
|
|
|
|
|
|
def test_a_job_narrows_one_pair_and_the_plants_table_survives():
|
|
"""THE WIPE-THE-TABLE TRAP, and why the shape has no list in it.
|
|
|
|
Lists replace across the layers. A list of switched-off row numbers in
|
|
the job file would delete the plant's whole table the moment a job wanted
|
|
one entry back -- a safety switch with the rebar stock-length bug in it.
|
|
With maps all the way down the job restates exactly one (row, type) pair.
|
|
"""
|
|
plant = {'checks': {'not_applicable': {
|
|
'3.3': {'insulated': _decl()},
|
|
'5.4': {'any': _decl()}}}}
|
|
job = {'checks': {'not_applicable': {'3.3': {'insulated': {
|
|
'applies': True,
|
|
'applicable_because': 'this job DOES have them',
|
|
'applicable_since': '2026-09-01'}}}}}
|
|
knowledge._deep_update(plant, job)
|
|
on = knowledge.check_not_applicable(plant, '3.3', panel_type='insulated',
|
|
verified=True)
|
|
assert on['not_applicable'] is False
|
|
assert on['reason'] == 'this job DOES have them'
|
|
# ... and the other entry, which nobody mentioned, is untouched.
|
|
still = knowledge.check_not_applicable(plant, '5.4')
|
|
assert still['not_applicable'] is True
|
|
|
|
|
|
def test_turning_a_row_back_on_must_state_its_own_case():
|
|
"""The reason key is named for the value it justifies.
|
|
|
|
After the merge nothing can tell which layer wrote which key. A single
|
|
`because:` would let a job flip `applies: true` and inherit the plant's
|
|
sentence for the OPPOSITE ruling -- a reason that reads backwards over a
|
|
re-enabled check. `applicable_because` is not a key the plant's entry
|
|
carries, so the narrowing states its own case or is refused.
|
|
"""
|
|
plant = {'checks': {'not_applicable': {'3.3': {'any': _decl()}}}}
|
|
knowledge._deep_update(
|
|
plant, {'checks': {'not_applicable': {'3.3': {'any': {
|
|
'applies': True}}}}})
|
|
got = knowledge.check_not_applicable(plant, '3.3')
|
|
assert 'applicable_because' in got['problem']
|
|
# And it does not fall through to the plant's switch-off either: the
|
|
# entry was refused whole.
|
|
assert got['not_applicable'] is False
|
|
|
|
|
|
def test_a_job_extends_the_table_without_restating_it():
|
|
plant = {'checks': {'not_applicable': {'3.3': {'any': _decl()}}}}
|
|
knowledge._deep_update(
|
|
plant, {'checks': {'not_applicable': {'5.4': {'any': _decl()}}}})
|
|
assert knowledge.check_not_applicable(plant, '3.3')['not_applicable']
|
|
assert knowledge.check_not_applicable(plant, '5.4')['not_applicable']
|
|
|
|
|
|
# ---------------------------------- a row that exists only for one type
|
|
|
|
def _scoped(**over):
|
|
entry = {'scoped': True,
|
|
'scoped_because': 'a solid zone interrupts insulation',
|
|
'scoped_since': '2026-08-31'}
|
|
entry.update(over)
|
|
return entry
|
|
|
|
|
|
def test_a_scoped_row_is_asked_on_the_type_it_is_about():
|
|
know = {'checks': {'only_applies_to': {'6.7': {'insulated': _scoped()}}}}
|
|
got = knowledge.check_not_applicable(know, '6.7', panel_type='insulated',
|
|
verified=True)
|
|
assert got['not_applicable'] is False
|
|
assert got['unaskable'] is False
|
|
assert got['only_types'] == ['insulated']
|
|
|
|
|
|
def test_a_scoped_row_is_not_applicable_on_another_type():
|
|
know = {'checks': {'only_applies_to': {'6.7': {'insulated': _scoped()}}}}
|
|
got = knowledge.check_not_applicable(know, '6.7', panel_type='solid',
|
|
verified=True)
|
|
assert got['not_applicable'] is True
|
|
assert got['condition'] == 'solid'
|
|
assert 'a solid zone interrupts insulation' in got['reason']
|
|
|
|
|
|
def test_a_scoped_row_on_an_unclassified_piece_is_unaskable_not_quiet():
|
|
"""THE REFINEMENT the "full checklist" rule needed.
|
|
|
|
A row scoped to a panel type cannot simply run on a piece nobody could
|
|
place: that answers an insulated question about a piece that may not be
|
|
one. Nor can it be NOT APPLICABLE, which claims the piece is NOT that
|
|
type -- precisely what could not be established. So nobody could ask it,
|
|
which is NOT RUN, which is red.
|
|
"""
|
|
know = {'checks': {'only_applies_to': {'6.7': {'insulated': _scoped()}}}}
|
|
got = knowledge.check_not_applicable(know, '6.7', panel_type=None,
|
|
verified=True)
|
|
assert got['unaskable'] is True
|
|
assert got['not_applicable'] is False
|
|
assert 'NOT ASKED' in got['reason']
|
|
|
|
|
|
def test_a_scoped_row_is_unaskable_while_detection_is_unverified():
|
|
know = {'checks': {'only_applies_to': {'6.7': {'insulated': _scoped()}}}}
|
|
got = knowledge.check_not_applicable(know, '6.7', panel_type='insulated',
|
|
verified=False)
|
|
assert got['unaskable'] is True
|
|
assert 'not a pass' in got['reason']
|
|
|
|
|
|
def test_scoping_a_row_to_any_is_not_a_scope_and_is_refused():
|
|
know = {'checks': {'only_applies_to': {'6.7': {'any': _scoped()}}}}
|
|
got = knowledge.check_not_applicable(know, '6.7', panel_type='solid',
|
|
verified=True)
|
|
assert got['not_applicable'] is False
|
|
assert 'not a scope' in got['problem']
|
|
|
|
|
|
def test_a_scope_with_no_reason_or_no_date_is_refused():
|
|
for over in ({'scoped_because': ''}, {'scoped_since': 'soon'}):
|
|
know = {'checks': {'only_applies_to': {
|
|
'6.7': {'insulated': _scoped(**over)}}}}
|
|
got = knowledge.check_not_applicable(know, '6.7', panel_type='solid',
|
|
verified=True)
|
|
assert got['not_applicable'] is False, over
|
|
assert got['problem'], over
|
|
|
|
|
|
def test_a_job_widens_a_scoped_row_back_to_every_piece():
|
|
plant = {'checks': {'only_applies_to': {'6.7': {'insulated': _scoped()}}}}
|
|
knowledge._deep_update(plant, {'checks': {'only_applies_to': {'6.7': {
|
|
'insulated': {'scoped': False,
|
|
'unscoped_because': 'we ask it everywhere now',
|
|
'unscoped_since': '2026-09-02'}}}}})
|
|
got = knowledge.check_not_applicable(plant, '6.7', panel_type='solid',
|
|
verified=True)
|
|
assert got['not_applicable'] is False
|
|
assert got['unaskable'] is False
|
|
assert got['only_types'] == []
|
|
|
|
|
|
# ------------------------------------------------------- which layer said it
|
|
|
|
def test_declaring_layer_names_the_last_layer_that_holds_the_key():
|
|
layers = [('defaults', None, True, {}),
|
|
('plant', 'p', True, {'checks': {'not_applicable': {
|
|
'3.3': {'any': {'applies': False}}}}}),
|
|
('job', 'j', True, {'checks': {'muted': {'3.3': 'x'}}})]
|
|
assert knowledge.declaring_layer(
|
|
layers, ['checks', 'not_applicable', '3.3', 'any']) == 'plant'
|
|
assert knowledge.declaring_layer(
|
|
layers, ['checks', 'not_applicable', '9.9']) is None
|
|
|
|
|
|
def test_declaring_layer_takes_keys_and_not_a_dotted_string():
|
|
"""Every key in this table is a checklist ROW NUMBER, which contains a
|
|
dot. A dotted path would split '3.3' into two segments and address
|
|
nothing, so the API takes the keys themselves."""
|
|
layers = [('plant', 'p', True, {'checks': {'not_applicable': {
|
|
'3.3': {'any': {'applies': False}}}}})]
|
|
assert knowledge.declaring_layer(
|
|
layers, ['checks', 'not_applicable', '3.3', 'any']) == 'plant'
|
|
|
|
|
|
def test_declaring_layer_prefers_the_layer_that_wins_the_merge():
|
|
layers = [('plant', 'p', True, {'checks': {'not_applicable': {
|
|
'3.3': {'any': {'applies': False}}}}}),
|
|
('job', 'j', True, {'checks': {'not_applicable': {
|
|
'3.3': {'any': {'applies': True}}}}})]
|
|
assert knowledge.declaring_layer(
|
|
layers, ['checks', 'not_applicable', '3.3', 'any']) == 'job'
|
|
|
|
|
|
# ============================================================================
|
|
# ATTACHED BARS, AND WHETHER A PRINTED TOKEN IS A BAR AT ALL
|
|
# ============================================================================
|
|
|
|
|
|
def test_attached_parents_normalises_the_map_the_drawing_checks_maintain():
|
|
know = {'drawings': {'annotation_covered_by_parent': {
|
|
'sr4996': ['SP29', 'SP30'], 'SR4999': 'SP61', 'SR3997': None}}}
|
|
got = knowledge.attached_parents(know)
|
|
assert got['SR4996'] == ['SP29', 'SP30']
|
|
# A single parent may be written as a scalar; no caller should have to
|
|
# test the shape.
|
|
assert got['SR4999'] == ['SP61']
|
|
assert got['SR3997'] == []
|
|
|
|
|
|
def test_attached_parents_is_empty_when_nobody_declares_it():
|
|
assert knowledge.attached_parents({}) == {}
|
|
assert knowledge.attached_parents(
|
|
{'drawings': {'annotation_covered_by_parent': ['SR4996']}}) == {}
|
|
|
|
|
|
def test_attached_parents_deep_merges_so_a_job_adds_without_wiping():
|
|
"""The opposite of `interference.exempt_families` beside it, which is a
|
|
LIST and replaces -- which is why that one carries a restated block and
|
|
this one must not."""
|
|
plant = {'drawings': {'annotation_covered_by_parent': {
|
|
'SR5602': ['P76'], 'SR6999': ['P76']}}}
|
|
knowledge._deep_update(plant, {'drawings': {
|
|
'annotation_covered_by_parent': {'SR4996': ['SP29', 'SP30']}}})
|
|
got = knowledge.attached_parents(plant)
|
|
assert sorted(got) == ['SR4996', 'SR5602', 'SR6999']
|
|
|
|
|
|
# ---------------------------------------- bar mark, hardware, or neither
|
|
|
|
def _grammar(**entries):
|
|
return {'part_numbers': {'prefixes': entries}}
|
|
|
|
|
|
def test_a_verified_hardware_prefix_sets_a_token_aside():
|
|
know = _grammar(P={'kind': 'hardware', 'verified': True,
|
|
'means': 'standard plate'})
|
|
assert knowledge.printed_token_population(know, 'P78')[0] == 'hardware'
|
|
|
|
|
|
def test_an_unverified_hardware_claim_is_refused_and_the_token_survives():
|
|
"""THE ASYMMETRY THIS KEY RESTS ON. `kind: hardware` REMOVES a token from
|
|
5.6's comparison, so a wrong one makes a real printed bar mark stop being
|
|
compared -- a silent pass on the exact defect 5.6 exists to find. A
|
|
reading is not a ruling, and a reading may not switch a comparison off.
|
|
"""
|
|
know = _grammar(P={'kind': 'hardware', 'verified': False,
|
|
'unverified_because': 'read off a manual'})
|
|
population, why = knowledge.printed_token_population(know, 'P78')
|
|
assert population == 'unknown'
|
|
assert 'REFUSED' in why
|
|
assert 'read off a manual' in why
|
|
|
|
|
|
def test_bar_mark_needs_no_verification_because_it_can_only_add_noise():
|
|
"""The other direction. Calling something a bar mark can only ever put a
|
|
visible finding on the report; it can never remove one."""
|
|
know = _grammar(SR={'kind': 'bar_mark', 'verified': False})
|
|
assert knowledge.printed_token_population(know, 'SR310')[0] == 'bar_mark'
|
|
|
|
|
|
def test_a_prefix_with_no_kind_leaves_the_token_unplaceable():
|
|
know = _grammar(MK={'means': 'erection / site mark', 'verified': False})
|
|
population, why = knowledge.printed_token_population(know, 'MK12')
|
|
assert population == 'unknown'
|
|
assert 'declares no `kind`' in why
|
|
assert 'NOT set aside' in why
|
|
|
|
|
|
def test_a_kind_this_code_does_not_know_is_refused_by_name():
|
|
know = _grammar(ZZ={'kind': 'widget', 'verified': True})
|
|
population, why = knowledge.printed_token_population(know, 'ZZ1')
|
|
assert population == 'unknown'
|
|
assert 'widget' in why
|
|
|
|
|
|
def test_a_token_matching_no_declared_prefix_is_unplaceable():
|
|
know = _grammar(P={'kind': 'hardware', 'verified': True})
|
|
assert knowledge.printed_token_population(know, 'QQ9')[0] == 'unknown'
|
|
|
|
|
|
def test_no_grammar_at_all_stands_down_to_ungoverned_not_to_hardware():
|
|
"""Standing down here has to mean "do not narrow". The other reading
|
|
would empty 5.6's comparison for every plant that has not written a
|
|
part-number grammar yet -- silence, arriving through a key nobody set."""
|
|
population, why = knowledge.printed_token_population({}, 'P78')
|
|
assert population == 'ungoverned'
|
|
assert 'nothing was set aside' in why
|
|
|
|
|
|
def test_longest_prefix_wins_so_a_special_plate_is_not_a_plate():
|
|
know = _grammar(P={'kind': 'hardware', 'verified': True},
|
|
SP={'kind': 'hardware', 'verified': False})
|
|
# SP is unverified, so SP29 is refused rather than falling back to P and
|
|
# being set aside on the strength of a prefix that is not its own.
|
|
population, _why = knowledge.printed_token_population(know, 'SP29')
|
|
assert population == 'unknown'
|
|
|
|
|
|
# ============================================================================
|
|
# PLANT-MEMBER CHECKLISTS: a board per kind of member, with its own numbers
|
|
# ============================================================================
|
|
|
|
|
|
def _board(*pairs):
|
|
return {'checklists': {'profiles': {'wp': {
|
|
'member_kind': 'wall_panel', 'title': 'Wall panel',
|
|
'items': ['{0} = {1}'.format(n, c) for (n, c) in pairs]}}},
|
|
'nomenclature': {'member_kinds': {'WP': 'wall_panel'}}}
|
|
|
|
|
|
def test_no_profile_declared_is_the_compatibility_contract():
|
|
"""THE CONTRACT, and it is the reason this could ship at all.
|
|
|
|
A plant that declares nothing gets the full master checklist, in master
|
|
order, under master numbers -- byte-identical to what this tool printed
|
|
before profiles existed.
|
|
"""
|
|
got = knowledge.profile_for({}, member_kind='wall_panel')
|
|
assert got['profile'] is None
|
|
assert got['items'] is None
|
|
rows = [(i[0], i[1], i[2], 'OK', None) for i in ticketpage.CHECKLIST_ITEMS]
|
|
assert ticketpage.profile_view(rows, got) == rows
|
|
text = ticketpage.checklist_table_text(rows)
|
|
assert ticketpage.checklist_table_text(
|
|
ticketpage.profile_view(rows, got)) == text
|
|
|
|
|
|
def test_a_piece_whose_member_kind_is_unreadable_gets_the_full_checklist():
|
|
know = _board(('1', '3.3'))
|
|
member = knowledge.member_kind_of(know, 'ZZ9')
|
|
assert member['member_kind'] is None
|
|
got = knowledge.profile_for(know, member_kind=None)
|
|
assert got['profile'] is None
|
|
assert 'FULL checklist' in got['why']
|
|
|
|
|
|
def test_the_member_prefix_is_read_the_way_scope_reads_it():
|
|
"""One reader, and it is `scope.prefix_of`. The prefix is the WHOLE
|
|
leading letter run and the lookup is EXACT -- scope.py argued that out
|
|
and re-deciding it here would let the declared scope and the checklist
|
|
disagree about the same mark, silently."""
|
|
know = _board(('1', '3.3'))
|
|
assert knowledge.member_kind_of(know, 'WP117')['member_kind'] == \
|
|
'wall_panel'
|
|
# `WPX117`'s prefix is WPX, which is not the declared WP.
|
|
assert knowledge.member_kind_of(know, 'WPX117')['member_kind'] is None
|
|
assert knowledge.member_kind_of(know, '117')['member_kind'] is None
|
|
|
|
|
|
def test_a_profile_renumbers_and_reorders_without_moving_the_master_id():
|
|
know = _board(('2.1', '3.3'), ('2.2', '1.1'))
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['items'] == [('2.1', '3.3'), ('2.2', '1.1')]
|
|
rows = [('1.1', 'a', 'A', 'OK', None), ('3.3', 'b', 'B', 'WARN', 'w')]
|
|
view = ticketpage.profile_view(rows, got)
|
|
# Reordered to the board's order ...
|
|
assert [r[0] for r in view] == ['3.3', '1.1']
|
|
# ... and the MASTER ID never moved off row[0].
|
|
assert [ticketpage.row_display(r) for r in view] == \
|
|
[('2.1', '3.3'), ('2.2', '1.1')]
|
|
|
|
|
|
def test_the_page_and_the_file_both_recover_the_master_id():
|
|
know = _board(('2.1', '3.3'))
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
view = ticketpage.profile_view([('3.3', 'b', 'B', 'WARN', 'w')], got)
|
|
html = ticketpage.checklist_table_html(view)
|
|
text = ticketpage.checklist_table_text(view)
|
|
assert '2.1' in html and '3.3' in html
|
|
# The file is the fallback a reader is told to trust: it has no hover
|
|
# text, so the id is always spelled out.
|
|
assert '2.1 [3.3]' in text
|
|
|
|
|
|
def test_a_row_the_board_does_not_name_is_asked_anyway_and_said_so():
|
|
"""THE HOLE A CLOSED LIST WOULD OPEN. A board names the questions that
|
|
existed when somebody wrote it; the code universe grows. The default
|
|
must be that the next check anybody builds is still asked."""
|
|
know = _board(('1', '3.3'))
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['unlisted'] == 'ask'
|
|
decisions = ticketpage.applicability_decisions(
|
|
know, resolver=lambda k, i, panel_type=None, verified=False:
|
|
{'not_applicable': False}, profile=got)
|
|
assert decisions['3.3']['off_board'] is False
|
|
assert decisions['1.1']['off_board'] is True
|
|
assert decisions['1.1']['not_applicable'] is False
|
|
assert '1.1' in ticketpage.off_board(decisions)
|
|
|
|
|
|
def test_a_closed_board_needs_a_reason_and_a_date_or_it_is_refused():
|
|
know = _board(('1', '3.3'))
|
|
know['checklists']['profiles']['wp']['unlisted'] = 'not_applicable'
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['unlisted'] == 'ask'
|
|
assert 'REFUSED' in got['problem']
|
|
|
|
know['checklists']['profiles']['wp'].update({
|
|
'unlisted_because': 'this plant asks nothing else of a wall panel',
|
|
'unlisted_since': '2026-08-31'})
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['unlisted'] == 'not_applicable'
|
|
decisions = ticketpage.applicability_decisions(
|
|
know, resolver=lambda k, i, panel_type=None, verified=False:
|
|
{'not_applicable': False}, profile=got)
|
|
assert decisions['1.1']['not_applicable'] is True
|
|
assert decisions['1.1']['source'] == 'profile'
|
|
assert 'does not ask this question' in decisions['1.1']['reason']
|
|
|
|
|
|
def test_two_profiles_claiming_one_member_kind_use_neither():
|
|
know = _board(('1', '3.3'))
|
|
know['checklists']['profiles']['wp2'] = {
|
|
'member_kind': 'wall_panel', 'items': ['1 = 1.1']}
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['profile'] is None
|
|
assert 'cannot have two checklists' in got['problem']
|
|
|
|
|
|
def test_a_panel_type_profile_beats_the_member_kind_one():
|
|
know = _board(('1', '3.3'))
|
|
know['checklists']['profiles']['wp_ins'] = {
|
|
'member_kind': 'wall_panel', 'panel_type': 'insulated',
|
|
'items': ['1 = 5.8']}
|
|
got = knowledge.profile_for(know, member_kind='wall_panel',
|
|
panel_type='insulated')
|
|
assert got['profile'] == 'wp_ins'
|
|
assert got['basis'] == 'member_kind+panel_type'
|
|
# ... and a piece that is only a wall panel still gets the general board.
|
|
assert knowledge.profile_for(
|
|
know, member_kind='wall_panel')['profile'] == 'wp'
|
|
|
|
|
|
def test_a_board_line_that_is_a_bare_number_is_refused_by_name():
|
|
"""THE TRAP THAT COST A ROW. `_coerce` numifies a value, so `5.10`
|
|
becomes the float 5.1 -- which is a DIFFERENT master id that really
|
|
exists. It is the leading-zero lesson with the zero at the other end,
|
|
and it cannot be undone after the fact, so it is refused."""
|
|
know = {'checklists': {'profiles': {'wp': {
|
|
'member_kind': 'wall_panel',
|
|
'items': [{'number': 5.1, 'check': 5.1}]}}}}
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['items'] == []
|
|
assert 'coerced' in got['problem'] or '5.10' in got['problem']
|
|
|
|
|
|
def test_the_equals_form_survives_coercion():
|
|
assert knowledge._coerce('5.10') == 5.1 # the trap itself
|
|
assert knowledge._coerce('2.1 = 5.10') == '2.1 = 5.10'
|
|
know = _board(('2.1', '5.10'))
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['items'] == [('2.1', '5.10')]
|
|
|
|
|
|
def test_a_board_may_not_put_one_question_on_twice():
|
|
know = _board(('1', '3.3'), ('2', '3.3'))
|
|
got = knowledge.profile_for(know, member_kind='wall_panel')
|
|
assert got['items'] == [('1', '3.3')]
|
|
assert 'twice' in got['problem']
|
|
|
|
|
|
# ------------------------------------------- how a human names a check now
|
|
|
|
def test_a_bare_number_is_a_master_id_and_always_was():
|
|
"""Every ruling written before 2026-08-31 cites a bare number, and every
|
|
one of them still means exactly what it meant."""
|
|
got, why, problem = knowledge.resolve_check_reference(
|
|
{}, '3.3', ids=ticketpage.CHECKLIST_IDS)
|
|
assert got == '3.3'
|
|
assert problem is None
|
|
assert 'master id' in why
|
|
|
|
|
|
def test_a_qualified_reference_resolves_through_the_board():
|
|
know = _board(('2.1', '3.3'))
|
|
got, why, problem = knowledge.resolve_check_reference(
|
|
know, 'sample_plant-wall_panel 2.1', plant='sample_plant',
|
|
ids=ticketpage.CHECKLIST_IDS)
|
|
assert got == '3.3'
|
|
assert problem is None
|
|
assert '3.3' in why
|
|
|
|
|
|
def test_a_qualified_reference_for_another_plant_is_refused():
|
|
know = _board(('2.1', '3.3'))
|
|
got, _why, problem = knowledge.resolve_check_reference(
|
|
know, 'other_plant-wall_panel 2.1', plant='sample_plant',
|
|
ids=ticketpage.CHECKLIST_IDS)
|
|
assert got is None
|
|
assert 'REFUSED' in problem
|
|
|
|
|
|
def test_an_unplaceable_reference_is_reported_never_dropped():
|
|
got, _why, problem = knowledge.resolve_check_reference(
|
|
{}, '9.9', ids=ticketpage.CHECKLIST_IDS)
|
|
assert got is None and problem
|
|
got, _why, problem = knowledge.resolve_check_reference(
|
|
_board(('2.1', '3.3')), 'sample_plant-wall_panel 9.9',
|
|
plant='sample_plant', ids=ticketpage.CHECKLIST_IDS)
|
|
assert got is None
|
|
assert 'no such number' in problem
|
|
|
|
|
|
def test_a_mute_is_stored_under_the_master_id_whatever_it_was_written_as():
|
|
"""THE PROPERTY THE WHOLE GRAMMAR EXISTS FOR.
|
|
|
|
A mute keyed by a board POSITION would move the moment the plant
|
|
renumbered. Keyed by the master id it follows the QUESTION, so
|
|
everything downstream -- the row, the JSON, the report -- speaks about
|
|
the same check whatever the reference was typed as.
|
|
"""
|
|
know = _board(('2.1', '3.3'))
|
|
know['checks'] = {'muted': {'sample_plant-wall_panel 2.1': 'quiet please'}}
|
|
assert ticketpage.muted_checks(
|
|
know, resolver=knowledge.resolve_check_reference,
|
|
plant='sample_plant') == {'3.3': 'quiet please'}
|
|
|
|
|
|
def test_a_renumbering_can_refuse_an_old_reference_but_never_re_point_it():
|
|
"""THE LIMIT OF THE QUALIFIED FORM, STATED RATHER THAN PAPERED OVER.
|
|
|
|
A qualified reference is a position on a board, and it is resolved
|
|
against the board AS IT IS NOW. If the plant moves that question from
|
|
2.1 to 7.4, a mute still written `...-wall_panel 2.1` has two possible
|
|
fates and only one of them is acceptable:
|
|
|
|
* silently resolve to whatever sits at 2.1 today -- a ruling now
|
|
quietening a DIFFERENT question, which is the ambush this whole
|
|
design exists to prevent;
|
|
* refuse, loudly, and be reported as a reference nobody can place.
|
|
|
|
It refuses. So a renumbering can COST a reference and can never
|
|
CORRUPT one -- and the bare master id, which is what every existing
|
|
mute uses, cannot even be cost.
|
|
"""
|
|
know = _board(('2.1', '3.3'))
|
|
know['checks'] = {'muted': {'sample_plant-wall_panel 2.1': 'quiet please'}}
|
|
know['checklists']['profiles']['wp']['items'] = ['7.4 = 3.3', '2.1 = 1.1']
|
|
problems = ticketpage.muted_reference_problems(know, plant='sample_plant')
|
|
assert problems == [] # 2.1 still exists -- it just means 1.1 now
|
|
# ... and it resolves to 1.1, NOT to 3.3. Which is why a reference like
|
|
# this is reported on every run and a bare master id is the durable form.
|
|
assert ticketpage.muted_checks(
|
|
know, resolver=knowledge.resolve_check_reference,
|
|
plant='sample_plant') == {'1.1': 'quiet please'}
|
|
|
|
# And when the number is gone entirely, it is refused rather than
|
|
# dropped or guessed.
|
|
know['checklists']['profiles']['wp']['items'] = ['7.4 = 3.3']
|
|
problems = ticketpage.muted_reference_problems(know, plant='sample_plant')
|
|
assert [w for (w, _p) in problems] == ['sample_plant-wall_panel 2.1']
|
|
assert 'no such number' in problems[0][1]
|
|
|
|
|
|
def test_todays_bare_mutes_keep_working_untouched():
|
|
know = {'checks': {'muted': {'3.3': 'a', '5.4': 'b', '1.4': 'c'}}}
|
|
muted = ticketpage.muted_checks(
|
|
know, resolver=knowledge.resolve_check_reference)
|
|
assert sorted(muted) == ['1.4', '3.3', '5.4']
|
|
assert ticketpage.muted_reference_problems(know) == []
|
|
|
|
|
|
def test_a_mute_nobody_can_place_is_reported_not_discarded():
|
|
know = {'checks': {'muted': {'9.9': 'a'}}}
|
|
problems = ticketpage.muted_reference_problems(know)
|
|
assert [w for (w, _p) in problems] == ['9.9']
|
|
# ... and it is still carried, under what was written, so
|
|
# `apply_muting` can say it mutes nothing.
|
|
assert '9.9' in ticketpage.muted_checks(
|
|
know, resolver=knowledge.resolve_check_reference)
|
|
|
|
|
|
def test_qualified_reference_is_spelled_one_way_everywhere():
|
|
assert knowledge.qualified_reference('sample_plant', 'wall_panel',
|
|
'2.1') == \
|
|
'sample_plant-wall_panel 2.1'
|
|
assert knowledge.qualified_reference(None, None, '3.3') == '3.3'
|
|
|
|
|
|
# ============================================================================
|
|
# CONNECTORS -- 5.8 exist, 5.9 sufficient, 5.10 edge distance
|
|
# ============================================================================
|
|
#
|
|
# Owner, 2026-08-31: "insulated panel 加一个 check,检查 connector 是不是存在,
|
|
# 以及够不够" and "再增加 connector edge distance 的 check".
|
|
#
|
|
# The numbers below are MEASURED off the live model the same day and are the
|
|
# calibration these three rows were written against: one insulated piece with
|
|
# five rows of pins across, 16.0 in field spacing (which IS the design
|
|
# summary's 1'-4" O.C.), tighter 9.5/6.5 pairs at the ends, and an in-plane
|
|
# minimum edge distance of 6.0 in on four pins with a median of 23 in.
|
|
|
|
|
|
def test_zero_connectors_on_an_insulated_panel_is_a_fail():
|
|
"""NOT A WARN. A sandwich panel whose wythes are not tied together is a
|
|
structural condition. Every other zero-shaped finding here is a WARN
|
|
because a zero usually means the tool could not see something -- this
|
|
one is only ever reached after the recognition gate has confirmed it
|
|
can see, so a zero here is a zero in the piece."""
|
|
severity, message = ticketpage.connectors_exist_status(0)
|
|
assert severity == 'FAIL'
|
|
assert 'ties the two concrete wythes' in message
|
|
assert ticketpage.state_color(severity) == 'red'
|
|
|
|
|
|
def test_connectors_present_is_a_pass_that_says_how_many():
|
|
severity, message = ticketpage.connectors_exist_status(115)
|
|
assert severity == 'OK'
|
|
assert '115' in message
|
|
|
|
|
|
def test_a_gate_that_excluded_everything_is_named_in_the_failure():
|
|
"""Either the piece has untied wythes or the plant's component gate is
|
|
wrong. Both need somebody, and a message that named only the first
|
|
would send him to the wrong one."""
|
|
severity, message = ticketpage.connectors_exist_status(0, gated_out=40)
|
|
assert severity == 'FAIL'
|
|
assert 'component gate' in message
|
|
assert '40' in message
|
|
|
|
|
|
def test_sufficiency_says_nothing_while_the_design_side_is_unreadable():
|
|
"""The count alone is a number, not a judgement. WP115's design summary
|
|
says `HK ST100'S @1'-4" O.C.` and 8.1 lists it among the lines the
|
|
parser cannot interpret -- so there is nothing to compare against, and
|
|
printing OK because nobody stated a requirement is the silent pass this
|
|
table exists to refuse."""
|
|
severity, message = ticketpage.connectors_sufficient_status(115, None)
|
|
assert severity is None
|
|
assert 'no connector requirement could be resolved' in message
|
|
assert '115' in message
|
|
|
|
|
|
def test_end_densification_is_normal_and_is_not_flagged():
|
|
"""MEASURED: 16.0 in in the field, with 9.5 and 6.5 in pairs at the
|
|
ends. A gap TIGHTER than the design calls for is never the defect, so
|
|
the comparison is against the widest gap."""
|
|
spacing = ticketpage.connector_spacing([0, 6.5, 16, 32, 48, 57.5, 64])
|
|
assert 6.5 in spacing and 16.0 in spacing
|
|
severity, message = ticketpage.connectors_sufficient_status(
|
|
7, None, spacing_in=spacing, required_spacing_in=16.0,
|
|
tolerance_in=0.5, tolerance_why='0.5 in, from x')
|
|
assert severity == 'OK'
|
|
assert '16' in message
|
|
# The allowance is IN the sentence, pass as well as fail: a green row
|
|
# that does not say what it allowed cannot be argued with.
|
|
assert '0.5 in, from x' in message
|
|
|
|
|
|
def test_a_gap_wider_than_the_design_calls_for_fails():
|
|
severity, message = ticketpage.connectors_sufficient_status(
|
|
3, None, spacing_in=[16.0, 30.0], required_spacing_in=16.0,
|
|
tolerance_in=0.5, tolerance_why='0.5 in, from x')
|
|
assert severity == 'FAIL'
|
|
assert '30' in message
|
|
|
|
|
|
def test_no_tolerance_means_no_comparison_and_never_a_borrowed_one():
|
|
"""The 5.10 lesson one row along: a number borrowed from somewhere else
|
|
is not this comparison's, and a spacing verdict reached on a tolerance
|
|
nobody supplied would be exactly that. Both the measurement and the
|
|
requirement are still named, so the reader can see what was NOT
|
|
compared."""
|
|
severity, message = ticketpage.connectors_sufficient_status(
|
|
3, None, spacing_in=[16.0, 30.0], required_spacing_in=16.0,
|
|
tolerance_in=None, tolerance_why='tolerances.connector_spacing_in '
|
|
'is unset')
|
|
assert severity is None
|
|
assert '30' in message and '16' in message
|
|
assert 'tolerances.connector_spacing_in is unset' in message
|
|
|
|
|
|
def test_the_requirement_note_rides_on_every_sufficiency_sentence():
|
|
""""16 in on centre" and "16 in on centre, because THIS line says so"
|
|
are different claims, and only the second can be disagreed with."""
|
|
note = 'the design summary line names ST100, declared as connector R82'
|
|
for spacing, required, expected in (([16.0], 16.0, 'OK'),
|
|
([30.0], 16.0, 'FAIL')):
|
|
severity, message = ticketpage.connectors_sufficient_status(
|
|
3, None, spacing_in=spacing, required_spacing_in=required,
|
|
requirement_note=note, tolerance_in=0.5, tolerance_why='0.5 in')
|
|
assert severity == expected
|
|
assert note in message
|
|
|
|
|
|
def test_too_few_connectors_fails_against_a_stated_count():
|
|
severity, message = ticketpage.connectors_sufficient_status(4, 10)
|
|
assert severity == 'FAIL'
|
|
assert '4' in message and '10' in message
|
|
|
|
|
|
def test_edge_distance_says_nothing_while_no_minimum_is_published():
|
|
"""The owner will supply the datasheets. Until then the row stands down
|
|
naming the missing datum -- and it may NEVER borrow another product's
|
|
number."""
|
|
severity, message = ticketpage.connector_edge_status(
|
|
[6.0, 23.0], None, None, mark='R82')
|
|
assert severity is None
|
|
assert 'not written down anywhere' in message
|
|
assert '6.0' in message
|
|
|
|
|
|
def test_edge_distance_names_whether_the_number_is_the_products_own():
|
|
"""6.2's lesson, one part along: "6 inches" and "6 inches for this
|
|
product" are different claims, and a reader deciding whether to move a
|
|
pin needs to know which one he was handed."""
|
|
_sev, product = ticketpage.connector_edge_status(
|
|
[6.0, 23.0], 5.0, 'product', mark='R82')
|
|
assert 'published minimum for R82' in product
|
|
_sev, plant = ticketpage.connector_edge_status(
|
|
[6.0, 23.0], 5.0, 'plant', mark='R82')
|
|
assert 'PLANT-WIDE' in plant
|
|
assert "NOT this product's own" in plant
|
|
|
|
|
|
def test_a_connector_inside_the_minimum_is_a_warn_that_names_it():
|
|
severity, message = ticketpage.connector_edge_status(
|
|
[6.0, 6.0, 23.0], 8.0, 'product', mark='R82')
|
|
assert severity == 'WARN'
|
|
assert '2 connector(s)' in message
|
|
assert '6.0 in' in message
|
|
|
|
|
|
def test_the_measured_six_inches_passes_a_six_inch_minimum():
|
|
"""The calibration. The smallest edge distance actually built on the
|
|
measured piece is 6.0 in and the owner has raised nothing about it, so
|
|
a limit AT 6 must not fire -- while a limit above 6 is a finding to
|
|
surface when his datasheet lands, not a reason to set the limit at 6."""
|
|
severity, _m = ticketpage.connector_edge_status(
|
|
[6.0, 23.0], 6.0, 'product', mark='R82')
|
|
assert severity == 'OK'
|
|
severity, _m = ticketpage.connector_edge_status(
|
|
[6.0, 23.0], 8.0, 'product', mark='R82')
|
|
assert severity == 'WARN'
|
|
|
|
|
|
# ------------------------------------------------ the knowledge behind them
|
|
|
|
def _conn(**over):
|
|
know = {
|
|
'connectors': {
|
|
'marks': {'g1': ['R80', 'R81'], 'g2': ['R82']},
|
|
'catalogue_alias': {'R80': 'HK Composites (ST50)',
|
|
'R82': 'HK Composites (ST100)',
|
|
'R81': None},
|
|
'products': {},
|
|
'recognition_verified': True,
|
|
}}
|
|
know['connectors'].update(over)
|
|
know['connectors']['products'] = {
|
|
'HK Composites (ST50)': {'min_edge_distance_in': None,
|
|
'min_edge_distance_unknown_because':
|
|
'awaiting the datasheet'},
|
|
'HK Composites (ST100)': {'min_edge_distance_in': 8.0},
|
|
}
|
|
return know
|
|
|
|
|
|
def test_connector_marks_flatten_the_groups_the_owner_named():
|
|
assert knowledge.connector_marks(_conn()) == ['R80', 'R81', 'R82']
|
|
|
|
|
|
def test_a_connector_number_resolves_to_its_published_product():
|
|
assert knowledge.connector_product(_conn(), 'R82')[0] == \
|
|
'HK Composites (ST100)'
|
|
assert knowledge.connector_product(_conn(), 'r80')[0] == \
|
|
'HK Composites (ST50)'
|
|
|
|
|
|
def test_an_unpaired_connector_number_borrows_nothing():
|
|
"""A guessed pairing would give a real connector another product's
|
|
capacity and another product's edge distance -- the quiet kind of
|
|
wrong."""
|
|
key, entry, why = knowledge.connector_product(_conn(), 'R81')
|
|
assert key is None and entry is None
|
|
assert 'Nothing was borrowed' in why
|
|
|
|
|
|
def test_the_edge_minimum_is_product_first_and_the_fallback_is_named():
|
|
value, source, _why = knowledge.connector_edge_minimum(_conn(), 'R82')
|
|
assert (value, source) == (8.0, 'product')
|
|
|
|
# ST50 publishes none. With no plant fallback there is no answer at all.
|
|
value, source, why = knowledge.connector_edge_minimum(_conn(), 'R80')
|
|
assert value is None and source is None
|
|
assert 'awaiting the datasheet' in why
|
|
assert 'Nothing was borrowed' in why
|
|
|
|
# With a plant fallback it is used AND flagged as the weaker claim.
|
|
value, source, why = knowledge.connector_edge_minimum(
|
|
_conn(min_edge_distance_in=6.0), 'R80')
|
|
assert (value, source) == (6.0, 'plant')
|
|
assert 'WEAKER claim' in why
|
|
|
|
|
|
def test_an_exact_alias_match_never_swallows_a_longer_number():
|
|
know = _conn()
|
|
know['connectors']['catalogue_alias'] = {'R8': 'HK Composites (ST50)'}
|
|
assert knowledge.connector_product(know, 'R80')[0] is None
|
|
|
|
|
|
def test_the_edge_distance_excludes_the_thickness_axis():
|
|
"""THE MEASUREMENT CORRECTION. Every pin sits at exact mid-depth, so a
|
|
three-axis minimum reports HALF THE PANEL THICKNESS on every connector
|
|
of every piece -- a number that is real, constant, and about nothing.
|
|
An edge distance is a distance to an EDGE; the two faces are not edges.
|
|
|
|
The thickness axis is derived (the local span closest to the panel's own
|
|
thickness), not assumed to be an index -- `strand_layout_of`'s idiom,
|
|
and for the same reason: a nearly square piece defeats any shortcut.
|
|
"""
|
|
class _Frame(object):
|
|
pass
|
|
|
|
lo, hi = (0.0, 0.0, 0.0), (20.0, 1.3333, 8.0) # ft: 20 long, 16" thick
|
|
# A pin at mid-depth, 6 in from the near long edge.
|
|
point = (10.0, 0.6667, 0.5)
|
|
got = _edge_of(lo, hi, point, thickness_ft=1.3333)
|
|
assert got == 6.0 # not 8.0
|
|
|
|
|
|
def _edge_of(lo, hi, local, thickness_ft):
|
|
"""The arithmetic `ticketqc.connector_edges` performs, isolated.
|
|
|
|
`connector_edges` itself needs a live panel and frame to reach the
|
|
concrete extents, so what is pinned here is the DECISION -- which axis
|
|
is dropped -- rather than the Revit plumbing around it.
|
|
"""
|
|
span = [hi[i] - lo[i] for i in (0, 1, 2)]
|
|
thick = 0
|
|
for axis in (1, 2):
|
|
if abs(span[axis] - thickness_ft) < abs(span[thick] - thickness_ft):
|
|
thick = axis
|
|
gaps = []
|
|
for axis in [i for i in (0, 1, 2) if i != thick]:
|
|
gaps.append(local[axis] - lo[axis])
|
|
gaps.append(hi[axis] - local[axis])
|
|
return round(min(gaps) * 12.0, 3)
|
|
|
|
|
|
# ==================== insulation: the solid zones, and which edge governs
|
|
#
|
|
# Landed 2026-08-31 for master rows 5.11-5.15 and for 5.10's insulation-edge
|
|
# semantics. Owner, on the edge: "检测 edge 检测的不是 concrete edge,而是
|
|
# insulation edge,所以如果 insulation 有 solid zone 被 cut through 而不只是
|
|
# recess 的话,那么 edge 就是被 cut 之后的 edge。如果只是 recess,则按照原来
|
|
# 的 edge 来算。"
|
|
|
|
INSUL = {
|
|
'insulation': {
|
|
'solid_zone': {'family': 'ZONE_PART', 'family_verified': True},
|
|
'legend': {
|
|
'swatch_types': {'Region A': 'recess_2in_nf',
|
|
'Region B': 'solid_zone'},
|
|
'expected_patterns': {'solid_zone': 'ANSI35',
|
|
'recess_2in_nf': 'TRIANG'},
|
|
'expected_patterns_measured_on': '2026-08-31',
|
|
},
|
|
'recess': {'region_types': ['Region A']},
|
|
},
|
|
'drawings': {'insulation_layout': 'Layout View 1'},
|
|
}
|
|
|
|
|
|
def test_the_layout_view_is_a_key_and_an_unset_one_says_so():
|
|
from vibe import knowledge
|
|
assert knowledge.insulation_layout_view(INSUL) == ('Layout View 1', None)
|
|
name, why = knowledge.insulation_layout_view({})
|
|
assert name is None
|
|
assert 'drawings.insulation_layout is unset' in why
|
|
assert 'not a finding about this piece' in why
|
|
|
|
|
|
def test_an_unverified_solid_zone_family_is_a_stand_down_not_a_name():
|
|
"""A family name that matches nothing counts ZERO zones on every piece,
|
|
and zero zones reads as "no lifter is missing one"."""
|
|
from vibe import knowledge
|
|
know = {'insulation': {'solid_zone': {'family': 'ZONE_PART',
|
|
'family_verified': False}}}
|
|
family, why = knowledge.solid_zone_rule(know)
|
|
assert family is None
|
|
assert 'family_verified is false' in why
|
|
assert knowledge.solid_zone_rule(INSUL) == ('ZONE_PART', None)
|
|
|
|
|
|
def test_the_legend_beats_the_plant_map_and_the_source_is_returned():
|
|
"""The legend is the sheet's own statement of intent and the only
|
|
self-updating reference. The plant map is a copy, and says so."""
|
|
from vibe import knowledge
|
|
pattern, source, why = knowledge.expected_hatch_pattern(
|
|
INSUL, 'solid_zone', {'solid_zone': 'ANSI99'})
|
|
assert (pattern, source) == ('ANSI99', 'legend')
|
|
pattern, source, why = knowledge.expected_hatch_pattern(
|
|
INSUL, 'solid_zone')
|
|
assert (pattern, source) == ('ANSI35', 'plant')
|
|
assert 'can be stale' in why
|
|
|
|
|
|
def test_an_unknown_concept_never_borrows_a_neighbours_pattern():
|
|
"""The whole defect these rows exist for is one concept drawn with
|
|
another's hatch, so a fallback across concepts would be the bug."""
|
|
from vibe import knowledge
|
|
pattern, source, why = knowledge.expected_hatch_pattern(INSUL, 'cap_2in')
|
|
assert pattern is None and source is None
|
|
assert 'No neighbouring concept' in why
|
|
|
|
|
|
def test_the_swatch_map_is_upper_cased_so_a_type_name_matches_either_way():
|
|
from vibe import knowledge
|
|
got = knowledge.legend_swatch_concepts(INSUL)
|
|
assert got['REGION A'] == 'recess_2in_nf'
|
|
assert knowledge.legend_swatch_concepts({}) == {}
|
|
|
|
|
|
def test_a_depth_is_read_out_of_a_description_and_absence_is_none():
|
|
from vibe import knowledge
|
|
assert knowledge.zone_depth_in('3" SOLID ZONE') == 3.0
|
|
assert knowledge.zone_depth_in('4 in SOLID ZONE') == 4.0
|
|
assert knowledge.zone_depth_in('SOLID ZONE') is None
|
|
assert knowledge.zone_depth_in(None) is None
|
|
|
|
|
|
def test_through_cut_is_inferred_and_is_labelled_inferred():
|
|
"""It is a reading of two names, not a measurement of two solids, and
|
|
every finding built on it has to say so."""
|
|
from vibe import knowledge
|
|
through, why = knowledge.zone_cuts_through(INSUL, '4" SOLID ZONE', 4.0)
|
|
assert through is True
|
|
assert 'INFERRED FROM NAMES' in why
|
|
assert 'moves the insulation edge' in why
|
|
|
|
|
|
def test_a_partial_zone_leaves_the_original_edge_in_place():
|
|
from vibe import knowledge
|
|
through, why = knowledge.zone_cuts_through(INSUL, '3" SOLID ZONE', 4.0)
|
|
assert through is False
|
|
assert 'leaves the original edge in place' in why
|
|
|
|
|
|
def test_an_unreadable_depth_is_none_and_never_defaults_either_way():
|
|
"""False would quietly measure to the old edge -- the permissive
|
|
direction -- and True would invent an edge that may not exist."""
|
|
from vibe import knowledge
|
|
through, why = knowledge.zone_cuts_through(INSUL, 'SOLID ZONE', 4.0)
|
|
assert through is None
|
|
assert 'nothing near it was judged' in why
|
|
through, why = knowledge.zone_cuts_through(INSUL, '3" SOLID ZONE', None)
|
|
assert through is None
|
|
assert 'insulation thickness is not known' in why
|
|
|
|
|
|
def test_st100_carries_the_owners_number_and_st50_still_does_not():
|
|
""""pin edge distance = 3". ST100 的知识。写。" -- one product's number,
|
|
and borrowing it is what min_edge_distance_unknown_because forbids."""
|
|
from vibe import knowledge
|
|
cat = knowledge.load_catalogue() if hasattr(knowledge, 'load_catalogue') \
|
|
else None
|
|
know = {'connectors': {
|
|
'catalogue_alias': {'R80': 'HK Composites (ST50)',
|
|
'R87': 'HK Composites (ST100)'},
|
|
'products': knowledge.get(_catalogue(), 'connectors.products')}}
|
|
value, source, why = knowledge.connector_edge_minimum(know, 'R87')
|
|
assert value == 3.0
|
|
assert source == 'product'
|
|
value, source, why = knowledge.connector_edge_minimum(know, 'R80')
|
|
assert value is None
|
|
assert 'Nothing was borrowed' in why
|
|
|
|
|
|
def _catalogue():
|
|
"""The published catalogue as a dict, read the way the tool reads it."""
|
|
from vibe import ticketpage
|
|
from pyrevit_yaml_fake import strings_only_yaml_reader
|
|
from vibe import knowledge
|
|
return knowledge._coerce(
|
|
strings_only_yaml_reader()(ticketpage.master_path()))
|