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, }) => SyncService( ({required onMerge}) async { syncs++; merge = onMerge; await hold?.future; if (failWith case final error?) { throw error; } }, pendingChanges: pendingChanges, syncedBefore: syncedBefore, ); 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(); }); }); }