feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre

Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 05:56:12 +02:00
parent c65a5a76a9
commit 65d43b9768
80 changed files with 12292 additions and 892 deletions

View File

@ -0,0 +1,126 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('builds active timer notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Squats bulgares',
dominantTimer: _timer(label: 'Chrono étape', accumulatedMs: 134000),
),
);
expect(content.title, 'Squats bulgares');
expect(content.primaryLine, '02:14');
expect(content.secondaryLine, 'Série 2/4');
});
test('builds manual score notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Lancers francs',
hasManualScore: true,
currentManualScoreValue: 8,
),
);
expect(content.title, 'Lancers francs');
expect(content.primaryLine, 'Série 2/4 · 8');
});
test('builds rest notification content with next exercise', () {
final now = DateTime.utc(2026, 7, 26, 10);
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.restRunning,
exerciseName: 'Squats',
nextExerciseName: 'Fentes',
dominantTimer: _timer(
kind: WatchTimerKind.rest,
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
accumulatedMs: 10000,
targetMs: 60000,
startedAtEpochMs: now.millisecondsSinceEpoch,
),
),
now: now.add(const Duration(seconds: 5)),
);
expect(content.title, 'Repos');
expect(content.primaryLine, '00:45 restant · Ensuite : Fentes');
});
test('prefixes paused content without ticking', () {
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.paused,
dominantTimer: _timer(
runState: WatchTimerRunState.paused,
accumulatedMs: 42000,
),
),
now: DateTime.utc(2026, 7, 26, 10),
);
expect(content.primaryLine, 'En pause · 00:42');
});
test('falls back to current step name without timer or score', () {
final content = buildSessionNotificationContent(
_projection(stepName: 'Gainage'),
);
expect(content.primaryLine, 'Gainage');
});
}
WatchSessionProjection _projection({
WatchSessionPhase phase = WatchSessionPhase.running,
String exerciseName = 'Pompes',
String? stepName,
WatchTimerProjection? dominantTimer,
bool hasManualScore = false,
double? currentManualScoreValue,
String? nextExerciseName,
}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
phase: phase,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: exerciseName,
stepName: stepName,
dominantTimer: dominantTimer,
primaryAction: WatchPrimaryAction.pauseSession,
nextExerciseName: nextExerciseName,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: (currentManualScoreValue ?? 0) > 0,
);
}
WatchTimerProjection _timer({
WatchTimerKind kind = WatchTimerKind.step,
String label = 'Chrono',
WatchTimerDisplayMode displayMode = WatchTimerDisplayMode.elapsed,
WatchTimerRunState runState = WatchTimerRunState.stopped,
int accumulatedMs = 0,
int? startedAtEpochMs,
int? targetMs,
}) {
return WatchTimerProjection(
kind: kind,
label: label,
displayMode: displayMode,
runState: runState,
referenceEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
accumulatedMs: accumulatedMs,
startedAtEpochMs: startedAtEpochMs,
targetMs: targetMs,
);
}

