Files
GameTime/test/presentation/home_screen_test.dart
Blomios c0dbce275b feat(online): sync client incrémentale LWW et profil/écrans auth (tickets #65, #67)
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>
2026-07-19 21:41:18 +02:00

605 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/application/app_bootstrap.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/home_screen.dart';
void main() {
testWidgets('lentrée Profil est présente sur laccueil', (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 laccueil', (
tester,
) async {
final navigatorKey = GlobalKey<NavigatorState>();
final activeRepository = _FakeActiveSessionRepository();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
navigatorObservers: [homeRouteObserver],
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Séance en cours'), findsNothing);
unawaited(
navigatorKey.currentState!.push<void>(
MaterialPageRoute(
builder: (context) => const Scaffold(body: Text('Séance active')),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
activeRepository.session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 12),
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
elapsedActiveMs: 300000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
navigatorKey.currentState!.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
await tester.pump();
expect(
find.text('Séance en cours — Reprendre · Squat, série 2/3'),
findsOneWidget,
);
});
testWidgets('le bandeau de reprise disparaît au retour après abandon', (
tester,
) async {
final navigatorKey = GlobalKey<NavigatorState>();
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 12),
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
elapsedActiveMs: 300000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
navigatorObservers: [homeRouteObserver],
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Séance en cours'), findsOneWidget);
unawaited(
navigatorKey.currentState!.push<void>(
MaterialPageRoute(
builder: (context) => const Scaffold(body: Text('Séance active')),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
activeRepository.session = null;
navigatorKey.currentState!.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
await tester.pump();
expect(find.textContaining('Séance en cours'), findsNothing);
});
}
final class _FakeBootstrap implements AppDependencies {
_FakeBootstrap(_FakeActiveSessionRepository activeRepository)
: authUseCases = AuthUseCases(
tokenStore: _FakeAuthTokenStore(),
accountRepository: _FakeOnlineAccountRepository(),
remoteAuthApi: _FakeRemoteAuthApi(),
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(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeExerciseStepUseCases = ActiveExerciseStepUseCases(
sessionRepository: activeRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: _FakeWorkoutHistoryRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
exerciseUseCases = ExerciseUseCases(
repository: _FakeExerciseRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
mediaUseCases = MediaUseCases(
mediaRepository: _FakeMediaAssetRepository(),
exerciseRepository: _FakeExerciseRepository(),
storage: _FakeMediaStorage(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases = ProgramUseCases(
programRepository: _FakeProgramRepository(),
exerciseRepository: _FakeExerciseRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
workoutTemplateUseCases = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
workoutHistoryUseCases = WorkoutHistoryUseCases(
repository: _FakeWorkoutHistoryRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
);
@override
final AuthUseCases authUseCases;
@override
final SyncUseCases syncUseCases;
@override
final ExerciseUseCases exerciseUseCases;
@override
final MediaUseCases mediaUseCases;
@override
final ProgramUseCases programUseCases;
@override
final WorkoutTemplateUseCases workoutTemplateUseCases;
@override
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override
final WorkoutHistoryUseCases workoutHistoryUseCases;
}
String _sessionSnapshot() {
return jsonEncode({
'name': 'Séance jambes',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme jambes',
'programSnapshotJson': jsonEncode({
'exercises': [
{
'id': 'exercise-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': 3,
'timeEnabled': true,
'repsEnabled': false,
'scoreEnabled': false,
'restSecondsOverride': 0,
},
],
}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
originDeviceId: 'device-1',
);
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeAuthTokenStore implements AuthTokenStore {
@override
Future<void> clearToken() async {}
@override
Future<String?> readToken() async => null;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {}
}
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
@override
Future<void> clearSession() async {}
@override
Future<UserAccountSession?> currentSession() async => null;
@override
Future<void> saveSession(UserAccountSession session) async {}
}
final class _FakeRemoteAuthApi implements RemoteAuthApi {
@override
Future<RemoteAuthResult> login({
required String email,
required String password,
}) async {
throw const RemoteAuthException(RemoteAuthFailure.network);
}
@override
Future<void> logout(String token) async {}
@override
Future<RemoteAuthResult> register({
required String email,
required String password,
String? displayName,
}) async {
throw const RemoteAuthException(RemoteAuthFailure.network);
}
}
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>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final results = <ActiveSetResult>[];
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates;
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates;
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return const [];
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {
restStates.add(restState);
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates.add(state);
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
}
}
final class _FakeExerciseRepository implements ExerciseRepository {
@override
Future<Exercise?> findById(String id) async => null;
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override
Future<List<Exercise>> listActive() async => const [];
@override
Future<void> save(Exercise exercise) async {}
}
final class _FakeMediaAssetRepository implements MediaAssetRepository {
@override
Future<MediaAsset?> findById(String id) async => null;
@override
Future<List<MediaAsset>> listActive() async => const [];
@override
Future<void> save(MediaAsset mediaAsset) async {}
}
final class _FakeMediaStorage implements LocalMediaStorage {
@override
Future<void> deleteByLocalUri(String localUri) async {}
@override
Future<StoredMediaFile> importFile({
required String sourcePath,
required MediaKind kind,
required String stableFileName,
}) async {
return StoredMediaFile(
localUri: 'file:///$stableFileName',
mimeType: 'image/png',
sizeBytes: 0,
);
}
@override
Future<Set<String>> listManagedLocalUris() async => const {};
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> delete(String id, DateTime deletedAt) async {}
@override
Future<WorkoutHistory?> findById(String id) async => null;
@override
Future<List<WorkoutHistory>> listActive() async => const [];
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
}