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.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,
|
||||
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(
|
||||
metadata: _metadataFromRow(row),
|
||||
programId: row.programId,
|
||||
sourceExerciseId: row.sourceExerciseId,
|
||||
position: row.position,
|
||||
exerciseNameSnapshot: row.exerciseNameSnapshot,
|
||||
position: row.position < 0 ? 0 : row.position,
|
||||
exerciseNameSnapshot: row.exerciseNameSnapshot.trim().isEmpty
|
||||
? row.id
|
||||
: 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),
|
||||
availableTimeSnapshot: availableTime,
|
||||
availableRepsSnapshot: availableReps,
|
||||
availableScoreSnapshot: availableScore,
|
||||
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,
|
||||
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>>
|
||||
_programExerciseHealthServicesStrategySnapshot(
|
||||
db.AppDatabase database,
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user