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:
@ -4181,45 +4181,222 @@ Future<domain.ProgramExercise> _programExerciseFromRow(
|
|||||||
db.AppDatabase database,
|
db.AppDatabase database,
|
||||||
db.ProgramExercise row,
|
db.ProgramExercise row,
|
||||||
) async {
|
) 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,
|
||||||
|
sourceExerciseId: row.sourceExerciseId,
|
||||||
|
position: row.position,
|
||||||
|
exerciseNameSnapshot: 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: row.availableTimeSnapshot,
|
||||||
|
availableRepsSnapshot: row.availableRepsSnapshot,
|
||||||
|
availableScoreSnapshot: row.availableScoreSnapshot,
|
||||||
|
scoreInputModeSnapshot: scoreInputMode,
|
||||||
|
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
||||||
|
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
||||||
|
setsCount: row.setsCount,
|
||||||
|
timeEnabled: row.timeEnabled,
|
||||||
|
repsEnabled: row.repsEnabled,
|
||||||
|
scoreEnabled: row.scoreEnabled,
|
||||||
|
targetTimeSeconds: row.targetTimeSeconds,
|
||||||
|
targetReps: row.targetReps,
|
||||||
|
targetScore: row.targetScore,
|
||||||
|
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(
|
return domain.ProgramExercise(
|
||||||
metadata: _metadataFromRow(row),
|
metadata: _metadataFromRow(row),
|
||||||
programId: row.programId,
|
programId: row.programId,
|
||||||
sourceExerciseId: row.sourceExerciseId,
|
sourceExerciseId: row.sourceExerciseId,
|
||||||
position: row.position,
|
position: row.position < 0 ? 0 : row.position,
|
||||||
exerciseNameSnapshot: row.exerciseNameSnapshot,
|
exerciseNameSnapshot: row.exerciseNameSnapshot.trim().isEmpty
|
||||||
|
? row.id
|
||||||
|
: row.exerciseNameSnapshot,
|
||||||
exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot,
|
exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot,
|
||||||
exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot,
|
exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot,
|
||||||
exerciseImageMediaIdsSnapshot: _decodeImageMediaIdsSnapshot(
|
exerciseImageMediaIdsSnapshot: imageMediaIds,
|
||||||
row.exerciseImageMediaIdsSnapshotJson,
|
exerciseStepsSnapshot: steps,
|
||||||
row.exerciseImageMediaIdSnapshot,
|
|
||||||
),
|
|
||||||
exerciseStepsSnapshot: _decodeExerciseStepsSnapshot(
|
|
||||||
row.exerciseStepsSnapshotJson,
|
|
||||||
),
|
|
||||||
autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot,
|
autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot,
|
||||||
autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride,
|
autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride,
|
||||||
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
|
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
|
||||||
healthServicesExerciseTypeStrategySnapshot:
|
healthServicesExerciseTypeStrategySnapshot: healthServicesStrategy,
|
||||||
await _programExerciseHealthServicesStrategySnapshot(database, row.id),
|
|
||||||
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
|
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
|
||||||
availableTimeSnapshot: row.availableTimeSnapshot,
|
availableTimeSnapshot: availableTime,
|
||||||
availableRepsSnapshot: row.availableRepsSnapshot,
|
availableRepsSnapshot: availableReps,
|
||||||
availableScoreSnapshot: row.availableScoreSnapshot,
|
availableScoreSnapshot: availableScore,
|
||||||
scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot),
|
scoreInputModeSnapshot: scoreInputMode,
|
||||||
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
||||||
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
||||||
setsCount: row.setsCount,
|
setsCount: row.setsCount <= 0 ? 1 : row.setsCount,
|
||||||
timeEnabled: row.timeEnabled,
|
timeEnabled: timeEnabled,
|
||||||
repsEnabled: row.repsEnabled,
|
repsEnabled: repsEnabled,
|
||||||
scoreEnabled: row.scoreEnabled,
|
scoreEnabled: scoreEnabled,
|
||||||
targetTimeSeconds: row.targetTimeSeconds,
|
targetTimeSeconds: timeEnabled
|
||||||
targetReps: row.targetReps,
|
? _positiveIntOrNull(row.targetTimeSeconds)
|
||||||
targetScore: row.targetScore,
|
: null,
|
||||||
targetScoreTimeMs: row.targetScoreTimeMs,
|
targetReps: repsEnabled ? _positiveIntOrNull(row.targetReps) : null,
|
||||||
restSecondsOverride: row.restSecondsOverride,
|
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>>
|
Future<List<domain.HealthServicesExerciseType>>
|
||||||
_programExerciseHealthServicesStrategySnapshot(
|
_programExerciseHealthServicesStrategySnapshot(
|
||||||
db.AppDatabase database,
|
db.AppDatabase database,
|
||||||
|
|||||||
@ -1750,6 +1750,64 @@ CREATE TABLE pending_share_actions (
|
|||||||
expect(programs.single.exercises.single.targetReps, 8);
|
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(
|
test(
|
||||||
'starter program and workout template snapshot exercise steps',
|
'starter program and workout template snapshot exercise steps',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user