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:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user