Ajoute la synchronisation client incrémentale LWW vers l'API serveur (application/use_cases.dart: SyncUseCases, ports.dart, migration Drift schemaVersion 10→11 pour les métadonnées de sync et mappings de ressources, infrastructure/remote/sync_api.dart) et l'écran de profil avec entrée sur l'accueil (presentation/profile_screen.dart, home_screen.dart, presentation.dart). Développés dans le même worktree partagé par DevBackend et DevFrontend ; commit combiné car test/presentation/home_screen_test.dart mélange authentiquement les deux tickets (le test de l'entrée Profil et la mise à jour du fake de bootstrap requise par le nouveau getter syncUseCases sur AppDependencies). Corrige au passage une couleur `crimson` inexistante dans le thème (remplacée par colorScheme.error) et une signature de paramètres positionnels/nommés incohérente sur un fake de test. flutter pub get OK, build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 119/119 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1251,6 +1251,116 @@ void main() {
|
||||
expect(remoteAuthApi.logoutCalls, 0);
|
||||
});
|
||||
|
||||
test('SyncUseCases skips silently without token', () async {
|
||||
final remoteSyncApi = _FakeRemoteSyncApi();
|
||||
final metadataRepository = _FakeSyncMetadataRepository();
|
||||
final localChanges = _FakeLocalSyncChangeRepository();
|
||||
|
||||
final summary = await _syncUseCase(
|
||||
tokenStore: _FakeAuthTokenStore(),
|
||||
remoteSyncApi: remoteSyncApi,
|
||||
metadataRepository: metadataRepository,
|
||||
localChanges: localChanges,
|
||||
).synchronize(manual: true);
|
||||
|
||||
expect(summary.skipped, isTrue);
|
||||
expect(remoteSyncApi.pushCalls, 0);
|
||||
expect(remoteSyncApi.pullCalls, 0);
|
||||
expect(metadataRepository.saved, isEmpty);
|
||||
});
|
||||
|
||||
test('SyncUseCases successful push marks change log rows synced', () async {
|
||||
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
||||
final localChanges = _FakeLocalSyncChangeRepository()
|
||||
..pendingChanges.add(_pendingExerciseChange());
|
||||
final remoteSyncApi = _FakeRemoteSyncApi()
|
||||
..pushResult = RemoteSyncPushResult(
|
||||
serverCursor: 'cursor-1',
|
||||
results: [
|
||||
RemoteSyncPushItemResult(
|
||||
resourceType: SyncResourceType.exercise,
|
||||
clientId: 'exercise-1',
|
||||
serverId: 'server-exercise-1',
|
||||
status: RemoteSyncPushStatus.accepted,
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
||||
),
|
||||
],
|
||||
);
|
||||
final mappingRepository = _FakeRemoteResourceMappingRepository();
|
||||
|
||||
final summary = await _syncUseCase(
|
||||
tokenStore: tokenStore,
|
||||
remoteSyncApi: remoteSyncApi,
|
||||
mappingRepository: mappingRepository,
|
||||
localChanges: localChanges,
|
||||
).synchronize(manual: true);
|
||||
|
||||
expect(summary.pushedChanges, 1);
|
||||
expect(localChanges.syncedChangeLogIds, ['change-1']);
|
||||
expect(mappingRepository.saved.single.serverId, 'server-exercise-1');
|
||||
});
|
||||
|
||||
test('SyncUseCases pull applies a newer remote item', () async {
|
||||
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
||||
final localChanges = _FakeLocalSyncChangeRepository()
|
||||
..localUpdatedAt = DateTime.utc(2026, 7, 17, 11);
|
||||
final remoteSyncApi = _FakeRemoteSyncApi()
|
||||
..pullResult = RemoteSyncPullResult(
|
||||
serverCursor: 'cursor-2',
|
||||
items: [
|
||||
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 12)),
|
||||
],
|
||||
);
|
||||
|
||||
final summary = await _syncUseCase(
|
||||
tokenStore: tokenStore,
|
||||
remoteSyncApi: remoteSyncApi,
|
||||
localChanges: localChanges,
|
||||
).synchronize(manual: true);
|
||||
|
||||
expect(summary.pulledChanges, 1);
|
||||
expect(localChanges.appliedItems.single.clientId, 'exercise-1');
|
||||
});
|
||||
|
||||
test('SyncUseCases pull ignores an older remote item', () async {
|
||||
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
||||
final localChanges = _FakeLocalSyncChangeRepository()
|
||||
..localUpdatedAt = DateTime.utc(2026, 7, 17, 12);
|
||||
final remoteSyncApi = _FakeRemoteSyncApi()
|
||||
..pullResult = RemoteSyncPullResult(
|
||||
serverCursor: 'cursor-2',
|
||||
items: [
|
||||
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 11)),
|
||||
],
|
||||
);
|
||||
|
||||
final summary = await _syncUseCase(
|
||||
tokenStore: tokenStore,
|
||||
remoteSyncApi: remoteSyncApi,
|
||||
localChanges: localChanges,
|
||||
).synchronize(manual: true);
|
||||
|
||||
expect(summary.pulledChanges, 0);
|
||||
expect(localChanges.appliedItems, isEmpty);
|
||||
});
|
||||
|
||||
test('SyncUseCases stores failure status without throwing', () async {
|
||||
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
||||
final metadataRepository = _FakeSyncMetadataRepository();
|
||||
final remoteSyncApi = _FakeRemoteSyncApi()
|
||||
..exception = const RemoteAuthException(RemoteAuthFailure.network);
|
||||
|
||||
final summary = await _syncUseCase(
|
||||
tokenStore: tokenStore,
|
||||
remoteSyncApi: remoteSyncApi,
|
||||
metadataRepository: metadataRepository,
|
||||
).synchronize(manual: true);
|
||||
|
||||
expect(summary.failed, isTrue);
|
||||
expect(metadataRepository.metadata.status, OnlineSyncStatus.failure);
|
||||
expect(metadataRepository.metadata.lastFailureAt, isNotNull);
|
||||
});
|
||||
|
||||
test('score result enforces manual xor stopwatch values', () {
|
||||
expect(
|
||||
() => ActiveSetResult(
|
||||
@ -1461,6 +1571,169 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
||||
}
|
||||
}
|
||||
|
||||
SyncUseCases _syncUseCase({
|
||||
_FakeAuthTokenStore? tokenStore,
|
||||
_FakeRemoteSyncApi? remoteSyncApi,
|
||||
_FakeSyncMetadataRepository? metadataRepository,
|
||||
_FakeRemoteResourceMappingRepository? mappingRepository,
|
||||
_FakeLocalSyncChangeRepository? localChanges,
|
||||
_FakeClock? clock,
|
||||
}) {
|
||||
return SyncUseCases(
|
||||
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
|
||||
remoteSyncApi: remoteSyncApi ?? _FakeRemoteSyncApi(),
|
||||
metadataRepository: metadataRepository ?? _FakeSyncMetadataRepository(),
|
||||
mappingRepository:
|
||||
mappingRepository ?? _FakeRemoteResourceMappingRepository(),
|
||||
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
|
||||
clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
deviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeRemoteSyncApi implements RemoteSyncApi {
|
||||
RemoteSyncPushResult pushResult = const RemoteSyncPushResult(
|
||||
serverCursor: null,
|
||||
results: [],
|
||||
);
|
||||
RemoteSyncPullResult pullResult = const RemoteSyncPullResult(
|
||||
serverCursor: null,
|
||||
items: [],
|
||||
);
|
||||
Exception? exception;
|
||||
var pushCalls = 0;
|
||||
var pullCalls = 0;
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
}) async {
|
||||
pushCalls += 1;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
return pushResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPullResult> pull({
|
||||
required String? since,
|
||||
required String token,
|
||||
}) async {
|
||||
pullCalls += 1;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
return pullResult;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
|
||||
SyncMetadataSnapshot metadata = const SyncMetadataSnapshot();
|
||||
final saved = <SyncMetadataSnapshot>[];
|
||||
|
||||
@override
|
||||
Future<SyncMetadataSnapshot> read() async => metadata;
|
||||
|
||||
@override
|
||||
Future<void> save(SyncMetadataSnapshot metadata) async {
|
||||
this.metadata = metadata;
|
||||
saved.add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeRemoteResourceMappingRepository
|
||||
implements RemoteResourceMappingRepository {
|
||||
final saved = <RemoteResourceMapping>[];
|
||||
|
||||
@override
|
||||
Future<RemoteResourceMapping?> find({
|
||||
required SyncResourceType resourceType,
|
||||
required String clientId,
|
||||
}) async {
|
||||
return saved
|
||||
.where(
|
||||
(mapping) =>
|
||||
mapping.resourceType == resourceType &&
|
||||
mapping.clientId == clientId,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(RemoteResourceMapping mapping) async {
|
||||
saved.removeWhere(
|
||||
(existing) =>
|
||||
existing.resourceType == mapping.resourceType &&
|
||||
existing.clientId == mapping.clientId,
|
||||
);
|
||||
saved.add(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeLocalSyncChangeRepository
|
||||
implements LocalSyncChangeRepository {
|
||||
final pendingChanges = <PendingSyncChange>[];
|
||||
final syncedChangeLogIds = <String>[];
|
||||
final appliedItems = <RemoteSyncedItem>[];
|
||||
DateTime? localUpdatedAt;
|
||||
|
||||
@override
|
||||
Future<List<PendingSyncChange>> listPendingChanges() async {
|
||||
return pendingChanges;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markChangesSynced(
|
||||
List<String> changeLogIds,
|
||||
DateTime syncedAt,
|
||||
) async {
|
||||
syncedChangeLogIds.addAll(changeLogIds);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyRemoteItem(RemoteSyncedItem item) async {
|
||||
final updatedAt = localUpdatedAt;
|
||||
if (updatedAt != null && !item.clientUpdatedAt.isAfter(updatedAt)) {
|
||||
return false;
|
||||
}
|
||||
appliedItems.add(item);
|
||||
localUpdatedAt = item.clientUpdatedAt;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
PendingSyncChange _pendingExerciseChange() {
|
||||
return PendingSyncChange(
|
||||
changeLogIds: const ['change-1'],
|
||||
item: RemoteSyncPushItem(
|
||||
resourceType: SyncResourceType.exercise,
|
||||
clientId: 'exercise-1',
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
deletedAt: null,
|
||||
payload: const {'id': 'exercise-1', 'name': 'Squat'},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) {
|
||||
return RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.exercise,
|
||||
clientId: 'exercise-1',
|
||||
serverId: 'server-exercise-1',
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: clientUpdatedAt,
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
||||
deletedAt: null,
|
||||
payload: const {'id': 'exercise-1', 'name': 'Squat'},
|
||||
);
|
||||
}
|
||||
|
||||
ExerciseUseCases _exerciseUseCase(
|
||||
_FakeExerciseRepository repository, {
|
||||
_FakeProgramRepository? programRepository,
|
||||
|
||||
Reference in New Issue
Block a user