merge(develop): intègre la QA finale (ticket #11)

Fusionne feature/#11-qa-tests-fonctionnels — bugs identifiés en QA
corrigés, 21/21 tests verts, analyze propre, build APK debug validé.
Clôture la v1 (tickets #1 à #11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 19:28:45 +02:00
7 changed files with 406 additions and 8 deletions

View File

@ -1,7 +1,7 @@
---
issueRef: "#11"
version: 7
version: 9
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784301397898
updatedAt: 1784309299325
---
Points à couvrir en priorité : (1) impossibilité de modifier les mesures activées ou l'ordre des exercices depuis une séance-modèle, seuls setsCount et cibles numériques doivent être modifiables ; (2) reprise de séance après kill complet de l'app (pas juste mise en arrière-plan) ; (3) recalcul correct des timers de repos ajustés (+/-15s) après reprise ; (4) intégrité de l'historique quand l'exercice, le programme ou la séance-modèle source ont été supprimés/archivés entre-temps ; (5) règle "au moins une mesure active" sur un exercice. Rapport d'échec réel obligatoire (commande, sortie brute, diagnostic) — pas d'enjolivement si KO, cf. règle du cycle dans le contexte Main.

View File

@ -2,7 +2,7 @@
id: "9592e96c-2c9f-439c-87f0-42b9147c1e00"
number: 11
title: "[QA] Plan et exécution des tests fonctionnels GameTime"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#6","kind":"dependsOn"},{"target":"#7","kind":"dependsOn"},{"target":"#8","kind":"dependsOn"},{"target":"#9","kind":"dependsOn"},{"target":"#10","kind":"dependsOn"}]
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784301312946
updatedAt: 1784301397898
version: 7
updatedAt: 1784309299325
version: 9
---
Écrire et exécuter les tests couvrant : CRUD Exercice/Programme/Séance-modèle, règles d'overrides limités en séance, robustesse de la session active (reprise après fermeture/mise en arrière-plan de l'app, recalcul des timers depuis horodatages), génération correcte de l'historique (snapshot autonome), relance de séance (via séance-modèle et via snapshot si séance-modèle supprimée). Rapport d'échec réel (commande + sortie + diagnostic) sans enjoliver en cas de KO.

View File

@ -125,13 +125,13 @@
"issueRef": "#11",
"path": "11",
"title": "[QA] Plan et exécution des tests fonctionnels GameTime",
"status": "open",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"7efa512f-3b3a-47b5-ade0-a2dd13073055"
],
"updatedAt": 1784301397898
"updatedAt": 1784309299325
},
{
"issueRef": "#12",

View File

@ -458,11 +458,17 @@ final class WorkoutTemplateUseCases {
}
final templateOverrides = <WorkoutTemplateExerciseOverride>[];
for (final input in overrides) {
final programInput = programs.firstWhere(
(program) => program.clientKey == input.workoutTemplateProgramClientKey,
orElse: () =>
throw const DomainException('Workout template program not found.'),
);
final workoutTemplateProgramId =
programIdsByClientKey[input.workoutTemplateProgramClientKey];
if (workoutTemplateProgramId == null) {
throw const DomainException('Workout template program not found.');
}
_validateOverrideTargets(programInput.programSnapshotJson, input);
templateOverrides.add(
WorkoutTemplateExerciseOverride(
metadata:
@ -833,6 +839,14 @@ final class CloseWorkoutSessionUseCase {
final now = clock.now();
final results = await sessionRepository.listSetResults(sessionId);
final historyId = ids.newId();
final historyResults = _historyResultsFromActiveResults(
historyId: historyId,
results: results,
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
now: now,
ids: ids,
originDeviceId: originDeviceId,
);
final history = WorkoutHistory(
metadata: EntityMetadata(
id: historyId,
@ -867,6 +881,7 @@ final class CloseWorkoutSessionUseCase {
)
.toList(),
}),
results: historyResults,
);
await historyRepository.save(history);
return history;
@ -886,6 +901,142 @@ final class WorkoutHistoryUseCases {
Future<void> delete(String id) => repository.delete(id, clock.now());
}
void _validateOverrideTargets(
String programSnapshotJson,
WorkoutTemplateExerciseOverrideConfig input,
) {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises = (snapshot['exercises'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final exercise = exercises.cast<Map<String, dynamic>?>().firstWhere(
(item) =>
item?['id'] == input.snapshotProgramExerciseId ||
item?['snapshotProgramExerciseId'] == input.snapshotProgramExerciseId,
orElse: () => null,
);
if (exercise == null) {
throw const DomainException('Snapshot exercise not found.');
}
if (input.targetTimeSecondsOverride != null &&
exercise['timeEnabled'] != true) {
throw const DomainException('Cannot override inactive time target.');
}
if (input.targetRepsOverride != null && exercise['repsEnabled'] != true) {
throw const DomainException('Cannot override inactive reps target.');
}
if (input.targetScoreOverride != null && exercise['scoreEnabled'] != true) {
throw const DomainException('Cannot override inactive score target.');
}
}
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
required String historyId,
required List<ActiveSetResult> results,
required String resolvedTemplateSnapshotJson,
required DateTime now,
required IdGenerator ids,
required String originDeviceId,
}) {
final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson);
return results.map((result) {
final snapshot = snapshots[result.exerciseSnapshotId];
return WorkoutHistorySetResult(
metadata: _newMetadata(ids, originDeviceId, now),
workoutHistoryId: historyId,
programSnapshotId: result.programSnapshotId,
exerciseSnapshotId: result.exerciseSnapshotId,
programIndex: result.programIndex,
exerciseIndex: result.exerciseIndex,
setIndex: result.setIndex,
programNameSnapshot:
snapshot?.programNameSnapshot ?? result.programSnapshotId,
exerciseNameSnapshot:
snapshot?.exerciseNameSnapshot ?? result.exerciseSnapshotId,
timeEnabledSnapshot: snapshot?.timeEnabled ?? result.actualTimeMs != null,
repsEnabledSnapshot: snapshot?.repsEnabled ?? result.actualReps != null,
scoreEnabledSnapshot:
snapshot?.scoreEnabled ?? result.actualScore != null,
targetTimeSecondsSnapshot: snapshot?.targetTimeSeconds,
targetRepsSnapshot: snapshot?.targetReps,
targetScoreSnapshot: snapshot?.targetScore,
actualTimeMs: result.actualTimeMs,
actualReps: result.actualReps,
actualScore: result.actualScore,
scoreLabelSnapshot:
result.scoreLabelSnapshot ?? snapshot?.scoreLabelSnapshot,
scoreUnitSnapshot:
result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot,
startedAt: result.startedAt,
completedAt: result.completedAt,
);
}).toList();
}
Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
String resolvedTemplateSnapshotJson,
) {
final decoded =
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
final snapshots = <String, _ResolvedExerciseSnapshot>{};
for (final program in programs.cast<Map<String, dynamic>>()) {
final programName = program['programNameSnapshot'] as String? ?? '';
final programSnapshotJson = program['programSnapshotJson'] as String?;
if (programSnapshotJson == null) {
continue;
}
final programSnapshot =
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises =
(programSnapshot['exercises'] as List<dynamic>? ?? const []);
for (final exercise in exercises.cast<Map<String, dynamic>>()) {
final id = exercise['id'] as String?;
if (id == null) {
continue;
}
snapshots[id] = _ResolvedExerciseSnapshot(
programNameSnapshot: programName,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
timeEnabled: exercise['timeEnabled'] == true,
repsEnabled: exercise['repsEnabled'] == true,
scoreEnabled: exercise['scoreEnabled'] == true,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
);
}
}
return snapshots;
}
final class _ResolvedExerciseSnapshot {
const _ResolvedExerciseSnapshot({
required this.programNameSnapshot,
required this.exerciseNameSnapshot,
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
});
final String programNameSnapshot;
final String exerciseNameSnapshot;
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
}
EntityMetadata _newMetadata(
IdGenerator ids,
String originDeviceId,

View File

@ -530,7 +530,7 @@ domain.EntityMetadata _metadataFromRow(dynamic row) {
);
}
List<Value<Object?>> _metadataValues(domain.EntityMetadata metadata) => [
List<dynamic> _metadataValues(domain.EntityMetadata metadata) => [
Value(metadata.id),
Value(metadata.createdAt),
Value(metadata.updatedAt),

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
@ -35,6 +37,55 @@ void main() {
);
});
test(
'Workout template use case rejects target overrides for inactive measures',
() async {
final useCase = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Séance tir',
programs: [
WorkoutTemplateProgramConfig(
clientKey: 'program-1',
programNameSnapshot: 'Programme tir',
defaultRestSecondsSnapshot: 30,
programSnapshotJson: jsonEncode({
'exercises': [
{
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Lancers francs',
'setsCount': 3,
'timeEnabled': true,
'repsEnabled': false,
'scoreEnabled': false,
'targetTimeSeconds': 60,
'targetReps': null,
'targetScore': null,
},
],
}),
),
],
overrides: const [
WorkoutTemplateExerciseOverrideConfig(
workoutTemplateProgramClientKey: 'program-1',
snapshotProgramExerciseId: 'exercise-snapshot-1',
targetRepsOverride: 10,
),
],
),
throwsA(isA<DomainException>()),
);
},
);
test('Active session elapsed time is based on persisted timestamps', () {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(

View File

@ -0,0 +1,196 @@
import 'dart:convert';
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;
void main() {
late local.AppDatabase database;
late local.DriftActiveSessionRepository activeRepository;
late local.DriftWorkoutTemplateRepository templateRepository;
late local.DriftWorkoutHistoryRepository historyRepository;
setUp(() {
database = local.AppDatabase(NativeDatabase.memory());
activeRepository = local.DriftActiveSessionRepository(database);
templateRepository = local.DriftWorkoutTemplateRepository(database);
historyRepository = local.DriftWorkoutHistoryRepository(database);
});
tearDown(() async {
await database.close();
});
test(
'running session elapsed time survives repository reconstruction',
() async {
final persistedAt = DateTime.utc(2026, 7, 17, 12);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1', persistedAt),
sourceWorkoutTemplateId: null,
status: ActiveWorkoutStatus.running,
startedAt: persistedAt.subtract(const Duration(seconds: 30)),
lastPersistedAt: persistedAt,
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
);
await activeRepository.save(session);
final afterAppKillRepository = local.DriftActiveSessionRepository(
database,
);
final afterAppKillUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: afterAppKillRepository,
templateRepository: templateRepository,
clock: _FakeClock(persistedAt.add(const Duration(seconds: 20))),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final restored = await afterAppKillRepository.findOpen();
expect(restored, isNotNull);
expect(afterAppKillUseCases.elapsedActiveMilliseconds(restored!), 50000);
},
);
test(
'closing a session stores autonomous history rows with set snapshots',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
final template = WorkoutTemplate(
metadata: _metadata('template-1', now),
name: 'Séance jambes',
);
await templateRepository.save(template);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1', now),
sourceWorkoutTemplateId: template.metadata.id,
status: ActiveWorkoutStatus.running,
startedAt: now.subtract(const Duration(minutes: 10)),
lastPersistedAt: now,
elapsedActiveMs: 600000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
);
await activeRepository.save(session);
await activeRepository.saveSetResult(
ActiveSetResult(
metadata: _metadata('active-result-1', now),
activeWorkoutSessionId: session.metadata.id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualTimeMs: 45000,
actualReps: 10,
actualScore: 80,
scoreLabelSnapshot: 'Charge',
scoreUnitSnapshot: 'kg',
completedAt: now,
),
);
final closeUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: _FakeClock(now.add(const Duration(seconds: 5))),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final history = await closeUseCase.close(
sessionId: session.metadata.id,
nameSnapshot: 'Séance jambes',
completed: true,
);
await templateRepository.save(
WorkoutTemplate(
metadata: template.metadata.markDeleted(now),
name: template.name,
),
);
final restored = await historyRepository.findById(history.metadata.id);
expect(restored, isNotNull);
expect(restored!.nameSnapshot, 'Séance jambes');
expect(restored.results, hasLength(1));
expect(restored.results.single.programNameSnapshot, 'Programme jambes');
expect(restored.results.single.exerciseNameSnapshot, 'Squat');
expect(restored.results.single.actualTimeMs, 45000);
expect(restored.results.single.actualReps, 10);
expect(restored.results.single.actualScore, 80);
expect(restored.results.single.scoreUnitSnapshot, 'kg');
},
);
}
String _resolvedSnapshot() {
return jsonEncode({
'name': 'Séance jambes',
'programs': [
{
'id': 'program-snapshot-1',
'programNameSnapshot': 'Programme jambes',
'programSnapshotJson': jsonEncode({
'exercises': [
{
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': 1,
'timeEnabled': true,
'repsEnabled': true,
'scoreEnabled': true,
'targetTimeSeconds': 45,
'targetReps': 10,
'targetScore': 80,
'scoreLabelSnapshot': 'Charge',
'scoreUnitSnapshot': 'kg',
'restSecondsOverride': 0,
},
],
}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id, DateTime now) {
return EntityMetadata(
id: id,
createdAt: now,
updatedAt: now,
originDeviceId: 'device-1',
);
}
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';
}
}