Skip to content

Commit 322e153

Browse files
committed
[green] verify explicit volatile replacement consent guards
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 10bfdbd commit 322e153

1 file changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
import 'dart:async';
5+
6+
import 'package:flutter_riverpod/flutter_riverpod.dart';
7+
import 'package:flutter_test/flutter_test.dart';
8+
import 'package:pyble/data/draft_repository.dart';
9+
import 'package:pyble/editor/board_target_guard.dart';
10+
import 'package:pyble/editor/draft_recovery.dart';
11+
import 'package:pyble/editor/editor_document.dart';
12+
13+
/// A previously committed slot whose storage becomes unavailable for writes.
14+
/// Failure must never be interpreted as permission to delete the old copy.
15+
class _UnavailableStore implements DraftRepository {
16+
final DraftSnapshot snapshot = DraftSnapshot(
17+
name: 'retained.py',
18+
content: 'previous exact text',
19+
originPath: '/lib/retained.py',
20+
generation: 7,
21+
checkpointTime: DateTime.utc(2026, 10, 31),
22+
);
23+
int writes = 0;
24+
int clears = 0;
25+
26+
@override
27+
Future<DraftRead> load() async =>
28+
DraftRead(generation: 7, snapshot: snapshot);
29+
30+
@override
31+
Future<void> write(
32+
DraftSnapshot snapshot, {
33+
required int expectedGeneration,
34+
}) async {
35+
writes++;
36+
throw const DraftStorageException(DraftStorageFailure.unavailable);
37+
}
38+
39+
@override
40+
Future<int> clear({required int expectedGeneration}) async {
41+
clears++;
42+
throw const DraftStorageException(DraftStorageFailure.unavailable);
43+
}
44+
45+
@override
46+
Future<void> close() async {}
47+
}
48+
49+
void main() {
50+
const EditorDocument replacement = EditorDocument(
51+
name: 'replacement.py',
52+
content: 'replacement text',
53+
dirty: false,
54+
boardPath: '/replacement.py',
55+
);
56+
57+
Future<ProviderContainer> setup(
58+
_UnavailableStore store,
59+
Future<bool> Function()? confirm,
60+
) async {
61+
final ProviderContainer container = ProviderContainer(
62+
overrides: <Override>[
63+
draftRepositoryProvider.overrideWithValue(store),
64+
editorVolatileReplacementConfirmationProvider.overrideWithValue(
65+
confirm,
66+
),
67+
],
68+
);
69+
addTearDown(container.dispose);
70+
final DraftRecoveryController recovery = container.read(
71+
draftRecoveryProvider.notifier,
72+
);
73+
await recovery.start();
74+
expect(recovery.restore(), isTrue);
75+
return container;
76+
}
77+
78+
Future<bool> replace(
79+
ProviderContainer container, {
80+
bool Function()? isStillValid,
81+
}) {
82+
final int epoch = container.read(editorDocumentEpochProvider);
83+
// The app's helper deliberately takes a Ref. This small provider exercises
84+
// that public seam without mounting widgets or creating a board transport.
85+
final Provider<Future<bool>> action = Provider<Future<bool>>(
86+
(Ref ref) => Future<bool>.microtask(
87+
() => replaceEditorDocument(
88+
ref,
89+
replacement,
90+
expectedEpoch: epoch,
91+
isStillValid: isStillValid,
92+
),
93+
),
94+
);
95+
return container.read(action);
96+
}
97+
98+
void expectRetained(_UnavailableStore store) {
99+
expect(store.snapshot.content, 'previous exact text');
100+
expect(store.snapshot.generation, 7);
101+
expect(store.clears, 0);
102+
}
103+
104+
test(
105+
'failed replacement keeps live text until volatile choice is declined',
106+
() async {
107+
final _UnavailableStore store = _UnavailableStore();
108+
final Completer<void> asked = Completer<void>();
109+
final Completer<bool> choice = Completer<bool>();
110+
int prompts = 0;
111+
final ProviderContainer container = await setup(store, () {
112+
prompts++;
113+
asked.complete();
114+
return choice.future;
115+
});
116+
final Future<bool> replacing = replace(container);
117+
await asked.future;
118+
expect(
119+
container.read(editorDocumentProvider).content,
120+
store.snapshot.content,
121+
);
122+
expect(
123+
container.read(draftRecoveryProvider).status,
124+
DraftRecoveryStatus.failed,
125+
);
126+
expectRetained(store);
127+
choice.complete(false);
128+
expect(await replacing, isFalse);
129+
expect(
130+
container.read(editorDocumentProvider).content,
131+
store.snapshot.content,
132+
);
133+
expect(
134+
container.read(draftRecoveryProvider).status,
135+
DraftRecoveryStatus.failed,
136+
);
137+
expect(store.writes, 1);
138+
expect(prompts, 1);
139+
expectRetained(store);
140+
},
141+
);
142+
143+
test(
144+
'explicit volatile acceptance adopts replacement and preserves stored copy',
145+
() async {
146+
final _UnavailableStore store = _UnavailableStore();
147+
int prompts = 0;
148+
final ProviderContainer container = await setup(store, () async {
149+
prompts++;
150+
return true;
151+
});
152+
expect(await replace(container), isTrue);
153+
expect(container.read(editorDocumentProvider), replacement);
154+
expect(
155+
container.read(draftRecoveryProvider).status,
156+
DraftRecoveryStatus.volatile,
157+
);
158+
expect(
159+
await container.read(draftRecoveryProvider.notifier).saveRecoveryCopy(),
160+
isFalse,
161+
);
162+
expect(store.writes, 1);
163+
expect(prompts, 1);
164+
expectRetained(store);
165+
},
166+
);
167+
168+
test(
169+
'edit away and back during volatile confirmation revokes captured choice',
170+
() async {
171+
final _UnavailableStore store = _UnavailableStore();
172+
final Completer<void> asked = Completer<void>();
173+
final Completer<bool> choice = Completer<bool>();
174+
final ProviderContainer container = await setup(store, () {
175+
asked.complete();
176+
return choice.future;
177+
});
178+
final Future<bool> replacing = replace(container);
179+
await asked.future;
180+
final EditorDocumentController editor = container.read(
181+
editorDocumentProvider.notifier,
182+
);
183+
editor.setContent('temporary new work');
184+
editor.setContent(store.snapshot.content);
185+
choice.complete(true);
186+
expect(await replacing, isFalse);
187+
expect(
188+
container.read(editorDocumentProvider).content,
189+
store.snapshot.content,
190+
);
191+
expect(container.read(editorDocumentProvider).name, 'retained.py');
192+
expect(
193+
container.read(draftRecoveryProvider).status,
194+
isNot(DraftRecoveryStatus.volatile),
195+
);
196+
expect(store.writes, 1);
197+
expectRetained(store);
198+
},
199+
);
200+
201+
test(
202+
'board-session guard change during volatile confirmation refuses adoption',
203+
() async {
204+
final _UnavailableStore store = _UnavailableStore();
205+
final Completer<void> asked = Completer<void>();
206+
final Completer<bool> choice = Completer<bool>();
207+
Object currentSession = Object();
208+
final Object capturedSession = currentSession;
209+
final ProviderContainer container = await setup(store, () {
210+
asked.complete();
211+
return choice.future;
212+
});
213+
final Future<bool> replacing = replace(
214+
container,
215+
isStillValid: () => identical(currentSession, capturedSession),
216+
);
217+
await asked.future;
218+
currentSession = Object();
219+
choice.complete(true);
220+
expect(await replacing, isFalse);
221+
expect(
222+
container.read(editorDocumentProvider).content,
223+
store.snapshot.content,
224+
);
225+
expect(
226+
container.read(draftRecoveryProvider).status,
227+
DraftRecoveryStatus.failed,
228+
);
229+
expect(store.writes, 1);
230+
expectRetained(store);
231+
},
232+
);
233+
234+
test(
235+
'already volatile session adopts without another failing write or prompt',
236+
() async {
237+
final _UnavailableStore store = _UnavailableStore();
238+
int prompts = 0;
239+
final ProviderContainer container = await setup(store, () async {
240+
prompts++;
241+
return false;
242+
});
243+
container.read(draftRecoveryProvider.notifier).continueWithoutRecovery();
244+
expect(await replace(container), isTrue);
245+
expect(container.read(editorDocumentProvider), replacement);
246+
expect(
247+
container.read(draftRecoveryProvider).status,
248+
DraftRecoveryStatus.volatile,
249+
);
250+
expect(store.writes, 0);
251+
expect(prompts, 0);
252+
expectRetained(store);
253+
},
254+
);
255+
256+
test(
257+
'missing volatile confirmation preserves live text and old recovery copy',
258+
() async {
259+
final _UnavailableStore store = _UnavailableStore();
260+
final ProviderContainer container = await setup(store, null);
261+
expect(await replace(container), isFalse);
262+
expect(
263+
container.read(editorDocumentProvider).content,
264+
store.snapshot.content,
265+
);
266+
expect(
267+
container.read(draftRecoveryProvider).status,
268+
DraftRecoveryStatus.failed,
269+
);
270+
expect(store.writes, 1);
271+
expectRetained(store);
272+
},
273+
);
274+
}

0 commit comments

Comments
 (0)