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