chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)

Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -1,9 +1,11 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/infrastructure/local/local.dart' as local;
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
@ -1192,6 +1194,51 @@ void main() {
expect(repository.session?.endedAt, isNull);
});
test('close materializes session telemetry aggregate into history', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 28, 10));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
);
final sessionRepository = _FakeActiveSessionRepository()..session = session;
final historyRepository = _FakeWorkoutHistoryRepository();
final telemetryRepository = _FakeWorkoutTelemetryRepository()
..aggregates.add(
WorkoutTelemetryAggregate(
sessionId: session.metadata.id,
scope: WorkoutTelemetryAggregateScope.session,
sampleCount: 3,
minHeartRateBpm: 118,
averageHeartRateBpm: 132.5,
maxHeartRateBpm: 151,
totalDistanceMeters: 720,
totalCaloriesKcal: 56,
),
);
final useCase = CloseWorkoutSessionUseCase(
sessionRepository: sessionRepository,
historyRepository: historyRepository,
telemetryRepository: telemetryRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final history = await useCase.close(
sessionId: session.metadata.id,
nameSnapshot: 'Séance',
completed: true,
);
expect(history.minHeartRateBpm, 118);
expect(history.averageHeartRateBpm, 132.5);
expect(history.maxHeartRateBpm, 151);
expect(history.totalDistanceMeters, 720);
expect(history.totalCaloriesKcal, 56);
expect(historyRepository.histories.single, history);
});
test('adjustRestSeconds persists adjusted rest duration', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository()
@ -2522,6 +2569,31 @@ void main() {
expect(pendingRepository.actions.single.payloadJson, contains('Programme'));
});
test('ShareUseCases sendShare supports workout packs', () async {
final remoteShareApi = _FakeRemoteShareApi();
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.add(_playableTemplate(id: 'template-1', name: 'Séance 1'))
..templates.add(_playableTemplate(id: 'template-2', name: 'Séance 2'));
final result =
await _shareUseCase(
remoteShareApi: remoteShareApi,
templateRepository: templateRepository,
).sendShare(
resourceType: ShareResourceType.pack,
localResourceId: '',
localResourceIds: const ['template-1', 'template-2'],
packName: 'Pack reprise',
recipientEmails: const ['coach@example.com'],
);
expect(result.status, ShareSendStatus.sent);
expect(remoteShareApi.lastResourceType, ShareResourceType.pack);
expect(remoteShareApi.lastPayload?['name'], 'Pack reprise');
expect(remoteShareApi.lastPayload?['workouts'], isA<List<Object?>>());
expect(remoteShareApi.lastPayload?['workouts'], hasLength(2));
});
test('ShareUseCases acceptShare imports returned resource locally', () async {
final remoteShareApi = _FakeRemoteShareApi()
..acceptResult = _remoteSharedProgramItem();
@ -2583,6 +2655,121 @@ void main() {
},
);
test(
'ShareUseCases acceptShare sans token importe un pack complet',
() async {
final pendingRepository = _FakePendingShareActionRepository();
final templateRepository = _FakeWorkoutTemplateRepository();
final inboxRepository = _FakeShareInboxRepository()
..items.add(
_shareInboxItem(
status: ShareInboxStatus.pending,
resourceType: ShareResourceType.pack,
payloadJson: jsonEncode({
'name': 'Pack reprise',
'workouts': [
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
_sharedTemplatePayload(id: 'template-b', name: 'Séance B'),
],
}),
),
);
await _shareUseCase(
tokenStore: _FakeAuthTokenStore(),
inboxRepository: inboxRepository,
pendingActionRepository: pendingRepository,
templateRepository: templateRepository,
).acceptShare('share-1');
expect(templateRepository.templates, hasLength(2));
expect(
templateRepository.templates.map((template) => template.name),
containsAll(['Séance A', 'Séance B']),
);
expect(
templateRepository.templates.map((template) => template.metadata.id),
isNot(contains('template-a')),
);
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
expect(
pendingRepository.actions.single.actionType,
PendingShareActionType.accept,
);
},
);
test(
'ShareUseCases acceptShare sans token annule tout le pack si un import échoue',
() async {
final database = local.AppDatabase(NativeDatabase.memory());
addTearDown(database.close);
final inboxRepository = local.DriftShareInboxRepository(database);
final pendingRepository = local.DriftPendingShareActionRepository(
database,
);
final templateRepository = local.DriftWorkoutTemplateRepository(database);
await inboxRepository.upsert(
_shareInboxItem(
shareId: 'share-pack-failing',
status: ShareInboxStatus.pending,
resourceType: ShareResourceType.pack,
payloadJson: jsonEncode({
'name': 'Pack incomplet',
'workouts': [
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
{
'id': 'template-b',
'name': 'Séance B',
'programs': [
{
'id': 'template-b-program-1',
'position': 0,
'programNameSnapshot': 'Programme A',
'defaultRestSecondsSnapshot': 60,
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
{
'id': 'template-b-program-2',
'position': 0,
'programNameSnapshot': 'Programme B',
'defaultRestSecondsSnapshot': 60,
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
},
],
}),
),
);
final useCase = ShareUseCases(
tokenStore: _FakeAuthTokenStore(),
remoteShareApi: _FakeRemoteShareApi(),
inboxRepository: inboxRepository,
pendingActionRepository: pendingRepository,
localChanges: _FakeLocalSyncChangeRepository(),
programRepository: local.DriftProgramRepository(database),
templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.acceptShare('share-pack-failing'),
throwsA(isA<Exception>()),
);
expect(await templateRepository.listActive(), isEmpty);
expect(
(await inboxRepository.findByShareId('share-pack-failing'))!.status,
ShareInboxStatus.pending,
);
expect(await pendingRepository.listPending(), isEmpty);
},
);
test('ShareUseCases refreshInbox updates the local cache', () async {
final remoteShareApi = _FakeRemoteShareApi()
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
@ -3038,6 +3225,113 @@ void main() {
},
);
test(
'WorkoutTelemetryUseCases persists samples and aggregates by scope',
() async {
final repository = _FakeWorkoutTelemetryRepository();
final useCase = WorkoutTelemetryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
ids: _FakeIds(),
);
final ignored = await useCase.recordTelemetrySample(
const WatchTelemetrySample(
sampleId: 'sample-empty',
sessionId: 'session-1',
capturedAtEpochMs: 1,
),
);
final first = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
).millisecondsSinceEpoch,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 120,
distanceMeters: 500,
caloriesKcal: 42,
),
);
final duplicate = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
1,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 300,
caloriesKcal: 10,
),
);
final second = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-2',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
2,
).millisecondsSinceEpoch,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 150,
distanceMeters: 620,
caloriesKcal: 48,
),
);
expect(ignored, isEmpty);
expect(first.map((aggregate) => aggregate.scope), [
WorkoutTelemetryAggregateScope.session,
WorkoutTelemetryAggregateScope.exercise,
WorkoutTelemetryAggregateScope.set,
WorkoutTelemetryAggregateScope.step,
]);
expect(duplicate, isEmpty);
expect(repository.samples, hasLength(2));
final sessionAggregate = second.singleWhere(
(aggregate) =>
aggregate.scope == WorkoutTelemetryAggregateScope.session,
);
expect(sessionAggregate.sampleCount, 2);
expect(sessionAggregate.minHeartRateBpm, 120);
expect(sessionAggregate.averageHeartRateBpm, 135);
expect(sessionAggregate.maxHeartRateBpm, 150);
expect(sessionAggregate.totalDistanceMeters, 620);
expect(sessionAggregate.totalCaloriesKcal, 48);
expect(
second.any(
(aggregate) =>
aggregate.scope == WorkoutTelemetryAggregateScope.step &&
aggregate.programIndex == 0 &&
aggregate.exerciseIndex == 0 &&
aggregate.setIndex == 0 &&
aggregate.stepIndex == 0,
),
isTrue,
);
},
);
test(
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
() async {
@ -4270,6 +4564,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
var acceptCalls = 0;
var declineCalls = 0;
var revokeCalls = 0;
ShareResourceType? lastResourceType;
Map<String, Object?>? lastPayload;
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
@ -4316,6 +4612,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
required String token,
}) async {
sendCalls += 1;
lastResourceType = resourceType;
lastPayload = payload;
final error = exception;
if (error != null) {
throw error;
@ -4419,13 +4717,14 @@ Program _shareableProgram() {
ShareInboxItem _shareInboxItem({
String shareId = 'share-1',
ShareResourceType resourceType = ShareResourceType.program,
required ShareInboxStatus status,
String payloadJson = '{"name":"Programme partagé"}',
}) {
return ShareInboxItem(
shareId: shareId,
senderUserId: 'sender-1',
resourceType: ShareResourceType.program,
resourceType: resourceType,
payloadJson: payloadJson,
status: status,
createdAt: DateTime.utc(2026, 7, 17, 12),
@ -4472,6 +4771,26 @@ Map<String, Object?> _sharedProgramPayload() {
};
}
Map<String, Object?> _sharedTemplatePayload({
required String id,
required String name,
}) {
return {
'id': id,
'name': name,
'programs': [
{
'id': '$id-program',
'position': 0,
'programNameSnapshot': 'Programme',
'defaultRestSecondsSnapshot': 60,
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
};
}
RemoteSyncedItem _remoteSharedProgramItem() {
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
@ -4767,6 +5086,13 @@ final class _FakeWorkoutTemplateRepository
templates.add(template);
}
@override
Future<void> saveAll(List<WorkoutTemplate> templates) async {
for (final template in templates) {
await save(template);
}
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@ -4851,8 +5177,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> patchHeartRateSummary({
required String historyId,
int? minHeartRateBpm,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
required DateTime patchedAt,
}) async {
final index = histories.indexWhere(
@ -4868,8 +5197,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final history = histories[index];
histories[index] = history.copyWith(
metadata: history.metadata.touch(patchedAt),
minHeartRateBpm: minHeartRateBpm,
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
totalDistanceMeters: totalDistanceMeters,
totalCaloriesKcal: totalCaloriesKcal,
);
}
@ -4891,6 +5223,73 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
}
}
final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final samples = <WorkoutTelemetrySample>[];
final aggregates = <WorkoutTelemetryAggregate>[];
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
if (samples.any((existing) => existing.id == sample.id)) {
return false;
}
samples.add(sample);
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
return true;
}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return samples
.where((sample) => sample.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<void> replaceAggregatesForSession({
required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates,
}) async {
this.aggregates.removeWhere(
(aggregate) => aggregate.sessionId == sessionId,
);
this.aggregates.addAll(aggregates);
}
@override
Future<List<WorkoutTelemetryAggregate>> listAggregates(
String sessionId,
) async {
return aggregates
.where((aggregate) => aggregate.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<WorkoutTelemetryAggregate?> findAggregate({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
return aggregates
.where(
(aggregate) =>
aggregate.sessionId == sessionId &&
aggregate.scope == scope &&
aggregate.programIndex == programIndex &&
aggregate.exerciseIndex == exerciseIndex &&
aggregate.setIndex == setIndex &&
aggregate.passageIndex == passageIndex &&
aggregate.stepIndex == stepIndex,
)
.firstOrNull;
}
}
ActiveWorkoutSessionUseCases _activeUseCase(
_FakeActiveSessionRepository repository,
_FakeClock clock,

View File

@ -764,6 +764,13 @@ final class _FakeWorkoutTemplateRepository
templates.add(template);
}
@override
Future<void> saveAll(List<WorkoutTemplate> templates) async {
for (final template in templates) {
await save(template);
}
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}

View File

@ -40,7 +40,7 @@ void main() {
expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise);
});
test('projects running with duration timer before stopwatch score', () async {
test('projects running with step timer before stopwatch score', () async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
timeEnabled: true,
@ -75,7 +75,7 @@ void main() {
now.millisecondsSinceEpoch,
);
expect(projection.secondaryTimers.map((timer) => timer.kind), [
WatchTimerKind.setTimer,
WatchTimerKind.scoreStopwatch,
]);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
expect(
@ -85,7 +85,7 @@ void main() {
});
test(
'projects duration and stopwatch score with only duration timer visible',
'projects duration and stopwatch score with stopwatch timer visible',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
@ -108,7 +108,7 @@ void main() {
final projection = await projector.project(revision: 1);
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer);
expect(projection.dominantTimer?.kind, WatchTimerKind.scoreStopwatch);
expect(projection.secondaryTimers, isEmpty);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
expect(
@ -118,6 +118,28 @@ void main() {
},
);
test(
'keeps set timer out of display timers while preserving phase',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(timeEnabled: true, repsEnabled: false);
final repository = _FakeActiveSessionRepository()
..session = session
..setTimerStates['set'] = _setTimer(
sessionId: session.metadata.id,
startedAt: now.subtract(const Duration(seconds: 6)),
);
final projector = _projector(repository, _clock(now));
final projection = await projector.project(revision: 1);
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer, isNull);
expect(projection.secondaryTimers, isEmpty);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
},
);
test('projects manual score state for the current set', () async {
final session = _session(scoreEnabled: true, targetScore: 8);
final repository = _FakeActiveSessionRepository()