Files
willtools-demo/WillTools.extension/tests/test_snapshot_defer.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

301 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for `tools/snapshot_defer.py` -- the parts that need no Revit.
The tool's job splits cleanly: WAIT for a process, then LAUNCH one. The wait
is the half that can be got wrong invisibly (it can decide a live Revit is
gone, and then two 10 GB models are resident at once, which is the single
outcome the whole design exists to prevent), so its clock, its sleep and its
liveness check are all injected and all tested here. The launch is one argv
built by `vibe.snapshotmeta`, tested there and asserted end-to-end here.
Loaded by path, like `test_knowledge_drift.py`: this is host CPython 3.12 code
sitting in a repository whose extension is IronPython 2.7.
"""
import json
import os
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
TOOLS = os.path.join(ROOT, 'tools')
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
from vibe import snapshotmeta as S # noqa: E402 (registers the vibe stub)
snapshot_defer = pytest.importorskip('snapshot_defer')
D = snapshot_defer
# Built, never spelled out -- see the note in test_snapshotmeta.py.
MODEL = 'm_detached' + S.MODEL_EXT
class Clock(object):
"""A wall clock that only moves when something sleeps on it."""
def __init__(self):
self.t = 1000.0
self.slept = []
def now(self):
return self.t
def sleep(self, seconds):
self.slept.append(seconds)
self.t += seconds
# ---------------------------------------------------------------- the wait
def test_returns_at_once_when_the_process_is_already_gone():
clock = Clock()
assert D.wait_for_exit(123, 600, alive=lambda pid: False,
sleep=clock.sleep, clock=clock.now) is True
assert clock.slept == []
def test_waits_until_the_process_goes_and_then_returns_true():
clock = Clock()
calls = {'n': 0}
def alive(_pid):
calls['n'] += 1
return calls['n'] <= 3
assert D.wait_for_exit(123, 600, alive=alive, sleep=clock.sleep,
clock=clock.now, poll=5) is True
assert clock.slept == [5, 5, 5]
def test_gives_up_and_says_so_rather_than_launching_anyway():
# The Revit that never closes -- an unanswered save prompt. Returning
# False is what makes that a reportable outcome instead of a second Revit
# started next to a live one.
clock = Clock()
assert D.wait_for_exit(123, 60, alive=lambda pid: True, sleep=clock.sleep,
clock=clock.now, poll=10) is False
assert clock.t - 1000.0 >= 60
def test_a_zero_pid_is_not_waited_on():
assert D.wait_for_exit(0, 600, alive=lambda pid: True,
sleep=lambda s: None, clock=lambda: 0.0) is True
def test_process_alive_says_false_for_a_pid_that_cannot_exist():
# 0xFFFFFFF0 is not a valid Windows pid. The point of the assertion is
# that the real implementation answers rather than raising.
assert D.process_alive(0xFFFFFFF0) in (True, False)
def test_process_alive_is_false_for_no_pid():
assert D.process_alive(0) is False
assert D.process_alive(None) is False
def test_this_very_process_is_alive():
assert D.process_alive(os.getpid()) is True
# ------------------------------------------- the machine, not just one pid
def test_a_revit_started_again_during_the_wait_is_seen():
# The hole this closes: he closes Revit, the tool starts waiting, he
# opens Revit again for something else. Waiting only on the original pid
# would launch a second 10 GB model beside his live one.
assert D.any_revit_running(run=lambda argv: 'Revit.exe 1234 Console') \
is True
def test_an_empty_task_list_means_the_machine_is_free():
assert D.any_revit_running(
run=lambda argv: 'INFO: No tasks are running.') is False
def test_a_task_list_that_will_not_run_counts_as_busy():
# Unknown must never read as free: being wrong that way thrashes the
# workstation, being wrong the other way costs a wait.
def explode(argv):
raise OSError('tasklist is not on the path')
assert D.any_revit_running(run=explode) is True
def test_the_machine_wide_wait_uses_the_same_loop_and_can_time_out():
clock = Clock()
assert D.wait_for_exit(1, 60, alive=lambda _pid: True, sleep=clock.sleep,
clock=clock.now, poll=10) is False
# ------------------------------------------------------------- the command
def test_command_for_matches_what_the_shared_module_builds():
assert D.command_for('p.exe', 'r.py', 2025) == \
S.pyrevit_run_argv('p.exe', 'r.py', 2025)
def test_command_for_without_a_year():
assert D.command_for('p.exe', 'r.py') == ['p.exe', 'run', 'r.py', '--purge']
# ------------------------------------------------------------- the dry run
def write_request(tmp_path, **over):
plan = S.plan(str(tmp_path / 'snap'), 'model_detached', '20260830-1612')
request = {'run': 'r20260830-161200', 'base': 'model_detached',
'layout': 'folder', 'worksets': 'preserve', 'plan': plan,
'log': str(tmp_path / 'snapshots.jsonl'),
'source': {'path': str(tmp_path / ('model' + S.MODEL_EXT)), 'cloud': None}}
request.update(over)
path = tmp_path / 'req.json'
path.write_text(json.dumps(request), encoding='utf-8')
return str(path), request
def test_dry_run_prints_the_command_and_launches_nothing(tmp_path, capsys):
req_path, request = write_request(tmp_path)
code = D.main(['--request', req_path, '--runner', r'C:\x\run.py',
'--pyrevit', r'C:\x\pyrevit.exe', '--revit', '2025',
'--wait-pid', '4242', '--dry-run'])
out = capsys.readouterr().out
assert code == 0
assert 'pyrevit.exe" run "C:\\x\\run.py" --revit=2025 --purge' in out \
or 'pyrevit.exe run C:\\x\\run.py --revit=2025 --purge' in out
assert request['plan']['path'] in out
assert 'WILLTOOLS_SNAPSHOT_REQUEST' in out
assert '4242' in out
# nothing was created, nothing was journalled
assert not os.path.exists(request['log'])
def test_dry_run_names_the_target_the_finder_will_later_find(tmp_path, capsys):
req_path, request = write_request(tmp_path)
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
'--dry-run'])
out = capsys.readouterr().out
target = request['plan']['path']
assert target in out
# and once that file exists, discovery agrees it is the newest snapshot
os.makedirs(os.path.dirname(target))
with open(target, 'w') as fh:
fh.write('x')
assert S.newest(request['plan']['folder'].rsplit(os.sep, 1)[0])['path'] \
== target
# ------------------------------------------------------- listing what exists
def test_newest_listing_reports_nothing_when_there_is_nothing(tmp_path, capsys):
code = D.main(['--newest', str(tmp_path)])
assert code == 1
assert 'no snapshots' in capsys.readouterr().out
def test_newest_listing_marks_the_newest(tmp_path, capsys):
root = str(tmp_path)
for stamp in ('20260828-0730', '20260830-1612'):
folder = os.path.join(root, 'm_detached_' + stamp)
os.makedirs(folder)
with open(os.path.join(folder, MODEL), 'w') as fh:
fh.write('x')
code = D.main(['--newest', root])
out = capsys.readouterr().out
assert code == 0
assert out.strip().endswith('m_detached_20260830-1612' + os.sep +
MODEL)
assert '* 20260830-1612' in out
assert ' 20260828-0730' in out
# --------------------------------------------------- the 2026-09-02 incident
#
# A press on 2026-09-01 was journalled as failed within seconds by the CYCLE
# route (not the after-close route the report first assumed), and the
# runner's own FAILED line was never written at all -- meaning it crashed
# before it could read its own request. Separately, this machine has five
# Revit versions attached and `pyrevit run` with no `--revit=` reproducibly
# hits the same bare "Object reference not set to an instance of an object"
# pyRevit CLI wraps every unhandled exception in. These tests lock in the
# three changes that came out of that investigation: `--pyrevit`
# auto-discovery (so a run started without it is no longer a silent
# `argparse.error()`), a loud journal line even when the request itself
# cannot be read, and a `--revit=<year>` reminder in both human-facing
# outputs (dry-run print, and the FAILED line of a real run).
def test_pyrevit_is_auto_discovered_when_left_off(tmp_path, monkeypatch,
capsys):
req_path, _request = write_request(tmp_path)
monkeypatch.setattr(S, 'pyrevit_exe_guess',
lambda: r'C:\guessed\pyrevit.exe')
code = D.main(['--request', req_path, '--runner', 'r.py', '--dry-run'])
out = capsys.readouterr().out
assert code == 0
assert r'C:\guessed\pyrevit.exe' in out
def test_a_run_with_no_pyrevit_anywhere_journals_instead_of_arg_erroring(
tmp_path, monkeypatch, capsys):
"""Until 2026-09-02 this was `ap.error()`: stderr only, exit 2, and the
ledger stayed exactly as empty as a run that never started at all."""
req_path, request = write_request(tmp_path)
monkeypatch.setattr(S, 'pyrevit_exe_guess', lambda: None)
code = D.main(['--request', req_path, '--runner', 'r.py'])
assert code == 2
rows = S.read(request['log'])
assert rows and rows[-1]['event'] == S.FAILED
assert 'pyRevit CLI was not found' in rows[-1]['error']
def test_an_unreadable_request_still_journals_to_the_detached_dir(tmp_path):
"""The request file IS where `log` lives, so a request this tool cannot
even parse used to leave nothing anywhere -- not even the traceback had
a home. `fallback_log_for` recovers `<detached_dir>/snapshots.jsonl`
from the `_requests/<run>.json` shape of the path alone."""
detached_dir = tmp_path / 'snaps'
req_dir = detached_dir / '_requests'
req_dir.mkdir(parents=True)
bad_req = req_dir / 'c-broken.json'
bad_req.write_text('not json at all {{{', encoding='utf-8')
code = D.main(['--request', str(bad_req), '--runner', 'r.py',
'--pyrevit', 'p.exe'])
assert code == 2
rows = S.read(str(detached_dir / S.LOG_NAME))
assert rows and rows[-1]['event'] == S.WATCHER_CRASHED
assert rows[-1]['run'] is None
def test_dry_run_warns_when_no_revit_year_was_given(tmp_path, capsys):
req_path, _request = write_request(tmp_path)
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
'--dry-run'])
out = capsys.readouterr().out
assert 'WARNING' in out and '--revit=<year>' in out
def test_dry_run_does_not_warn_when_a_revit_year_was_given(tmp_path, capsys):
req_path, _request = write_request(tmp_path)
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
'--revit', '2024', '--dry-run'])
out = capsys.readouterr().out
assert 'WARNING' not in out
def test_a_real_run_journals_watcher_launched_before_touching_the_cli(
tmp_path):
"""The proof this process actually started and got as far as the wait/
launch step -- a REQUESTED with no WATCHER_LAUNCHED after it now means
this tool never got this far at all, not "still running"."""
req_path, request = write_request(tmp_path)
code = D.main(['--request', req_path, '--runner', 'r.py',
'--pyrevit', sys.executable])
assert code == 2 # sys.executable is not pyrevit.exe
rows = S.read(request['log'])
events = [row['event'] for row in rows]
assert S.WATCHER_LAUNCHED in events
assert events.index(S.WATCHER_LAUNCHED) < len(events) - 1
launched = [r for r in rows if r['event'] == S.WATCHER_LAUNCHED][0]
assert launched['pyrevit_exe'] == sys.executable
assert launched['revit_year'] is None