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,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(
|
||||
|
||||
Reference in New Issue
Block a user