import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/testing.dart'; import 'package:material_ui/material_ui.dart'; /// Where an app puts the sync's widgets: a banner on its home, a dot on its /// way to Settings, and the Sync card in Settings, which More leads to. class const _Home() extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( body: Column( children: [ AppMismatchBanner(onMore: () => _openSettings(context)), SyncTroubleBadge( child: IconButton( tooltip: 'Settings', icon: const Icon(Icons.settings), onPressed: () => _openSettings(context), ), ), ], ), ); static void _openSettings(BuildContext context) => Navigator.of(context).push( MaterialPageRoute( builder: (context) => Scaffold( appBar: AppBar(title: const Text('Settings')), body: const SingleChildScrollView(child: SyncStatusCard()), ), ), ); } void main() { /// What the next sync does: null goes through, anything else is thrown. late Object? nextSync; /// Shows the app on test.example.org once a first sync ran, with /// [pending] changes kept from before that haven't reached the server. /// (The service isn't started: its timers would outlive the test.) Future showApp(WidgetTester tester, {int pending = 0}) async { final sync = SyncService( ({required onMerge}) => switch (nextSync) { final Object error => Future.error(error), null => Future.value(), }, pendingChanges: MemoryPendingChangesStore(pending), )..syncNow(); addTearDown(sync.dispose); await tester.pumpWidget( BlocProvider( create: (context) => SyncStatusBloc(sync)..add(const SyncStatusEventStarted()), child: testApp(const _Home(), dependencies: TestAppDependencies()), ), ); await tester.pumpAndSettle(); } Finder trouble() => find.bySemanticsLabel(RegExp('Sync needs attention')); Future openSettings(WidgetTester tester) async { await tester.tap(find.byTooltip('Settings')); await tester.pumpAndSettle(); } setUp(() => nextSync = null); testWidgets('shows nothing while sync goes through', (tester) async { await showApp(tester); expect(trouble(), findsNothing); expect(find.byType(MaterialBanner), findsNothing); }); testWidgets('a failed sync puts a dot on the way to Settings, and the ' 'Sync card says what is wrong', (tester) async { nextSync = Exception('No connection'); await showApp(tester); expect(trouble(), findsOneWidget); await openSettings(tester); expect(find.text('Sync is failing'), findsOneWidget); expect(find.textContaining('Last attempt failed at'), findsOneWidget); expect( find.text('No sync has completed since the app started'), findsOneWidget, ); }); testWidgets('the Sync card counts the changes that wait to sync, until a ' 'sync takes them', (tester) async { nextSync = Exception('No connection'); await showApp(tester, pending: 2); await openSettings(tester); expect(find.text('Sync is failing'), findsOneWidget); expect(find.text('2 changes wait to sync'), findsOneWidget); nextSync = null; await tester.tap(find.text('Sync now')); await tester.pumpAndSettle(); expect(find.text('Everything synced'), findsOneWidget); expect(find.textContaining('wait to sync'), findsNothing); }); testWidgets('Sync now syncs, and the trouble goes once a sync went ' 'through', (tester) async { nextSync = Exception('No connection'); await showApp(tester); await openSettings(tester); nextSync = null; await tester.tap(find.text('Sync now')); await tester.pumpAndSettle(); expect(find.text('Everything synced'), findsOneWidget); expect(find.textContaining('Last completed at'), findsOneWidget); await tester.pageBack(); await tester.pumpAndSettle(); expect(trouble(), findsNothing); }); testWidgets("when the app and the server don't match, the banner says so " 'and More leads to the Sync card, until a sync goes through', ( tester, ) async { nextSync = const AppServerMismatchException(); await showApp(tester); const banner = "This app can't sync with test.example.org any more. Update it to " 'keep syncing.'; expect(find.text(banner), findsOneWidget); await tester.tap( find.descendant( of: find.byType(MaterialBanner), matching: find.text('More'), ), ); await tester.pumpAndSettle(); expect( find.text("This app can't sync with test.example.org"), findsOneWidget, ); expect( find.text( 'Update the app. If it is up to date, the server needs an update. ' 'What you do meanwhile stays on this device and syncs afterwards.', ), findsOneWidget, ); nextSync = null; await tester.tap(find.text('Sync now')); await tester.pumpAndSettle(); await tester.pageBack(); await tester.pumpAndSettle(); expect(find.text(banner), findsNothing); }); testWidgets('an ordinary failure shows no banner', (tester) async { nextSync = Exception('No connection'); await showApp(tester); expect(find.textContaining('Update it to keep syncing'), findsNothing); }); group('on mobile data (ADR 0042)', () { late FakeNetworkWatch network; late int syncs; /// Shows the banner above the Sync card, on a phone ([offered]) on /// mobile data, sync started; [changes] made here wait. Future<(SyncService, MobileDataGate)> showPhone( WidgetTester tester, { bool offered = true, int changes = 0, }) async { final gate = MobileDataGate(network: network, offered: offered); addTearDown(gate.dispose); final sync = SyncService(({required onMerge}) async { syncs++; }, mobileData: gate)..start(); for (var change = 0; change < changes; change++) { sync.requestSync(); } await tester.pumpWidget( BlocProvider( create: (context) => SyncStatusBloc(sync)..add(const SyncStatusEventStarted()), child: testApp( Scaffold( body: Builder( builder: (context) => Column( children: [ ?mobileDataBannerOf(context), const Expanded( child: SingleChildScrollView(child: SyncStatusCard()), ), ], ), ), ), dependencies: TestAppDependencies(), ), ), ); await tester.pump(const Duration(seconds: 3)); await tester.pumpAndSettle(); return (sync, gate); } setUp(() { network = FakeNetworkWatch()..state = FakeNetworkWatch.mobile; syncs = 0; }); testWidgets('the banner counts the changes waiting; Not now puts it off ' 'until another waits', (tester) async { final (sync, _) = await showPhone(tester, changes: 2); expect(find.text('On mobile data · 2 changes waiting'), findsOneWidget); expect(find.text('Held back on mobile data'), findsOneWidget); expect(syncs, 0); await tester.tap(find.text('Not now')); await tester.pumpAndSettle(); expect(find.byType(MobileDataBanner), findsNothing); sync.requestSync(); await tester.pump(const Duration(seconds: 3)); await tester.pumpAndSettle(); expect(find.text('On mobile data · 3 changes waiting'), findsOneWidget); expect(syncs, 0); unawaited(sync.dispose()); }); testWidgets('Sync now syncs and the banner goes', (tester) async { final (sync, _) = await showPhone(tester); expect(find.text('On mobile data · sync paused'), findsOneWidget); await tester.tap(find.text('Sync now').first); await tester.pumpAndSettle(); expect(syncs, 1); expect(find.byType(MobileDataBanner), findsNothing); unawaited(sync.dispose()); }); testWidgets('Without files syncs, and the Sync card says the files wait', ( tester, ) async { final (sync, _) = await showPhone(tester); await tester.tap(find.text('Without files')); await tester.pumpAndSettle(); expect(syncs, 1); expect(find.byType(MobileDataBanner), findsNothing); expect(find.text('Files wait for Wi-Fi'), findsOneWidget); unawaited(sync.dispose()); }); testWidgets('the Sync card chooses what goes unasked; Sync everything ' "syncs and doesn't ask", (tester) async { final (sync, gate) = await showPhone(tester); expect(find.text('Ask first'), findsOneWidget); await tester.tap(find.text('Ask first')); await tester.pumpAndSettle(); await tester.tap(find.text('Sync everything').last); await tester.pumpAndSettle(); expect(gate.mobileData, MobileData.everything); expect(syncs, 1); expect(find.byType(MobileDataBanner), findsNothing); unawaited(sync.dispose()); }); testWidgets('a desktop shows no banner and no choice', (tester) async { network.state = FakeNetworkWatch.wifi; final (sync, _) = await showPhone(tester, offered: false); expect(find.byType(MobileDataBanner), findsNothing); expect(find.text('On mobile data'), findsNothing); expect(syncs, 1); unawaited(sync.dispose()); }); }); }