fix(exercice): sélection d'un score par défaut à 0 impossible (ticket #71)

Corrige la contrainte CHECK sur la table exercises (tables.dart) qui
empêchait à tort une valeur de score par défaut à 0, et ajoute la
migration Drift schemaVersion 12→13 reconstruisant la table avec la
contrainte corrigée (app_database.dart). build_runner OK, test de
régression ajouté.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 07:50:26 +02:00
parent 871ac95298
commit ef2c3ffffd
3 changed files with 131 additions and 2 deletions

View File

@ -46,7 +46,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 12;
int get schemaVersion => 13;
@override
MigrationStrategy get migration {
@ -97,6 +97,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 12) {
await _migrateToSchema12(migrator);
}
if (from < 13) {
await _migrateToSchema13();
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -359,4 +362,111 @@ extension on AppDatabase {
await migrator.createTable(shareInboxItems);
await migrator.createTable(pendingShareActions);
}
Future<void> _migrateToSchema13() async {
await customStatement('PRAGMA foreign_keys = OFF');
await customStatement('''
CREATE TABLE exercises_new (
id TEXT NOT NULL PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
schema_version INTEGER NOT NULL DEFAULT 1,
sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')),
local_revision INTEGER NOT NULL CHECK (local_revision >= 0),
origin_device_id TEXT NOT NULL,
future_owner_profile_id TEXT,
last_synced_at INTEGER,
remote_revision TEXT,
name TEXT NOT NULL,
description TEXT,
icon_media_id TEXT REFERENCES media_assets(id),
video_media_id TEXT REFERENCES media_assets(id),
has_time_measure INTEGER NOT NULL CHECK (has_time_measure IN (0, 1)),
has_reps_measure INTEGER NOT NULL CHECK (has_reps_measure IN (0, 1)),
has_score_measure INTEGER NOT NULL CHECK (has_score_measure IN (0, 1)),
score_input_mode TEXT NOT NULL DEFAULT 'manual',
score_label TEXT,
score_unit TEXT,
default_target_time_seconds INTEGER,
default_target_reps INTEGER,
default_target_score REAL,
default_target_score_time_ms INTEGER,
archived_at INTEGER,
CHECK (length(trim(origin_device_id)) > 0),
CHECK (length(trim(name)) > 0),
CHECK (has_time_measure OR has_reps_measure OR has_score_measure),
CHECK (score_input_mode IN ('manual', 'stopwatch')),
CHECK (has_score_measure OR score_input_mode = 'manual'),
CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR (score_label IS NOT NULL AND length(trim(score_label)) > 0 AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0)),
CHECK (default_target_time_seconds IS NULL OR default_target_time_seconds > 0),
CHECK (default_target_reps IS NULL OR default_target_reps > 0),
CHECK (default_target_score IS NULL OR default_target_score >= 0),
CHECK (default_target_score_time_ms IS NULL OR default_target_score_time_ms > 0),
CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0),
CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0)
)
''');
await customStatement('''
INSERT INTO exercises_new (
id,
created_at,
updated_at,
deleted_at,
schema_version,
sync_state,
local_revision,
origin_device_id,
future_owner_profile_id,
last_synced_at,
remote_revision,
name,
description,
icon_media_id,
video_media_id,
has_time_measure,
has_reps_measure,
has_score_measure,
score_input_mode,
score_label,
score_unit,
default_target_time_seconds,
default_target_reps,
default_target_score,
default_target_score_time_ms,
archived_at
)
SELECT
id,
created_at,
updated_at,
deleted_at,
schema_version,
sync_state,
local_revision,
origin_device_id,
future_owner_profile_id,
last_synced_at,
remote_revision,
name,
description,
icon_media_id,
video_media_id,
has_time_measure,
has_reps_measure,
has_score_measure,
score_input_mode,
score_label,
score_unit,
default_target_time_seconds,
default_target_reps,
default_target_score,
default_target_score_time_ms,
archived_at
FROM exercises
''');
await customStatement('DROP TABLE exercises');
await customStatement('ALTER TABLE exercises_new RENAME TO exercises');
await customStatement('PRAGMA foreign_keys = ON');
}
}

View File

@ -204,7 +204,7 @@ class Exercises extends SyncableTable {
'CHECK (default_target_time_seconds IS NULL OR '
'default_target_time_seconds > 0)',
'CHECK (default_target_reps IS NULL OR default_target_reps > 0)',
'CHECK (default_target_score IS NULL OR default_target_score > 0)',
'CHECK (default_target_score IS NULL OR default_target_score >= 0)',
'CHECK (default_target_score_time_ms IS NULL OR '
'default_target_score_time_ms > 0)',
];

View File

@ -79,6 +79,25 @@ void main() {
expect(changes.every((change) => change.entityType == 'Exercise'), isTrue);
});
test('exercise repository accepts zero manual default score', () async {
final exercise = Exercise(
metadata: _metadata('exercise-score-zero', DateTime.utc(2026, 7, 17, 12)),
name: 'Score nul',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Score',
scoreUnit: 'pts',
defaultTargetScore: 0,
);
await exerciseRepository.save(exercise);
final restored = await exerciseRepository.findById(exercise.metadata.id);
expect(restored, isNotNull);
expect(restored!.defaultTargetScore, 0);
});
test('exercise repository round-trips configured steps', () async {
final now = DateTime.utc(2026, 7, 17, 12);
final exercise = Exercise(