Skip to content

Commit 1548ef0

Browse files
committed
test(sync): raise #509 patch coverage to ~97%
Cover the recovery notifier methods (real SyncNotifier), tap-through the Troubleshoot dialog flows (confirm + cancel), and the wipe best-effort delete-failure path. Two fixes surfaced: rebuildBackendFromThisDevice kept the error status durable (refreshState was clearing it), and the wipe typed-confirm dialog moved to a StatefulWidget that owns its controller (the inline dispose-after-showDialog hit the disposed-controller trap).
1 parent 91ce151 commit 1548ef0

5 files changed

Lines changed: 376 additions & 56 deletions

File tree

lib/features/settings/presentation/pages/troubleshoot_sync_page.dart

Lines changed: 67 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -160,56 +160,14 @@ class TroubleshootSyncPage extends ConsumerWidget {
160160
}
161161

162162
/// Destructive full-backend wipe: guarded by typing the word WIPE so it
163-
/// cannot be triggered by a single mis-tap.
163+
/// cannot be triggered by a single mis-tap. The confirmation controller is
164+
/// owned by [_WipeConfirmDialog]'s State (never disposed inline after
165+
/// showDialog, which would be used again during the dialog's exit animation).
164166
Future<void> _confirmWipeAll(BuildContext context, WidgetRef ref) async {
165-
final controller = TextEditingController();
166167
final ok = await showDialog<bool>(
167168
context: context,
168-
builder: (context) => StatefulBuilder(
169-
builder: (context, setState) {
170-
final armed = controller.text.trim() == 'WIPE';
171-
return AlertDialog(
172-
title: const Text('Wipe all sync data?'),
173-
content: Column(
174-
mainAxisSize: MainAxisSize.min,
175-
crossAxisAlignment: CrossAxisAlignment.start,
176-
children: [
177-
const Text(
178-
'This deletes EVERY device’s sync data from this backend, '
179-
'including the library markers. Every device must '
180-
're-establish sync from scratch. Your dive data is not '
181-
'affected.\n\nType WIPE to confirm.',
182-
),
183-
const SizedBox(height: 12),
184-
TextField(
185-
controller: controller,
186-
autofocus: true,
187-
decoration: const InputDecoration(
188-
border: OutlineInputBorder(),
189-
hintText: 'WIPE',
190-
),
191-
onChanged: (_) => setState(() {}),
192-
),
193-
],
194-
),
195-
actions: [
196-
TextButton(
197-
onPressed: () => Navigator.pop(context, false),
198-
child: const Text('Cancel'),
199-
),
200-
FilledButton(
201-
onPressed: armed ? () => Navigator.pop(context, true) : null,
202-
style: FilledButton.styleFrom(
203-
backgroundColor: Theme.of(context).colorScheme.error,
204-
),
205-
child: const Text('Wipe everything'),
206-
),
207-
],
208-
);
209-
},
210-
),
169+
builder: (context) => const _WipeConfirmDialog(),
211170
);
212-
controller.dispose();
213171
if (ok != true) return;
214172
await ref.read(syncStateProvider.notifier).wipeAllCloudSyncData();
215173
if (context.mounted) {
@@ -219,3 +177,66 @@ class TroubleshootSyncPage extends ConsumerWidget {
219177
}
220178
}
221179
}
180+
181+
/// Typed-confirmation dialog for the destructive full-backend wipe. Owns its
182+
/// [TextEditingController] so it is disposed with the widget, not inline after
183+
/// showDialog (which the dialog's exit animation would then rebuild against).
184+
class _WipeConfirmDialog extends StatefulWidget {
185+
const _WipeConfirmDialog();
186+
187+
@override
188+
State<_WipeConfirmDialog> createState() => _WipeConfirmDialogState();
189+
}
190+
191+
class _WipeConfirmDialogState extends State<_WipeConfirmDialog> {
192+
final _controller = TextEditingController();
193+
194+
@override
195+
void dispose() {
196+
_controller.dispose();
197+
super.dispose();
198+
}
199+
200+
@override
201+
Widget build(BuildContext context) {
202+
final armed = _controller.text.trim() == 'WIPE';
203+
return AlertDialog(
204+
title: const Text('Wipe all sync data?'),
205+
content: Column(
206+
mainAxisSize: MainAxisSize.min,
207+
crossAxisAlignment: CrossAxisAlignment.start,
208+
children: [
209+
const Text(
210+
'This deletes EVERY device’s sync data from this backend, '
211+
'including the library markers. Every device must re-establish '
212+
'sync from scratch. Your dive data is not affected.\n\n'
213+
'Type WIPE to confirm.',
214+
),
215+
const SizedBox(height: 12),
216+
TextField(
217+
controller: _controller,
218+
autofocus: true,
219+
decoration: const InputDecoration(
220+
border: OutlineInputBorder(),
221+
hintText: 'WIPE',
222+
),
223+
onChanged: (_) => setState(() {}),
224+
),
225+
],
226+
),
227+
actions: [
228+
TextButton(
229+
onPressed: () => Navigator.pop(context, false),
230+
child: const Text('Cancel'),
231+
),
232+
FilledButton(
233+
onPressed: armed ? () => Navigator.pop(context, true) : null,
234+
style: FilledButton.styleFrom(
235+
backgroundColor: Theme.of(context).colorScheme.error,
236+
),
237+
child: const Text('Wipe everything'),
238+
),
239+
],
240+
);
241+
}
242+
}

