Skip to content

Commit 10bfdbd

Browse files
committed
[green] verify durable recovery across controller sessions
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 8c1deb4 commit 10bfdbd

1 file changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
import 'dart:io';
5+
6+
import 'package:drift/native.dart';
7+
import 'package:flutter_riverpod/flutter_riverpod.dart';
8+
import 'package:flutter_test/flutter_test.dart';
9+
import 'package:pyble/data/draft_repository.dart';
10+
import 'package:pyble/data/drift_draft_repository.dart';
11+
import 'package:pyble/editor/draft_recovery.dart';
12+
import 'package:pyble/editor/editor_document.dart';
13+
14+
void main() {
15+
late Directory directory;
16+
late File file;
17+
final List<ProviderContainer> containers = <ProviderContainer>[];
18+
final List<DraftRepository> repositories = <DraftRepository>[];
19+
20+
ProviderContainer container(DraftRepository repository) {
21+
final ProviderContainer result = ProviderContainer(
22+
overrides: <Override>[
23+
draftRepositoryProvider.overrideWithValue(repository),
24+
],
25+
);
26+
containers.add(result);
27+
return result;
28+
}
29+
30+
Future<DriftDraftRepository> open() async {
31+
final DriftDraftRepository repository = await DriftDraftRepository.open(
32+
file,
33+
);
34+
repositories.add(repository);
35+
return repository;
36+
}
37+
38+
setUp(() async {
39+
directory = await Directory.systemTemp.createTemp(
40+
'pyble-recovery-integration-',
41+
);
42+
file = File('${directory.path}/pyble.sqlite');
43+
});
44+
tearDown(() async {
45+
for (final ProviderContainer container in containers.reversed) {
46+
// Each container is disposed once below as soon as its app session ends.
47+
// ProviderContainer.dispose is idempotent for teardown fallback.
48+
container.dispose();
49+
}
50+
containers.clear();
51+
for (final DraftRepository repository in repositories.reversed) {
52+
await repository.close();
53+
}
54+
repositories.clear();
55+
await directory.delete(recursive: true);
56+
});
57+
58+
test(
59+
'checkpoint, reopen, explicit offline restore and New tombstone round-trip',
60+
() async {
61+
const String source = '# café 🐍\r\nprint("exact text")\n\t \n';
62+
final DriftDraftRepository first = await open();
63+
final ProviderContainer initial = container(first);
64+
final DraftRecoveryController writer = initial.read(
65+
draftRecoveryProvider.notifier,
66+
);
67+
await writer.start();
68+
initial
69+
.read(editorDocumentProvider.notifier)
70+
.openGenerated(name: 'generated.py', content: source);
71+
expect(await writer.saveRecoveryCopy(), isTrue);
72+
expect(
73+
initial.read(draftRecoveryProvider).status,
74+
DraftRecoveryStatus.saved,
75+
);
76+
expect((await first.load()).snapshot!.content, source);
77+
initial.dispose();
78+
await first.close();
79+
80+
final DriftDraftRepository second = await open();
81+
final ProviderContainer restarted = container(second);
82+
final DraftRecoveryController reader = restarted.read(
83+
draftRecoveryProvider.notifier,
84+
);
85+
await reader.start();
86+
expect(
87+
restarted.read(draftRecoveryProvider).status,
88+
DraftRecoveryStatus.decision,
89+
);
90+
expect(
91+
restarted.read(editorDocumentProvider),
92+
const EditorDocument.untitled(),
93+
);
94+
expect(restarted.read(draftRecoveryProvider).candidate!.content, source);
95+
expect(reader.restore(), isTrue);
96+
final EditorDocument restored = restarted.read(editorDocumentProvider);
97+
expect(restored.content, source);
98+
expect(restored.name, 'generated.py');
99+
expect(restored.dirty, isTrue);
100+
expect(restored.boardPath, isNull);
101+
expect(restarted.read(editorRecoveredDraftProvider), isTrue);
102+
expect(
103+
await reader.replaceDocument(
104+
const EditorDocument.untitled(),
105+
expectedEpoch: restarted.read(editorDocumentEpochProvider),
106+
),
107+
isTrue,
108+
);
109+
final int clearedGeneration = (await second.load()).generation;
110+
expect(clearedGeneration, greaterThan(1));
111+
expect((await second.load()).snapshot, isNull);
112+
restarted.dispose();
113+
await second.close();
114+
115+
final DriftDraftRepository third = await open();
116+
final ProviderContainer afterNew = container(third);
117+
await afterNew.read(draftRecoveryProvider.notifier).start();
118+
expect(
119+
afterNew.read(draftRecoveryProvider).status,
120+
DraftRecoveryStatus.saved,
121+
);
122+
expect(afterNew.read(draftRecoveryProvider).candidate, isNull);
123+
expect(
124+
afterNew.read(editorDocumentProvider),
125+
const EditorDocument.untitled(),
126+
);
127+
expect((await third.load()).generation, clearedGeneration);
128+
},
129+
);
130+
131+
test(
132+
'real SQLite full failure preserves live edits and previous recoverable text',
133+
() async {
134+
final DriftDraftRepository first = await open();
135+
final ProviderContainer initial = container(first);
136+
final DraftRecoveryController writer = initial.read(
137+
draftRecoveryProvider.notifier,
138+
);
139+
await writer.start();
140+
initial
141+
.read(editorDocumentProvider.notifier)
142+
.setContent('previous checkpoint');
143+
expect(await writer.saveRecoveryCopy(), isTrue);
144+
initial.dispose();
145+
await first.close();
146+
147+
final DriftDraftRepository limited = DriftDraftRepository.forTesting(
148+
NativeDatabase(
149+
file,
150+
setup: (db) {
151+
final int pages =
152+
db.select('PRAGMA page_count').single.values.single as int;
153+
db.execute('PRAGMA max_page_count = ${pages + 2}');
154+
},
155+
),
156+
);
157+
repositories.add(limited);
158+
final ProviderContainer editing = container(limited);
159+
final DraftRecoveryController recovery = editing.read(
160+
draftRecoveryProvider.notifier,
161+
);
162+
await recovery.start();
163+
expect(recovery.restore(), isTrue);
164+
final String source = 'x' * 100000;
165+
editing.read(editorDocumentProvider.notifier).setContent(source);
166+
expect(await recovery.saveRecoveryCopy(), isFalse);
167+
expect(editing.read(editorDocumentProvider).content, source);
168+
expect(
169+
editing.read(draftRecoveryProvider).status,
170+
DraftRecoveryStatus.failed,
171+
);
172+
expect(
173+
editing.read(draftRecoveryProvider).failure,
174+
DraftStorageFailure.unavailable,
175+
);
176+
expect((await limited.load()).snapshot!.content, 'previous checkpoint');
177+
editing.dispose();
178+
await limited.close();
179+
180+
final DriftDraftRepository reopened = await open();
181+
final ProviderContainer restarted = container(reopened);
182+
await restarted.read(draftRecoveryProvider.notifier).start();
183+
expect(
184+
restarted.read(draftRecoveryProvider).status,
185+
DraftRecoveryStatus.decision,
186+
);
187+
expect(
188+
restarted.read(draftRecoveryProvider).candidate!.content,
189+
'previous checkpoint',
190+
);
191+
},
192+
);
193+
}

0 commit comments

Comments
 (0)