52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
193 lines
6.8 KiB
Python
193 lines
6.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Plant extraction rule packs.
|
|
|
|
The property every test here circles is the one the module rests on: a pack
|
|
may re-describe a note or move it to a field a person reads, and NOTHING it
|
|
can say makes a note disappear. Everything else -- which regex fires, what
|
|
sentence comes back -- is detail beside that.
|
|
|
|
The seven notes used throughout are the real ones, verbatim, from `WPA1.0`
|
|
(sample_job, 8" horizontal wall) read on 2026-09-01: the run that
|
|
prompted this module, where all seven landed in `unresolved` under one
|
|
blanket reason.
|
|
"""
|
|
import pytest
|
|
|
|
from vibe import extractrules
|
|
from vibe.extractrules import mc
|
|
|
|
|
|
# The seven, in sheet order. Whitespace-normalised the way
|
|
# `ticketqc.unresolved_in_view` normalises before anything sees them.
|
|
WPA1_NOTES = [
|
|
'*USE MC STANDARD REINFORCEMENT FOR CUT DOWN PANELS AND AROUND ALL OPENINGS',
|
|
'STRIPPING & BLOCKING DETAIL',
|
|
'USE FOR BACK OR EDGE PICK (UNO)',
|
|
'STRAND PATTERN',
|
|
'STRAND',
|
|
"#5 BAR X CONT ONLY NEEDED WHEN LAST PAIR OF STRANDS ARE MORE THAN 1'-0 "
|
|
'FROM EDGE OF WALL.',
|
|
"STRIPPING & BLOCKING DETAIL < 25'",
|
|
]
|
|
|
|
|
|
def entries(texts, reason='no extraction rule matched this note'):
|
|
return [{'text': t, 'reason': reason} for t in texts]
|
|
|
|
|
|
def know_with(packs):
|
|
"""A knowledge stand-in and a getter over it, so these tests never need
|
|
the real layered loader (which wants a Revit doc)."""
|
|
store = {'extract.rule_packs': packs}
|
|
return store, lambda know, key, default=None: know.get(key, default)
|
|
|
|
|
|
# --------------------------------------------------------------- the gate
|
|
|
|
def test_no_declaration_loads_no_pack_and_changes_nothing():
|
|
"""The honest default: told nothing about an office, recognise nothing."""
|
|
know, get = know_with([])
|
|
packs, problems = extractrules.load(know, get)
|
|
assert packs == []
|
|
assert problems == []
|
|
|
|
given = entries(WPA1_NOTES)
|
|
notes, unresolved = extractrules.apply_to(given, packs)
|
|
assert notes == []
|
|
assert unresolved == given
|
|
|
|
|
|
def test_declared_pack_loads():
|
|
know, get = know_with(['mc'])
|
|
packs, problems = extractrules.load(know, get)
|
|
assert problems == []
|
|
assert [p.NAME for p in packs] == ['mc']
|
|
|
|
|
|
def test_a_bare_string_is_accepted_as_one_name():
|
|
know, get = know_with('mc')
|
|
packs, problems = extractrules.load(know, get)
|
|
assert problems == []
|
|
assert [p.NAME for p in packs] == ['mc']
|
|
|
|
|
|
def test_unknown_pack_is_refused_loudly_and_nothing_loads():
|
|
"""All or nothing. A half-loaded rule set classifies some notes and not
|
|
others, and the reader cannot tell which sentences to trust."""
|
|
know, get = know_with(['mc', 'no-such-plant'])
|
|
packs, problems = extractrules.load(know, get)
|
|
assert packs == []
|
|
assert len(problems) == 1
|
|
assert 'no-such-plant' in problems[0]
|
|
assert 'mc' in problems[0] # says what IS available
|
|
|
|
|
|
def test_empty_and_duplicate_names_are_refused():
|
|
know, get = know_with(['mc', 'mc'])
|
|
_, problems = extractrules.load(know, get)
|
|
assert problems and 'twice' in problems[0]
|
|
|
|
know, get = know_with([''])
|
|
_, problems = extractrules.load(know, get)
|
|
assert problems and 'empty' in problems[0]
|
|
|
|
|
|
def test_knowledge_default_is_empty():
|
|
from vibe import knowledge
|
|
assert knowledge.DEFAULTS['extract']['rule_packs'] == []
|
|
|
|
|
|
# ------------------------------------------------- the load-bearing property
|
|
|
|
def test_no_note_is_ever_dropped():
|
|
"""The whole module in one assertion."""
|
|
know, get = know_with(['mc'])
|
|
packs, _ = extractrules.load(know, get)
|
|
given = entries(WPA1_NOTES)
|
|
notes, unresolved = extractrules.apply_to(given, packs)
|
|
|
|
assert len(notes) + len(unresolved) == len(given)
|
|
assert (sorted(n['text'] for n in notes + unresolved)
|
|
== sorted(WPA1_NOTES))
|
|
|
|
|
|
def test_every_placement_names_the_rule_that_made_it():
|
|
"""An unattributable classification is one nobody can argue with."""
|
|
know, get = know_with(['mc'])
|
|
packs, _ = extractrules.load(know, get)
|
|
notes, unresolved = extractrules.apply_to(entries(WPA1_NOTES), packs)
|
|
for entry in notes:
|
|
assert entry['rule'].startswith('mc/')
|
|
for entry in unresolved:
|
|
if 'rule' in entry:
|
|
assert entry['rule'].startswith('mc/')
|
|
|
|
|
|
def test_a_placement_must_declare_a_known_kind_and_a_rule():
|
|
with pytest.raises(ValueError):
|
|
extractrules.Placement('vanish', 'mc/x', 'because')
|
|
with pytest.raises(ValueError):
|
|
extractrules.Placement(extractrules.NOTE, '', 'because')
|
|
|
|
|
|
# ------------------------------------------------------- the mc pack itself
|
|
|
|
def test_the_seven_notes_land_where_they_should():
|
|
know, get = know_with(['mc'])
|
|
packs, _ = extractrules.load(know, get)
|
|
notes, unresolved = extractrules.apply_to(entries(WPA1_NOTES), packs)
|
|
|
|
assert sorted(n['text'] for n in notes) == sorted([
|
|
'*USE MC STANDARD REINFORCEMENT FOR CUT DOWN PANELS AND AROUND ALL '
|
|
'OPENINGS',
|
|
'USE FOR BACK OR EDGE PICK (UNO)',
|
|
"#5 BAR X CONT ONLY NEEDED WHEN LAST PAIR OF STRANDS ARE MORE THAN "
|
|
"1'-0 FROM EDGE OF WALL.",
|
|
"STRIPPING & BLOCKING DETAIL < 25'",
|
|
])
|
|
assert sorted(u['text'] for u in unresolved) == sorted([
|
|
'STRIPPING & BLOCKING DETAIL',
|
|
'STRAND PATTERN',
|
|
'STRAND',
|
|
])
|
|
|
|
|
|
def test_a_conditional_detail_title_is_a_note_and_the_bare_one_is_not():
|
|
"""The asymmetry this pack exists to get right: the condition is the part
|
|
that could change what gets built, so it is never called furniture."""
|
|
bare = mc.classify('STRIPPING & BLOCKING DETAIL')
|
|
cond = mc.classify("STRIPPING & BLOCKING DETAIL < 25'")
|
|
assert bare.kind == extractrules.FURNITURE
|
|
assert cond.kind == extractrules.NOTE
|
|
assert bare.rule != cond.rule
|
|
|
|
|
|
def test_furniture_keeps_its_place_in_unresolved():
|
|
"""`furniture` is not an ignore list -- the line is still written out,
|
|
only the sentence beside it changes."""
|
|
know, get = know_with(['mc'])
|
|
packs, _ = extractrules.load(know, get)
|
|
given = entries(['STRAND PATTERN'])
|
|
notes, unresolved = extractrules.apply_to(given, packs)
|
|
assert notes == []
|
|
assert len(unresolved) == 1
|
|
assert unresolved[0]['text'] == 'STRAND PATTERN'
|
|
assert unresolved[0]['reason'] != given[0]['reason'] # better sentence
|
|
assert 'legend' in unresolved[0]['reason']
|
|
|
|
|
|
def test_the_pack_recognises_nothing_it_was_not_shown():
|
|
"""A rule with no observation behind it does not belong in the pack, and
|
|
a sentence from another producer's sheet must fall straight through."""
|
|
for text in ('SEE ARCHITECTURAL DRAWINGS',
|
|
'ALL DIMENSIONS ARE NOMINAL',
|
|
'USE 6X6 -D3.0/D3.0 W.W.F.',
|
|
'',
|
|
'STRANDS'):
|
|
assert mc.classify(text) is None
|
|
|
|
|
|
def test_classify_is_case_and_padding_tolerant():
|
|
assert mc.classify(' strand pattern ') is not None
|
|
assert mc.classify('Strand Pattern').kind == extractrules.FURNITURE
|