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:
2026-07-22 10:01:31 +02:00
parent 10fba8d2ec
commit add5025dd2
9 changed files with 1012 additions and 10 deletions

View File

@ -2256,6 +2256,221 @@ void main() {
);
});
test('Tags are normalized and validated on taggable entities', () {
final exercise = Exercise(
metadata: _metadata('exercise-tags'),
name: 'Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
tags: const [' Match Prep ', 'INTENSE'],
);
expect(exercise.tags, ['match prep', 'intense']);
expect(
() => Program(
metadata: _metadata('program-tags'),
name: 'Program',
defaultRestSeconds: 30,
tags: const ['match', 'MATCH'],
),
throwsA(isA<DomainException>()),
);
expect(
() => WorkoutTemplate(
metadata: _metadata('template-tags'),
name: 'Template',
tags: List.generate(9, (index) => 'tag$index'),
),
throwsA(isA<DomainException>()),
);
expect(
() => Exercise(
metadata: _metadata('exercise-long-tag'),
name: 'Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
tags: const ['tag beaucoup trop long pour le mvp'],
),
throwsA(isA<DomainException>()),
);
});
test('Tag helpers filter by all selected tags and sort suggestions', () {
final programs = [
Program(
metadata: _metadata('program-1'),
name: 'A',
defaultRestSeconds: 30,
tags: const ['match', 'intense'],
),
Program(
metadata: _metadata('program-2'),
name: 'B',
defaultRestSeconds: 30,
tags: const ['match', 'extérieur'],
),
Program(
metadata: _metadata('program-3'),
name: 'C',
defaultRestSeconds: 30,
tags: const ['intense'],
),
];
final filtered = filterByRequiredTags(programs, {
' MATCH ',
'intense',
}, (program) => program.tags);
final suggestions = tagSuggestionsFor(programs, (program) => program.tags);
expect(filtered.map((program) => program.metadata.id), ['program-1']);
expect(suggestions.map((usage) => '${usage.tag}:${usage.count}'), [
'intense:2',
'match:2',
'extérieur:1',
]);
});
test('Program use case duplicates deeply with copy name conflicts', () async {
final programRepository = _FakeProgramRepository();
final templateRepository = _FakeWorkoutTemplateRepository();
final source = Program(
metadata: _metadata('program-source'),
name: 'Programme tirs',
defaultRestSeconds: 45,
isExample: true,
tags: const ['match', 'extérieur'],
exercises: [
_programExercise(
id: 'program-exercise-source',
programId: 'program-source',
sourceExerciseId: 'exercise-source',
position: 0,
),
],
);
programRepository.programs.addAll([
source,
Program(
metadata: _metadata('program-copy-1'),
name: 'Copie de Programme tirs',
defaultRestSeconds: 45,
),
]);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final copy = await useCase.duplicate('program-source');
expect(copy.metadata.id, isNot('program-source'));
expect(copy.name, 'Copie 2 de Programme tirs');
expect(copy.isExample, isFalse);
expect(copy.tags, source.tags);
expect(copy.exercises.single.metadata.id, isNot('program-exercise-source'));
expect(copy.exercises.single.programId, copy.metadata.id);
expect(copy.exercises.single.sourceExerciseId, 'exercise-source');
expect(copy.exercises.single.targetReps, 10);
expect(
programRepository.programs
.singleWhere((program) => program.metadata.id == 'program-source')
.exercises
.single
.metadata
.id,
'program-exercise-source',
);
});
test(
'Workout template use case duplicates deeply and remaps overrides',
() async {
final templateRepository = _FakeWorkoutTemplateRepository();
final source = WorkoutTemplate(
metadata: _metadata('template-source'),
name: 'Prépa match',
lastStartedAt: DateTime.utc(2026, 7, 20, 12),
isExample: true,
tags: const ['match', 'intense'],
programs: [
_templateProgram(
id: 'template-program-source',
workoutTemplateId: 'template-source',
sourceProgramId: 'program-source',
position: 0,
),
],
overrides: [
WorkoutTemplateExerciseOverride(
metadata: _metadata('override-source'),
workoutTemplateProgramId: 'template-program-source',
snapshotProgramExerciseId: 'program-exercise-source',
setsCountOverride: 4,
targetRepsOverride: 12,
autoStartNextTimedStepOverride: false,
),
],
);
templateRepository.templates.addAll([
source,
WorkoutTemplate(
metadata: _metadata('template-copy-1'),
name: 'Copie de Prépa match',
),
]);
final useCase = WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final copy = await useCase.duplicate('template-source');
expect(copy.metadata.id, isNot('template-source'));
expect(copy.name, 'Copie 2 de Prépa match');
expect(copy.lastStartedAt, isNull);
expect(copy.isExample, isFalse);
expect(copy.tags, source.tags);
expect(
copy.programs.single.metadata.id,
isNot('template-program-source'),
);
expect(copy.programs.single.workoutTemplateId, copy.metadata.id);
expect(copy.overrides.single.metadata.id, isNot('override-source'));
expect(
copy.overrides.single.workoutTemplateProgramId,
copy.programs.single.metadata.id,
);
expect(
templateRepository.templates
.singleWhere(
(template) => template.metadata.id == 'template-source',
)
.programs
.single
.metadata
.id,
'template-program-source',
);
expect(
copy.overrides.single.snapshotProgramExerciseId,
'program-exercise-source',
);
expect(copy.overrides.single.setsCountOverride, 4);
expect(copy.overrides.single.targetRepsOverride, 12);
expect(copy.overrides.single.autoStartNextTimedStepOverride, isFalse);
},
);
test('Progression stats use case resolves four week overview', () async {
final repository = _FakeProgressionStatsRepository();
final now = DateTime.utc(2026, 7, 22, 12);

View File

@ -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(