lib/features/settings/presentation/providers/sync_providers.dart

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -953,18 +953,20 @@ class SyncNotifier extends StateNotifier<SyncState> {
953953
/// peers adopt from us. Un-pauses the awaiting-adoption state.
954954
Future<void> rebuildBackendFromThisDevice() async {
955955
final result = await _syncService.rebuildBackendFromThisDevice();
956-
if (result.status == SyncResultStatus.success) {
957-
state = state.copyWith(
958-
replaceAwaitingAdoption: false,
959-
replaceMarker: null,
960-
status: SyncStatus.idle,
961-
message: null,
962-
);
963-
await _ref.read(libraryEpochStoreProvider).clearPendingReplace();
964-
await performSync(); // publish our library as the epoch's base
965-
} else {
956+
if (result.status != SyncResultStatus.success) {
957+
// Keep the error visible: refreshState (which recomputes status from the
958+
// repository) must NOT run here, or it would clear the reason.
966959
state = state.copyWith(status: SyncStatus.error, message: result.message);
960+
return;
967961
}
962+
state = state.copyWith(
963+
replaceAwaitingAdoption: false,
964+
replaceMarker: null,
965+
status: SyncStatus.idle,
966+
message: null,
967+
);
968+
await _ref.read(libraryEpochStoreProvider).clearPendingReplace();
969+
await performSync(); // publish our library as the epoch's base
968970
await refreshState();
969971
}
970972

test/core/services/sync/sync_service_cloud_clear_test.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,16 @@ void main() {
7979
expect(cloud.bytesOf(libraryEpochFileName), isNull);
8080
expect(cloud.bytesOf(libraryMovedFileName), isNull);
8181
});
82+
83+
test(
84+
'wipeAllSyncData is best-effort: a delete failure does not throw',
85+
() async {
86+
cloud.seedFile(ChangesetLogLayout.manifestName('dev1'), b('m1'));
87+
cloud.seedFile(libraryEpochFileName, b('epoch'));
88+
cloud.failDeletes = true; // offline/denied provider
89+
90+
// Every delete throws, but the wipe swallows and logs each one.
91+
await buildService().wipeAllSyncData(cloud);
92+
},
93+
);
8294
}

test/features/settings/presentation/pages/troubleshoot_sync_page_test.dart

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,50 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
33
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:submersion/core/providers/provider.dart' show StateNotifier;
45
import 'package:submersion/features/settings/presentation/pages/troubleshoot_sync_page.dart';
6+
import 'package:submersion/features/settings/presentation/providers/sync_providers.dart';
7+
8+
/// Records the recovery calls the Troubleshoot page routes to the notifier so
9+
/// the tap-through tests can assert each confirm flow fires exactly once. All
10+
/// other SyncNotifier members fall through to noSuchMethod (unused here).
11+
class _FakeSyncNotifier extends StateNotifier<SyncState>
12+
implements SyncNotifier {
13+
_FakeSyncNotifier() : super(const SyncState());
14+
15+
int repairSyncCalls = 0;
16+
int removeThisDeviceCalls = 0;
17+
int wipeAllCalls = 0;
18+
int rebuildCalls = 0;
19+
20+
@override
21+
Future<void> repairSync() async => repairSyncCalls++;
22+
23+
@override
24+
Future<void> removeThisDeviceCloudFiles() async => removeThisDeviceCalls++;
25+
26+
@override
27+
Future<void> wipeAllCloudSyncData() async => wipeAllCalls++;
28+
29+
@override
30+
Future<void> rebuildBackendFromThisDevice() async => rebuildCalls++;
31+
32+
@override
33+
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
34+
}
35+
36+
/// Pumps the page with [fake] backing syncStateProvider. Returns the fake.
37+
Future<_FakeSyncNotifier> _pump(WidgetTester tester) async {
38+
final fake = _FakeSyncNotifier();
39+
await tester.pumpWidget(
40+
ProviderScope(
41+
overrides: [syncStateProvider.overrideWith((ref) => fake)],
42+
child: const MaterialApp(home: TroubleshootSyncPage()),
43+
),
44+
);
45+
await tester.pumpAndSettle();
46+
return fake;
47+
}
548

