Ajoute la synchronisation client incrémentale LWW vers l'API serveur (application/use_cases.dart: SyncUseCases, ports.dart, migration Drift schemaVersion 10→11 pour les métadonnées de sync et mappings de ressources, infrastructure/remote/sync_api.dart) et l'écran de profil avec entrée sur l'accueil (presentation/profile_screen.dart, home_screen.dart, presentation.dart). Développés dans le même worktree partagé par DevBackend et DevFrontend ; commit combiné car test/presentation/home_screen_test.dart mélange authentiquement les deux tickets (le test de l'entrée Profil et la mise à jour du fake de bootstrap requise par le nouveau getter syncUseCases sur AppDependencies). Corrige au passage une couleur `crimson` inexistante dans le thème (remplacée par colorScheme.error) et une signature de paramètres positionnels/nommés incohérente sur un fake de test. flutter pub get OK, build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 119/119 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -9,6 +9,21 @@ import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/presentation/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('l’entrée Profil est présente sur l’accueil', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorObservers: [homeRouteObserver],
|
||||
home: HomeScreen(
|
||||
bootstrap: _FakeBootstrap(_FakeActiveSessionRepository()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Profil'), findsOneWidget);
|
||||
expect(find.text('Compte, synchronisation et partages'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('le bandeau de reprise apparaît au retour sur l’accueil', (
|
||||
tester,
|
||||
) async {
|
||||
@ -122,6 +137,15 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
),
|
||||
syncUseCases = SyncUseCases(
|
||||
tokenStore: _FakeAuthTokenStore(),
|
||||
remoteSyncApi: _FakeRemoteSyncApi(),
|
||||
metadataRepository: _FakeSyncMetadataRepository(),
|
||||
mappingRepository: _FakeRemoteResourceMappingRepository(),
|
||||
localChanges: _FakeLocalSyncChangeRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
deviceId: 'device-1',
|
||||
),
|
||||
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: activeRepository,
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
@ -180,6 +204,9 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
@override
|
||||
final AuthUseCases authUseCases;
|
||||
|
||||
@override
|
||||
final SyncUseCases syncUseCases;
|
||||
|
||||
@override
|
||||
final ExerciseUseCases exerciseUseCases;
|
||||
|
||||
@ -303,6 +330,66 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeRemoteSyncApi implements RemoteSyncApi {
|
||||
@override
|
||||
Future<RemoteSyncPullResult> pull({
|
||||
required String? since,
|
||||
required String token,
|
||||
}) async {
|
||||
return const RemoteSyncPullResult(serverCursor: null, items: []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
}) async {
|
||||
return const RemoteSyncPushResult(serverCursor: null, results: []);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
|
||||
SyncMetadataSnapshot _metadata = const SyncMetadataSnapshot();
|
||||
|
||||
@override
|
||||
Future<SyncMetadataSnapshot> read() async => _metadata;
|
||||
|
||||
@override
|
||||
Future<void> save(SyncMetadataSnapshot metadata) async {
|
||||
_metadata = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeRemoteResourceMappingRepository
|
||||
implements RemoteResourceMappingRepository {
|
||||
@override
|
||||
Future<RemoteResourceMapping?> find({
|
||||
required SyncResourceType resourceType,
|
||||
required String clientId,
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(RemoteResourceMapping mapping) async {}
|
||||
}
|
||||
|
||||
final class _FakeLocalSyncChangeRepository
|
||||
implements LocalSyncChangeRepository {
|
||||
@override
|
||||
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => false;
|
||||
|
||||
@override
|
||||
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
|
||||
|
||||
@override
|
||||
Future<void> markChangesSynced(
|
||||
List<String> changeLogIds,
|
||||
DateTime syncedAt,
|
||||
) async {}
|
||||
}
|
||||
|
||||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
ActiveWorkoutSession? session;
|
||||
final restStates = <ActiveRestState>[];
|
||||
|
||||
228
test/presentation/profile_screen_test.dart
Normal file
228
test/presentation/profile_screen_test.dart
Normal file
@ -0,0 +1,228 @@
|
||||
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)),
|
||||
);
|
||||
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)),
|
||||
);
|
||||
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)),
|
||||
);
|
||||
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)),
|
||||
);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
final class _AuthHarness {
|
||||
_AuthHarness({UserAccountSession? initialSession, String? initialToken})
|
||||
: tokenStore = _FakeAuthTokenStore(initialToken),
|
||||
accountRepository = _FakeOnlineAccountRepository(initialSession),
|
||||
remote = _FakeRemoteAuthApi() {
|
||||
useCases = AuthUseCases(
|
||||
tokenStore: tokenStore,
|
||||
accountRepository: accountRepository,
|
||||
remoteAuthApi: remote,
|
||||
clock: const _FakeClock(),
|
||||
ids: _FakeIds(),
|
||||
);
|
||||
}
|
||||
|
||||
final _FakeAuthTokenStore tokenStore;
|
||||
final _FakeOnlineAccountRepository accountRepository;
|
||||
final _FakeRemoteAuthApi remote;
|
||||
late final AuthUseCases useCases;
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user