WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# -*- 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())
|
||||
Reference in New Issue
Block a user