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.
270 lines
11 KiB
Python
270 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The offline CLI: `tools/reinforce_offline.py`, run with no Revit at all.
|
|
|
|
WHAT THESE TESTS ARE REALLY FOR
|
|
--------------------------------
|
|
The claim this feature rests on is a claim about an IMPORT GRAPH: that the
|
|
whole chain from an index file to a printed suggestion touches nothing in
|
|
`Autodesk.*`. That claim cannot be argued, only run -- and running it here is
|
|
the proof, because host CPython has no Revit API at all, so a stray
|
|
module-scope `from Autodesk.Revit.DB import ...` anywhere on the path does not
|
|
degrade, it raises ImportError and this file goes red.
|
|
|
|
The rest is the wording an offline reader depends on: that a snapshot is
|
|
never presented as a model, that a missing index says what to do about it, and
|
|
that an index from another schema is refused whole rather than half-read.
|
|
|
|
Every mark here is an invented `PNL...` placeholder, as everywhere else in
|
|
this suite. No real mark, job or client name appears.
|
|
"""
|
|
# pylint: disable=import-error,invalid-name
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from vibe import foldreport as fold
|
|
from vibe import reinforcecore as core
|
|
|
|
CLI_PATH = Path(__file__).resolve().parents[2] / 'tools' / 'reinforce_offline.py'
|
|
|
|
|
|
def _load_cli():
|
|
spec = importlib.util.spec_from_file_location('reinforce_offline',
|
|
str(CLI_PATH))
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
cli = _load_cli()
|
|
|
|
|
|
# ------------------------------------------------------------------ fixtures
|
|
|
|
def _piece(name, rects, reinforced=1, on_hold=0, master=False):
|
|
feats = [core.feature(name, i, r[0], r[1], r[2], r[3], True, 0.0,
|
|
30.0, 10.0, 8.0)
|
|
for i, r in enumerate(rects)]
|
|
return core.piece_record(name, 100 + len(name), 'sig', reinforced, on_hold,
|
|
design_number='PNL1.0',
|
|
length_ft=30.0, width_ft=10.0, thickness_in=8.0,
|
|
features=feats, master=master)
|
|
|
|
|
|
@pytest.fixture
|
|
def snapshot(tmp_path):
|
|
"""A jobs root with one model in it, holding a two-donor index."""
|
|
folder = tmp_path / 'jobs' / 'sample_job' / 'reinforce'
|
|
folder.mkdir(parents=True)
|
|
cache = core.new_cache('sample_job')
|
|
cache['built_at'] = '2026-08-30 09:00:00'
|
|
cache['scope'] = {'prefixes': ['PNL'], 'note': None, 'declared_by': None}
|
|
pieces = [
|
|
_piece('PNL800', [(4.0, 6.0, 2.0, 5.0), (20.0, 22.0, 2.0, 5.0)],
|
|
reinforced=0), # the target: not a donor
|
|
_piece('PNL801', [(4.0, 6.0, 2.0, 5.0), (20.0, 22.0, 2.0, 5.0)]),
|
|
_piece('BM802', [(4.0, 6.0, 2.0, 5.0)]), # another engineer's piece
|
|
]
|
|
cache['pieces'] = dict((p['name'], p) for p in pieces)
|
|
core.save(str(folder / 'index.json'), cache)
|
|
return tmp_path / 'jobs'
|
|
|
|
|
|
# ----------------------------------------------------- the import graph claim
|
|
|
|
def test_no_part_of_the_offline_path_touches_revit(snapshot, capsys):
|
|
"""The claim, run rather than argued. See the module docstring."""
|
|
before = set(sys.modules)
|
|
assert cli.main(['PNL800', '--jobs', str(snapshot),
|
|
'--model', 'sample_job']) == 0
|
|
capsys.readouterr()
|
|
loaded = set(sys.modules) - before
|
|
forbidden = [m for m in set(sys.modules)
|
|
if m.split('.')[0] in ('Autodesk', 'clr', 'pyrevit',
|
|
'System', 'Microsoft')]
|
|
assert forbidden == [], forbidden
|
|
# And the modules that DID load are the ones that were promised to be free
|
|
# of it, which is the other half of the claim.
|
|
assert 'vibe.reinforcebind' in loaded or 'vibe.reinforcebind' in sys.modules
|
|
|
|
|
|
def test_the_two_functions_that_need_a_document_are_named_and_not_called():
|
|
"""`outline_audit` and `suggest_for_piece` are the Revit-bound pair. They
|
|
are importable -- the module has no Revit at module scope -- and calling
|
|
either is what an offline caller must not do."""
|
|
from vibe import reinforcebind as bind
|
|
assert callable(bind.outline_audit)
|
|
assert callable(bind.suggest_for_piece)
|
|
assert callable(bind.suggest_from_index) # the one this path uses
|
|
assert callable(bind.load_audit_at) # the doc-free audit reader
|
|
|
|
|
|
# -------------------------------------------------------------- finding it
|
|
|
|
def test_an_explicit_path_wins_and_is_named_in_the_output(snapshot):
|
|
path, how = cli.resolve_index(
|
|
str(snapshot / 'sample_job' / 'reinforce' / 'index.json'))
|
|
assert path.endswith('index.json')
|
|
assert '--index' in how
|
|
|
|
|
|
def test_a_jobs_root_plus_a_model_resolves_the_standard_layout(snapshot):
|
|
path, how = cli.resolve_index(None, str(snapshot), 'sample_job')
|
|
assert os.path.isfile(path)
|
|
assert 'sample_job' in how
|
|
|
|
|
|
def test_one_model_under_the_root_needs_no_naming(snapshot):
|
|
path, _how = cli.resolve_index(None, str(snapshot), None)
|
|
assert os.path.isfile(path)
|
|
|
|
|
|
def test_several_models_refuse_to_be_guessed_between(snapshot):
|
|
(snapshot / 'another_job').mkdir()
|
|
with pytest.raises(SystemExit) as got:
|
|
cli.resolve_index(None, str(snapshot), None)
|
|
assert 'Which model' in str(got.value)
|
|
|
|
|
|
def test_no_index_and_no_jobs_root_says_all_three_ways_to_give_one(monkeypatch):
|
|
monkeypatch.delenv('WILLTOOLS_JOBS', raising=False)
|
|
with pytest.raises(SystemExit) as got:
|
|
cli.resolve_index(None, None, None)
|
|
message = str(got.value)
|
|
assert '--index' in message and '--jobs' in message and 'WILLTOOLS_JOBS' \
|
|
in message
|
|
|
|
|
|
def test_a_missing_index_says_it_is_written_from_inside_revit(tmp_path):
|
|
with pytest.raises(SystemExit) as got:
|
|
cli.load_index(str(tmp_path / 'nope.json'))
|
|
assert 'from inside Revit' in str(got.value)
|
|
|
|
|
|
def test_an_index_from_another_schema_is_refused_whole(tmp_path):
|
|
"""Never half-read: a donors-only file would answer 'not in the index'
|
|
for every ordinary target while looking entirely healthy."""
|
|
path = tmp_path / 'index.json'
|
|
path.write_text(json.dumps({'schema': core.SCHEMA - 1, 'pieces': {}}),
|
|
encoding='utf-8')
|
|
with pytest.raises(SystemExit) as got:
|
|
cli.load_index(str(path))
|
|
message = str(got.value)
|
|
assert 'discarded whole' in message
|
|
assert 'Reinforcing Suggestion' in message # and what to do about it
|
|
|
|
|
|
# ------------------------------------------------------------------ the run
|
|
|
|
def test_a_target_that_may_not_donate_is_answered_from_the_snapshot(snapshot):
|
|
"""The whole point: PNL800 is not reinforced, so before schema 5 it was
|
|
not in the file at all and only a live measurement could answer it."""
|
|
folder = str(snapshot / 'sample_job' / 'reinforce')
|
|
cache = cli.load_index(os.path.join(folder, 'index.json'))
|
|
suggestion, info, sections = cli.run(cache, 'PNL800', folder)
|
|
assert suggestion is not None
|
|
assert info['offline'] is True
|
|
assert info['donor_names'] == ['PNL801'] # BM802 is out of scope
|
|
text = fold.text(sections, show_all=True)
|
|
assert 'OFFLINE' in text
|
|
assert '2026-08-30 09:00:00' in text
|
|
assert 'checked against the model' in text
|
|
|
|
|
|
def test_the_stamped_scope_is_used_and_can_be_overridden(snapshot):
|
|
folder = str(snapshot / 'sample_job' / 'reinforce')
|
|
cache = cli.load_index(os.path.join(folder, 'index.json'))
|
|
_s, bounded, _sections = cli.run(cache, 'PNL800', folder)
|
|
assert bounded['rejected'].get('prefix') == 1
|
|
_s, unbounded, _sections = cli.run(cache, 'PNL800', folder,
|
|
no_scope=True)
|
|
assert 'prefix' not in unbounded['rejected']
|
|
|
|
|
|
def test_an_index_with_no_stamped_scope_says_so_rather_than_assuming(snapshot):
|
|
"""'Nobody recorded it' is not 'no scope was declared'."""
|
|
folder = str(snapshot / 'sample_job' / 'reinforce')
|
|
path = os.path.join(folder, 'index.json')
|
|
cache = cli.load_index(path)
|
|
cache['scope'] = None
|
|
_s, info, sections = cli.run(cache, 'PNL800', folder)
|
|
assert info['scope_stamped'] is False
|
|
# It goes in the HEADLINE, not a folded section: every count below rests
|
|
# on a pool bounded by nobody knows what.
|
|
assert any('no declared scope' in b[1] for b in sections[0].blocks
|
|
if b[0] == 'md')
|
|
|
|
|
|
def test_listing_keeps_donors_and_targets_apart(snapshot):
|
|
cache = cli.load_index(str(snapshot / 'sample_job' / 'reinforce' /
|
|
'index.json'))
|
|
donors, targets = cli.listing(cache)
|
|
assert donors == ['BM802', 'PNL801']
|
|
assert targets == ['PNL800']
|
|
|
|
|
|
# ------------------------------------------------------------- the printing
|
|
|
|
def test_entities_become_ascii_and_the_words_are_left_alone():
|
|
got = cli.plain('This is **bold** — and `code`.')
|
|
assert '--' in got
|
|
assert '—' not in got
|
|
assert 'This is bold -- and code.' in got
|
|
|
|
|
|
def test_a_quote_block_is_marked_so_a_caveat_cannot_look_like_prose():
|
|
assert cli.plain('> a warning').lstrip().startswith('!')
|
|
|
|
|
|
def test_a_table_never_truncates_a_mark_however_wide_the_row_gets():
|
|
"""A silently cut mark is a wrong answer that looks like a right one."""
|
|
got = cli.table(['donor', 'what'],
|
|
[['PNL803', 'x' * 120], ['PNL804', 'y']])
|
|
assert 'PNL803' in got and 'PNL804' in got
|
|
assert 'x' * 120 in got
|
|
|
|
|
|
def test_an_empty_table_says_nothing_rather_than_printing_a_header():
|
|
assert 'nothing' in cli.table(['a'], [])
|
|
|
|
|
|
# ------------------------------------------------------- what folds at a shell
|
|
|
|
def test_the_terminal_folds_the_routine_sections_to_one_line(snapshot, capsys):
|
|
"""The owner's rule, at a command line: the answer, then a short list of
|
|
what else there is. `--all` opens everything."""
|
|
args = ['PNL800', '--jobs', str(snapshot), '--model', 'sample_job']
|
|
cli.main(args)
|
|
folded = _flat(capsys.readouterr().out)
|
|
cli.main(args + ['--all'])
|
|
everything = _flat(capsys.readouterr().out)
|
|
assert len(everything) > len(folded)
|
|
assert '(folded)' in folded
|
|
assert 'How to read the numbers' in folded # named, though folded
|
|
assert 'finest tolerance' not in folded # but not printed in full
|
|
assert 'finest tolerance' in everything
|
|
|
|
|
|
def test_the_unfolded_report_can_always_be_written_out(snapshot, tmp_path,
|
|
capsys):
|
|
"""Folded is not gone. A fold that will not open must cost a file open,
|
|
never a lost sentence -- so the file holds every section in full."""
|
|
out = tmp_path / 'full.txt'
|
|
cli.main(['PNL800', '--jobs', str(snapshot), '--model', 'sample_job',
|
|
'--out', str(out)])
|
|
printed = _flat(capsys.readouterr().out)
|
|
written = _flat(out.read_text(encoding='utf-8'))
|
|
assert 'finest tolerance' in written
|
|
assert 'finest tolerance' not in printed
|
|
assert str(out) in written # the page names itself
|
|
|
|
|
|
def _flat(text):
|
|
"""Whitespace-normalised. The plain-text renderer wraps at 78 columns, so
|
|
a phrase these tests look for can be split across two lines."""
|
|
return ' '.join(text.split())
|