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);