import 'dart:async'; import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/testing.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late int syncs; late Exception? failWith; late VoidCallback? merge; // While set and not completed, a sync keeps running. late Completer? hold; SyncService service({ PendingChangesStore? pendingChanges, bool syncedBefore = false, MobileDataGate? mobileData, }) => SyncService( ({required onMerge}) async { syncs++; merge = onMerge; await hold?.future; if (failWith case final error?) { throw error; } }, pendingChanges: pendingChanges, syncedBefore: syncedBefore, mobileData: mobileData, ); setUp(() { syncs = 0; failWith = null; merge = null; hold = null; }); test('syncs once at start', () { fakeAsync((async) { final sync = service()..start(); async.flushMicrotasks(); expect(syncs, 1); sync.dispose(); }); }); test('syncs once after a burst of local changes has settled', () { fakeAsync((async) { final sync = service()..start(); async.flushMicrotasks(); sync ..requestSync() ..requestSync(); async.elapse(const Duration(seconds: 1)); sync.requestSync(); async.elapse(const Duration(seconds: 1)); expect(syncs, 1, reason: 'still within the debounce'); async.elapse(const Duration(seconds: 1)); expect(syncs, 2); sync.dispose(); }); }); test('syncs every five minutes without other triggers', () { fakeAsync((async) { final sync = service()..start(); async.elapse(const Duration(minutes: 10)); expect(syncs, 3); sync.dispose(); }); }); test('keeps syncing after a failed sync', () { fakeAsync((async) { failWith = Exception('No connection'); final sync = service()..start(); async.flushMicrotasks(); failWith = null; sync.requestSync(); async.elapse(const Duration(seconds: 2)); expect(syncs, 2); sync.dispose(); }); }); test('reports changes from other devices', () { fakeAsync((async) { final sync = service(); var merges = 0; sync.merges.listen((_) => merges++); sync.start(); async.flushMicrotasks(); merge!(); async.flushMicrotasks(); expect(merges, 1); sync.dispose(); }); }); test('a trigger during a sync runs one more sync afterwards', () { fakeAsync((async) { final running = hold = Completer(); final sync = service()..start(); async.flushMicrotasks(); sync.requestSync(); async.elapse(const Duration(seconds: 2)); sync.requestSync(); async.elapse(const Duration(seconds: 2)); expect(syncs, 1, reason: 'the first sync is still running'); running.complete(); async.flushMicrotasks(); expect(syncs, 2); sync.dispose(); }); }); test('lets a running sync finish before it stops', () { fakeAsync((async) { final running = hold = Completer(); final sync = service()..start(); async.flushMicrotasks(); sync.requestSync(); async.elapse(const Duration(seconds: 2)); var stopped = false; unawaited(sync.dispose().then((_) => stopped = true)); async.flushMicrotasks(); expect(stopped, isFalse, reason: 'the database is still in use'); running.complete(); async.flushMicrotasks(); expect(stopped, isTrue); expect(syncs, 1, reason: 'no sync starts once it is stopping'); }); }); test('stops syncing when disposed', () { fakeAsync((async) { final sync = service()..start(); async.flushMicrotasks(); sync ..requestSync() ..dispose(); async.elapse(const Duration(minutes: 10)); expect(syncs, 1); }); }); group('status', () { final noon = DateTime.utc(2026, 10, 3, 12); test('nothing has synced before the first sync', () { expect(service().status, const SyncStatus()); expect(service().status.hasTrouble, isFalse); }); test('says while a sync runs, and when it went through', () { fakeAsync((async) { withClock(async.getClock(noon), () { final running = hold = Completer(); final sync = service()..start(); async.flushMicrotasks(); expect(sync.status, const SyncStatus(syncing: true)); async.elapse(const Duration(seconds: 3)); running.complete(); async.flushMicrotasks(); expect( sync.status, SyncStatus( lastSyncedAt: noon.add(const Duration(seconds: 3)), syncedHere: true, ), ); sync.dispose(); }); }); }); test('a failed sync is trouble until a sync goes through again', () { fakeAsync((async) { withClock(async.getClock(noon), () { final sync = service()..start(); async.flushMicrotasks(); failWith = Exception('No connection'); async.elapse(const Duration(minutes: 5)); expect( sync.status, SyncStatus( lastSyncedAt: noon, failure: ( at: noon.add(const Duration(minutes: 5)), appMismatch: false, ), syncedHere: true, ), ); expect(sync.status.hasTrouble, isTrue); failWith = null; async.elapse(const Duration(minutes: 5)); expect( sync.status, SyncStatus( lastSyncedAt: noon.add(const Duration(minutes: 10)), syncedHere: true, ), ); sync.dispose(); }); }); }); test('still shows the failure while the next sync runs', () { fakeAsync((async) { withClock(async.getClock(noon), () { failWith = Exception('No connection'); final sync = service()..start(); async.flushMicrotasks(); hold = Completer(); sync.syncNow(); async.flushMicrotasks(); expect(sync.status.syncing, isTrue); expect(sync.status.hasTrouble, isTrue); hold!.complete(); sync.dispose(); }); }); }); test("tells when this app and the server don't match", () { fakeAsync((async) { withClock(async.getClock(noon), () { failWith = const AppServerMismatchException(); final sync = service()..start(); async.flushMicrotasks(); expect(sync.status.appMismatch, isTrue); expect(sync.status.failure, (at: noon, appMismatch: true)); sync.dispose(); }); }); }); test('reports every change', () { fakeAsync((async) { final sync = service(); final seen = []; sync.statusChanges.listen(seen.add); sync.start(); async.flushMicrotasks(); expect(seen.map((status) => status.syncing), [true, false]); sync.dispose(); }); }); }); test('syncs at once when the person asks', () { fakeAsync((async) { final sync = service()..start(); async.flushMicrotasks(); sync.syncNow(); async.flushMicrotasks(); expect(syncs, 2, reason: 'no waiting for changes to settle'); sync.dispose(); }); }); group('changes not on the server yet', () { test('are the saves since the last sync that went through', () { fakeAsync((async) { final sync = service()..start(); async.flushMicrotasks(); sync ..requestSync() ..requestSync(); expect(sync.status.pending, 2); async.elapse(const Duration(seconds: 2)); expect(sync.status.pending, 0); sync.dispose(); }); }); test('stay while syncs fail', () { fakeAsync((async) { failWith = Exception('No connection'); final sync = service()..start(); async.flushMicrotasks(); sync.requestSync(); async.elapse(const Duration(seconds: 2)); expect(sync.status.pending, 1); expect(sync.status.hasTrouble, isTrue); failWith = null; sync.syncNow(); async.flushMicrotasks(); expect(sync.status.pending, 0); sync.dispose(); }); }); test('saved while a sync runs, wait for the next', () { fakeAsync((async) { final running = hold = Completer(); final sync = service()..start(); async.flushMicrotasks(); sync.requestSync(); running.complete(); async.flushMicrotasks(); expect(sync.status.pending, 1); hold = null; async.elapse(const Duration(seconds: 2)); expect(sync.status.pending, 0); sync.dispose(); }); }); test('are kept while the app is closed, and taken by the first sync', () { fakeAsync((async) { failWith = Exception('No connection'); final kept = MemoryPendingChangesStore(3); final sync = service(pendingChanges: kept)..start(); async.flushMicrotasks(); expect(sync.status.pending, 3); sync.requestSync(); async.flushMicrotasks(); expect(kept.kept, 4); failWith = null; sync.syncNow(); async.flushMicrotasks(); expect(sync.status.pending, 0); expect(kept.kept, 0); sync.dispose(); }); }); }); test('says whether a sync ever went through on this device: as kept from ' 'before, and from the first that goes through, kept for next ' 'time', () { fakeAsync((async) { expect(service(syncedBefore: true).status.syncedHere, isTrue); failWith = Exception('No connection'); final kept = MemoryPendingChangesStore(0, false); final sync = service(pendingChanges: kept)..start(); async.flushMicrotasks(); expect(sync.status.syncedHere, isFalse); expect(sync.status.hasTrouble, isTrue); failWith = null; sync.syncNow(); async.flushMicrotasks(); expect(sync.status.syncedHere, isTrue); expect(kept.synced, isTrue); sync.requestSync(); async.flushMicrotasks(); expect(sync.status.syncedHere, isTrue); sync.dispose(); }); }); group('on mobile data (ADR 0042)', () { late FakeNetworkWatch network; late MobileDataGate gate; setUp(() { network = FakeNetworkWatch()..state = FakeNetworkWatch.mobile; gate = MobileDataGate(network: network, offered: true); }); tearDown(() => gate.dispose()); test('nothing syncs, the changes waiting counted, and it asks', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); async.flushMicrotasks(); sync.requestSync(); async.elapse(const Duration(minutes: 10)); expect(syncs, 0); expect(sync.status.heldBack, isTrue); expect(sync.status.asking, isTrue); expect(sync.status.pending, 1); expect(sync.status.mobileData, MobileData.ask); expect(sync.filesAllowed, isFalse); sync.dispose(); }); }); test('Sync now syncs at once, and on every trigger after, files and ' 'all', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); async.flushMicrotasks(); sync.answer(SyncAllowance.everything); async.flushMicrotasks(); expect(syncs, 1); expect(sync.status.heldBack, isFalse); expect(sync.status.asking, isFalse); expect(sync.filesAllowed, isTrue); async.elapse(const Duration(minutes: 5)); expect(syncs, 2); sync.dispose(); }); }); test('Without files syncs the rows, the files held back', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); async.flushMicrotasks(); sync.answer(SyncAllowance.rows); async.flushMicrotasks(); expect(syncs, 1); expect(sync.filesAllowed, isFalse); expect(sync.status.filesHeld, isTrue); sync.dispose(); }); }); test('Not now stops asking until a new change waits; nothing syncs', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); async.flushMicrotasks(); sync ..requestSync() ..notNow(); expect(sync.status.asking, isFalse); expect(sync.status.heldBack, isTrue); sync.requestSync(); async.elapse(const Duration(seconds: 3)); expect(sync.status.asking, isTrue); expect(syncs, 0); sync.dispose(); }); }); test('asked to sync now (a list pulled down, Settings) is Sync now', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); async.flushMicrotasks(); sync.syncNow(); async.flushMicrotasks(); expect(syncs, 1); expect(gate.allowance, SyncAllowance.everything); sync.dispose(); }); }); test('Wi-Fi syncs at once what waited; back on mobile data, it asks ' 'again', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); async.flushMicrotasks(); sync ..requestSync() ..notNow(); async.elapse(const Duration(seconds: 3)); expect(syncs, 0); network.moveTo(FakeNetworkWatch.wifi); async.flushMicrotasks(); expect(syncs, 1); expect(sync.status.heldBack, isFalse); expect(sync.status.pending, 0); network.moveTo(FakeNetworkWatch.mobile); async.elapse(const Duration(minutes: 5)); expect(syncs, 1); expect(sync.status.asking, isTrue); sync.dispose(); }); }); test('nothing syncs before the network is known', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); expect(syncs, 0); async.flushMicrotasks(); expect(syncs, 0); sync.dispose(); }); }); test('the setting is chosen through it, and shows in its status', () { fakeAsync((async) { final sync = service(mobileData: gate)..start(); async.flushMicrotasks(); sync.chooseMobileData(MobileData.withoutFiles); async.flushMicrotasks(); expect(sync.status.mobileData, MobileData.withoutFiles); expect(syncs, 1, reason: 'the rows may go now'); sync.dispose(); }); }); test("a device not offered the choice shows none, and doesn't hold " 'back', () { fakeAsync((async) { final desktop = MobileDataGate(); final sync = service(mobileData: desktop)..start(); async.flushMicrotasks(); expect(syncs, 1); expect(sync.status.mobileData, isNull); sync.dispose(); desktop.dispose(); }); }); }); }