8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
4567 lines
196 KiB
Python
4567 lines
196 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""The `ticketchecks` rulings that are pure decision, tested off-model.
|
||
|
||
WHY THIS FILE NEEDS A TRICK AND `test_ticketpage.py` DOES NOT
|
||
--------------------------------------------------------------
|
||
`vibe.ticketqc` imports eight names from `Autodesk.Revit.DB` AT MODULE SCOPE,
|
||
and `vibe.ticketchecks` imports `Finding` from `ticketqc`, so neither can be
|
||
imported on host CPython the way `ticketpage` can. CLAUDE.md names them as
|
||
the standing example of code the test conftest cannot reach.
|
||
|
||
It cannot reach them *by itself*. Eight names is a small enough surface to
|
||
stand up as a stub, and the two things tested below never touch Revit at all:
|
||
|
||
* checklist 4.7's disposition is a decision over a dict
|
||
(`_debond_requirement`'s result), and that dict is CACHED ON THE TICKET
|
||
-- so a test can put the answer there and the geometry is never asked
|
||
for. That cache is not a hook added for testing; it exists because four
|
||
callers were reading the panel solid four times.
|
||
* checklist 8.1 reads a spec dict and formats a sentence.
|
||
* checklist 7.1's PAIR LIST is text and a file path. The geometry was
|
||
solved long before `clash_csv_rows` sees a hit -- it gets the same
|
||
dicts `member_boxes` built, and reads three keys off them.
|
||
|
||
The stub is deliberately inert: every name is a bare class that does
|
||
nothing. If a future edit makes one of these functions actually call the
|
||
Revit API, the test does not quietly pass -- it raises, which is the correct
|
||
outcome, because the function would no longer be the pure decision this file
|
||
claims it is.
|
||
"""
|
||
# pylint: disable=import-error,invalid-name,broad-except
|
||
import contextlib
|
||
import sys
|
||
import types
|
||
|
||
import pytest
|
||
|
||
|
||
def _stub_revit():
|
||
"""`Autodesk.Revit.DB`, inert, for a host interpreter.
|
||
|
||
Returns the names it put into `sys.modules`, so they can be taken back
|
||
out again -- see below, and read that comment before reusing this.
|
||
"""
|
||
if 'Autodesk.Revit.DB' in sys.modules:
|
||
return []
|
||
autodesk = types.ModuleType('Autodesk')
|
||
revit = types.ModuleType('Autodesk.Revit')
|
||
db = types.ModuleType('Autodesk.Revit.DB')
|
||
for name in ('FilteredElementCollector', 'AssemblyInstance', 'View',
|
||
'ViewSheet', 'ViewType', 'BuiltInCategory', 'Element',
|
||
'StorageType', 'ElementId', 'XYZ', 'Transform',
|
||
'Options', 'Solid', 'BoundingBoxXYZ'):
|
||
setattr(db, name, type(str(name), (object,), {}))
|
||
autodesk.Revit = revit
|
||
revit.DB = db
|
||
sys.modules['Autodesk'] = autodesk
|
||
sys.modules['Autodesk.Revit'] = revit
|
||
sys.modules['Autodesk.Revit.DB'] = db
|
||
return ['Autodesk.Revit.DB', 'Autodesk.Revit', 'Autodesk']
|
||
|
||
|
||
# THE STUB IS PUT BACK. `sys.modules` is process-wide and pytest imports
|
||
# every test module before it runs any of them, so a stub left behind here
|
||
# is a stub present during every other test in the suite --
|
||
# `test_reinforceoffline.py` proves the offline path never pulls in Revit by
|
||
# asserting that no `Autodesk*` module is loaded, and it would have failed on
|
||
# a lie this file told. The imported modules keep their own references to the
|
||
# stub classes, so removing the entries costs them nothing.
|
||
_ADDED = _stub_revit()
|
||
try:
|
||
from vibe import ticketchecks as TC # noqa: E402
|
||
from vibe import ticketpage as TP # noqa: E402
|
||
# `ticketchecks` has already pulled this in (it takes `Finding` from it),
|
||
# so this binds a name and imports nothing new. 8.1's other source is
|
||
# here: `parse_design_summary` is regexes over text notes, and the notes
|
||
# are the only thing it needs a document for -- see `_summary_view`.
|
||
from vibe import ticketqc as QC # noqa: E402
|
||
finally:
|
||
for _name in _ADDED:
|
||
sys.modules.pop(_name, None)
|
||
|
||
|
||
def _know():
|
||
from vibe import knowledge
|
||
return knowledge._deep_copy(knowledge.DEFAULTS)
|
||
|
||
|
||
def _ticket(requirement):
|
||
"""A ticket whose 4.7 answer is already solved.
|
||
|
||
`_debond_requirement` returns its cache when one is present, so nothing
|
||
below reaches for a panel, a frame or a solid.
|
||
"""
|
||
return {'mark': 'TEST-1', '_debond_requirement': requirement}
|
||
|
||
|
||
REASON = 'panel geometry for the debond check is not solvable'
|
||
|
||
|
||
# ================================================================== 4.7
|
||
#
|
||
# Owner's report of a WP133 run, 2026-08-30: 4.7 read NOT RUN with the
|
||
# reason "piece has no through-thickness opening -- no strand run needs
|
||
# debonding". That sentence is a JUDGEMENT, not an inability: the geometry
|
||
# was solved (`_debond_requirement`'s `reason` was None) and it found zero
|
||
# through-thickness openings. A piece that cannot interrupt a strand run
|
||
# does not need debonding, and saying so is a pass.
|
||
#
|
||
# NOT RUN has to keep meaning exactly one thing -- "could not judge" -- or
|
||
# the red it is printed in stops meaning anything. Rendering an answered
|
||
# question as NOT RUN breaks that rule from the other side, and it is worth
|
||
# a fix precisely because the rule is the product.
|
||
|
||
|
||
def test_47_no_opening_is_a_pass_not_a_silence():
|
||
findings = TC.check_strand_debond(
|
||
_ticket({'runs': [], 'reason': None, 'has_openings': False,
|
||
'threshold_ft': 4.0}), {}, _know(), None)
|
||
assert len(findings) == 1
|
||
assert findings[0].severity == 'OK'
|
||
assert findings[0].code == 'STRAND-DEBOND'
|
||
assert 'no opening interrupts any strand run' in findings[0].message
|
||
|
||
|
||
def test_47_no_opening_declares_nothing_to_the_skip_probe():
|
||
"""The probe is what turns a silence into NOT RUN. It must stay quiet."""
|
||
probe = TC._SKIP_PROBES['check_strand_debond']
|
||
assert probe(_ticket({'runs': [], 'reason': None, 'has_openings': False,
|
||
'threshold_ft': 4.0}), {}, _know(), None) is None
|
||
|
||
|
||
def test_47_unreadable_geometry_still_reports_not_run():
|
||
"""The distinction that matters, and the half that must NOT move.
|
||
|
||
Judged-and-clear is now OK. Could-not-look is still NOT RUN, with the
|
||
sentence that says why.
|
||
"""
|
||
t = _ticket({'runs': None, 'reason': REASON, 'has_openings': False,
|
||
'threshold_ft': 4.0})
|
||
assert TC.check_strand_debond(t, {}, _know(), None) == []
|
||
probe = TC._SKIP_PROBES['check_strand_debond']
|
||
assert probe(t, {}, _know(), None) == REASON
|
||
|
||
|
||
def test_47_openings_that_leave_enough_concrete_still_pass_their_own_way():
|
||
"""A piece WITH openings that all develop is a different OK sentence.
|
||
|
||
Two passes that mean different things must read differently, or the
|
||
reader learns nothing from either.
|
||
"""
|
||
findings = TC.check_strand_debond(
|
||
_ticket({'runs': [], 'reason': None, 'has_openings': True,
|
||
'threshold_ft': 4.0}), {}, _know(), None)
|
||
assert findings[0].severity == 'OK'
|
||
assert 'still develops' in findings[0].message
|
||
assert 'no opening interrupts' not in findings[0].message
|
||
|
||
|
||
def test_47_a_short_run_still_warns():
|
||
findings = TC.check_strand_debond(
|
||
_ticket({'reason': None, 'has_openings': True, 'threshold_ft': 4.0,
|
||
'runs': [{'length_ft': 2.5, 'x0': 10.0, 'x1': 12.5,
|
||
'strands_in': [3.0, 9.0]}]}),
|
||
{}, _know(), None)
|
||
assert findings[0].severity == 'WARN'
|
||
assert 'must be debonded' in findings[0].message
|
||
|
||
|
||
def test_47_reaches_the_checklist_as_ok():
|
||
"""End to end through the table the reader actually sees."""
|
||
findings = TC.check_strand_debond(
|
||
_ticket({'runs': [], 'reason': None, 'has_openings': False,
|
||
'threshold_ft': 4.0}), {}, _know(), None)
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
findings, [('check_strand_debond', 'RAN', None)]))
|
||
assert rows['4.7'][3] == 'OK'
|
||
assert TP.state_color(rows['4.7'][3]) == 'green'
|
||
|
||
|
||
def test_47_reaches_the_checklist_as_red_when_it_could_not_look():
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_strand_debond', 'SKIPPED', REASON)]))
|
||
assert rows['4.7'][3] == 'NOT RUN'
|
||
assert rows['4.7'][4] == REASON
|
||
assert TP.state_color(rows['4.7'][3]) == 'red'
|
||
|
||
|
||
def test_45_still_stands_down_only_on_a_real_failure_to_judge():
|
||
"""4.5 consumes 4.7. Flipping 4.7 must not have moved 4.5.
|
||
|
||
4.5 stands down when 4.7 COULD NOT judge. "No openings" was never that,
|
||
and 4.5's probe reads `reason` -- which is why the flip was safe.
|
||
"""
|
||
know = _know()
|
||
know['strand']['debond_callout_tokens'] = ['DEBOND']
|
||
probe = TC._SKIP_PROBES['check_debond_callout']
|
||
judged = _ticket({'runs': [], 'reason': None, 'has_openings': False,
|
||
'threshold_ft': 4.0})
|
||
judged['_view_text'] = {'BED PLAN': 'nothing about debonding'}
|
||
assert probe(judged, {}, know, None) is None
|
||
|
||
blind = _ticket({'runs': None, 'reason': REASON, 'has_openings': False,
|
||
'threshold_ft': 4.0})
|
||
blind['_view_text'] = {'BED PLAN': ''}
|
||
assert REASON in probe(blind, {}, know, None)
|
||
|
||
|
||
# ================================================================== 8.1
|
||
#
|
||
# Owner, reading WP133: "都哪些lines not interpreted?" -- 8.1 printed
|
||
# "4 line(s) of the design summary were not interpreted" and then named
|
||
# three of them and an ellipsis. A count the reader cannot resolve into the
|
||
# things counted is not information; it is a prompt to go and look somewhere
|
||
# the tool had already looked.
|
||
|
||
def _spec(n):
|
||
return {'unresolved': [{'text': 'LINE {0}'.format(i), 'reason': 'x'}
|
||
for i in range(n)]}
|
||
|
||
|
||
def test_81_names_every_line_not_the_first_three():
|
||
findings = TC.check_spec_unresolved({}, {}, _know(), _spec(7))
|
||
assert len(findings) == 1
|
||
message = findings[0].message
|
||
for i in range(7):
|
||
assert 'LINE {0}'.format(i) in message
|
||
assert '| ...' not in message
|
||
|
||
|
||
def test_81_still_leads_with_the_count():
|
||
findings = TC.check_spec_unresolved({}, {}, _know(), _spec(4))
|
||
assert findings[0].message.startswith('4 line(s)')
|
||
assert findings[0].severity == 'WARN'
|
||
|
||
|
||
# 8.1 was the last row in the table with no skip probe at all: on two live
|
||
# tickets it read "NOT RUN -- no probe declared", because BOTH its empty
|
||
# returns reached `run_all` as RAN-EMPTY. The two are not the same silence
|
||
# and the tests below are one per path.
|
||
|
||
def test_81_a_clean_spec_is_an_explicit_pass_not_a_silence():
|
||
"""A spec that records zero unresolved lines was READ, and every line of
|
||
the design summary matched a rule. That is an answer, and 8.1 says so
|
||
rather than leaving the row to be inferred from an absence."""
|
||
findings = TC.check_spec_unresolved({}, {}, _know(), {'unresolved': []})
|
||
assert len(findings) == 1
|
||
assert findings[0].severity == 'OK'
|
||
assert findings[0].code == 'SPEC-UNRESOLVED'
|
||
assert 'every line of the design summary was interpreted' \
|
||
in findings[0].message
|
||
|
||
|
||
def test_81_clean_spec_declares_nothing_to_the_skip_probe():
|
||
probe = TC._SKIP_PROBES['check_spec_unresolved']
|
||
assert probe({}, {}, _know(), {'unresolved': []}) is None
|
||
assert probe({}, {}, _know(), {'unresolved': None}) is None
|
||
|
||
|
||
def test_81_no_spec_on_disk_is_not_run_with_a_reason():
|
||
"""No spec means nothing recorded which lines went uninterpreted. The
|
||
piece was still judged -- off the regex reading of the summary view --
|
||
and the row has to say that rather than imply a clean design."""
|
||
assert TC.check_spec_unresolved({}, {}, _know(), None) == []
|
||
reason = TC._SKIP_PROBES['check_spec_unresolved']({}, {}, _know(), None)
|
||
assert reason
|
||
assert 'no spec on disk' in reason
|
||
assert 'Extract Design' in reason
|
||
|
||
|
||
def test_81_spec_without_an_unresolved_field_is_not_run():
|
||
""""The extractor never wrote down what it could not interpret" is not
|
||
"the extractor interpreted everything", and only one of those is a pass."""
|
||
spec = {'design_number': 'WP1'}
|
||
assert TC.check_spec_unresolved({}, {}, _know(), spec) == []
|
||
reason = TC._SKIP_PROBES['check_spec_unresolved']({}, {}, _know(), spec)
|
||
assert reason
|
||
assert 'no readable "unresolved" list' in reason
|
||
|
||
|
||
def test_81_unresolved_of_the_wrong_shape_is_not_counted_letter_by_letter():
|
||
"""A hand-edited `unresolved: foo` used to be walked character by
|
||
character and reported as "3 line(s) ... f | o | o"."""
|
||
spec = {'unresolved': 'foo'}
|
||
assert TC.check_spec_unresolved({}, {}, _know(), spec) == []
|
||
assert TC._SKIP_PROBES['check_spec_unresolved']({}, {}, _know(), spec)
|
||
|
||
|
||
def test_81_the_three_silences_render_as_three_different_rows():
|
||
"""End to end through the table: a pass, and two NOT RUNs that say
|
||
different things. None of them may read "no probe declared"."""
|
||
def _row(spec):
|
||
findings = TC.check_spec_unresolved({}, {}, _know(), spec)
|
||
log = []
|
||
reason = TC._SKIP_PROBES['check_spec_unresolved'](
|
||
{}, {}, _know(), spec)
|
||
if findings:
|
||
log.append(('check_spec_unresolved', 'RAN', None))
|
||
elif reason:
|
||
log.append(('check_spec_unresolved', 'SKIPPED', reason))
|
||
else:
|
||
log.append(('check_spec_unresolved', 'RAN-EMPTY', None))
|
||
for row in TP.checklist_states(findings, log):
|
||
if row[0] == '8.1':
|
||
return row
|
||
raise AssertionError('8.1 is not in the checklist')
|
||
|
||
clean = _row({'unresolved': []})
|
||
assert clean[3] == 'OK'
|
||
|
||
no_spec = _row(None)
|
||
assert no_spec[3] == 'NOT RUN'
|
||
assert 'no spec on disk' in no_spec[4]
|
||
|
||
no_field = _row({'design_number': 'WP1'})
|
||
assert no_field[3] == 'NOT RUN'
|
||
assert 'no readable' in no_field[4]
|
||
|
||
for row in (clean, no_spec, no_field):
|
||
assert 'no probe declared' not in (row[4] or '')
|
||
|
||
|
||
def test_81_a_clean_pass_adds_no_enumerated_detail():
|
||
"""`checklist_details` splits SPEC-UNRESOLVED messages on ' | ' to print
|
||
one line per entry. The OK message must not be shredded by it."""
|
||
findings = TC.check_spec_unresolved({}, {}, _know(), {'unresolved': []})
|
||
assert TP.checklist_details(findings) == {}
|
||
|
||
|
||
def test_81_message_splits_back_into_the_lines_the_page_prints():
|
||
"""The two modules must agree on the separator, or the detail is lost.
|
||
|
||
`ticketchecks` joins with SPEC_UNRESOLVED_SEP and `ticketpage` splits on
|
||
its own default. This test is the contract between them.
|
||
"""
|
||
findings = TC.check_spec_unresolved({}, {}, _know(), _spec(5))
|
||
detail = TP.checklist_details(findings)
|
||
assert detail['8.1'] == ['LINE 0', 'LINE 1', 'LINE 2', 'LINE 3', 'LINE 4']
|
||
assert TC.SPEC_UNRESOLVED_SEP == ' | '
|
||
|
||
|
||
def test_81_enumeration_lands_in_the_rendered_table():
|
||
findings = TC.check_spec_unresolved({}, {}, _know(), _spec(4))
|
||
rows = TP.checklist_states(findings,
|
||
[('check_spec_unresolved', 'RAN', None)])
|
||
html = TP.checklist_table_html(rows, TP.checklist_details(findings))
|
||
for i in range(4):
|
||
assert 'LINE {0}'.format(i) in html
|
||
|
||
|
||
# ------------------------------------------------- 8.1's OTHER source
|
||
#
|
||
# The live post-restart run, 2026-08-30. The probe work above narrowed 8.1
|
||
# to spec-backed intent -- and no design has a spec yet, so 8.1 answered
|
||
# "no spec on disk" on every ticket in the run. The owner's earlier run had
|
||
# printed the four lines of a design summary nobody had classified, and
|
||
# those lines never came from a spec: they came from the regex reader,
|
||
# which keeps its own list of notes no rule would place.
|
||
#
|
||
# The rule these tests hold down: 8.1 reports from WHICHEVER parser built
|
||
# the intent, and NOT RUN is only for neither.
|
||
|
||
|
||
def _regex_intent(n):
|
||
return {'source': 'regex',
|
||
'unresolved': [{'text': 'LINE {0}'.format(i), 'reason': 'x'}
|
||
for i in range(n)]}
|
||
|
||
|
||
def test_81_reports_the_regex_readers_own_uninterpreted_lines():
|
||
"""No spec, but the summary was read -- and the reading knows what it
|
||
could not place. That is the enumeration the owner asked for."""
|
||
findings = TC.check_spec_unresolved({}, _regex_intent(4), _know(), None)
|
||
assert len(findings) == 1
|
||
assert findings[0].severity == 'WARN'
|
||
assert findings[0].code == 'SPEC-UNRESOLVED'
|
||
assert findings[0].message.startswith('4 line(s)')
|
||
for i in range(4):
|
||
assert 'LINE {0}'.format(i) in findings[0].message
|
||
|
||
|
||
def test_81_the_regex_enumeration_is_worded_exactly_as_the_spec_one():
|
||
"""The format is the thing that was lost, so it is spelled out here.
|
||
|
||
ticketpage splits on ': ' and then on SPEC_UNRESOLVED_SEP; a message
|
||
that drifts on either side stops producing detail rows silently.
|
||
"""
|
||
from_regex = TC.check_spec_unresolved({}, _regex_intent(4), _know(), None)
|
||
from_spec = TC.check_spec_unresolved({}, {}, _know(), _spec(4))
|
||
assert from_regex[0].message == from_spec[0].message
|
||
assert from_regex[0].message == (
|
||
'4 line(s) of the design summary were not interpreted: '
|
||
'LINE 0 | LINE 1 | LINE 2 | LINE 3')
|
||
assert TP.checklist_details(from_regex)['8.1'] == \
|
||
['LINE 0', 'LINE 1', 'LINE 2', 'LINE 3']
|
||
|
||
|
||
def test_81_the_regex_path_reaches_the_rendered_table_as_a_warn():
|
||
findings = TC.check_spec_unresolved({}, _regex_intent(4), _know(), None)
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
findings, [('check_spec_unresolved', 'RAN', None)]))
|
||
assert rows['8.1'][3] == 'WARN'
|
||
html = TP.checklist_table_html(rows.values(),
|
||
TP.checklist_details(findings))
|
||
for i in range(4):
|
||
assert 'LINE {0}'.format(i) in html
|
||
|
||
|
||
def test_81_a_clean_read_says_which_parser_is_speaking():
|
||
""""Every line was interpreted" is a much weaker claim from a handful of
|
||
regexes than from a reviewed spec, so the reader is told which one said
|
||
it. Both are still an explicit OK, not a silence."""
|
||
from_spec = TC.check_spec_unresolved({}, {}, _know(), {'unresolved': []})
|
||
from_regex = TC.check_spec_unresolved(
|
||
{}, {'source': 'regex', 'unresolved': []}, _know(), None)
|
||
for findings in (from_spec, from_regex):
|
||
assert len(findings) == 1
|
||
assert findings[0].severity == 'OK'
|
||
assert 'every line of the design summary was interpreted' \
|
||
in findings[0].message
|
||
assert TC.SPEC_SOURCE_SPEC in from_spec[0].message
|
||
assert TC.SPEC_SOURCE_REGEX in from_regex[0].message
|
||
assert TC.SPEC_SOURCE_REGEX not in from_spec[0].message
|
||
assert TC.SPEC_SOURCE_SPEC not in from_regex[0].message
|
||
|
||
|
||
def test_81_neither_clean_message_is_shredded_into_detail_rows():
|
||
"""Both OK sentences must stay clear of ': ' -- `checklist_details`
|
||
partitions on it and would print the tail as enumerated lines."""
|
||
for spec, intent in (({'unresolved': []}, {}),
|
||
(None, {'source': 'regex', 'unresolved': []})):
|
||
findings = TC.check_spec_unresolved({}, intent, _know(), spec)
|
||
assert ': ' not in findings[0].message
|
||
assert TP.checklist_details(findings) == {}
|
||
|
||
|
||
def test_81_the_regex_path_declares_nothing_to_the_skip_probe():
|
||
probe = TC._SKIP_PROBES['check_spec_unresolved']
|
||
assert probe({}, _regex_intent(4), _know(), None) is None
|
||
assert probe({}, {'source': 'regex', 'unresolved': []},
|
||
_know(), None) is None
|
||
|
||
|
||
def test_81_not_run_is_now_only_for_no_record_from_either_parser():
|
||
"""The reason has to name both absences, because either one alone is no
|
||
longer enough to stand 8.1 down."""
|
||
reason = TC._SKIP_PROBES['check_spec_unresolved'](
|
||
{}, {'source': 'regex'}, _know(), None)
|
||
assert TC.check_spec_unresolved({}, {'source': 'regex'}, _know(), None) == []
|
||
assert reason
|
||
assert 'no spec on disk' in reason
|
||
assert 'Extract Design' in reason
|
||
assert 'regex reader could not place' in reason
|
||
|
||
|
||
def test_81_an_intent_unresolved_of_the_wrong_shape_is_not_walked_letter_by_letter():
|
||
"""The spec side already refuses to count a stray string character by
|
||
character. The intent side inherits the same refusal."""
|
||
intent = {'source': 'regex', 'unresolved': 'foo'}
|
||
assert TC.check_spec_unresolved({}, intent, _know(), None) == []
|
||
assert TC._SKIP_PROBES['check_spec_unresolved']({}, intent, _know(), None)
|
||
|
||
|
||
def test_81_a_spec_still_outranks_the_intent_when_both_are_present():
|
||
"""The reviewed record wins, and its silences keep their own meaning --
|
||
a spec with no readable field is still NOT RUN even when the intent
|
||
beside it would have answered."""
|
||
assert TC.check_spec_unresolved({}, _regex_intent(2), _know(),
|
||
_spec(5))[0].message.startswith('5 line(s)')
|
||
bare = {'design_number': 'WP1'}
|
||
assert TC.check_spec_unresolved({}, _regex_intent(2), _know(), bare) == []
|
||
assert 'no readable' in TC._SKIP_PROBES['check_spec_unresolved'](
|
||
{}, _regex_intent(2), _know(), bare)
|
||
|
||
|
||
# The plumbing that makes the branch above reachable on a live ticket: the
|
||
# regex reader has to hand its list forward on the intent dict, or 8.1 has
|
||
# nothing to read. `parse_design_summary` is regexes over text notes, and
|
||
# `_text_notes` is the only thing in it that needs a document.
|
||
|
||
class _Coord(object):
|
||
def __init__(self, x, y):
|
||
self.X, self.Y = x, y
|
||
|
||
|
||
class _Note(object):
|
||
def __init__(self, text, x=0.0, y=0.0):
|
||
self.Text = text
|
||
self.Coord = _Coord(x, y)
|
||
|
||
|
||
class _Id(object):
|
||
IntegerValue = 42
|
||
|
||
|
||
class _View(object):
|
||
Name = 'PNL1.0 DESIGN SUMMARY'
|
||
Id = _Id()
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _summary_view(texts):
|
||
"""`parse_design_summary` over exactly these note texts."""
|
||
notes = [_Note(t, y=float(i)) for i, t in enumerate(texts)]
|
||
original = QC._text_notes
|
||
QC._text_notes = lambda doc, view_id: notes
|
||
try:
|
||
yield _View()
|
||
finally:
|
||
QC._text_notes = original
|
||
|
||
|
||
def test_the_regex_reader_carries_its_uninterpreted_lines_on_the_intent():
|
||
lines = ["USE 5,000 KSI MILD", 'STRAND PATTERN', '1/2" DIA. STR.']
|
||
with _summary_view(lines) as view:
|
||
intent = QC.parse_design_summary(None, view)
|
||
assert intent['mild_ksi'] == 5000 # the reader still reads
|
||
texts = [u['text'] for u in intent['unresolved']]
|
||
assert texts == ['STRAND PATTERN', '1/2" DIA. STR.']
|
||
assert all(u['reason'] for u in intent['unresolved'])
|
||
|
||
|
||
def test_a_summary_the_reader_placed_entirely_carries_an_empty_list():
|
||
"""Not a missing key -- the difference between "interpreted everything"
|
||
and "never wrote down what it could not interpret" is the whole point of
|
||
8.1's third state."""
|
||
with _summary_view(["USE 5,000 KSI MILD"]) as view:
|
||
intent = QC.parse_design_summary(None, view)
|
||
assert intent['unresolved'] == []
|
||
assert TC.check_spec_unresolved({}, intent, _know(), None)[0].severity \
|
||
== 'OK'
|
||
|
||
|
||
def test_the_reader_and_the_check_meet_end_to_end():
|
||
"""The regression, from the notes in the view to the row on the page.
|
||
|
||
`STRAND PATTERN` is now PLACED by the summary vocabulary as a group
|
||
label, so 8.1 reports two uninterpreted lines and not three -- and the
|
||
line it placed is still visible, in the inventory clause. That is the
|
||
whole contract of the vocabulary: it shrinks the row by understanding
|
||
lines, never by dropping them.
|
||
"""
|
||
with _summary_view(['STRAND PATTERN', '1/2" DIA. STR.',
|
||
'STRIPPING PER PLANT STANDARD']) as view:
|
||
intent = QC.parse_design_summary(None, view)
|
||
intent['source'] = 'regex'
|
||
findings, log = TC.run_all({'mark': 'TEST-1'}, intent, _know(), None)
|
||
unresolved = [f for f in findings if f.code == 'SPEC-UNRESOLVED']
|
||
assert len(unresolved) == 1
|
||
assert unresolved[0].message.startswith('2 line(s)')
|
||
rows = dict((r[0], r) for r in TP.checklist_states(findings, log))
|
||
assert rows['8.1'][3] == 'WARN'
|
||
details = TP.checklist_details(findings)['8.1']
|
||
assert details[:2] == ['1/2" DIA. STR.', 'STRIPPING PER PLANT STANDARD']
|
||
assert 'group label' in details[-1]
|
||
# The verbatim record on the intent is UNTOUCHED: the reader still wrote
|
||
# down every line its own rules could not place.
|
||
assert [u['text'] for u in intent['unresolved']] == [
|
||
'STRAND PATTERN', '1/2" DIA. STR.', 'STRIPPING PER PLANT STANDARD']
|
||
|
||
|
||
# ================================================================== 6.4
|
||
#
|
||
# Handed back 2026-08-30: `check_dunnage_openings` had six `return []` paths
|
||
# and no probe, so `run_all` logged it RAN-EMPTY and the row read NOT RUN
|
||
# with the reason "no probe declared" -- a sentence about the tool, not
|
||
# about the piece. Every silent return now leaves `_dunnage_silence` behind
|
||
# and a probe reads it, the same shape 5.2 already used.
|
||
#
|
||
# THE TRAP, and it is the whole point of the item: "no size notes found" and
|
||
# "notes found but no parseable feet" are FAILURES TO READ THE TICKET, not
|
||
# clean pieces. They must keep reporting NOT RUN. Only the fall-through
|
||
# after the clearance loop is a pass, and it now says so out loud.
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _revit():
|
||
"""The stub, for the duration of one call, and gone again afterwards.
|
||
|
||
`check_dunnage_openings` imports four Revit names at the top of its own
|
||
body, before its first early return, so the two config paths below
|
||
cannot be reached without it. It is popped again immediately -- see the
|
||
module docstring: a stub left in `sys.modules` breaks
|
||
`test_reinforceoffline`.
|
||
"""
|
||
added = _stub_revit()
|
||
try:
|
||
yield
|
||
finally:
|
||
for name in added:
|
||
sys.modules.pop(name, None)
|
||
|
||
|
||
def _dunnage_probe(t):
|
||
return TC._SKIP_PROBES['check_dunnage_openings'](t, {}, _know(), None)
|
||
|
||
|
||
def test_64_a_ticket_with_no_panel_says_so_instead_of_declaring_no_probe():
|
||
t = {'mark': 'TEST-1'}
|
||
with _revit():
|
||
assert TC.check_dunnage_openings(t, {}, _know(), None) == []
|
||
assert 'no panel or no piece length' in t['_dunnage_silence']
|
||
assert 'no panel or no piece length' in _dunnage_probe(t)
|
||
|
||
|
||
# ----- geometry stand-in: 6.4 now reads the openings BEFORE the sheet ------
|
||
#
|
||
# Owner, reading a live report, 2026-08-31: "6.4 应该是 OK, 既然没有
|
||
# opening" -- a piece with zero openings settles the row on its own,
|
||
# because dunnage cannot sit over an opening that does not exist. The
|
||
# fixture below stands up just enough of the geometry chain (panel, frame,
|
||
# extents, solid, openings) for `check_dunnage_openings` to reach that
|
||
# judgement without touching Revit, and controls `solid` separately from
|
||
# `rects` so a genuine zero (solid found, no opening) and a genuine
|
||
# ungathered (solid never found) are two different tests, not one.
|
||
|
||
|
||
class _FakeTdoc(object):
|
||
def __init__(self, panel):
|
||
self._panel = panel
|
||
|
||
def GetElement(self, _eid):
|
||
return self._panel
|
||
|
||
|
||
class _FakeCoord(object):
|
||
def __init__(self, x, y):
|
||
self.X = x
|
||
self.Y = y
|
||
|
||
|
||
class _FakeNote(object):
|
||
def __init__(self, text, x, y):
|
||
self.Text = text
|
||
self.Coord = _FakeCoord(x, y)
|
||
|
||
|
||
class _FakeNoteQuery(object):
|
||
def __init__(self, notes):
|
||
self._notes = notes
|
||
|
||
def OfCategory(self, _cat):
|
||
return self
|
||
|
||
def ToElements(self):
|
||
return self._notes
|
||
|
||
|
||
class _FakeSheet(object):
|
||
Id = 'sheet-1'
|
||
|
||
|
||
def _patch_dunnage_geometry(monkeypatch, rects, solid_present=True,
|
||
notes=None):
|
||
"""Stands the geometry (and, if `notes` is given, the sheet) up for one
|
||
`check_dunnage_openings` call, entirely off host fakes:
|
||
|
||
rects -- the openings `liftexport._openings` hands back.
|
||
solid_present -- False models the panel solid itself failing on the
|
||
SECOND read (`liftexport.concrete_solid` returns
|
||
None) -- the state 6.4 must never call a zero.
|
||
notes -- [(text, x, y)] text notes on sheet 1, only reached
|
||
once `rects` is non-empty.
|
||
"""
|
||
db = sys.modules['Autodesk.Revit.DB']
|
||
monkeypatch.setattr(db, 'ElementId', lambda v: v, raising=False)
|
||
if notes is not None:
|
||
monkeypatch.setattr(
|
||
db, 'FilteredElementCollector',
|
||
lambda _doc, _view_id: _FakeNoteQuery(
|
||
[_FakeNote(text, x, y) for (text, x, y) in notes]),
|
||
raising=False)
|
||
db.BuiltInCategory.OST_TextNotes = 'OST_TextNotes'
|
||
monkeypatch.setattr(QC, '_panel_frame', lambda p: ('frame',))
|
||
monkeypatch.setattr(QC, 'concrete_extents',
|
||
lambda p, f: ((0.0, 0.0, 0.0), (20.0, 4.0, 0.5)))
|
||
from vibe import liftexport
|
||
solid = object() if solid_present else None
|
||
monkeypatch.setattr(liftexport, 'concrete_solid', lambda p: solid)
|
||
monkeypatch.setattr(liftexport, '_openings',
|
||
lambda solid_, frame, lo, hi, axes: list(rects))
|
||
monkeypatch.setattr(
|
||
liftexport, 'opening_penetration',
|
||
lambda panel, frame, solid_, rects_, axes, lo, hi:
|
||
[{'through': True} for _ in rects_])
|
||
return object() # the fake panel `_FakeTdoc.GetElement` hands back
|
||
|
||
|
||
def test_64_zero_openings_measured_is_ok_before_any_notes_are_read(
|
||
monkeypatch):
|
||
"""The vacuous case the owner's correction is for. `sheets` is left
|
||
empty and no notes are wired up at all -- if the check reached for
|
||
either, it would raise (no `FilteredElementCollector` patched) or read
|
||
NOT RUN off the empty-sheets gate, so an OK here proves the openings
|
||
settled the row before the stations were ever needed."""
|
||
t = {'mark': 'TEST-1', 'panel_id': 1, 'length': 20.0, 'sheets': []}
|
||
with _revit():
|
||
panel = _patch_dunnage_geometry(monkeypatch, rects=[])
|
||
t['doc'] = _FakeTdoc(panel)
|
||
out = TC.check_dunnage_openings(t, {}, _know(), None)
|
||
assert len(out) == 1
|
||
assert out[0].severity == 'OK'
|
||
assert out[0].code == 'DUN-OPENING'
|
||
assert 'no openings on this piece' in out[0].message
|
||
assert 'stations were not needed' in out[0].message
|
||
# never touched _dunnage_silence -- this is a Finding, not a silence
|
||
assert t.get('_dunnage_silence') is None
|
||
|
||
|
||
def test_64_openings_ungathered_is_not_run_and_distinct_from_zero(
|
||
monkeypatch):
|
||
"""`solid is None` on the second read is NOT the same answer as zero
|
||
openings measured -- both `_openings` and the old code would have
|
||
handed back an empty list either way, which is the exact ambiguity the
|
||
owner's correction had to resolve without also breaking this case."""
|
||
t = {'mark': 'TEST-1', 'panel_id': 1, 'length': 20.0, 'sheets': []}
|
||
with _revit():
|
||
panel = _patch_dunnage_geometry(monkeypatch, rects=[],
|
||
solid_present=False)
|
||
t['doc'] = _FakeTdoc(panel)
|
||
assert TC.check_dunnage_openings(t, {}, _know(), None) == []
|
||
assert t['_dunnage_silence'] is not None
|
||
assert 'no claim is made about where the dunnage lands' \
|
||
in t['_dunnage_silence']
|
||
assert 'no openings on this piece' not in t['_dunnage_silence']
|
||
|
||
|
||
def test_64_a_ticket_with_no_sheet_says_so(monkeypatch):
|
||
"""Only reached now once an opening is on the piece -- with none, the
|
||
row is already settled and the sheet is never asked for."""
|
||
t = {'mark': 'TEST-1', 'panel_id': 1, 'length': 20.0, 'sheets': []}
|
||
with _revit():
|
||
panel = _patch_dunnage_geometry(monkeypatch,
|
||
rects=[(8.0, 10.0, 0.5, 2.0)])
|
||
t['doc'] = _FakeTdoc(panel)
|
||
assert TC.check_dunnage_openings(t, {}, _know(), None) == []
|
||
assert 'no sheet' in t['_dunnage_silence']
|
||
|
||
|
||
def test_64_openings_present_and_dunnage_unparsed_is_not_run(monkeypatch):
|
||
"""The trap the owner's report actually hit: a piece WITH an opening
|
||
whose printed insets fail to parse still must not pass -- it is a
|
||
failure to read the ticket, not a clean piece, and it must not be
|
||
confused with the vacuous zero-opening case above."""
|
||
notes = [('PLANT STD"', 3.0, 0.59), ('SEE NOTE"', 5.0, 0.59)]
|
||
t = {'mark': 'TEST-1', 'panel_id': 1, 'length': 20.0,
|
||
'sheets': [_FakeSheet()]}
|
||
with _revit():
|
||
panel = _patch_dunnage_geometry(monkeypatch,
|
||
rects=[(8.0, 10.0, 0.5, 2.0)],
|
||
notes=notes)
|
||
t['doc'] = _FakeTdoc(panel)
|
||
assert TC.check_dunnage_openings(t, {}, _know(), None) == []
|
||
assert 'none parsed into feet' in t['_dunnage_silence']
|
||
|
||
|
||
def test_64_openings_present_and_dunnage_over_one_is_warn(monkeypatch):
|
||
"""A piece WITH an opening, printed insets that DO parse, and one
|
||
station too close to it -- the WP115-style WARN, unchanged by any of
|
||
this."""
|
||
notes = [('7\'-8"', 3.0, 0.59), ('3\'-0"', 5.0, 0.59)]
|
||
t = {'mark': 'TEST-1', 'panel_id': 1, 'length': 20.0,
|
||
'sheets': [_FakeSheet()]}
|
||
with _revit():
|
||
panel = _patch_dunnage_geometry(monkeypatch,
|
||
rects=[(8.0, 10.0, 0.5, 2.0)],
|
||
notes=notes)
|
||
t['doc'] = _FakeTdoc(panel)
|
||
out = TC.check_dunnage_openings(t, {}, _know(), None)
|
||
assert len(out) == 1
|
||
assert out[0].severity == 'WARN'
|
||
assert out[0].code == 'DUN-OPENING'
|
||
assert 'sits over an opening' not in out[0].message
|
||
assert '4.0"' in out[0].message # 7.667 ft to 8.0 ft edge
|
||
|
||
|
||
def test_64_the_probe_never_returns_none_on_a_silence():
|
||
"""A probe returning None turns an empty return into a quiet OK. On a
|
||
check with six silent paths that is the one outcome that must be
|
||
unreachable, so the fallback is a reason, never None."""
|
||
assert _dunnage_probe({}) is not None
|
||
assert 'not run' in _dunnage_probe({})
|
||
|
||
|
||
def test_64_reads_not_run_with_a_real_reason_on_the_table_the_reader_sees():
|
||
reason = 'no size notes ... could not be read'
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_dunnage_openings', 'SKIPPED', reason)]))
|
||
assert rows['6.4'][3] == 'NOT RUN'
|
||
assert rows['6.4'][4] == reason
|
||
assert 'no probe declared' not in (rows['6.4'][4] or '')
|
||
|
||
|
||
def test_64_a_clear_piece_reaches_the_table_as_ok_not_as_a_silence():
|
||
"""The fall-through after the clearance loop is the ONLY pass, and it
|
||
now emits an OK finding rather than an empty list -- 4.7's flip, applied
|
||
to the row this check exists for."""
|
||
findings = [TC.Finding('OK', 'DUN-OPENING',
|
||
'every dunnage station is clear of every opening')]
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
findings, [('check_dunnage_openings', 'RAN', None)]))
|
||
assert rows['6.4'][3] == 'OK'
|
||
assert TP.state_color(rows['6.4'][3]) == 'green'
|
||
|
||
|
||
# ================================================================== 5.4
|
||
#
|
||
# The owner read 5.4 as "没跑" on a live ticket (2026-08-30). It had not
|
||
# failed: the design it was checking records no mesh at all. What was wrong
|
||
# was that ONE sentence covered three different silences and pointed at
|
||
# Extract Design in all of them -- including the one where re-extracting
|
||
# returns exactly the same nothing.
|
||
#
|
||
# The probe is the whole subject here. It is a pure decision over a spec
|
||
# dict and an intent dict and touches no document.
|
||
|
||
_WWF_PROBE = TC._SKIP_PROBES['check_wwf_designation']
|
||
|
||
|
||
def test_54_a_design_that_has_a_mesh_is_not_a_silence_at_all():
|
||
assert _WWF_PROBE({}, {'wwf_mesh': '6X6-W1.4XW1.4'}, _know(), {}) is None
|
||
|
||
|
||
def test_54_no_spec_at_all_sends_the_reader_to_extract_design():
|
||
reason = _WWF_PROBE({}, {'source': 'spec'}, _know(), None)
|
||
assert 'Extract Design' in reason
|
||
assert 'no design summary has been extracted' in reason
|
||
|
||
|
||
def test_54_the_regex_fallback_says_it_was_the_one_that_found_nothing():
|
||
"""With no spec on file the intent came off the summary view through
|
||
regexes. Blaming Extract Design for that reading is blaming a tool that
|
||
was never run."""
|
||
reason = _WWF_PROBE({}, {'source': 'regex'}, _know(), None)
|
||
assert 'fall-back reader' in reason
|
||
|
||
|
||
def test_54_a_spec_that_records_no_mesh_does_not_blame_the_extractor():
|
||
"""THE COMMON CASE, and the one the owner hit: the spec is there, it was
|
||
extracted cleanly, and `reinforcing.wwf.mesh` is null because the design
|
||
reinforces with bars. The old text told him to go verify in Extract
|
||
Design, which would have produced the same null."""
|
||
spec = {'reinforcing': {'wwf': {'mesh': None, 'layers': None,
|
||
'per_face': None}}}
|
||
reason = _WWF_PROBE({}, {'source': 'spec'}, _know(), spec)
|
||
assert 'reinforcing.wwf.mesh is empty' in reason
|
||
assert 'bars rather than mesh' in reason
|
||
assert 're-extracting will not' in reason
|
||
assert 'run Extract Design' not in reason
|
||
|
||
|
||
def test_54_a_spec_predating_the_field_is_the_one_worth_re_extracting():
|
||
reason = _WWF_PROBE({}, {'source': 'spec'}, _know(),
|
||
{'reinforcing': {'mild_grade_ksi': 60}})
|
||
assert 'no reinforcing.wwf section' in reason
|
||
assert 're-run Extract Design' in reason
|
||
|
||
|
||
def test_54_every_one_of_those_silences_is_still_red_and_never_a_pass():
|
||
"""A better sentence is not permission to soften the state. 5.4 cannot
|
||
judge a designation nobody has, so the row stays NOT RUN."""
|
||
for spec in (None, {}, {'reinforcing': {'wwf': {'mesh': None}}}):
|
||
reason = _WWF_PROBE({}, {'source': 'spec'}, _know(), spec)
|
||
assert reason
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_wwf_designation', 'SKIPPED', reason)]))
|
||
assert rows['5.4'][3] == 'NOT RUN'
|
||
assert TP.state_color(rows['5.4'][3]) == 'red'
|
||
assert rows['5.4'][4] == reason
|
||
|
||
|
||
# ================================================================== 5.7
|
||
#
|
||
# The two config gates are pure decisions -- read a key, refuse, say why --
|
||
# and they are the paths that decide whether this check can speak at all on
|
||
# a plant that has written nothing down.
|
||
|
||
|
||
def test_57_stands_down_when_the_stock_length_is_not_written_down():
|
||
"""It never assumes 40: the number is published data and a null key is
|
||
NOT RUN with the key named."""
|
||
know = _know()
|
||
know['catalogue']['rebar']['stock_length_ft'] = None
|
||
t = {'mark': 'TEST-1'}
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
assert 'catalogue.rebar.stock_length_ft' in t['_bar_length_silence']
|
||
|
||
|
||
def test_57_stands_down_rather_than_measuring_a_bar_off_its_geometry():
|
||
"""The owner's ruling is that the length is READ. With no parameter
|
||
mapped there is nothing to read, and a bounding box is not an offer this
|
||
check is allowed to make."""
|
||
know = _know()
|
||
know['catalogue']['rebar']['stock_length_ft'] = 40.0
|
||
know.setdefault('parameters', {}).setdefault('member', {})['length'] = None
|
||
t = {'mark': 'TEST-1'}
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
assert 'member.length' in t['_bar_length_silence']
|
||
assert 'measure' in t['_bar_length_silence']
|
||
|
||
|
||
def _know_with_bars(bars, stock=40.0, size='#4', unknown=None,
|
||
basis='parameter'):
|
||
"""A ticket whose bar gather is already solved.
|
||
|
||
EVERY BAR GETS A SIZE unless the caller pins one, because 5.7 resolves
|
||
its stock length PER SIZE (`knowledge.rebar_stock`) as of 2026-08-31 and
|
||
a bar whose size cannot be read is deliberately not judged at all -- see
|
||
`test_57_a_bar_whose_size_cannot_be_read_is_not_judged_at_all`.
|
||
Pass `size=None` to exercise that path.
|
||
|
||
AND EVERY SIZE GETS A BASIS, because a size travels with the field that
|
||
answered for it -- `'parameter'` for the straight population and
|
||
`'description'` for the bent one. A gathered bar always carries one; a
|
||
row here without it would be a shape the gather cannot produce.
|
||
"""
|
||
know = _know()
|
||
know['catalogue']['rebar']['stock_length_ft'] = stock
|
||
know.setdefault('parameters', {}).setdefault(
|
||
'member', {})['length'] = 'BAR_LENGTH_PARAM'
|
||
know['parameters']['member']['bar_size'] = 'BAR_SIZE_PARAM'
|
||
seeded = []
|
||
for bar in bars:
|
||
row = dict(bar)
|
||
row.setdefault('size', size)
|
||
if row.get('size'):
|
||
row.setdefault('size_basis', basis)
|
||
seeded.append(row)
|
||
t = {'mark': 'TEST-1',
|
||
'_bar_members': {'bars': seeded, 'reason': None,
|
||
'unknown': list(unknown or [])}}
|
||
return t, know
|
||
|
||
|
||
def test_57_a_long_bar_warns_and_says_the_ruling_is_still_open():
|
||
"""Measured: 2,122 elements sit at or over 40 ft on the live model, and
|
||
whether a continuous run may is with the owner. The finding ships
|
||
honest rather than pre-exempting anything."""
|
||
t, know = _know_with_bars(
|
||
[{'mark': 'SR1101', 'family': 'F', 'length_ft': 60.0}])
|
||
findings = TC.check_bar_stock_length(t, {}, know, None)
|
||
assert findings[0].severity == 'WARN'
|
||
assert findings[0].code == 'BAR-LENGTH'
|
||
assert 'SR1101' in findings[0].message
|
||
assert 'stock_exempt_marks' in findings[0].message
|
||
|
||
|
||
def test_57_the_bars_it_could_not_judge_get_their_own_code_not_the_rows():
|
||
t, know = _know_with_bars(
|
||
[{'mark': 'SR310', 'family': 'F', 'length_ft': 11.0},
|
||
{'mark': 'SR9', 'family': 'F', 'length_ft': 0.0},
|
||
{'mark': 'SR8', 'family': 'F', 'length_ft': None}])
|
||
findings = TC.check_bar_stock_length(t, {}, know, None)
|
||
codes = dict((f.code, f) for f in findings)
|
||
assert codes['BAR-LENGTH'].severity == 'OK'
|
||
assert codes['BAR-LENGTH-UNREAD'].severity == 'OK'
|
||
assert 'SR9' in codes['BAR-LENGTH-UNREAD'].message
|
||
assert 'SR8' in codes['BAR-LENGTH-UNREAD'].message
|
||
# and the row is decided by BAR-LENGTH alone
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
findings, [('check_bar_stock_length', 'RAN', None)]))
|
||
assert rows['5.7'][3] == 'OK'
|
||
|
||
|
||
def test_57_a_piece_with_no_recognised_bar_names_the_key_that_would_fix_it():
|
||
"""This plant models bars as Specialty Equipment, so Revit's category
|
||
says nothing and everything rests on drawings.reinforcing_families --
|
||
which is empty. The row must say that, not pass."""
|
||
t, know = _know_with_bars([])
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
assert 'reinforcing_families' in t['_bar_length_silence']
|
||
|
||
|
||
def test_57_an_exempt_mark_is_set_aside_and_leaves_nothing_to_judge():
|
||
"""Exempt the only bar and the check has compared NOTHING. That is a
|
||
NOT RUN naming what was set aside -- never an OK, which would report a
|
||
ticket nobody looked at as a clean one."""
|
||
t, know = _know_with_bars(
|
||
[{'mark': 'SR1101', 'family': 'F', 'length_ft': 60.0}])
|
||
know['catalogue']['rebar']['stock_exempt_marks'] = ['SR1101']
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
assert 'stock_exempt_marks' in t['_bar_length_silence']
|
||
assert 'SR1101' in t['_bar_length_silence']
|
||
|
||
|
||
def test_57_an_exemption_alongside_a_judged_bar_is_reported_not_hidden():
|
||
t, know = _know_with_bars(
|
||
[{'mark': 'SR1101', 'family': 'F', 'length_ft': 60.0},
|
||
{'mark': 'SR310', 'family': 'F', 'length_ft': 11.0}])
|
||
know['catalogue']['rebar']['stock_exempt_marks'] = ['SR1101']
|
||
codes = dict((f.code, f) for f in
|
||
TC.check_bar_stock_length(t, {}, know, None))
|
||
assert codes['BAR-LENGTH'].severity == 'OK' # the one judged bar
|
||
assert 'SR1101' in codes['BAR-LENGTH-UNREAD'].message
|
||
|
||
|
||
# ================================================================== 5.6
|
||
|
||
def test_56_stands_down_when_no_bar_was_recognised():
|
||
t, know = _know_with_bars([])
|
||
assert TC.check_bar_mark_uniqueness(t, {}, know, None) == []
|
||
assert 'reinforcing_families' in t['_barmark_silence']
|
||
|
||
|
||
# ------------- what 5.6 and 5.7 say when no bar was recognised (08-30)
|
||
#
|
||
# Both stood down on the same gather and printed the SAME paragraph, one row
|
||
# under the other, opening with "no reinforcing found on this piece". The
|
||
# owner read the pair as "5.6 5.7 没跑" and he was right that they had not
|
||
# run -- but the sentence was also making a claim the tool is in no position
|
||
# to make. This plant models bars outside any rebar category, so with
|
||
# `drawings.reinforcing_families` empty a piece with three hundred members
|
||
# and a bar line in its design summary produced exactly the same words as a
|
||
# piece with genuinely no steel in it.
|
||
|
||
|
||
def test_5657_do_not_claim_the_piece_has_no_reinforcing_when_they_cannot_tell():
|
||
"""With the key empty the honest statement is about the TOOL, not the
|
||
piece: nothing could be recognised."""
|
||
t, know = _know_with_bars([])
|
||
TC.check_bar_stock_length(t, {}, know, None)
|
||
reason = t['_bar_length_silence']
|
||
assert 'could be RECOGNISED' in reason
|
||
assert 'not the same as the piece carrying none' in reason
|
||
assert 'no reinforcing found on this piece' not in reason
|
||
|
||
|
||
def test_5657_name_the_switch_that_would_make_them_run():
|
||
"""A NOT RUN a reader cannot act on is half a report."""
|
||
t, know = _know_with_bars([])
|
||
TC.check_bar_mark_uniqueness(t, {}, know, None)
|
||
reason = t['_barmark_silence']
|
||
assert 'TO MAKE THIS ROW RUN' in reason
|
||
assert 'drawings.reinforcing_families' in reason
|
||
|
||
|
||
def test_5657_each_says_what_IT_lost_rather_than_printing_one_paragraph_twice():
|
||
"""Two rows, two sentences. The shared half is shared; the consequence
|
||
is the row's own, so a reader sees two checks reporting rather than one
|
||
message pasted twice."""
|
||
t, know = _know_with_bars([])
|
||
TC.check_bar_stock_length(t, {}, know, None)
|
||
TC.check_bar_mark_uniqueness(t, {}, know, None)
|
||
length, mark = t['_bar_length_silence'], t['_barmark_silence']
|
||
assert length != mark
|
||
assert 'No bar length was compared against stock' in length
|
||
assert 'printed bar mark' in mark
|
||
assert 'printed bar mark' not in length
|
||
|
||
|
||
def test_5657_with_the_key_filled_in_it_IS_a_statement_about_the_piece():
|
||
"""Both signals were asked and both came back empty: now the tool has
|
||
looked, and it may say so -- naming what it looked for."""
|
||
t, know = _know_with_bars([])
|
||
know['drawings']['reinforcing_families'] = ['BAR_TOKEN_A', 'BAR_TOKEN_B']
|
||
TC.check_bar_stock_length(t, {}, know, None)
|
||
reason = t['_bar_length_silence']
|
||
assert 'appears to carry no reinforcing' in reason
|
||
assert 'BAR_TOKEN_A' in reason and 'BAR_TOKEN_B' in reason
|
||
assert 'TO MAKE THIS ROW RUN' not in reason
|
||
|
||
|
||
def test_5657_a_better_sentence_does_not_make_either_row_any_less_red():
|
||
"""Naming the switch is not flipping it. Nothing here decides a piece
|
||
is clean."""
|
||
t, know = _know_with_bars([])
|
||
TC.check_bar_stock_length(t, {}, know, None)
|
||
TC.check_bar_mark_uniqueness(t, {}, know, None)
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_bar_stock_length', 'SKIPPED', t['_bar_length_silence']),
|
||
('check_bar_mark_uniqueness', 'SKIPPED', t['_barmark_silence'])]))
|
||
for num in ('5.6', '5.7'):
|
||
assert rows[num][3] == 'NOT RUN', num
|
||
assert TP.state_color(rows[num][3]) == 'red', num
|
||
assert rows['5.7'][4] == t['_bar_length_silence']
|
||
assert rows['5.6'][4] == t['_barmark_silence']
|
||
|
||
|
||
def test_56_a_clean_ticket_reaches_the_table_as_ok():
|
||
t, know = _know_with_bars(
|
||
[{'mark': 'SR310', 'family': 'F', 'length_ft': 11.0}])
|
||
t['_view_text'] = {}
|
||
findings = [TC.Finding('OK', 'BARMARK-UNIQUE', 'all marks agree')]
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
findings, [('check_bar_mark_uniqueness', 'RAN', None)]))
|
||
assert rows['5.6'][3] == 'OK'
|
||
|
||
|
||
# ============================ rows answered by a CODE, not a check function
|
||
#
|
||
# On a live run, 2026-08-31, six rows came back like this in `qc-run.json`:
|
||
#
|
||
# {"item": "3.1", "state": "NOT RUN", "judged": false,
|
||
# "checks": [], "reason": null, "note": null, "owner_codes": ["FC"]}
|
||
#
|
||
# `reason` null, `note` null, `checks` empty; in the text report a bare red
|
||
# NOT RUN with nothing under it. Those rows name no check function -- they
|
||
# are answered by codes `ticketqc.check_ticket` emits -- so the skip probes,
|
||
# which are keyed by check function, could not reach them. That is the §3.1
|
||
# guarantee failing one level ABOVE where it was installed, and the reasons
|
||
# existed all along: the design had no extracted spec.
|
||
|
||
|
||
def _no_spec_intent():
|
||
"""The intent table as it arrives with nothing extracted: every column a
|
||
dash, and `source` saying which reader produced it."""
|
||
return {'source': 'regex'}
|
||
|
||
|
||
def _reasons(t=None, intent=None, know=None, spec=None):
|
||
return dict((name[len(TC.ROW_NOTE_PREFIX):], note)
|
||
for (name, _state, note) in TC.row_silences(
|
||
t if t is not None else {'mark': 'TEST-1'},
|
||
_no_spec_intent() if intent is None else intent,
|
||
_know() if know is None else know, spec))
|
||
|
||
|
||
def test_every_code_answered_row_can_explain_its_own_silence():
|
||
"""THE MECHANICAL GUARANTEE. A row that names no check and declares no
|
||
reason of its own must have a row probe, or it can print NOT RUN with
|
||
nothing to say -- which is what happened. Adding such a row now fails
|
||
here instead of on the owner's screen."""
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
num, declared, check_slot = item[0], item[4], item[5]
|
||
if num in TP.CHECKLIST_FOR_ENGINEER or TP._check_names(check_slot):
|
||
continue
|
||
assert num in TC._ROW_PROBES or declared, (
|
||
'row {0} is answered by finding codes alone and has neither a '
|
||
'row probe nor a declared reason'.format(num))
|
||
|
||
|
||
def test_the_six_rows_from_the_live_run_all_speak_now():
|
||
said = _reasons()
|
||
for num in ('2.1', '2.3', '3.1', '3.2', '4.1', '4.2'):
|
||
assert said.get(num), num
|
||
assert said[num].strip(), num
|
||
|
||
|
||
def test_the_missing_spec_is_named_as_the_reason_not_the_ticket():
|
||
"""Three of those rows are quiet for ONE reason -- nothing was extracted
|
||
for this design -- and that is a statement about the design side, never
|
||
about the piece. The sentence has to say which, or it reads as a defect
|
||
in a ticket nobody checked."""
|
||
said = _reasons()
|
||
for num in ('3.1', '3.2', '2.3', '4.2'):
|
||
assert 'no spec has been extracted for this design' in said[num]
|
||
assert 'intent.source = regex' in said[num]
|
||
assert 'Run Extract Design' in said[num]
|
||
assert 'nothing here is a statement about the ticket' in said[num]
|
||
assert "carries no f'c" in said['3.1']
|
||
assert "carries no f'ci" in said['3.2']
|
||
assert 'carries no unit weight' in said['2.3']
|
||
assert 'carries no strand group' in said['4.2']
|
||
|
||
|
||
def test_a_row_probe_goes_quiet_the_moment_its_input_is_there():
|
||
"""A probe explains a SILENCE and must never answer for a row that was
|
||
answered. With the design value present it says nothing at all."""
|
||
said = _reasons(intent={'source': 'spec', 'fc': 6000, 'fci': 3500,
|
||
'pcf': 150, 'strands': [{'dia': 0.5, 'qty': 4}],
|
||
'envelope': {'MAX_LENGTH': (30.0, "30'")}},
|
||
t={'mark': 'T', 'length': 24.9, 'width': 8.0,
|
||
'ticket_strand_count': 4,
|
||
'design_number': 'PNL1', 'tkt_design_number': 'PNL1.0'},
|
||
spec={'concrete': {}})
|
||
assert said == {}
|
||
|
||
|
||
def test_41_names_both_halves_because_either_would_have_answered():
|
||
"""4.1 needs the ticket's printed count OR the design's strand groups.
|
||
Row 6.4 reports that same parse failure in a full sentence on the same
|
||
piece; a fact that answers one row and leaves another blank is the
|
||
defect."""
|
||
said = _reasons()
|
||
assert 'did not parse into a count' in said['4.1']
|
||
assert 'no strand group to fall back on' in said['4.1']
|
||
# the printed count alone is enough to make it stop speaking
|
||
assert '4.1' not in _reasons(t={'mark': 'T', 'ticket_strand_count': 6})
|
||
|
||
|
||
def test_21_separates_a_piece_it_could_not_measure_from_a_design_with_no_envelope():
|
||
"""Two absences, two different fixes: one is a piece the tool could not
|
||
measure, the other a design nobody extracted."""
|
||
both = _reasons()['2.1']
|
||
assert 'were not measured AND' in both
|
||
unmeasured = _reasons(
|
||
intent={'source': 'spec', 'envelope': {'MAX_LENGTH': (30.0, "30'")}},
|
||
t={'mark': 'T'})['2.1']
|
||
assert 'could not be measured off' in unmeasured
|
||
no_envelope = _reasons(t={'mark': 'T', 'length': 24.9, 'width': 8.0},
|
||
intent={'source': 'spec'},
|
||
spec={'x': 1})['2.1']
|
||
assert 'no size envelope' in no_envelope
|
||
|
||
|
||
def test_a_row_probe_that_raises_is_reported_and_never_swallowed():
|
||
"""A probe whose own failure was silent would reproduce, one level
|
||
further out, the exact silence this mechanism exists to close."""
|
||
def _boom(t, intent, know, spec):
|
||
raise KeyError('nope')
|
||
|
||
TC._ROW_PROBES['9.9'] = _boom
|
||
try:
|
||
said = _reasons()
|
||
finally:
|
||
del TC._ROW_PROBES['9.9']
|
||
assert 'KeyError' in said['9.9']
|
||
assert 'never as a pass' in said['9.9']
|
||
|
||
|
||
def test_run_all_carries_the_row_reasons_out_in_its_own_log():
|
||
"""The log is the one channel that already reaches every consumer, so
|
||
the button and `qc-run.json` get this with no edit of their own."""
|
||
findings, log = TC.run_all({'mark': 'T'}, _no_spec_intent(), _know(), None)
|
||
rows = [entry for entry in log
|
||
if str(entry[0]).startswith(TC.ROW_NOTE_PREFIX)]
|
||
assert rows, 'run_all recorded no row-level reasons at all'
|
||
for (name, state, note) in rows:
|
||
assert state == 'NO-CODE'
|
||
assert note
|
||
table = dict((r[0], r) for r in TP.checklist_states(findings, log))
|
||
assert table['3.1'][3] == 'NOT RUN'
|
||
assert 'no spec has been extracted' in table['3.1'][4]
|
||
|
||
|
||
# ================================================================== 1.5
|
||
|
||
def test_15_and_56_and_57_all_declare_a_probe():
|
||
"""Every new check answers "why did you say nothing" -- the whole reason
|
||
6.4 came back."""
|
||
for name in ('check_callout_overlap', 'check_bar_mark_uniqueness',
|
||
'check_bar_stock_length', 'check_dunnage_openings'):
|
||
assert name in TC._SKIP_PROBES
|
||
|
||
|
||
def test_15_and_56_and_57_are_actually_registered_to_run():
|
||
names = [c.__name__ for c in TC.CHECKS]
|
||
for name in ('check_callout_overlap', 'check_bar_mark_uniqueness',
|
||
'check_bar_stock_length'):
|
||
assert name in names
|
||
|
||
|
||
def test_the_new_probes_never_return_none_on_a_silence():
|
||
for name in ('check_callout_overlap', 'check_bar_mark_uniqueness',
|
||
'check_bar_stock_length'):
|
||
assert TC._SKIP_PROBES[name]({}, {}, _know(), None) is not None
|
||
|
||
|
||
def test_15_reaches_the_table_and_the_rotated_note_cannot_decide_it():
|
||
findings = [TC.Finding('OK', 'CALLOUT-OVERLAP', 'no two overlap'),
|
||
TC.Finding('OK', 'CALLOUT-OVERLAP-UNREAD', '1 rotated')]
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
findings, [('check_callout_overlap', 'RAN', None)]))
|
||
assert rows['1.5'][3] == 'OK'
|
||
|
||
|
||
# ============================ the green-row trap, on all three new checks
|
||
#
|
||
# Found by reading `checklist_states` back against the new checks rather than
|
||
# by a failing test, and it is worth a test of its own because the shape
|
||
# recurs: a check that returns ONLY its set-aside note returns a non-empty
|
||
# list, so `run_all` logs it RAN and never asks the probe -- and
|
||
# `checklist_states`, finding no OWNER code among the findings on a check
|
||
# that RAN, renders the row **OK**. A ticket on which nothing could be
|
||
# judged would have printed green.
|
||
#
|
||
# The fix is the same in all three: when nothing was actually compared, the
|
||
# check returns [] and the detail goes into the probe's reason, so the row
|
||
# is red and says what was set aside. NOT RUN has to keep meaning exactly
|
||
# one thing, in both directions.
|
||
|
||
|
||
def test_57_all_bars_unreadable_is_red_and_never_a_green_row():
|
||
t, know = _know_with_bars(
|
||
[{'mark': 'SR8', 'family': 'F', 'length_ft': None},
|
||
{'mark': 'SR9', 'family': 'F', 'length_ft': 0.0}])
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
reason = t['_bar_length_silence']
|
||
assert 'SR8' in reason and 'SR9' in reason # the detail survives
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_bar_stock_length', 'SKIPPED', reason)]))
|
||
assert rows['5.7'][3] == 'NOT RUN'
|
||
assert TP.state_color(rows['5.7'][3]) == 'red'
|
||
|
||
|
||
def test_56_all_bars_unmarked_is_red_and_never_a_green_row():
|
||
t, know = _know_with_bars(
|
||
[{'mark': None, 'family': 'BAR_FAMILY_B', 'length_ft': 2.0}])
|
||
assert TC.check_bar_mark_uniqueness(t, {}, know, None) == []
|
||
assert 'BAR_FAMILY_B' in t['_barmark_silence']
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_bar_mark_uniqueness', 'SKIPPED',
|
||
t['_barmark_silence'])]))
|
||
assert rows['5.6'][3] == 'NOT RUN'
|
||
|
||
|
||
def test_the_set_aside_note_alone_would_have_rendered_green():
|
||
"""The bug itself, pinned: this is what the three checks must never
|
||
return. If a future edit lets a set-aside note out on its own, this
|
||
assertion is the one that explains why the row went green."""
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[TC.Finding('OK', 'BAR-LENGTH-UNREAD', 'nothing could be read')],
|
||
[('check_bar_stock_length', 'RAN', None)]))
|
||
assert rows['5.7'][3] == 'OK' # <- exactly the wrong outcome
|
||
|
||
|
||
# ================================================================== 6.3 cost
|
||
#
|
||
# Measured on the live model 2026-08-30: the all-checks wall on WP396 (the
|
||
# LEDGE piece) was 41.9 s, and a per-check breakdown put 98.9% of it -- 40.2 s
|
||
# -- inside `check_handling_result`. A second call cost 38.8 s, so it is
|
||
# per-ticket cost and not cold geometry paid once.
|
||
#
|
||
# The cut is `recommend=False` on the panellift subprocess, which is 93% of a
|
||
# run. It is only allowed because NOTHING ON THIS SIDE READS WHAT IT DROPS:
|
||
# `_findings_from_summary` takes the top-level verdict and each stage's
|
||
# severity/code/message, all of which panellift builds from `cases` before the
|
||
# flag is consulted. The tests below pin both halves of that -- the flag
|
||
# really reaches the subprocess, and the consumed set really is
|
||
# recommendation-free -- because a cut justified by an invariant nobody
|
||
# asserts is a cut that quietly stops being safe.
|
||
|
||
def _summary(recommendation):
|
||
return {
|
||
'verdict': 'WARN',
|
||
'stages': [{'name': 'STRIP', 'findings': [
|
||
{'severity': 'WARN', 'code': 'TENSION',
|
||
'message': 'peak tension 512 psi against 480 allowable'}]}],
|
||
'recommendation': recommendation,
|
||
}
|
||
|
||
|
||
_SEARCHED = {'searched': True, 'worth_moving': True,
|
||
'rows': [{'x_ft': 4.0, 'count': 2}],
|
||
'moves': [{'mark': 'L1', 'dx_in': 6.0, 'moved': True}],
|
||
'as_built_ratio': 1.07, 'recommended_ratio': 0.91}
|
||
_NOT_SEARCHED = {'searched': False, 'worth_moving': None, 'rows': [],
|
||
'moves': [], 'as_built_ratio': None,
|
||
'recommended_ratio': None}
|
||
|
||
|
||
def test_63_the_ticket_path_asks_panellift_not_to_search():
|
||
"""The flag has to reach the subprocess. A flag that silently does not is
|
||
indistinguishable from a ticket run that is still 41 seconds."""
|
||
from vibe import liftexport
|
||
seen = {}
|
||
|
||
def _fake_run(python_exe, job_path, out_path, **kw):
|
||
seen.update(kw)
|
||
seen['out_path'] = out_path
|
||
return 0, _summary(_NOT_SEARCHED), ''
|
||
|
||
real = liftexport.run
|
||
liftexport.run = _fake_run
|
||
try:
|
||
summary, stderr = TC._run_panellift_json('py.exe', 'j.json', 'WP396')
|
||
finally:
|
||
liftexport.run = real
|
||
|
||
assert seen.get('recommend') is False
|
||
assert summary['verdict'] == 'WARN'
|
||
# Still a throwaway report path, still not beside a reviewed one.
|
||
assert 'willtools_ticketqc' in seen['out_path']
|
||
|
||
|
||
def test_63_the_findings_do_not_change_when_the_search_is_off():
|
||
"""The whole justification for the cut, as an assertion. If a future
|
||
panellift ever lets `--no-recommend` move a verdict or drop a finding,
|
||
this is the test that says the ticket path may no longer force it."""
|
||
on = TC._findings_from_summary(_summary(_SEARCHED), 'WP396',
|
||
TC._ONSPOT_TAG)
|
||
off = TC._findings_from_summary(_summary(_NOT_SEARCHED), 'WP396',
|
||
TC._ONSPOT_TAG)
|
||
assert [(f.severity, f.code, f.message) for f in on] == \
|
||
[(f.severity, f.code, f.message) for f in off]
|
||
assert len(on) == 1 and on[0].severity == 'WARN'
|
||
|
||
|
||
def test_63_nothing_on_this_side_reads_the_recommendation_block():
|
||
"""Belt and braces: a summary with NO recommendation key at all must
|
||
still produce the same findings, which is only true if the block is
|
||
never touched."""
|
||
bare = _summary(_SEARCHED)
|
||
del bare['recommendation']
|
||
assert [(f.severity, f.code, f.message)
|
||
for f in TC._findings_from_summary(bare, 'WP396', TC._ONSPOT_TAG)] \
|
||
== [(f.severity, f.code, f.message)
|
||
for f in TC._findings_from_summary(_summary(_SEARCHED), 'WP396',
|
||
TC._ONSPOT_TAG)]
|
||
|
||
|
||
def test_63_the_reader_is_told_the_positions_were_not_computed():
|
||
"""Ticket QC has no 'Recommended insert positions' heading to print NOT
|
||
COMPUTED under -- it never printed positions -- so the fact rides on the
|
||
provenance tag, which is the one thing a reader of a handling row sees."""
|
||
assert 'no position search' in TC._ONSPOT_TAG
|
||
findings = TC._findings_from_summary(_summary(_NOT_SEARCHED), 'WP396',
|
||
TC._ONSPOT_TAG)
|
||
assert 'no position search' in findings[0].message
|
||
# The reviewed tag is deliberately NOT changed: it points at a report that
|
||
# does carry its positions.
|
||
assert 'no position search' not in TC._REVIEWED_TAG
|
||
|
||
|
||
def test_63_the_tag_did_not_grow_the_pressure_on_the_note_cap():
|
||
"""`checklist_states` caps a row note at 180 characters and the tag is
|
||
spent before the finding's own message starts. The new tag says more in
|
||
fewer characters; if it ever grows past the old one, the row starts
|
||
truncating the actual problem instead."""
|
||
assert len(TC._ONSPOT_TAG) <= 106
|
||
assert 'good enough to find a problem, not good enough to sign' \
|
||
in TC._ONSPOT_TAG # the button's handling section quotes this
|
||
|
||
|
||
def test_63_a_swept_row_carries_the_same_provenance():
|
||
"""`_findings_from_problems_row` reuses _ONSPOT_TAG, so the cached sweep
|
||
path says the same thing as the on-the-spot one."""
|
||
row = {'verdict': 'OK', 'stages': [{'name': 'STRIP', 'findings': []}]}
|
||
findings = TC._findings_from_problems_row(row, 'WP396')
|
||
assert len(findings) == 1
|
||
assert findings[0].severity == 'OK'
|
||
assert 'no position search' in findings[0].message
|
||
|
||
|
||
# ================================================================== 7.1
|
||
#
|
||
# The interference pair list, as a file. Owner, reading his WP296 run,
|
||
# 2026-08-30: "interference check 如果是 warning,那么 note 那边应该是一个
|
||
# 文件的链接,单击打开那个文件,写成 csv,里面包含干涉的 mark#,ID,X,mark#,ID
|
||
# 这样。带表头。"
|
||
#
|
||
# Everything below is pure: `clash_csv_rows` is handed the same dicts
|
||
# `member_boxes` builds, and no Revit name is touched -- which is why these
|
||
# run under the inert stub like the rest of this file.
|
||
#
|
||
# THE MARKS IN THESE TESTS ARE SYNTHETIC. The real ones are the client's and
|
||
# live in the CSV beside the model, never in this repo.
|
||
|
||
_A = {'id': 111, 'mark': 'AA111', 'family': 'BAR_(SCHEDULED)'}
|
||
_B = {'id': 222, 'mark': 'BB222', 'family': 'ANCHOR'}
|
||
_NOMARK = {'id': 333, 'mark': None, 'family': 'MESH'}
|
||
|
||
|
||
def test_71_the_five_columns_are_his_five_in_his_order():
|
||
"""MARK, ID, X, MARK, ID -- and nothing inserted between them."""
|
||
assert TC.CLASH_CSV_COLUMNS[:5] == ('MARK', 'ID', 'X', 'MARK', 'ID')
|
||
|
||
|
||
def test_71_the_middle_column_holds_a_literal_x():
|
||
"""`X` is the versus between the two sides, not data about the clash."""
|
||
row = TC.clash_csv_rows([(0.15, 0.08, _A, _B)])[0]
|
||
assert row[2] == 'x'
|
||
assert TC.CLASH_CSV_X == 'x'
|
||
|
||
|
||
def test_71_a_pair_becomes_one_row_of_mark_id_x_mark_id():
|
||
row = TC.clash_csv_rows([(0.15, 0.08, _A, _B)])[0]
|
||
assert row[:5] == ['AA111', '111', 'x', 'BB222', '222']
|
||
|
||
|
||
def test_71_what_the_check_also_knows_comes_after_his_five():
|
||
row = TC.clash_csv_rows([(0.15, 0.08, _A, _B)])[0]
|
||
assert row[5:] == ['BAR_(SCHEDULED)', 'ANCHOR']
|
||
assert TC.CLASH_CSV_COLUMNS[5:] == ('FAMILY_1', 'FAMILY_2')
|
||
|
||
|
||
def test_71_the_two_measurement_columns_are_gone_from_the_file():
|
||
"""Owner, 2026-08-30: keep THICKNESS_IN / VOLUME_IN3 only if they can
|
||
ship hidden by default in Excel, otherwise turn them off. A .csv is
|
||
plain text and has no column formatting, so they are off.
|
||
|
||
Seven columns, and the numbers still arrive in `hits` and are still
|
||
dropped -- a row that grew back a tenth cell would be this test.
|
||
"""
|
||
assert len(TC.CLASH_CSV_COLUMNS) == 7
|
||
for gone in ('THICKNESS_IN', 'VOLUME_IN3'):
|
||
assert gone not in TC.CLASH_CSV_COLUMNS
|
||
assert len(TC.clash_csv_rows([(0.15, 0.08, _A, _B)])[0]) == 7
|
||
|
||
|
||
def test_71_the_file_keeps_the_deepest_pair_first_now_that_no_column_says():
|
||
"""Order is the surviving trace of THICKNESS_IN. The caller sorts
|
||
deepest-first; this must not reshuffle it."""
|
||
deep = {'id': 1, 'mark': 'DEEP', 'family': 'F'}
|
||
shallow = {'id': 2, 'mark': 'SHALLOW', 'family': 'F'}
|
||
rows = TC.clash_csv_rows([(0.9, 0.5, deep, _B), (0.13, 0.01, shallow, _B)])
|
||
assert [r[0] for r in rows] == ['DEEP', 'SHALLOW']
|
||
|
||
|
||
# --------------- where the dropped thickness went: the note's depth band
|
||
|
||
|
||
def test_71_the_note_carries_the_depth_band_the_csv_no_longer_does():
|
||
"""THICKNESS_IN was the quantity the filter tests, so it was the
|
||
reader's only per-pair answer to "why is this pair here at all". It
|
||
cannot simply be deleted; it is a clause on the row that links to the
|
||
file."""
|
||
band = TC.clash_depth_band(
|
||
[(0.85, 0.4, _A, _B), (0.13, 0.01, _A, _B)], 0.125)
|
||
assert '0.13"-0.85"' in band
|
||
assert '0.125"' in band # the filter it cleared
|
||
assert 'deepest row first' in band # how to read the file
|
||
assert 'shared material' in band
|
||
|
||
|
||
def test_71_the_depth_band_stays_short_enough_to_read_folded():
|
||
"""Nothing truncates a note any more, so this is not about survival --
|
||
it is about the COLLAPSED height. 7.1's row folds to its item's two
|
||
lines, and a band that ran to a paragraph would put the interesting
|
||
half behind a click for no reason."""
|
||
band = TC.clash_depth_band(
|
||
[(12.345, 9.0, _A, _B), (0.125, 0.01, _A, _B)], 0.125)
|
||
assert '0.125"-12.345"' in band
|
||
assert len(band) < 100
|
||
# and the path still reaches the reader, outside the fold
|
||
note = ('35 interference(s) among the 301 member(s) of this piece\'s '
|
||
'own assembly; ' + band)
|
||
full = note + TP.NOTE_FILE_SEP + r'C:\jobs\a job\interference-PNL1.csv'
|
||
html = TP.checklist_table_html(
|
||
[('7.1', 'Interference check', 'B', 'WARN', full)], table_id='X')
|
||
assert 'wt-ck-note-path' in html
|
||
assert 'interference-PNL1.csv</a>' in html
|
||
|
||
|
||
def test_71_one_pair_reads_as_one_number_not_a_band_of_it_to_itself():
|
||
band = TC.clash_depth_band([(0.5, 0.1, _A, _B)], 0.125)
|
||
assert '0.5"' in band
|
||
assert '-' not in band.split(',')[0]
|
||
|
||
|
||
def test_71_an_inexact_run_says_box_overlap_and_never_shared_material():
|
||
"""`interference.exact` off measures a bounding box, and calling that
|
||
shared material claims a boolean nobody ran."""
|
||
band = TC.clash_depth_band([(0.5, None, _A, _B)], 0.125, exact=False)
|
||
assert 'box overlap' in band
|
||
assert 'shared material' not in band
|
||
|
||
|
||
def test_71_no_pairs_means_no_band_rather_than_an_empty_clause():
|
||
assert TC.clash_depth_band([], 0.125) == ''
|
||
|
||
|
||
def test_71_a_member_with_no_mark_says_so_rather_than_leaving_a_blank():
|
||
"""None is "there is no mark", not "the mark could not be read"."""
|
||
row = TC.clash_csv_rows([(0.2, 0.01, _NOMARK, _A)])[0]
|
||
assert row[0] == '-'
|
||
assert row[1] == '333'
|
||
|
||
|
||
def test_71_a_missing_volume_no_longer_has_a_cell_to_be_wrong_in():
|
||
"""This was `test_71_no_volume_is_empty_and_never_zero`: with
|
||
`interference.exact` off there is no volume, and a 0 in that cell would
|
||
have claimed the shared material was measured and found to be nothing.
|
||
The cell is gone, so the trap is gone with it -- and a row that grows it
|
||
back must not quietly write a zero, which the length assertion catches.
|
||
"""
|
||
row = TC.clash_csv_rows([(0.5, None, _A, _B)])[0]
|
||
assert len(row) == 7
|
||
assert '0' not in row[5:]
|
||
|
||
|
||
def test_71_every_pair_is_written_not_just_the_reported_ones():
|
||
"""The note's `interference.max_reported` limit is a NOTE limit.
|
||
|
||
The file is the place the pairs went to stop being truncated, so it
|
||
cannot inherit the cap that made them unreadable.
|
||
"""
|
||
hits = [(0.1 + i / 100.0, 0.01, _A, _B) for i in range(40)]
|
||
assert len(TC.clash_csv_rows(hits)) == 40
|
||
|
||
|
||
def test_71_the_csv_has_a_header_row():
|
||
""""带表头"."""
|
||
text = TC.csv_text(TC.CLASH_CSV_COLUMNS,
|
||
TC.clash_csv_rows([(0.15, 0.08, _A, _B)]))
|
||
first = text.split('\r\n')[0]
|
||
assert first == 'MARK,ID,X,MARK,ID,FAMILY_1,FAMILY_2'
|
||
|
||
|
||
def test_71_a_field_with_a_comma_is_quoted_and_a_quote_is_doubled():
|
||
odd = {'id': 9, 'mark': 'A,1', 'family': 'SAY "HI"'}
|
||
text = TC.csv_text(TC.CLASH_CSV_COLUMNS,
|
||
TC.clash_csv_rows([(0.15, 0.08, odd, _B)]))
|
||
body = text.split('\r\n')[1]
|
||
assert body.startswith('"A,1",9,x,BB222,222,"SAY ""HI"""')
|
||
|
||
|
||
def test_71_the_rows_end_in_crlf_for_a_spreadsheet():
|
||
text = TC.csv_text(('A',), [['1']])
|
||
assert text == 'A\r\n1\r\n'
|
||
|
||
|
||
def test_71_the_file_is_named_after_the_piece():
|
||
"""A run checks many pieces; one shared file would be whichever was
|
||
checked last."""
|
||
assert TC.clash_csv_name('PNL1') == 'interference-PNL1.csv'
|
||
assert TC.clash_csv_name('PNL 1/A') == 'interference-PNL 1_A.csv'
|
||
|
||
|
||
def test_71_the_file_is_written_where_the_qc_data_lives(tmp_path):
|
||
path = TC.write_clash_csv(str(tmp_path), 'PNL1',
|
||
TC.clash_csv_rows([(0.15, 0.08, _A, _B)]))
|
||
assert path == str(tmp_path / 'interference-PNL1.csv')
|
||
body = open(path, 'rb').read().decode('utf-8')
|
||
assert body.split('\r\n')[0].startswith('MARK,ID,X,MARK,ID')
|
||
assert 'AA111,111,x,BB222,222' in body
|
||
|
||
|
||
def test_71_a_now_clean_piece_does_not_keep_the_last_runs_list(tmp_path):
|
||
"""A stale file is a list of clashes that will be believed."""
|
||
TC.write_clash_csv(str(tmp_path), 'PNL1',
|
||
TC.clash_csv_rows([(0.15, 0.08, _A, _B)]))
|
||
assert TC.clear_clash_csv(str(tmp_path), 'PNL1') is None
|
||
assert not (tmp_path / 'interference-PNL1.csv').exists()
|
||
|
||
|
||
def test_71_clearing_a_file_that_was_never_written_is_silent(tmp_path):
|
||
assert TC.clear_clash_csv(str(tmp_path), 'PNL1') is None
|
||
|
||
|
||
def test_71_a_stale_file_that_survives_is_reported_back(tmp_path):
|
||
"""It is open in Excel, say. Swallowing that leaves the defect in place.
|
||
|
||
The path comes back so the note can name it; nothing else can tell.
|
||
"""
|
||
import os as _os
|
||
path = TC.write_clash_csv(str(tmp_path), 'PNL1', [])
|
||
real_remove = _os.remove
|
||
|
||
def _refuse(target):
|
||
raise OSError(13, 'in use')
|
||
|
||
_os.remove = _refuse
|
||
try:
|
||
assert TC.clear_clash_csv(str(tmp_path), 'PNL1') == path
|
||
finally:
|
||
_os.remove = real_remove
|
||
assert _os.path.exists(path)
|
||
|
||
|
||
def test_71_the_note_marker_is_the_one_the_page_splits_on():
|
||
"""The contract between the check that writes the path and the table
|
||
that turns it into a link -- one spelling, owned by the page."""
|
||
note = ('4 interference(s) among the 142 member(s) of this piece\'s own '
|
||
'assembly' + TP.NOTE_FILE_SEP + r'C:\jobs\x\interference-PNL1.csv')
|
||
head, sep, path = TP.note_parts(note)
|
||
assert sep == TP.NOTE_FILE_SEP
|
||
assert path == r'C:\jobs\x\interference-PNL1.csv'
|
||
assert '142 member(s) of this piece' in head
|
||
html = TP.checklist_table_html(
|
||
[('7.1', 'Interference check', 'B', 'WARN', note)], table_id='X')
|
||
assert 'href="file:///C:/jobs/x/interference-PNL1.csv"' in html
|
||
|
||
|
||
# ==================================================== 4.3 tolerances.it_lb
|
||
#
|
||
# The key was declared, documented and offered in the config form while the
|
||
# check compared kips against a hardcoded `0.1`. The literal is gone and the
|
||
# key is read; the owner set the key at 100 lb so that wiring it moves
|
||
# nothing today. Everything about the COMPARISON is in test_ticketpage.py --
|
||
# what is tested here is the resolution of the key and the silence it can
|
||
# now produce.
|
||
|
||
def test_43_with_nothing_declared_the_check_still_uses_100_lb():
|
||
"""The call-site fallback IS the deleted literal: 100 lb = 0.1 kips.
|
||
A knowledge layer that declares nothing must not change the check."""
|
||
assert TC._jacking_tolerance_lb({}) == 100.0
|
||
|
||
|
||
def test_43_a_layer_turns_the_knob_which_is_the_point_of_wiring_it():
|
||
know = _know()
|
||
know['tolerances']['it_lb'] = 250.0
|
||
assert TC._jacking_tolerance_lb(know) == 250.0
|
||
know['tolerances']['it_lb'] = 1
|
||
assert TC._jacking_tolerance_lb(know) == 1.0
|
||
|
||
|
||
def test_43_no_module_constant_shadows_the_key_any_more():
|
||
"""A constant sitting beside a knowledge key of the same meaning is how
|
||
`tolerances.it_lb` came to be declared and read by nothing. The only
|
||
100 left is the fallback INSIDE the getter, so there is nowhere for a
|
||
second source of truth to hide."""
|
||
import inspect
|
||
src = inspect.getsource(TC.check_jacking_force)
|
||
body = src.split('"""', 2)[-1] # past the docstring
|
||
body = '\n'.join(l.split('#')[0] for l in body.splitlines())
|
||
assert '0.1' not in body
|
||
for name in dir(TC):
|
||
if name.isupper() and 'TOL' in name and 'IT' in name:
|
||
raise AssertionError('a constant shadows the key: ' + name)
|
||
|
||
|
||
def test_43_a_tolerance_that_is_not_a_tension_is_could_not_judge():
|
||
"""Unusable is not 'fall back to a number nobody chose'."""
|
||
for bad in ('tight', -1, []):
|
||
know = _know()
|
||
know['tolerances']['it_lb'] = bad
|
||
assert TC._jacking_tolerance_lb(know) is None, bad
|
||
reason = TC._SKIP_PROBES['check_jacking_force']({}, {}, know, None)
|
||
assert reason and 'tolerances.it_lb' in reason
|
||
|
||
|
||
def test_43_the_check_stands_down_rather_than_guessing_a_tolerance():
|
||
know = _know()
|
||
know['tolerances']['it_lb'] = 'tight'
|
||
t = {}
|
||
assert TC.check_jacking_force(t, {}, know, None) == []
|
||
assert 'tolerances.it_lb' in t['_jack_force_skip']
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_jacking_force', 'SKIPPED', t['_jack_force_skip'])]))
|
||
assert rows['4.3'][3] == 'NOT RUN'
|
||
assert 'tolerances.it_lb' in rows['4.3'][4]
|
||
|
||
|
||
# ======================== 5.1's detail-sheet half: detail_sheet_prefix
|
||
#
|
||
# The key is read now. The half it belongs to is NOT built -- nothing reads
|
||
# what a detail sheet draws -- and the check says so instead of passing.
|
||
|
||
def test_51_the_detail_half_is_registered_and_declares_a_probe():
|
||
assert 'check_detail_callouts' in [c.__name__ for c in TC.CHECKS]
|
||
assert 'check_detail_callouts' in TC._SKIP_PROBES
|
||
|
||
|
||
def test_51_the_probe_never_returns_none_on_a_silence():
|
||
assert TC._SKIP_PROBES['check_detail_callouts'](
|
||
{}, {}, _know(), None) is not None
|
||
|
||
|
||
def test_51_an_undeclared_prefix_stands_the_half_down_by_name():
|
||
"""`knowledge.DEFAULTS` declares no detail_sheet_prefix -- the plant
|
||
layer does -- so the stock configuration reads NOT RUN, naming the key
|
||
a plant has to write down."""
|
||
t = {}
|
||
assert TC.check_detail_callouts(t, {}, _know(), None) == []
|
||
reason = TC._SKIP_PROBES['check_detail_callouts'](t, {}, _know(), None)
|
||
assert 'nomenclature.detail_sheet_prefix' in reason
|
||
|
||
|
||
def _know_with_prefix(bed_text):
|
||
"""A plant that names its detail series, and a BED PLAN that says
|
||
`bed_text`. The view lookup is the only Revit-touching part of the
|
||
path, so it is the only thing replaced."""
|
||
know = _know()
|
||
know.setdefault('nomenclature', {})['detail_sheet_prefix'] = 'X'
|
||
real = TC._bed_plan_text
|
||
TC._bed_plan_text = lambda t, k: bed_text
|
||
return know, real
|
||
|
||
|
||
def test_51_a_plan_that_cites_a_detail_is_not_run_and_names_the_sheet():
|
||
know, real = _know_with_prefix('P106 (S.I.F.)\n06/X1')
|
||
try:
|
||
t = {}
|
||
assert TC.check_detail_callouts(t, {}, know, None) == []
|
||
reason = TC._SKIP_PROBES['check_detail_callouts'](t, {}, know, None)
|
||
finally:
|
||
TC._bed_plan_text = real
|
||
assert '06/X1' in reason and 'X1' in reason
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[TC.Finding('OK', 'BOM-MEMBERS', '9 named BOM item(s) match')],
|
||
[('check_bom_members', 'RAN', None),
|
||
('check_detail_callouts', 'SKIPPED', reason)]))
|
||
# THE POINT OF THE WHOLE ROW: the BOM half passed and 5.1 is still not
|
||
# answered, because the half that could have failed it was never asked.
|
||
assert rows['5.1'][3] == 'NOT RUN'
|
||
assert 'X1' in rows['5.1'][4]
|
||
|
||
|
||
def test_51_a_plan_that_cites_no_detail_is_a_pass_and_the_row_goes_green():
|
||
know, real = _know_with_prefix('P106 (S.I.F.)(E.S.)\nSR309 (E.L.)')
|
||
try:
|
||
t = {}
|
||
out = TC.check_detail_callouts(t, {}, know, None)
|
||
finally:
|
||
TC._bed_plan_text = real
|
||
assert [f.severity for f in out] == ['OK']
|
||
assert [f.code for f in out] == ['DETAIL-CALLOUT']
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
list(out) + [TC.Finding('OK', 'BOM-MEMBERS', 'match')],
|
||
[('check_bom_members', 'RAN', None),
|
||
('check_detail_callouts', 'RAN', None)]))
|
||
assert rows['5.1'][3] == 'OK'
|
||
|
||
|
||
# ------------------------------------------------- 6.2, the per-part minimum
|
||
#
|
||
# The catalogue publishes a minimum edge distance PER PART; the plant-wide
|
||
# `lifters.min_edge_distance_in` is one number for everything. 6.2 used to
|
||
# read only the second, so it disagreed with Panel Lift Check -- which has
|
||
# always gone through `knowledge.lifter_edge_distance` -- about the same
|
||
# insert on the same model.
|
||
#
|
||
# EVERY PART NUMBER BELOW IS INVENTED. A fixture pinned to a real catalogue
|
||
# entry breaks the day a published rating is corrected, which is a change to
|
||
# product data and not to this decision.
|
||
|
||
_FAR_PART = 'INVENTED_FAR_ANCHOR' # publishes 14", above the default
|
||
_NEAR_PART = 'INVENTED_NEAR_ANCHOR' # publishes 6", below the default
|
||
_UNPUBLISHED_PART = 'INVENTED_NO_ENTRY' # in no catalogue, no alias
|
||
|
||
|
||
def _edge_know(plant_default=9):
|
||
"""A knowledge tree holding two invented parts and a plant-wide default."""
|
||
return {
|
||
'lifters': {
|
||
'min_edge_distance_in': plant_default,
|
||
'catalogue_alias': {_FAR_PART: 'INVENTED_FAR_ITEM',
|
||
_NEAR_PART: 'INVENTED_NEAR_ITEM'},
|
||
},
|
||
'catalogue': {
|
||
'inserts': {
|
||
'INVENTED_FAR_ITEM': {'min_edge_distance_in': 14},
|
||
'INVENTED_NEAR_ITEM': {'min_edge_distance_in': 6},
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _edge_ticket(family, gap_ft):
|
||
"""A 20 ft piece with one insert `gap_ft` in from the near end."""
|
||
return {'lifters': [{'family': family, 'local': (gap_ft, 0.0, 0.0)}],
|
||
'panel_extents': ((0.0, 0.0, 0.0), (20.0, 4.0, 0.5))}
|
||
|
||
|
||
def _edge_row(out, log_state='RAN', note=None):
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
list(out), [('check_lifter_edge_distance', log_state, note)]))
|
||
return rows['6.2']
|
||
|
||
|
||
def test_62_a_part_whose_published_minimum_is_above_the_plant_default_fails():
|
||
"""10 inches from the end. The plant-wide 9" said OK; this part publishes
|
||
14", so it is short -- and short of a number that IS this part's own, so
|
||
the verdict is FAIL and not a hedge."""
|
||
know = _edge_know(plant_default=9)
|
||
out = TC.check_lifter_edge_distance(
|
||
_edge_ticket(_FAR_PART, 10.0 / 12.0), {}, know, None)
|
||
assert [f.code for f in out] == ['LIFT-EDGE']
|
||
assert out[0].severity == 'FAIL'
|
||
assert '14"' in out[0].message
|
||
assert 'published minimum' in out[0].message
|
||
assert _FAR_PART in out[0].message
|
||
assert _edge_row(out)[3] == 'FAIL'
|
||
|
||
|
||
def test_62_a_part_whose_published_minimum_is_below_the_plant_default_passes():
|
||
"""7 inches from the end. The plant-wide 9" flagged it; this part
|
||
publishes 6", so there is nothing to report and the row goes green."""
|
||
know = _edge_know(plant_default=9)
|
||
out = TC.check_lifter_edge_distance(
|
||
_edge_ticket(_NEAR_PART, 7.0 / 12.0), {}, know, None)
|
||
assert [f.severity for f in out] == ['OK']
|
||
assert '6"' in out[0].message
|
||
assert _edge_row(out)[3] == 'OK'
|
||
|
||
|
||
def test_62_an_unpublished_part_falls_back_and_says_it_leaned_on_the_default():
|
||
"""A part the catalogue does not publish is held to the plant-wide number
|
||
-- the same fallback `liftexport` uses -- and the finding says so, because
|
||
"9 inches" and "14 inches for this part" are different claims."""
|
||
know = _edge_know(plant_default=9)
|
||
out = TC.check_lifter_edge_distance(
|
||
_edge_ticket(_UNPUBLISHED_PART, 5.0 / 12.0), {}, know, None)
|
||
# Short of the fallback, so it is reported -- but as a WARN, because the
|
||
# limit may not belong to this insert. FAIL would assert that it does.
|
||
assert out[0].severity == 'WARN'
|
||
assert 'plant-wide default' in out[0].message
|
||
assert 'lifters.min_edge_distance_in' in out[0].message
|
||
assert _edge_row(out)[3] == 'WARN'
|
||
|
||
|
||
def test_62_an_unknown_part_with_no_plant_default_is_could_not_judge():
|
||
"""Not in the catalogue and no plant-wide number: nobody knows how far
|
||
this insert must sit from an end. That is NOT RUN with a reason, never a
|
||
pass -- and the reason names the part and the key, so it can be fixed."""
|
||
know = _edge_know(plant_default=None)
|
||
t = _edge_ticket(_UNPUBLISHED_PART, 1.0 / 12.0)
|
||
out = TC.check_lifter_edge_distance(t, {}, know, None)
|
||
assert out == []
|
||
reason = TC._SKIP_PROBES['check_lifter_edge_distance'](t, {}, know, None)
|
||
assert reason
|
||
assert _UNPUBLISHED_PART in reason
|
||
assert 'lifters.min_edge_distance_in' in reason
|
||
row = _edge_row(out, log_state='SKIPPED', note=reason)
|
||
assert row[3] == 'NOT RUN'
|
||
assert TP.state_color(row[3]) == 'red'
|
||
assert _UNPUBLISHED_PART in row[4]
|
||
|
||
|
||
def test_62_never_reports_a_pass_over_an_insert_it_could_not_judge():
|
||
"""One insert the catalogue publishes and one it does not, with no plant
|
||
default. The judged one is clear, and the row still may not go green: a
|
||
green row is a claim about every insert on the piece."""
|
||
know = _edge_know(plant_default=None)
|
||
t = {'lifters': [{'family': _NEAR_PART, 'local': (5.0, 0.0, 0.0)},
|
||
{'family': _UNPUBLISHED_PART, 'local': (9.0, 0.0, 0.0)}],
|
||
'panel_extents': ((0.0, 0.0, 0.0), (20.0, 4.0, 0.5))}
|
||
out = TC.check_lifter_edge_distance(t, {}, know, None)
|
||
assert [f.severity for f in out] == ['WARN']
|
||
assert 'NOT judged' in out[0].message
|
||
assert _UNPUBLISHED_PART in out[0].message
|
||
assert _edge_row(out)[3] == 'WARN'
|
||
|
||
|
||
def test_62_asks_the_catalogue_the_same_question_panel_lift_check_does():
|
||
"""The point of the fix: two outputs of one tool, one answer. This holds
|
||
`_edge_limit` to `liftexport`'s own resolution order rather than to a
|
||
hand-copied number, so the two cannot drift apart again."""
|
||
from vibe import knowledge
|
||
know = _edge_know(plant_default=9)
|
||
for family, expected in ((_FAR_PART, 14), (_NEAR_PART, 6)):
|
||
liftexport_would_use = (knowledge.lifter_edge_distance(know, family) or
|
||
knowledge.get(know,
|
||
'lifters.min_edge_distance_in'))
|
||
assert TC._edge_limit(know, family)[0] == liftexport_would_use
|
||
assert liftexport_would_use == expected
|
||
# And the fallback hop is the same hop, for a part with no entry.
|
||
assert TC._edge_limit(know, _UNPUBLISHED_PART) == (9, 'plant')
|
||
|
||
|
||
# ------------------------------------ 6.7, the minimum spacing between two
|
||
#
|
||
# ALP catalog p53, 02/2024, footnote: "Minimum anchor spacing is 2x the
|
||
# published edge distance". The owner handed over that page; its edge
|
||
# distances became 6.2 and this sentence on the same page never landed, so
|
||
# two 8T anchors 20 in apart passed every row on the board while the page
|
||
# asked for 28.
|
||
#
|
||
# EVERY PART NUMBER BELOW IS INVENTED, for `_edge_know`'s reason: a fixture
|
||
# pinned to a real catalogue entry breaks the day a published rating is
|
||
# corrected, which is a change to product data and not to this decision.
|
||
|
||
|
||
def _spacing_know(published=None, multiple=2.0, edge=14):
|
||
rule = {'source': 'an invented datasheet, p1, 01/2099'}
|
||
if multiple is not None:
|
||
rule['multiple_of_edge_distance'] = multiple
|
||
if published is not None:
|
||
rule['published_in'] = published
|
||
return {
|
||
'lifters': {
|
||
# A plant-wide edge distance, present ON PURPOSE: it must not
|
||
# become a spacing. 6.2 may lean on it; 6.7 may not, because
|
||
# doubling a general number produces a specific-looking claim
|
||
# about a part nobody measured.
|
||
'min_edge_distance_in': 9,
|
||
'catalogue_alias': {_FAR_PART: 'INVENTED_FAR_ITEM',
|
||
_NEAR_PART: 'INVENTED_NEAR_ITEM'},
|
||
},
|
||
'catalogue': {
|
||
'inserts': {
|
||
'INVENTED_FAR_ITEM': {'min_edge_distance_in': edge,
|
||
'min_spacing_rule': rule},
|
||
'INVENTED_NEAR_ITEM': {'min_edge_distance_in': 6,
|
||
'min_spacing_rule': dict(rule)},
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _spacing_ticket(pairs):
|
||
"""A 20 ft x 4 ft x 6 in piece carrying inserts at (family, x_ft)."""
|
||
return {'lifters': [{'mark': 'L{0}'.format(i), 'family': fam,
|
||
'local': (x, 2.0, 0.25)}
|
||
for i, (fam, x) in enumerate(pairs)],
|
||
'panel_extents': ((0.0, 0.0, 0.0), (20.0, 4.0, 0.5)),
|
||
'thickness': 0.5}
|
||
|
||
|
||
def _spacing_row(out, log_state='RAN', note=None):
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
list(out), [('check_lifter_anchor_spacing', log_state, note)]))
|
||
return rows['6.7']
|
||
|
||
|
||
def test_67_two_anchors_closer_than_twice_the_edge_distance_fail():
|
||
"""The case the page names and nothing measured: 20 in apart on a part
|
||
publishing 14 in edge distance, so the minimum is 28 in."""
|
||
know = _spacing_know(edge=14)
|
||
out = TC.check_lifter_anchor_spacing(
|
||
_spacing_ticket([(_FAR_PART, 0.0), (_FAR_PART, 20.0 / 12.0)]),
|
||
{}, know, None)
|
||
assert [f.code for f in out] == ['LIFT-SPACING']
|
||
assert out[0].severity == 'FAIL'
|
||
assert '20"' in out[0].message # what was measured
|
||
assert '28"' in out[0].message # what the page requires
|
||
assert '2x' in out[0].message and '14"' in out[0].message
|
||
assert 'p1' in out[0].message # the source, named
|
||
assert _spacing_row(out)[3] == 'FAIL'
|
||
|
||
|
||
def test_67_the_same_pair_one_inch_further_apart_passes():
|
||
know = _spacing_know(edge=14)
|
||
out = TC.check_lifter_anchor_spacing(
|
||
_spacing_ticket([(_FAR_PART, 0.0), (_FAR_PART, 29.0 / 12.0)]),
|
||
{}, know, None)
|
||
assert [f.severity for f in out] == ['OK']
|
||
assert '28"' in out[0].message
|
||
assert _spacing_row(out)[3] == 'OK'
|
||
|
||
|
||
def test_67_a_mixed_pair_is_held_to_the_LARGER_of_the_two_minimums():
|
||
"""A 14 in anchor beside a 6 in one does not get the 6 in rule. Taking
|
||
the smaller passes exactly the pair this check exists for."""
|
||
know = _spacing_know(edge=14)
|
||
out = TC.check_lifter_anchor_spacing(
|
||
_spacing_ticket([(_FAR_PART, 0.0), (_NEAR_PART, 20.0 / 12.0)]),
|
||
{}, know, None)
|
||
assert out[0].severity == 'FAIL'
|
||
assert '28"' in out[0].message # 2 x 14, not 2 x 6
|
||
assert '12"' not in out[0].message
|
||
|
||
|
||
def test_67_a_published_spacing_overrides_the_multiple():
|
||
"""A sheet that states a spacing outright beats a rule of thumb about
|
||
the same part -- and the arithmetic disappears from the message with it,
|
||
because there is none to show."""
|
||
know = _spacing_know(published=18, edge=14)
|
||
out = TC.check_lifter_anchor_spacing(
|
||
_spacing_ticket([(_FAR_PART, 0.0), (_FAR_PART, 20.0 / 12.0)]),
|
||
{}, know, None)
|
||
assert [f.severity for f in out] == ['OK'] # 20" clears 18"
|
||
assert '18"' in out[0].message
|
||
assert '2x' not in out[0].message
|
||
|
||
|
||
def test_67_an_unknown_part_is_could_not_judge_and_never_a_pass():
|
||
"""The honesty state. An insert the catalogue does not cover has no
|
||
spacing, and the plant-wide edge distance in the fixture must not be
|
||
doubled into one."""
|
||
know = _spacing_know()
|
||
t = _spacing_ticket([(_UNPUBLISHED_PART, 0.0), (_UNPUBLISHED_PART, 0.5)])
|
||
out = TC.check_lifter_anchor_spacing(t, {}, know, None)
|
||
assert out == []
|
||
reason = TC._SKIP_PROBES['check_lifter_anchor_spacing'](t, {}, know, None)
|
||
assert reason
|
||
assert _UNPUBLISHED_PART in reason
|
||
row = _spacing_row(out, log_state='SKIPPED', note=reason)
|
||
assert row[3] == 'NOT RUN'
|
||
assert TP.state_color(row[3]) == 'red'
|
||
|
||
|
||
def test_67_a_part_with_no_published_edge_distance_stands_down_naming_it():
|
||
""""2x the published edge distance" is not a number when nothing is
|
||
published to double, and the sentence says exactly that."""
|
||
know = _spacing_know(edge=None)
|
||
know['catalogue']['inserts']['INVENTED_FAR_ITEM'].pop(
|
||
'min_edge_distance_in')
|
||
t = _spacing_ticket([(_FAR_PART, 0.0), (_FAR_PART, 0.5)])
|
||
out = TC.check_lifter_anchor_spacing(t, {}, know, None)
|
||
assert out == []
|
||
reason = TC._SKIP_PROBES['check_lifter_anchor_spacing'](t, {}, know, None)
|
||
assert 'nothing to double' in reason
|
||
assert _spacing_row(out, log_state='SKIPPED', note=reason)[3] == 'NOT RUN'
|
||
|
||
|
||
def test_67_never_reports_a_pass_over_a_pair_it_could_not_judge():
|
||
"""A judged pair that is clear plus an insert nobody can resolve. The
|
||
row may not go green: green is a claim about every anchor on the piece.
|
||
"""
|
||
know = _spacing_know(edge=14)
|
||
t = _spacing_ticket([(_FAR_PART, 0.0), (_FAR_PART, 3.0),
|
||
(_UNPUBLISHED_PART, 6.0)])
|
||
out = TC.check_lifter_anchor_spacing(t, {}, know, None)
|
||
assert [f.code for f in out] == ['LIFT-SPACING']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'Not every insert could be judged' in out[0].message
|
||
assert _UNPUBLISHED_PART in out[0].message
|
||
assert _spacing_row(out)[3] == 'WARN'
|
||
|
||
|
||
def test_67_one_insert_is_no_pair_and_says_so():
|
||
know = _spacing_know()
|
||
t = _spacing_ticket([(_FAR_PART, 0.0)])
|
||
assert TC.check_lifter_anchor_spacing(t, {}, know, None) == []
|
||
reason = TC._SKIP_PROBES['check_lifter_anchor_spacing'](t, {}, know, None)
|
||
assert 'no pair' in reason
|
||
|
||
|
||
def test_67_measures_in_the_plane_and_ignores_the_thickness_axis():
|
||
"""Two anchors at the same in-plane point in different wythes are 0 in
|
||
apart for this rule, not half a panel thickness apart. In-plane is the
|
||
smaller distance, which is the conservative read, and it is what a
|
||
spacing rule about overlapping concrete cones means."""
|
||
know = _spacing_know(edge=14)
|
||
t = _spacing_ticket([(_FAR_PART, 0.0), (_FAR_PART, 0.0)])
|
||
t['lifters'][1]['local'] = (0.0, 2.0, 0.45) # 2.4" away in thickness
|
||
out = TC.check_lifter_anchor_spacing(t, {}, know, None)
|
||
assert out[0].severity == 'FAIL'
|
||
assert '0"' in out[0].message
|
||
# ...and the axis was DERIVED, not assumed to be index 2.
|
||
assert TC._in_plane_axes(t) == [0, 1]
|
||
tall = dict(t, panel_extents=((0.0, 0.0, 0.0), (0.5, 4.0, 20.0)),
|
||
thickness=0.5)
|
||
assert TC._in_plane_axes(tall) == [1, 2]
|
||
|
||
|
||
def test_67_unreadable_geometry_is_a_warn_and_not_a_silence():
|
||
know = _spacing_know()
|
||
t = _spacing_ticket([(_FAR_PART, 0.0), (_FAR_PART, 0.5)])
|
||
t['panel_extents'] = None
|
||
out = TC.check_lifter_anchor_spacing(t, {}, know, None)
|
||
assert [f.severity for f in out] == ['WARN']
|
||
assert 'not measured' in out[0].message
|
||
|
||
|
||
# ------------------------------------- 6.9, lifting-point count vs length
|
||
#
|
||
# MC PLANT RULE, owner verbatim 2026-09-02 (see ticketchecks.py above
|
||
# `check_lifting_points` for the full quote and the arithmetic). The plant
|
||
# id, family name and every dimension in these fixtures are invented --
|
||
# `sample_plant` shape, never the real plant name -- because the point of
|
||
# the test is the ARITHMETIC (count vs length), not any real job.
|
||
|
||
_MC_LIFT_KNOW = {
|
||
'lifters': {
|
||
'lifting_points': {
|
||
'two_point_max_length_ft': 20,
|
||
'two_point_absolute_max_ft': 25,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _points_ticket(length_ft, xs, family='LIFT_SAMPLE_8T'):
|
||
"""A `length_ft` x 4 ft x 6 in panel carrying one lifter at each x in
|
||
`xs` (feet from the panel origin, along its own long axis)."""
|
||
return {
|
||
'lifters': [{'family': family, 'id': i, 'local': (x, 2.0, 0.25)}
|
||
for i, x in enumerate(xs)],
|
||
'panel_extents': ((0.0, 0.0, 0.0), (length_ft, 4.0, 0.5)),
|
||
'length': length_ft,
|
||
'thickness': 0.5,
|
||
}
|
||
|
||
|
||
def _points_row(out, log_state='RAN', note=None):
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
list(out), [('check_lifting_points', log_state, note)]))
|
||
return rows['6.9']
|
||
|
||
|
||
def test_69_18ft_two_points_ok():
|
||
"""Under the 20 ft nominal limit -- 2-point is simply fine here."""
|
||
t = _points_ticket(18.0, [3.0, 15.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POINTS']
|
||
assert out[0].severity == 'OK'
|
||
assert '2 lifting point' in out[0].message
|
||
assert _points_row(out)[3] == 'OK'
|
||
|
||
|
||
def test_69_18ft_four_points_ok():
|
||
"""More points is never worse: still OK well under the nominal limit."""
|
||
t = _points_ticket(18.0, [2.0, 6.0, 12.0, 16.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
assert '4 lifting point' in out[0].message
|
||
|
||
|
||
def test_69_22ft_two_points_warn():
|
||
"""Over the nominal 20 ft rule but inside the real 25 ft ceiling --
|
||
the grey zone the owner named explicitly ("你按照20-25给warn")."""
|
||
t = _points_ticket(22.0, [3.0, 19.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'WARN'
|
||
assert '2 lifting point' in out[0].message
|
||
assert '20' in out[0].message and '25' in out[0].message
|
||
assert _points_row(out)[3] == 'WARN'
|
||
|
||
|
||
def test_69_22ft_four_points_ok():
|
||
t = _points_ticket(22.0, [2.0, 8.0, 14.0, 20.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
|
||
|
||
def test_69_27ft_two_points_fail():
|
||
"""Past the real 25 ft ceiling -- 2-point no longer holds, per the
|
||
owner's "25以上给fail"."""
|
||
t = _points_ticket(27.0, [3.0, 24.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'FAIL'
|
||
assert '2 lifting point' in out[0].message
|
||
assert '25' in out[0].message
|
||
assert _points_row(out)[3] == 'FAIL'
|
||
|
||
|
||
def test_69_27ft_four_points_ok():
|
||
"""4-point clears any length -- the rule is about too FEW points, never
|
||
too many."""
|
||
t = _points_ticket(27.0, [2.0, 9.0, 18.0, 25.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
assert '4-point rule' in out[0].message
|
||
|
||
|
||
def test_69_boundary_20ft_exactly_is_still_the_two_point_zone():
|
||
""""小于20ft则2p" / "25以内都可以2p" pin the boundaries inclusive: at
|
||
exactly 20 ft, 2-point is still simply fine, not the grey zone."""
|
||
t = _points_ticket(20.0, [3.0, 17.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
|
||
|
||
def test_69_boundary_25ft_exactly_is_the_warn_ceiling_not_fail():
|
||
"""At exactly 25 ft 2-point still just barely holds -- WARN, not FAIL."""
|
||
t = _points_ticket(25.0, [3.0, 22.0])
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'WARN'
|
||
|
||
|
||
def test_69_a_pair_of_anchors_at_one_station_is_one_point_not_two():
|
||
"""Two anchors a few inches apart are ONE pick point, not two -- the
|
||
whole reason this reuses `liftexport.rows_of` instead of counting
|
||
`len(t['lifters'])`. Proven the way that matters: with 4 modelled
|
||
anchors forming 2 real points on a 27 ft panel, counting anchors
|
||
would read 4-point and pass; counting points must read 2-point and
|
||
FAIL."""
|
||
t = _points_ticket(27.0, [2.0, 2.3, 24.0, 24.3])
|
||
assert len(t['lifters']) == 4
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert out[0].severity == 'FAIL'
|
||
assert '2 lifting point' in out[0].message
|
||
|
||
|
||
def test_69_non_mc_layer_stands_down_naming_the_missing_rule():
|
||
"""A plant that has not declared MC's numbers never gets judged against
|
||
them -- any non-MC layer."""
|
||
t = _points_ticket(27.0, [3.0, 24.0])
|
||
know = {}
|
||
assert TC.check_lifting_points(t, {}, know, None) == []
|
||
reason = TC._SKIP_PROBES['check_lifting_points'](t, {}, know, None)
|
||
assert reason
|
||
assert 'two_point_max_length_ft' in reason
|
||
assert 'two_point_absolute_max_ft' in reason
|
||
row = _points_row([], log_state='SKIPPED', note=reason)
|
||
assert row[3] == 'NOT RUN'
|
||
assert TP.state_color(row[3]) == 'red'
|
||
|
||
|
||
def test_69_length_unreadable_is_a_warn_finding():
|
||
t = _points_ticket(27.0, [3.0, 24.0])
|
||
t['length'] = None
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POINTS-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'length' in out[0].message
|
||
assert _points_row(out)[3] == 'WARN'
|
||
|
||
|
||
def test_69_zero_lifters_defers_to_61_and_never_passes():
|
||
"""No recognised lifter at all is 6.1's (LIFT-CAPACITY) territory --
|
||
this check stands down silently and its OWN probe points at 6.1 by
|
||
name rather than re-deriving the families.lifter_include story."""
|
||
t = {'lifters': [], 'panel_extents': ((0.0, 0.0, 0.0), (20.0, 4.0, 0.5)),
|
||
'length': 20.0}
|
||
assert TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None) == []
|
||
reason = TC._SKIP_PROBES['check_lifting_points'](t, {}, _MC_LIFT_KNOW, None)
|
||
assert reason
|
||
assert '6.1' in reason
|
||
row = _points_row([], log_state='SKIPPED', note=reason)
|
||
assert row[3] == 'NOT RUN'
|
||
|
||
|
||
def test_69_lifters_with_no_readable_position_is_unread_not_a_pass():
|
||
"""Recognised lifters exist, but none carry a resolved local position --
|
||
the panel frame could not be solved. A could-not-judge, not a pass, and
|
||
it is named on the row rather than left for the probe."""
|
||
t = _points_ticket(27.0, [3.0, 24.0])
|
||
for lift in t['lifters']:
|
||
lift['local'] = None
|
||
out = TC.check_lifting_points(t, {}, _MC_LIFT_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POINTS-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'position' in out[0].message
|
||
|
||
|
||
def test_69_a_bad_threshold_value_is_treated_as_not_set():
|
||
"""A non-numeric threshold must not raise into CHECK-ERROR -- it stands
|
||
down the same as an absent one."""
|
||
t = _points_ticket(27.0, [3.0, 24.0])
|
||
know = {'lifters': {'lifting_points': {
|
||
'two_point_max_length_ft': 'not-a-number',
|
||
'two_point_absolute_max_ft': 25}}}
|
||
assert TC.check_lifting_points(t, {}, know, None) == []
|
||
|
||
|
||
# ------------------------------------- 6.10, lift-point POSITION vs PCI
|
||
#
|
||
# MC PLANT RULE, owner verbatim 2026-09-02 (see ticketchecks.py above
|
||
# `check_lift_positions` for the full quote). DISTINCT from 6.9's tests
|
||
# above: this is never about how many points there are, only where they
|
||
# sit. `_points_ticket` is reused unchanged -- same fixture shape, same
|
||
# invented `sample_plant`-grade family name and dimensions, nothing real.
|
||
|
||
_MC_POS_KNOW = {
|
||
'lifters': {
|
||
'lifting_points': {
|
||
'pci_position_tolerance_in': 6,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _pci_stations(length_ft):
|
||
"""The same PCI two-point stations the check itself computes, via the
|
||
check module's own constant -- so a test fixture can never quietly
|
||
drift from what the check considers "on PCI"."""
|
||
near = TC.PCI_TWO_POINT_FRACTION * length_ft
|
||
return near, length_ft - near
|
||
|
||
|
||
def test_610_2point_both_on_pci_ok():
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1, s2])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS']
|
||
assert out[0].severity == 'OK'
|
||
|
||
|
||
def test_610_2point_one_point_8in_off_warns_the_other_stays_ok():
|
||
"""One point 8" off PCI warns and names it; the other, exactly on PCI,
|
||
reads OK in the same finding."""
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1 + 8.0 / 12.0, s2])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS']
|
||
assert out[0].severity == 'WARN'
|
||
msg = out[0].message
|
||
assert 'point 1' in msg and 'WARN' in msg
|
||
assert 'point 2' in msg and 'OK' in msg
|
||
assert '8' in msg
|
||
|
||
|
||
def test_610_boundary_exactly_6in_off_is_ok():
|
||
""""相差大于6\"" is strictly greater-than -- exactly 6" off passes."""
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1 + 0.5, s2]) # 0.5 ft == 6.0 in, exact
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
|
||
|
||
def test_610_boundary_6_01in_off_warns():
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1 + 6.01 / 12.0, s2])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert out[0].severity == 'WARN'
|
||
|
||
|
||
def _pci_four_stations(length_ft):
|
||
"""The same PCI four-point stations the check itself computes, via the
|
||
check module's own constant -- so a test fixture can never quietly
|
||
drift from what the check considers "on PCI"."""
|
||
return [f * length_ft for f in TC.PCI_FOUR_POINT_FRACTIONS]
|
||
|
||
|
||
def test_610_4point_all_on_pci_ok():
|
||
"""Owner-confirmed four-point stations (0.104L/0.395L/0.605L/0.896L,
|
||
2026-09-02) are now wired in -- a 4-point pick sitting exactly on all
|
||
four is judged OK, not stood down."""
|
||
length = 40.0
|
||
s1, s2, s3, s4 = _pci_four_stations(length)
|
||
t = _points_ticket(length, [s1, s2, s3, s4])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS']
|
||
assert out[0].severity == 'OK'
|
||
|
||
|
||
def test_610_4point_one_point_8in_off_warns_the_others_stay_ok():
|
||
"""One of the four points 8" off PCI warns and names it; the other
|
||
three, exactly on PCI, read OK in the same finding."""
|
||
length = 40.0
|
||
s1, s2, s3, s4 = _pci_four_stations(length)
|
||
t = _points_ticket(length, [s1, s2 + 8.0 / 12.0, s3, s4])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS']
|
||
assert out[0].severity == 'WARN'
|
||
msg = out[0].message
|
||
assert 'point 1' in msg and 'point 3' in msg and 'point 4' in msg
|
||
assert 'point 2' in msg and 'WARN' in msg
|
||
assert '8' in msg
|
||
|
||
|
||
def test_610_4point_boundary_exactly_6in_off_is_ok():
|
||
"""Same strictly-greater-than-6" boundary as the 2-point case."""
|
||
length = 40.0
|
||
s1, s2, s3, s4 = _pci_four_stations(length)
|
||
t = _points_ticket(length, [s1 + 0.5, s2, s3, s4]) # 0.5 ft == 6.0 in
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
|
||
|
||
def test_610_4point_boundary_6_01in_off_warns():
|
||
length = 40.0
|
||
s1, s2, s3, s4 = _pci_four_stations(length)
|
||
t = _points_ticket(length, [s1 + 6.01 / 12.0, s2, s3, s4])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert out[0].severity == 'WARN'
|
||
|
||
|
||
def test_610_2point_still_uses_0207_0793_unchanged():
|
||
"""The 4-point branch landing must not perturb the 2-point case --
|
||
still the 0.207L/0.793L fractions, named verbatim in the finding."""
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1, s2])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
assert '0.207L' in out[0].message
|
||
assert '0.793L' in out[0].message
|
||
|
||
|
||
def test_610_3point_stands_down_not_configured():
|
||
"""Any count other than 2 or 4 (e.g. 3) is still not known -- a
|
||
could-not-judge, never a pass and never a fail."""
|
||
length = 20.0
|
||
t = _points_ticket(length, [2.0, 10.0, 18.0])
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert '3-point' in out[0].message
|
||
assert 'not configured' in out[0].message
|
||
|
||
|
||
def test_610_non_mc_layer_stands_down_naming_the_missing_key():
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1, s2])
|
||
know = {}
|
||
assert TC.check_lift_positions(t, {}, know, None) == []
|
||
reason = TC._SKIP_PROBES['check_lift_positions'](t, {}, know, None)
|
||
assert reason
|
||
assert 'pci_position_tolerance_in' in reason
|
||
|
||
|
||
def test_610_length_unreadable_is_a_warn_finding():
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1, s2])
|
||
t['length'] = None
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'length' in out[0].message
|
||
|
||
|
||
def test_610_zero_lifters_defers_to_61_and_never_passes():
|
||
t = {'lifters': [], 'panel_extents': ((0.0, 0.0, 0.0), (20.0, 4.0, 0.5)),
|
||
'length': 20.0}
|
||
assert TC.check_lift_positions(t, {}, _MC_POS_KNOW, None) == []
|
||
reason = TC._SKIP_PROBES['check_lift_positions'](t, {}, _MC_POS_KNOW, None)
|
||
assert reason
|
||
assert '6.1' in reason
|
||
|
||
|
||
def test_610_lifters_with_no_readable_position_is_unread_not_a_pass():
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1, s2])
|
||
for lift in t['lifters']:
|
||
lift['local'] = None
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'position' in out[0].message
|
||
|
||
|
||
def test_610_anchor_pair_at_one_station_is_one_point_on_pci():
|
||
"""Two anchors a few inches apart cluster into ONE pick point, same
|
||
grouping 6.9 relies on -- proven the way that matters: 4 modelled
|
||
anchors forming 2 real points, each pair straddling a PCI station,
|
||
must be judged as a 2-point lift (OK against PCI) rather than
|
||
stood down as an unconfigured 4-point lift."""
|
||
length = 20.0
|
||
s1, s2 = _pci_stations(length)
|
||
t = _points_ticket(length, [s1 - 0.1, s1 + 0.1, s2 - 0.1, s2 + 0.1])
|
||
assert len(t['lifters']) == 4
|
||
out = TC.check_lift_positions(t, {}, _MC_POS_KNOW, None)
|
||
assert [f.code for f in out] == ['LIFT-POS']
|
||
assert out[0].severity == 'OK'
|
||
|
||
|
||
# ------------------------------------- 6.11, dunnage-point count vs
|
||
# stripping-point count
|
||
#
|
||
# MC PLANT RULE, owner verbatim 2026-09-02 (see ticketchecks.py above
|
||
# `check_dunnage_stripping_match` for the full quotes and every design
|
||
# decision). `_points_ticket`'s shape is reused for the lifter/geometry
|
||
# half; the dunnage half goes through `_dunnage_station_reader`, which is
|
||
# monkeypatched directly here rather than standing 6.4's whole
|
||
# sheet-reading fixture chain back up a second time -- that reader's OWN
|
||
# correctness (sheet notes, printing order, "not a pass" silences) is 6.4's
|
||
# tests' job, proven above; this row's tests are the COMPARISON, not the
|
||
# read. `sample_plant`-grade family names throughout, same as 6.9/6.10.
|
||
|
||
_MC_DUN_STRIP_KNOW = {
|
||
'lifters': {
|
||
'lifting_points': {
|
||
'dunnage_matches_stripping': True,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _dun_strip_ticket(length_ft, face_xs, other_xs=(),
|
||
face_family='LPA_FACE_4T', other_family='QL_TOP_8T'):
|
||
"""`_points_ticket`'s shape plus a second (default non-face) family of
|
||
lifters and the bits `check_dunnage_stripping_match` needs to reach for
|
||
the dunnage stations. `face_family` starts with LPA on purpose -- the
|
||
fallback identification -- so most tests below never need a
|
||
`usage`-tagged knowledge fixture. `doc` is any non-None placeholder:
|
||
`_dunnage_station_reader` is monkeypatched in these tests and never
|
||
actually dereferences it."""
|
||
lifters = [{'family': face_family, 'id': 'F{0}'.format(i),
|
||
'local': (x, 2.0, 0.25)} for i, x in enumerate(face_xs)]
|
||
lifters += [{'family': other_family, 'id': 'O{0}'.format(i),
|
||
'local': (x, 2.0, 0.25)} for i, x in enumerate(other_xs)]
|
||
return {
|
||
'lifters': lifters,
|
||
'panel_extents': ((0.0, 0.0, 0.0), (length_ft, 4.0, 0.5)),
|
||
'length': length_ft,
|
||
'thickness': 0.5,
|
||
'panel_id': 1,
|
||
'doc': object(),
|
||
}
|
||
|
||
|
||
def _patch_dun_stations(monkeypatch, stations, reason=None):
|
||
monkeypatch.setattr(TC, '_dunnage_station_reader',
|
||
lambda t, tdoc: (stations, reason))
|
||
|
||
|
||
def _dun_strip_row(out, log_state='RAN', note=None):
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
list(out), [('check_dunnage_stripping_match', log_state, note)]))
|
||
return rows['6.11']
|
||
|
||
|
||
def test_611_usage_tag_identifies_a_non_lpa_family_as_face():
|
||
"""The primary identification -- `usage: back_stripping`, read through
|
||
the same resolver `lifter_capacity()` uses -- works on a family that
|
||
does not even start with LPA."""
|
||
know = {'lifters': {'capacity_lb': {
|
||
'QL846': {'usage': 'back_stripping', 'tension_lb': 3080}}}}
|
||
assert TC._is_face_stripping_lifter(know, 'QL846') is True
|
||
|
||
|
||
def test_611_usage_tag_overrides_the_lpa_prefix_fallback():
|
||
"""An LPA-prefixed family explicitly tagged something OTHER than
|
||
back_stripping is not a face lifter -- a stated usage tag is
|
||
authoritative and the bare-prefix fallback never overrides it."""
|
||
know = {'lifters': {'capacity_lb': {
|
||
'LPA_SPECIAL': {'usage': 'erection_stripping'}}}}
|
||
assert TC._is_face_stripping_lifter(know, 'LPA_SPECIAL') is False
|
||
|
||
|
||
def test_611_lpa_prefix_is_only_a_fallback_for_no_usage_tag_at_all():
|
||
know = {}
|
||
assert TC._is_face_stripping_lifter(know, 'LPA4T512_LIFTING PIN ANCHOR') \
|
||
is True
|
||
assert TC._is_face_stripping_lifter(know, 'LIFT_QL589G_8T') is False
|
||
|
||
|
||
def test_611_2v2_matches_is_ok(monkeypatch):
|
||
t = _dun_strip_ticket(27.0, [3.0, 24.0])
|
||
_patch_dun_stations(monkeypatch, [5.0, 17.0])
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert [f.code for f in out] == ['DUN-STRIP']
|
||
assert out[0].severity == 'OK'
|
||
assert '2 dunnage point' in out[0].message
|
||
assert '2 stripping' in out[0].message
|
||
assert _dun_strip_row(out)[3] == 'OK'
|
||
|
||
|
||
def test_611_4_face_2_dunnage_warns_naming_4_vs_2(monkeypatch):
|
||
t = _dun_strip_ticket(27.0, [2.0, 9.0, 18.0, 25.0])
|
||
_patch_dun_stations(monkeypatch, [5.0, 17.0])
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert [f.code for f in out] == ['DUN-STRIP']
|
||
assert out[0].severity == 'WARN'
|
||
assert '2 dunnage' in out[0].message and '4 stripping' in out[0].message
|
||
assert _dun_strip_row(out)[3] == 'WARN'
|
||
|
||
|
||
def test_611_2_face_4_dunnage_warns_naming_2_vs_4(monkeypatch):
|
||
t = _dun_strip_ticket(27.0, [3.0, 24.0])
|
||
_patch_dun_stations(monkeypatch, [2.0, 8.0, 18.0, 25.0])
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert out[0].severity == 'WARN'
|
||
assert '4 dunnage' in out[0].message and '2 stripping' in out[0].message
|
||
|
||
|
||
def test_611_mixed_only_face_lifters_count_toward_stripping(monkeypatch):
|
||
"""A top/side lifter sharing the ticket with two face lifters must not
|
||
be counted toward N_strip -- proven the way that matters: 3 real
|
||
points on the piece (2 face, 1 top/side) but the verdict must read as
|
||
2-vs-2, not 3-vs-2."""
|
||
t = _dun_strip_ticket(27.0, [3.0, 24.0], other_xs=[13.5])
|
||
_patch_dun_stations(monkeypatch, [5.0, 17.0])
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
assert '2 stripping' in out[0].message
|
||
|
||
|
||
def test_611_a_pair_of_anchors_at_one_station_is_one_point(monkeypatch):
|
||
"""Same grouping fixture as 6.9/6.10: 4 modelled face anchors forming 2
|
||
real points must read as 2-point, not 4-point."""
|
||
t = _dun_strip_ticket(27.0, [2.0, 2.3, 24.0, 24.3])
|
||
assert len(t['lifters']) == 4
|
||
_patch_dun_stations(monkeypatch, [5.0, 17.0])
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert out[0].severity == 'OK'
|
||
assert '2 stripping' in out[0].message
|
||
|
||
|
||
def test_611_non_mc_layer_stands_down_naming_the_missing_key():
|
||
t = _dun_strip_ticket(27.0, [3.0, 24.0])
|
||
know = {}
|
||
assert TC.check_dunnage_stripping_match(t, {}, know, None) == []
|
||
reason = TC._SKIP_PROBES['check_dunnage_stripping_match'](
|
||
t, {}, know, None)
|
||
assert reason
|
||
assert 'dunnage_matches_stripping' in reason
|
||
row = _dun_strip_row([], log_state='SKIPPED', note=reason)
|
||
assert row[3] == 'NOT RUN'
|
||
assert TP.state_color(row[3]) == 'red'
|
||
|
||
|
||
def test_611_zero_lifters_defers_to_61_and_never_passes():
|
||
t = {'lifters': [], 'panel_extents': ((0.0, 0.0, 0.0), (20.0, 4.0, 0.5)),
|
||
'length': 20.0}
|
||
assert TC.check_dunnage_stripping_match(
|
||
t, {}, _MC_DUN_STRIP_KNOW, None) == []
|
||
reason = TC._SKIP_PROBES['check_dunnage_stripping_match'](
|
||
t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert reason
|
||
assert '6.1' in reason
|
||
|
||
|
||
def test_611_zero_face_lifters_is_not_judged_not_a_pass(monkeypatch):
|
||
"""Only a top/side lifter on this piece -- no stripping-point count can
|
||
be established, and this must NOT read as a quiet 0-vs-0 pass."""
|
||
t = _dun_strip_ticket(27.0, [], other_xs=[3.0, 24.0])
|
||
_patch_dun_stations(monkeypatch, []) # must never even be reached
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert [f.code for f in out] == ['DUN-STRIP-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'back_stripping' in out[0].message or 'face' in out[0].message
|
||
|
||
|
||
def test_611_lifters_with_no_readable_position_is_unread_not_a_pass():
|
||
t = _dun_strip_ticket(27.0, [3.0, 24.0])
|
||
for lift in t['lifters']:
|
||
lift['local'] = None
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert [f.code for f in out] == ['DUN-STRIP-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'position' in out[0].message
|
||
|
||
|
||
def test_611_dunnage_unreadable_is_dun_strip_unread(monkeypatch):
|
||
t = _dun_strip_ticket(27.0, [3.0, 24.0])
|
||
_patch_dun_stations(monkeypatch, None,
|
||
reason='no size notes found on sheet 1')
|
||
out = TC.check_dunnage_stripping_match(t, {}, _MC_DUN_STRIP_KNOW, None)
|
||
assert [f.code for f in out] == ['DUN-STRIP-UNREAD']
|
||
assert out[0].severity == 'WARN'
|
||
assert 'no size notes found on sheet 1' in out[0].message
|
||
|
||
|
||
# ==================================================================
|
||
# 5.6 / 5.7: the corrected reinforcing rule, and the per-size stock
|
||
# ==================================================================
|
||
#
|
||
# Both of these were a day of knowledge work that landed and was never
|
||
# connected: `knowledge.reinforcing_verdict` and `knowledge.rebar_stock`
|
||
# existed, were tested, and had no production caller, while the superseded
|
||
# shapes kept running next door. Wired 2026-08-31.
|
||
|
||
|
||
def _rule_know(verified=True, tokens=('BAR',), listed=None):
|
||
"""A knowledge tree whose reinforcing RULE can run (or deliberately not).
|
||
|
||
The token and the parameter name are invented for the fixture. The real
|
||
ones are a plant fact and belong in the plant layer, never here.
|
||
"""
|
||
know = _know()
|
||
know.setdefault('parameters', {}).setdefault(
|
||
'member', {})['manufacture_component'] = 'INVENTED_COMPONENT_PARAM'
|
||
know.setdefault('drawings', {})['reinforcing_rule'] = {
|
||
'parameter': 'member.manufacture_component',
|
||
'value_contains': list(tokens),
|
||
'read_from': ['instance', 'type'],
|
||
'parameter_verified': verified,
|
||
}
|
||
know['drawings']['reinforcing_families'] = list(listed or [])
|
||
return know
|
||
|
||
|
||
def test_could_not_judge_no_longer_collapses_into_not_a_bar():
|
||
"""THE BUG. `_is_reinforcing` returned a bool, so a member nothing could
|
||
judge came back False -- indistinguishable from a member positively
|
||
identified as not reinforcing. `reinforcing_verdict`'s own docstring names
|
||
that collapse as how 5.6 and 5.7 went quiet."""
|
||
know = _rule_know()
|
||
# A member the rule can read and rejects: a real False.
|
||
assert TC._reinforcing_verdict('SOME_FAMILY', 'Specialty Equipment', know,
|
||
'A PLATE')[0] is False
|
||
# A member carrying no value at all: could not judge, and it must NOT be
|
||
# False.
|
||
verdict, why = TC._reinforcing_verdict('SOME_FAMILY',
|
||
'Specialty Equipment', know)
|
||
assert verdict is None
|
||
assert verdict is not False
|
||
assert 'not the same as it not being a bar' in why
|
||
|
||
|
||
def test_the_dormant_rule_stays_dormant_and_says_why():
|
||
"""The parameter's real name is verified but the key ships
|
||
`parameter_verified: false` pending the owner. Wiring the call must not
|
||
turn a pending question into a guess: every member comes back None, and
|
||
the reason is the one `reinforcing_mode` already writes."""
|
||
know = _rule_know(verified=False)
|
||
verdict, why = TC._reinforcing_verdict('SOME_FAMILY',
|
||
'Specialty Equipment', know,
|
||
'A BAR')
|
||
assert verdict is None, 'a dormant rule must not judge anything'
|
||
mode, mode_why = knowledge_module().reinforcing_mode(know)
|
||
assert mode is None
|
||
assert why == mode_why
|
||
|
||
|
||
def knowledge_module():
|
||
from vibe import knowledge
|
||
return knowledge
|
||
|
||
|
||
def test_revits_own_category_still_answers_first():
|
||
"""A recorded category is a fact in the model, not an inference from a
|
||
name, and it is the only live signal at a plant that has declared
|
||
nothing. An enumeration must not be able to turn a rebar-category member
|
||
into 'not a bar'."""
|
||
know = _rule_know(verified=False, listed=['SOMETHING_ELSE'])
|
||
verdict, why = TC._reinforcing_verdict('ANY', 'Structural Rebar', know)
|
||
assert verdict is True
|
||
assert 'category' in why
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# `_reinforcing_values` must keep instance and type GENUINELY separate.
|
||
#
|
||
# `ticketqc.pstr`/`rstr` started unioning instance-then-type on 2026-09-02
|
||
# (a role mapped to a TYPE parameter used to read back `None` forever,
|
||
# silently). That union is correct for almost every caller, but
|
||
# `_reinforcing_values` reports its two return values downstream as which
|
||
# one WON ('source': 'instance' or 'type' in the finding text) and a
|
||
# plant's `read_from` states which one it trusts. These tests pin that
|
||
# `_reinforcing_values` uses `rstr_instance_only`/`rstr_instance_only(el.
|
||
# Symbol, ...)`, NOT the unioning `rstr`, so a type-only value can never
|
||
# surface as the instance value.
|
||
|
||
QC.StorageType.String = 'String'
|
||
|
||
|
||
class _RVParam(object):
|
||
def __init__(self, value):
|
||
self.StorageType = QC.StorageType.String
|
||
self._value = value
|
||
|
||
def AsString(self):
|
||
return self._value
|
||
|
||
def AsValueString(self):
|
||
return self._value
|
||
|
||
|
||
class _RVElement(object):
|
||
"""LookupParameter only -- no `GetTypeId`/`Document` -- because
|
||
`rstr_instance_only` never needs a type walk of its own; the caller
|
||
supplies `.Symbol` directly, the same way Revit does."""
|
||
|
||
def __init__(self, params=None, symbol=None):
|
||
self._params = params or {}
|
||
self.Symbol = symbol
|
||
|
||
def LookupParameter(self, name):
|
||
return self._params.get(name)
|
||
|
||
|
||
def test_reinforcing_values_type_only_value_reports_as_type_not_instance():
|
||
"""THE COLLISION. A value that exists ONLY on the type must come back as
|
||
`(None, value)`, never `(value, value)` or `(value, None)` -- either of
|
||
those would mean the unioning reader leaked the type's value into the
|
||
instance slot, which is the exact wrong-attribution bug (CLAUDE.md
|
||
Sec.1: an answer from the wrong request is indistinguishable from a
|
||
right one)."""
|
||
know = _rule_know()
|
||
symbol = _RVElement(params={'INVENTED_COMPONENT_PARAM': _RVParam('BAR')})
|
||
el = _RVElement(params={}, symbol=symbol)
|
||
instance_value, type_value = TC._reinforcing_values(el, know)
|
||
assert instance_value is None
|
||
assert type_value == 'BAR'
|
||
|
||
|
||
def test_reinforcing_values_instance_only_value_reports_as_instance_not_type():
|
||
"""The mirror case: instance-only must never leak into the type slot."""
|
||
know = _rule_know()
|
||
symbol = _RVElement(params={})
|
||
el = _RVElement(params={'INVENTED_COMPONENT_PARAM': _RVParam('BAR')},
|
||
symbol=symbol)
|
||
instance_value, type_value = TC._reinforcing_values(el, know)
|
||
assert instance_value == 'BAR'
|
||
assert type_value is None
|
||
|
||
|
||
def test_reinforcing_values_both_set_stay_genuinely_distinct():
|
||
"""Both carry a value, and they may differ -- proving neither read
|
||
quietly copied the other's answer."""
|
||
know = _rule_know()
|
||
symbol = _RVElement(params={'INVENTED_COMPONENT_PARAM': _RVParam('TYPE-BAR')})
|
||
el = _RVElement(params={'INVENTED_COMPONENT_PARAM': _RVParam('INST-BAR')},
|
||
symbol=symbol)
|
||
instance_value, type_value = TC._reinforcing_values(el, know)
|
||
assert instance_value == 'INST-BAR'
|
||
assert type_value == 'TYPE-BAR'
|
||
|
||
|
||
def test_a_plant_preferring_instance_is_not_satisfied_by_a_type_only_value():
|
||
"""`read_from` is the plant's promise about WHICH field it trusts. A
|
||
plant that names only 'instance' must get `None` (could not judge) for
|
||
a member whose value sits solely on the type -- not the type's value
|
||
served up under the instance's name."""
|
||
know = _rule_know()
|
||
know['drawings']['reinforcing_rule']['read_from'] = ['instance']
|
||
verdict, why = TC._reinforcing_verdict(
|
||
'SOME_FAMILY', 'Specialty Equipment', know,
|
||
instance_value=None, type_value='BAR')
|
||
assert verdict is None
|
||
assert 'not the same as it not being a bar' in why
|
||
|
||
|
||
def test_5657_say_how_many_members_they_could_not_judge_and_why():
|
||
"""The `None`s must reach `_no_bars_reason` -- it is the only place that
|
||
can tell 'this piece carries no steel' from 'nothing here could recognise
|
||
any'. A boolean filter threw them away before they got there."""
|
||
unknown = [{'family': 'INVENTED_FAM_A', 'why': 'no value for the role'},
|
||
{'family': 'INVENTED_FAM_A', 'why': 'no value for the role'},
|
||
{'family': 'INVENTED_FAM_B', 'why': 'no value for the role'}]
|
||
t, know = _know_with_bars([], unknown=unknown)
|
||
TC.check_bar_stock_length(t, {}, know, None)
|
||
reason = t['_bar_length_silence']
|
||
assert 'COULD NOT BE JUDGED' in reason
|
||
assert '3 member(s)' in reason
|
||
assert 'INVENTED_FAM_A' in reason
|
||
assert 'INVENTED_FAM_B' in reason
|
||
assert 'not counted as "not a bar"' in reason
|
||
|
||
|
||
def test_5657_print_the_reason_the_rule_is_dormant():
|
||
"""`reinforcing_mode`'s sentence is the difference between 'nothing was
|
||
recognised' and 'nothing was recognised BECAUSE the parameter behind the
|
||
rule has not been verified yet' -- only the second can be acted on."""
|
||
t, _ = _know_with_bars([])
|
||
know = _rule_know(verified=False)
|
||
know['catalogue']['rebar']['stock_length_ft'] = 40.0
|
||
know.setdefault('parameters', {}).setdefault(
|
||
'member', {})['length'] = 'BAR_LENGTH_PARAM'
|
||
TC.check_bar_mark_uniqueness(t, {}, know, None)
|
||
reason = t['_barmark_silence']
|
||
assert 'HOW THE TOOL IS DECIDING' in reason
|
||
_mode, mode_why = knowledge_module().reinforcing_mode(know)
|
||
assert mode_why in reason
|
||
|
||
|
||
def test_the_bar_gather_is_cached_per_checklist_row():
|
||
"""5.6 and 5.7 may legitimately see different populations once a per-row
|
||
scope is declared, so one cache serving both would hand the second row
|
||
the first row's answer."""
|
||
t, know = _know_with_bars([{'mark': 'SR1', 'family': 'F',
|
||
'length_ft': 10.0}])
|
||
t['_bar_members:5.6'] = {'bars': [], 'reason': None, 'unknown': []}
|
||
bars_56, _r, _u = TC._bar_members(t, know, item='5.6')
|
||
bars_57, _r, _u = TC._bar_members(t, know, item='5.7')
|
||
assert bars_56 == []
|
||
assert [b['mark'] for b in bars_57] == ['SR1']
|
||
|
||
|
||
# ------------------------------------------------- 5.7, per-size stock
|
||
|
||
_SMALL_SIZE = '#4'
|
||
_BIG_SIZE = '#9'
|
||
|
||
|
||
def _size_know(bars, by_size=None, generic=40.0):
|
||
t, know = _know_with_bars(bars, stock=generic)
|
||
know['catalogue']['rebar']['stock_by_size'] = dict(by_size or {})
|
||
return t, know
|
||
|
||
|
||
def test_57_uses_the_per_size_table_the_moment_a_size_is_available():
|
||
"""THE FALSE OK the per-size table was built to fix, and which was still
|
||
live because nothing called `rebar_stock`: at a plant stocking #4 in 20 ft
|
||
lengths, a 30 ft #4 passed the generic 40 ft comparison."""
|
||
t, know = _size_know(
|
||
[{'mark': 'SR-SMALL', 'family': 'F', 'length_ft': 30.0,
|
||
'size': _SMALL_SIZE}],
|
||
by_size={_SMALL_SIZE: {'stock_length_in': 240}}) # 20 ft
|
||
findings = TC.check_bar_stock_length(t, {}, know, None)
|
||
codes = dict((f.code, f) for f in findings)
|
||
assert codes['BAR-LENGTH'].severity == 'WARN'
|
||
assert 'SR-SMALL' in codes['BAR-LENGTH'].message
|
||
# ... and against the generic 40 ft alone it would have passed.
|
||
t2, know2 = _size_know(
|
||
[{'mark': 'SR-SMALL', 'family': 'F', 'length_ft': 30.0,
|
||
'size': _SMALL_SIZE}], by_size={})
|
||
assert dict((f.code, f) for f in TC.check_bar_stock_length(
|
||
t2, {}, know2, None))['BAR-LENGTH'].severity == 'OK'
|
||
|
||
|
||
def test_57_a_bar_whose_size_cannot_be_read_is_not_judged_at_all():
|
||
"""Never a pass, and never silently judged against the generic number:
|
||
"40 ft" and "40 ft for this size" are different claims. Which parameter
|
||
carries a bar's size is question A3 with the owner."""
|
||
t, know = _size_know(
|
||
[{'mark': 'SR-NOSIZE', 'family': 'F', 'length_ft': 30.0,
|
||
'size': None}], by_size={_SMALL_SIZE: {'stock_length_in': 240}})
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
reason = t['_bar_length_silence']
|
||
assert 'SR-NOSIZE' in reason
|
||
assert 'nothing was judged' in reason
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_bar_stock_length', 'SKIPPED', reason)]))
|
||
assert rows['5.7'][3] == 'NOT RUN'
|
||
assert TP.state_color(rows['5.7'][3]) == 'red'
|
||
|
||
|
||
def test_57_with_neither_reader_available_it_names_both_of_them():
|
||
"""The old sentence said the size role was unmapped and sent the reader
|
||
to the owner's open question A3. A3 was answered by measurement on
|
||
2026-08-31 and the role is declared, so that sentence pointed at a closed
|
||
question -- worse than no message. What is true now is that there are TWO
|
||
readers over two disjoint populations, and only when BOTH are off can
|
||
nothing read a size on any bar."""
|
||
t, know = _size_know([{'mark': 'SR-NOSIZE', 'family': 'F',
|
||
'length_ft': 30.0, 'size': None}])
|
||
del know['parameters']['member']['bar_size']
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
reason = t['_bar_length_silence']
|
||
assert 'member.bar_size' in reason
|
||
assert 'drawings.bar_size_rule.description_fallback' in reason
|
||
assert 'DISJOINT' in reason
|
||
assert 'A3' not in reason
|
||
|
||
|
||
def test_57_says_so_when_it_fell_back_to_the_generic_number():
|
||
"""A size the per-size table does not list is judged against the generic
|
||
length -- and the note has to say the number is not size specific."""
|
||
t, know = _size_know(
|
||
[{'mark': 'SR-BIG', 'family': 'F', 'length_ft': 10.0,
|
||
'size': _BIG_SIZE}],
|
||
by_size={_SMALL_SIZE: {'stock_length_in': 240}})
|
||
findings = TC.check_bar_stock_length(t, {}, know, None)
|
||
codes = dict((f.code, f) for f in findings)
|
||
assert codes['BAR-LENGTH'].severity == 'OK'
|
||
assert 'BAR-LENGTH-GENERIC' in codes
|
||
assert _BIG_SIZE in codes['BAR-LENGTH-GENERIC'].message
|
||
assert 'not size specific' in codes['BAR-LENGTH-GENERIC'].message
|
||
|
||
|
||
def test_57_names_the_length_per_mark_once_two_sizes_disagree():
|
||
"""One number in a sentence is easier to read than the same number
|
||
repeated -- but a sentence saying "the 40 ft stock length" while half the
|
||
bars were judged against 20 would be worse than no sentence."""
|
||
findings, _u, _z, _e = TP.bar_stock_length_status(
|
||
[{'mark': 'SR-A', 'family': 'F', 'length_ft': 30.0, 'stock_ft': 20.0},
|
||
{'mark': 'SR-B', 'family': 'F', 'length_ft': 45.0,
|
||
'stock_ft': 40.0}], 40.0)
|
||
assert findings[0][0] == 'WARN'
|
||
assert '20 ft stock' in findings[0][1]
|
||
assert '40 ft stock' in findings[0][1]
|
||
# With one length everywhere, the old single-number wording is kept.
|
||
same, _u, _z, _e = TP.bar_stock_length_status(
|
||
[{'mark': 'SR-A', 'family': 'F', 'length_ft': 45.0}], 40.0)
|
||
assert 'the 40 ft stock length' in same[0][1]
|
||
|
||
|
||
# ------------------------------- 5.7, the two size readers (2026-08-31)
|
||
#
|
||
# THE POPULATIONS ARE DISJOINT AND THE READERS ARE NOT A CHAIN. Censused over
|
||
# 24,539 rebar members in 13 families: the one STRAIGHT family carries the
|
||
# size parameter and not one of the twelve bent families does; all twelve
|
||
# bent families carry their part description on the TYPE (instance count
|
||
# zero) and no straight member carries one, with 7,405 of 7,405 bent
|
||
# descriptions parsing in one uniform shape. No bar has both fields, so the
|
||
# order `bar_size_of` tries them in has never decided anything and must not
|
||
# be read as a ranking.
|
||
#
|
||
# The bent half was the half still unwired after the size role landed, and it
|
||
# is roughly 30% of the reinforcing in this model -- not a rounding error.
|
||
|
||
|
||
def _two_reader_know():
|
||
"""A plant with BOTH size readers switched on."""
|
||
_t, know = _know_with_bars([])
|
||
know['parameters']['member']['identity_description'] = 'DESC_PARAM'
|
||
rule = know['drawings']['bar_size_rule']
|
||
rule['description_fallback'] = True
|
||
rule['bent_bar_tokens'] = ['BENT BAR']
|
||
return know
|
||
|
||
|
||
def _ticket_of(bars):
|
||
return {'mark': 'TEST-1',
|
||
'_bar_members': {'bars': list(bars), 'reason': None,
|
||
'unknown': []}}
|
||
|
||
|
||
def _gathered(know, mark, length_ft, parameter_value=None, description=None):
|
||
"""One bar row, sized exactly the way the gather sizes it."""
|
||
row = {'mark': mark, 'family': 'F', 'length_ft': length_ft}
|
||
row.update(TC._bar_size(know, parameter_value, description))
|
||
return row
|
||
|
||
|
||
def test_57_a_straight_bar_is_sized_by_the_dedicated_parameter():
|
||
got = TC._bar_size(_two_reader_know(), '#4', None)
|
||
assert got['size'] == '#4'
|
||
assert got['size_basis'] == 'parameter'
|
||
assert 'member.bar_size' in got['size_why']
|
||
|
||
|
||
def test_57_a_bent_bar_is_sized_off_the_front_of_its_part_description():
|
||
"""The half that was still standing down. The bent families carry no size
|
||
field at all, so this is the reader for that population and not a
|
||
fallback for the other one."""
|
||
got = TC._bar_size(_two_reader_know(), None, '#5 BENT BAR (17)')
|
||
assert got['size'] == '#5'
|
||
assert got['size_basis'] == 'description'
|
||
assert 'part description' in got['size_why']
|
||
|
||
|
||
def test_57_a_bent_bar_is_judged_against_the_stock_for_its_own_size():
|
||
"""End to end: a bent bar whose size only its description knows is held
|
||
to the per-size table like any other bar. Against the generic 40 ft it
|
||
would have passed."""
|
||
know = _two_reader_know()
|
||
know['catalogue']['rebar']['stock_by_size'] = {'#5': {'stock_length_in': 240}}
|
||
t = _ticket_of([_gathered(know, 'SR-BENT', 30.0,
|
||
description='#5 BENT BAR (17)')])
|
||
codes = dict((f.code, f)
|
||
for f in TC.check_bar_stock_length(t, {}, know, None))
|
||
assert codes['BAR-LENGTH'].severity == 'WARN'
|
||
assert 'SR-BENT' in codes['BAR-LENGTH'].message
|
||
assert '#5' in codes['BAR-LENGTH'].message # not merely "20 ft"
|
||
|
||
|
||
def test_57_a_description_that_does_not_parse_leaves_the_bar_unjudged():
|
||
"""The parse is strict, and the reason is not that the field is
|
||
unreliable -- it is structured plant data. The shape `#N BENT BAR` IS the
|
||
evidence that the leading token is a size, so a reworded description must
|
||
leave the bar unjudged rather than mis-sized."""
|
||
got = TC._bar_size(_two_reader_know(), None, 'REWORDED, NO LONGER A SHAPE')
|
||
assert got['size'] is None
|
||
assert got['size_basis'] is None
|
||
assert 'REWORDED, NO LONGER A SHAPE' in got['size_why']
|
||
|
||
|
||
def test_57_an_unparseable_description_is_set_aside_with_ITS_OWN_reason():
|
||
"""Not against the generic stock length, and not with a sentence about a
|
||
mapping that is already right: the reason has to name what was missing
|
||
for THAT bar, or a reader re-checks a file that is not the problem."""
|
||
know = _two_reader_know()
|
||
t = _ticket_of([_gathered(know, 'SR-BENT', 30.0,
|
||
description='REWORDED, NO LONGER A SHAPE')])
|
||
assert TC.check_bar_stock_length(t, {}, know, None) == []
|
||
reason = t['_bar_length_silence']
|
||
assert 'SR-BENT' in reason
|
||
assert 'REWORDED, NO LONGER A SHAPE' in reason
|
||
assert 'unjudged rather than mis-sized' in reason
|
||
assert 'no parameters.member.bar_size is mapped' not in reason
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_bar_stock_length', 'SKIPPED', reason)]))
|
||
assert rows['5.7'][3] == 'NOT RUN'
|
||
assert TP.state_color(rows['5.7'][3]) == 'red'
|
||
|
||
|
||
def test_57_the_two_size_readers_never_both_answer_for_one_bar():
|
||
"""Two populations, two readers -- never a race and never a merge. The
|
||
census says no bar carries both fields; what the code guarantees is that
|
||
even if one did, exactly ONE field is named as having answered."""
|
||
know = _two_reader_know()
|
||
straight = TC._bar_size(know, '#4', None)
|
||
bent = TC._bar_size(know, None, '#5 BENT BAR (17)')
|
||
assert straight['size_basis'] == 'parameter'
|
||
assert bent['size_basis'] == 'description'
|
||
assert straight['size_basis'] != bent['size_basis']
|
||
both = TC._bar_size(know, '#4', '#5 BENT BAR (17)')
|
||
assert both['size_basis'] == 'parameter' # one answer, not two
|
||
assert both['size'] == '#4'
|
||
assert '#5' not in both['size_why'] # the other field is not read
|
||
# ...and with the parameter empty the description is not "second choice",
|
||
# it is the only reader for the population that has no parameter at all.
|
||
assert TC._bar_size(know, '', '#5 BENT BAR (17)')['size_basis'] \
|
||
== 'description'
|
||
|
||
|
||
class _Recorder(object):
|
||
"""An element that answers no parameter and remembers what was asked.
|
||
|
||
`ticketqc.pstr` stops at a falsy parameter, so nothing here reaches the
|
||
inert Revit stub at the top of this file -- which is exactly why WHERE a
|
||
field is read from is testable off-model at all.
|
||
"""
|
||
|
||
def __init__(self, symbol=None):
|
||
self.Symbol = symbol
|
||
self.asked = []
|
||
|
||
def LookupParameter(self, pname):
|
||
self.asked.append(pname)
|
||
return None
|
||
|
||
|
||
def test_57_the_description_is_read_off_the_type_and_never_the_instance():
|
||
"""One parameter name over two kinds of element: the ASSEMBLY's identity
|
||
description is the owner's freeform notes and the FAMILY's is structured
|
||
plant data. `description_read_from` is what keeps them apart, and reading
|
||
the wrong one gets prose where structure was expected."""
|
||
know = _two_reader_know()
|
||
symbol = _Recorder()
|
||
el = _Recorder(symbol=symbol)
|
||
assert TC._bar_size_values(el, know) == (None, None)
|
||
assert 'DESC_PARAM' in symbol.asked
|
||
assert 'DESC_PARAM' not in el.asked
|
||
# The size parameter stays a union over instance and type -- `_bar_mark`'s
|
||
# precedence, for `_bar_mark`'s measured reason.
|
||
assert el.asked == ['BAR_SIZE_PARAM']
|
||
assert symbol.asked[0] == 'BAR_SIZE_PARAM'
|
||
|
||
|
||
def test_57_the_finding_pins_which_field_answered():
|
||
"""PROVENANCE IS THE PART A REFACTOR DROPS QUIETLY. "40 ft" and "40 ft
|
||
for a #4" are different claims, and so are "read from a dedicated size
|
||
parameter" and "parsed off the front of a description" -- a verdict that
|
||
cannot say what it read cannot be audited."""
|
||
t, know = _size_know(
|
||
[{'mark': 'SR-STRAIGHT', 'family': 'F', 'length_ft': 10.0,
|
||
'size': '#4', 'size_basis': 'parameter'},
|
||
{'mark': 'SR-BENT', 'family': 'F', 'length_ft': 12.0,
|
||
'size': '#5', 'size_basis': 'description'}])
|
||
codes = dict((f.code, f)
|
||
for f in TC.check_bar_stock_length(t, {}, know, None))
|
||
note = codes['BAR-LENGTH-SIZE-BASIS']
|
||
assert note.severity == 'OK'
|
||
assert ('read from the size field behind parameters.member.bar_size'
|
||
in note.message)
|
||
assert ('parsed from the part description behind '
|
||
'parameters.member.identity_description' in note.message)
|
||
assert '#4' in note.message and '#5' in note.message
|
||
|
||
|
||
def test_57_the_provenance_note_can_never_decide_the_row():
|
||
"""Its own code, unowned -- 5.2's BED-REVERSE-UNCLASSIFIED shape. It may
|
||
neither turn 5.7 green nor disappear from the report."""
|
||
owners = []
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
owners.extend(item[3] or ())
|
||
assert 'BAR-LENGTH-SIZE-BASIS' not in owners
|
||
t, know = _size_know([{'mark': 'SR-STRAIGHT', 'family': 'F',
|
||
'length_ft': 10.0, 'size': '#4'}])
|
||
findings = TC.check_bar_stock_length(t, {}, know, None)
|
||
assert 'BAR-LENGTH-SIZE-BASIS' in [f.code for f in findings]
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[f for f in findings if f.code != 'BAR-LENGTH'],
|
||
[('check_bar_stock_length', 'RAN', None)]))
|
||
assert rows['5.7'][3] == 'OK' # off the log, not off this note
|
||
|
||
|
||
def test_57_the_two_sentences_about_a_closed_question_are_gone():
|
||
"""Tombstone. Question A3 -- which parameter carries a bar's size -- was
|
||
answered by measurement on 2026-08-31 and `member.bar_size` is declared
|
||
in `knowledge.PARAMETER_ROLES`. Two strings in this module still said the
|
||
opposite: `_bar_size`'s docstring and `BAR_SIZE_UNMAPPED`. A message that
|
||
sends a reader to a closed question is worse than no message."""
|
||
assert 'A3' not in TC.BAR_SIZE_UNMAPPED
|
||
assert 'question A3' not in TC.check_bar_stock_length.__doc__
|
||
doc = ' '.join((TC._bar_size.__doc__ or '').split())
|
||
assert 'is question A3 with the owner' not in doc
|
||
assert 'has not been answered' not in doc
|
||
assert 'NOT IN `knowledge.PARAMETER_ROLES`' not in doc
|
||
assert 'question A3 was answered' in doc
|
||
assert TC.BAR_SIZE_ROLE in knowledge_module().PARAMETER_ROLES
|
||
|
||
|
||
# ================================================== 5.4, the mesh word
|
||
#
|
||
# Three definitions of "mesh" existed. `takeoffgather._is_mesh` reads
|
||
# `families.wwf_prefix` defaulting to `''`, `ticketqc` reads it defaulting to
|
||
# `'WWF'`, and `check_wwf_designation` -- the check that OWNS row 5.4 -- read
|
||
# no key at all and hardcoded the two English words.
|
||
|
||
_MESH_FAMILY = 'INVENTED_FABRIC_6x6' # matches neither built-in word
|
||
|
||
|
||
class _StubSymbol(object):
|
||
def __init__(self, family_name):
|
||
self.Family = type(str('F'), (object,), {'Name': family_name})()
|
||
|
||
|
||
class _StubMember(object):
|
||
def __init__(self, family_name):
|
||
self.Symbol = _StubSymbol(family_name)
|
||
|
||
|
||
class _StubAssembly(object):
|
||
def __init__(self, families):
|
||
self._members = [_StubMember(f) for f in families]
|
||
|
||
def GetMemberIds(self):
|
||
return list(range(len(self._members)))
|
||
|
||
|
||
_ASM_ID = 'THE-ASSEMBLY'
|
||
|
||
|
||
class _StubDoc(object):
|
||
"""Just enough document to walk one assembly's members.
|
||
|
||
The assembly answers to its own id and members answer to their index, so
|
||
a mix-up between the two cannot silently return the wrong element -- the
|
||
first version of this stub returned a MEMBER for the assembly id, the
|
||
check's `try` swallowed the IndexError, and every assertion below passed
|
||
against the "no assembly" branch.
|
||
"""
|
||
|
||
def __init__(self, families):
|
||
self._asm = _StubAssembly(families)
|
||
|
||
def GetElement(self, ref):
|
||
if ref == _ASM_ID:
|
||
return self._asm
|
||
return self._asm._members[ref]
|
||
|
||
|
||
def _mesh_ticket(families, prefix='WWF'):
|
||
know = _know()
|
||
know.setdefault('families', {})['wwf_prefix'] = prefix
|
||
t = {'asm_id': _ASM_ID, 'doc': _StubDoc(families)}
|
||
return t, know
|
||
|
||
|
||
def _wwf(t, mesh, know):
|
||
"""5.4 with the Revit stub in place, and with an `ElementId` that can
|
||
actually be constructed.
|
||
|
||
The check does `tdoc.GetElement(ElementId(t['asm_id']))` inside a try, so
|
||
with the inert stub the construction raises, the assembly comes back None
|
||
and the walk never happens -- every assertion below would then be testing
|
||
the "no assembly" branch while looking like it tested the mesh rule. The
|
||
id here is a pass-through, which is exactly what the stub document
|
||
expects.
|
||
"""
|
||
with _revit():
|
||
db = sys.modules['Autodesk.Revit.DB']
|
||
was = db.ElementId
|
||
db.ElementId = lambda value: value
|
||
try:
|
||
return TC.check_wwf_designation(t, {'wwf_mesh': mesh}, know, None)
|
||
finally:
|
||
db.ElementId = was
|
||
|
||
|
||
def test_54_reads_the_plants_word_instead_of_two_hardcoded_english_ones():
|
||
"""A plant that calls its mesh anything else got a silent pass: no family
|
||
matched the built-in words, so 5.4 reported "no mesh family is modelled"
|
||
as a fact about the MODEL rather than as the tool not knowing the word."""
|
||
t, know = _mesh_ticket([_MESH_FAMILY], prefix='INVENTED_FABRIC')
|
||
out = _wwf(t, '6x6-W2.9xW2.9', know)
|
||
codes = dict((f.code, f) for f in out)
|
||
assert 'WWF' in codes
|
||
# It found the family, so the message is a comparison and not a "nothing
|
||
# is modelled" claim.
|
||
assert 'no modelled family matches' not in codes['WWF'].message
|
||
|
||
|
||
def test_54_names_the_key_when_nothing_matches_it():
|
||
t, know = _mesh_ticket(['SOMETHING_ELSE'], prefix='INVENTED_FABRIC')
|
||
out = _wwf(t, '6x6', know)
|
||
assert out[0].severity == 'WARN'
|
||
assert 'families.wwf_prefix' in out[0].message
|
||
assert 'INVENTED_FABRIC' in out[0].message
|
||
|
||
|
||
def test_54_a_family_that_looks_like_mesh_but_misses_the_key_is_reported():
|
||
"""The signature of a prefix set to the wrong word. Neither a pass nor a
|
||
comparison -- and it must not be able to decide the row or to vanish."""
|
||
t, know = _mesh_ticket(['A MESH FAMILY'], prefix='INVENTED_FABRIC')
|
||
out = _wwf(t, '6x6', know)
|
||
assert out[0].severity == 'WARN'
|
||
assert 'read like mesh by name' in out[0].message
|
||
assert 'A MESH FAMILY' in out[0].message
|
||
assert 'Nothing was compared' in out[0].message
|
||
|
||
|
||
def test_54_keeps_todays_behaviour_when_the_key_is_null():
|
||
"""What a null prefix should mean plant-wide is an open owner question --
|
||
the two other readers of this key disagree about it -- so nothing is
|
||
invented: the built-in words still apply, and the finding says so."""
|
||
t, know = _mesh_ticket(['A MESH FAMILY'], prefix='')
|
||
out = _wwf(t, '6x6', know)
|
||
codes = dict((f.code, f) for f in out)
|
||
assert 'WWF' in codes
|
||
assert 'WWF-UNMATCHED' not in codes
|
||
t2, know2 = _mesh_ticket(['SOMETHING_ELSE'], prefix='')
|
||
out2 = _wwf(t2, '6x6', know2)
|
||
assert 'unset' in out2[0].message
|
||
assert 'MESH/WWF' in out2[0].message
|
||
|
||
|
||
def test_54_a_mismatched_family_beside_a_matched_one_cannot_decide_the_row():
|
||
t, know = _mesh_ticket([_MESH_FAMILY, 'A MESH FAMILY'],
|
||
prefix='INVENTED_FABRIC')
|
||
out = _wwf(t, '6x6', know)
|
||
codes = dict((f.code, f) for f in out)
|
||
assert codes['WWF-UNMATCHED'].severity == 'OK'
|
||
assert 'A MESH FAMILY' in codes['WWF-UNMATCHED'].message
|
||
# WWF-UNMATCHED owns no checklist row, so it can neither set nor sink 5.4.
|
||
owners = set()
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
for code in (item[3] or ()):
|
||
owners.add(code)
|
||
assert 'WWF-UNMATCHED' not in owners
|
||
|
||
|
||
# ============================================ 5.2, the bed plan view name
|
||
|
||
def test_52_reads_the_plants_bed_plan_name_and_never_a_literal():
|
||
"""Triage B1. `_bed_plan_text` read `drawings.bed_plan` while
|
||
`check_bed_plan_reverse`, ten screens down in the same file, compared
|
||
against a literal English view name."""
|
||
know = _know()
|
||
know.setdefault('drawings', {})['bed_plan'] = 'INVENTED VIEW NAME'
|
||
name, why = TC._bed_plan_name(know)
|
||
assert name == 'INVENTED VIEW NAME'
|
||
assert why is None
|
||
# WALKED WITH `ast`, not grepped. Prose mentions survive -- a comment
|
||
# recording a measurement off the live model, and the docstrings that
|
||
# explain this very fix -- and none of them can decide a checklist row.
|
||
# What may not survive is a STRING THE CODE EVALUATES, so that is what is
|
||
# looked for, and every string constant that is not a docstring is
|
||
# checked rather than a list of the ones somebody remembered.
|
||
for text in _code_strings(TC.__file__.replace('.pyc', '.py')):
|
||
assert 'ELEVATION TOP' not in text.upper(), (
|
||
'a plant view name is still a string this code evaluates: '
|
||
'{0!r}'.format(text))
|
||
|
||
|
||
def _code_strings(path):
|
||
"""Every string constant in a module that is NOT a docstring."""
|
||
import ast
|
||
import io as _io
|
||
tree = ast.parse(_io.open(path, encoding='utf-8').read())
|
||
docstrings = set()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, (ast.Module, ast.FunctionDef, ast.ClassDef)):
|
||
body = getattr(node, 'body', None) or []
|
||
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str):
|
||
docstrings.add(id(body[0].value))
|
||
out = []
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in docstrings:
|
||
out.append(node.value)
|
||
return out
|
||
|
||
|
||
def test_52_stands_down_with_a_reason_when_the_key_is_unset():
|
||
"""Not a fallback to the name this tool shipped with: that is a guess
|
||
about the plant dressed as a default, and the row it feeds would report
|
||
on a view nobody chose."""
|
||
know = _know()
|
||
know.setdefault('drawings', {})['bed_plan'] = ''
|
||
name, why = TC._bed_plan_name(know)
|
||
assert name is None
|
||
assert 'drawings.bed_plan' in why
|
||
assert 'will not guess' in why
|
||
rows = dict((r[0], r) for r in TP.checklist_states(
|
||
[], [('check_bed_plan_reverse', 'SKIPPED', why)]))
|
||
assert rows['5.2'][3] == 'NOT RUN'
|
||
assert TP.state_color(rows['5.2'][3]) == 'red'
|
||
|
||
|
||
def test_52_both_readers_of_the_bed_plan_name_go_through_one_resolver():
|
||
"""The bug was two readers of one fact disagreeing. One resolver is what
|
||
stops them drifting apart again."""
|
||
know = _know()
|
||
know.setdefault('drawings', {})['bed_plan'] = 'INVENTED VIEW NAME'
|
||
assert TC._bed_plan_name(know)[0] == 'INVENTED VIEW NAME'
|
||
assert TC._bed_plan_text({'doc': None}, know) is None # no document
|
||
know['drawings']['bed_plan'] = None
|
||
assert TC._bed_plan_name(know)[0] is None
|
||
|
||
|
||
# ================================================================== 6.6
|
||
#
|
||
# Owner's ruling, 2026-08-31: when the count of one part on a piece is ODD,
|
||
# the ticket's BED PLAN must carry a particular phrase.
|
||
#
|
||
# WHAT THESE TESTS REACH, AND WHY THEY CAN. `check_parity_note` has exactly
|
||
# two reads that need a document -- the member census and the BED PLAN's
|
||
# text -- and both are seams. The census is CACHED ON THE TICKET (the same
|
||
# shape `_bar_members` uses, and for the same reason: four callers were
|
||
# reading the same thing four times), so a test puts the answer there and
|
||
# no assembly is ever opened. The BED PLAN's text comes through one
|
||
# function. Everything between them is arithmetic over a dict.
|
||
#
|
||
# THE PART NUMBER IS INVENTED. Asserting on the plant's real one would pin
|
||
# the plant's rule into the repository from the test side, which is the
|
||
# same defect as writing it into the check. The WORDING is the real one and
|
||
# is doing a job: the last test in this block asserts that this exact
|
||
# string appears in neither module, so the day somebody hardcodes the
|
||
# phrase the suite says so.
|
||
|
||
PART = 'ACME_PART_9'
|
||
PHRASE = 'FOR ERECTION ONLY'
|
||
|
||
|
||
def _parity_know(bed_plan='ELEVATION TEST', **overrides):
|
||
know = _know()
|
||
rule = {'family_names': [PART],
|
||
'family_verified': True,
|
||
'family_unverified_because': None,
|
||
'parity': 'odd',
|
||
'require_text': PHRASE}
|
||
rule.update(overrides)
|
||
know.setdefault('drawings', {})['parity_note_rule'] = rule
|
||
if bed_plan is not None:
|
||
know['drawings']['bed_plan'] = bed_plan
|
||
return know
|
||
|
||
|
||
def _parity_ticket(count, total=None, unknown=None, reason=None):
|
||
"""A ticket whose member census is already solved."""
|
||
return {'mark': 'TEST-1',
|
||
'_parity_members': {'count': count,
|
||
'total': total if total is not None else 40,
|
||
'unknown': list(unknown or []),
|
||
'reason': reason}}
|
||
|
||
|
||
def _bed(monkeypatch, text):
|
||
"""Point the one BED PLAN read at a string, and record that it happened."""
|
||
calls = []
|
||
|
||
def fake(t, know):
|
||
calls.append(True)
|
||
return text
|
||
monkeypatch.setattr(TC, '_bed_plan_text', fake)
|
||
return calls
|
||
|
||
|
||
def test_66_odd_count_with_the_note_is_ok(monkeypatch):
|
||
_bed(monkeypatch, 'DUNNAGE PER PLANT STANDARD. FOR ERECTION ONLY.')
|
||
out = TC.check_parity_note(_parity_ticket(3), {}, _parity_know(), None)
|
||
assert len(out) == 1
|
||
assert out[0].severity == 'OK'
|
||
assert out[0].code == 'PARITY-NOTE'
|
||
|
||
|
||
def test_66_odd_count_without_the_note_is_the_defect(monkeypatch):
|
||
_bed(monkeypatch, 'DUNNAGE PER PLANT STANDARD.')
|
||
t = _parity_ticket(3)
|
||
out = TC.check_parity_note(t, {}, _parity_know(), None)
|
||
assert [f.severity for f in out] == ['FAIL']
|
||
assert t['_parity_note_silence'] is None
|
||
|
||
|
||
def test_66_even_count_is_ok_and_never_opens_the_bed_plan(monkeypatch):
|
||
"""The gate order is the design: a count whose parity does not demand
|
||
the note is fully judged without the plan, so requiring a readable plan
|
||
there would print NOT RUN for a piece this answers completely."""
|
||
calls = _bed(monkeypatch, None)
|
||
out = TC.check_parity_note(_parity_ticket(4), {}, _parity_know(), None)
|
||
assert [f.severity for f in out] == ['OK']
|
||
assert calls == []
|
||
|
||
|
||
def test_66_zero_count_is_a_pass_and_names_the_members_it_read(monkeypatch):
|
||
_bed(monkeypatch, None)
|
||
out = TC.check_parity_note(_parity_ticket(0, total=112), {},
|
||
_parity_know(), None)
|
||
assert [f.severity for f in out] == ['OK']
|
||
assert '112' in out[0].message
|
||
|
||
|
||
def test_66_zero_and_unrecognised_are_not_the_same_answer(monkeypatch):
|
||
_bed(monkeypatch, PHRASE)
|
||
know = _parity_know()
|
||
zero = TC.check_parity_note(_parity_ticket(0, total=112), {}, know, None)
|
||
blind = _parity_ticket(0, total=112,
|
||
unknown=[{'family': '', 'why': 'no family name'}])
|
||
silent = TC.check_parity_note(blind, {}, know, None)
|
||
assert zero[0].severity == 'OK'
|
||
assert silent == []
|
||
assert 'not trustworthy' in blind['_parity_note_silence']
|
||
|
||
|
||
def test_66_an_unrecognisable_part_is_not_run_with_a_reason(monkeypatch):
|
||
_bed(monkeypatch, PHRASE)
|
||
t = _parity_ticket(3, unknown=[{'family': '', 'why': 'no family name'}])
|
||
assert TC.check_parity_note(t, {}, _parity_know(), None) == []
|
||
reason = TC._probe_parity_note(t, {}, _parity_know(), None)
|
||
assert 'no family name' in reason
|
||
|
||
|
||
def test_66_an_unreadable_bed_plan_is_not_run_with_its_own_reason(
|
||
monkeypatch):
|
||
_bed(monkeypatch, None)
|
||
t = _parity_ticket(3)
|
||
assert TC.check_parity_note(t, {}, _parity_know(), None) == []
|
||
reason = TC._probe_parity_note(t, {}, _parity_know(), None)
|
||
assert 'ELEVATION TEST' in reason
|
||
assert TC.BED_PLAN_KEY in reason
|
||
|
||
|
||
def test_66_no_bed_plan_name_declared_is_its_own_third_reason(monkeypatch):
|
||
"""Three silences and they are different things: the plant's bed-plan
|
||
view is not named, the named view is not on this ticket, or the count
|
||
could not be made. None of them may read the same."""
|
||
calls = _bed(monkeypatch, PHRASE)
|
||
t = _parity_ticket(3)
|
||
assert TC.check_parity_note(t, {}, _parity_know(bed_plan=None),
|
||
None) == []
|
||
assert calls == []
|
||
assert TC.BED_PLAN_KEY in t['_parity_note_silence']
|
||
|
||
|
||
def test_66_the_phrase_key_unset_is_not_run(monkeypatch):
|
||
_bed(monkeypatch, PHRASE)
|
||
t = _parity_ticket(3)
|
||
know = _parity_know(require_text=None)
|
||
assert TC.check_parity_note(t, {}, know, None) == []
|
||
assert 'require_text' in TC._probe_parity_note(t, {}, know, None)
|
||
|
||
|
||
def test_66_stands_down_loudly_on_stock_defaults(monkeypatch):
|
||
"""The keys are not in DEFAULTS yet. Until they land the row must read
|
||
NOT RUN naming the key that is missing -- never OK, and never silence."""
|
||
_bed(monkeypatch, PHRASE)
|
||
t = _parity_ticket(3)
|
||
assert TC.check_parity_note(t, {}, _know(), None) == []
|
||
assert 'family_names' in TC._probe_parity_note(t, {}, _know(), None)
|
||
|
||
|
||
def test_66_an_unverified_family_name_stands_down(monkeypatch):
|
||
"""A family name nobody has confirmed matches nothing, counts zero on
|
||
every piece, and zero is even -- a permanent invisible OK. The same
|
||
guard `reinforcing_rule.parameter_verified` carries."""
|
||
_bed(monkeypatch, PHRASE)
|
||
t = _parity_ticket(3)
|
||
know = _parity_know(family_verified=False,
|
||
family_unverified_because='not read off a model yet')
|
||
assert TC.check_parity_note(t, {}, know, None) == []
|
||
reason = TC._probe_parity_note(t, {}, know, None)
|
||
assert 'family_verified' in reason
|
||
assert 'not read off a model yet' in reason
|
||
|
||
|
||
def test_66_a_census_that_could_not_be_made_carries_its_own_reason(
|
||
monkeypatch):
|
||
_bed(monkeypatch, PHRASE)
|
||
t = _parity_ticket(None, reason='this ticket has no assembly')
|
||
assert TC.check_parity_note(t, {}, _parity_know(), None) == []
|
||
assert TC._probe_parity_note(t, {}, _parity_know(),
|
||
None) == 'this ticket has no assembly'
|
||
|
||
|
||
def test_66_the_probe_never_returns_none_on_a_silence():
|
||
"""With a probe registered, a None return turns a silence into a quiet
|
||
OK -- a worse failure than the one the probe fixes."""
|
||
assert TC._probe_parity_note({}, {}, _know(), None)
|
||
assert TC._probe_parity_note({'_parity_note_silence': None}, {}, _know(),
|
||
None)
|
||
|
||
|
||
def test_66_the_check_is_registered_and_carries_a_probe():
|
||
assert TC.check_parity_note in TC.CHECKS
|
||
assert 'check_parity_note' in TC._SKIP_PROBES
|
||
|
||
|
||
def test_66_no_part_number_or_wording_is_written_into_the_code():
|
||
"""The three plant facts are read, never held. A literal in either
|
||
module is the exact shape this project keeps having to remove."""
|
||
import inspect
|
||
for module in (TC, TP):
|
||
source = inspect.getsource(module)
|
||
assert 'parity_note_rule' in source
|
||
assert PART not in source
|
||
assert PHRASE not in source
|
||
|
||
|
||
# ============================================================================
|
||
# A CHECK IS SKIPPED ONLY WHEN NO ROW IT ANSWERS CAN BE ASKED
|
||
# ============================================================================
|
||
#
|
||
# `knowledge.check_not_applicable` decides per ROW; `CHECKS` is per FUNCTION,
|
||
# and the two are not one-to-one -- 4.4 and 4.6 both name
|
||
# `check_strand_layout`, and 5.1 names two functions. Skipping a check
|
||
# because ONE of its rows was declared inapplicable would take the other row
|
||
# with it: a row nobody switched off, going quiet, because of a declaration
|
||
# about a different row. That is a silently-disabled safety check arriving
|
||
# through the mechanism built to make disabling one visible.
|
||
|
||
|
||
def _switch_off(item, condition='any'):
|
||
know = _know()
|
||
know['checks']['not_applicable'] = {item: {condition: {
|
||
'applies': False,
|
||
'not_applicable_because': 'declared for the test',
|
||
'not_applicable_since': '2026-08-31'}}}
|
||
return know
|
||
|
||
|
||
def test_nothing_is_skipped_when_nothing_is_declared():
|
||
assert TC._applicability_skips({'mark': 'X'}, _know()) == {}
|
||
|
||
|
||
def test_a_check_whose_only_row_is_switched_off_does_not_run():
|
||
know = _switch_off('3.3') # 3.3 is check_release_ratio's only row
|
||
skips = TC._applicability_skips({'mark': 'X'}, know)
|
||
assert 'check_release_ratio' in skips
|
||
assert 'declared for the test' in skips['check_release_ratio']
|
||
|
||
|
||
def test_a_check_shared_with_a_row_that_still_applies_keeps_running():
|
||
"""4.4 and 4.6 both name `check_strand_layout`. Switching 4.4 off must
|
||
not take 4.6 with it -- the row is repainted, the check is not stopped."""
|
||
know = _switch_off('4.4')
|
||
skips = TC._applicability_skips({'mark': 'X'}, know)
|
||
assert 'check_strand_layout' not in skips
|
||
know = _switch_off('4.6')
|
||
assert 'check_strand_layout' not in TC._applicability_skips(
|
||
{'mark': 'X'}, know)
|
||
|
||
|
||
def test_switching_both_shared_rows_off_does_stop_the_check():
|
||
know = _know()
|
||
entry = {'applies': False,
|
||
'not_applicable_because': 'declared for the test',
|
||
'not_applicable_since': '2026-08-31'}
|
||
know['checks']['not_applicable'] = {'4.4': {'any': entry},
|
||
'4.6': {'any': dict(entry)}}
|
||
skips = TC._applicability_skips({'mark': 'X'}, know)
|
||
assert 'check_strand_layout' in skips
|
||
assert '4.4' in skips['check_strand_layout']
|
||
assert '4.6' in skips['check_strand_layout']
|
||
|
||
|
||
def test_a_ticket_with_no_classification_resolves_as_unclassified():
|
||
"""Every host test and every caller older than 2026-08-31 builds `t` by
|
||
hand. That resolves to UNCLASSIFIED and unverified -- the safe default,
|
||
where only a plant-wide `any` declaration can switch anything off."""
|
||
know = _switch_off('3.3', condition='insulated')
|
||
assert TC._applicability_skips({'mark': 'X'}, know) == {}
|
||
decisions, classification, member, profile = TC.applicability_decisions_for({'mark': 'X'}, know)
|
||
assert member['member_kind'] is None
|
||
assert profile['profile'] is None
|
||
assert classification == {}
|
||
assert decisions['3.3']['not_applicable'] is False
|
||
assert 'REFUSED' in decisions['3.3']['problem']
|
||
|
||
|
||
def test_a_classified_ticket_lets_a_piece_conditioned_rule_fire():
|
||
know = _switch_off('3.3', condition='insulated')
|
||
t = {'mark': 'X', 'panel_type': {'panel_type': 'insulated',
|
||
'verified': True}}
|
||
skips = TC._applicability_skips(t, know)
|
||
assert 'check_release_ratio' in skips
|
||
|
||
|
||
def test_run_all_logs_a_skipped_check_rather_than_dropping_it():
|
||
"""The disposition log is the one channel that reaches every consumer.
|
||
A check that was not run and said nothing would be indistinguishable
|
||
from one that ran and found nothing -- the founding failure."""
|
||
know = _switch_off('3.3')
|
||
_findings, log = TC.run_all({'mark': 'X'}, {}, know, None)
|
||
named = dict([(name, (state, note)) for (name, state, note) in log])
|
||
assert named['check_release_ratio'][0] == TC.NOT_APPLICABLE_DISPOSITION
|
||
assert 'declared for the test' in named['check_release_ratio'][1]
|
||
|
||
|
||
def test_a_row_scoped_to_a_type_is_not_run_on_an_unplaceable_piece():
|
||
"""The other direction: a row that exists ONLY for one panel type.
|
||
|
||
On a piece nothing could classify it is NOT ASKED -- and the row ends up
|
||
NOT RUN, in red, rather than quietly answered as though the piece were
|
||
the ordinary type.
|
||
"""
|
||
know = _know()
|
||
know['checks']['only_applies_to'] = {'3.3': {'insulated': {
|
||
'scoped': True,
|
||
'scoped_because': 'only insulated pieces have one',
|
||
'scoped_since': '2026-08-31'}}}
|
||
skips = TC._applicability_skips({'mark': 'X'}, know)
|
||
assert 'check_release_ratio' in skips
|
||
assert 'NOT ASKED' in skips['check_release_ratio']
|
||
decisions = TC.applicability_decisions_for({'mark': 'X'}, know)[0]
|
||
assert decisions['3.3']['unaskable'] is True
|
||
rows, notes = TP.apply_applicability(
|
||
[('3.3', 'release ratio', 'B', 'OK', None)], decisions)
|
||
assert rows[0][3] == 'NOT RUN'
|
||
assert notes == []
|
||
|
||
|
||
# ============================================================================
|
||
# THE MASTER CHECKLIST LIVES IN YAML NOW, SO IT CAN DRIFT FROM THE CODE
|
||
# ============================================================================
|
||
#
|
||
# Owner, 2026-08-31: "做一个总的 checklist 放 public knowledge 里面,然后 plant
|
||
# 里面做一层映射,job 里面用静音按键去控制。"
|
||
#
|
||
# A tuple in `ticketpage.py` could not disagree with the functions beside it.
|
||
# A YAML list can, in two directions, and BOTH ARE SILENT:
|
||
#
|
||
# * a master entry naming a check function nobody wrote -> the row is NOT
|
||
# RUN forever and reads like an unbuilt feature rather than a typo;
|
||
# * a registered check function no master entry names -> the check RUNS,
|
||
# spends the time, and can never reach a row. Its findings land in
|
||
# `qc-run.json` under a code no row owns and nobody looks at them.
|
||
#
|
||
# This pair is the guard that replaces what the tuple used to give for free,
|
||
# and it is why the move was safe to make at all.
|
||
|
||
|
||
# TWO CHECKS THAT RUN AND REACH NO CHECKLIST ROW. Found 2026-08-31 by the
|
||
# test below, on the day the master checklist moved into the public layer --
|
||
# a defect that had been there the whole time and that a tuple could never
|
||
# have surfaced, because nothing had ever asked the question.
|
||
#
|
||
# Neither is named by a master entry's `checks:` slot, AND neither emits a
|
||
# finding code any master entry owns:
|
||
#
|
||
# check_bom_strand_count -> BOM-STRAND. Row 4.1 asks the same question
|
||
# ("printed strand count vs modelled") and owns
|
||
# STR-QTY, which `ticketqc.check_ticket` emits
|
||
# directly. So the question IS on the checklist
|
||
# and this second, independent answer to it is
|
||
# not -- it can FAIL, it lands in qc-run.json,
|
||
# and no row turns red.
|
||
# check_picking_match -> PICK-STATIONS / PICK-UNREADABLE. Nothing on
|
||
# the checklist asks about picking stations at
|
||
# all.
|
||
#
|
||
# BOTH WERE WIRED UP ON 2026-08-31 AND THIS LIST IS NOW EMPTY. Giving a
|
||
# check a row is a decision about what the report claims and it was the
|
||
# owner's to make; he made it -- "看看能跑的,能做的,现在都做了去" -- and
|
||
# they landed as master ids 4.8 and 6.8. The argument for a row of its own
|
||
# rather than a second check on a neighbouring row is written out beside
|
||
# each entry in `knowledge/catalogue.yaml`; the short version is that
|
||
# hanging either off an existing row would have made that row NOT RUN
|
||
# whenever the new check went quiet, which is the opposite of what was
|
||
# asked for.
|
||
#
|
||
# THE TUPLE STAYS, EMPTY, AND SO DO THE THREE TESTS. It is an allowlist
|
||
# with two guards pointing in opposite directions -- one fails if a check
|
||
# reaches no row and is not listed, the other fails if a listed check gains
|
||
# one -- and an empty allowlist under a working pair of guards is exactly
|
||
# what "there are none" looks like. Deleting it would delete the mechanism
|
||
# that found these two in the first place.
|
||
ROWLESS_CHECKS = ()
|
||
|
||
|
||
def test_every_master_entry_names_a_check_that_exists():
|
||
missing = []
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
for name in TP._check_names(item[5]):
|
||
if not hasattr(TC, name):
|
||
missing.append('{0} -> {1}'.format(item[0], name))
|
||
assert not missing, (
|
||
'the published master checklist names check function(s) that do not '
|
||
'exist in vibe.ticketchecks: {0}. Either the function was renamed '
|
||
'without the catalogue, or the entry was written before the code.'
|
||
.format('; '.join(missing)))
|
||
|
||
|
||
def test_every_registered_check_is_reachable_from_a_master_entry():
|
||
"""The silent half. A check that runs and can never set a row is worse
|
||
than one that is missing: it costs the time and produces nothing a
|
||
reader will ever see."""
|
||
named = set()
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
for name in TP._check_names(item[5]):
|
||
named.add(name)
|
||
orphans = sorted([fn.__name__ for fn in TC.CHECKS
|
||
if fn.__name__ not in named
|
||
and fn.__name__ not in ROWLESS_CHECKS])
|
||
assert not orphans, (
|
||
'these check functions are registered in CHECKS and no master '
|
||
'checklist entry names them, so they run and can never reach a row: '
|
||
'{0}. If that is deliberate, add it to ROWLESS_CHECKS with a reason.'
|
||
.format(', '.join(orphans)))
|
||
|
||
|
||
def test_the_rowless_exemptions_are_still_really_rowless():
|
||
"""The other half of an allowlist: it must not rot into a place where
|
||
things go to be forgotten. If somebody gives one of these a row, this
|
||
fails and the exemption comes out."""
|
||
named = set()
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
for name in TP._check_names(item[5]):
|
||
named.add(name)
|
||
stale = sorted([name for name in ROWLESS_CHECKS if name in named])
|
||
assert not stale, (
|
||
'these are listed as reaching no checklist row and a master entry '
|
||
'now names them: {0}. Remove them from ROWLESS_CHECKS.'
|
||
.format(', '.join(stale)))
|
||
|
||
|
||
def test_every_check_function_a_master_entry_names_is_registered():
|
||
"""The other silent half: a function that exists, is named by a row, and
|
||
is not in CHECKS never runs -- and the row then reads NOT RUN with the
|
||
generic 'no probe declared' rather than saying nobody wired it."""
|
||
registered = set([fn.__name__ for fn in TC.CHECKS])
|
||
unwired = []
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
for name in TP._check_names(item[5]):
|
||
if hasattr(TC, name) and name not in registered:
|
||
unwired.append('{0} -> {1}'.format(item[0], name))
|
||
assert not unwired, (
|
||
'these master entries name a function that exists but is not in '
|
||
'TC.CHECKS, so it never runs: {0}'.format('; '.join(unwired)))
|
||
|
||
|
||
def test_the_master_ids_are_frozen():
|
||
"""A WRITTEN-DOWN GOLDEN, and it must stay written down.
|
||
|
||
A plant may now renumber its own checklist in one YAML edit. These ids
|
||
are what everything ELSE names: the owner's mutes, `checks.muted`,
|
||
`checks.not_applicable`, `checks.only_applies_to`, the QNA records,
|
||
`qc-run.json` and its consumers. A renumbering that reached them would
|
||
either break those references or -- far worse -- leave them working and
|
||
pointing at a different question.
|
||
|
||
So this list is TYPED OUT and compared. A pin that recomputes both sides
|
||
pins nothing; it agrees with any refactor, including a wrong one. Adding
|
||
a question means adding its id here, deliberately, which is the one
|
||
keystroke that makes somebody notice they are minting an identity rather
|
||
than a row number.
|
||
|
||
REMOVING one is a DELETION, not a renumbering, and it orphans every
|
||
ruling that names it. A retired id is never reused.
|
||
"""
|
||
assert list(TP.CHECKLIST_IDS) == [
|
||
'1.1', '1.2', '1.3', '1.4', '1.5',
|
||
'2.1', '2.2', '2.3', '2.4',
|
||
'3.1', '3.2', '3.3',
|
||
'4.1', '4.2', '4.3', '4.4', '4.5', '4.6', '4.7',
|
||
# 4.8, minted 2026-08-31 for a check that had been RUNNING and
|
||
# reaching no row -- `check_bom_strand_count`, whose BOM-STRAND
|
||
# finding can FAIL. Typed out here on purpose: this pin is the
|
||
# keystroke that makes minting an identity deliberate.
|
||
'4.8',
|
||
'5.1', '5.2', '5.3', '5.4', '5.5', '5.6', '5.7',
|
||
'5.8', '5.9', '5.10',
|
||
# SOLID ZONES AND INSULATION RECESSES, minted 2026-08-31 on the
|
||
# owner's second asking. Five new IDENTITIES, typed out here
|
||
# deliberately -- which is the keystroke this pin exists to force.
|
||
'5.11', '5.12', '5.13', '5.14', '5.15',
|
||
# 5.16, minted 2026-09-03: door-opening diagonal edge bars.
|
||
'5.16',
|
||
'6.1', '6.2', '6.3', '6.4', '6.5', '6.6', '6.7',
|
||
# 6.8, the other one: `check_picking_match`, which asked a question
|
||
# nothing on the checklist asked at all.
|
||
'6.8',
|
||
# 6.9, minted 2026-09-02: `check_lifting_points`, an MC plant rule
|
||
# (owner verbatim in ticketchecks.py above the check) grading
|
||
# lifting-point count against panel length. Typed out here
|
||
# deliberately, same as every id above it.
|
||
'6.9',
|
||
# 6.10, minted the same day as 6.9: `check_lift_positions`, a
|
||
# second MC plant rule -- lifting-point POSITION vs PCI, distinct
|
||
# from 6.9's COUNT vs length. Typed out here deliberately, same as
|
||
# every id above it.
|
||
'6.10',
|
||
# 6.11, minted the same day: `check_dunnage_stripping_match`, a
|
||
# third MC plant rule -- dunnage-point count vs stripping-point
|
||
# (face-lifter) count. Typed out here deliberately, same as every
|
||
# id above it.
|
||
'6.11',
|
||
# 6.13, minted 2026-09-08 (REVIT issue #26): lifter accessory
|
||
# (cup/patch) assembly membership. 6.12 is skipped on purpose --
|
||
# see `FLEXURAL_CHECKS_ALREADY_ASKED` in ticketchecks.py. Typed
|
||
# out here deliberately, same as every id above it.
|
||
'6.13',
|
||
'7.1',
|
||
'8.1', '8.2',
|
||
]
|
||
|
||
|
||
def test_a_master_id_is_never_reused():
|
||
assert len(TP.CHECKLIST_IDS) == len(set(TP.CHECKLIST_IDS))
|
||
|
||
|
||
def test_the_checklist_refuses_rather_than_shrinking(tmp_path):
|
||
"""A checklist that quietly lost rows would report every ticket it
|
||
looked at as fine. Refusing to load is the cheap outcome."""
|
||
missing = str(tmp_path / 'nope.yaml')
|
||
with pytest.raises(TP.ChecklistUnavailable) as caught:
|
||
TP.load_master(missing)
|
||
assert 'nope.yaml' in str(caught.value)
|
||
|
||
empty = tmp_path / 'empty.yaml'
|
||
empty.write_text('checklist:\n master: []\n', encoding='utf-8')
|
||
with pytest.raises(TP.ChecklistUnavailable):
|
||
TP.load_master(str(empty))
|
||
|
||
dupe = tmp_path / 'dupe.yaml'
|
||
dupe.write_text(
|
||
"checklist:\n master:\n"
|
||
" - {id: '1.1', name: a, grade: A, owner_codes: null,"
|
||
" not_run_reason: null, checks: null}\n"
|
||
" - {id: '1.1', name: b, grade: A, owner_codes: null,"
|
||
" not_run_reason: null, checks: null}\n", encoding='utf-8')
|
||
with pytest.raises(TP.ChecklistUnavailable) as caught:
|
||
TP.load_master(str(dupe))
|
||
assert 'identity' in str(caught.value)
|
||
|
||
nameless = tmp_path / 'nameless.yaml'
|
||
nameless.write_text(
|
||
"checklist:\n master:\n"
|
||
" - {id: '1.1', grade: A, owner_codes: null,"
|
||
" not_run_reason: null, checks: null}\n", encoding='utf-8')
|
||
with pytest.raises(TP.ChecklistUnavailable):
|
||
TP.load_master(str(nameless))
|
||
|
||
|
||
def test_the_for_engineer_rows_still_derive_from_the_published_table():
|
||
"""The 5.3 defect, one layer out. `CHECKLIST_FOR_ENGINEER` is derived
|
||
from the row's own declared reason, and the reason now comes out of
|
||
YAML -- so an entry that loses `not_run_reason` in the move would turn a
|
||
person's row back into a red NOT RUN on every ticket."""
|
||
assert '5.3' in TP.CHECKLIST_FOR_ENGINEER
|
||
assert '5.5' in TP.CHECKLIST_FOR_ENGINEER
|
||
for num in TP.CHECKLIST_FOR_ENGINEER:
|
||
item = [i for i in TP.CHECKLIST_ITEMS if i[0] == num][0]
|
||
assert item[4], num
|
||
assert not item[3], num
|
||
assert not TP._check_names(item[5]), num
|
||
|
||
|
||
# ================== 5.11 - 5.15: the gates, and every one of the silences
|
||
#
|
||
# Owner, 2026-08-31: "没有 solid zone 相关的检查 ... 这些都没有啊" and "不光是
|
||
# solid zone,insulation recess 也得加上。"
|
||
#
|
||
# WHAT IS ASSERTED HERE IS THAT NONE OF THE FIVE CAN GO QUIET. Every empty
|
||
# return leaves a sentence behind and a probe reads it -- the shape 6.4 had
|
||
# to be retrofitted with, built in from the first commit this time.
|
||
#
|
||
# EVERY PLANT STRING BELOW IS INVENTED. A test that pinned this plant's real
|
||
# type names would put its vocabulary into the repository from the test side,
|
||
# which is the same defect as writing it into the check.
|
||
|
||
SOLID_ZONE_KNOW = {
|
||
'insulation': {
|
||
'solid_zone': {'family': 'ZONE_PART', 'family_verified': True},
|
||
'legend': {
|
||
'swatch_types': {'RECESS TYPE A': 'recess_2in_nf'},
|
||
'expected_patterns': {'solid_zone': 'ANSI35',
|
||
'recess_2in_nf': 'TRIANG'},
|
||
'expected_patterns_measured_on': '2026-08-31',
|
||
},
|
||
'recess': {'region_types': ['RECESS TYPE A'],
|
||
'modelled_path_verified': False,
|
||
'modelled_path_unverified_because': 'nobody has ruled'},
|
||
},
|
||
'drawings': {'insulation_layout': 'Layout View 1'},
|
||
}
|
||
|
||
|
||
def _zone_ticket(**kw):
|
||
t = {'solid_zones': [{'id': 1, 'lifter_id': 2, 'member': True}],
|
||
'lifters': [{'id': 2}], 'lifters_without_zone': 0,
|
||
'lifters_unread': 0, 'sheets': [{'name': 'PNL101'}]}
|
||
t.update(kw)
|
||
return t
|
||
|
||
|
||
def test_511_an_unverified_solid_zone_family_stands_the_row_down():
|
||
"""The 6.6 guard, and it is doing more work than usual here: a family
|
||
name that matches nothing counts ZERO zones, and zero zones reads as
|
||
"no lifter is missing one" -- a permanent invisible pass."""
|
||
know = {'insulation': {'solid_zone': {'family': 'ZONE_PART',
|
||
'family_verified': False}}}
|
||
t = _zone_ticket()
|
||
assert TC.check_solid_zone_members(t, {}, know, {}) == []
|
||
assert 'family_verified is false' in t['_solid_zone_silence']
|
||
|
||
|
||
def test_511_an_unnamed_family_stands_down_naming_the_key():
|
||
t = _zone_ticket()
|
||
assert TC.check_solid_zone_members(t, {}, {}, {}) == []
|
||
assert 'insulation.solid_zone.family is unset' in t['_solid_zone_silence']
|
||
|
||
|
||
def test_511_zones_never_gathered_is_told_apart_from_zones_measured_at_zero():
|
||
"""6.4's lesson, landed the same day. `None` and `[]` reach the check as
|
||
different values so that no later edit can collapse them."""
|
||
ungathered = _zone_ticket(solid_zones=None)
|
||
assert TC.check_solid_zone_members(
|
||
ungathered, {}, SOLID_ZONE_KNOW, {}) == []
|
||
assert 'not gathered on this run' in ungathered['_solid_zone_silence']
|
||
|
||
measured = _zone_ticket(solid_zones=[])
|
||
assert TC.check_solid_zone_members(measured, {}, SOLID_ZONE_KNOW, {}) == []
|
||
assert 'no solid zone was found' in measured['_solid_zone_silence']
|
||
assert ungathered['_solid_zone_silence'] != measured['_solid_zone_silence']
|
||
|
||
|
||
def test_511_a_non_member_fails_on_its_own_code():
|
||
t = _zone_ticket(solid_zones=[{'id': 1, 'lifter_id': 2, 'member': False}])
|
||
findings = TC.check_solid_zone_members(t, {}, SOLID_ZONE_KNOW, {})
|
||
assert [(f.severity, f.code) for f in findings] == [
|
||
('FAIL', 'SOLID-ZONE-ASM')]
|
||
|
||
|
||
def test_511_a_lifter_with_no_zone_rides_a_second_code_on_the_same_row():
|
||
t = _zone_ticket(lifters_without_zone=1)
|
||
codes = [f.code for f in
|
||
TC.check_solid_zone_members(t, {}, SOLID_ZONE_KNOW, {})]
|
||
assert 'SOLID-ZONE-ABSENT' in codes
|
||
assert 'SOLID-ZONE-ASM' in codes
|
||
|
||
|
||
def test_511_an_absent_zone_survives_an_unjudgeable_membership():
|
||
"""The row is answered by the absent FAIL, so the unjudged half rides as
|
||
an OK-severity note rather than swallowing the verdict."""
|
||
t = _zone_ticket(lifters_without_zone=1,
|
||
solid_zones=[{'id': 1, 'lifter_id': 2, 'member': None}])
|
||
findings = TC.check_solid_zone_members(t, {}, SOLID_ZONE_KNOW, {})
|
||
codes = dict([(f.code, f.severity) for f in findings])
|
||
assert codes['SOLID-ZONE-ABSENT'] == 'FAIL'
|
||
assert codes['SOLID-ZONE-UNJUDGED'] == 'OK'
|
||
|
||
|
||
def test_512_no_sheet_issued_is_not_run_and_is_neither_a_pass_nor_a_failure():
|
||
"""12 of the 27 insulated pieces have no sheet yet (2026-08-31). A piece
|
||
with no ticket has not got a ticket defect, and has not got a clean
|
||
ticket either."""
|
||
t = _zone_ticket(sheets=[])
|
||
assert TC.check_solid_zone_on_ticket(t, {}, SOLID_ZONE_KNOW, {}) == []
|
||
assert 'no sheet has been issued' in t['_solid_zone_ticket_silence']
|
||
|
||
|
||
def test_512_an_unnamed_layout_view_stands_down_naming_the_key():
|
||
know = dict(SOLID_ZONE_KNOW)
|
||
know['drawings'] = {}
|
||
t = _zone_ticket()
|
||
assert TC.check_solid_zone_on_ticket(t, {}, know, {}) == []
|
||
assert ('drawings.insulation_layout is unset'
|
||
in t['_solid_zone_ticket_silence'])
|
||
|
||
|
||
def test_512_a_non_member_is_reported_as_missing_from_the_ticket():
|
||
t = _zone_ticket(solid_zones=[{'id': 1, 'lifter_id': 2, 'member': False}])
|
||
findings = TC.check_solid_zone_on_ticket(t, {}, SOLID_ZONE_KNOW, {})
|
||
assert findings[0].severity == 'FAIL'
|
||
assert findings[0].code == 'SOLID-ZONE-TICKET'
|
||
assert 'Layout View 1' in findings[0].message
|
||
|
||
|
||
def test_513_falls_back_to_the_plant_map_and_says_it_is_a_fallback():
|
||
t = _zone_ticket(solid_zone_pattern='TRIANG')
|
||
findings = TC.check_solid_zone_hatch(t, {}, SOLID_ZONE_KNOW, {})
|
||
assert len(findings) == 1
|
||
assert findings[0].severity == 'FAIL'
|
||
assert 'plant layer' in findings[0].message
|
||
assert 'TRIANG' in findings[0].message and 'ANSI35' in findings[0].message
|
||
|
||
|
||
def test_513_prefers_the_legend_read_off_this_very_sheet():
|
||
"""The legend is the sheet's own statement of intent and the only
|
||
self-updating reference; the plant map is a copy of it."""
|
||
t = _zone_ticket(solid_zone_pattern='ANSI35',
|
||
legend_patterns={'solid_zone': 'ANSI35'})
|
||
findings = TC.check_solid_zone_hatch(t, {}, SOLID_ZONE_KNOW, {})
|
||
assert findings[0].severity == 'OK'
|
||
assert 'own legend' in findings[0].message
|
||
|
||
|
||
def test_513_an_ungathered_pattern_is_a_named_silence_not_a_pass():
|
||
t = _zone_ticket()
|
||
assert TC.check_solid_zone_hatch(t, {}, SOLID_ZONE_KNOW, {}) == []
|
||
assert 'could not be read on this run' in t['_solid_zone_hatch_silence']
|
||
|
||
|
||
def test_514_stands_down_and_the_probe_carries_both_unblockers():
|
||
t = _zone_ticket(recess_regions={'RECESS TYPE A': 2})
|
||
assert TC.check_recess_regions(t, {}, SOLID_ZONE_KNOW, {}) == []
|
||
said = TC._SKIP_PROBES['check_recess_regions'](t, {}, SOLID_ZONE_KNOW, {})
|
||
assert 'NOTHING WAS COMPARED' in said
|
||
assert 'NOT consulted' in said
|
||
|
||
|
||
def test_514_an_ungathered_region_list_names_the_gather_it_needs():
|
||
t = _zone_ticket(recess_regions=None)
|
||
assert TC.check_recess_regions(t, {}, SOLID_ZONE_KNOW, {}) == []
|
||
assert 'FilledRegion' in t['_recess_silence']
|
||
|
||
|
||
def test_515_reports_every_recess_type_in_ONE_finding():
|
||
"""`checklist_states` keeps the LAST finding per code, so a second
|
||
RECESS-HATCH would silently replace the first and the row would print
|
||
half the answer. 6.7's ruling, obeyed rather than rediscovered."""
|
||
know = {'insulation': {
|
||
'legend': {'swatch_types': {'RECESS TYPE A': 'recess_2in_nf',
|
||
'RECESS TYPE B': 'recess_3in_nf'},
|
||
'expected_patterns': {'recess_2in_nf': 'TRIANG',
|
||
'recess_3in_nf': 'TUCKPOINT'}},
|
||
'recess': {'region_types': ['RECESS TYPE A', 'RECESS TYPE B']}}}
|
||
t = _zone_ticket(recess_patterns={'RECESS TYPE A': 'STARS',
|
||
'RECESS TYPE B': 'STARS'})
|
||
findings = TC.check_recess_hatch(t, {}, know, {})
|
||
assert len(findings) == 1
|
||
assert findings[0].severity == 'FAIL'
|
||
assert 'RECESS TYPE A' in findings[0].message
|
||
assert 'RECESS TYPE B' in findings[0].message
|
||
|
||
|
||
def test_515_carries_the_size_caveat_without_answering_it():
|
||
know = {'insulation': {
|
||
'legend': {'swatch_types': {'RECESS TYPE A': 'recess_2in_nf'},
|
||
'expected_patterns': {'recess_2in_nf': 'TRIANG'}},
|
||
'recess': {'region_types': ['RECESS TYPE A']}}}
|
||
t = _zone_ticket(recess_patterns={'RECESS TYPE A': 'TRIANG'})
|
||
findings = TC.check_recess_hatch(t, {}, know, {})
|
||
assert findings[0].severity == 'OK'
|
||
assert 'question for the owner' in findings[0].message
|
||
|
||
|
||
def test_all_five_probes_return_a_reason_and_never_none_on_a_silence():
|
||
"""A probe that can return None on a silent path converts the silence
|
||
into a quiet OK -- a worse failure than the one it exists to fix."""
|
||
for name in ('check_solid_zone_members', 'check_solid_zone_on_ticket',
|
||
'check_solid_zone_hatch', 'check_recess_regions',
|
||
'check_recess_hatch'):
|
||
assert name in TC._SKIP_PROBES
|
||
said = TC._SKIP_PROBES[name]({}, {}, {}, {})
|
||
assert said and 'not as a pass' in said
|
||
|
||
|
||
def test_the_five_rows_are_wired_end_to_end():
|
||
pairs = (('5.11', 'SOLID-ZONE-ASM', 'check_solid_zone_members'),
|
||
('5.12', 'SOLID-ZONE-TICKET', 'check_solid_zone_on_ticket'),
|
||
('5.13', 'SOLID-ZONE-HATCH', 'check_solid_zone_hatch'),
|
||
('5.14', 'RECESS-PRESENT', 'check_recess_regions'),
|
||
('5.15', 'RECESS-HATCH', 'check_recess_hatch'))
|
||
for num, code, fn in pairs:
|
||
item = [i for i in TP.CHECKLIST_ITEMS if i[0] == num][0]
|
||
assert code in item[3], num
|
||
assert fn in TP._check_names(item[5]), num
|
||
assert fn in [f.__name__ for f in TC.CHECKS], num
|
||
assert num not in TP.CHECKLIST_FOR_ENGINEER
|
||
|
||
|
||
# ============ 6.13: lifter accessory (cup/patch) assembly membership =====
|
||
#
|
||
# REVIT issue #26. Every plant string below is invented, same rule as the
|
||
# solid-zone fixtures above -- this plant's real family names (QL121,
|
||
# QL062, DB_PATCH) belong in the plant layer, not in a test.
|
||
|
||
_ACCESSORY_KNOW = {
|
||
'lifters': {
|
||
'accessories': {
|
||
'CUP_PART': {'parent': 'LIFT_PART', 'role': 'cup'},
|
||
'PATCH_PART': {'parent': ['LIFT_PART', 'LIFT_PART_2'],
|
||
'role': 'patch'},
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _accessory_ticket(**kw):
|
||
t = {'lifters': [{'id': 2, 'family': 'LIFT_PART'}],
|
||
'lifter_children': [], 'lifter_children_unread': 0}
|
||
t.update(kw)
|
||
return t
|
||
|
||
|
||
def test_613_no_lifter_recognised_defers_to_61():
|
||
t = _accessory_ticket(lifters=[])
|
||
assert TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {}) == []
|
||
assert 'see 6.1' in t['_lifter_accessory_silence']
|
||
|
||
|
||
def test_613_children_never_gathered_is_told_apart_from_gathered_at_zero():
|
||
"""The same `None` vs `[]` discipline 5.11 uses for `solid_zones`."""
|
||
ungathered = _accessory_ticket(lifter_children=None)
|
||
assert TC.check_lifter_accessory_in_assembly(
|
||
ungathered, {}, _ACCESSORY_KNOW, {}) == []
|
||
assert 'not gathered on this run' in ungathered['_lifter_accessory_silence']
|
||
|
||
gathered = _accessory_ticket(lifter_children=[])
|
||
findings = TC.check_lifter_accessory_in_assembly(
|
||
gathered, {}, _ACCESSORY_KNOW, {})
|
||
assert findings[0].severity == 'OK'
|
||
assert 'no nested child' in findings[0].message
|
||
assert gathered['_lifter_accessory_silence'] is None
|
||
|
||
|
||
def test_613_a_classified_accessory_absent_from_the_assembly_fails():
|
||
t = _accessory_ticket(lifter_children=[
|
||
{'id': 10, 'family': 'CUP_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART', 'member': False}])
|
||
findings = TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {})
|
||
assert [(f.severity, f.code) for f in findings] == [
|
||
('FAIL', 'LIFT-ACCESSORY-NOT-IN-ASSEMBLY')]
|
||
assert 'lifter 2' in findings[0].message
|
||
assert 'child 10' in findings[0].message
|
||
assert 'CUP_PART' in findings[0].message
|
||
assert 'cup' in findings[0].message
|
||
|
||
|
||
def test_613_a_classified_accessory_present_is_ok():
|
||
t = _accessory_ticket(lifter_children=[
|
||
{'id': 10, 'family': 'CUP_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART', 'member': True}])
|
||
findings = TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {})
|
||
assert [(f.severity, f.code) for f in findings] == [
|
||
('OK', 'LIFT-ACCESSORY-NOT-IN-ASSEMBLY')]
|
||
|
||
|
||
def test_613_parent_as_a_list_is_accepted():
|
||
"""DB_PATCH's own shape: `parent` is a list, not a bare string."""
|
||
t = _accessory_ticket(lifter_children=[
|
||
{'id': 11, 'family': 'PATCH_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART_2', 'member': False}])
|
||
findings = TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {})
|
||
assert findings[0].severity == 'FAIL'
|
||
assert 'patch' in findings[0].message
|
||
|
||
from vibe import knowledge as K
|
||
entry = K.lifter_accessories(_ACCESSORY_KNOW)['PATCH_PART']
|
||
assert entry['parents'] == ['LIFT_PART', 'LIFT_PART_2']
|
||
assert entry['role'] == 'patch'
|
||
|
||
|
||
def test_613_an_unclassified_child_not_in_the_assembly_warns():
|
||
t = _accessory_ticket(lifter_children=[
|
||
{'id': 12, 'family': 'MYSTERY_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART', 'member': False}])
|
||
findings = TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {})
|
||
assert [(f.severity, f.code) for f in findings] == [
|
||
('WARN', 'LIFT-ACCESSORY-UNCLASSIFIED')]
|
||
assert 'MYSTERY_PART' in findings[0].message
|
||
|
||
|
||
def test_613_an_unclassified_member_is_a_quiet_pass():
|
||
"""Unclassified AND already in the assembly: nothing to flag, nothing
|
||
to claim -- an empty return that is a genuine ran-and-found-nothing,
|
||
not a silence."""
|
||
t = _accessory_ticket(lifter_children=[
|
||
{'id': 12, 'family': 'MYSTERY_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART', 'member': True}])
|
||
assert TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {}) == []
|
||
assert t['_lifter_accessory_silence'] is None
|
||
|
||
|
||
def test_613_unreadable_membership_is_never_a_pass():
|
||
t = _accessory_ticket(lifter_children=[
|
||
{'id': 10, 'family': 'CUP_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART', 'member': None}])
|
||
assert TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {}) == []
|
||
assert 'readable assembly membership' in t['_lifter_accessory_silence']
|
||
assert 'nothing was judged' in t['_lifter_accessory_silence']
|
||
|
||
|
||
def test_613_a_fail_and_a_warn_coexist_on_different_codes():
|
||
t = _accessory_ticket(lifter_children=[
|
||
{'id': 10, 'family': 'CUP_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART', 'member': False},
|
||
{'id': 12, 'family': 'MYSTERY_PART', 'lifter_id': 2,
|
||
'lifter_family': 'LIFT_PART', 'member': False}])
|
||
findings = TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {})
|
||
codes = dict([(f.code, f.severity) for f in findings])
|
||
assert codes['LIFT-ACCESSORY-NOT-IN-ASSEMBLY'] == 'FAIL'
|
||
assert codes['LIFT-ACCESSORY-UNCLASSIFIED'] == 'WARN'
|
||
|
||
|
||
def test_613_probe_never_returns_none_on_a_silent_path():
|
||
t = _accessory_ticket(lifters=[])
|
||
assert TC.check_lifter_accessory_in_assembly(t, {}, _ACCESSORY_KNOW, {}) == []
|
||
said = TC._SKIP_PROBES['check_lifter_accessory_in_assembly'](
|
||
t, {}, _ACCESSORY_KNOW, {})
|
||
assert said and 'see 6.1' in said
|
||
|
||
|
||
def test_613_is_wired_end_to_end():
|
||
item = [i for i in TP.CHECKLIST_ITEMS if i[0] == '6.13'][0]
|
||
assert 'LIFT-ACCESSORY-NOT-IN-ASSEMBLY' in item[3]
|
||
assert 'check_lifter_accessory_in_assembly' in TP._check_names(item[5])
|
||
assert 'check_lifter_accessory_in_assembly' in [
|
||
f.__name__ for f in TC.CHECKS]
|
||
assert 'check_lifter_accessory_in_assembly' in [
|
||
f.__name__ for f in TC.REINFORCING_CHECKS]
|
||
assert 'check_lifter_accessory_in_assembly' not in TC.REINFORCING_EXCLUDED
|
||
assert 'check_lifter_accessory_in_assembly' not in TC.REINFORCING_APPLIES_TO
|
||
|
||
|
||
def test_no_plant_vocabulary_reached_the_code():
|
||
"""The filled-region type names, the layout view name and the solid-zone
|
||
family are PLANT facts. A literal in the code would be the `bed_plan`
|
||
mistake repeated, and it is the mistake this whole layer exists against.
|
||
"""
|
||
import io as _io
|
||
import os as _os
|
||
here = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
|
||
for name in ('ticketchecks.py', 'ticketpage.py', 'ticketqc.py',
|
||
'knowledge.py'):
|
||
path = _os.path.join(here, 'lib', 'vibe', name)
|
||
text = _io.open(path, encoding='utf-8').read()
|
||
assert 'KP SOLID' not in text, name
|
||
assert 'KP 2in' not in text, name
|
||
assert 'KP 3in' not in text, name
|
||
assert 'Elevation Top 1' not in text, name
|
||
assert 'SOLID_ZONE_RECTANGULAR' not in text, name
|
||
|
||
|
||
# ==================================================================== probes
|
||
#
|
||
# THE `RAN-EMPTY -> "no probe declared"` CLASS, closed 2026-08-31 on the
|
||
# owner's standing order: "qc别再给我出这么多not-run。看看能跑的,能做的,现在
|
||
# 都做了去。" Seven checks still had no probe -- `check_lifter_capacity`,
|
||
# `check_handling_result`, `check_member_interference`, `check_picking_match`,
|
||
# `check_ticket_set`, `check_sheet_name`, `check_invisible` -- and any one of
|
||
# them returning empty printed a red NOT RUN carrying a sentence about the
|
||
# TOOL on a row that may well have been fine.
|
||
#
|
||
# The guard below is deliberately universal rather than a list: a list would
|
||
# have to be edited by the same person who forgot the probe.
|
||
|
||
|
||
def test_every_registered_check_has_a_skip_probe():
|
||
"""The one guard that makes "a new check owes a probe" enforceable.
|
||
|
||
`CLAUDE.md` ss5 has said it in prose since the mechanism was built, and
|
||
prose is what let the count sit at seven. A check with no probe is not a
|
||
check that works -- it is a NOT RUN row nobody can act on, shipped.
|
||
"""
|
||
orphans = sorted([fn.__name__ for fn in TC.CHECKS
|
||
if fn.__name__ not in TC._SKIP_PROBES])
|
||
assert not orphans, (
|
||
'these registered checks have no entry in _SKIP_PROBES, so an empty '
|
||
'return from any of them renders NOT RUN reading "no probe '
|
||
'declared" -- a sentence about the tool, in red, on a row that may '
|
||
'be fine: {0}'.format(', '.join(orphans)))
|
||
|
||
|
||
def test_no_probe_is_registered_for_a_check_that_does_not_exist():
|
||
"""The other direction, and it is silent: a probe keyed by a renamed or
|
||
deleted function never fires, and the row it was written for is back to
|
||
"no probe declared" with nothing saying why."""
|
||
registered = set([fn.__name__ for fn in TC.CHECKS])
|
||
stray = sorted([name for name in TC._SKIP_PROBES
|
||
if name not in registered])
|
||
assert not stray, (
|
||
'these skip probes are keyed by a name that is not in TC.CHECKS, so '
|
||
'they can never run: {0}'.format(', '.join(stray)))
|
||
|
||
|
||
def test_every_probe_returns_text_or_none_and_never_an_empty_string():
|
||
"""`''` is the one return the contract forbids: it is falsy, so `run_all`
|
||
reads it as "ran and found nothing" -- a probe that meant to explain a
|
||
silence turning that silence into a green row."""
|
||
checked = 0
|
||
for name in sorted(TC._SKIP_PROBES):
|
||
try:
|
||
answer = TC._SKIP_PROBES[name]({}, {}, _know(), None)
|
||
except Exception:
|
||
# A probe that reaches the Revit API cannot be asked here, and
|
||
# `run_all` already treats a raising probe as its own reported
|
||
# state ('probe raised'). It is skipped, not excused: this test
|
||
# counts what it managed to ask so it cannot rot into a loop
|
||
# over nothing.
|
||
continue
|
||
checked += 1
|
||
assert answer is None or (isinstance(answer, str) and answer.strip()), (
|
||
'{0}: a probe returns a reason or None, never {1!r}'
|
||
.format(name, answer))
|
||
assert checked > len(TC._SKIP_PROBES) // 2
|
||
|
||
|
||
def test_the_probes_for_the_three_checks_that_cannot_be_silent_say_so():
|
||
"""1.1, 1.3 and 8.2 answer with a severity and a sentence for every
|
||
input, including no sheets at all. Their probes therefore never fire in
|
||
normal running -- and when one does, the row must name the check and say
|
||
the silence is a defect in the tool rather than a verdict."""
|
||
for name, row in (('check_sheet_name', '1.1'),
|
||
('check_ticket_set', '1.3'),
|
||
('check_invisible', '8.2')):
|
||
said = TC._SKIP_PROBES[name]({}, {}, _know(), None)
|
||
assert said and row in said and name in said
|
||
assert 'defect in the tool' in said
|
||
|
||
|
||
def test_the_lifter_capacity_probe_separates_none_from_unrecognised():
|
||
""""this piece carries no insert" and "none of its inserts matches the
|
||
plant's family rule" are different answers with different repairs, and
|
||
the sentence names both."""
|
||
said = TC._SKIP_PROBES['check_lifter_capacity']({}, {}, _know(), None)
|
||
assert 'families.lifter_include' in said
|
||
assert TC._SKIP_PROBES['check_lifter_capacity'](
|
||
{'lifters': [{'family': 'X'}]}, {}, _know(), None) is None
|
||
|
||
|
||
def test_the_interference_probe_names_the_switch_and_stays_quiet_when_on():
|
||
"""The clean path -- geometry solved, nothing interfering -- is also an
|
||
empty return, and the probe MUST return None for it or a correct piece
|
||
reads NOT RUN."""
|
||
off = TC._SKIP_PROBES['check_member_interference']({}, {}, {}, None)
|
||
assert 'interference.check is off' in off
|
||
know = {'interference': {'check': True}}
|
||
assert 'panel' in TC._SKIP_PROBES['check_member_interference'](
|
||
{}, {}, know, None)
|
||
assert TC._SKIP_PROBES['check_member_interference'](
|
||
{'panel_id': 7}, {}, know, None) is None
|
||
|
||
|
||
def test_the_picking_probe_names_the_reading_failure_as_the_tools():
|
||
"""Six silent paths, and the last one is a READING failure: the printed
|
||
band did not parse. "the ticket is wrong" and "we could not read it" are
|
||
the two answers this whole tool exists to keep apart."""
|
||
know = {'handling': {'lifter_groups': {'face': {'families': ['P136']}}}}
|
||
assert 'no sheet has been issued' in TC._SKIP_PROBES[
|
||
'check_picking_match']({}, {}, know, None)
|
||
assert 'length' in TC._SKIP_PROBES['check_picking_match'](
|
||
{'sheets': [object()]}, {}, know, None)
|
||
assert 'face.families is empty' in TC._SKIP_PROBES['check_picking_match'](
|
||
{'sheets': [object()], 'length': 24.9}, {}, {}, None)
|
||
said = TC._SKIP_PROBES['check_picking_match'](
|
||
{'sheets': [object()], 'length': 24.9}, {}, know, None)
|
||
assert 'could not be recovered' in said
|
||
assert 'Nothing about the modelled inserts is claimed' in said
|
||
|
||
|
||
# ============================================================================
|
||
# MK0625: INTERFERENCE-EXEMPT THE SAME WAY SR5602 IS FOR P76
|
||
# ============================================================================
|
||
#
|
||
# Owner, 2026-09-02, on the live sample_job model, verbatim: "MC厂知识:MK0625是
|
||
# QL589的tension bar,不与任何东西干涉。" -- MK0625 is QL589's tension bar
|
||
# and interferes with nothing. Same shape as the other plant's SR5602/
|
||
# SR6999 being the bars welded to the P76 embed: fully exempt via
|
||
# `interference.exempt_families`, and also grouped with its lifter under
|
||
# `interference.component_groups` so the assembly's own members are
|
||
# explained even in a shape that does not lean on the exemption alone.
|
||
#
|
||
# `_exempt` and `_grouped` are pure decisions over dicts -- no Revit, no
|
||
# geometry solved -- so this is a direct unit test, same as the rest of
|
||
# this file.
|
||
|
||
def test_mk0625_is_interference_exempt_the_same_way_sr5602_is():
|
||
know = {'interference': {
|
||
'exempt_families': ['MK0625'],
|
||
'component_groups': [
|
||
{'name': 'LIFT_QL589G_8T ASSEMBLY',
|
||
'families': ['LIFT_QL589G_8T', 'MK0625'],
|
||
'interferes': 'within_group'}]}}
|
||
boxes = [{'id': 1, 'family': 'MK0625'},
|
||
{'id': 2, 'family': 'LIFT_QL589G_8T'},
|
||
{'id': 3, 'family': 'SOME_OTHER_EMBED'}]
|
||
|
||
# exempt_families alone already keeps MK0625 out of every pair, exactly
|
||
# the mechanism SR5602/SR6999 rely on at the other plant
|
||
exempt, unexplained = TC._exempt({}, boxes, know, None)
|
||
assert exempt == set([1])
|
||
assert unexplained == []
|
||
|
||
# the component group also explains the assembly's own members
|
||
# touching each other -- belt-and-suspenders with the exemption above,
|
||
# mirroring the "EMBED P76" group exactly
|
||
groups = TC._component_groups(know)
|
||
assert TC._grouped('MK0625', 'LIFT_QL589G_8T', groups) is True
|
||
# but it is not a blanket exemption for the lifter itself against a
|
||
# THIRD family outside the group
|
||
assert TC._grouped('LIFT_QL589G_8T', 'SOME_OTHER_EMBED', groups) is False
|
||
|
||
|
||
# ============================================================================
|
||
# 2.2: reconstruction NOT APPLICABLE (WP100, 2026-09-08, `_scratch/wp100_
|
||
# weight.md`) -- `liftexport._reconstructed_ft3` can find a wythe whose
|
||
# thickness is not constant (a step or taper it has no way to represent) and
|
||
# stand ONLY the reconstruction leg down, naming why, while the measured leg
|
||
# (which never depends on it) is still read normally. `check_weight_three_way`
|
||
# must then compare printed vs measured through
|
||
# `ticketpage.weight_agreement_no_reconstruction` -- which, unlike the
|
||
# generic two-way path, CAN say OK -- rather than the ordinary `WGT-READ`
|
||
# ("suspect the tool first") that fired live on WP100 before this fix.
|
||
#
|
||
# `_weight_sources` caches its answer on the ticket (`t['_weight_sources']`),
|
||
# exactly the seam `_ticket()` above already uses for 4.7 -- so these tests
|
||
# prime that cache directly and never touch a panel, a frame or a solid,
|
||
# the same trick this file's docstring describes for 4.7 and 8.1.
|
||
|
||
def _weight_ticket(sources, weight_lb=None):
|
||
t = {'mark': 'TEST-1', 'weight_lb': weight_lb}
|
||
full = {'printed': None, 'reconstructed': None, 'measured': None,
|
||
'stated': weight_lb, 'reconstruction_reason': None, 'why': {}}
|
||
full.update(sources)
|
||
t['_weight_sources'] = full
|
||
return t
|
||
|
||
|
||
def test_222_reconstruction_not_applicable_and_printed_measured_agree_is_ok():
|
||
"""The WP100 shape itself: printed 9,045 vs measured 9,115 (0.77% apart,
|
||
inside 2%) with the reconstruction stood down as not applicable."""
|
||
reason = ('wythe 1 of 2 (its own solid measures 41.22 ft3) does not '
|
||
'reconcile ... stepped or tapered wythe')
|
||
t = _weight_ticket({'printed': 9045.0, 'measured': 9115.0,
|
||
'stated': 9115.0, 'reconstruction_reason': reason,
|
||
'why': {'reconstructed': reason}})
|
||
out = TC.check_weight_three_way(t, {}, _know(), None)
|
||
assert len(out) == 1
|
||
assert out[0].severity == 'OK'
|
||
assert out[0].code == 'WGT-OK'
|
||
assert 'not applicable' in out[0].message
|
||
assert 'stepped or tapered' in out[0].message
|
||
|
||
|
||
def test_222_reconstruction_not_applicable_but_printed_measured_disagree_warns():
|
||
reason = 'stepped or tapered wythe'
|
||
t = _weight_ticket({'printed': 8000.0, 'measured': 9115.0,
|
||
'stated': 9115.0, 'reconstruction_reason': reason,
|
||
'why': {'reconstructed': reason}})
|
||
out = TC.check_weight_three_way(t, {}, _know(), None)
|
||
assert out[0].severity == 'WARN'
|
||
assert out[0].code == 'WGT-TWO-WAY-NA'
|
||
assert 'not applicable' in out[0].message
|
||
|
||
|
||
def test_222_reconstruction_missing_for_an_ordinary_reason_still_never_ok():
|
||
"""A reconstruction absent for any OTHER reason (concrete unreadable, the
|
||
geometry switch, ...) must still go through the generic two-way path,
|
||
which is deliberately never OK -- only the NAMED not-applicable state
|
||
(`reconstruction_reason` set) gets the new treatment."""
|
||
t = _weight_ticket({'printed': 9045.0, 'measured': 9115.0,
|
||
'stated': 9115.0, 'reconstruction_reason': None,
|
||
'why': {'reconstructed': 'some unrelated reason'}})
|
||
out = TC.check_weight_three_way(t, {}, _know(), None)
|
||
assert out[0].severity == 'WARN'
|
||
assert out[0].code == 'WGT-TWO-WAY'
|
||
|
||
|
||
def test_222_three_way_is_unchanged_when_reconstruction_is_present():
|
||
"""Regression: a normal three-way (reconstruction present and used)
|
||
must not be touched by any of the above -- same OK it always gave."""
|
||
t = _weight_ticket({'printed': 9115.0, 'measured': 9115.0,
|
||
'reconstructed': 9115.0, 'stated': 9115.0})
|
||
out = TC.check_weight_three_way(t, {}, _know(), None)
|
||
assert out[0].severity == 'OK'
|
||
assert out[0].code == 'WGT-OK'
|
||
assert 'not applicable' not in out[0].message
|