merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre
Intègre le commit de clôture du lot #91 resté non fusionné. Conflits attendus sur entities.dart, workout_execution_screen.dart et watch_companion_projection_test.dart : arbitrage Architect - garder la base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter uniquement les invariants chronos utiles (Durée / Temps réalisé). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> # Conflicts: # lib/domain/entities.dart # lib/presentation/workout_execution_screen.dart # test/application/watch_companion_projection_test.dart
This commit is contained in:
126
test/application/session_notification_use_cases_test.dart
Normal file
126
test/application/session_notification_use_cases_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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', () {
|
||||
@ -374,6 +375,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(
|
||||
@ -649,6 +684,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(
|
||||
@ -692,6 +747,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(
|
||||
@ -943,6 +1046,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()
|
||||
@ -1509,6 +1758,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 {
|
||||
@ -2346,6 +2667,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'),
|
||||
@ -3135,6 +3815,7 @@ ExerciseStep _step({
|
||||
String? scoreUnit,
|
||||
double? defaultTargetScore,
|
||||
int? defaultTargetScoreTimeMs,
|
||||
bool linkedToSeriesScore = false,
|
||||
}) {
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
@ -3148,6 +3829,7 @@ ExerciseStep _step({
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
linkedToSeriesScore: linkedToSeriesScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -3809,6 +4491,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>[];
|
||||
@ -4059,6 +4778,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,
|
||||
@ -4078,6 +4863,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>[];
|
||||
|
||||
@ -4111,6 +4897,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,
|
||||
@ -4169,6 +4974,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
|
||||
@ -4225,6 +5043,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,
|
||||
@ -4266,6 +5089,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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -69,10 +69,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.setTimer,
|
||||
@ -110,9 +110,111 @@ void main() {
|
||||
expect(projection.phase, WatchSessionPhase.running);
|
||||
expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer);
|
||||
expect(projection.secondaryTimers, isEmpty);
|
||||
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
|
||||
expect(
|
||||
projection.secondaryActions,
|
||||
contains(WatchSecondaryAction.finishCurrentSet),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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(
|
||||
@ -307,14 +409,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,
|
||||
@ -323,21 +433,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(
|
||||
@ -366,6 +509,7 @@ ActiveWorkoutSession _session({
|
||||
bool repsEnabled = true,
|
||||
bool scoreEnabled = false,
|
||||
int? targetTimeSeconds,
|
||||
double? targetScore,
|
||||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||||
bool? autoStartNextTimedStepSnapshot = true,
|
||||
List<ExerciseStep> steps = const [],
|
||||
@ -380,6 +524,7 @@ ActiveWorkoutSession _session({
|
||||
'scoreEnabled': scoreEnabled,
|
||||
'targetTimeSeconds': targetTimeSeconds,
|
||||
'targetReps': repsEnabled ? setsCount : null,
|
||||
'targetScore': targetScore,
|
||||
'scoreInputModeSnapshot': scoreInputMode.name,
|
||||
'exerciseStepsSnapshot': steps
|
||||
.map((step) => step.toSnapshotJson())
|
||||
@ -428,6 +573,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,
|
||||
@ -435,6 +584,10 @@ ExerciseStep _step({
|
||||
name: 'Step ${position + 1}',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: defaultTargetValue,
|
||||
hasScore: hasScore,
|
||||
scoreLabel: scoreLabel,
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -539,6 +692,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>[];
|
||||
|
||||
@ -553,6 +707,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;
|
||||
@ -596,6 +761,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,
|
||||
@ -645,6 +825,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
|
||||
@ -686,6 +875,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);
|
||||
|
||||
76
test/infrastructure/remote/http_api_client_test.dart
Normal file
76
test/infrastructure/remote/http_api_client_test.dart
Normal 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'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 d’enregistrer l’exercice. Vérifie les champs puis réessaie.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Enregistrer'),
|
||||
)
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'le réglage d’enchaî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);
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
|
||||
@ -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 d’export 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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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('n’affiche 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: (_) {},
|
||||
),
|
||||
),
|
||||
@ -870,6 +1125,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 {
|
||||
@ -1248,11 +1560,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 {
|
||||
@ -1527,7 +1903,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(
|
||||
@ -1588,7 +1964,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);
|
||||
@ -1909,6 +2285,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,
|
||||
@ -2022,6 +2412,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>[];
|
||||
@ -2063,6 +2454,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,
|
||||
@ -2117,6 +2527,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;
|
||||
@ -2176,6 +2595,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(
|
||||
@ -2245,6 +2676,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(
|
||||
@ -2299,6 +2747,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 {}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user