Skip to content

Commit 2e43ab9

Browse files
committed
[red] specify durable draft recovery coordination and race behavior
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 1cbd04e commit 2e43ab9

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
import 'dart:async';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:pyble/data/draft_repository.dart';
7+
import 'package:pyble/editor/draft_recovery.dart';
8+
import 'package:pyble/editor/editor_document.dart';
9+
10+
class Store implements DraftRepository {
11+
Store({this.snapshot, this.generation = 0});
12+
DraftSnapshot? snapshot;
13+
int generation;
14+
final List<DraftSnapshot> writes = [];
15+
Completer<DraftRead>? heldLoad;
16+
Completer<void>? heldWrite;
17+
DraftStorageFailure? failure;
18+
int loads=0, clears=0, active=0, maxActive=0;
19+
@override Future<DraftRead> load() async { loads++; return heldLoad?.future ?? DraftRead(generation:generation,snapshot:snapshot); }
20+
@override Future<void> write(DraftSnapshot value,{required int expectedGeneration}) async {
21+
active++; if(active>maxActive) maxActive=active;
22+
try {
23+
await heldWrite?.future;
24+
if(failure!=null) throw DraftStorageException(failure!);
25+
if(generation!=expectedGeneration || value.generation<=generation) throw const DraftStorageException(DraftStorageFailure.conflict);
26+
writes.add(value); snapshot=value; generation=value.generation;
27+
} finally {active--;}
28+
}
29+
@override Future<int> clear({required int expectedGeneration}) async {
30+
if(failure!=null) throw DraftStorageException(failure!);
31+
if(generation!=expectedGeneration) throw const DraftStorageException(DraftStorageFailure.conflict);
32+
clears++; snapshot=null; return ++generation;
33+
}
34+
@override Future<void> close() async {}
35+
}
36+
DraftSnapshot saved()=>DraftSnapshot(name:'blink.py',content:'print(“exact”)\n',originPath:'/lib/blink.py',generation:17,checkpointTime:DateTime.utc(2026,10,31));
37+
void main(){
38+
ProviderContainer container(Store store){
39+
final c=ProviderContainer(overrides:[draftRepositoryProvider.overrideWithValue(store)]);
40+
addTearDown(c.dispose); return c;
41+
}
42+
DraftRecoveryController recovery(ProviderContainer c)=>c.read(draftRecoveryProvider.notifier);
43+
EditorDocumentController editor(ProviderContainer c)=>c.read(editorDocumentProvider.notifier);
44+
test('existing harnesses have no implicit disk access',(){final c=ProviderContainer();addTearDown(c.dispose);expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.disabled);});
45+
test('startup offers committed copy without hydrating or writing',()async{
46+
final s=Store(snapshot:saved(),generation:17),c=container(s);await recovery(c).start();
47+
expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.decision);
48+
expect(c.read(draftRecoveryProvider).candidate?.content,saved().content);
49+
expect(c.read(editorDocumentProvider).content,isEmpty);expect(s.writes,isEmpty);expect(s.clears,0);
50+
});
51+
test('Restore is dirty, unbound and does not delete or rewrite copy',()async{
52+
final s=Store(snapshot:saved(),generation:17),c=container(s);await recovery(c).start();
53+
expect(recovery(c).restore(),isTrue);expect(c.read(editorDocumentProvider).content,saved().content);
54+
expect(c.read(editorDocumentProvider).dirty,isTrue);expect(c.read(editorDocumentProvider).boardPath,isNull);
55+
expect(c.read(editorRecoveredDraftProvider),isTrue);expect(s.snapshot,isNotNull);expect(s.writes,isEmpty);
56+
expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.saved);
57+
});
58+
test('failed Discard preserves prompt and stored copy',()async{
59+
final s=Store(snapshot:saved(),generation:17),c=container(s);await recovery(c).start();s.failure=DraftStorageFailure.unavailable;
60+
expect(await recovery(c).discard(),isFalse);expect(c.read(draftRecoveryProvider).candidate,isNotNull);
61+
expect(c.read(draftRecoveryProvider).blocksStartup,isTrue);expect(s.snapshot,isNotNull);
62+
});
63+
test('Discard commits newer tombstone before clearing decision',()async{
64+
final s=Store(snapshot:saved(),generation:17),c=container(s);await recovery(c).start();
65+
expect(await recovery(c).discard(),isTrue);expect(s.generation,18);expect(s.snapshot,isNull);
66+
expect(c.read(draftRecoveryProvider).blocksStartup,isFalse);expect(c.read(editorDocumentProvider).content,isEmpty);
67+
});
68+
test('manual checkpoint acknowledges captured epoch, not later edits',()async{
69+
final s=Store(),c=container(s);await recovery(c).start();editor(c).setContent('first');s.heldWrite=Completer<void>();
70+
final write=recovery(c).saveRecoveryCopy();await Future<void>.delayed(Duration.zero);editor(c).setContent('newer');s.heldWrite!.complete();
71+
expect(await write,isTrue);expect(s.snapshot?.content,'first');expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.pending);
72+
});
73+
testWidgets('idle edits checkpoint after 500ms and clean named empty file persists',(tester)async{
74+
final s=Store(),c=container(s);await recovery(c).start();editor(c).openFromBoard(path:'/empty.py',content:'');
75+
await tester.pump(const Duration(milliseconds:499));expect(s.writes,isEmpty);
76+
await tester.pump(const Duration(milliseconds:1));await tester.pump();expect(s.snapshot?.name,'empty.py');
77+
expect(s.snapshot?.content,isEmpty);expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.saved);
78+
});
79+
testWidgets('continuous edits checkpoint within two seconds',(tester)async{
80+
final s=Store(),c=container(s);await recovery(c).start();
81+
for(int i=0;i<5;i++){editor(c).setContent('edit $i');await tester.pump(const Duration(milliseconds:400));}
82+
await tester.pump();expect(s.writes,isNotEmpty);expect(s.maxActive,1);
83+
});
84+
test('failed checkpoint retains live text and previous copy',()async{
85+
final s=Store(snapshot:saved(),generation:17),c=container(s);await recovery(c).start();recovery(c).restore();editor(c).setContent('newer');s.failure=DraftStorageFailure.tooLarge;
86+
expect(await recovery(c).saveRecoveryCopy(),isFalse);expect(s.snapshot?.content,saved().content);expect(c.read(editorDocumentProvider).content,'newer');
87+
expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.failed);expect(c.read(draftRecoveryProvider).failure,DraftStorageFailure.tooLarge);
88+
});
89+
test('volatile continuation fences delayed hydration and protects old storage',()async{
90+
final s=Store()..heldLoad=Completer<DraftRead>();final c=container(s);final startup=recovery(c).start();recovery(c).continueWithoutRecovery();editor(c).setContent('live');
91+
s.heldLoad!.complete(DraftRead(generation:17,snapshot:saved()));await startup;expect(c.read(editorDocumentProvider).content,'live');
92+
expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.volatile);expect(await recovery(c).saveRecoveryCopy(),isFalse);expect(s.writes,isEmpty);
93+
});
94+
testWidgets('ten second timeout and retry do not duplicate active load',(tester)async{
95+
final s=Store()..heldLoad=Completer<DraftRead>();final c=container(s);final startup=recovery(c).start();await tester.pump(const Duration(seconds:10));
96+
expect(c.read(draftRecoveryProvider).timedOut,isTrue);final retry=recovery(c).retry();expect(s.loads,1);recovery(c).continueWithoutRecovery();
97+
s.heldLoad!.complete(const DraftRead(generation:0));await startup;await retry;
98+
});
99+
test('volatile Retry offers choice without replacing current text',()async{
100+
final s=Store(snapshot:saved(),generation:17),c=container(s);recovery(c).continueWithoutRecovery();editor(c).setContent('live');await recovery(c).retry();
101+
expect(c.read(draftRecoveryProvider).candidate?.content,saved().content);expect(c.read(editorDocumentProvider).content,'live');expect(c.read(draftRecoveryProvider).status,DraftRecoveryStatus.decision);
102+
});
103+
test('replacement failure preserves current document and previous durable copy',()async{
104+
final s=Store(snapshot:saved(),generation:17),c=container(s);await recovery(c).start();recovery(c).restore();s.failure=DraftStorageFailure.unavailable;
105+
expect(await recovery(c).replaceDocument(const EditorDocument(name:'new.py',content:'new',dirty:true),expectedEpoch:c.read(editorDocumentEpochProvider)),isFalse);
106+
expect(c.read(editorDocumentProvider).content,saved().content);expect(s.snapshot?.content,saved().content);
107+
});
108+
test('replacement rejects stale edit-away/back epoch',()async{
109+
final s=Store(),c=container(s);await recovery(c).start();editor(c).setContent('first');final captured=c.read(editorDocumentEpochProvider);
110+
editor(c).setContent('other');editor(c).setContent('first');expect(await recovery(c).replaceDocument(const EditorDocument.untitled(),expectedEpoch:captured),isFalse);
111+
expect(c.read(editorDocumentProvider).content,'first');expect(s.clears,0);
112+
});
113+
test('replacement waits for active write and prevents queued resurrection',()async{
114+
final s=Store(),c=container(s);await recovery(c).start();editor(c).setContent('first');s.heldWrite=Completer<void>();
115+
final write=recovery(c).saveRecoveryCopy();await Future<void>.delayed(Duration.zero);editor(c).setContent('obsolete queued');
116+
final clear=recovery(c).replaceDocument(const EditorDocument.untitled(),expectedEpoch:c.read(editorDocumentEpochProvider));s.heldWrite!.complete();await write;
117+
expect(await clear,isTrue);expect(s.snapshot,isNull);expect(c.read(editorDocumentProvider).content,isEmpty);expect(s.maxActive,1);
118+
});
119+
}

0 commit comments

Comments
 (0)