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:
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user