feat(bibliotheque): tags et duplication profonde
Ticket #83 B1-B3 : tags JSON dénormalisés, migration Drift v19, mappings sync, helpers de filtre/suggestions, duplication profonde Program/WorkoutTemplate, tests associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -15,6 +15,7 @@ void main() {
|
||||
late local.DriftWorkoutTemplateRepository templateRepository;
|
||||
late local.DriftWorkoutHistoryRepository historyRepository;
|
||||
late local.DriftProgressionStatsRepository progressionStatsRepository;
|
||||
late local.DriftLocalSyncChangeRepository syncChangeRepository;
|
||||
late local.DriftExercisePerformanceReferenceRepository
|
||||
performanceReferenceRepository;
|
||||
|
||||
@ -28,6 +29,7 @@ void main() {
|
||||
progressionStatsRepository = local.DriftProgressionStatsRepository(
|
||||
database,
|
||||
);
|
||||
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
|
||||
performanceReferenceRepository =
|
||||
local.DriftExercisePerformanceReferenceRepository(database);
|
||||
});
|
||||
@ -106,6 +108,290 @@ void main() {
|
||||
expect(restored!.defaultTargetScore, 0);
|
||||
});
|
||||
|
||||
test('taggable tables expose tags json columns on fresh schema', () async {
|
||||
Future<List<String>> columnNames(String tableName) async {
|
||||
final rows = await database
|
||||
.customSelect('PRAGMA table_info($tableName)')
|
||||
.get();
|
||||
return rows.map((row) => row.data['name'] as String).toList();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('repositories save and load normalized tags', () async {
|
||||
final now = DateTime.utc(2026, 7, 22, 10);
|
||||
await exerciseRepository.save(
|
||||
Exercise(
|
||||
metadata: _metadata('exercise-tags', now),
|
||||
name: 'Shoot',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
tags: const [' Match ', 'Extérieur'],
|
||||
),
|
||||
);
|
||||
await programRepository.save(
|
||||
Program(
|
||||
metadata: _metadata('program-tags', now),
|
||||
name: 'Program',
|
||||
defaultRestSeconds: 45,
|
||||
tags: const ['Intense'],
|
||||
),
|
||||
);
|
||||
await templateRepository.save(
|
||||
WorkoutTemplate(
|
||||
metadata: _metadata('template-tags', now),
|
||||
name: 'Template',
|
||||
tags: const ['Routine'],
|
||||
),
|
||||
);
|
||||
|
||||
final exercise = await exerciseRepository.findById('exercise-tags');
|
||||
final program = await programRepository.findById('program-tags');
|
||||
final template = await templateRepository.findById('template-tags');
|
||||
|
||||
expect(exercise!.tags, ['match', 'extérieur']);
|
||||
expect(program!.tags, ['intense']);
|
||||
expect(template!.tags, ['routine']);
|
||||
});
|
||||
|
||||
test('local sync payload includes tags for taggable resources', () async {
|
||||
final now = DateTime.utc(2026, 7, 22, 10, 30);
|
||||
await exerciseRepository.save(
|
||||
Exercise(
|
||||
metadata: _metadata('exercise-sync-tags', now),
|
||||
name: 'Shoot',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
tags: const ['match'],
|
||||
),
|
||||
);
|
||||
await programRepository.save(
|
||||
Program(
|
||||
metadata: _metadata('program-sync-tags', now),
|
||||
name: 'Program',
|
||||
defaultRestSeconds: 45,
|
||||
tags: const ['intense'],
|
||||
),
|
||||
);
|
||||
await templateRepository.save(
|
||||
WorkoutTemplate(
|
||||
metadata: _metadata('template-sync-tags', now),
|
||||
name: 'Template',
|
||||
tags: const ['routine'],
|
||||
),
|
||||
);
|
||||
|
||||
final changes = await syncChangeRepository.listPendingChanges();
|
||||
final payloadsById = {
|
||||
for (final change in changes) change.item.clientId: change.item.payload,
|
||||
};
|
||||
|
||||
expect(payloadsById['exercise-sync-tags']!['tags'], ['match']);
|
||||
expect(payloadsById['program-sync-tags']!['tags'], ['intense']);
|
||||
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
|
||||
});
|
||||
|
||||
test('local sync pull defaults missing tags to empty lists', () async {
|
||||
final now = DateTime.utc(2026, 7, 22, 11);
|
||||
await syncChangeRepository.applyRemoteItem(
|
||||
RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.exercise,
|
||||
clientId: 'remote-exercise-no-tags',
|
||||
serverId: 'server-exercise-no-tags',
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: now,
|
||||
serverUpdatedAt: now,
|
||||
deletedAt: null,
|
||||
payload: const {
|
||||
'id': 'remote-exercise-no-tags',
|
||||
'name': 'Remote exercise',
|
||||
'hasTimeMeasure': false,
|
||||
'hasRepsMeasure': true,
|
||||
'hasScoreMeasure': false,
|
||||
},
|
||||
),
|
||||
);
|
||||
await syncChangeRepository.applyRemoteItem(
|
||||
RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.program,
|
||||
clientId: 'remote-program-no-tags',
|
||||
serverId: 'server-program-no-tags',
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: now,
|
||||
serverUpdatedAt: now,
|
||||
deletedAt: null,
|
||||
payload: const {
|
||||
'id': 'remote-program-no-tags',
|
||||
'name': 'Remote program',
|
||||
'defaultRestSeconds': 30,
|
||||
},
|
||||
),
|
||||
);
|
||||
await syncChangeRepository.applyRemoteItem(
|
||||
RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.workoutTemplate,
|
||||
clientId: 'remote-template-no-tags',
|
||||
serverId: 'server-template-no-tags',
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: now,
|
||||
serverUpdatedAt: now,
|
||||
deletedAt: null,
|
||||
payload: const {
|
||||
'id': 'remote-template-no-tags',
|
||||
'name': 'Remote template',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final exercise = await exerciseRepository.findById(
|
||||
'remote-exercise-no-tags',
|
||||
);
|
||||
final program = await programRepository.findById('remote-program-no-tags');
|
||||
final template = await templateRepository.findById(
|
||||
'remote-template-no-tags',
|
||||
);
|
||||
|
||||
expect(exercise!.tags, isEmpty);
|
||||
expect(program!.tags, isEmpty);
|
||||
expect(template!.tags, isEmpty);
|
||||
});
|
||||
|
||||
test('program duplication persists copied children through Drift', () async {
|
||||
final now = DateTime.utc(2026, 7, 22, 11, 15);
|
||||
await exerciseRepository.save(
|
||||
Exercise(
|
||||
metadata: _metadata('exercise-dup-source', now),
|
||||
name: 'Shoot',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 10,
|
||||
),
|
||||
);
|
||||
await programRepository.replaceExercises(
|
||||
Program(
|
||||
metadata: _metadata('program-dup-source', now),
|
||||
name: 'Programme tirs',
|
||||
defaultRestSeconds: 30,
|
||||
isExample: true,
|
||||
tags: const ['match'],
|
||||
exercises: [
|
||||
_programExercise(
|
||||
'program-exercise-dup-source',
|
||||
now,
|
||||
programId: 'program-dup-source',
|
||||
position: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
now,
|
||||
);
|
||||
final useCase = ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: exerciseRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(now.add(const Duration(minutes: 1))),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final copy = await useCase.duplicate('program-dup-source');
|
||||
final restored = await programRepository.findById(copy.metadata.id);
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.name, 'Copie de Programme tirs');
|
||||
expect(restored.isExample, isFalse);
|
||||
expect(restored.tags, ['match']);
|
||||
expect(
|
||||
restored.exercises.single.metadata.id,
|
||||
isNot('program-exercise-dup-source'),
|
||||
);
|
||||
expect(restored.exercises.single.programId, restored.metadata.id);
|
||||
expect(restored.exercises.single.exerciseNameSnapshot, 'Exercise 0');
|
||||
});
|
||||
|
||||
test(
|
||||
'workout template duplication persists remapped overrides through Drift',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 22, 11, 30);
|
||||
await programRepository.save(
|
||||
Program(
|
||||
metadata: _metadata('program-template-source', now),
|
||||
name: 'Program source',
|
||||
defaultRestSeconds: 30,
|
||||
),
|
||||
);
|
||||
await templateRepository.replaceComposition(
|
||||
WorkoutTemplate(
|
||||
metadata: _metadata('template-dup-source', now),
|
||||
name: 'Prépa match',
|
||||
lastStartedAt: now,
|
||||
isExample: true,
|
||||
tags: const ['intense'],
|
||||
programs: [
|
||||
WorkoutTemplateProgram(
|
||||
metadata: _metadata('template-program-dup-source', now),
|
||||
workoutTemplateId: 'template-dup-source',
|
||||
sourceProgramId: 'program-template-source',
|
||||
position: 0,
|
||||
programNameSnapshot: 'Program source',
|
||||
defaultRestSecondsSnapshot: 30,
|
||||
programSnapshotJson: '{"exercises":[]}',
|
||||
),
|
||||
],
|
||||
overrides: [
|
||||
WorkoutTemplateExerciseOverride(
|
||||
metadata: _metadata('template-override-dup-source', now),
|
||||
workoutTemplateProgramId: 'template-program-dup-source',
|
||||
snapshotProgramExerciseId: 'snapshot-exercise',
|
||||
setsCountOverride: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
now,
|
||||
);
|
||||
final useCase = WorkoutTemplateUseCases(
|
||||
templateRepository: templateRepository,
|
||||
programRepository: programRepository,
|
||||
clock: _FakeClock(now.add(const Duration(minutes: 1))),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final copy = await useCase.duplicate('template-dup-source');
|
||||
final restored = await templateRepository.findById(copy.metadata.id);
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.name, 'Copie de Prépa match');
|
||||
expect(restored.lastStartedAt, isNull);
|
||||
expect(restored.isExample, isFalse);
|
||||
expect(restored.tags, ['intense']);
|
||||
expect(
|
||||
restored.programs.single.metadata.id,
|
||||
isNot('template-program-dup-source'),
|
||||
);
|
||||
expect(restored.programs.single.workoutTemplateId, restored.metadata.id);
|
||||
expect(
|
||||
restored.overrides.single.metadata.id,
|
||||
isNot('template-override-dup-source'),
|
||||
);
|
||||
expect(
|
||||
restored.overrides.single.workoutTemplateProgramId,
|
||||
restored.programs.single.metadata.id,
|
||||
);
|
||||
expect(
|
||||
restored.overrides.single.snapshotProgramExerciseId,
|
||||
'snapshot-exercise',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('starter seed populates a fresh database once', () async {
|
||||
final seedRepository = local.DriftStarterSeedRepository(database);
|
||||
final result = await SeedStarterContentUseCase(
|
||||
|
||||
Reference in New Issue
Block a user