649
void main() {
750
testWidgets('shows Repair Sync action with an explanation', (tester) async {
@@ -68,4 +111,96 @@ void main() {
68111
reason: 'enabled once the user types WIPE',
69112
);
70113
});
114+
115+
// ---------------------------------------------------------------------------
116+
// Tap-through flows: each confirm invokes the notifier and shows a snackbar.
117+
// ---------------------------------------------------------------------------
118+
119+
testWidgets('Repair confirm calls repairSync and shows a snackbar', (
120+
tester,
121+
) async {
122+
final fake = await _pump(tester);
123+
124+
await tester.tap(find.text('Repair Sync'));
125+
await tester.pumpAndSettle();
126+
await tester.tap(find.widgetWithText(FilledButton, 'Repair'));
127+
await tester.pumpAndSettle();
128+
129+
expect(fake.repairSyncCalls, 1);
130+
expect(find.text('Sync repaired'), findsOneWidget);
131+
});
132+
133+
testWidgets('Repair cancel does not call repairSync', (tester) async {
134+
final fake = await _pump(tester);
135+
136+
await tester.tap(find.text('Repair Sync'));
137+
await tester.pumpAndSettle();
138+
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
139+
await tester.pumpAndSettle();
140+
141+
expect(fake.repairSyncCalls, 0);
142+
});
143+
144+
testWidgets('Rebuild confirm calls rebuildBackendFromThisDevice', (
145+
tester,
146+
) async {
147+
final fake = await _pump(tester);
148+
149+
await tester.tap(find.text('Rebuild backend from this device'));
150+
await tester.pumpAndSettle();
151+
await tester.tap(find.widgetWithText(FilledButton, 'Rebuild'));
152+
await tester.pumpAndSettle();
153+
154+
expect(fake.rebuildCalls, 1);
155+
expect(find.text('Rebuilt backend from this device'), findsOneWidget);
156+
});
157+
158+
testWidgets('Remove-this-device confirm calls the notifier', (tester) async {
159+
final fake = await _pump(tester);
160+
161+
await tester.tap(find.text('Remove this device’s cloud files'));
162+
await tester.pumpAndSettle();
163+
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
164+
await tester.pumpAndSettle();
165+
166+
expect(fake.removeThisDeviceCalls, 1);
167+
expect(find.text('Removed this device’s cloud files'), findsOneWidget);
168+
});
169+
170+
testWidgets('Wipe-all confirm (after typing WIPE) calls the notifier', (
171+
tester,
172+
) async {
173+
final fake = await _pump(tester);
174+
175+
await tester.tap(find.text('Wipe all sync data on this backend'));
176+
await tester.pumpAndSettle();
177+
await tester.enterText(find.byType(TextField), 'WIPE');
178+
await tester.pump();
179+
await tester.tap(find.widgetWithText(FilledButton, 'Wipe everything'));
180+
await tester.pumpAndSettle();
181+
182+
expect(fake.wipeAllCalls, 1);
183+
expect(find.text('Wiped all sync data'), findsOneWidget);
184+
});
185+
186+
testWidgets('cancelling each dialog invokes no notifier action', (
187+
tester,
188+
) async {
189+
final fake = await _pump(tester);
190+
191+
for (final tile in const [
192+
'Rebuild backend from this device',
193+
'Remove this device’s cloud files',
194+
'Wipe all sync data on this backend',
195+
]) {
196+
await tester.tap(find.text(tile));
197+
await tester.pumpAndSettle();
198+
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
199+
await tester.pumpAndSettle();
200+
}
201+
202+
expect(fake.rebuildCalls, 0);
203+
expect(fake.removeThisDeviceCalls, 0);
204+
expect(fake.wipeAllCalls, 0);
205+
});
71206
}

0 commit comments

Comments
 (0)