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>
432 lines
11 KiB
Dart
432 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:gametime/application/application.dart';
|
|
import 'package:gametime/domain/domain.dart';
|
|
import 'package:gametime/presentation/profile_screen.dart';
|
|
|
|
void main() {
|
|
testWidgets('le profil déconnecté invite sans bloquer le reste', (
|
|
tester,
|
|
) async {
|
|
final harness = _AuthHarness();
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: ProfileScreen(
|
|
authUseCases: harness.useCases,
|
|
syncUseCases: harness.syncUseCases,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Compte optionnel'), findsOneWidget);
|
|
expect(find.text('Créer un compte'), findsOneWidget);
|
|
expect(find.text('Se connecter'), findsOneWidget);
|
|
expect(find.byType(AlertDialog), findsNothing);
|
|
});
|
|
|
|
testWidgets('la connexion invalide affiche une erreur inline', (
|
|
tester,
|
|
) async {
|
|
final harness = _AuthHarness()
|
|
..remote.loginFailure = RemoteAuthFailure.invalidCredentials;
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: ProfileScreen(
|
|
authUseCases: harness.useCases,
|
|
syncUseCases: harness.syncUseCases,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Se connecter'));
|
|
await tester.pumpAndSettle();
|
|
await tester.enterText(
|
|
find.widgetWithText(TextFormField, 'Email'),
|
|
'a@b.fr',
|
|
);
|
|
await tester.enterText(
|
|
find.widgetWithText(TextFormField, 'Mot de passe'),
|
|
'password1',
|
|
);
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
|
|
expect(find.byType(AlertDialog), findsNothing);
|
|
});
|
|
|
|
testWidgets('la connexion réussie affiche le profil connecté', (
|
|
tester,
|
|
) async {
|
|
final harness = _AuthHarness();
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: ProfileScreen(
|
|
authUseCases: harness.useCases,
|
|
syncUseCases: harness.syncUseCases,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Se connecter'));
|
|
await tester.pumpAndSettle();
|
|
await tester.enterText(
|
|
find.widgetWithText(TextFormField, 'Email'),
|
|
'alex@example.com',
|
|
);
|
|
await tester.enterText(
|
|
find.widgetWithText(TextFormField, 'Mot de passe'),
|
|
'password1',
|
|
);
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('alex@example.com'), findsOneWidget);
|
|
expect(find.text('Synchronisation'), findsOneWidget);
|
|
expect(find.text('Partages reçus'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('la déconnexion repasse au profil déconnecté', (tester) async {
|
|
final harness = _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',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: ProfileScreen(
|
|
authUseCases: harness.useCases,
|
|
syncUseCases: harness.syncUseCases,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Alex'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Se déconnecter'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Se déconnecter'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(harness.tokenStore.token, isNull);
|
|
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(),
|
|
syncRemote = _FakeRemoteSyncApi(),
|
|
syncMetadata = _FakeSyncMetadataRepository(),
|
|
syncMappings = _FakeRemoteResourceMappingRepository(),
|
|
syncChanges = _FakeLocalSyncChangeRepository() {
|
|
useCases = AuthUseCases(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remote,
|
|
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 {
|
|
const _FakeClock();
|
|
|
|
@override
|
|
DateTime now() => DateTime.utc(2026, 7, 17, 12);
|
|
}
|
|
|
|
final class _FakeIds implements IdGenerator {
|
|
var _next = 0;
|
|
|
|
@override
|
|
String newId() {
|
|
_next += 1;
|
|
return 'local-account-$_next';
|
|
}
|
|
}
|
|
|
|
final class _FakeAuthTokenStore implements AuthTokenStore {
|
|
_FakeAuthTokenStore(this.token);
|
|
|
|
String? token;
|
|
|
|
@override
|
|
Future<void> clearToken() async {
|
|
token = null;
|
|
}
|
|
|
|
@override
|
|
Future<String?> readToken() async => token;
|
|
|
|
@override
|
|
Future<void> saveToken(String token, DateTime expiresAt) async {
|
|
this.token = token;
|
|
}
|
|
}
|
|
|
|
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
|
|
_FakeOnlineAccountRepository(this.session);
|
|
|
|
UserAccountSession? session;
|
|
|
|
@override
|
|
Future<void> clearSession() async {
|
|
session = null;
|
|
}
|
|
|
|
@override
|
|
Future<UserAccountSession?> currentSession() async => session;
|
|
|
|
@override
|
|
Future<void> saveSession(UserAccountSession session) async {
|
|
this.session = session;
|
|
}
|
|
}
|
|
|
|
final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
|
RemoteAuthFailure? loginFailure;
|
|
RemoteAuthFailure? registerFailure;
|
|
|
|
@override
|
|
Future<RemoteAuthResult> login({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
final failure = loginFailure;
|
|
if (failure != null) {
|
|
throw RemoteAuthException(failure);
|
|
}
|
|
return RemoteAuthResult(
|
|
userId: 'server-user-1',
|
|
email: email.trim(),
|
|
token: 'token-1',
|
|
expiresAt: DateTime.utc(2026, 7, 18),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> logout(String token) async {}
|
|
|
|
@override
|
|
Future<RemoteAuthResult> register({
|
|
required String email,
|
|
required String password,
|
|
String? displayName,
|
|
}) async {
|
|
final failure = registerFailure;
|
|
if (failure != null) {
|
|
throw RemoteAuthException(failure);
|
|
}
|
|
return RemoteAuthResult(
|
|
userId: 'server-user-1',
|
|
email: email.trim(),
|
|
token: 'token-1',
|
|
expiresAt: DateTime.utc(2026, 7, 18),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 {}
|
|
}
|