authorclover caruso <git@paperclover.net> 2026-10-10 20:24:48-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:06-07:00
log142c8f9c9d68cacd0497db0f916de8d72514d52c
tree0fdb5c98f8de6ed07c5a964fd4e74d6d476a51df
parent550d71c9d8ee053ba3bf16b0269e6bdac3352136
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

test: verify intended edit attribution and isolate CI cleanup failure

Keep the random-edit graph oracle strict while expecting revision FILETIME, the named author object, and attribution only on edited paragraphs. Check new author initials and preserve existing author/metadata fields; reject damaged attribution as well as unrelated content. Exercise repeated edits that reuse attribution. Run the cleanup-failure test with its CI boundary inside the disposable fixture and assert the package-scoped clean command is reached before any concurrent lane starts. Assisted-by: gpt-6.1-sol

4 files changed, 86 insertions(+), 5 deletions(-)

tools/random_edit_campaign.py+43-1
...@@ -25,7 +25,7 @@ def export(path):...@@ -25,7 +25,7 @@ def export(path):
25 sorted(hashlib.sha256(p.read_bytes()).hexdigest() for p in (output / 'assets').iterdir()))25 sorted(hashlib.sha256(p.read_bytes()).hexdigest() for p in (output / 'assets').iterdir()))
2626
2727
28def verify(before, after, record):28def verify(before, after, record, *, author="Random editor"):
29 old, old_text, old_assets = before29 old, old_text, old_assets = before
30 new, new_text, new_assets = after30 new, new_text, new_assets = after
31 assert old_assets == new_assets, 'An edit changed embedded payload bytes'31 assert old_assets == new_assets, 'An edit changed embedded payload bytes'
...@@ -90,9 +90,51 @@ def verify(before, after, record):...@@ -90,9 +90,51 @@ def verify(before, after, record):
90 else:90 else:
91 parent = previous['nodes'][key]91 parent = previous['nodes'][key]
92 pending.extend((child, path + [key]) for child in parent['children'] + parent['content'] + parent['structure'])92 pending.extend((child, path + [key]) for child in parent['children'] + parent['content'] + parent['structure'])
93 assert '4' in current['roots'], 'An edit omitted revision attribution'
94 metadata_id = current['roots']['4']
95 metadata = current['nodes'][metadata_id]
96 assert metadata['kind']['type'] == 'RevisionMetadata', 'Revision attribution has another object kind'
97 filetime = metadata['kind']['modified_filetime']
98 assert record['started_ms'] * 10000 + 116444736000000000 <= filetime < (record['finished_ms'] + 1) * 10000 + 116444736000000000, 'Revision time falls outside the edit interval'
99 author_id = metadata['latest_author']
100 assert author_id in current['nodes'], 'Revision attribution has no author object'
101 blank = {
102 'children': [], 'content': [], 'structure': [], 'spaces': [],
103 'child_level': None, 'layout': {key: None for key in node['layout']},
104 'format': {key: None for key in node['format']}, 'created': None, 'modified': None,
105 'original_author': None, 'latest_author': None, 'media_ids': [],
106 'media_time_ms': None, 'tags': [], 'extra': [[]],
107 }
108 existing_authors = {key for key, value in previous['nodes'].items()
109 if value['kind'] == {'type': 'Author', 'name': author}}
110 assert not existing_authors or author_id in existing_authors, 'An edit duplicated an existing author'
111 if author_id not in previous['nodes']:
112 initials = ''.join(word[0].upper() for word in author.split())
113 revision['nodes'][author_id] = {**copy.deepcopy(blank), 'jcid': 0x120001,
114 'kind': {'type': 'Author', 'name': author},
115 'extra': [[{'id': 0x1c001df8, 'value': {'Bytes': (initials + '\0').encode('utf-16-le').hex()}}]]}
116 assert current['nodes'][author_id]['kind'] == {'type': 'Author', 'name': author}, 'The edit names another author'
117 if '4' in previous['roots']:
118 assert metadata_id == previous['roots']['4'], 'An edit replaced existing revision metadata identity'
119 else:
120 assert metadata_id not in previous['nodes'], 'Revision metadata reused another object identity'
121 revision['roots']['4'] = metadata_id
122 revision['nodes'][metadata_id] = {**copy.deepcopy(blank), 'jcid': 0x20044,
123 'kind': {'type': 'RevisionMetadata', 'modified_filetime': None}}
124 detached_authors = {revision['nodes'][metadata_id]['latest_author']}
125 revision['nodes'][metadata_id]['latest_author'] = author_id
126 revision['nodes'][metadata_id]['kind']['modified_filetime'] = filetime
93 for key in ancestors:127 for key in ancestors:
94 if previous['nodes'][key]['modified'] is not None:128 if previous['nodes'][key]['modified'] is not None:
95 revision['nodes'][key]['modified'] = changed['modified']129 revision['nodes'][key]['modified'] = changed['modified']
130 if previous['nodes'][key]['kind']['type'] == 'Paragraph':
131 detached_authors.add(revision['nodes'][key]['latest_author'])
132 revision['nodes'][key]['latest_author'] = author_id
133 revision['nodes'][key]['modified'] = filetime // 10000000 - 11960006400
134 for key in detached_authors - {None, author_id}:
135 assert revision['nodes'][key]['kind']['type'] == 'Author'
136 if not any(json.dumps(key) in json.dumps(value) for other, value in revision['nodes'].items() if other != key):
137 del revision['nodes'][key]
96 del node['modified'], changed['modified']138 del node['modified'], changed['modified']
97 del node['kind']['runs'], changed['kind']['runs']139 del node['kind']['runs'], changed['kind']['runs']
98 assert expected == observed, 'An edit changed unrelated document structure, metadata or properties'140 assert expected == observed, 'An edit changed unrelated document structure, metadata or properties'
tools/test_cargo_fresh.py+4-1
...@@ -94,15 +94,18 @@ class CargoFreshTest(unittest.TestCase):...@@ -94,15 +94,18 @@ class CargoFreshTest(unittest.TestCase):
94 def test_clean_failure_stops_ci_before_concurrent_lanes(self):94 def test_clean_failure_stops_ci_before_concurrent_lanes(self):
95 with tempfile.TemporaryDirectory(prefix='snowbound-ci-clean-') as folder, \95 with tempfile.TemporaryDirectory(prefix='snowbound-ci-clean-') as folder, \
96 patch.object(ci, 'RUNS', Path(folder)), \96 patch.object(ci, 'RUNS', Path(folder)), \
97 patch.object(ci, 'CI', Path(folder) / 'checkout'), \
97 patch.object(ci, 'lanes', return_value=[{'name': 'test'}]), \98 patch.object(ci, 'lanes', return_value=[{'name': 'test'}]), \
98 patch.object(ci, 'checkout', return_value='commit'), \99 patch.object(ci, 'checkout', return_value='commit'), \
99 patch.object(ci, 'jj', return_value='fixture'), \100 patch.object(ci, 'jj', return_value='fixture'), \
100 patch.object(ci, 'local_packages', return_value=['fixture']), \101 patch.object(ci, 'local_packages', return_value=['fixture']), \
101 patch.object(ci.sys, 'argv', ['ci.py']), \102 patch.object(ci.sys, 'argv', ['ci.py']), \
102 patch.object(ci.Run, 'execute', return_value=101), \103 patch.object(ci.Run, 'execute', return_value=101) as execute, \
103 patch.object(ci, 'ThreadPoolExecutor') as pool:104 patch.object(ci, 'ThreadPoolExecutor') as pool:
104 with self.assertRaisesRegex(SystemExit, 'Workspace cleanup failed'):105 with self.assertRaisesRegex(SystemExit, 'Workspace cleanup failed'):
105 ci.main()106 ci.main()
107 execute.assert_called_once()
108 self.assertEqual(execute.call_args.args[0], ['cargo', 'clean', '--locked', '-p', 'fixture'])
106 pool.assert_not_called()109 pool.assert_not_called()
107110
108111
tools/test_notebook_editor.py+1-1
...@@ -59,7 +59,7 @@ class EditorTest(unittest.TestCase):...@@ -59,7 +59,7 @@ class EditorTest(unittest.TestCase):
59 edit = intent['edit']59 edit = intent['edit']
60 verify(self.before, export(self.file), {'page': self.row['object'], 'space': edit['space'], 'object': self.oid,60 verify(self.before, export(self.file), {'page': self.row['object'], 'space': edit['space'], 'object': self.oid,
61 'range': [edit['action']['start'], edit['action']['end']], 'replacement': replacement, 'run_start': edit['action']['start'],61 'range': [edit['action']['start'], edit['action']['end']], 'replacement': replacement, 'run_start': edit['action']['start'],
62 'run_before': self.text, 'started_ms': intent['started_ms'], 'finished_ms': outcome['finished_ms']})62 'run_before': self.text, 'started_ms': intent['started_ms'], 'finished_ms': outcome['finished_ms']}, author='')
63 after = self.file.read_bytes()63 after = self.file.read_bytes()
64 fresh = self.session.output / 'fresh-report'64 fresh = self.session.output / 'fresh-report'
65 editor.generate(self.session.output / 'g/1/snapshot', fresh, editable=True)65 editor.generate(self.session.output / 'g/1/snapshot', fresh, editable=True)
tools/test_random_edit_campaign.py+38-2
...@@ -5,7 +5,7 @@ import subprocess...@@ -5,7 +5,7 @@ import subprocess
5import tempfile5import tempfile
6import unittest6import unittest
7from random_edit_campaign import EDITOR, ROOT, export, verify7from random_edit_campaign import EDITOR, ROOT, export, verify
8from document_model import ordered_pages8from document_model import ordered_pages, view
99
1010
11class RandomEditOracleTests(unittest.TestCase):11class RandomEditOracleTests(unittest.TestCase):
...@@ -35,13 +35,28 @@ class RandomEditOracleTests(unittest.TestCase):...@@ -35,13 +35,28 @@ class RandomEditOracleTests(unittest.TestCase):
35 verify(before, after, record)35 verify(before, after, record)
36 sid, oid = record['space'], record['object']36 sid, oid = record['space'], record['object']
37 rid = after[0]['spaces'][sid]['contexts']['{00000000-0000-0000-0000-000000000000},0']37 rid = after[0]['spaces'][sid]['contexts']['{00000000-0000-0000-0000-000000000000},0']
38 for kind in ['format', 'metadata', 'payload', 'structure', 'ancestor-time', 'unrelated-time']:38 for kind in ['format', 'metadata', 'payload', 'structure', 'ancestor-time', 'unrelated-time',
39 'revision-time', 'revision-author', 'revision-extra', 'missing-attribution',
40 'author-name', 'author-initials', 'paragraph-author', 'original-author']:
39 damaged = copy.deepcopy(after)41 damaged = copy.deepcopy(after)
40 if kind == 'format': damaged[1][sid][rid][oid][0]['format']['bold'] = not damaged[1][sid][rid][oid][0]['format']['bold']42 if kind == 'format': damaged[1][sid][rid][oid][0]['format']['bold'] = not damaged[1][sid][rid][oid][0]['format']['bold']
41 if kind == 'metadata': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][oid]['created'] = 043 if kind == 'metadata': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][oid]['created'] = 0
42 if kind == 'payload': damaged[2].append('another payload')44 if kind == 'payload': damaged[2].append('another payload')
43 if kind == 'structure': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][oid]['children'].append('another object')45 if kind == 'structure': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][oid]['children'].append('another object')
44 if kind == 'ancestor-time': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][record['page']]['modified'] = 046 if kind == 'ancestor-time': damaged[0]['spaces'][sid]['revisions'][rid]['nodes'][record['page']]['modified'] = 0
47 nodes = damaged[0]['spaces'][sid]['revisions'][rid]['nodes']
48 metadata = nodes[after[0]['spaces'][sid]['revisions'][rid]['roots']['4']]
49 author_id = metadata['latest_author']
50 paragraph = next(key for key, node in nodes.items()
51 if node['kind']['type'] == 'Paragraph' and oid in node['content'])
52 if kind == 'revision-time': metadata['kind']['modified_filetime'] = 0
53 if kind == 'revision-author': metadata['latest_author'] = paragraph
54 if kind == 'revision-extra': metadata['extra'][0].append({'id': 123, 'value': {'Bytes': '00'}})
55 if kind == 'missing-attribution': del damaged[0]['spaces'][sid]['revisions'][rid]['roots']['4']
56 if kind == 'author-name': nodes[author_id]['kind']['name'] = 'Another author'
57 if kind == 'author-initials': nodes[author_id]['extra'][0][0]['value']['Bytes'] = '0000'
58 if kind == 'paragraph-author': nodes[paragraph]['latest_author'] = nodes[paragraph]['original_author']
59 if kind == 'original-author': nodes[paragraph]['original_author'] = author_id
45 if kind == 'unrelated-time':60 if kind == 'unrelated-time':
46 old_rid = before[0]['spaces'][sid]['contexts']['{00000000-0000-0000-0000-000000000000},0']61 old_rid = before[0]['spaces'][sid]['contexts']['{00000000-0000-0000-0000-000000000000},0']
47 old = before[0]['spaces'][sid]['revisions'][old_rid]['nodes']62 old = before[0]['spaces'][sid]['revisions'][old_rid]['nodes']
...@@ -50,6 +65,27 @@ class RandomEditOracleTests(unittest.TestCase):...@@ -50,6 +65,27 @@ class RandomEditOracleTests(unittest.TestCase):
50 nodes[key]['modified'] = 065 nodes[key]['modified'] = 0
51 with self.assertRaises(AssertionError, msg=kind): verify(before, damaged, record)66 with self.assertRaises(AssertionError, msg=kind): verify(before, damaged, record)
5267
68 def test_repeated_edits_reuse_attribution_and_preserve_existing_author_data(self):
69 source = ROOT / 'corpus/native/20260905-05/snapshots/06-attachment/notebook/synthetic.one'
70 with tempfile.TemporaryDirectory() as temporary:
71 destination = Path(temporary) / 'edited.one'
72 record = json.loads(subprocess.check_output([EDITOR, source, destination, '42']))
73 before = export(destination)
74 _, previous = view(before[0], record['space'])
75 author_id = previous['nodes'][previous['roots']['4']]['latest_author']
76 record = json.loads(subprocess.check_output([EDITOR, destination, '--in-place', '43', record['page']]))
77 after = export(destination)
78 verify(before, after, record)
79 _, current = view(after[0], record['space'])
80 self.assertEqual(current['roots']['4'], previous['roots']['4'])
81 self.assertEqual(current['nodes'][current['roots']['4']]['latest_author'], author_id)
82 self.assertEqual(current['nodes'][author_id], previous['nodes'][author_id])
83 damaged = copy.deepcopy(after)
84 _, damaged_revision = view(damaged[0], record['space'])
85 damaged_revision['nodes'][author_id]['extra'][0][0]['value']['Bytes'] = '0000'
86 with self.assertRaises(AssertionError):
87 verify(before, damaged, record)
88
53 def test_seeded_edits_work_on_native_legacy_text(self):89 def test_seeded_edits_work_on_native_legacy_text(self):
54 source = ROOT / 'corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one'90 source = ROOT / 'corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one'
55 with tempfile.TemporaryDirectory() as temporary:91 with tempfile.TemporaryDirectory() as temporary: