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.
188 lines
7.5 KiB
Python
188 lines
7.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""`tools/sweep_in_revit.py` obeys the DECLARED scope, and says what it left.
|
|
|
|
WHY THIS FILE IS SHAPED LIKE THIS
|
|
----------------------------------
|
|
The sweep script cannot be imported. It is written to be sent to a live Revit
|
|
by `rvt.py exec`, so it imports the Revit API at module scope and its last
|
|
line is `result = run(doc)` against a `doc` the bridge injects. Importing it
|
|
here would fail twice over.
|
|
|
|
So the source is read, cut at that last line, and the rest executed against
|
|
stubs for the three modules that genuinely need Revit -- `Autodesk.Revit.DB`,
|
|
`vibe.designspec` and `vibe.ticketqc`. `vibe.scope` is the REAL module: the
|
|
whole point is to prove that the declaration in the job file, and nothing
|
|
else, decides what this sweep measures.
|
|
|
|
What is still not covered: `measure()` and `run()`, which need a document.
|
|
Every mark below is a placeholder -- `PNL` for the pieces in scope, `PNLA`
|
|
for the near miss, `XX` for somebody else's. A real prefix is job
|
|
information and belongs in the job knowledge file, never here.
|
|
"""
|
|
import json
|
|
import re
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
SWEEP = REPO / 'tools' / 'sweep_in_revit.py'
|
|
SENTINEL = 'result = run(doc)'
|
|
|
|
|
|
# ------------------------------------------------------------- the harness
|
|
|
|
class FakeAssembly(object):
|
|
def __init__(self, name, eid):
|
|
self.Name = name
|
|
self._eid = eid
|
|
|
|
|
|
def _install(monkeypatch, assemblies_by_design):
|
|
"""Stub only what truly needs Revit, and put it back afterwards."""
|
|
revit_db = types.ModuleType('Autodesk.Revit.DB')
|
|
revit_db.ElementId = lambda value: value
|
|
revit = types.ModuleType('Autodesk.Revit')
|
|
revit.DB = revit_db
|
|
autodesk = types.ModuleType('Autodesk')
|
|
autodesk.Revit = revit
|
|
monkeypatch.setitem(sys.modules, 'Autodesk', autodesk)
|
|
monkeypatch.setitem(sys.modules, 'Autodesk.Revit', revit)
|
|
monkeypatch.setitem(sys.modules, 'Autodesk.Revit.DB', revit_db)
|
|
|
|
designspec = types.ModuleType('vibe.designspec')
|
|
designspec.sub_dir = lambda doc, name: str(doc)
|
|
|
|
ticketqc = types.ModuleType('vibe.ticketqc')
|
|
ticketqc.assemblies_by_design = lambda doc, know: assemblies_by_design
|
|
ticketqc.eid = lambda asm: asm._eid
|
|
ticketqc.collect_context = lambda doc: ({}, {})
|
|
|
|
vibe = sys.modules['vibe']
|
|
for name, module in (('designspec', designspec), ('ticketqc', ticketqc)):
|
|
monkeypatch.setitem(sys.modules, 'vibe.' + name, module)
|
|
monkeypatch.setattr(vibe, name, module, raising=False)
|
|
|
|
source = SWEEP.read_text(encoding='utf-8')
|
|
assert SENTINEL in source, 'the sweep no longer ends the way this reads it'
|
|
namespace = {'__name__': 'sweep_in_revit_under_test'}
|
|
exec(compile(source.split(SENTINEL)[0], str(SWEEP), 'exec'), namespace)
|
|
return namespace
|
|
|
|
|
|
@pytest.fixture
|
|
def sweep(monkeypatch):
|
|
def build(assemblies_by_design):
|
|
return _install(monkeypatch, assemblies_by_design)
|
|
return build
|
|
|
|
|
|
def _pieces():
|
|
return {'PNL1': [FakeAssembly('PNL101', 11), FakeAssembly('PNL102', 12)],
|
|
'PNLA1': [FakeAssembly('PNLA101', 21)],
|
|
'XX1': [FakeAssembly('XX101', 31), FakeAssembly('XX102', 32)]}
|
|
|
|
|
|
def _run_enumerate(sweep, know, tmp_path):
|
|
module = sweep(_pieces())
|
|
todo_path = str(tmp_path / 'todo.json')
|
|
scope_path = str(tmp_path / 'scope.json')
|
|
todo, info = module['enumerate_pieces'](object(), know, todo_path,
|
|
scope_path)
|
|
return module, todo, info, json.loads((tmp_path / 'scope.json')
|
|
.read_text(encoding='utf-8'))
|
|
|
|
|
|
# -------------------------------------------------- the declaration governs
|
|
|
|
def test_the_declaration_decides_what_is_measured(sweep, tmp_path):
|
|
_m, todo, _info, _saved = _run_enumerate(
|
|
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
|
assert sorted(r['mark'] for r in todo) == ['PNL101', 'PNL102']
|
|
|
|
|
|
def test_changing_the_declaration_changes_the_sweep(sweep, tmp_path):
|
|
"""The hole this closes. The old code filtered on a literal, so editing
|
|
the job file moved nothing -- the declaration was decoration here while
|
|
every other consumer honoured it."""
|
|
_m, todo, _i, _s = _run_enumerate(
|
|
sweep, {'scope': {'mark_prefixes': ['XX']}}, tmp_path)
|
|
assert sorted(r['mark'] for r in todo) == ['XX101', 'XX102']
|
|
|
|
|
|
def test_no_declaration_sweeps_everything(sweep, tmp_path):
|
|
"""An undeclared scope is not an empty scope -- and a sweep that
|
|
measured nothing at all would look like a model with nothing in it."""
|
|
_m, todo, info, _s = _run_enumerate(sweep, {}, tmp_path)
|
|
assert len(todo) == 5
|
|
assert info['declared'] is False
|
|
assert 'No scope is declared' in info['line']
|
|
|
|
|
|
def test_a_mark_with_no_readable_prefix_is_swept_not_dropped(sweep, tmp_path):
|
|
module = sweep({'D1': [FakeAssembly('12345', 41)]})
|
|
todo, info = module['enumerate_pieces'](
|
|
object(), {'scope': {'mark_prefixes': ['PNL']}},
|
|
str(tmp_path / 'todo.json'), str(tmp_path / 'scope.json'))
|
|
assert [r['mark'] for r in todo] == ['12345']
|
|
assert 'no readable mark prefix' in info['line']
|
|
|
|
|
|
# ---------------------------------------------- and it says what it skipped
|
|
|
|
def test_what_was_left_alone_is_counted_named_and_written_beside_the_jobs(
|
|
sweep, tmp_path):
|
|
_m, _todo, info, saved = _run_enumerate(
|
|
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
|
assert info['left_alone_count'] == 3
|
|
assert info['assemblies_seen'] == 5
|
|
assert sorted(info['left_alone']) == [('PNLA', 1), ('XX', 2)]
|
|
assert 'NOT JUDGED, OUT OF SCOPE' in info['line']
|
|
assert saved['line'] == info['line'] # the file, not just the return
|
|
# Named by kind on disk too, where a tuple has become a JSON list.
|
|
assert sorted(saved['left_alone']) == [['PNLA', 1], ['XX', 2]]
|
|
|
|
|
|
def test_the_near_miss_reaches_the_sweep_output(sweep, tmp_path):
|
|
_m, _todo, info, _saved = _run_enumerate(
|
|
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
|
assert 'PNLA' in info['near_miss']
|
|
assert 'NEAR MISS' in info['headline']
|
|
|
|
|
|
def test_the_headline_fronts_every_return_value_because_the_caller_truncates(
|
|
sweep, tmp_path):
|
|
"""`tools/sweep.py` prints `str(out)[:200]`. The scope has to be in
|
|
those 200 characters or nobody reads it."""
|
|
module, _todo, info, _saved = _run_enumerate(
|
|
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
|
headline = module['_scope_headline'](info)
|
|
assert headline.startswith('NOT JUDGED, OUT OF SCOPE')
|
|
assert len(headline) < 200
|
|
|
|
|
|
def test_a_sweep_folder_with_no_scope_file_says_so_rather_than_assuming(
|
|
sweep, tmp_path):
|
|
"""An older folder was enumerated by the literal. What it left out is
|
|
genuinely unknown, and inventing a reassuring line would be the silent
|
|
exclusion this whole mechanism refuses."""
|
|
module = sweep({})
|
|
said = module['_scope_headline'](None)
|
|
assert 'NOT RECORDED' in said
|
|
assert 're-run' in said
|
|
|
|
|
|
# --------------------------------------------------------- no job literals
|
|
|
|
def test_the_sweep_carries_no_hardcoded_mark_prefix():
|
|
"""The other half of the hole: a job's naming baked into a repository
|
|
that must not carry one. Catches `startswith('AB')` and its family."""
|
|
source = SWEEP.read_text(encoding='utf-8')
|
|
body = source.split('"""', 2)[-1] # past the module docstring
|
|
assert re.search(r"""startswith\(\s*['"][A-Za-z]""", body) is None
|
|
assert 'vibe import scope' in source or 'from vibe import' in source
|
|
assert 'scopelib.of(know)' in source
|