feat(exécution): dernière performance et meilleur score par exercice (ticket #81)

Ajoute la migration Drift v17 (colonne sourceExerciseIdSnapshot sur
workout_history_set_results et workout_history_step_results, index
associés), le port ExercisePerformanceReferenceRepository et son
implémentation Drift, et le use case
ExercisePerformanceReferenceUseCase (dernière performance + meilleur
score par métrique active pour un exercice donné).

Validé GO par Main : dart analyze propre (1 lint mineur de style),
178 tests (4 échecs préexistants sans rapport avec ce lot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:54:49 +02:00
parent e05405157a
commit ac51c16b51
10 changed files with 1073 additions and 3 deletions

View File

@ -14,6 +14,8 @@ void main() {
late local.DriftActiveSessionRepository activeRepository;
late local.DriftWorkoutTemplateRepository templateRepository;
late local.DriftWorkoutHistoryRepository historyRepository;
late local.DriftExercisePerformanceReferenceRepository
performanceReferenceRepository;
setUp(() {
database = local.AppDatabase(NativeDatabase.memory());
@ -22,6 +24,8 @@ void main() {
activeRepository = local.DriftActiveSessionRepository(database);
templateRepository = local.DriftWorkoutTemplateRepository(database);
historyRepository = local.DriftWorkoutHistoryRepository(database);
performanceReferenceRepository =
local.DriftExercisePerformanceReferenceRepository(database);
});
tearDown(() async {
@ -843,6 +847,265 @@ void main() {
expect(restored.results.single.actualReps, 10);
expect(restored.results.single.actualScore, 80);
expect(restored.results.single.scoreUnitSnapshot, 'kg');
expect(restored.results.single.sourceExerciseIdSnapshot, 'exercise-1');
},
);
test(
'performance reference ignores skipped and null values for latest set',
() async {
final now = DateTime.utc(2026, 7, 22, 10);
await historyRepository.save(
_history(
id: 'history-old',
startedAt: now.subtract(const Duration(days: 2)),
result: _historySetResult(
id: 'result-old',
historyId: 'history-old',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now.subtract(const Duration(days: 2)),
actualReps: 8,
),
),
);
await historyRepository.save(
_history(
id: 'history-new',
startedAt: now,
results: [
_historySetResult(
id: 'result-skipped',
historyId: 'history-new',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
status: SetResultStatus.skipped,
),
_historySetResult(
id: 'result-null',
historyId: 'history-new',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now,
),
_historySetResult(
id: 'result-value',
historyId: 'history-new',
sourceExerciseId: 'exercise-1',
setIndex: 2,
startedAt: now,
actualReps: 11,
),
],
),
);
final latest = await performanceReferenceRepository
.findLatestSetPerformance(
exerciseId: 'exercise-1',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
),
currentSetIndex: 0,
);
expect(latest, isNotNull);
expect(latest!.workoutHistoryId, 'history-new');
expect(latest.setIndex, 2);
expect(latest.actualReps, 11);
},
);
test('performance reference uses same set when available', () async {
final now = DateTime.utc(2026, 7, 22, 11);
await historyRepository.save(
_history(
id: 'history-sets',
startedAt: now,
results: [
_historySetResult(
id: 'result-set-0',
historyId: 'history-sets',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
actualReps: 7,
),
_historySetResult(
id: 'result-set-1',
historyId: 'history-sets',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now,
actualReps: 9,
),
],
),
);
final latest = await performanceReferenceRepository
.findLatestSetPerformance(
exerciseId: 'exercise-1',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
),
currentSetIndex: 0,
);
expect(latest, isNotNull);
expect(latest!.setIndex, 0);
expect(latest.actualReps, 7);
});
test('performance reference finds records by metric rules', () async {
final now = DateTime.utc(2026, 7, 22, 12);
await historyRepository.save(
_history(
id: 'history-records',
startedAt: now,
results: [
_historySetResult(
id: 'result-reps-low',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
actualTimeMs: 30000,
actualReps: 6,
actualScore: 15,
),
_historySetResult(
id: 'result-reps-high',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now,
actualTimeMs: 45000,
actualReps: 12,
actualScore: 20,
),
_historySetResult(
id: 'result-stopwatch-slow',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 2,
startedAt: now,
scoreInputMode: ScoreInputMode.stopwatch,
actualScoreTimeMs: 11000,
),
_historySetResult(
id: 'result-stopwatch-fast',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 3,
startedAt: now,
scoreInputMode: ScoreInputMode.stopwatch,
actualScoreTimeMs: 9000,
),
],
),
);
final reps = await performanceReferenceRepository.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.reps,
scoreInputMode: ScoreInputMode.manual,
);
final time = await performanceReferenceRepository.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.time,
scoreInputMode: ScoreInputMode.manual,
);
final manualScore = await performanceReferenceRepository
.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.score,
scoreInputMode: ScoreInputMode.manual,
);
final stopwatchScore = await performanceReferenceRepository
.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.score,
scoreInputMode: ScoreInputMode.stopwatch,
);
expect(reps!.actualReps, 12);
expect(time!.actualTimeMs, 45000);
expect(manualScore!.actualScore, 20);
expect(stopwatchScore!.actualScoreTimeMs, 9000);
});
test('performance reference matches archived source exercise id', () async {
final now = DateTime.utc(2026, 7, 22, 13);
await exerciseRepository.save(
Exercise(
metadata: _metadata('exercise-archived', now),
name: 'Archived drill',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
archivedAt: now,
),
);
await historyRepository.save(
_history(
id: 'history-archived',
startedAt: now,
result: _historySetResult(
id: 'result-archived',
historyId: 'history-archived',
sourceExerciseId: 'exercise-archived',
setIndex: 0,
startedAt: now,
actualReps: 13,
),
),
);
expect(
await performanceReferenceRepository.hasAnyCompletedHistoryForExercise(
'exercise-archived',
),
isTrue,
);
final latest = await performanceReferenceRepository
.findLatestSetPerformance(
exerciseId: 'exercise-archived',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
),
currentSetIndex: 0,
);
expect(latest!.actualReps, 13);
});
test(
'performance reference use case prioritizes record score metric',
() async {
final repository = _FakePerformanceReferenceRepository();
final useCase = ExercisePerformanceReferenceUseCase(
repository: repository,
);
final reference = await useCase.getExercisePerformanceReference(
exerciseId: 'exercise-1',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: true,
repsEnabled: true,
scoreEnabled: true,
),
currentSetIndex: 0,
);
expect(reference.hasAnyHistoryForExercise, isTrue);
expect(repository.requestedMetric, PerformanceMetric.score);
},
);
}
@ -858,6 +1121,7 @@ String _resolvedSnapshot() {
'exercises': [
{
'id': 'exercise-snapshot-1',
'sourceExerciseId': 'exercise-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': 1,
'timeEnabled': true,
@ -888,6 +1152,71 @@ EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
);
}
WorkoutHistory _history({
required String id,
required DateTime startedAt,
WorkoutHistorySetResult? result,
List<WorkoutHistorySetResult>? results,
}) {
return WorkoutHistory(
metadata: _metadata(id, startedAt),
nameSnapshot: id,
startedAt: startedAt,
endedAt: startedAt.add(const Duration(minutes: 5)),
totalActiveMs: 300000,
completed: true,
historySnapshotJson: '{"name":"$id"}',
results: results ?? [result!],
);
}
WorkoutHistorySetResult _historySetResult({
required String id,
required String historyId,
required String sourceExerciseId,
required int setIndex,
required DateTime startedAt,
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
SetResultStatus status = SetResultStatus.completed,
}) {
final scoreEnabled = actualScore != null || actualScoreTimeMs != null;
return WorkoutHistorySetResult(
metadata: _metadata(id, startedAt),
workoutHistoryId: historyId,
programSnapshotId: 'program-snapshot',
exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
programIndex: 0,
exerciseIndex: 0,
setIndex: setIndex,
programNameSnapshot: 'Program',
exerciseNameSnapshot: 'Exercise',
timeEnabledSnapshot: actualTimeMs != null,
repsEnabledSnapshot:
actualReps != null || (actualTimeMs == null && !scoreEnabled),
scoreEnabledSnapshot: scoreEnabled,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
scoreInputModeSnapshot: scoreInputMode,
scoreLabelSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
? 'Score'
: null,
scoreUnitSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
? 'pts'
: null,
sourceExerciseIdSnapshot: sourceExerciseId,
completedAt: status == SetResultStatus.completed
? startedAt.add(const Duration(minutes: 1))
: null,
status: status,
);
}
ProgramExercise _programExercise(
String id,
DateTime now, {
@ -1019,3 +1348,40 @@ final class _FakeIds implements IdGenerator {
return 'id-$_next';
}
}
final class _FakePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository {
PerformanceMetric? requestedMetric;
@override
Future<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
return true;
}
@override
Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
required String exerciseId,
required ActivePerformanceMeasures activeMeasures,
required int currentSetIndex,
}) async {
return null;
}
@override
Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
required String exerciseId,
required PerformanceMetric metric,
required ScoreInputMode scoreInputMode,
}) async {
requestedMetric = metric;
return WorkoutHistoryMetricPerformance(
workoutHistoryId: 'history-1',
startedAt: DateTime.utc(2026, 7, 22),
setIndex: 0,
exerciseNameSnapshot: 'Exercise',
metric: metric,
scoreInputModeSnapshot: scoreInputMode,
actualScore: 10,
);
}
}

View File

@ -211,6 +211,9 @@ final class _FakeBootstrap implements AppDependencies {
workoutHistoryUseCases = WorkoutHistoryUseCases(
repository: _FakeWorkoutHistoryRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
),
exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase(
repository: _FakeExercisePerformanceReferenceRepository(),
);
@override
@ -245,6 +248,9 @@ final class _FakeBootstrap implements AppDependencies {
@override
final WorkoutHistoryUseCases workoutHistoryUseCases;
@override
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
}
String _sessionSnapshot() {
@ -704,3 +710,29 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
}
final class _FakeExercisePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository {
@override
Future<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
return false;
}
@override
Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
required String exerciseId,
required ActivePerformanceMeasures activeMeasures,
required int currentSetIndex,
}) async {
return null;
}
@override
Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
required String exerciseId,
required PerformanceMetric metric,
required ScoreInputMode scoreInputMode,
}) async {
return null;
}
}