8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
469 lines
18 KiB
Python
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
|