# -*- 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 `` + 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