|
| 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