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.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# -*- 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 == []
|
||||
Reference in New Issue
Block a user