halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set on halkyon_base's client: the instances a device keeps an account on, the first start's tiles and where an instance keeps people's data, the account switcher; signing in anonymously, with GitHub or an email code, switching person and the start screen; the gates that build an app's dependencies per instance and per person; and the sync service with its status, Settings card and banner. Texts of its own in English and German take the app's name; what only the app can say, it passes in. Fakes for apps' tests are in testing.dart. An instance's basics now name the app it serves (`app`, set by the host's configuration), and an app accepts only an instance of its own. SerialQueue and CoalescingRunner move into halkyon_core. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
af1de799f4
commit
e23cc77f7c
4 changed files with 10761 additions and 10 deletions
120
packages/halkyon_app/test/sync/app_compatibility_test.dart
Normal file
120
packages/halkyon_app/test/sync/app_compatibility_test.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
void main() {
|
||||
group('AppCompatibility', () {
|
||||
late int asked;
|
||||
late Object? answer;
|
||||
|
||||
AppCompatibility compatibility(int? appBuild) => AppCompatibility(
|
||||
appBuild: appBuild,
|
||||
minimumAppBuild: () {
|
||||
asked++;
|
||||
return switch (answer) {
|
||||
final Exception error => Future.error(error),
|
||||
_ => Future.value(answer as int?),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
asked = 0;
|
||||
answer = null;
|
||||
});
|
||||
|
||||
test('lets any build sync when the server names no minimum', () async {
|
||||
await compatibility(1).check();
|
||||
});
|
||||
|
||||
test('lets a build at or above the minimum sync', () async {
|
||||
answer = 3;
|
||||
await compatibility(3).check();
|
||||
await compatibility(4).check();
|
||||
});
|
||||
|
||||
test('refuses a build below the minimum', () async {
|
||||
answer = 3;
|
||||
await expectLater(
|
||||
compatibility(2).check(),
|
||||
throwsA(isA<AppServerMismatchException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('asks the server once per launch', () async {
|
||||
answer = 1;
|
||||
final check = compatibility(1);
|
||||
await check.check();
|
||||
await check.check();
|
||||
expect(asked, 1);
|
||||
});
|
||||
|
||||
test('asks again after the question failed', () async {
|
||||
answer = Exception('No connection');
|
||||
final check = compatibility(1);
|
||||
await expectLater(check.check(), throwsException);
|
||||
|
||||
answer = 1;
|
||||
await check.check();
|
||||
expect(asked, 2);
|
||||
});
|
||||
|
||||
test('never refuses a build without a number, and never asks', () async {
|
||||
answer = 99;
|
||||
await compatibility(null).check();
|
||||
expect(asked, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('compatibleSync', () {
|
||||
test('checks before syncing', () async {
|
||||
var synced = false;
|
||||
final sync = compatibleSync(
|
||||
({required onMerge}) {
|
||||
synced = true;
|
||||
return Future.value();
|
||||
},
|
||||
AppCompatibility(appBuild: 1, minimumAppBuild: () => Future.value(2)),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
sync(onMerge: () {}),
|
||||
throwsA(isA<AppServerMismatchException>()),
|
||||
);
|
||||
expect(synced, isFalse);
|
||||
});
|
||||
|
||||
test("tells when the synced tables don't match the server's", () async {
|
||||
final sync = compatibleSync(
|
||||
({required onMerge}) => Future.error(
|
||||
const OfflineSyncTablesHashMismatchException(
|
||||
received: 'server',
|
||||
expected: 'app',
|
||||
),
|
||||
),
|
||||
AppCompatibility(
|
||||
appBuild: 1,
|
||||
minimumAppBuild: Future<int?>.value,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
sync(onMerge: () {}),
|
||||
throwsA(isA<AppServerMismatchException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('passes other failures on as they are', () async {
|
||||
final failure = Exception('No connection');
|
||||
final sync = compatibleSync(
|
||||
({required onMerge}) => Future.error(failure),
|
||||
AppCompatibility(
|
||||
appBuild: 1,
|
||||
minimumAppBuild: Future<int?>.value,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(sync(onMerge: () {}), throwsA(same(failure)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late File file;
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('sync_pending');
|
||||
file = File('${folder.path}/sync_pending.json');
|
||||
});
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
test('nothing kept yet: none pending, never synced here', () async {
|
||||
expect(await FilePendingChangesStore(file).read(), (
|
||||
pending: 0,
|
||||
synced: false,
|
||||
));
|
||||
});
|
||||
|
||||
test('keeps the count and whether a sync went through', () async {
|
||||
await FilePendingChangesStore(file).write((pending: 3, synced: true));
|
||||
|
||||
expect(await FilePendingChangesStore(file).read(), (
|
||||
pending: 3,
|
||||
synced: true,
|
||||
));
|
||||
});
|
||||
|
||||
test('a file from before it kept that is a device that synced', () async {
|
||||
file.writeAsStringSync('{"pending": 2}');
|
||||
|
||||
expect(await FilePendingChangesStore(file).read(), (
|
||||
pending: 2,
|
||||
synced: true,
|
||||
));
|
||||
});
|
||||
}
|
||||
390
packages/halkyon_app/test/sync/sync_service_test.dart
Normal file
390
packages/halkyon_app/test/sync/sync_service_test.dart
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
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<void>? 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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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 = <SyncStatus>[];
|
||||
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<void>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
171
packages/halkyon_app/test/sync/widget/sync_widgets_test.dart
Normal file
171
packages/halkyon_app/test/sync/widget/sync_widgets_test.dart
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
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<void>(
|
||||
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<void> showApp(WidgetTester tester, {int pending = 0}) async {
|
||||
final sync = SyncService(
|
||||
({required onMerge}) => switch (nextSync) {
|
||||
final Object error => Future<void>.error(error),
|
||||
null => Future<void>.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<void> 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);
|
||||
});
|
||||
}
|
||||
Reference in a new issue