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