feat(statistiques): implémentation backend statistiques de progression (ticket #82)
Lots B1+B2+B3 : schéma/domaine, requêtes d'agrégation et ports/use cases pour les statistiques de progression, avec tests associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2255,6 +2255,168 @@ void main() {
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Progression stats use case resolves four week overview', () async {
|
||||
final repository = _FakeProgressionStatsRepository();
|
||||
final now = DateTime.utc(2026, 7, 22, 12);
|
||||
repository.globalStats = ProgressionGlobalStatsData(
|
||||
completedSessionCount: 2,
|
||||
totalActiveMs: 180000,
|
||||
activeWeekStarts: [DateTime(2026, 7, 13), DateTime(2026, 7, 20)],
|
||||
hasAnyCompletedHistory: true,
|
||||
);
|
||||
repository.exerciseOptions = [
|
||||
ProgressionExerciseOptionData(
|
||||
exerciseKey: 'exercise-1',
|
||||
nameSnapshot: 'Shoot',
|
||||
isArchived: false,
|
||||
lastPerformedAt: now,
|
||||
),
|
||||
];
|
||||
repository.measureOptions['exercise-1'] = const [
|
||||
ProgressionMeasureOptionData(
|
||||
measure: ProgressionMeasure.manualScore,
|
||||
label: 'Score',
|
||||
scoreLabel: 'Réussites',
|
||||
scoreUnit: 'paniers',
|
||||
lowerIsBetter: false,
|
||||
),
|
||||
];
|
||||
|
||||
final overview = await ProgressionStatsUseCase(
|
||||
repository: repository,
|
||||
clock: _FakeClock(now),
|
||||
).getOverview(ProgressionPeriod.fourWeeks);
|
||||
|
||||
expect(repository.lastGlobalRange!.startedAt, DateTime(2026, 6, 29));
|
||||
expect(repository.lastGlobalRange!.endedAt, now);
|
||||
expect(overview.totalWeekCount, 4);
|
||||
expect(overview.activeWeekCount, 2);
|
||||
expect(overview.rangeLabelKind, ProgressionRangeLabelKind.sinceDate);
|
||||
expect(
|
||||
overview.exerciseOptions.single.measures.single.scoreUnit,
|
||||
'paniers',
|
||||
);
|
||||
});
|
||||
|
||||
test('Progression stats use case resolves all-time empty overview', () async {
|
||||
final repository = _FakeProgressionStatsRepository();
|
||||
repository.globalStats = const ProgressionGlobalStatsData(
|
||||
completedSessionCount: 0,
|
||||
totalActiveMs: 0,
|
||||
activeWeekStarts: [],
|
||||
hasAnyCompletedHistory: false,
|
||||
);
|
||||
|
||||
final overview = await ProgressionStatsUseCase(
|
||||
repository: repository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
||||
).getOverview(ProgressionPeriod.all);
|
||||
|
||||
expect(repository.lastGlobalRange!.startedAt, isNull);
|
||||
expect(overview.totalWeekCount, isNull);
|
||||
expect(overview.rangeLabelKind, ProgressionRangeLabelKind.none);
|
||||
expect(overview.hasAnyCompletedHistory, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'Progression stats use case maps series summaries and empty period',
|
||||
() async {
|
||||
final repository = _FakeProgressionStatsRepository();
|
||||
final now = DateTime.utc(2026, 7, 22, 12);
|
||||
repository.series = ProgressionExerciseSeriesData(
|
||||
exerciseKey: 'exercise-1',
|
||||
exerciseNameSnapshot: 'Shoot',
|
||||
measure: const ProgressionMeasureOptionData(
|
||||
measure: ProgressionMeasure.reps,
|
||||
label: 'Répétitions',
|
||||
lowerIsBetter: false,
|
||||
),
|
||||
points: [
|
||||
ProgressionPointData(
|
||||
workoutHistoryId: 'history-1',
|
||||
workoutNameSnapshot: 'Séance 1',
|
||||
startedAt: now.subtract(const Duration(days: 1)),
|
||||
value: 12,
|
||||
rawValue: 12,
|
||||
),
|
||||
ProgressionPointData(
|
||||
workoutHistoryId: 'history-2',
|
||||
workoutNameSnapshot: 'Séance 2',
|
||||
startedAt: now,
|
||||
value: 15,
|
||||
rawValue: 15,
|
||||
),
|
||||
],
|
||||
hasAnyAllTimeData: true,
|
||||
hasAnyCompletedExerciseResult: true,
|
||||
);
|
||||
|
||||
final series =
|
||||
await ProgressionStatsUseCase(
|
||||
repository: repository,
|
||||
clock: _FakeClock(now),
|
||||
).getExerciseSeries(
|
||||
period: ProgressionPeriod.fourWeeks,
|
||||
exerciseKey: 'exercise-1',
|
||||
measure: ProgressionMeasure.reps,
|
||||
);
|
||||
|
||||
expect(series.state, ProgressionSeriesState.ready);
|
||||
expect(series.summary.kind, ProgressionSeriesSummaryKind.totals);
|
||||
expect(series.summary.recentTotal, 27);
|
||||
expect(series.summary.lastSessionTotal, 15);
|
||||
|
||||
repository.series = const ProgressionExerciseSeriesData(
|
||||
exerciseKey: 'exercise-1',
|
||||
exerciseNameSnapshot: 'Shoot',
|
||||
measure: ProgressionMeasureOptionData(
|
||||
measure: ProgressionMeasure.manualScore,
|
||||
label: 'Score',
|
||||
lowerIsBetter: false,
|
||||
),
|
||||
points: [],
|
||||
hasAnyAllTimeData: true,
|
||||
hasAnyCompletedExerciseResult: true,
|
||||
);
|
||||
final empty =
|
||||
await ProgressionStatsUseCase(
|
||||
repository: repository,
|
||||
clock: _FakeClock(now),
|
||||
).getExerciseSeries(
|
||||
period: ProgressionPeriod.fourWeeks,
|
||||
exerciseKey: 'exercise-1',
|
||||
measure: ProgressionMeasure.manualScore,
|
||||
);
|
||||
|
||||
expect(empty.state, ProgressionSeriesState.emptyButHasAllTimeData);
|
||||
expect(empty.summary.kind, ProgressionSeriesSummaryKind.none);
|
||||
|
||||
repository.series = const ProgressionExerciseSeriesData(
|
||||
exerciseKey: 'exercise-1',
|
||||
exerciseNameSnapshot: 'Shoot',
|
||||
measure: ProgressionMeasureOptionData(
|
||||
measure: ProgressionMeasure.manualScore,
|
||||
label: 'Score',
|
||||
lowerIsBetter: false,
|
||||
),
|
||||
points: [],
|
||||
hasAnyAllTimeData: false,
|
||||
hasAnyCompletedExerciseResult: true,
|
||||
);
|
||||
final noGraphable =
|
||||
await ProgressionStatsUseCase(
|
||||
repository: repository,
|
||||
clock: _FakeClock(now),
|
||||
).getExerciseSeries(
|
||||
period: ProgressionPeriod.all,
|
||||
exerciseKey: 'exercise-1',
|
||||
measure: ProgressionMeasure.manualScore,
|
||||
);
|
||||
|
||||
expect(noGraphable.state, ProgressionSeriesState.noGraphableMeasure);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ExerciseStep _step({
|
||||
@ -2313,6 +2475,65 @@ final class _FakeIds implements IdGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeProgressionStatsRepository
|
||||
implements ProgressionStatsRepository {
|
||||
ProgressionDateRange? lastGlobalRange;
|
||||
ProgressionDateRange? lastSeriesRange;
|
||||
ProgressionGlobalStatsData globalStats = const ProgressionGlobalStatsData(
|
||||
completedSessionCount: 0,
|
||||
totalActiveMs: 0,
|
||||
activeWeekStarts: [],
|
||||
hasAnyCompletedHistory: false,
|
||||
);
|
||||
List<ProgressionExerciseOptionData> exerciseOptions = const [];
|
||||
final measureOptions = <String, List<ProgressionMeasureOptionData>>{};
|
||||
ProgressionExerciseSeriesData series = const ProgressionExerciseSeriesData(
|
||||
exerciseKey: 'exercise-1',
|
||||
exerciseNameSnapshot: 'Exercise',
|
||||
measure: ProgressionMeasureOptionData(
|
||||
measure: ProgressionMeasure.manualScore,
|
||||
label: 'Score',
|
||||
lowerIsBetter: false,
|
||||
),
|
||||
points: [],
|
||||
hasAnyAllTimeData: false,
|
||||
hasAnyCompletedExerciseResult: false,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<ProgressionGlobalStatsData> readGlobalStats(
|
||||
ProgressionDateRange range,
|
||||
) async {
|
||||
lastGlobalRange = range;
|
||||
return globalStats;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
|
||||
ProgressionDateRange range,
|
||||
) async {
|
||||
return exerciseOptions;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
|
||||
required ProgressionDateRange range,
|
||||
required String exerciseKey,
|
||||
}) async {
|
||||
return measureOptions[exerciseKey] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProgressionExerciseSeriesData> readExerciseSeries({
|
||||
required ProgressionDateRange range,
|
||||
required String exerciseKey,
|
||||
required ProgressionMeasure measure,
|
||||
}) async {
|
||||
lastSeriesRange = range;
|
||||
return series;
|
||||
}
|
||||
}
|
||||
|
||||
AuthUseCases _authUseCase({
|
||||
_FakeAuthTokenStore? tokenStore,
|
||||
_FakeOnlineAccountRepository? accountRepository,
|
||||
@ -3253,10 +3474,8 @@ ActiveWorkoutSession _sessionWithSnapshot({
|
||||
'exerciseStepsSnapshot': exerciseSteps
|
||||
.map((step) => step.toSnapshotJson())
|
||||
.toList(),
|
||||
if (autoStartNextTimedStepSnapshot != null)
|
||||
'autoStartNextTimedStepSnapshot': autoStartNextTimedStepSnapshot,
|
||||
if (autoStartNextTimedStepOverride != null)
|
||||
'autoStartNextTimedStepOverride': autoStartNextTimedStepOverride,
|
||||
'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot,
|
||||
'autoStartNextTimedStepOverride': ?autoStartNextTimedStepOverride,
|
||||
};
|
||||
return ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
|
||||
Reference in New Issue
Block a user