fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)

This commit is contained in:
2026-07-29 11:22:17 +02:00
parent 6f913e4e8d
commit 30c6259748
28 changed files with 1658 additions and 248 deletions

View File

@ -2611,6 +2611,68 @@ void main() {
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
});
test(
'ShareUseCases acceptShare imports all returned resources locally',
() async {
final remoteShareApi = _FakeRemoteShareApi()
..acceptResults = [
_remoteSharedProgramItem(),
_remoteSharedTemplateItem(),
];
final localChanges = _FakeLocalSyncChangeRepository();
final inboxRepository = _FakeShareInboxRepository()
..items.add(_shareInboxItem(status: ShareInboxStatus.pending));
await _shareUseCase(
remoteShareApi: remoteShareApi,
localChanges: localChanges,
inboxRepository: inboxRepository,
).acceptShare('share-1');
expect(localChanges.appliedItems.map((item) => item.resourceType), [
SyncResourceType.program,
SyncResourceType.workoutTemplate,
]);
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
},
);
test(
'ShareUseCases acceptShare imports cached pack payload after remote ack',
() async {
final remoteShareApi = _FakeRemoteShareApi();
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(
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
templateRepository: templateRepository,
).acceptShare('share-1');
expect(remoteShareApi.acceptCalls, 1);
expect(templateRepository.templates, hasLength(2));
expect(
templateRepository.templates.map((template) => template.name),
containsAll(['Séance A', 'Séance B']),
);
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
},
);
test(
'ShareUseCases acceptShare sans token importe une copie locale indépendante',
() async {
@ -4559,6 +4621,7 @@ ShareUseCases _shareUseCase({
final class _FakeRemoteShareApi implements RemoteShareApi {
Exception? exception;
RemoteSyncedItem? acceptResult;
List<RemoteSyncedItem>? acceptResults;
List<ShareInboxItem> inboxItems = const [];
var sendCalls = 0;
var acceptCalls = 0;
@ -4568,13 +4631,16 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
Map<String, Object?>? lastPayload;
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
acceptCalls += 1;
final error = exception;
if (error != null) {
throw error;
}
return acceptResult ?? _remoteSharedProgramItem();
return acceptResults ?? [acceptResult ?? _remoteSharedProgramItem()];
}
@override
@ -4804,6 +4870,19 @@ RemoteSyncedItem _remoteSharedProgramItem() {
);
}
RemoteSyncedItem _remoteSharedTemplateItem() {
return RemoteSyncedItem(
resourceType: SyncResourceType.workoutTemplate,
clientId: 'shared-template-1',
serverId: 'server-template-1',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12, 2),
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 3),
deletedAt: null,
payload: const {'id': 'shared-template-1', 'name': 'Séance partagée'},
);
}
ExerciseUseCases _exerciseUseCase(
_FakeExerciseRepository repository, {
_FakeProgramRepository? programRepository,

View File

@ -377,6 +377,43 @@ void main() {
expect(env.repository.stepResults.last.actualScore, 0);
});
test('projects reps target for independent step manual score', () async {
final session = _session(
steps: [
_step(
type: ExerciseStepType.reps,
defaultTargetValue: 10,
hasScore: true,
defaultTargetScore: 8,
),
],
);
final repository = _FakeActiveSessionRepository()..session = session;
repository.stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
);
final projectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _FakeClock(_now),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final projection = await projectionUseCases.emitCurrentProjection();
expect(projection.hasManualScore, isTrue);
expect(projection.manualScoreScope, WatchManualScoreScope.step);
expect(projection.manualScoreRepsTargetValue, 10);
expect(projection.manualScoreTargetValue, 8);
expect(projection.manualScoreTargetLabel, 'Cible');
expect(
projection.expiresAtEpochMs - projection.projectedAtEpochMs,
const Duration(seconds: 12).inMilliseconds,
);
await projectionUseCases.dispose();
});
test('decrementScore at zero is accepted no-op', () async {
final env = _env(
session: _session(scoreEnabled: true),
@ -740,6 +777,7 @@ final class _FakeProjectionSource implements WatchProjectionSource {
deviceSessionId: projection.deviceSessionId,
revision: projection.revision + 1,
projectedAtEpochMs: projection.projectedAtEpochMs,
expiresAtEpochMs: projection.expiresAtEpochMs,
phase: projection.phase,
phoneReachable: projection.phoneReachable,
seriesIndex: projection.seriesIndex,
@ -752,6 +790,9 @@ final class _FakeProjectionSource implements WatchProjectionSource {
hasManualScore: projection.hasManualScore,
currentManualScoreValue: projection.currentManualScoreValue,
canDecrementScore: projection.canDecrementScore,
manualScoreTargetValue: projection.manualScoreTargetValue,
manualScoreTargetLabel: projection.manualScoreTargetLabel,
manualScoreRepsTargetValue: projection.manualScoreRepsTargetValue,
manualScoreScope: projection.manualScoreScope,
);
return projection;

View File

@ -10,6 +10,7 @@ import 'package:gametime/infrastructure/local/local.dart' as local;
void main() {
late local.AppDatabase database;
late local.DriftMediaAssetRepository mediaAssetRepository;
late local.DriftExerciseRepository exerciseRepository;
late local.DriftProgramRepository programRepository;
late local.DriftActiveSessionRepository activeRepository;
@ -24,6 +25,7 @@ void main() {
setUp(() {
database = local.AppDatabase(NativeDatabase.memory());
mediaAssetRepository = local.DriftMediaAssetRepository(database);
exerciseRepository = local.DriftExerciseRepository(database);
programRepository = local.DriftProgramRepository(database);
activeRepository = local.DriftActiveSessionRepository(database);
@ -482,6 +484,209 @@ CREATE TABLE pending_share_actions (
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
});
test('local sync payload includes full workout history aggregate', () async {
final now = DateTime.utc(2026, 7, 22, 10, 45);
await historyRepository.save(
_history(
id: 'sync-history-full',
startedAt: now,
result: _historySetResult(
id: 'sync-history-set-result',
historyId: 'sync-history-full',
sourceExerciseId: 'exercise-sync-full',
setIndex: 0,
startedAt: now,
actualScore: 12,
),
stepResults: [
_historyStepResult(
id: 'sync-history-step-result',
historyId: 'sync-history-full',
sourceExerciseId: 'exercise-sync-full',
startedAt: now,
),
],
minHeartRateBpm: 90,
averageHeartRateBpm: 120,
maxHeartRateBpm: 150,
totalDistanceMeters: 42,
totalCaloriesKcal: 12,
),
);
final changes = await syncChangeRepository.listPendingChanges();
final payload = changes
.singleWhere((change) => change.item.clientId == 'sync-history-full')
.item
.payload;
expect(payload['minHeartRateBpm'], 90);
expect(payload['averageHeartRateBpm'], 120);
expect(payload['maxHeartRateBpm'], 150);
expect(payload['totalDistanceMeters'], 42);
expect(payload['totalCaloriesKcal'], 12);
expect(payload['results'], hasLength(1));
expect(payload['stepResults'], hasLength(1));
});
test('local sync pull restores exercise images and steps', () async {
final now = DateTime.utc(2026, 7, 22, 10, 50);
await mediaAssetRepository.save(
MediaAsset(
metadata: _metadata('remote-image', now),
kind: MediaKind.image,
localUri: 'file:///remote-image.png',
),
);
final applied = await syncChangeRepository.applyRemoteItem(
RemoteSyncedItem(
resourceType: SyncResourceType.exercise,
clientId: 'remote-exercise-with-children',
serverId: 'server-exercise-with-children',
schemaVersion: 1,
clientUpdatedAt: now,
serverUpdatedAt: now,
deletedAt: null,
payload: {
'id': 'remote-exercise-with-children',
'name': 'Remote exercise',
'imageMediaIds': const ['remote-image'],
'iconMediaId': 'remote-image',
'hasTimeMeasure': false,
'hasRepsMeasure': true,
'hasScoreMeasure': true,
'scoreInputMode': 'manual',
'scoreLabel': 'Paniers',
'scoreUnit': 'pts',
'steps': [
_exerciseStep(
id: 'remote-step',
position: 0,
name: 'Tir main droite',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'pts',
linkedToSeriesScore: true,
).toSnapshotJson(),
],
},
),
);
final exercise = await exerciseRepository.findById(
'remote-exercise-with-children',
);
expect(applied, isTrue);
expect(exercise!.imageMediaIds, ['remote-image']);
expect(exercise.steps, hasLength(1));
expect(exercise.steps.single.linkedToSeriesScore, isTrue);
});
test('local sync pull restores full workout history aggregate', () async {
final now = DateTime.utc(2026, 7, 22, 10, 55);
final payload = <String, Object?>{
'metadata': {
'id': 'remote-history-full',
'createdAt': now.toUtc().toIso8601String(),
'updatedAt': now.toUtc().toIso8601String(),
'schemaVersion': 1,
'syncState': 'synced',
'localRevision': 0,
'originDeviceId': 'device-remote',
},
'id': 'remote-history-full',
'nameSnapshot': 'Remote history',
'startedAt': now.toUtc().toIso8601String(),
'endedAt': now.add(const Duration(minutes: 5)).toUtc().toIso8601String(),
'totalActiveMs': 300000,
'completed': true,
'historySnapshotJson': '{"name":"remote-history-full"}',
'minHeartRateBpm': 95,
'averageHeartRateBpm': 125,
'maxHeartRateBpm': 155,
'totalDistanceMeters': 84,
'totalCaloriesKcal': 24,
'results': [
{
'id': 'remote-history-set-result',
'workoutHistoryId': 'remote-history-full',
'programSnapshotId': 'program-snapshot',
'exerciseSnapshotId': 'exercise-snapshot-remote-exercise',
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'programNameSnapshot': 'Program',
'exerciseNameSnapshot': 'Exercise',
'timeEnabledSnapshot': false,
'repsEnabledSnapshot': false,
'scoreEnabledSnapshot': true,
'scoreInputModeSnapshot': 'stopwatch',
'actualScoreTimeMs': 12000,
'sourceExerciseIdSnapshot': 'remote-exercise',
'completedAt': now
.add(const Duration(minutes: 1))
.toUtc()
.toIso8601String(),
'status': 'completed',
},
],
'stepResults': [
{
'id': 'remote-history-step-result',
'workoutHistoryId': 'remote-history-full',
'programSnapshotId': 'program-snapshot',
'exerciseSnapshotId': 'exercise-snapshot-remote-exercise',
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'passageIndex': 0,
'stepIndex': 0,
'stepSnapshotId': 'step-snapshot',
'stepNameSnapshot': 'Step',
'stepTypeSnapshot': 'reps',
'targetValueSnapshot': 10,
'hasScoreSnapshot': false,
'status': 'completed',
'startedAt': now.toUtc().toIso8601String(),
'completedAt': now
.add(const Duration(seconds: 10))
.toUtc()
.toIso8601String(),
'actualReps': 10,
'sourceExerciseIdSnapshot': 'remote-exercise',
},
],
};
final applied = await syncChangeRepository.applyRemoteItem(
RemoteSyncedItem(
resourceType: SyncResourceType.workoutHistory,
clientId: 'remote-history-full',
serverId: 'server-history-full',
schemaVersion: 1,
clientUpdatedAt: now,
serverUpdatedAt: now,
deletedAt: null,
payload: payload,
),
);
final restored = await historyRepository.findById('remote-history-full');
expect(applied, isTrue);
expect(restored!.minHeartRateBpm, 95);
expect(restored.averageHeartRateBpm, 125);
expect(restored.maxHeartRateBpm, 155);
expect(restored.totalDistanceMeters, 84);
expect(restored.totalCaloriesKcal, 24);
expect(restored.results.single.actualScoreTimeMs, 12000);
expect(restored.stepResults.single.actualReps, 10);
});
test('local sync pull defaults missing tags to empty lists', () async {
final now = DateTime.utc(2026, 7, 22, 11);
await syncChangeRepository.applyRemoteItem(
@ -2616,8 +2821,14 @@ WorkoutHistory _history({
required DateTime startedAt,
WorkoutHistorySetResult? result,
List<WorkoutHistorySetResult>? results,
List<WorkoutHistoryStepResult> stepResults = const [],
bool completed = true,
int totalActiveMs = 300000,
int? minHeartRateBpm,
double? averageHeartRateBpm,
int? maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
}) {
return WorkoutHistory(
metadata: _metadata(id, startedAt),
@ -2628,6 +2839,12 @@ WorkoutHistory _history({
completed: completed,
historySnapshotJson: '{"name":"$id"}',
results: results ?? [result!],
stepResults: stepResults,
minHeartRateBpm: minHeartRateBpm,
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
totalDistanceMeters: totalDistanceMeters,
totalCaloriesKcal: totalCaloriesKcal,
);
}
@ -2744,6 +2961,7 @@ ExerciseStep _exerciseStep({
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
bool linkedToSeriesScore = false,
}) {
return ExerciseStep(
id: id,
@ -2757,6 +2975,7 @@ ExerciseStep _exerciseStep({
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
);
}

View File

@ -0,0 +1,181 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/infrastructure/remote/remote.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test(
'sendShare maps local workout pack payload to server pack contract',
() async {
Map<String, Object?>? capturedBody;
final api = HttpRemoteShareApi(
HttpApiClient(
baseUrl: Uri.parse('http://api.example.test'),
client: MockClient((request) async {
capturedBody = _jsonMap(request.body);
expect(request.method, 'POST');
expect(request.url.path, '/shares');
expect(request.headers['authorization'], 'Bearer token-1');
return http.Response(
jsonEncode({
'shareId': 'share-1',
'recipientUserIds': ['user-2'],
'unresolvedEmails': const <String>[],
}),
201,
);
}),
),
);
await api.sendShare(
resourceType: ShareResourceType.pack,
payload: const {
'name': 'Pack reprise',
'workouts': [
{'id': 'template-1', 'name': 'Séance 1'},
{'id': 'template-2', 'name': 'Séance 2'},
],
},
recipientEmails: const ['friend@example.com'],
token: 'token-1',
);
expect(capturedBody, {
'shareKind': 'pack',
'packName': 'Pack reprise',
'items': [
{
'resourceType': 'workoutTemplate',
'payload': {'id': 'template-1', 'name': 'Séance 1'},
},
{
'resourceType': 'workoutTemplate',
'payload': {'id': 'template-2', 'name': 'Séance 2'},
},
],
'recipientEmails': ['friend@example.com'],
});
},
);
test(
'fetchInbox maps server pack payload without resourceType to local pack',
() async {
final api = HttpRemoteShareApi(
HttpApiClient(
baseUrl: Uri.parse('http://api.example.test'),
client: MockClient((request) async {
expect(request.method, 'GET');
expect(request.url.path, '/shares/inbox');
return http.Response(
jsonEncode({
'items': [
{
'shareId': 'share-pack-1',
'senderUserId': 'sender-1',
'shareKind': 'pack',
'packName': 'Pack été',
'resourceType': null,
'payload': {
'items': [
{
'resourceType': 'workoutTemplate',
'payload': {'id': 'template-1', 'name': 'Séance 1'},
},
],
},
'status': 'pending',
'createdAt': '2026-07-17T12:00:00.000Z',
'respondedAt': null,
},
],
}),
200,
);
}),
),
);
final items = await api.fetchInbox('token-1');
final payload = _jsonMap(items.single.payloadJson);
expect(items.single.resourceType, ShareResourceType.pack);
expect(payload, {
'name': 'Pack été',
'workouts': [
{'id': 'template-1', 'name': 'Séance 1'},
],
});
},
);
test(
'acceptShare returns every created resource from server packs',
() async {
final api = HttpRemoteShareApi(
HttpApiClient(
baseUrl: Uri.parse('http://api.example.test'),
client: MockClient((request) async {
expect(request.method, 'POST');
expect(request.url.path, '/shares/share-pack-1/accept');
return http.Response(
jsonEncode({
'createdResources': [
_createdResourceJson(
resourceType: 'program',
clientId: 'program-1',
serverId: 'server-program-1',
),
_createdResourceJson(
resourceType: 'workoutTemplate',
clientId: 'template-1',
serverId: 'server-template-1',
),
],
}),
200,
);
}),
),
);
final resources = await api.acceptShare('share-pack-1', 'token-1');
expect(resources.map((item) => item.resourceType), [
SyncResourceType.program,
SyncResourceType.workoutTemplate,
]);
expect(resources.map((item) => item.clientId), [
'program-1',
'template-1',
]);
},
);
}
Map<String, Object?> _createdResourceJson({
required String resourceType,
required String clientId,
required String serverId,
}) {
return {
'resourceType': resourceType,
'clientId': clientId,
'serverId': serverId,
'schemaVersion': 1,
'clientUpdatedAt': '2026-07-17T12:00:00.000Z',
'serverUpdatedAt': '2026-07-17T12:01:00.000Z',
'deletedAt': null,
'payload': {'id': clientId},
};
}
Map<String, Object?> _jsonMap(String source) {
final decoded = jsonDecode(source);
return Map<String, Object?>.from(decoded as Map);
}

View File

@ -520,7 +520,10 @@ final class _FakeLocalSyncChangeRepository
final class _FakeRemoteShareApi implements RemoteShareApi {
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
throw const RemoteAuthException(RemoteAuthFailure.network);
}

View File

@ -887,17 +887,22 @@ final class _FakeLocalSyncChangeRepository
final class _FakeRemoteShareApi implements RemoteShareApi {
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'program-remote',
serverId: 'program-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
);
Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
return [
RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'program-remote',
serverId: 'program-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
),
];
}
@override

View File

@ -1241,17 +1241,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
List<String> lastRecipientEmails = const [];
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'program-remote',
serverId: 'program-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
);
Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
return [
RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'program-remote',
serverId: 'program-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
),
];
}
@override

View File

@ -365,19 +365,24 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
Map<String, Object?>? lastPayload;
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
acceptCalls += 1;
_mark(shareId, ShareInboxStatus.accepted);
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'shared-program-1',
serverId: 'server-program-1',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme tirs'},
);
return [
RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'shared-program-1',
serverId: 'server-program-1',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme tirs'},
),
];
}
@override

View File

@ -1942,6 +1942,12 @@ void main() {
await tester.pump();
expect(find.text('8'), findsWidgets);
expect(find.byType(SingleChildScrollView), findsNothing);
expect(
find.widgetWithText(OutlinedButton, 'Passer létape'),
findsOneWidget,
);
expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsOneWidget);
await tester.tap(find.text('Étape suivante'));
await tester.pump();

View File

@ -1272,17 +1272,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
List<String> lastRecipientEmails = const [];
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.workoutTemplate,
clientId: 'template-remote',
serverId: 'template-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Séance partagée'},
);
Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
return [
RemoteSyncedItem(
resourceType: SyncResourceType.workoutTemplate,
clientId: 'template-remote',
serverId: 'template-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Séance partagée'},
),
];
}
@override