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.
207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""`tools/sweep.py` refuses job files measured from a different model.
|
|
|
|
THE INCIDENT, 2026-09-01
|
|
------------------------
|
|
A whole sweep -- 184 job files -- carried `source.model` naming one model,
|
|
while the document open in Revit was a detached copy that had since DIVERGED
|
|
from it: a notch measured 3.5 in in one and 2.5 in in the other. Nothing
|
|
looked wrong. Every report rendered, every weight reconciled, and it
|
|
reconciled because a job file is the complete input to the check: the numbers
|
|
agree with the file they came from whether or not that file is about the piece
|
|
in front of you. The two were told apart by hand, and the field that would
|
|
have answered it in one line was sitting in every job file unread.
|
|
|
|
So the guard is a REFUSAL with both model names in it, not a warning line, and
|
|
"could not ask Revit" is a third answer that is printed and never mistaken for
|
|
agreement.
|
|
|
|
`tools/sweep.py` is plain host CPython -- it drives Revit through `rvt.py` as
|
|
a subprocess and imports nothing from the API -- so unlike `sweep_in_revit.py`
|
|
it can simply be loaded by path and called.
|
|
"""
|
|
import importlib.util
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
SWEEP_PY = REPO / 'tools' / 'sweep.py'
|
|
|
|
|
|
def _load():
|
|
spec = importlib.util.spec_from_file_location('_sweep_under_test',
|
|
str(SWEEP_PY))
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
sweep = _load()
|
|
|
|
|
|
def job(model, mark='PNL101'):
|
|
return (mark, {'panel': {'mark': mark},
|
|
'source': ({} if model is None else {'model': model})})
|
|
|
|
|
|
# ----------------------------------------------------------- the comparison
|
|
|
|
@pytest.mark.parametrize('name,stem', [
|
|
('sample_job.rvt', 'sample_job'),
|
|
('sample_job', 'sample_job'),
|
|
('SAMPLE_JOB.RVT', 'sample_job'),
|
|
(r'C:\models\sample_job.rvt', 'sample_job'),
|
|
(' sample_job.rvt ', 'sample_job'),
|
|
])
|
|
def test_the_extension_and_the_path_are_not_part_of_the_name(name, stem):
|
|
"""`source.model` is a basename with `.rvt` on it and `doc.Title` is not,
|
|
so a literal comparison would call every job file a mismatch."""
|
|
assert sweep.model_stem(name) == stem
|
|
|
|
|
|
@pytest.mark.parametrize('empty', [None, '', ' ', '.rvt'])
|
|
def test_a_nameless_model_is_none_and_never_a_match(empty):
|
|
assert sweep.model_stem(empty) is None
|
|
|
|
|
|
# ------------------------------------------------------------- the mismatch
|
|
|
|
def test_a_job_from_another_model_is_a_mismatch():
|
|
mismatched, unstamped, reason = sweep.check_source_model(
|
|
[job('sample_job.rvt')], 'sample_job_detached')
|
|
assert reason is None and unstamped == []
|
|
assert mismatched == [('PNL101', 'sample_job.rvt')]
|
|
|
|
|
|
def test_the_same_model_is_not_a_mismatch():
|
|
mismatched, unstamped, reason = sweep.check_source_model(
|
|
[job('sample_job.rvt')], 'sample_job')
|
|
assert (mismatched, unstamped, reason) == ([], [], None)
|
|
|
|
|
|
def test_the_refusal_names_both_models():
|
|
"""The one thing the message must do. A refusal that says 'wrong model'
|
|
without saying which two leaves the reader exactly where they were."""
|
|
mismatched, _unstamped, _reason = sweep.check_source_model(
|
|
[job('sample_job.rvt'), job('sample_job.rvt', 'PNL102')],
|
|
'sample_job_detached')
|
|
message = sweep.model_refusal(mismatched, 'sample_job_detached', 2)
|
|
assert 'sample_job.rvt' in message
|
|
assert 'sample_job_detached' in message
|
|
assert '2 of 2' in message
|
|
assert 'PNL101' in message
|
|
assert 'Nothing was checked' in message
|
|
assert '--any-model' in message
|
|
|
|
|
|
def test_a_job_with_no_source_model_is_unjudged_not_cleared():
|
|
""""Written before the field existed" is not "measured from this model".
|
|
It comes back in its own list so the caller can say so."""
|
|
mismatched, unstamped, reason = sweep.check_source_model(
|
|
[job(None)], 'sample_job')
|
|
assert mismatched == [] and reason is None
|
|
assert unstamped == ['PNL101']
|
|
|
|
|
|
def test_no_open_document_is_could_not_judge_and_says_why():
|
|
"""The empty `mismatched` here means DID NOT ASK. Reading it as agreement
|
|
is the whole failure this guard exists for, one level up."""
|
|
mismatched, unstamped, reason = sweep.check_source_model(
|
|
[job('sample_job.rvt')], None)
|
|
assert mismatched == []
|
|
assert unstamped == ['PNL101']
|
|
assert reason and 'no open document' in reason
|
|
|
|
|
|
# ------------------------------------------------ the refusal actually fires
|
|
|
|
class _Recorder(object):
|
|
"""A sweep folder on disk is a lot of scaffolding for one question, so
|
|
`check()` is exercised against a jobs dir and stopped before it ever
|
|
reaches panellift -- the refusal is meant to happen first."""
|
|
|
|
def __init__(self):
|
|
self.ran = 0
|
|
|
|
|
|
def _sweep_folder(tmp_path, model):
|
|
jobs = tmp_path / 'jobs'
|
|
jobs.mkdir()
|
|
(tmp_path / 'meta').mkdir()
|
|
(jobs / 'PNL101.job.json').write_text(
|
|
'{"panel": {"mark": "PNL101"}, "source": {"model": "%s"}}' % model,
|
|
encoding='utf-8')
|
|
(tmp_path / 'meta' / 'PNL101.json').write_text(
|
|
'{"mark": "PNL101", "status": "exported"}', encoding='utf-8')
|
|
return tmp_path
|
|
|
|
|
|
def test_check_refuses_before_it_computes_anything(tmp_path, monkeypatch):
|
|
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
|
seen = _Recorder()
|
|
|
|
def explode(*args, **kwargs):
|
|
seen.ran += 1
|
|
raise AssertionError('panellift must not run on a mismatched sweep')
|
|
|
|
monkeypatch.setattr(sweep.subprocess, 'run', explode)
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
sweep.check(str(root), 'sample_job_detached')
|
|
assert 'sample_job.rvt' in str(excinfo.value)
|
|
assert 'sample_job_detached' in str(excinfo.value)
|
|
assert seen.ran == 0
|
|
|
|
|
|
def test_any_model_proceeds_and_carries_the_caveat_into_the_report(tmp_path,
|
|
monkeypatch):
|
|
"""The override is not a way to make the sentence go away: it goes to the
|
|
top of problems.txt, where `render` puts it."""
|
|
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
|
|
|
class _Result(object):
|
|
stdout = '{"verdict": "OK", "stages": []}'
|
|
stderr = ''
|
|
|
|
monkeypatch.setattr(sweep.subprocess, 'run',
|
|
lambda *a, **k: _Result())
|
|
rows, notes = sweep.check(str(root), 'sample_job_detached',
|
|
any_model=True)
|
|
assert len(rows) == 1
|
|
assert any('--any-model' in note for note in notes)
|
|
assert notes[0].startswith('!!')
|
|
assert notes[0] in sweep.render(rows, [], notes)
|
|
|
|
|
|
def test_an_unreachable_revit_says_the_model_was_not_verified(tmp_path,
|
|
monkeypatch):
|
|
"""It does not refuse -- there is nothing to refuse against -- and it does
|
|
not stay quiet either."""
|
|
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
|
|
|
class _Result(object):
|
|
stdout = '{"verdict": "OK", "stages": []}'
|
|
stderr = ''
|
|
|
|
monkeypatch.setattr(sweep.subprocess, 'run',
|
|
lambda *a, **k: _Result())
|
|
_rows, notes = sweep.check(str(root), None)
|
|
assert any('NOT verified' in note for note in notes)
|
|
|
|
|
|
def test_a_matching_model_adds_no_caveat_at_all(tmp_path, monkeypatch):
|
|
"""The ordinary run is unchanged, down to the absence of a banner."""
|
|
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
|
|
|
class _Result(object):
|
|
stdout = '{"verdict": "OK", "stages": []}'
|
|
stderr = ''
|
|
|
|
monkeypatch.setattr(sweep.subprocess, 'run',
|
|
lambda *a, **k: _Result())
|
|
_rows, notes = sweep.check(str(root), 'sample_job')
|
|
assert notes == []
|