import 'dart:convert'; 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/share_inbox_screen.dart'; import 'package:gametime/presentation/share_screen.dart'; void main() { testWidgets('la boîte de réception affiche les partages et les actions', ( tester, ) async { final remoteShareApi = _FakeRemoteShareApi() ..inboxItems = [ _shareItem( shareId: 'share-program', resourceType: ShareResourceType.program, name: 'Programme tirs', status: ShareInboxStatus.pending, ), _shareItem( shareId: 'share-template', resourceType: ShareResourceType.workoutTemplate, name: 'Séance match', status: ShareInboxStatus.pending, ), ]; final inboxRepository = _FakeShareInboxRepository(); await tester.pumpWidget( MaterialApp( home: ShareInboxScreen( shareUseCases: _shareUseCases( remoteShareApi: remoteShareApi, inboxRepository: inboxRepository, ), ), ), ); await tester.pump(); await tester.pump(); expect(find.text('Programme tirs'), findsOneWidget); expect(find.text('Séance match'), findsOneWidget); expect(find.text('Accepter'), findsNWidgets(2)); expect(find.text('Refuser'), findsNWidgets(2)); await tester.tap(find.widgetWithText(FilledButton, 'Accepter').first); await tester.pump(); await tester.pump(); expect(remoteShareApi.acceptCalls, 1); expect( inboxRepository.items .firstWhere((item) => item.shareId == 'share-program') .status, ShareInboxStatus.accepted, ); expect(find.text('Programme ajouté.'), findsOneWidget); await tester.tap(find.widgetWithText(OutlinedButton, 'Refuser').first); await tester.pump(); await tester.pump(); expect(remoteShareApi.declineCalls, 1); expect( inboxRepository.items .firstWhere((item) => item.shareId == 'share-template') .status, ShareInboxStatus.declined, ); }); testWidgets('un pack reçu affiche un détail et importe toutes les séances', ( tester, ) async { final remoteShareApi = _FakeRemoteShareApi() ..inboxItems = [ _shareItem( shareId: 'share-pack', resourceType: ShareResourceType.pack, name: 'Pack reprise', status: ShareInboxStatus.pending, ), ]; final inboxRepository = _FakeShareInboxRepository(); final templateRepository = _FakeWorkoutTemplateRepository(); await tester.pumpWidget( MaterialApp( home: ShareInboxScreen( shareUseCases: _shareUseCases( remoteShareApi: remoteShareApi, inboxRepository: inboxRepository, templateRepository: templateRepository, ), ), ), ); await tester.pump(); await tester.pump(); expect(find.text('Pack reprise'), findsOneWidget); expect(find.text('Pack · 2 séances'), findsOneWidget); expect(find.text('Voir le pack'), findsOneWidget); await tester.tap(find.text('Voir le pack')); await tester.pumpAndSettle(); expect(find.text('Pack reçu'), findsOneWidget); expect(find.text('Séance tirs'), findsOneWidget); expect(find.text('Séance jambes'), findsOneWidget); await tester.tap(find.text('Importer')); await tester.pump(); await tester.pumpAndSettle(); expect(templateRepository.saved, hasLength(2)); expect( templateRepository.saved.map((template) => template.name), containsAll(['Séance tirs', 'Séance jambes']), ); expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); expect(find.text('Pack importé.'), findsOneWidget); }); testWidgets('composer un pack ouvre le formulaire de partage pack', ( tester, ) async { final remoteShareApi = _FakeRemoteShareApi(); final templateRepository = _FakeWorkoutTemplateRepository() ..templates.add(_workoutTemplate('template-1', 'Séance tirs')) ..templates.add(_workoutTemplate('template-2', 'Séance jambes')); await tester.pumpWidget( MaterialApp( home: SharePackSelectionScreen( shareUseCases: _shareUseCases( remoteShareApi: remoteShareApi, inboxRepository: _FakeShareInboxRepository(), templateRepository: templateRepository, ), ), ), ); await tester.pump(); await tester.pump(); await tester.enterText( find.widgetWithText(TextField, 'Nom du pack'), 'Pack reprise', ); await tester.tap(find.text('Séance tirs')); await tester.tap(find.text('Séance jambes')); await tester.pump(); await tester.tap(find.text('Continuer')); await tester.pumpAndSettle(); expect(find.text('Partager le pack'), findsOneWidget); expect(find.text('Pack reprise'), findsOneWidget); expect(find.text('2 séances'), findsOneWidget); await tester.enterText( find.widgetWithText(TextFormField, 'Emails destinataires'), 'coach@example.com', ); await tester.tap(find.text('Envoyer le partage')); await tester.pump(); await tester.pump(); expect(remoteShareApi.lastResourceType, ShareResourceType.pack); expect(remoteShareApi.lastPayload?['name'], 'Pack reprise'); expect(remoteShareApi.lastPayload?['workouts'], hasLength(2)); }); testWidgets( 'accepter un partage en cache hors connexion importe une copie locale', (tester) async { final remoteShareApi = _FakeRemoteShareApi(); final inboxRepository = _FakeShareInboxRepository() ..items.add( _shareItem( shareId: 'share-program', resourceType: ShareResourceType.program, name: 'Programme tirs', status: ShareInboxStatus.pending, ), ); final programRepository = _FakeProgramRepository(); final pendingRepository = _FakePendingShareActionRepository(); await tester.pumpWidget( MaterialApp( home: ShareInboxScreen( shareUseCases: _shareUseCases( tokenStore: _FakeAuthTokenStore(null), remoteShareApi: remoteShareApi, inboxRepository: inboxRepository, pendingActionRepository: pendingRepository, programRepository: programRepository, ), ), ), ); await tester.pump(); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, 'Accepter')); await tester.pump(); await tester.pump(); expect(remoteShareApi.acceptCalls, 0); expect(programRepository.saved.single.name, 'Programme tirs'); expect( programRepository.saved.single.metadata.id, isNot('share-program'), ); expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); expect( pendingRepository.actions.single.actionType, PendingShareActionType.accept, ); expect(find.text('Programme ajouté.'), findsOneWidget); }, ); } ShareUseCases _shareUseCases({ _FakeAuthTokenStore? tokenStore, required _FakeRemoteShareApi remoteShareApi, required _FakeShareInboxRepository inboxRepository, _FakePendingShareActionRepository? pendingActionRepository, _FakeProgramRepository? programRepository, _FakeWorkoutTemplateRepository? templateRepository, }) { return ShareUseCases( tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'), remoteShareApi: remoteShareApi, inboxRepository: inboxRepository, pendingActionRepository: pendingActionRepository ?? _FakePendingShareActionRepository(), localChanges: _FakeLocalSyncChangeRepository(), programRepository: programRepository ?? _FakeProgramRepository(), templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } ShareInboxItem _shareItem({ required String shareId, required ShareResourceType resourceType, required String name, required ShareInboxStatus status, }) { final payload = switch (resourceType) { ShareResourceType.program => { 'name': name, 'exercises': const [ { 'exerciseNameSnapshot': 'Sprint', 'setsCount': 3, 'availableTimeSnapshot': false, 'availableRepsSnapshot': true, 'availableScoreSnapshot': false, 'timeEnabled': false, 'repsEnabled': true, 'scoreEnabled': false, 'targetReps': 10, }, ], }, ShareResourceType.workoutTemplate => { 'name': name, 'programs': [ { 'programSnapshotJson': jsonEncode({ 'exercises': const [ {'id': 'exercise-1'}, ], }), }, ], }, ShareResourceType.pack => { 'name': name, 'workouts': [ _templatePayload('template-1', 'Séance tirs'), _templatePayload('template-2', 'Séance jambes'), ], }, }; return ShareInboxItem( shareId: shareId, senderUserId: 'Alex', resourceType: resourceType, payloadJson: jsonEncode(payload), status: status, createdAt: DateTime.utc(2026, 7, 17), ); } Map _templatePayload(String id, String name) { return { 'id': id, 'name': name, 'programs': [ { 'id': '$id-program', 'position': 0, 'programNameSnapshot': 'Programme', 'defaultRestSecondsSnapshot': 60, 'programSnapshotJson': jsonEncode({'exercises': const []}), }, ], 'overrides': const [], }; } 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 { _FakeAuthTokenStore(this.token); String? token; @override Future clearToken() async { token = null; } @override Future readToken() async => token; @override Future saveToken(String token, DateTime expiresAt) async { this.token = token; } } final class _FakeRemoteShareApi implements RemoteShareApi { List inboxItems = const []; var acceptCalls = 0; var declineCalls = 0; ShareResourceType? lastResourceType; Map? lastPayload; @override Future acceptShare(String shareId, String token) async { acceptCalls += 1; _mark(shareId, ShareInboxStatus.accepted); return RemoteSyncedItem( resourceType: SyncResourceType.program, clientId: 'shared-program-1', serverId: 'server-program-1', schemaVersion: 1, clientUpdatedAt: DateTime.utc(2026, 7, 17), serverUpdatedAt: DateTime.utc(2026, 7, 17), deletedAt: null, payload: const {'name': 'Programme tirs'}, ); } @override Future declineShare(String shareId, String token) async { declineCalls += 1; _mark(shareId, ShareInboxStatus.declined); } @override Future> fetchInbox(String token) async => inboxItems; @override Future revokeShare(String shareId, String token) async { _mark(shareId, ShareInboxStatus.revoked); } @override Future sendShare({ required ShareResourceType resourceType, required Map payload, required List recipientEmails, required String token, }) async { lastResourceType = resourceType; lastPayload = payload; return const RemoteShareSendResult( shareId: 'share-1', recipientUserIds: ['recipient-1'], unresolvedEmails: [], ); } void _mark(String shareId, ShareInboxStatus status) { inboxItems = [ for (final item in inboxItems) if (item.shareId == shareId) item.copyWith(status: status, respondedAt: DateTime.utc(2026, 7, 17)) else item, ]; } } final class _FakeShareInboxRepository implements ShareInboxRepository { final items = []; @override Future findByShareId(String shareId) async { return items.cast().firstWhere( (item) => item?.shareId == shareId, orElse: () => null, ); } @override Future> listAll() async => List.of(items); @override Future markStatus( String shareId, ShareInboxStatus status, DateTime respondedAt, ) async { final index = items.indexWhere((item) => item.shareId == shareId); if (index == -1) return; items[index] = items[index].copyWith( status: status, respondedAt: respondedAt, ); } @override Future upsert(ShareInboxItem item) async { items.removeWhere((existing) => existing.shareId == item.shareId); items.add(item); } @override Future upsertAll(List items) async { for (final item in items) { await upsert(item); } } } final class _FakePendingShareActionRepository implements PendingShareActionRepository { final actions = []; @override Future add(PendingShareAction action) async { actions.add(action); } @override Future> listPending() async => const []; @override Future markFailed(String id, DateTime attemptedAt) async {} @override Future markSucceeded(String id, DateTime attemptedAt) async {} } final class _FakeLocalSyncChangeRepository implements LocalSyncChangeRepository { @override Future applyRemoteItem(RemoteSyncedItem item) async => true; @override Future> listPendingChanges() async => const []; @override Future markChangesSynced( List changeLogIds, DateTime syncedAt, ) async {} } final class _FakeProgramRepository implements ProgramRepository { final saved = []; @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future replaceExercises(Program program, DateTime deletedAt) async {} @override Future save(Program program) async { saved.add(program); } @override Future saveExercise(ProgramExercise exercise) async {} } final class _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { final templates = []; final saved = []; @override Future findById(String id) async { return templates .where((template) => template.metadata.id == id) .firstOrNull; } @override Future> listActive() async => List.of(templates); @override Future replaceComposition( WorkoutTemplate template, DateTime deletedAt, ) async {} @override Future save(WorkoutTemplate template) async { saved.add(template); } @override Future saveAll(List templates) async { for (final template in templates) { await save(template); } } @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @override Future saveProgram(WorkoutTemplateProgram program) async {} } WorkoutTemplate _workoutTemplate(String id, String name) { return WorkoutTemplate( metadata: EntityMetadata( id: id, createdAt: DateTime.utc(2026, 7, 17), updatedAt: DateTime.utc(2026, 7, 17), originDeviceId: 'device-1', ), name: name, programs: [ WorkoutTemplateProgram( metadata: EntityMetadata( id: '$id-program', createdAt: DateTime.utc(2026, 7, 17), updatedAt: DateTime.utc(2026, 7, 17), originDeviceId: 'device-1', ), workoutTemplateId: id, sourceProgramId: null, position: 0, programNameSnapshot: 'Programme', defaultRestSecondsSnapshot: 60, programSnapshotJson: jsonEncode({'exercises': const []}), ), ], ); }