# -*- 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