fix(core): complete la nullification defensive de sourceExerciseId pour #185

Delta final apres QA vert : renforce la robustesse du pull des programmes quand l'exercice source reference par sourceExerciseId est absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:02:38 +02:00
parent dd882f07ae
commit 933fc1121d
2 changed files with 259 additions and 24 deletions

View File

@ -4181,6 +4181,15 @@ Future<domain.ProgramExercise> _programExerciseFromRow(
db.AppDatabase database,
db.ProgramExercise row,
) async {
final imageMediaIds = _tryDecodeImageMediaIdsSnapshot(
row.exerciseImageMediaIdsSnapshotJson,
row.exerciseImageMediaIdSnapshot,
);
final steps = _tryDecodeExerciseStepsSnapshot(row.exerciseStepsSnapshotJson);
final scoreInputMode = _tryScoreInputModeFromDb(row.scoreInputModeSnapshot);
final healthServicesStrategy =
await _tryProgramExerciseHealthServicesStrategySnapshot(database, row.id);
try {
return domain.ProgramExercise(
metadata: _metadataFromRow(row),
programId: row.programId,
@ -4189,23 +4198,17 @@ Future<domain.ProgramExercise> _programExerciseFromRow(
exerciseNameSnapshot: row.exerciseNameSnapshot,
exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: _decodeImageMediaIdsSnapshot(
row.exerciseImageMediaIdsSnapshotJson,
row.exerciseImageMediaIdSnapshot,
),
exerciseStepsSnapshot: _decodeExerciseStepsSnapshot(
row.exerciseStepsSnapshotJson,
),
exerciseImageMediaIdsSnapshot: imageMediaIds,
exerciseStepsSnapshot: steps,
autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride,
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
healthServicesExerciseTypeStrategySnapshot:
await _programExerciseHealthServicesStrategySnapshot(database, row.id),
healthServicesExerciseTypeStrategySnapshot: healthServicesStrategy,
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
availableTimeSnapshot: row.availableTimeSnapshot,
availableRepsSnapshot: row.availableRepsSnapshot,
availableScoreSnapshot: row.availableScoreSnapshot,
scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot),
scoreInputModeSnapshot: scoreInputMode,
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
setsCount: row.setsCount,
@ -4218,6 +4221,180 @@ Future<domain.ProgramExercise> _programExerciseFromRow(
targetScoreTimeMs: row.targetScoreTimeMs,
restSecondsOverride: row.restSecondsOverride,
);
} on domain.DomainException {
return _repairedProgramExerciseFromRow(
row,
imageMediaIds: imageMediaIds,
steps: steps,
healthServicesStrategy: healthServicesStrategy,
scoreInputMode: scoreInputMode,
);
}
}
domain.ProgramExercise _repairedProgramExerciseFromRow(
db.ProgramExercise row, {
required List<String> imageMediaIds,
required List<domain.ExerciseStep> steps,
required List<domain.HealthServicesExerciseType> healthServicesStrategy,
required domain.ScoreInputMode scoreInputMode,
}) {
var availableTime = row.availableTimeSnapshot;
var availableReps = row.availableRepsSnapshot;
var availableScore = row.availableScoreSnapshot;
var timeEnabled = row.timeEnabled && availableTime;
var repsEnabled = row.repsEnabled && availableReps;
var scoreEnabled = row.scoreEnabled && availableScore;
if (!timeEnabled && !repsEnabled && !scoreEnabled) {
if (availableReps) {
repsEnabled = true;
} else if (availableTime) {
timeEnabled = true;
} else if (availableScore) {
scoreEnabled = true;
} else {
availableReps = true;
repsEnabled = true;
}
}
final repairedScoreInputMode = scoreEnabled
? scoreInputMode
: domain.ScoreInputMode.manual;
try {
return _programExerciseFromRepairedValues(
row,
imageMediaIds: imageMediaIds,
steps: steps,
healthServicesStrategy: healthServicesStrategy,
scoreInputMode: repairedScoreInputMode,
availableTime: availableTime,
availableReps: availableReps,
availableScore: availableScore,
timeEnabled: timeEnabled,
repsEnabled: repsEnabled,
scoreEnabled: scoreEnabled,
);
} on domain.DomainException {
return _programExerciseFromRepairedValues(
row,
imageMediaIds: imageMediaIds,
steps: const [],
healthServicesStrategy: healthServicesStrategy,
scoreInputMode: repairedScoreInputMode,
availableTime: availableTime,
availableReps: availableReps,
availableScore: availableScore,
timeEnabled: timeEnabled,
repsEnabled: repsEnabled,
scoreEnabled: scoreEnabled,
);
}
}
domain.ProgramExercise _programExerciseFromRepairedValues(
db.ProgramExercise row, {
required List<String> imageMediaIds,
required List<domain.ExerciseStep> steps,
required List<domain.HealthServicesExerciseType> healthServicesStrategy,
required domain.ScoreInputMode scoreInputMode,
required bool availableTime,
required bool availableReps,
required bool availableScore,
required bool timeEnabled,
required bool repsEnabled,
required bool scoreEnabled,
}) {
return domain.ProgramExercise(
metadata: _metadataFromRow(row),
programId: row.programId,
sourceExerciseId: row.sourceExerciseId,
position: row.position < 0 ? 0 : row.position,
exerciseNameSnapshot: row.exerciseNameSnapshot.trim().isEmpty
? row.id
: row.exerciseNameSnapshot,
exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: imageMediaIds,
exerciseStepsSnapshot: steps,
autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride,
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
healthServicesExerciseTypeStrategySnapshot: healthServicesStrategy,
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
availableTimeSnapshot: availableTime,
availableRepsSnapshot: availableReps,
availableScoreSnapshot: availableScore,
scoreInputModeSnapshot: scoreInputMode,
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
setsCount: row.setsCount <= 0 ? 1 : row.setsCount,
timeEnabled: timeEnabled,
repsEnabled: repsEnabled,
scoreEnabled: scoreEnabled,
targetTimeSeconds: timeEnabled
? _positiveIntOrNull(row.targetTimeSeconds)
: null,
targetReps: repsEnabled ? _positiveIntOrNull(row.targetReps) : null,
targetScore: scoreEnabled && scoreInputMode == domain.ScoreInputMode.manual
? _nonNegativeDoubleOrNull(row.targetScore)
: null,
targetScoreTimeMs:
scoreEnabled && scoreInputMode == domain.ScoreInputMode.stopwatch
? _positiveIntOrNull(row.targetScoreTimeMs)
: null,
restSecondsOverride: _nonNegativeIntOrNull(row.restSecondsOverride),
);
}
List<String> _tryDecodeImageMediaIdsSnapshot(
String? encoded,
String? fallbackImageMediaId,
) {
try {
return _decodeImageMediaIdsSnapshot(encoded, fallbackImageMediaId);
} on Object {
return fallbackImageMediaId == null ? const [] : [fallbackImageMediaId];
}
}
List<domain.ExerciseStep> _tryDecodeExerciseStepsSnapshot(String? encoded) {
try {
return _decodeExerciseStepsSnapshot(encoded);
} on Object {
return const [];
}
}
domain.ScoreInputMode _tryScoreInputModeFromDb(String value) {
try {
return _scoreInputModeFromDb(value);
} on domain.DomainException {
return domain.ScoreInputMode.manual;
}
}
int? _positiveIntOrNull(int? value) {
return value != null && value > 0 ? value : null;
}
int? _nonNegativeIntOrNull(int? value) {
return value != null && value >= 0 ? value : null;
}
double? _nonNegativeDoubleOrNull(double? value) {
return value != null && value >= 0 ? value : null;
}
Future<List<domain.HealthServicesExerciseType>>
_tryProgramExerciseHealthServicesStrategySnapshot(
db.AppDatabase database,
String id,
) async {
try {
return _programExerciseHealthServicesStrategySnapshot(database, id);
} on Object {
return domain.legacyHealthServicesExerciseTypeStrategy;
}
}
Future<List<domain.HealthServicesExerciseType>>

View File

@ -1750,6 +1750,64 @@ CREATE TABLE pending_share_actions (
expect(programs.single.exercises.single.targetReps, 8);
});
test(
'program list tolerates invalid snapshots after source exercise deletion',
() async {
final now = DateTime.utc(2026, 7, 30, 18);
final exercise = Exercise(
metadata: _metadata('exercise-deleted-invalid-snapshot', now),
name: 'Concours de tirs',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Score',
scoreUnit: 'pts',
);
await exerciseRepository.save(exercise);
await programRepository.save(
Program(
metadata: _metadata('program-invalid-snapshot', now),
name: 'Programme snapshot legacy',
defaultRestSeconds: 30,
exercises: [
ProgramExercise.snapshotFromExercise(
metadata: _metadata('program-exercise-invalid-snapshot', now),
programId: 'program-invalid-snapshot',
exercise: exercise,
position: 0,
setsCount: 2,
enabledMeasures: const {WorkoutMeasure.score},
),
],
),
);
await exerciseRepository.save(
exercise.copyWith(
metadata: exercise.metadata.markDeleted(
now.add(const Duration(minutes: 1)),
),
),
);
await database.customStatement('PRAGMA ignore_check_constraints = ON');
await database.customStatement(
'UPDATE program_exercises '
'SET available_reps_snapshot = 1, available_score_snapshot = 0, '
'score_label_snapshot = NULL, score_unit_snapshot = NULL '
"WHERE id = 'program-exercise-invalid-snapshot'",
);
await database.customStatement('PRAGMA ignore_check_constraints = OFF');
final programs = await programRepository.listActive();
expect(programs, hasLength(1));
final restored = programs.single.exercises.single;
expect(restored.exerciseNameSnapshot, 'Concours de tirs');
expect(restored.sourceExerciseId, exercise.metadata.id);
expect(restored.repsEnabled, isTrue);
expect(restored.scoreEnabled, isFalse);
},
);
test(
'starter program and workout template snapshot exercise steps',
() async {