chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/infrastructure/local/local.dart' as local;
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
@ -1192,6 +1194,51 @@ void main() {
|
||||
expect(repository.session?.endedAt, isNull);
|
||||
});
|
||||
|
||||
test('close materializes session telemetry aggregate into history', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 28, 10));
|
||||
final session = _sessionWithSnapshot(
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
);
|
||||
final sessionRepository = _FakeActiveSessionRepository()..session = session;
|
||||
final historyRepository = _FakeWorkoutHistoryRepository();
|
||||
final telemetryRepository = _FakeWorkoutTelemetryRepository()
|
||||
..aggregates.add(
|
||||
WorkoutTelemetryAggregate(
|
||||
sessionId: session.metadata.id,
|
||||
scope: WorkoutTelemetryAggregateScope.session,
|
||||
sampleCount: 3,
|
||||
minHeartRateBpm: 118,
|
||||
averageHeartRateBpm: 132.5,
|
||||
maxHeartRateBpm: 151,
|
||||
totalDistanceMeters: 720,
|
||||
totalCaloriesKcal: 56,
|
||||
),
|
||||
);
|
||||
final useCase = CloseWorkoutSessionUseCase(
|
||||
sessionRepository: sessionRepository,
|
||||
historyRepository: historyRepository,
|
||||
telemetryRepository: telemetryRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final history = await useCase.close(
|
||||
sessionId: session.metadata.id,
|
||||
nameSnapshot: 'Séance',
|
||||
completed: true,
|
||||
);
|
||||
|
||||
expect(history.minHeartRateBpm, 118);
|
||||
expect(history.averageHeartRateBpm, 132.5);
|
||||
expect(history.maxHeartRateBpm, 151);
|
||||
expect(history.totalDistanceMeters, 720);
|
||||
expect(history.totalCaloriesKcal, 56);
|
||||
expect(historyRepository.histories.single, history);
|
||||
});
|
||||
|
||||
test('adjustRestSeconds persists adjusted rest duration', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
@ -2522,6 +2569,31 @@ void main() {
|
||||
expect(pendingRepository.actions.single.payloadJson, contains('Programme'));
|
||||
});
|
||||
|
||||
test('ShareUseCases sendShare supports workout packs', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi();
|
||||
final templateRepository = _FakeWorkoutTemplateRepository()
|
||||
..templates.add(_playableTemplate(id: 'template-1', name: 'Séance 1'))
|
||||
..templates.add(_playableTemplate(id: 'template-2', name: 'Séance 2'));
|
||||
|
||||
final result =
|
||||
await _shareUseCase(
|
||||
remoteShareApi: remoteShareApi,
|
||||
templateRepository: templateRepository,
|
||||
).sendShare(
|
||||
resourceType: ShareResourceType.pack,
|
||||
localResourceId: '',
|
||||
localResourceIds: const ['template-1', 'template-2'],
|
||||
packName: 'Pack reprise',
|
||||
recipientEmails: const ['coach@example.com'],
|
||||
);
|
||||
|
||||
expect(result.status, ShareSendStatus.sent);
|
||||
expect(remoteShareApi.lastResourceType, ShareResourceType.pack);
|
||||
expect(remoteShareApi.lastPayload?['name'], 'Pack reprise');
|
||||
expect(remoteShareApi.lastPayload?['workouts'], isA<List<Object?>>());
|
||||
expect(remoteShareApi.lastPayload?['workouts'], hasLength(2));
|
||||
});
|
||||
|
||||
test('ShareUseCases acceptShare imports returned resource locally', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..acceptResult = _remoteSharedProgramItem();
|
||||
@ -2583,6 +2655,121 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'ShareUseCases acceptShare sans token importe un pack complet',
|
||||
() async {
|
||||
final pendingRepository = _FakePendingShareActionRepository();
|
||||
final templateRepository = _FakeWorkoutTemplateRepository();
|
||||
final inboxRepository = _FakeShareInboxRepository()
|
||||
..items.add(
|
||||
_shareInboxItem(
|
||||
status: ShareInboxStatus.pending,
|
||||
resourceType: ShareResourceType.pack,
|
||||
payloadJson: jsonEncode({
|
||||
'name': 'Pack reprise',
|
||||
'workouts': [
|
||||
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
|
||||
_sharedTemplatePayload(id: 'template-b', name: 'Séance B'),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await _shareUseCase(
|
||||
tokenStore: _FakeAuthTokenStore(),
|
||||
inboxRepository: inboxRepository,
|
||||
pendingActionRepository: pendingRepository,
|
||||
templateRepository: templateRepository,
|
||||
).acceptShare('share-1');
|
||||
|
||||
expect(templateRepository.templates, hasLength(2));
|
||||
expect(
|
||||
templateRepository.templates.map((template) => template.name),
|
||||
containsAll(['Séance A', 'Séance B']),
|
||||
);
|
||||
expect(
|
||||
templateRepository.templates.map((template) => template.metadata.id),
|
||||
isNot(contains('template-a')),
|
||||
);
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
expect(
|
||||
pendingRepository.actions.single.actionType,
|
||||
PendingShareActionType.accept,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'ShareUseCases acceptShare sans token annule tout le pack si un import échoue',
|
||||
() async {
|
||||
final database = local.AppDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final inboxRepository = local.DriftShareInboxRepository(database);
|
||||
final pendingRepository = local.DriftPendingShareActionRepository(
|
||||
database,
|
||||
);
|
||||
final templateRepository = local.DriftWorkoutTemplateRepository(database);
|
||||
await inboxRepository.upsert(
|
||||
_shareInboxItem(
|
||||
shareId: 'share-pack-failing',
|
||||
status: ShareInboxStatus.pending,
|
||||
resourceType: ShareResourceType.pack,
|
||||
payloadJson: jsonEncode({
|
||||
'name': 'Pack incomplet',
|
||||
'workouts': [
|
||||
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
|
||||
{
|
||||
'id': 'template-b',
|
||||
'name': 'Séance B',
|
||||
'programs': [
|
||||
{
|
||||
'id': 'template-b-program-1',
|
||||
'position': 0,
|
||||
'programNameSnapshot': 'Programme A',
|
||||
'defaultRestSecondsSnapshot': 60,
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
{
|
||||
'id': 'template-b-program-2',
|
||||
'position': 0,
|
||||
'programNameSnapshot': 'Programme B',
|
||||
'defaultRestSecondsSnapshot': 60,
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final useCase = ShareUseCases(
|
||||
tokenStore: _FakeAuthTokenStore(),
|
||||
remoteShareApi: _FakeRemoteShareApi(),
|
||||
inboxRepository: inboxRepository,
|
||||
pendingActionRepository: pendingRepository,
|
||||
localChanges: _FakeLocalSyncChangeRepository(),
|
||||
programRepository: local.DriftProgramRepository(database),
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
useCase.acceptShare('share-pack-failing'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
expect(await templateRepository.listActive(), isEmpty);
|
||||
expect(
|
||||
(await inboxRepository.findByShareId('share-pack-failing'))!.status,
|
||||
ShareInboxStatus.pending,
|
||||
);
|
||||
expect(await pendingRepository.listPending(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('ShareUseCases refreshInbox updates the local cache', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
|
||||
@ -3038,6 +3225,113 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'WorkoutTelemetryUseCases persists samples and aggregates by scope',
|
||||
() async {
|
||||
final repository = _FakeWorkoutTelemetryRepository();
|
||||
final useCase = WorkoutTelemetryUseCases(
|
||||
repository: repository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
|
||||
ids: _FakeIds(),
|
||||
);
|
||||
|
||||
final ignored = await useCase.recordTelemetrySample(
|
||||
const WatchTelemetrySample(
|
||||
sampleId: 'sample-empty',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: 1,
|
||||
),
|
||||
);
|
||||
final first = await useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
28,
|
||||
10,
|
||||
).millisecondsSinceEpoch,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
heartRateBpm: 120,
|
||||
distanceMeters: 500,
|
||||
caloriesKcal: 42,
|
||||
),
|
||||
);
|
||||
final duplicate = await useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
28,
|
||||
10,
|
||||
1,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 160,
|
||||
distanceMeters: 300,
|
||||
caloriesKcal: 10,
|
||||
),
|
||||
);
|
||||
final second = await useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
28,
|
||||
10,
|
||||
2,
|
||||
).millisecondsSinceEpoch,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
heartRateBpm: 150,
|
||||
distanceMeters: 620,
|
||||
caloriesKcal: 48,
|
||||
),
|
||||
);
|
||||
|
||||
expect(ignored, isEmpty);
|
||||
expect(first.map((aggregate) => aggregate.scope), [
|
||||
WorkoutTelemetryAggregateScope.session,
|
||||
WorkoutTelemetryAggregateScope.exercise,
|
||||
WorkoutTelemetryAggregateScope.set,
|
||||
WorkoutTelemetryAggregateScope.step,
|
||||
]);
|
||||
expect(duplicate, isEmpty);
|
||||
expect(repository.samples, hasLength(2));
|
||||
|
||||
final sessionAggregate = second.singleWhere(
|
||||
(aggregate) =>
|
||||
aggregate.scope == WorkoutTelemetryAggregateScope.session,
|
||||
);
|
||||
expect(sessionAggregate.sampleCount, 2);
|
||||
expect(sessionAggregate.minHeartRateBpm, 120);
|
||||
expect(sessionAggregate.averageHeartRateBpm, 135);
|
||||
expect(sessionAggregate.maxHeartRateBpm, 150);
|
||||
expect(sessionAggregate.totalDistanceMeters, 620);
|
||||
expect(sessionAggregate.totalCaloriesKcal, 48);
|
||||
expect(
|
||||
second.any(
|
||||
(aggregate) =>
|
||||
aggregate.scope == WorkoutTelemetryAggregateScope.step &&
|
||||
aggregate.programIndex == 0 &&
|
||||
aggregate.exerciseIndex == 0 &&
|
||||
aggregate.setIndex == 0 &&
|
||||
aggregate.stepIndex == 0,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
||||
() async {
|
||||
@ -4270,6 +4564,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||
var acceptCalls = 0;
|
||||
var declineCalls = 0;
|
||||
var revokeCalls = 0;
|
||||
ShareResourceType? lastResourceType;
|
||||
Map<String, Object?>? lastPayload;
|
||||
|
||||
@override
|
||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||||
@ -4316,6 +4612,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||
required String token,
|
||||
}) async {
|
||||
sendCalls += 1;
|
||||
lastResourceType = resourceType;
|
||||
lastPayload = payload;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
@ -4419,13 +4717,14 @@ Program _shareableProgram() {
|
||||
|
||||
ShareInboxItem _shareInboxItem({
|
||||
String shareId = 'share-1',
|
||||
ShareResourceType resourceType = ShareResourceType.program,
|
||||
required ShareInboxStatus status,
|
||||
String payloadJson = '{"name":"Programme partagé"}',
|
||||
}) {
|
||||
return ShareInboxItem(
|
||||
shareId: shareId,
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: ShareResourceType.program,
|
||||
resourceType: resourceType,
|
||||
payloadJson: payloadJson,
|
||||
status: status,
|
||||
createdAt: DateTime.utc(2026, 7, 17, 12),
|
||||
@ -4472,6 +4771,26 @@ Map<String, Object?> _sharedProgramPayload() {
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, Object?> _sharedTemplatePayload({
|
||||
required String id,
|
||||
required String name,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'programs': [
|
||||
{
|
||||
'id': '$id-program',
|
||||
'position': 0,
|
||||
'programNameSnapshot': 'Programme',
|
||||
'defaultRestSecondsSnapshot': 60,
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
};
|
||||
}
|
||||
|
||||
RemoteSyncedItem _remoteSharedProgramItem() {
|
||||
return RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.program,
|
||||
@ -4767,6 +5086,13 @@ final class _FakeWorkoutTemplateRepository
|
||||
templates.add(template);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {
|
||||
for (final template in templates) {
|
||||
await save(template);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
@ -4851,8 +5177,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
final index = histories.indexWhere(
|
||||
@ -4868,8 +5197,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
final history = histories[index];
|
||||
histories[index] = history.copyWith(
|
||||
metadata: history.metadata.touch(patchedAt),
|
||||
minHeartRateBpm: minHeartRateBpm,
|
||||
averageHeartRateBpm: averageHeartRateBpm,
|
||||
maxHeartRateBpm: maxHeartRateBpm,
|
||||
totalDistanceMeters: totalDistanceMeters,
|
||||
totalCaloriesKcal: totalCaloriesKcal,
|
||||
);
|
||||
}
|
||||
|
||||
@ -4891,6 +5223,73 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutTelemetryRepository
|
||||
implements WorkoutTelemetryRepository {
|
||||
final samples = <WorkoutTelemetrySample>[];
|
||||
final aggregates = <WorkoutTelemetryAggregate>[];
|
||||
|
||||
@override
|
||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
||||
if (samples.any((existing) => existing.id == sample.id)) {
|
||||
return false;
|
||||
}
|
||||
samples.add(sample);
|
||||
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
||||
return samples
|
||||
.where((sample) => sample.sessionId == sessionId)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
required List<WorkoutTelemetryAggregate> aggregates,
|
||||
}) async {
|
||||
this.aggregates.removeWhere(
|
||||
(aggregate) => aggregate.sessionId == sessionId,
|
||||
);
|
||||
this.aggregates.addAll(aggregates);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTelemetryAggregate>> listAggregates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return aggregates
|
||||
.where((aggregate) => aggregate.sessionId == sessionId)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WorkoutTelemetryAggregate?> findAggregate({
|
||||
required String sessionId,
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) async {
|
||||
return aggregates
|
||||
.where(
|
||||
(aggregate) =>
|
||||
aggregate.sessionId == sessionId &&
|
||||
aggregate.scope == scope &&
|
||||
aggregate.programIndex == programIndex &&
|
||||
aggregate.exerciseIndex == exerciseIndex &&
|
||||
aggregate.setIndex == setIndex &&
|
||||
aggregate.passageIndex == passageIndex &&
|
||||
aggregate.stepIndex == stepIndex,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
ActiveWorkoutSessionUseCases _activeUseCase(
|
||||
_FakeActiveSessionRepository repository,
|
||||
_FakeClock clock,
|
||||
|
||||
@ -764,6 +764,13 @@ final class _FakeWorkoutTemplateRepository
|
||||
templates.add(template);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {
|
||||
for (final template in templates) {
|
||||
await save(template);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ void main() {
|
||||
expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise);
|
||||
});
|
||||
|
||||
test('projects running with duration timer before stopwatch score', () async {
|
||||
test('projects running with step timer before stopwatch score', () async {
|
||||
final now = DateTime.utc(2026, 7, 25, 12);
|
||||
final session = _session(
|
||||
timeEnabled: true,
|
||||
@ -75,7 +75,7 @@ void main() {
|
||||
now.millisecondsSinceEpoch,
|
||||
);
|
||||
expect(projection.secondaryTimers.map((timer) => timer.kind), [
|
||||
WatchTimerKind.setTimer,
|
||||
WatchTimerKind.scoreStopwatch,
|
||||
]);
|
||||
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
|
||||
expect(
|
||||
@ -85,7 +85,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'projects duration and stopwatch score with only duration timer visible',
|
||||
'projects duration and stopwatch score with stopwatch timer visible',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 25, 12);
|
||||
final session = _session(
|
||||
@ -108,7 +108,7 @@ void main() {
|
||||
final projection = await projector.project(revision: 1);
|
||||
|
||||
expect(projection.phase, WatchSessionPhase.running);
|
||||
expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer);
|
||||
expect(projection.dominantTimer?.kind, WatchTimerKind.scoreStopwatch);
|
||||
expect(projection.secondaryTimers, isEmpty);
|
||||
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
|
||||
expect(
|
||||
@ -118,6 +118,28 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'keeps set timer out of display timers while preserving phase',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 25, 12);
|
||||
final session = _session(timeEnabled: true, repsEnabled: false);
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = session
|
||||
..setTimerStates['set'] = _setTimer(
|
||||
sessionId: session.metadata.id,
|
||||
startedAt: now.subtract(const Duration(seconds: 6)),
|
||||
);
|
||||
final projector = _projector(repository, _clock(now));
|
||||
|
||||
final projection = await projector.project(revision: 1);
|
||||
|
||||
expect(projection.phase, WatchSessionPhase.running);
|
||||
expect(projection.dominantTimer, isNull);
|
||||
expect(projection.secondaryTimers, isEmpty);
|
||||
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
|
||||
},
|
||||
);
|
||||
|
||||
test('projects manual score state for the current set', () async {
|
||||
final session = _session(scoreEnabled: true, targetScore: 8);
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
@ -17,6 +18,7 @@ void main() {
|
||||
late local.DriftProgressionStatsRepository progressionStatsRepository;
|
||||
late local.DriftLocalSyncChangeRepository syncChangeRepository;
|
||||
late local.DriftLocalDataBackupRepository localDataBackupRepository;
|
||||
late local.DriftWorkoutTelemetryRepository telemetryRepository;
|
||||
late local.DriftExercisePerformanceReferenceRepository
|
||||
performanceReferenceRepository;
|
||||
|
||||
@ -32,6 +34,7 @@ void main() {
|
||||
);
|
||||
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
|
||||
localDataBackupRepository = local.DriftLocalDataBackupRepository(database);
|
||||
telemetryRepository = local.DriftWorkoutTelemetryRepository(database);
|
||||
performanceReferenceRepository =
|
||||
local.DriftExercisePerformanceReferenceRepository(database);
|
||||
});
|
||||
@ -121,9 +124,289 @@ void main() {
|
||||
expect(await columnNames('exercises'), contains('tags_json'));
|
||||
expect(await columnNames('programs'), contains('tags_json'));
|
||||
expect(await columnNames('workout_templates'), contains('tags_json'));
|
||||
expect(database.schemaVersion, 19);
|
||||
expect(
|
||||
await columnNames('workout_history'),
|
||||
contains('min_heart_rate_bpm'),
|
||||
);
|
||||
expect(
|
||||
await columnNames('workout_history'),
|
||||
contains('total_distance_meters'),
|
||||
);
|
||||
expect(
|
||||
await columnNames('workout_history'),
|
||||
contains('total_calories_kcal'),
|
||||
);
|
||||
expect(await columnNames('workout_telemetry_samples'), contains('id'));
|
||||
expect(
|
||||
await columnNames('workout_telemetry_aggregates'),
|
||||
contains('sample_count'),
|
||||
);
|
||||
expect(database.schemaVersion, 24);
|
||||
});
|
||||
|
||||
test(
|
||||
'telemetry repository persists samples and replaces aggregates',
|
||||
() async {
|
||||
final first = WorkoutTelemetrySample(
|
||||
id: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAt: DateTime.utc(2026, 7, 28, 10),
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
heartRateBpm: 120,
|
||||
distanceMeters: 500,
|
||||
caloriesKcal: 42,
|
||||
);
|
||||
final second = WorkoutTelemetrySample(
|
||||
id: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
|
||||
heartRateBpm: 150,
|
||||
distanceMeters: 620,
|
||||
caloriesKcal: 48,
|
||||
);
|
||||
|
||||
expect(await telemetryRepository.saveSample(first), isTrue);
|
||||
expect(await telemetryRepository.saveSample(first), isFalse);
|
||||
expect(await telemetryRepository.saveSample(second), isTrue);
|
||||
|
||||
final samples = await telemetryRepository.listSamples('session-1');
|
||||
expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']);
|
||||
|
||||
await telemetryRepository.replaceAggregatesForSession(
|
||||
sessionId: 'session-1',
|
||||
aggregates: [
|
||||
WorkoutTelemetryAggregate(
|
||||
sessionId: 'session-1',
|
||||
scope: WorkoutTelemetryAggregateScope.session,
|
||||
sampleCount: 2,
|
||||
minHeartRateBpm: 120,
|
||||
averageHeartRateBpm: 135,
|
||||
maxHeartRateBpm: 150,
|
||||
totalDistanceMeters: 620,
|
||||
totalCaloriesKcal: 48,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final aggregate = await telemetryRepository.findAggregate(
|
||||
sessionId: 'session-1',
|
||||
scope: WorkoutTelemetryAggregateScope.session,
|
||||
);
|
||||
expect(aggregate?.sampleCount, 2);
|
||||
expect(aggregate?.minHeartRateBpm, 120);
|
||||
expect(aggregate?.averageHeartRateBpm, 135);
|
||||
expect(aggregate?.maxHeartRateBpm, 150);
|
||||
expect(aggregate?.totalDistanceMeters, 620);
|
||||
expect(aggregate?.totalCaloriesKcal, 48);
|
||||
|
||||
await telemetryRepository.replaceAggregatesForSession(
|
||||
sessionId: 'session-1',
|
||||
aggregates: const [],
|
||||
);
|
||||
expect(await telemetryRepository.listAggregates('session-1'), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('share inbox and pending actions persist workout packs', () async {
|
||||
final now = DateTime.utc(2026, 7, 28, 10);
|
||||
final inboxRepository = local.DriftShareInboxRepository(database);
|
||||
final pendingRepository = local.DriftPendingShareActionRepository(database);
|
||||
|
||||
await inboxRepository.upsert(
|
||||
ShareInboxItem(
|
||||
shareId: 'share-pack-1',
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: ShareResourceType.pack,
|
||||
payloadJson: jsonEncode({
|
||||
'kind': 'pack',
|
||||
'name': 'Pack reprise',
|
||||
'workouts': const [],
|
||||
}),
|
||||
status: ShareInboxStatus.pending,
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
await pendingRepository.add(
|
||||
PendingShareAction(
|
||||
id: 'pending-pack-1',
|
||||
actionType: PendingShareActionType.send,
|
||||
resourceType: ShareResourceType.pack,
|
||||
payloadJson: jsonEncode({
|
||||
'kind': 'pack',
|
||||
'name': 'Pack reprise',
|
||||
'workouts': const [],
|
||||
}),
|
||||
recipientEmailsJson: jsonEncode(['coach@example.com']),
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
|
||||
final inboxItem = await inboxRepository.findByShareId('share-pack-1');
|
||||
final pendingActions = await pendingRepository.listPending();
|
||||
|
||||
expect(inboxItem, isNotNull);
|
||||
expect(inboxItem!.resourceType, ShareResourceType.pack);
|
||||
expect(pendingActions.single.resourceType, ShareResourceType.pack);
|
||||
});
|
||||
|
||||
test('migration 22 to 24 preserves share rows and accepts packs', () async {
|
||||
final file = File(
|
||||
'${Directory.systemTemp.path}/gametime_schema24_${DateTime.now().microsecondsSinceEpoch}.sqlite',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
});
|
||||
final seedDatabase = local.AppDatabase(NativeDatabase(file));
|
||||
await seedDatabase.customSelect('SELECT 1').getSingle();
|
||||
await seedDatabase.customStatement('DROP TABLE share_inbox_items');
|
||||
await seedDatabase.customStatement('DROP TABLE pending_share_actions');
|
||||
await seedDatabase.customStatement('''
|
||||
CREATE TABLE share_inbox_items (
|
||||
share_id TEXT NOT NULL PRIMARY KEY,
|
||||
sender_user_id TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL CHECK (
|
||||
resource_type IN ('program', 'workoutTemplate')
|
||||
),
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN ('pending', 'accepted', 'declined', 'revoked')
|
||||
),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
responded_at INTEGER
|
||||
)
|
||||
''');
|
||||
await seedDatabase.customStatement('''
|
||||
CREATE TABLE pending_share_actions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
action_type TEXT NOT NULL CHECK (
|
||||
action_type IN ('send', 'accept', 'decline', 'revoke')
|
||||
),
|
||||
share_id TEXT,
|
||||
resource_type TEXT CHECK (
|
||||
resource_type IS NULL OR
|
||||
resource_type IN ('program', 'workoutTemplate')
|
||||
),
|
||||
payload_json TEXT,
|
||||
recipient_emails_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_attempt_at INTEGER,
|
||||
attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed'))
|
||||
)
|
||||
''');
|
||||
await seedDatabase.customStatement(
|
||||
'INSERT INTO share_inbox_items VALUES '
|
||||
'''('share-program-1', 'sender-1', 'program', '{"name":"P"}', '''
|
||||
"'pending', 1785225600000, 1785225600000, NULL)",
|
||||
);
|
||||
await seedDatabase.customStatement(
|
||||
'INSERT INTO pending_share_actions VALUES '
|
||||
'''('pending-program-1', 'send', NULL, 'program', '{"name":"P"}', '''
|
||||
''''["coach@example.com"]', 1785225600000, NULL, 0, 'pending')''',
|
||||
);
|
||||
await seedDatabase.customStatement('PRAGMA user_version = 22');
|
||||
await seedDatabase.close();
|
||||
|
||||
final migratedDatabase = local.AppDatabase(NativeDatabase(file));
|
||||
addTearDown(migratedDatabase.close);
|
||||
|
||||
final inboxRepository = local.DriftShareInboxRepository(migratedDatabase);
|
||||
final pendingRepository = local.DriftPendingShareActionRepository(
|
||||
migratedDatabase,
|
||||
);
|
||||
final now = DateTime.utc(2026, 7, 28, 11);
|
||||
|
||||
await inboxRepository.upsert(
|
||||
ShareInboxItem(
|
||||
shareId: 'share-pack-1',
|
||||
senderUserId: 'sender-2',
|
||||
resourceType: ShareResourceType.pack,
|
||||
payloadJson: jsonEncode({
|
||||
'kind': 'pack',
|
||||
'name': 'Pack reprise',
|
||||
'workouts': const [],
|
||||
}),
|
||||
status: ShareInboxStatus.pending,
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
await pendingRepository.add(
|
||||
PendingShareAction(
|
||||
id: 'pending-pack-1',
|
||||
actionType: PendingShareActionType.send,
|
||||
resourceType: ShareResourceType.pack,
|
||||
payloadJson: jsonEncode({
|
||||
'kind': 'pack',
|
||||
'name': 'Pack reprise',
|
||||
'workouts': const [],
|
||||
}),
|
||||
recipientEmailsJson: jsonEncode(['coach@example.com']),
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
|
||||
final version = await migratedDatabase
|
||||
.customSelect('PRAGMA user_version')
|
||||
.getSingle();
|
||||
final inboxItems = await inboxRepository.listAll();
|
||||
final pendingActions = await pendingRepository.listPending();
|
||||
|
||||
expect(version.data['user_version'], 24);
|
||||
expect(inboxItems.map((item) => item.shareId), contains('share-program-1'));
|
||||
expect(inboxItems.map((item) => item.shareId), contains('share-pack-1'));
|
||||
expect(
|
||||
pendingActions.map((action) => action.id),
|
||||
containsAll(['pending-program-1', 'pending-pack-1']),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'workout template saveAll rolls back every template on failure',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 28, 12);
|
||||
final valid = WorkoutTemplate(
|
||||
metadata: _metadata('template-valid', now),
|
||||
name: 'Séance valide',
|
||||
);
|
||||
final invalid = WorkoutTemplate(
|
||||
metadata: _metadata('template-invalid', now),
|
||||
name: 'Séance invalide',
|
||||
programs: [
|
||||
WorkoutTemplateProgram(
|
||||
metadata: _metadata('template-invalid-program-1', now),
|
||||
workoutTemplateId: 'template-invalid',
|
||||
position: 0,
|
||||
programNameSnapshot: 'Programme A',
|
||||
defaultRestSecondsSnapshot: 60,
|
||||
programSnapshotJson: jsonEncode({'exercises': const []}),
|
||||
),
|
||||
WorkoutTemplateProgram(
|
||||
metadata: _metadata('template-invalid-program-2', now),
|
||||
workoutTemplateId: 'template-invalid',
|
||||
position: 0,
|
||||
programNameSnapshot: 'Programme B',
|
||||
defaultRestSecondsSnapshot: 60,
|
||||
programSnapshotJson: jsonEncode({'exercises': const []}),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
templateRepository.saveAll([valid, invalid]),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
expect(await templateRepository.findById(valid.metadata.id), isNull);
|
||||
expect(await templateRepository.findById(invalid.metadata.id), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('repositories save and load normalized tags', () async {
|
||||
final now = DateTime.utc(2026, 7, 22, 10);
|
||||
await exerciseRepository.save(
|
||||
|
||||
@ -4,7 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/infrastructure/infrastructure.dart'
|
||||
hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult;
|
||||
hide
|
||||
WorkoutHistory,
|
||||
WorkoutHistorySetResult,
|
||||
WorkoutHistoryStepResult,
|
||||
WorkoutTelemetryAggregate,
|
||||
WorkoutTelemetrySample;
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
@ -221,6 +226,48 @@ void main() {
|
||||
await adapter.stop();
|
||||
await sensorUseCases.dispose();
|
||||
});
|
||||
|
||||
test('persists telemetry samples from the native stream', () async {
|
||||
final native = _FakeWatchBridgeNativeChannel();
|
||||
final source = _FakeProjectionSource(_runningProjection(revision: 1));
|
||||
final telemetryRepository = _FakeWorkoutTelemetryRepository();
|
||||
final adapter = _adapter(
|
||||
native: native,
|
||||
source: source,
|
||||
telemetryUseCases: WorkoutTelemetryUseCases(
|
||||
repository: telemetryRepository,
|
||||
clock: _FakeClock(_now),
|
||||
ids: _FakeIds(),
|
||||
),
|
||||
);
|
||||
await adapter.start();
|
||||
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now.millisecondsSinceEpoch,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
heartRateBpm: 120,
|
||||
distanceMeters: 500,
|
||||
caloriesKcal: 42,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(telemetryRepository.samples.single.id, 'sample-1');
|
||||
final aggregate = telemetryRepository.aggregates.singleWhere(
|
||||
(aggregate) => aggregate.scope == WorkoutTelemetryAggregateScope.session,
|
||||
);
|
||||
expect(aggregate.sampleCount, 1);
|
||||
expect(aggregate.minHeartRateBpm, 120);
|
||||
expect(aggregate.totalDistanceMeters, 500);
|
||||
expect(aggregate.totalCaloriesKcal, 42);
|
||||
await adapter.stop();
|
||||
});
|
||||
}
|
||||
|
||||
WatchWearDataLayerAdapter _adapter({
|
||||
@ -229,6 +276,7 @@ WatchWearDataLayerAdapter _adapter({
|
||||
required _FakeProjectionSource source,
|
||||
WorkoutHistoryUseCases? historyUseCases,
|
||||
ActiveWorkoutSensorUseCases? sensorUseCases,
|
||||
WorkoutTelemetryUseCases? telemetryUseCases,
|
||||
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||
}) {
|
||||
return WatchWearDataLayerAdapter(
|
||||
@ -237,6 +285,7 @@ WatchWearDataLayerAdapter _adapter({
|
||||
projectionSource: source,
|
||||
workoutHistoryUseCases: historyUseCases,
|
||||
activeWorkoutSensorUseCases: sensorUseCases,
|
||||
workoutTelemetryUseCases: telemetryUseCases,
|
||||
projectionRefreshInterval: heartbeatInterval,
|
||||
);
|
||||
}
|
||||
@ -312,6 +361,16 @@ final class _FakeClock implements Clock {
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
@override
|
||||
String newId() {
|
||||
_next += 1;
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeProjectionSource implements WatchProjectionSource {
|
||||
_FakeProjectionSource(this.current);
|
||||
|
||||
@ -466,8 +525,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
final index = histories.indexWhere(
|
||||
@ -497,6 +559,72 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
Future<void> delete(String id, DateTime deletedAt) async {}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutTelemetryRepository
|
||||
implements WorkoutTelemetryRepository {
|
||||
final samples = <WorkoutTelemetrySample>[];
|
||||
final aggregates = <WorkoutTelemetryAggregate>[];
|
||||
|
||||
@override
|
||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
||||
if (samples.any((existing) => existing.id == sample.id)) {
|
||||
return false;
|
||||
}
|
||||
samples.add(sample);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
||||
return samples
|
||||
.where((sample) => sample.sessionId == sessionId)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
required List<WorkoutTelemetryAggregate> aggregates,
|
||||
}) async {
|
||||
this.aggregates.removeWhere(
|
||||
(aggregate) => aggregate.sessionId == sessionId,
|
||||
);
|
||||
this.aggregates.addAll(aggregates);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTelemetryAggregate>> listAggregates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return aggregates
|
||||
.where((aggregate) => aggregate.sessionId == sessionId)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WorkoutTelemetryAggregate?> findAggregate({
|
||||
required String sessionId,
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) async {
|
||||
return aggregates
|
||||
.where(
|
||||
(aggregate) =>
|
||||
aggregate.sessionId == sessionId &&
|
||||
aggregate.scope == scope &&
|
||||
aggregate.programIndex == programIndex &&
|
||||
aggregate.exerciseIndex == exerciseIndex &&
|
||||
aggregate.setIndex == setIndex &&
|
||||
aggregate.passageIndex == passageIndex &&
|
||||
aggregate.stepIndex == stepIndex,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
final class _SentAck {
|
||||
const _SentAck(this.command, this.ack, this.revisionAtAck);
|
||||
|
||||
|
||||
@ -510,8 +510,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@ -539,6 +542,9 @@ final class _FakeWorkoutTemplateRepository
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
|
||||
@ -230,6 +230,11 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
),
|
||||
workoutTelemetryUseCases = WorkoutTelemetryUseCases(
|
||||
repository: _FakeWorkoutTelemetryRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
),
|
||||
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeRepository,
|
||||
historyRepository: _FakeWorkoutHistoryRepository(),
|
||||
@ -319,6 +324,9 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
@override
|
||||
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
|
||||
|
||||
@override
|
||||
final WorkoutTelemetryUseCases workoutTelemetryUseCases;
|
||||
|
||||
@override
|
||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||
|
||||
@ -868,6 +876,9 @@ final class _FakeWorkoutTemplateRepository
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
@ -891,8 +902,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@ -903,6 +917,43 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutTelemetryRepository
|
||||
implements WorkoutTelemetryRepository {
|
||||
@override
|
||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async => true;
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
required List<WorkoutTelemetryAggregate> aggregates,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTelemetryAggregate>> listAggregates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WorkoutTelemetryAggregate?> findAggregate({
|
||||
required String sessionId,
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeProgressionStatsRepository
|
||||
implements ProgressionStatsRepository {
|
||||
@override
|
||||
|
||||
@ -1019,6 +1019,9 @@ final class _FakeWorkoutTemplateRepository
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
|
||||
@ -1095,6 +1095,13 @@ final class _FakeWorkoutTemplateRepository
|
||||
templates.add(template);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {
|
||||
for (final template in templates) {
|
||||
await save(template);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
|
||||
@ -459,8 +459,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@ -488,6 +491,9 @@ final class _FakeWorkoutTemplateRepository
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/presentation/share_inbox_screen.dart';
|
||||
import 'package:gametime/presentation/share_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('la boîte de réception affiche les partages et les actions', (
|
||||
@ -71,6 +72,108 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('un pack reçu affiche un détail et importe toutes les séances', (
|
||||
tester,
|
||||
) async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..inboxItems = [
|
||||
_shareItem(
|
||||
shareId: 'share-pack',
|
||||
resourceType: ShareResourceType.pack,
|
||||
name: 'Pack reprise',
|
||||
status: ShareInboxStatus.pending,
|
||||
),
|
||||
];
|
||||
final inboxRepository = _FakeShareInboxRepository();
|
||||
final templateRepository = _FakeWorkoutTemplateRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ShareInboxScreen(
|
||||
shareUseCases: _shareUseCases(
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: inboxRepository,
|
||||
templateRepository: templateRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Pack reprise'), findsOneWidget);
|
||||
expect(find.text('Pack · 2 séances'), findsOneWidget);
|
||||
expect(find.text('Voir le pack'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Voir le pack'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Pack reçu'), findsOneWidget);
|
||||
expect(find.text('Séance tirs'), findsOneWidget);
|
||||
expect(find.text('Séance jambes'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Importer'));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(templateRepository.saved, hasLength(2));
|
||||
expect(
|
||||
templateRepository.saved.map((template) => template.name),
|
||||
containsAll(['Séance tirs', 'Séance jambes']),
|
||||
);
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
expect(find.text('Pack importé.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('composer un pack ouvre le formulaire de partage pack', (
|
||||
tester,
|
||||
) async {
|
||||
final remoteShareApi = _FakeRemoteShareApi();
|
||||
final templateRepository = _FakeWorkoutTemplateRepository()
|
||||
..templates.add(_workoutTemplate('template-1', 'Séance tirs'))
|
||||
..templates.add(_workoutTemplate('template-2', 'Séance jambes'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SharePackSelectionScreen(
|
||||
shareUseCases: _shareUseCases(
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: _FakeShareInboxRepository(),
|
||||
templateRepository: templateRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Nom du pack'),
|
||||
'Pack reprise',
|
||||
);
|
||||
await tester.tap(find.text('Séance tirs'));
|
||||
await tester.tap(find.text('Séance jambes'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Continuer'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Partager le pack'), findsOneWidget);
|
||||
expect(find.text('Pack reprise'), findsOneWidget);
|
||||
expect(find.text('2 séances'), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Emails destinataires'),
|
||||
'coach@example.com',
|
||||
);
|
||||
await tester.tap(find.text('Envoyer le partage'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(remoteShareApi.lastResourceType, ShareResourceType.pack);
|
||||
expect(remoteShareApi.lastPayload?['name'], 'Pack reprise');
|
||||
expect(remoteShareApi.lastPayload?['workouts'], hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'accepter un partage en cache hors connexion importe une copie locale',
|
||||
(tester) async {
|
||||
@ -129,6 +232,7 @@ ShareUseCases _shareUseCases({
|
||||
required _FakeShareInboxRepository inboxRepository,
|
||||
_FakePendingShareActionRepository? pendingActionRepository,
|
||||
_FakeProgramRepository? programRepository,
|
||||
_FakeWorkoutTemplateRepository? templateRepository,
|
||||
}) {
|
||||
return ShareUseCases(
|
||||
tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'),
|
||||
@ -138,7 +242,7 @@ ShareUseCases _shareUseCases({
|
||||
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
||||
localChanges: _FakeLocalSyncChangeRepository(),
|
||||
programRepository: programRepository ?? _FakeProgramRepository(),
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
@ -180,6 +284,13 @@ ShareInboxItem _shareItem({
|
||||
},
|
||||
],
|
||||
},
|
||||
ShareResourceType.pack => {
|
||||
'name': name,
|
||||
'workouts': [
|
||||
_templatePayload('template-1', 'Séance tirs'),
|
||||
_templatePayload('template-2', 'Séance jambes'),
|
||||
],
|
||||
},
|
||||
};
|
||||
return ShareInboxItem(
|
||||
shareId: shareId,
|
||||
@ -191,6 +302,23 @@ ShareInboxItem _shareItem({
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _templatePayload(String id, String name) {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'programs': [
|
||||
{
|
||||
'id': '$id-program',
|
||||
'position': 0,
|
||||
'programNameSnapshot': 'Programme',
|
||||
'defaultRestSecondsSnapshot': 60,
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
};
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
const _FakeClock(this.value);
|
||||
|
||||
@ -233,6 +361,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||
List<ShareInboxItem> inboxItems = const [];
|
||||
var acceptCalls = 0;
|
||||
var declineCalls = 0;
|
||||
ShareResourceType? lastResourceType;
|
||||
Map<String, Object?>? lastPayload;
|
||||
|
||||
@override
|
||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||||
@ -271,6 +401,8 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||
required List<String> recipientEmails,
|
||||
required String token,
|
||||
}) async {
|
||||
lastResourceType = resourceType;
|
||||
lastPayload = payload;
|
||||
return const RemoteShareSendResult(
|
||||
shareId: 'share-1',
|
||||
recipientUserIds: ['recipient-1'],
|
||||
@ -388,11 +520,18 @@ final class _FakeProgramRepository implements ProgramRepository {
|
||||
|
||||
final class _FakeWorkoutTemplateRepository
|
||||
implements WorkoutTemplateRepository {
|
||||
@override
|
||||
Future<WorkoutTemplate?> findById(String id) async => null;
|
||||
final templates = <WorkoutTemplate>[];
|
||||
final saved = <WorkoutTemplate>[];
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||||
Future<WorkoutTemplate?> findById(String id) async {
|
||||
return templates
|
||||
.where((template) => template.metadata.id == id)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTemplate>> listActive() async => List.of(templates);
|
||||
|
||||
@override
|
||||
Future<void> replaceComposition(
|
||||
@ -401,7 +540,16 @@ final class _FakeWorkoutTemplateRepository
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
Future<void> save(WorkoutTemplate template) async {
|
||||
saved.add(template);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {
|
||||
for (final template in templates) {
|
||||
await save(template);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
@ -409,3 +557,31 @@ final class _FakeWorkoutTemplateRepository
|
||||
@override
|
||||
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||||
}
|
||||
|
||||
WorkoutTemplate _workoutTemplate(String id, String name) {
|
||||
return WorkoutTemplate(
|
||||
metadata: EntityMetadata(
|
||||
id: id,
|
||||
createdAt: DateTime.utc(2026, 7, 17),
|
||||
updatedAt: DateTime.utc(2026, 7, 17),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
name: name,
|
||||
programs: [
|
||||
WorkoutTemplateProgram(
|
||||
metadata: EntityMetadata(
|
||||
id: '$id-program',
|
||||
createdAt: DateTime.utc(2026, 7, 17),
|
||||
updatedAt: DateTime.utc(2026, 7, 17),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
workoutTemplateId: id,
|
||||
sourceProgramId: null,
|
||||
position: 0,
|
||||
programNameSnapshot: 'Programme',
|
||||
defaultRestSecondsSnapshot: 60,
|
||||
programSnapshotJson: jsonEncode({'exercises': const []}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -895,6 +895,81 @@ void main() {
|
||||
expect(repository.restStates.single.skippedAt, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('les mutations externes de repos resynchronisent l’écran', (
|
||||
tester,
|
||||
) async {
|
||||
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: 2,
|
||||
restSeconds: 60,
|
||||
),
|
||||
);
|
||||
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('Repos avant la prochaine série'), findsNothing);
|
||||
repository.restStates.add(
|
||||
ActiveRestState(
|
||||
metadata: _metadata('rest-1'),
|
||||
activeWorkoutSessionId: 'session-1',
|
||||
afterProgramIndex: 0,
|
||||
afterExerciseIndex: 0,
|
||||
afterSetIndex: 0,
|
||||
plannedRestSeconds: 60,
|
||||
adjustedRestSeconds: 60,
|
||||
startedAt: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Repos avant la prochaine série'), findsOneWidget);
|
||||
|
||||
repository.restStates.clear();
|
||||
repository.session = ActiveWorkoutSession(
|
||||
metadata: session.metadata,
|
||||
sourceWorkoutTemplateId: session.sourceWorkoutTemplateId,
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: session.startedAt,
|
||||
lastPersistedAt: session.lastPersistedAt,
|
||||
elapsedActiveMs: session.elapsedActiveMs,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 1,
|
||||
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Repos avant la prochaine série'), findsNothing);
|
||||
expect(find.text('Terminer la série'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('un repos en pause est repris sans compter la durée de pause', (
|
||||
tester,
|
||||
) async {
|
||||
@ -1629,6 +1704,139 @@ void main() {
|
||||
expect(find.byType(SingleChildScrollView), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('répétitions et chrono score d’étape tiennent sans overflow', (
|
||||
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(
|
||||
targetReps: 12,
|
||||
exerciseSteps: [
|
||||
_step(
|
||||
'step-1',
|
||||
0,
|
||||
'Pompes tempo',
|
||||
ExerciseStepType.reps,
|
||||
12,
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
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('RÉPÉTITIONS'), findsOneWidget);
|
||||
expect(find.text('CHRONO SCORE'), findsOneWidget);
|
||||
expect(find.byTooltip('Lancer'), findsOneWidget);
|
||||
expect(find.byTooltip('Réinitialiser'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(OutlinedButton, 'Passer l’étape'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextButton, 'Réinitialiser'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('chrono décompte et score libre tiennent avec stepper compact', (
|
||||
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(
|
||||
timeEnabled: false,
|
||||
repsEnabled: false,
|
||||
scoreEnabled: false,
|
||||
exerciseSteps: [
|
||||
_step(
|
||||
'step-1',
|
||||
0,
|
||||
'Maintien défensif',
|
||||
ExerciseStepType.time,
|
||||
10,
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.manual,
|
||||
scoreLabel: 'Paniers',
|
||||
scoreUnit: 'points',
|
||||
defaultTargetScore: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
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('CHRONO'), findsOneWidget);
|
||||
expect(find.text('SCORE'), findsOneWidget);
|
||||
expect(find.text('00:10'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
expect(find.byTooltip('Diminuer'), findsOneWidget);
|
||||
expect(find.byTooltip('Augmenter'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextField, 'Score de l’étape'), findsNothing);
|
||||
expect(
|
||||
find.widgetWithText(OutlinedButton, 'Passer l’étape'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('l’écran actif avec séquence longue reste sans scroll', (
|
||||
tester,
|
||||
) async {
|
||||
@ -1790,6 +1998,24 @@ void main() {
|
||||
|
||||
expect(find.text('00:03'), findsOneWidget);
|
||||
expect(audio.shortBeeps, 1);
|
||||
|
||||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 3);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('00:02'), findsOneWidget);
|
||||
expect(audio.shortBeeps, 2);
|
||||
|
||||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 4);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('00:01'), findsOneWidget);
|
||||
expect(audio.shortBeeps, 3);
|
||||
|
||||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 5, 200);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
|
||||
expect(audio.longBeeps, 1);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@ -2727,6 +2953,9 @@ final class _FakeWorkoutTemplateRepository
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
@ -2750,8 +2979,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
|
||||
@ -1007,6 +1007,13 @@ final class _FakeWorkoutTemplateRepository
|
||||
templates.add(template);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates) async {
|
||||
for (final template in templates) {
|
||||
await save(template);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
@ -1207,8 +1214,11 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user