diff --git a/tools/random_edit_campaign.py b/tools/random_edit_campaign.py index 1fef1080b28b5bbd34dc224e36a8e2a83dfc845a..0f1c187275d76fd0772c59901da5548d5fbc7387 100644 --- a/tools/random_edit_campaign.py +++ b/tools/random_edit_campaign.py @@ -25,7 +25,7 @@ def export(path): sorted(hashlib.sha256(p.read_bytes()).hexdigest() for p in (output / 'assets').iterdir())) -def verify(before, after, record): +def verify(before, after, record, *, author="Random editor"): old, old_text, old_assets = before new, new_text, new_assets = after assert old_assets == new_assets, 'An edit changed embedded payload bytes' @@ -90,9 +90,51 @@ def verify(before, after, record): else: parent = previous['nodes'][key] pending.extend((child, path + [key]) for child in parent['children'] + parent['content'] + parent['structure']) + assert '4' in current['roots'], 'An edit omitted revision attribution' + metadata_id = current['roots']['4'] + metadata = current['nodes'][metadata_id] + assert metadata['kind']['type'] == 'RevisionMetadata', 'Revision attribution has another object kind' + filetime = metadata['kind']['modified_filetime'] + assert record['started_ms'] * 10000 + 116444736000000000 <= filetime < (record['finished_ms'] + 1) * 10000 + 116444736000000000, 'Revision time falls outside the edit interval' + author_id = metadata['latest_author'] + assert author_id in current['nodes'], 'Revision attribution has no author object' + blank = { + 'children': [], 'content': [], 'structure': [], 'spaces': [], + 'child_level': None, 'layout': {key: None for key in node['layout']}, + 'format': {key: None for key in node['format']}, 'created': None, 'modified': None, + 'original_author': None, 'latest_author': None, 'media_ids': [], + 'media_time_ms': None, 'tags': [], 'extra': [[]], + } + existing_authors = {key for key, value in previous['nodes'].items() + if value['kind'] == {'type': 'Author', 'name': author}} + assert not existing_authors or author_id in existing_authors, 'An edit duplicated an existing author' + if author_id not in previous['nodes']: + initials = ''.join(word[0].upper() for word in author.split()) + revision['nodes'][author_id] = {**copy.deepcopy(blank), 'jcid': 0x120001, + 'kind': {'type': 'Author', 'name': author}, + 'extra': [[{'id': 0x1c001df8, 'value': {'Bytes': (initials + '\0').encode('utf-16-le').hex()}}]]} + assert current['nodes'][author_id]['kind'] == {'type': 'Author', 'name': author}, 'The edit names another author' + if '4' in previous['roots']: + assert metadata_id == previous['roots']['4'], 'An edit replaced existing revision metadata identity' + else: + assert metadata_id not in previous['nodes'], 'Revision metadata reused another object identity' + revision['roots']['4'] = metadata_id + revision['nodes'][metadata_id] = {**copy.deepcopy(blank), 'jcid': 0x20044, + 'kind': {'type': 'RevisionMetadata', 'modified_filetime': None}} + detached_authors = {revision['nodes'][metadata_id]['latest_author']} + revision['nodes'][metadata_id]['latest_author'] = author_id + revision['nodes'][metadata_id]['kind']['modified_filetime'] = filetime for key in ancestors: if previous['nodes'][key]['modified'] is not None: revision['nodes'][key]['modified'] = changed['modified'] + if previous['nodes'][key]['kind']['type'] == 'Paragraph': + detached_authors.add(revision['nodes'][key]['latest_author']) + revision['nodes'][key]['latest_author'] = author_id + revision['nodes'][key]['modified'] = filetime // 10000000 - 11960006400 + for key in detached_authors - {None, author_id}: + assert revision['nodes'][key]['kind']['type'] == 'Author' + if not any(json.dumps(key) in json.dumps(value) for other, value in revision['nodes'].items() if other != key): + del revision['nodes'][key] del node['modified'], changed['modified'] del node['kind']['runs'], changed['kind']['runs'] assert expected == observed, 'An edit changed unrelated document structure, metadata or properties' diff --git a/tools/test_cargo_fresh.py b/tools/test_cargo_fresh.py index a56c1513be45b61c8f61ebc0e4c50021b58c27ae..8fe8d6319b63181ca083cf8f370155f8070b07e4 100644 --- a/tools/test_cargo_fresh.py +++ b/tools/test_cargo_fresh.py @@ -94,15 +94,18 @@ class CargoFreshTest(unittest.TestCase): def test_clean_failure_stops_ci_before_concurrent_lanes(self): with tempfile.TemporaryDirectory(prefix='snowbound-ci-clean-') as folder, \ patch.object(ci, 'RUNS', Path(folder)), \ + patch.object(ci, 'CI', Path(folder) / 'checkout'), \ patch.object(ci, 'lanes', return_value=[{'name': 'test'}]), \ patch.object(ci, 'checkout', return_value='commit'), \ patch.object(ci, 'jj', return_value='fixture'), \ patch.object(ci, 'local_packages', return_value=['fixture']), \ patch.object(ci.sys, 'argv', ['ci.py']), \ - patch.object(ci.Run, 'execute', return_value=101), \ + patch.object(ci.Run, 'execute', return_value=101) as execute, \ patch.object(ci, 'ThreadPoolExecutor') as pool: with self.assertRaisesRegex(SystemExit, 'Workspace cleanup failed'): ci.main() + execute.assert_called_once() + self.assertEqual(execute.call_args.args[0], ['cargo', 'clean', '--locked', '-p', 'fixture']) pool.assert_not_called() diff --git a/tools/test_notebook_editor.py b/tools/test_notebook_editor.py index a92e5019b12c5d13ba73d3413f62ab42e415bc1f..3f0a8f69c52a4b299736772c421317d918770a52 100644 --- a/tools/test_notebook_editor.py +++ b/tools/test_notebook_editor.py @@ -59,7 +59,7 @@ class EditorTest(unittest.TestCase): edit = intent['edit'] verify(self.before, export(self.file), {'page': self.row['object'], 'space': edit['space'], 'object': self.oid, 'range': [edit['action']['start'], edit['action']['end']], 'replacement': replacement, 'run_start': edit['action']['start'], - 'run_before': self.text, 'started_ms': intent['started_ms'], 'finished_ms': outcome['finished_ms']}) + 'run_before': self.text, 'started_ms': intent['started_ms'], 'finished_ms': outcome['finished_ms']}, author='') after = self.file.read_bytes() fresh = self.session.output / 'fresh-report' editor.generate(self.session.output / 'g/1/snapshot', fresh, editable=True) diff --git a/tools/test_random_edit_campaign.py b/tools/test_random_edit_campaign.py index a2190fabbac427fe9515aa77d5b6b625ef53f600..bf9829453d988941dce303588cfef594c277fc47 100644 --- a/tools/test_random_edit_campaign.py +++ b/tools/test_random_edit_campaign.py @@ -5,7 +5,7 @@ import subprocess import tempfile import unittest from random_edit_campaign import EDITOR, ROOT, export, verify -from document_model import ordered_pages +from document_model import ordered_pages, view class RandomEditOracleTests(unittest.TestCase): @@ -35,13 +35,28 @@ class RandomEditOracleTests(unittest.TestCase): verify(before, after, record) sid, oid = record['space'], record['object'] rid = after[0]['spaces'][sid]['contexts']['{00000000-0000-0000-0000-000000000000},0'] - for kind in ['format', 'metadata', 'payload', 'structure', 'ancestor-time', 'unrelated-time']: + for kind in ['format', 'metadata', 'payload', 'structure', 'ancestor-time', 'unrelated-time', + 'revision-time', 'revision-author', 'revision-extra', 'missing-attribution', + 'author-name', 'author-initials', 'paragraph-author', 'original-author']: damaged = copy.deepcopy(after) if kind == 'format': damaged[1][sid][rid][oid][0]['format']['bold'] = not damaged[1][sid][rid][oid][0]['format']['bold'] if kind == 'metadata': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][oid]['created'] = 0 if kind == 'payload': damaged[2].append('another payload') if kind == 'structure': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][oid]['children'].append('another object') if kind == 'ancestor-time': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][record['page']]['modified'] = 0 + nodes = damaged[0]['spaces'][sid]['revisions'][rid]['nodes'] + metadata = nodes[after[0]['spaces'][sid]['revisions'][rid]['roots']['4']] + author_id = metadata['latest_author'] + paragraph = next(key for key, node in nodes.items() + if node['kind']['type'] == 'Paragraph' and oid in node['content']) + if kind == 'revision-time': metadata['kind']['modified_filetime'] = 0 + if kind == 'revision-author': metadata['latest_author'] = paragraph + if kind == 'revision-extra': metadata['extra'][0].append({'id': 123, 'value': {'Bytes': '00'}}) + if kind == 'missing-attribution': del damaged[0]['spaces'][sid]['revisions'][rid]['roots']['4'] + if kind == 'author-name': nodes[author_id]['kind']['name'] = 'Another author' + if kind == 'author-initials': nodes[author_id]['extra'][0][0]['value']['Bytes'] = '0000' + if kind == 'paragraph-author': nodes[paragraph]['latest_author'] = nodes[paragraph]['original_author'] + if kind == 'original-author': nodes[paragraph]['original_author'] = author_id if kind == 'unrelated-time': old_rid = before[0]['spaces'][sid]['contexts']['{00000000-0000-0000-0000-000000000000},0'] old = before[0]['spaces'][sid]['revisions'][old_rid]['nodes'] @@ -50,6 +65,27 @@ class RandomEditOracleTests(unittest.TestCase): nodes[key]['modified'] = 0 with self.assertRaises(AssertionError, msg=kind): verify(before, damaged, record) + def test_repeated_edits_reuse_attribution_and_preserve_existing_author_data(self): + source = ROOT / 'corpus/native/20260905-05/snapshots/06-attachment/notebook/synthetic.one' + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / 'edited.one' + record = json.loads(subprocess.check_output([EDITOR, source, destination, '42'])) + before = export(destination) + _, previous = view(before[0], record['space']) + author_id = previous['nodes'][previous['roots']['4']]['latest_author'] + record = json.loads(subprocess.check_output([EDITOR, destination, '--in-place', '43', record['page']])) + after = export(destination) + verify(before, after, record) + _, current = view(after[0], record['space']) + self.assertEqual(current['roots']['4'], previous['roots']['4']) + self.assertEqual(current['nodes'][current['roots']['4']]['latest_author'], author_id) + self.assertEqual(current['nodes'][author_id], previous['nodes'][author_id]) + damaged = copy.deepcopy(after) + _, damaged_revision = view(damaged[0], record['space']) + damaged_revision['nodes'][author_id]['extra'][0][0]['value']['Bytes'] = '0000' + with self.assertRaises(AssertionError): + verify(before, damaged, record) + def test_seeded_edits_work_on_native_legacy_text(self): source = ROOT / 'corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one' with tempfile.TemporaryDirectory() as temporary: