1#!/usr/bin/env python3
2"""Verify captured SMB collaboration and recovery against native cold reads."""
3from collections import defaultdict
4import hashlib
5import json
6from pathlib import Path
7import subprocess
8import xml.etree.ElementTree as ET
9from native_xml import ns, pages, texts
10
11root = Path(__file__).resolve().parent.parent
12corpus = root / 'corpus/collaboration/round-01'
13for item in json.loads((corpus / 'manifest.json').read_text())['files']:
14 data = (corpus / item['path']).read_bytes()
15 assert len(data) == item['bytes']
16 assert hashlib.sha256(data).hexdigest() == item['sha256'], item['path']
17
18expected = {
19 'fault-01': ['A notebook created in Rust.'],
20 'fault-02': ['Publication survived a lost reply.'],
21 'fault-03': ['Cleanup survived a lost reply.'],
22 'live-01': ['Live Rust: café, 東京, مرحبا', 'Rust same paragraph.', 'Left cell', 'Right cell'],
23 'offline-02': ['Rust edited during the native outage.'],
24 'contention-01': ['Native edit waited for the lock.'],
25}
26for name, text in expected.items():
27 captured = pages(corpus / 'native' / f'stage5-cold-{name}' / 'read')
28 assert len(captured) == 1 and texts(captured[0]) == text, name
29
30subprocess.run(['cargo', 'build', '-p', 'onestore', '--quiet', '--example', 'inventory'], cwd=root, check=True)
31for case, main, conflict in [
32 ('live', 'Rust same paragraph.', 'Native same paragraph.'),
33 ('offline', 'Rust edited during the native outage.', 'Native edited while disconnected.'),
34]:
35 output = subprocess.check_output([root / 'target/debug/examples/inventory', corpus / case / 'notebook/synthetic.one'], text=True)
36 by_space = defaultdict(list)
37 metadata = {}
38 for row in output.splitlines():
39 kind, space, value = row.split('\t')
40 if kind == 'page':
41 metadata[space] = int(value, 16)
42 elif kind in ('ascii', 'unicode'):
43 by_space[space].append(bytes.fromhex(value).decode('ascii' if kind == 'ascii' else 'utf-16-le').rstrip('\0'))
44 assert sum(metadata.get(space) == 0x20030 and main in text for space, text in by_space.items()) == 1
45 assert any(metadata.get(space) == 0x20038 and conflict in text for space, text in by_space.items())
46
47before = ET.parse(corpus / 'live/before-native-edit.xml').getroot()
48merged = ET.parse(corpus / 'live/merged.xml').getroot()
49assert 'Fictitious: café, 東京, مرحبا' in texts(before)
50assert texts(merged) == ['Live Rust: café, 東京, مرحبا', 'Native edited the other outline.', 'Left cell', 'Right cell']
51
52# Cache identities and edit timestamps change across native COM saves.
53def content(node):
54 attrs = {key: value for key, value in node.attrib.items()
55 if key not in ('objectID', 'pathCache', 'creationTime', 'lastModifiedTime')}
56 return node.tag, sorted(attrs.items()), (node.text or '').strip(), [content(child) for child in node]
57
58original = pages(root / 'corpus/native-ink/cold-ui-ink/read')[0]
59final = pages(corpus / 'native/stage5-cold-live-01/read')[0]
60# These exact geometry changes appeared in the first native COM merge capture.
61ink_position = original.findall('one:InkDrawing/one:Position', ns)[1]
62assert ink_position.get('z') == '7'
63assert merged.findall('one:InkDrawing/one:Position', ns)[1].get('z') == '6'
64ink_position.set('z', '6')
65column = original.find('.//one:Table/one:Columns/one:Column', ns)
66assert column.get('width') == '39.14614105224609'
67assert merged.find('.//one:Table/one:Columns/one:Column', ns).get('width') == '38.61000061035156'
68column.set('width', '38.61000061035156')
69for tag in ('Image', 'InkDrawing', 'Table', 'InsertedFile'):
70 old = [content(node) for node in original.findall(f'.//one:{tag}', ns)]
71 new = [content(node) for node in final.findall(f'.//one:{tag}', ns)]
72 assert old and old == new, tag
73assert [content(node) for node in original.findall('one:QuickStyleDef', ns)] == [content(node) for node in final.findall('one:QuickStyleDef', ns)]
74assert [node.attrib for node in original.findall('one:Outline/one:Position', ns)] == [node.attrib for node in final.findall('one:Outline/one:Position', ns)]
75assert next((root / 'corpus/native-ink/cold-ui-ink/read').glob('*.attachment')).read_bytes() == next((corpus / 'native/stage5-cold-live-01/read').glob('*.attachment')).read_bytes()
76
77lock = json.loads((corpus / 'contention/lock-result.json').read_text())
78assert lock['changed_while_locked'] is False
79assert lock['before_sha256'] == lock['after_sha256']
80assert hashlib.sha256((corpus / 'contention/notebook/synthetic.one').read_bytes()).hexdigest() != lock['after_sha256']
81assert 'write' in (corpus / 'contention/locks.txt').read_text()
82assert json.loads((corpus / 'contention/controller/inbox/0002.json').read_text())['text'] == expected['contention-01'][0]
83for action in (2, 3):
84 assert (corpus / f'contention/controller/outbox/{action}/done').read_text() == 'ok'
85
86trace = [json.loads(line) for line in (corpus / 'smb-faults.jsonl').read_text().splitlines()]
87cuts = [i for i, row in enumerate(trace) if 'cut' in row]
88assert len(cuts) == 3
89start = 0
90for end, counter, state, case in zip(cuts, (1, 2, 256), ('NotCommitted', 'Unknown', 'Committed'), ('fault-01', 'fault-02', 'fault-03')):
91 cut = trace[end]['cut']
92 assert cut['command'] == 7 and cut['direction'] == 'response' and cut['status'] == '0x0'
93 segment = trace[start:end]
94 assert [row['transactions'] for row in segment if 'transactions' in row][-1] == counter
95 assert any([0, 2**64 - 1, 18] in row.get('locks', []) for row in segment)
96 result = json.loads((corpus / case / 'result.json').read_text())
97 assert result['exit'] != 0
98 assert result.get('state') == state or f'state: {state},' in result.get('stderr', '')
99 if counter == 256:
100 publication = next(i for i, row in enumerate(segment) if row.get('transactions') == 511)
101 cleanup = next(i for i, row in enumerate(segment) if row.get('transactions') == 256)
102 assert any(row.get('direction') == 'response' and row.get('command') == 7 and row.get('status') == '0x0' for row in segment[publication + 1:cleanup])
103 start = end + 1
104print('Passed: native lock contention, disjoint merge, retained conflict edits, offline convergence, three lost-FLUSH outcomes, and cold native content preservation')