View File

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('Exercise requires at least one active measure', () {
@ -285,6 +286,40 @@ void main() {
);
});
test('linked step score requires manual series score', () async {
final linkedStep = _step(
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'pts',
linkedToSeriesScore: true,
);
final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater(
useCase.create(
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [linkedStep],
),
throwsA(isA<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Tirs chrono',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetReps: 1,
steps: [linkedStep],
),
throwsA(isA<DomainException>()),
);
});
test('Exercise image gallery rejects a sixth image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
@ -560,6 +595,26 @@ void main() {
expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse);
});
test('program saveConfigured rejects empty exercise list', () async {
final useCase = ProgramUseCases(
programRepository: _FakeProgramRepository(),
exerciseRepository: _FakeExerciseRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Programme',
defaultRestSeconds: 60,
exercises: const [],
),
throwsA(isA<DomainException>()),
);
});
test('workout template override preserves step chaining override', () async {
final templateRepository = _FakeWorkoutTemplateRepository();
final useCase = WorkoutTemplateUseCases(
@ -603,6 +658,54 @@ void main() {
expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
});
test('workout template saveConfigured rejects empty program list', () 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',
programs: const [],
overrides: const [],
),
throwsA(isA<DomainException>()),
);
});
test(
'workout template saveConfigured rejects program snapshot without sets',
() 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',
programs: [
WorkoutTemplateProgramConfig(
clientKey: 'program-1',
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
overrides: const [],
),
throwsA(isA<DomainException>()),
);
},
);
test('delete exercise keeps existing program snapshots unchanged', () async {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(
@ -854,6 +957,152 @@ void main() {
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
});
test(
'startFromTemplate rejects template without playable exercise',
() async {
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.add(
WorkoutTemplate(
metadata: _metadata('template-1'),
name: 'Séance vide',
),
);
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.startFromTemplate('template-1'),
throwsA(isA<DomainException>()),
);
expect(repository.session, isNull);
},
);
test('startFromTemplate stamps the source template start time', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.add(_playableTemplate(id: 'template-1', name: 'A'));
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final session = await useCase.startFromTemplate('template-1');
expect(session.sourceWorkoutTemplateId, 'template-1');
expect(repository.session, session);
expect(templateRepository.templates.single.lastStartedAt, clock.now());
});
test(
'startFromLastTemplate starts most recently used playable template',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.addAll([
_playableTemplate(
id: 'older',
name: 'B',
lastStartedAt: DateTime.utc(2026, 7, 10, 12),
),
_playableTemplate(
id: 'latest',
name: 'A',
lastStartedAt: DateTime.utc(2026, 7, 12, 12),
),
WorkoutTemplate(metadata: _metadata('empty'), name: 'Empty'),
]);
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final session = await useCase.startFromLastTemplate();
expect(session.sourceWorkoutTemplateId, 'latest');
expect(repository.session, session);
},
);
test('resume abandons invalid session snapshot before throwing', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.paused,
startedAt: clock.now(),
pausedAt: clock.now(),
lastPersistedAt: clock.now(),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
await expectLater(
useCase.resume(session.metadata.id),
throwsA(isA<DomainException>()),
);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
expect(repository.session?.endedAt, clock.now());
});
test('findOpen silently abandons invalid legacy session', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.running,
startedAt: clock.now(),
lastPersistedAt: clock.now(),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final open = await useCase.findOpen();
expect(open, isNull);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
expect(repository.session?.endedAt, clock.now());
});
test('findOpen returns playable open session unchanged', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final open = await useCase.findOpen();
expect(open, session);
expect(repository.session?.status, ActiveWorkoutStatus.running);
expect(repository.session?.endedAt, isNull);
});
test('adjustRestSeconds persists adjusted rest duration', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository()
@ -1420,6 +1669,78 @@ void main() {
},
);
test('linked step score controls current series manual score', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
scoreEnabled: true,
exerciseSteps: [
_step(
id: 'step-1',
position: 0,
name: 'Tirs main droite',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'pts',
linkedToSeriesScore: true,
),
],
);
final repository = _FakeActiveSessionRepository()..session = session;
final activeUseCase = _activeUseCase(repository, clock);
final stepUseCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
activeSessionUseCases: activeUseCase,
);
await stepUseCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.incrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.incrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.decrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(repository.manualScoreStates.values.single.value, 1);
final result = await stepUseCase.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 3,
);
expect(result.actualScore, 3);
expect(repository.manualScoreStates.values.single.value, 4);
});
test(
'step chaining resolution uses template override before program override',
() async {
@ -2257,6 +2578,365 @@ void main() {
);
});
test(
'recordCurrentSetResult uses live manual score unless overridden',
() async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final fromLiveState = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(fromLiveState.actualScore, 2);
expect(repository.manualScoreStates, isEmpty);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final overridden = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 5,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(overridden.actualScore, 5);
},
);
test('recordCurrentSetResult upserts by logical set key', () async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
final first = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 2,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
clock.value = DateTime.utc(2026, 7, 25, 12, 1);
final second = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 5,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(second.metadata.id, first.metadata.id);
expect(second.metadata.localRevision, first.metadata.localRevision + 1);
expect(repository.results, hasLength(1));
expect(repository.results.single.actualScore, 5);
});
test(
'setManualScore pushes phone corrections into live manual score state',
() async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
final corrected = await useCase.setManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: 7.5,
);
expect(corrected.state.value, 7.5);
expect(corrected.changed, isTrue);
final incremented = await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
final clamped = await useCase.setManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: -4,
);
expect(clamped.state.value, 0);
final decremented = await useCase.decrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(decremented.state.value, 0);
expect(decremented.changed, isFalse);
},
);
test('WorkoutHistoryUseCases applies heart rate summary once', () async {
final repository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: DateTime.utc(2026, 7, 25, 11),
endedAt: DateTime.utc(2026, 7, 25, 12),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final useCase = WorkoutHistoryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 12,
averageHeartRateBpm: 126.5,
maxHeartRateBpm: 171,
),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 14,
averageHeartRateBpm: 130,
maxHeartRateBpm: 180,
),
);
expect(repository.histories.single.averageHeartRateBpm, 126.5);
expect(repository.histories.single.maxHeartRateBpm, 171);
});
test(
'ActiveWorkoutSensorUseCases tracks live heart rate and calories',
() async {
final useCase = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
);
final updates = <ActiveWorkoutSensorState>[];
final subscription = useCase.updates.listen(updates.add);
expect(
useCase.recordHeartRateSample(
const WatchSensorSample(
sessionId: '',
recordedAtEpochMs: 0,
heartRateBpm: 120,
),
),
isNull,
);
useCase.recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
).millisecondsSinceEpoch,
heartRateBpm: 120,
),
);
final state = useCase.recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
30,
).millisecondsSinceEpoch,
heartRateBpm: 150,
),
);
await Future<void>.delayed(Duration.zero);
expect(state?.latestHeartRateBpm, 150);
expect(state?.sampleCount, 2);
expect(state?.averageHeartRateBpm, 135);
expect(state?.maxHeartRateBpm, 150);
expect(state?.estimatedCaloriesKcal, 187.5);
expect(updates, hasLength(2));
useCase.clear('session-1');
expect(useCase.current('session-1'), isNull);
await subscription.cancel();
await useCase.dispose();
},
);
test(
'ActiveWorkoutSensorUseCases tracks telemetry and ignores duplicates',
() async {
final useCase = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
);
final first = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
).millisecondsSinceEpoch,
heartRateBpm: 120,
distanceMeters: 500,
caloriesKcal: 42,
),
);
final duplicate = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
1,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 300,
caloriesKcal: 10,
),
);
final distanceOnly = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-2',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
2,
).millisecondsSinceEpoch,
distanceMeters: 620,
caloriesKcal: 48,
),
);
expect(first?.latestHeartRateBpm, 120);
expect(duplicate, isNull);
expect(distanceOnly?.sampleCount, 2);
expect(distanceOnly?.latestHeartRateBpm, 120);
expect(distanceOnly?.minHeartRateBpm, 120);
expect(distanceOnly?.averageHeartRateBpm, 120);
expect(distanceOnly?.maxHeartRateBpm, 120);
expect(distanceOnly?.latestDistanceMeters, 620);
expect(distanceOnly?.latestCaloriesKcal, 48);
await useCase.dispose();
},
);
test(
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
() async {
final repository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: DateTime.utc(2026, 7, 25, 11),
endedAt: DateTime.utc(2026, 7, 25, 12),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final useCase = WorkoutHistoryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 2,
averageHeartRateBpm: 126.5,
maxHeartRateBpm: 171,
),
);
expect(repository.histories.single.averageHeartRateBpm, isNull);
expect(repository.histories.single.maxHeartRateBpm, isNull);
},
);
test('Tags are normalized and validated on taggable entities', () {
final exercise = Exercise(
metadata: _metadata('exercise-tags'),
@ -3046,6 +3726,7 @@ ExerciseStep _step({
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
bool linkedToSeriesScore = false,
}) {
return ExerciseStep(
id: id,
@ -3059,6 +3740,7 @@ ExerciseStep _step({
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
);
}
@ -3720,6 +4402,43 @@ WorkoutTemplateProgram _templateProgram({
);
}
WorkoutTemplate _playableTemplate({
required String id,
required String name,
DateTime? lastStartedAt,
}) {
return WorkoutTemplate(
metadata: _metadata(id),
name: name,
lastStartedAt: lastStartedAt,
programs: [
WorkoutTemplateProgram(
metadata: _metadata('$id-program'),
workoutTemplateId: id,
sourceProgramId: null,
position: 0,
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({
'exercises': [
{
'id': '$id-exercise',
'exerciseNameSnapshot': 'Squat',
'setsCount': 2,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': 10,
'exerciseStepsSnapshot': const [],
'autoStartNextTimedStepSnapshot': false,
},
],
}),
),
],
);
}
final class _FakeExerciseRepository implements ExerciseRepository {
Exercise? exercise;
final saved = <Exercise>[];
@ -3970,6 +4689,72 @@ final class _FakeProgramRepository implements ProgramRepository {
}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final histories = <WorkoutHistory>[];
@override
Future<WorkoutHistory?> findById(String id) async {
return histories.where((history) => history.metadata.id == id).firstOrNull;
}
@override
Future<List<WorkoutHistory>> listActive() async {
return histories
.where((history) => history.metadata.deletedAt == null)
.toList();
}
@override
Future<void> save(WorkoutHistory history) async {
histories.removeWhere(
(existing) => existing.metadata.id == history.metadata.id,
);
histories.add(history);
}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
final index = histories.indexWhere(
(history) =>
history.metadata.id == historyId &&
history.metadata.deletedAt == null &&
history.averageHeartRateBpm == null &&
history.maxHeartRateBpm == null,
);
if (index == -1) {
return;
}
final history = histories[index];
histories[index] = history.copyWith(
metadata: history.metadata.touch(patchedAt),
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
);
}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
@override
Future<void> delete(String id, DateTime deletedAt) async {
final index = histories.indexWhere((history) => history.metadata.id == id);
if (index == -1) {
return;
}
histories[index] = histories[index].copyWith(
metadata: histories[index].metadata.markDeleted(deletedAt),
);
}
}
ActiveWorkoutSessionUseCases _activeUseCase(
_FakeActiveSessionRepository repository,
_FakeClock clock,
@ -3989,6 +4774,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -4022,6 +4808,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null,
)
.firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -4080,6 +4885,19 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -4136,6 +4954,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
@ -4177,6 +5000,26 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.remove(state.metadata.id);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final state = await findManualScoreState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
return;
}
manualScoreStates.remove(state.metadata.id);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.removeWhere(

View File

@ -213,6 +213,189 @@ void main() {
expect(env.repository.session?.currentSetIndex, 1);
});
test(
'accepts skipCurrentRest after a refresh that keeps the same revision',
() async {
final session = _session(setsCount: 2);
final repository = _FakeActiveSessionRepository()..session = session;
repository.restStates['rest'] = ActiveRestState(
metadata: _metadata('rest'),
activeWorkoutSessionId: session.metadata.id,
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
adjustedRestSeconds: 60,
startedAt: _now,
);
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final projectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final handler = WatchCompanionCommandHandler(
sessionRepository: repository,
activeSessionUseCases: activeUseCases,
stepUseCases: ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
activeSessionUseCases: activeUseCases,
),
projectionSource: projectionUseCases,
);
final firstProjection = await projectionUseCases.emitCurrentProjection();
final refreshedProjection = await projectionUseCases
.emitCurrentProjection();
final ack = await handler.dispatch(
_command(
WatchCommandType.skipCurrentRest,
expectedRevision: firstProjection.revision,
),
);
expect(refreshedProjection.revision, firstProjection.revision);
expect(ack, WatchCommandAck.accepted);
expect(repository.restStates['rest']?.skippedAt, isNotNull);
expect(repository.session?.currentSetIndex, 1);
await projectionUseCases.dispose();
},
);
test(
'routes incrementScore and decrementScore to manual score use cases',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 0);
},
);
test('routes score commands to independent current step score', () async {
final session = _session(
scoreEnabled: true,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final env = _env(
session: session,
projection: _projection(
hasManualScore: true,
manualScoreScope: WatchManualScoreScope.step,
),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.single.actualScore, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.last.actualScore, 0);
});
test('decrementScore at zero is accepted no-op', () async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
final ack = await env.dispatch(WatchCommandType.decrementScore);
expect(ack, WatchCommandAck.acceptedNoOp);
expect(env.repository.manualScoreStates.values.single.value, 0);
expect(env.projections.emitCount, 0);
});
test('rejects score commands outside manual score mode', () async {
final env = _env(
session: _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
),
projection: _projection(hasManualScore: false),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.rejectedNotApplicable,
);
expect(env.repository.manualScoreStates, isEmpty);
});
test(
'accepts repeated score commands with stale expected revisions',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(revision: 3, hasManualScore: true),
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-1',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-2',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 2);
},
);
test(
'rejects stale revision, non applicable, missing and mismatch',
() async {
@ -256,6 +439,22 @@ void main() {
);
},
);
test('rejects commands when no session is active', () async {
final env = _env(
projection: _projection(
phase: WatchSessionPhase.noActiveSession,
deviceSessionId: '',
primaryAction: WatchPrimaryAction.none,
),
);
expect(
await env.dispatch(WatchCommandType.startCurrentExercise),
WatchCommandAck.rejectedNoActiveSession,
);
expect(env.repository.session, isNull);
});
}
final _now = DateTime.utc(2026, 7, 25, 12);
@ -263,13 +462,14 @@ final _now = DateTime.utc(2026, 7, 25, 12);
_Harness _env({
ActiveWorkoutSession? session,
required WatchSessionProjection projection,
_FakeWorkoutTemplateRepository? templateRepository,
}) {
final repository = _FakeActiveSessionRepository()..session = session;
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
@ -304,20 +504,27 @@ final class _Harness {
final _FakeProjectionSource projections;
final WatchCompanionCommandHandler handler;
Future<WatchCommandAck> dispatch(WatchCommandType type) {
return handler.dispatch(_command(type));
Future<WatchCommandAck> dispatch(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return handler.dispatch(
_command(type, commandId: commandId, expectedRevision: expectedRevision),
);
}
}
WatchCommandEnvelope _command(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return WatchCommandEnvelope(
commandId: commandId,
type: type,
sessionId: 'session-1',
expectedRevision: 1,
expectedRevision: expectedRevision,
sentAtEpochMs: _now.millisecondsSinceEpoch,
);
}
@ -331,6 +538,10 @@ WatchSessionProjection _projection({
WatchSecondaryAction.finishCurrentSet,
WatchSecondaryAction.skipCurrentSet,
],
bool hasManualScore = false,
double? currentManualScoreValue,
bool canDecrementScore = false,
WatchManualScoreScope? manualScoreScope,
}) {
return WatchSessionProjection(
deviceSessionId: deviceSessionId,
@ -343,6 +554,12 @@ WatchSessionProjection _projection({
exerciseName: 'Squat',
primaryAction: primaryAction,
secondaryActions: secondaryActions,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: canDecrementScore,
manualScoreScope:
manualScoreScope ??
(hasManualScore ? WatchManualScoreScope.series : null),
);
}
@ -354,6 +571,8 @@ ActiveWorkoutSession _session({
bool timeEnabled = false,
int? targetReps = 10,
int restSeconds = 0,
bool scoreEnabled = false,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<ExerciseStep> steps = const [],
}) {
final exerciseSnapshot = {
@ -362,9 +581,9 @@ ActiveWorkoutSession _session({
'setsCount': setsCount,
'timeEnabled': timeEnabled,
'repsEnabled': true,
'scoreEnabled': false,
'scoreEnabled': scoreEnabled,
'targetReps': targetReps,
'scoreInputModeSnapshot': ScoreInputMode.manual.name,
'scoreInputModeSnapshot': scoreInputMode.name,
'restSecondsOverride': restSeconds,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -395,13 +614,24 @@ ActiveWorkoutSession _session({
);
}
ExerciseStep _step({String id = 'step-1', int position = 0}) {
ExerciseStep _step({
String id = 'step-1',
int position = 0,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
position: position,
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: 1,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -473,6 +703,10 @@ final class _FakeProjectionSource implements WatchProjectionSource {
exerciseName: projection.exerciseName,
primaryAction: projection.primaryAction,
secondaryActions: projection.secondaryActions,
hasManualScore: projection.hasManualScore,
currentManualScoreValue: projection.currentManualScoreValue,
canDecrementScore: projection.canDecrementScore,
manualScoreScope: projection.manualScoreScope,
);
return projection;
}
@ -499,11 +733,24 @@ final class _FakeIds implements IdGenerator {
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
final templates = <WorkoutTemplate>[];
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
Future<WorkoutTemplate?> findById(String id) async {
for (final template in templates) {
if (template.metadata.id == id && template.metadata.deletedAt == null) {
return template;
}
}
return null;
}
@override
Future<List<WorkoutTemplate>> listActive() async {
return templates
.where((template) => template.metadata.deletedAt == null)
.toList();
}
@override
Future<void> replaceComposition(
@ -512,7 +759,10 @@ final class _FakeWorkoutTemplateRepository
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
Future<void> save(WorkoutTemplate template) async {
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
templates.add(template);
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@ -527,6 +777,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -541,6 +792,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -584,6 +846,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -633,6 +910,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -661,6 +947,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.removeWhere((item) => item.metadata.id == result.metadata.id);
stepResults.add(result);
}
@ -674,6 +961,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);

View File

@ -71,10 +71,10 @@ void main() {
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.step);
expect(projection.dominantTimer?.accumulatedMs, 0);
expect(projection.dominantTimer?.accumulatedMs, 5000);
expect(
projection.dominantTimer?.startedAtEpochMs,
now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch,
now.millisecondsSinceEpoch,
);
expect(projection.secondaryTimers.map((timer) => timer.kind), [
WatchTimerKind.scoreStopwatch,
@ -88,6 +88,103 @@ void main() {
},
);
test('projects manual score state for the current set', () async {
final session = _session(scoreEnabled: true, targetScore: 8);
final repository = _FakeActiveSessionRepository()
..session = session
..manualScoreStates['manual-score'] = ActiveManualScoreState(
metadata: _metadata('manual-score'),
activeWorkoutSessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: 3,
updatedAt: DateTime.utc(2026, 7, 25, 12),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 3);
expect(projection.canDecrementScore, isTrue);
expect(projection.manualScoreTargetValue, 8);
expect(projection.manualScoreTargetLabel, 'Cible');
expect(projection.manualScoreScope, WatchManualScoreScope.series);
});
test(
'projects independent current step manual score before series score',
() async {
final session = _session(
scoreEnabled: true,
targetScore: 8,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.waitingManual,
)
..stepResults.add(
ActiveExerciseStepResult(
metadata: _metadata('step-score'),
activeWorkoutSessionId: session.metadata.id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-1',
stepNameSnapshot: 'Step 1',
stepTypeSnapshot: ExerciseStepType.time,
targetValueSnapshot: 1,
hasScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Réussites',
scoreUnitSnapshot: 'pts',
targetScoreSnapshot: 5,
status: SetResultStatus.completed,
actualScore: 2,
),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 2);
expect(projection.manualScoreTargetValue, 5);
expect(projection.manualScoreScope, WatchManualScoreScope.step);
},
);
test('does not project manual score controls for stopwatch score', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
});
test('projects paused after a running session is paused', () async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
@ -282,14 +379,22 @@ void main() {
);
test(
'emits projections through stream and publisher with incremented revision',
'keeps revision stable when refresh only changes volatile timing fields',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final clock = _clock(now);
final session = _session(steps: [_step(defaultTargetValue: 30)]);
final repository = _FakeActiveSessionRepository()
..session = _session(steps: [_step()]);
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
@ -298,21 +403,54 @@ void main() {
final subscription = useCases.projections.listen(emitted.add);
final first = await useCases.emitCurrentProjection();
clock.value = now.add(const Duration(seconds: 2));
final second = await useCases.emitCurrentProjection();
await Future<void>.delayed(Duration.zero);
expect(first.revision, 1);
expect(second.revision, 2);
expect(emitted.map((projection) => projection.revision), [1, 2]);
expect(second.revision, 1);
expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs));
expect(
second.dominantTimer?.accumulatedMs,
greaterThan(first.dominantTimer?.accumulatedMs ?? 0),
);
expect(emitted.map((projection) => projection.revision), [1, 1]);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
1,
]);
await subscription.cancel();
await useCases.dispose();
},
);
test('increments revision when session command state changes', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(setsCount: 2);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
);
final first = await useCases.emitCurrentProjection();
repository.session = repository.session!.copyWith(currentSetIndex: 1);
final second = await useCases.emitCurrentProjection();
expect(first.revision, 1);
expect(second.revision, 2);
expect(second.seriesIndex, 2);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
]);
await useCases.dispose();
});
}
WatchSessionProjectionProjector _projector(
@ -341,6 +479,7 @@ ActiveWorkoutSession _session({
bool repsEnabled = true,
bool scoreEnabled = false,
int? targetTimeSeconds,
double? targetScore,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
bool? autoStartNextTimedStepSnapshot = true,
List<ExerciseStep> steps = const [],
@ -355,6 +494,7 @@ ActiveWorkoutSession _session({
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': repsEnabled ? setsCount : null,
'targetScore': targetScore,
'scoreInputModeSnapshot': scoreInputMode.name,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -403,6 +543,10 @@ ExerciseStep _step({
String id = 'step-1',
int position = 0,
int defaultTargetValue = 1,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
@ -410,6 +554,10 @@ ExerciseStep _step({
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -514,6 +662,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -528,6 +677,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -571,6 +731,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -620,6 +795,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -661,6 +845,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);

View File

@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/infrastructure/remote/remote.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test(
'defaultBaseUrl keeps localhost outside Android without env override',
() {
expect(
HttpApiClient.defaultBaseUrlFor(isAndroid: false),
'http://localhost:8080',
);
},
);
test('defaultBaseUrl uses Android emulator host without env override', () {
expect(
HttpApiClient.defaultBaseUrlFor(isAndroid: true),
'http://10.0.2.2:8080',
);
});
test('defaultBaseUrl keeps env override on Android', () {
expect(
HttpApiClient.defaultBaseUrlFor(
isAndroid: true,
configuredBaseUrl: ' http://192.168.1.42:8080 ',
),
'http://192.168.1.42:8080',
);
});
test('postJson maps server errors to server failure', () async {
final client = HttpApiClient(
baseUrl: Uri.parse('http://api.example.test'),
client: MockClient(
(_) async => http.Response('{"error":"internal"}', 500),
),
);
await expectLater(
client.postJson('/auth/login'),
throwsA(
isA<RemoteAuthException>()
.having(
(error) => error.failure,
'failure',
RemoteAuthFailure.server,
)
.having((error) => error.message, 'message', 'internal'),
),
);
});
test('postJson maps transport failures to network failure', () async {
final client = HttpApiClient(
baseUrl: Uri.parse('http://api.example.test'),
client: MockClient((_) async => throw http.ClientException('refused')),
);
await expectLater(
client.postJson('/auth/login'),
throwsA(
isA<RemoteAuthException>()
.having(
(error) => error.failure,
'failure',
RemoteAuthFailure.network,
)
.having((error) => error.message, 'message', 'refused'),
),
);
});
}

View File

@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/infrastructure/infrastructure.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/infrastructure/infrastructure.dart'
hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult;
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
@ -120,19 +122,122 @@ void main() {
]);
await adapter.stop();
});
test('patches workout history when a sensor summary arrives', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_projection(revision: 0));
final historyRepository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: _now.subtract(const Duration(hours: 1)),
endedAt: _now,
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final adapter = _adapter(
native: native,
source: source,
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: _FakeClock(_now),
),
);
await adapter.start();
native.emitSensorSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 8,
averageHeartRateBpm: 121.5,
maxHeartRateBpm: 168,
),
);
await Future<void>.delayed(Duration.zero);
expect(historyRepository.histories.single.averageHeartRateBpm, 121.5);
expect(historyRepository.histories.single.maxHeartRateBpm, 168);
await adapter.stop();
});
test('records live telemetry samples in active sensor state', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_runningProjection(revision: 1));
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: _FakeClock(_now));
final adapter = _adapter(
native: native,
source: source,
sensorUseCases: sensorUseCases,
);
await adapter.start();
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-1',
sessionId: 'session-1',
recordedAtEpochMs: _now.millisecondsSinceEpoch,
heartRateBpm: 120,
distanceMeters: 500,
),
);
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-2',
sessionId: 'session-1',
recordedAtEpochMs: _now
.add(const Duration(minutes: 30))
.millisecondsSinceEpoch,
heartRateBpm: 150,
distanceMeters: 900,
caloriesKcal: 120,
),
);
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-2',
sessionId: 'session-1',
recordedAtEpochMs: _now
.add(const Duration(minutes: 31))
.millisecondsSinceEpoch,
heartRateBpm: 170,
distanceMeters: 100,
caloriesKcal: 10,
),
);
await Future<void>.delayed(Duration.zero);
final state = sensorUseCases.current('session-1');
expect(state?.latestHeartRateBpm, 150);
expect(state?.sampleCount, 2);
expect(state?.averageHeartRateBpm, 135);
expect(state?.maxHeartRateBpm, 150);
expect(state?.latestDistanceMeters, 900);
expect(state?.latestCaloriesKcal, 120);
expect(state?.estimatedCaloriesKcal, 187.5);
await adapter.stop();
await sensorUseCases.dispose();
});
}
WatchWearDataLayerAdapter _adapter({
required _FakeWatchBridgeNativeChannel native,
WatchCommandIngress? ingress,
required _FakeProjectionSource source,
WorkoutHistoryUseCases? historyUseCases,
ActiveWorkoutSensorUseCases? sensorUseCases,
Duration heartbeatInterval = const Duration(seconds: 5),
}) {
return WatchWearDataLayerAdapter(
nativeChannel: native,
commandIngress: ingress ?? _FakeCommandIngress(),
projectionSource: source,
heartbeatInterval: heartbeatInterval,
workoutHistoryUseCases: historyUseCases,
activeWorkoutSensorUseCases: sensorUseCases,
projectionRefreshInterval: heartbeatInterval,
);
}
@ -189,6 +294,24 @@ WatchSessionProjection _runningProjection({required int revision}) {
final _now = DateTime.utc(2026, 7, 25, 12);
EntityMetadata _metadata(String id) {
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 _FakeProjectionSource implements WatchProjectionSource {
_FakeProjectionSource(this.current);
@ -260,6 +383,8 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
final published = <WatchSessionProjection>[];
final acks = <_SentAck>[];
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast();
final _sensorSamples = StreamController<WatchSensorSample>.broadcast();
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
var capabilityRefreshCount = 0;
var foregroundStartCount = 0;
@ -268,6 +393,12 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
@override
Stream<WatchCommandEnvelope> get commands => _commands.stream;
@override
Stream<WatchSensorSummary> get sensorSummaries => _sensorSummaries.stream;
@override
Stream<WatchSensorSample> get sensorSamples => _sensorSamples.stream;
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
_connections.stream;
@ -276,6 +407,14 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
_commands.add(command);
}
void emitSensorSummary(WatchSensorSummary summary) {
_sensorSummaries.add(summary);
}
void emitSensorSample(WatchSensorSample sample) {
_sensorSamples.add(sample);
}
void emitConnection(WatchBridgeConnectionEvent event) {
_connections.add(event);
}
@ -310,6 +449,54 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final histories = <WorkoutHistory>[];
@override
Future<WorkoutHistory?> findById(String id) async {
return histories.where((history) => history.metadata.id == id).firstOrNull;
}
@override
Future<List<WorkoutHistory>> listActive() async => histories;
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
final index = histories.indexWhere(
(history) =>
history.metadata.id == historyId &&
history.averageHeartRateBpm == null &&
history.maxHeartRateBpm == null,
);
if (index == -1) {
return;
}
final history = histories[index];
histories[index] = history.copyWith(
metadata: history.metadata.touch(patchedAt),
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
);
}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
@override
Future<void> delete(String id, DateTime deletedAt) async {}
}
final class _SentAck {
const _SentAck(this.command, this.ack, this.revisionAtAck);

View File

@ -405,6 +405,66 @@ void main() {
expect(find.text('Mode de score'), findsOneWidget);
});
testWidgets('une étape peut utiliser le score de la série', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 4200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Score à saisir').first,
'Paniers',
);
await tester.enterText(find.widgetWithText(TextFormField, 'Unité'), 'pts');
await tester.enterText(
find.widgetWithText(TextFormField, 'Score par défaut'),
'0',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.tap(_stepTypeRadio('Répétitions').last);
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Tirs main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Répétitions par défaut').last,
'10',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score détape'));
await tester.pump();
await tester.tap(
find.widgetWithText(SwitchListTile, 'Utiliser le score de la série'),
);
await tester.pump();
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final step = exerciseRepository.saved.single.steps.single;
expect(step.linkedToSeriesScore, isTrue);
expect(step.scoreLabel, 'Paniers');
expect(step.scoreUnit, 'pts');
expect(step.defaultTargetScore, isNull);
});
testWidgets('réordonner les étapes via Monter Descendre', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
@ -543,6 +603,82 @@ void main() {
expect(saved.steps.single.defaultTargetValue, 12);
});
testWidgets('une étape invalide affiche une erreur inline sans sauvegarder', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Dribble main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
'abc',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(find.text('Saisis une durée supérieure à 0.'), findsOneWidget);
expect(exerciseRepository.saved, isEmpty);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('un échec inattendu de sauvegarde libère le bouton', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()..throwOnSave = true;
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(
find.text(
'Impossible denregistrer lexercice. Vérifie les champs puis réessaie.',
),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNotNull,
);
});
testWidgets(
'le réglage denchaînement automatique des étapes est sauvegardé',
(tester) async {
@ -839,6 +975,7 @@ final class _FakeExerciseRepository implements ExerciseRepository {
final exercises = <Exercise>[];
final saved = <Exercise>[];
var referenced = false;
var throwOnSave = false;
@override
Future<Exercise?> findById(String id) async {
@ -862,6 +999,9 @@ final class _FakeExerciseRepository implements ExerciseRepository {
@override
Future<void> save(Exercise exercise) async {
if (throwOnSave) {
throw Exception('save failed');
}
saved.add(exercise);
exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id);
exercises.add(exercise);

View File

@ -71,6 +71,32 @@ void main() {
);
});
testWidgets('le détail affiche la fréquence cardiaque quand elle existe', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(
id: 'history-1',
averageHeartRateBpm: 126.4,
maxHeartRateBpm: 171,
),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(),
closeUseCase: _closeUseCase(),
),
),
);
expect(find.text('Fréquence cardiaque'), findsOneWidget);
expect(find.text('Moyenne'), findsOneWidget);
expect(find.text('Max'), findsOneWidget);
expect(find.text('126 bpm', findRichText: true), findsOneWidget);
expect(find.text('171 bpm', findRichText: true), findsOneWidget);
});
testWidgets('le détail affiche le score chrono comme temps réalisé', (
tester,
) async {
@ -156,6 +182,72 @@ void main() {
expect(repository.deletedIds, ['history-1']);
});
testWidgets('relancer un historique vide affiche une erreur sans navigation', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository();
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-empty', emptySnapshot: true),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pump();
expect(
find.text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
findsOneWidget,
);
expect(find.byType(WorkoutExecutionScreen), findsNothing);
});
testWidgets('une session legacy invalide ne bloque pas une relance valide', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptyResolvedSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-1'),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, isNull);
});
}
WorkoutHistory _history({
@ -163,6 +255,9 @@ WorkoutHistory _history({
DateTime? startedAt,
bool stopwatchScore = false,
bool withStepResults = false,
bool emptySnapshot = false,
double? averageHeartRateBpm,
int? maxHeartRateBpm,
}) {
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory(
@ -173,13 +268,17 @@ WorkoutHistory _history({
startedAt: start,
endedAt: start.add(const Duration(minutes: 30)),
totalActiveMs: 1800000,
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
completed: true,
historySnapshotJson: jsonEncode({
'sessionId': 'session-1',
'resolvedTemplateSnapshotJson': _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'resolvedTemplateSnapshotJson': emptySnapshot
? _emptyResolvedSnapshot()
: _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'results': [
{
'programSnapshotId': 'program-snapshot-1',
@ -308,6 +407,20 @@ String _resolvedSnapshot({
});
}
String _emptyResolvedSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'program-snapshot-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
@ -336,9 +449,11 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() {
);
}
ActiveWorkoutSessionUseCases _activeUseCases() {
ActiveWorkoutSessionUseCases _activeUseCases([
_FakeActiveSessionRepository? repository,
]) {
return ActiveWorkoutSessionUseCases(
sessionRepository: _FakeActiveSessionRepository(),
sessionRepository: repository ?? _FakeActiveSessionRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
ids: _FakeIds(),
@ -392,6 +507,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@ -441,11 +564,15 @@ final class _FakeProgramRepository implements ProgramRepository {
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveWorkoutSession?> findById(String id) async => null;
ActiveWorkoutSession? session;
@override
Future<ActiveWorkoutSession?> findOpen() async => null;
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@ -458,6 +585,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -486,6 +621,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
@ -511,7 +653,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
@override
Future<void> save(ActiveWorkoutSession session) async {}
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@ -519,6 +663,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@ -539,6 +686,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}

View File

@ -102,6 +102,37 @@ void main() {
);
});
testWidgets('le bandeau de reprise masque une séance invalide', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 12),
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
elapsedActiveMs: 300000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [homeRouteObserver],
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Séance en cours'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('le bandeau de reprise disparaît au retour après abandon', (
tester,
) async {
@ -196,6 +227,9 @@ final class _FakeBootstrap implements AppDependencies {
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
),
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: _FakeWorkoutHistoryRepository(),
@ -282,6 +316,9 @@ final class _FakeBootstrap implements AppDependencies {
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@ -327,6 +364,20 @@ String _sessionSnapshot() {
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
@ -553,6 +604,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -585,6 +646,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return scoreStopwatchStates;
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates;
@ -624,6 +692,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.add(state);
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates.add(state);
@ -646,6 +717,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
@ -808,6 +888,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

@ -27,7 +27,7 @@ void main() {
);
await tester.pump();
expect(find.text('Compte optionnel'), findsOneWidget);
expect(find.text('Compte GameTime'), findsOneWidget);
expect(find.text('Créer un compte'), findsOneWidget);
expect(find.text('Se connecter'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
@ -52,6 +52,43 @@ void main() {
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Tu peux revenir à GameTime.'), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, 'Email'),
'a@b.fr',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Mot de passe'),
'password1',
);
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('une erreur serveur affiche le message dédié', (tester) async {
final harness = _AuthHarness()
..remote.loginFailure = RemoteAuthFailure.server;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
await tester.enterText(
@ -65,7 +102,10 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
expect(
find.text('Serveur indisponible pour le moment. Réessaie plus tard.'),
findsOneWidget,
);
expect(find.byType(AlertDialog), findsNothing);
});
@ -142,7 +182,7 @@ void main() {
expect(harness.tokenStore.token, isNull);
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
expect(find.text('Compte optionnel'), findsOneWidget);
expect(find.text('Compte GameTime'), findsOneWidget);
});
testWidgets(
@ -295,36 +335,33 @@ void main() {
},
);
testWidgets(
'export réussi affiche le message de sauvegarde exportée',
(tester) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(
status: ShareResultStatus.success,
);
testWidgets('export réussi affiche le message de sauvegarde exportée', (
tester,
) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(status: ShareResultStatus.success);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
);
await tester.pump();
),
);
await tester.pump();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
},
);
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
});
testWidgets('un échec dexport affiche un message neutre', (tester) async {
final harness = _AuthHarness();
@ -404,74 +441,70 @@ void main() {
]);
});
testWidgets(
'remplacer tout exige une seconde confirmation destructive',
(tester) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
testWidgets('remplacer tout exige une seconde confirmation destructive', (
tester,
) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
),
);
await tester.pump();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Remplacer toutes les données locales ?'), findsOneWidget);
expect(harness.backupRepository.appliedModes, isEmpty);
expect(
find.text('Remplacer toutes les données locales ?'),
findsOneWidget,
);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
expect(harness.backupRepository.appliedModes, isEmpty);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
},
);
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
});
testWidgets('un fichier invalide affiche le message dédié', (tester) async {
final harness = _AuthHarness();

View File

@ -15,6 +15,22 @@ void main() {
name: 'Fondations basket - 45 min',
defaultRestSeconds: 45,
isExample: true,
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository();
@ -65,6 +81,44 @@ void main() {
);
});
testWidgets('un programme sans exercice ne peut pas être enregistré', (
tester,
) async {
final programRepository = _FakeProgramRepository();
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
programRepository,
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
),
),
);
expect(
find.text('Ajoute au moins un exercice pour enregistrer ce programme.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(programRepository.saved, isEmpty);
});
testWidgets('filtre les programmes par tag et peut effacer les filtres', (
tester,
) async {
@ -125,9 +179,36 @@ void main() {
name: 'Jambes',
defaultRestSeconds: 60,
tags: const ['match'],
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
sourceExerciseId: 'exercise-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
@ -167,9 +248,36 @@ void main() {
name: 'Fondations basket - 45 min',
defaultRestSeconds: 45,
isExample: true,
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
sourceExerciseId: 'exercise-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(

View File

@ -456,6 +456,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@ -539,6 +547,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -567,6 +583,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
String sessionId,
) async => const [];
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async => const [];
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
@ -594,9 +615,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
}

View File

@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/presentation.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
testWidgets('le temps affiché est recalculé depuis les horodatages', (
@ -45,6 +46,48 @@ void main() {
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
});
testWidgets('une séance invalide est abandonnée avec un message clair', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('Séance indisponible'), findsOneWidget);
expect(
find.text(
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
),
findsOneWidget,
);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
});
testWidgets('les répétitions sont initialisées avec la cible', (
tester,
) async {
@ -81,6 +124,214 @@ void main() {
expect(find.text('8'), findsOneWidget);
});
testWidgets('affiche les statistiques montre live disponibles', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
distanceMeters: 840,
caloriesKcal: 186,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.text('840 m'), findsOneWidget);
expect(find.text('186 kcal'), findsOneWidget);
});
testWidgets('naffiche aucun indicateur capteur sans donnée FC', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.textContaining('bpm'), findsNothing);
expect(find.textContaining('kcal'), findsNothing);
expect(find.byIcon(Icons.favorite), findsNothing);
expect(find.byIcon(Icons.local_fire_department), findsNothing);
});
testWidgets('ne fabrique pas de calories sans mesure montre native', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.textContaining('kcal'), findsNothing);
});
testWidgets('atténue la fréquence cardiaque périmée', (tester) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(seconds: 30))
.millisecondsSinceEpoch,
heartRateBpm: 118,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 118 bpm'), findsOneWidget);
expect(
find.ancestor(
of: find.text('FC 118 bpm'),
matching: find.byWidgetPredicate(
(widget) => widget is Opacity && widget.opacity == 0.45,
),
),
findsOneWidget,
);
});
testWidgets('les références de temps sont formatées sans valeurs brutes', (
tester,
) async {
@ -152,7 +403,9 @@ void main() {
tester,
) async {
final scoreController = TextEditingController();
final scoreFocusNode = FocusNode();
addTearDown(scoreController.dispose);
addTearDown(scoreFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
@ -170,6 +423,8 @@ void main() {
),
reps: 0,
scoreController: scoreController,
scoreFocusNode: scoreFocusNode,
onScoreSubmitted: (_) {},
onRepsChanged: (_) {},
),
),
@ -808,6 +1063,63 @@ void main() {
},
);
testWidgets(
'la saisie du score téléphone alimente le score vivant de la montre',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final activeUseCases = _activeUseCases(repository, clock);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
restSeconds: 0,
scoreUnit: 'points',
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: activeUseCases,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.enterText(
find.widgetWithText(TextField, 'Score (points)'),
'7.5',
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(repository.manualScoreStates.single.value, 7.5);
final incremented = await activeUseCases.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
},
);
testWidgets('la flèche de retour met la séance active en pause', (
tester,
) async {
@ -1186,11 +1498,75 @@ void main() {
expect(find.byType(ListView), findsNothing);
expect(find.text('SÉQUENCE'), findsOneWidget);
expect(find.text('Passage 1 / 10'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Dribble main droite'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
});
testWidgets('un exercice dense avec étapes et scores reste scrollable', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 3,
targetReps: 20,
scoreEnabled: true,
scoreUnit: 'paniers',
exerciseSteps: [
_step(
'step-1',
0,
'Tirs après dribble',
ExerciseStepType.time,
10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'paniers',
defaultTargetScore: 8,
),
_step('step-2', 1, 'Replacement défensif', ExerciseStepType.reps, 6),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Tirs après dribble'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
expect(find.text('Passer létape'), findsOneWidget);
expect(find.byType(SingleChildScrollView), findsWidgets);
});
testWidgets('lécran actif avec séquence longue reste sans scroll', (
tester,
) async {
@ -1465,7 +1841,7 @@ void main() {
expect(repository.stepResults.single.stepIndex, 0);
expect(repository.stepResults.single.status, SetResultStatus.completed);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(
@ -1526,7 +1902,7 @@ void main() {
await tester.pump();
expect(repository.stepResults.single.stepIndex, 0);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(find.text('Chrono prêt'), findsOneWidget);
@ -1847,6 +2223,20 @@ String _sessionSnapshot({
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
Map<String, Object?> _step(
String id,
int position,
@ -1960,6 +2350,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final manualScoreStates = <ActiveManualScoreState>[];
final setTimerStates = <ActiveSetTimerState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@ -2001,6 +2392,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in manualScoreStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -2055,6 +2465,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
@ -2114,6 +2533,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
final index = manualScoreStates.indexWhere(
(saved) => saved.metadata.id == state.metadata.id,
);
if (index == -1) {
manualScoreStates.add(state);
} else {
manualScoreStates[index] = state;
}
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
final index = setTimerStates.indexWhere(
@ -2183,6 +2614,23 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.removeWhere(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex,
);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
final index = results.indexWhere(
@ -2237,6 +2685,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

@ -126,6 +126,134 @@ void main() {
);
});
testWidgets('une séance sans programme ne peut pas être enregistrée', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository(const []);
final programRepository = _FakeProgramRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateFormScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
),
),
);
expect(
find.text('Ajoute au moins un programme pour enregistrer cette séance.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(templateRepository.saved, isEmpty);
});
testWidgets('le lancement est bloqué si aucun exercice ne peut être joué', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
WorkoutTemplate(
metadata: _metadata('template-empty'),
name: 'Séance vide',
programs: [
WorkoutTemplateProgram(
metadata: _metadata('template-program-empty'),
workoutTemplateId: 'template-empty',
position: 0,
programNameSnapshot: 'Programme vide',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pump();
expect(
find.text(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
),
findsOneWidget,
);
expect(activeRepository.session, isNull);
});
testWidgets('filtre les séances par tag et peut effacer les filtres', (
tester,
) async {
@ -517,6 +645,89 @@ void main() {
},
);
testWidgets('une session legacy invalide ne bloque pas le lancement', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
}),
);
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, 'template-1');
});
testWidgets(
'supprimer une séance demande confirmation puis la retire de la liste',
(tester) async {
@ -828,6 +1039,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -860,6 +1081,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
@ -896,6 +1124,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@ -916,6 +1147,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}
@ -964,6 +1204,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}