Files
willtools-demo/WillTools.extension/tests/test_snapshotmeta.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

469 lines
18 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for `vibe.snapshotmeta` -- the Revit-free half of the snapshot flow.
Everything here runs on host CPython with no model, no licence and no Revit,
which is the whole reason the module exists as its own file. Real temporary
directories are used rather than a faked filesystem: the questions being asked
are "does listdir order change the answer", "is a stamped folder with no model
in it a snapshot", and "does a backup file beat the real one" -- all of which a
fake would have to model correctly to be worth anything, at which point it is
the thing under test.
"""
import io
import json
import os
import pytest
from vibe import snapshotmeta as S
# The model extension is never spelled out as a literal anywhere in this
# repository -- `tools/scrub.py` treats any `<word>` + extension as a possible
# real model filename and fails the audit on it, which is right: that pattern
# is exactly how a client's model name gets committed. Names are built from
# `S.MODEL_EXT` instead.
MODEL = 'm_detached' + S.MODEL_EXT
def touch(path):
folder = os.path.dirname(path)
if folder and not os.path.isdir(folder):
os.makedirs(folder)
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(u'not really a model')
return path
# -------------------------------------------------------------- the stamp
def test_stamp_shape():
text = S.stamp()
assert S.is_stamp(text)
assert len(text) == 13 and text[8] == '-'
def test_stamp_of_a_known_time():
# 2026-08-30 16:12 local. Built from a struct_time via mktime so the test
# asks the same question on any machine's timezone.
import time
when = time.mktime((2026, 8, 30, 16, 12, 0, 0, 0, -1))
assert S.stamp(when) == '20260830-1612'
@pytest.mark.parametrize('bad', ['', None, '2026083-1612', '20260830-161',
'20260830_1612', 'nope'])
def test_is_stamp_rejects(bad):
assert S.is_stamp(bad) is False
# ------------------------------------------------------------ splitting
def test_split_stamped_plain():
assert S.split_stamped('model_20260830-1612') == ('model',
'20260830-1612')
def test_split_stamped_keeps_a_base_that_contains_detached_and_spaces():
# The real title on this job is of exactly this shape. A base that gets
# truncated here is a snapshot that gets reported under the wrong name.
base, text = S.split_stamped('some model_detached_20260830-1612')
assert base == 'some model_detached'
assert text == '20260830-1612'
@pytest.mark.parametrize('name', ['model', 'model_detached', '',
'model_2026-08-30', '_20260830-1612'])
def test_split_stamped_returns_none_for_unstamped(name):
assert S.split_stamped(name) == (None, None)
def test_split_stamped_never_raises_on_junk():
assert S.split_stamped(None) == (None, None)
# --------------------------------------------------------------- planning
def test_plan_folder_layout_keeps_the_title_constant():
a = S.plan(r'C:\snap', 'model_detached', '20260830-1612')
b = S.plan(r'C:\snap', 'model_detached', '20260831-0900')
assert a['title'] == b['title'] == 'model_detached'
assert a['title_is_stable'] is True
assert a['folder'].endswith('model_detached_20260830-1612')
assert a['path'].endswith(os.path.join('model_detached_20260830-1612',
('model_detached' + S.MODEL_EXT)))
assert a['warning'] is None
def test_plan_filename_layout_moves_the_title_and_says_so():
p = S.plan(r'C:\snap', 'model_detached', '20260830-1612',
layout=S.FILENAME)
assert p['title'] == 'model_detached_20260830-1612'
assert p['title_is_stable'] is False
assert p['path'].endswith(('model_detached_20260830-1612' + S.MODEL_EXT))
assert 'WILLTOOLS_JOBS' in p['warning']
def test_plan_falls_back_to_folder_for_an_unknown_layout():
assert S.plan(r'C:\snap', 'm', '20260830-1612',
layout='sideways')['layout'] == S.FOLDER
def test_plan_stamps_itself_when_not_given_one():
assert S.is_stamp(S.plan(r'C:\snap', 'm')['stamp'])
# ------------------------------------------------------- finding the newest
def test_snapshots_of_a_missing_root_is_empty_not_an_error():
assert S.snapshots(r'C:\nope\nothing\here') == []
def test_newest_of_an_empty_folder_is_none(tmp_path):
assert S.newest(str(tmp_path)) is None
def test_finds_both_layouts_and_orders_them_oldest_first(tmp_path):
root = str(tmp_path)
touch(os.path.join(root, 'm_detached_20260830-1612', MODEL))
touch(os.path.join(root, 'm_detached_20260901-0800' + S.MODEL_EXT))
touch(os.path.join(root, 'm_detached_20260828-0730', MODEL))
rows = S.snapshots(root)
assert [r['stamp'] for r in rows] == ['20260828-0730', '20260830-1612',
'20260901-0800']
assert [r['layout'] for r in rows] == [S.FOLDER, S.FOLDER, S.FILENAME]
assert S.newest(root)['stamp'] == '20260901-0800'
def test_a_stamped_folder_with_no_model_in_it_is_not_a_snapshot(tmp_path):
# This is the state a Phase C that died mid-SaveAs leaves behind, and
# handing that path to Revit next time is a crash for no reason.
root = str(tmp_path)
os.makedirs(os.path.join(root, 'm_detached_20260830-1612'))
touch(os.path.join(root, 'm_detached_20260828-0730', MODEL))
rows = S.snapshots(root)
assert [r['stamp'] for r in rows] == ['20260828-0730']
def test_unstamped_folders_and_files_are_ignored(tmp_path):
root = str(tmp_path)
touch(os.path.join(root, 'm_detached', MODEL))
touch(os.path.join(root, 'notes.txt'))
touch(os.path.join(root, MODEL))
touch(os.path.join(root, 'm_detached_20260830-1612', MODEL))
assert [r['stamp'] for r in S.snapshots(root)] == ['20260830-1612']
def test_a_revit_backup_beside_the_model_does_not_win(tmp_path):
# Revit leaves a `.0001` backup next to a saved central. The real model is
# the shorter name; picking the backup would scan yesterday's geometry.
root = str(tmp_path)
folder = os.path.join(root, 'm_detached_20260830-1612')
touch(os.path.join(folder, 'm_detached.0001' + S.MODEL_EXT))
touch(os.path.join(folder, MODEL))
assert os.path.basename(S.newest(root)['path']) == MODEL
def test_title_comes_from_the_file_on_disk_not_from_the_folder_name(tmp_path):
# The folder is stamped; the model inside is not. The title Revit will
# report is the model's stem, and that is what the job folder keys on.
root = str(tmp_path)
touch(os.path.join(root, 'm_detached_20260830-1612', MODEL))
assert S.newest(root)['title'] == 'm_detached'
def test_the_plan_and_the_finder_agree(tmp_path):
# The two halves of the contract: what plan() says it will write is what
# snapshots() finds afterwards. Drift between them is silent.
root = str(tmp_path)
p = S.plan(root, 'm_detached', '20260830-1612')
touch(p['path'])
found = S.newest(root)
assert found['path'] == p['path']
assert found['stamp'] == p['stamp']
assert found['title'] == p['title']
def test_the_plan_and_the_finder_agree_in_filename_layout(tmp_path):
root = str(tmp_path)
p = S.plan(root, 'm_detached', '20260830-1612', layout=S.FILENAME)
touch(p['path'])
found = S.newest(root)
assert found['path'] == p['path']
assert found['title'] == p['title'] == 'm_detached_20260830-1612'
# ------------------------------------------------------------- provenance
def test_provenance_carries_everything_a_reader_needs():
b = S.provenance('A', S.LIVE_DOC, source_path=None, source_stamp=None,
source_title='model', revit='2025.4',
job_folder=r'C:\jobs\model')
assert b['phase'] == 'A'
assert b['kind'] == S.LIVE_DOC and b['kind_known'] is True
assert b['source'] == {'path': None, 'stamp': None, 'title': 'model'}
assert b['revit'] == '2025.4'
assert b['job_folder'] == r'C:\jobs\model'
assert b['at']
def test_provenance_flags_an_unknown_kind_instead_of_silently_taking_it():
assert S.provenance('C', 'guessed')['kind_known'] is False
def test_provenance_keeps_source_stamp_separate_from_run_time():
# A live document has no snapshot stamp. Filling it with the run time
# would make every Phase A snapshot claim a source age it does not have.
b = S.provenance('A', S.LIVE_DOC)
assert b['source']['stamp'] is None
assert b['at'] is not None
def test_provenance_line_is_printable_for_a_detached_source():
b = S.provenance('C', S.FRESH_DETACH, source_path=r'C:\s\m' + S.MODEL_EXT,
source_stamp='20260830-1612', revit='2025.4')
line = S.provenance_line(b)
assert 'phase C' in line and ('m' + S.MODEL_EXT) in line and '20260830-1612' in line
def test_provenance_line_says_so_when_there_is_none():
assert 'cannot say where it came from' in S.provenance_line(None)
# ---------------------------------------------------------------- the log
def test_append_and_read_round_trip(tmp_path):
path = S.log_path(str(tmp_path))
assert S.append(path, {'event': S.SNAPSHOT, 'pieces': 718}) is True
assert S.append(path, {'event': S.REQUESTED, 'run': 'r1'}) is True
rows = S.read(path)
assert [r['event'] for r in rows] == [S.SNAPSHOT, S.REQUESTED]
assert rows[0]['pieces'] == 718
assert rows[0]['at']
def test_append_never_raises_on_an_impossible_path():
assert S.append('', {'event': 'x'}) is False
def test_read_of_a_missing_log_is_empty():
assert S.read(r'C:\nope\snapshots.jsonl') == []
def test_a_half_written_last_line_costs_only_that_line(tmp_path):
path = S.log_path(str(tmp_path))
S.append(path, {'event': S.SNAPSHOT})
with io.open(path, 'a', encoding='utf-8') as fh:
fh.write(u'{"event": "phase-c-do')
rows = S.read(path)
assert [r['event'] for r in rows] == [S.SNAPSHOT]
def test_read_limit_keeps_the_newest(tmp_path):
path = S.log_path(str(tmp_path))
for i in range(5):
S.append(path, {'event': S.SNAPSHOT, 'n': i})
assert [r['n'] for r in S.read(path, limit=2)] == [3, 4]
# ------------------------------------------- the deferred step that vanished
def test_a_request_with_no_answer_is_outstanding():
rows = [{'event': S.REQUESTED, 'run': 'r1', 'at': 'yesterday'}]
assert S.outstanding(rows)['run'] == 'r1'
def test_a_request_that_finished_is_not_outstanding():
rows = [{'event': S.REQUESTED, 'run': 'r1'},
{'event': S.DONE, 'run': 'r1'}]
assert S.outstanding(rows) is None
def test_a_failure_closes_a_request_too():
rows = [{'event': S.REQUESTED, 'run': 'r1'},
{'event': S.FAILED, 'run': 'r1', 'error': 'no revit'}]
assert S.outstanding(rows) is None
def test_an_old_unfinished_request_still_shows_after_a_later_success():
# The failure mode this whole ledger exists for: yesterday's detached copy
# never happened, today's did, and today's must not paper over it.
rows = [{'event': S.REQUESTED, 'run': 'r1', 'at': 'mon'},
{'event': S.REQUESTED, 'run': 'r2', 'at': 'tue'},
{'event': S.DONE, 'run': 'r2', 'at': 'tue'}]
assert S.outstanding(rows)['run'] == 'r1'
def test_a_request_written_without_an_id_is_cleared_by_a_later_terminal_line():
rows = [{'event': S.REQUESTED, 'at': 'mon'},
{'event': S.DONE, 'at': 'mon'}]
assert S.outstanding(rows) is None
def test_summary_of_nothing_says_nothing_has_happened():
assert 'no snapshot has been recorded' in S.summary([])[0]
def test_summary_warns_loudly_about_a_vanished_phase_c():
rows = [{'event': S.REQUESTED, 'run': 'r1', 'at': '2026-08-29 18:00'}]
text = ' '.join(S.summary(rows))
assert 'WARNING' in text and '2026-08-29 18:00' in text
def test_summary_reports_the_last_good_copy_and_the_last_index():
rows = [{'event': S.SNAPSHOT, 'at': '2026-08-30 17:00', 'pieces': 718},
{'event': S.REQUESTED, 'run': 'r1', 'at': '2026-08-30 17:00'},
{'event': S.DONE, 'run': 'r1', 'at': '2026-08-30 17:40',
'path': os.path.join(r'C:\s\m_detached_20260830-1700', MODEL)}]
text = ' '.join(S.summary(rows))
assert 'WARNING' not in text
assert 'm_detached_20260830-1700' in text
assert '718 pieces' in text
# ------------------------------------------------------ the command lines
def test_pyrevit_run_argv_shape():
argv = S.pyrevit_run_argv(r'C:\p\pyrevit.exe', r'C:\s\run.py', 2025)
assert argv == [r'C:\p\pyrevit.exe', 'run', r'C:\s\run.py',
'--revit=2025', '--purge']
def test_pyrevit_run_argv_without_a_year_leaves_the_choice_to_pyrevit():
argv = S.pyrevit_run_argv(r'C:\p\pyrevit.exe', r'C:\s\run.py', None)
assert '--revit=2025' not in argv
assert not [a for a in argv if a.startswith('--revit')]
def test_quote_only_quotes_what_needs_it():
assert S.quote(['a', 'b c']) == 'a "b c"'
def test_quote_produces_a_pasteable_line_for_a_real_looking_command():
argv = S.pyrevit_run_argv(
r'D:\tools\pyRevit-Master\bin\pyrevit.exe',
r'D:\dev\pyrevit-dev\WillTools.extension\runners\detach_snapshot.py',
2025)
line = S.quote(argv)
assert line.count('"') == 0 # no spaces in either path
assert line.endswith('--revit=2025 --purge')
def test_quote_handles_a_space_in_the_extension_path():
line = S.quote([r'C:\p\pyrevit.exe', 'run', r'C:\side projects\run.py'])
assert '"C:\\side projects\\run.py"' in line
# ============================================================ the CYCLE
#
# Added 2026-09-01 with `tools/snapshot_cycle.py`. The old three phases are a
# close-model ritual; these events describe the pipeline that replaced its
# tail -- copy, cycle the sandbox Revit, run a batch, close. Same log file on
# purpose: a reader wants one ledger per job, not two to interleave by hand.
def test_the_sandbox_launch_is_revit_itself_not_the_pyrevit_runner():
"""`pyrevit run` plays a journal and quits, which is right for the
one-shot detach and wrong for a session a sweep has to drive for ten
minutes. The sandbox is an ordinary Revit opened on one file."""
argv = S.revit_exe_argv(r'C:\Autodesk\Revit.exe', r'C:\s\a' + S.MODEL_EXT)
assert argv == [r'C:\Autodesk\Revit.exe', r'C:\s\a' + S.MODEL_EXT]
assert 'run' not in argv
def test_the_revit_install_guess_is_a_guess_and_says_nothing_without_a_year():
assert S.revit_exe_guess(None) is None
guess = S.revit_exe_guess(2024)
assert guess.endswith('Revit.exe')
assert '2024' in guess
def test_each_batch_command_gets_its_own_recognisable_log():
"""A folder of batch-01.log, batch-02.log is a folder nobody opens
twice, so the command is in the name as well as in the file."""
name = S.batch_log_name(1, 'python tools/sweep.py')
assert name.startswith('batch-01')
assert name.endswith('.log')
assert 'sweep' in name
def test_a_batch_log_name_survives_a_command_full_of_punctuation():
name = S.batch_log_name(12, r'"C:\p y\python.exe" x.py --out=".\a b"')
assert name.startswith('batch-12')
assert name.endswith('.log')
for ch in '"\\/:*?<>|':
assert ch not in name
def test_a_very_long_command_does_not_become_a_very_long_filename():
name = S.batch_log_name(3, 'python ' + 'a' * 400)
assert len(name) < 80
def test_a_batch_log_name_with_no_usable_words_is_still_unique():
assert S.batch_log_name(7, '///') == 'batch-07.log'
assert S.batch_log_name(8, '') == 'batch-08.log'
def test_a_cycle_that_started_and_never_reported_is_outstanding():
"""The failure this event pair exists for: a cycle that died mid-run can
have left a Revit open on a 300 MB model with nobody in front of it, and
the next day looks exactly like a day it worked."""
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '2026-09-01 09:00',
'sandbox_pid': 4242}]
pending = S.outstanding_cycle(rows)
assert pending is not None
assert pending['run'] == 'c1'
assert 'WARNING' in ' '.join(S.cycle_summary(rows))
assert '4242' in ' '.join(S.cycle_summary(rows))
def test_a_cycle_that_reported_is_not_outstanding():
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '2026-09-01 09:00'},
{'event': S.CYCLE_DONE, 'run': 'c1', 'at': '2026-09-01 09:40',
'batch_count': 1, 'seconds': 2400}]
assert S.outstanding_cycle(rows) is None
text = ' '.join(S.cycle_summary(rows))
assert 'WARNING' not in text
assert '1 batch command' in text
def test_an_old_unfinished_cycle_is_not_papered_over_by_a_later_good_one():
"""Matched on the run id, not on position -- the same discipline
`outstanding()` has for phase C."""
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '09:00'},
{'event': S.CYCLE_START, 'run': 'c2', 'at': '10:00'},
{'event': S.CYCLE_DONE, 'run': 'c2', 'at': '10:40'}]
assert S.outstanding_cycle(rows)['run'] == 'c1'
def test_a_failed_cycle_is_reported_with_its_reason():
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '2026-09-01 09:00'},
{'event': S.CYCLE_FAILED, 'run': 'c1', 'at': '2026-09-01 09:05',
'error': 'the sandbox never answered'}]
text = ' '.join(S.cycle_summary(rows))
assert 'FAILED' in text
assert 'never answered' in text
def test_no_cycle_yet_says_so_rather_than_printing_nothing():
assert S.cycle_summary([]) == [
'no snapshot cycle has been recorded for this job yet']
def test_the_cycle_events_do_not_disturb_the_phase_ledger():
"""Both ledgers share one file, so each has to ignore the other's rows.
A cycle line must not read as a phase C that never reported."""
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '09:00'},
{'event': S.SANDBOX_OPENED, 'run': 'c1', 'pid': 1, 'at': '09:10'}]
assert S.outstanding(rows) is None
assert 'WARNING' not in ' '.join(S.summary(rows))
def test_the_two_ledgers_round_trip_through_one_log_file(tmp_path):
log = S.log_path(str(tmp_path))
S.append(log, {'event': S.REQUESTED, 'run': 'r1'})
S.append(log, {'event': S.CYCLE_START, 'run': 'c1', 'sandbox_pid': 9})
S.append(log, {'event': S.CYCLE_DONE, 'run': 'c1', 'batch_count': 2})
rows = S.read(log)
assert S.outstanding(rows)['run'] == 'r1' # phase C still pending
assert S.outstanding_cycle(rows) is None # the cycle closed