feat(online): statut de synchronisation discret et action manuelle (ticket #68)

Affiche le statut de synchronisation sur profile_screen.dart et
home_screen.dart avec une action de déclenchement manuel, en
formulation neutre (jamais "Erreur"/"Échec" ni couleur alarmante :
"Synchronisation en attente", "Nouvelle tentative automatique").
flutter pub get OK, dart format appliqué, analyze propre (mêmes infos
préexistantes), 127/127 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:05:55 +02:00
parent 7b63792a85
commit 557bfbb37e
4 changed files with 409 additions and 25 deletions

View File

@ -11,7 +11,12 @@ void main() {
final harness = _AuthHarness();
await tester.pumpWidget(
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
),
),
);
await tester.pump();
@ -28,7 +33,12 @@ void main() {
..remote.loginFailure = RemoteAuthFailure.invalidCredentials;
await tester.pumpWidget(
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
),
),
);
await tester.pump();
@ -55,7 +65,12 @@ void main() {
final harness = _AuthHarness();
await tester.pumpWidget(
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
),
),
);
await tester.pump();
@ -92,7 +107,12 @@ void main() {
);
await tester.pumpWidget(
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
),
),
);
await tester.pump();
@ -107,13 +127,94 @@ void main() {
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
expect(find.text('Compte optionnel'), findsOneWidget);
});
testWidgets(
'le bouton de synchronisation lance une synchronisation manuelle',
(tester) async {
final harness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
),
),
);
await tester.pump();
expect(find.text('Synchroniser maintenant'), findsOneWidget);
await tester.tap(find.text('Synchroniser maintenant'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(harness.syncRemote.pullCalls, 1);
},
);
testWidgets('un souci de synchronisation reste neutre dans le profil', (
tester,
) async {
final harness = _AuthHarness.connected()..syncRemote.throwOnPull = true;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
),
),
);
await tester.pump();
await tester.tap(find.text('Synchroniser maintenant'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(harness.syncMetadata.metadata.status, OnlineSyncStatus.failure);
expect(find.text('Synchronisation en attente'), findsOneWidget);
expect(find.textContaining('Erreur'), findsNothing);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('le statut se met à jour après une synchronisation réussie', (
tester,
) async {
final harness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
),
),
);
await tester.pump();
expect(find.text('Synchronisation en attente'), findsOneWidget);
await tester.tap(find.text('Synchroniser maintenant'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(find.textContaining('Dernière synchro :'), findsOneWidget);
});
}
final class _AuthHarness {
_AuthHarness({UserAccountSession? initialSession, String? initialToken})
: tokenStore = _FakeAuthTokenStore(initialToken),
accountRepository = _FakeOnlineAccountRepository(initialSession),
remote = _FakeRemoteAuthApi() {
remote = _FakeRemoteAuthApi(),
syncRemote = _FakeRemoteSyncApi(),
syncMetadata = _FakeSyncMetadataRepository(),
syncMappings = _FakeRemoteResourceMappingRepository(),
syncChanges = _FakeLocalSyncChangeRepository() {
useCases = AuthUseCases(
tokenStore: tokenStore,
accountRepository: accountRepository,
@ -121,12 +222,41 @@ final class _AuthHarness {
clock: const _FakeClock(),
ids: _FakeIds(),
);
syncUseCases = SyncUseCases(
tokenStore: tokenStore,
remoteSyncApi: syncRemote,
metadataRepository: syncMetadata,
mappingRepository: syncMappings,
localChanges: syncChanges,
clock: const _FakeClock(),
deviceId: 'device-1',
);
}
factory _AuthHarness.connected() {
return _AuthHarness(
initialSession: UserAccountSession(
id: 'account-1',
serverUserId: 'server-user-1',
email: 'alex@example.com',
displayName: 'Alex',
isLoggedIn: true,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
),
initialToken: 'token-1',
);
}
final _FakeAuthTokenStore tokenStore;
final _FakeOnlineAccountRepository accountRepository;
final _FakeRemoteAuthApi remote;
final _FakeRemoteSyncApi syncRemote;
final _FakeSyncMetadataRepository syncMetadata;
final _FakeRemoteResourceMappingRepository syncMappings;
final _FakeLocalSyncChangeRepository syncChanges;
late final AuthUseCases useCases;
late final SyncUseCases syncUseCases;
}
final class _FakeClock implements Clock {
@ -226,3 +356,76 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi {
);
}
}
final class _FakeRemoteSyncApi implements RemoteSyncApi {
var pullCalls = 0;
var pushCalls = 0;
var throwOnPull = false;
@override
Future<RemoteSyncPushResult> push({
required String deviceId,
required List<RemoteSyncPushItem> items,
required String token,
}) async {
pushCalls += 1;
return const RemoteSyncPushResult(serverCursor: null, results: []);
}
@override
Future<RemoteSyncPullResult> pull({
required String? since,
required String token,
}) async {
pullCalls += 1;
if (throwOnPull) {
throw Exception('temporary unavailable');
}
return const RemoteSyncPullResult(serverCursor: 'cursor-1', items: []);
}
}
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
var metadata = const SyncMetadataSnapshot();
@override
Future<SyncMetadataSnapshot> read() async => metadata;
@override
Future<void> save(SyncMetadataSnapshot metadata) async {
this.metadata = metadata;
}
}
final class _FakeRemoteResourceMappingRepository
implements RemoteResourceMappingRepository {
final _mappings = <String, RemoteResourceMapping>{};
@override
Future<RemoteResourceMapping?> find({
required SyncResourceType resourceType,
required String clientId,
}) async {
return _mappings['${resourceType.name}:$clientId'];
}
@override
Future<void> save(RemoteResourceMapping mapping) async {
_mappings['${mapping.resourceType.name}:${mapping.clientId}'] = mapping;
}
}
final class _FakeLocalSyncChangeRepository
implements LocalSyncChangeRepository {
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
@override
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {}
}