Cover storage_service.dart to 100%

Migration (v1->v3), reps-increment at weight cap, and restore-from-backup
paths were untested. Add targeted unit tests plus a file-db test hook
(resetForTesting dbPath) so onUpgrade actually runs. coverage:ignore the
prod DB path and the defensive null-state branch (unreachable — seeding
guarantees a state row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
This commit is contained in:
Krzysztof Rudnicki 2026-07-13 13:49:38 +02:00
parent a5ea2ff206
commit b76d156ae3
2 changed files with 171 additions and 5 deletions

View File

@ -73,17 +73,20 @@ class StorageService {
/// Resets the singleton so [init] can be called again in tests.
///
/// Also switches to an in-memory database so each test starts with a clean
/// slate and file-based data from other tests does not leak in.
/// Defaults to an in-memory database so each test starts with a clean
/// slate and file-based data from other tests does not leak in. Pass a
/// file [dbPath] to exercise on-disk paths (e.g. schema migrations, which
/// need a persisted DB opened twice at different versions).
@visibleForTesting
static void resetForTesting() {
static void resetForTesting({String dbPath = ':memory:'}) {
_instance = null;
_testDbPath = ':memory:';
_testDbPath = dbPath;
}
Future<void> _open() async {
final dbPath =
_testDbPath ?? p.join(await getDatabasesPath(), 'workout_app.db');
_testDbPath ??
p.join(await getDatabasesPath(), 'workout_app.db'); // coverage:ignore-line
_db = await openDatabase(
dbPath,
version: 3,
@ -307,7 +310,11 @@ class StorageService {
for (final ex in template) {
final state = await getExerciseState(ex.name);
if (state == null) {
// Defensive: _seedDefaultsIfNeeded guarantees every template exercise
// has a state row, so this branch is unreachable in practice.
// coverage:ignore-start
result.add(ex);
// coverage:ignore-end
} else {
result.add(ex.copyWith(weight: state.weight, reps: state.reps));
}

View File

@ -1,6 +1,10 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/storage_service.dart';
StorageService get _svc => StorageService.instance;
@ -316,4 +320,159 @@ void main() {
final b = await StorageService.init();
expect(identical(a, b), isTrue);
});
// ── Schema migration (v1 → v3) ──────────────────────────────────────────────
test('migrates a v1 database up to v3 on open', () async {
// A v1 DB predates the threshold columns and the settings/active_session
// tables — build one on disk, then open it through StorageService (v3) so
// _migrateSchema runs both the <2 and <3 upgrade blocks.
final dir = await Directory.systemTemp.createTemp('mw_migrate');
final dbFile = p.join(dir.path, 'old.db');
final oldDb = await databaseFactory.openDatabase(
dbFile,
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, _) async {
await db.execute(
'CREATE TABLE exercise_state ('
'name TEXT PRIMARY KEY, weight REAL NOT NULL, reps INTEGER NOT NULL, '
'success_streak INTEGER NOT NULL DEFAULT 0, '
'fail_streak INTEGER NOT NULL DEFAULT 0, max_weight REAL NOT NULL)',
);
},
),
);
await oldDb.close();
StorageService.resetForTesting(dbPath: dbFile);
await StorageService.init();
// <3 block created the settings table (getNextWorkoutType reads it) …
expect(await _svc.getNextWorkoutType(), 'A');
// … and the <2 block added the threshold columns (defaulted to 3/2).
final st = await _svc.getExerciseState(workoutA.first.name);
expect(st, isNotNull);
expect(st!.successThreshold, 3);
expect(st.failThreshold, 2);
await dir.delete(recursive: true);
});
// ── Progression at the weight cap ───────────────────────────────────────────
test('applyProgression bumps reps (not weight) once at max weight', () async {
final name = workoutA.first.name;
final maxW = (await _svc.getExerciseState(name))!.maxWeight;
// Pin the working weight at the cap; streaks reset to 0.
await _svc.setExerciseWeight(name, maxW);
final startReps = (await _svc.getExerciseState(name))!.reps;
// Default success threshold is 3 — three straight successes trigger a
// progression, which at the cap increments reps instead of weight.
final today = DateTime.now();
for (var i = 0; i < 3; i++) {
await _svc.applyProgression(
succeededExercises: {name: true},
lastWorkoutDate: today,
);
}
final st = (await _svc.getExerciseState(name))!;
expect(st.weight, maxW); // weight stayed capped
expect(st.reps, startReps + 1); // reps incremented instead
});
// ── Restore from backup ─────────────────────────────────────────────────────
group('restoreFromBackupIfNeeded', () {
late Directory tmp;
setUp(() {
tmp = Directory.systemTemp.createTempSync('mw_restore');
BackupService.baseDirForTesting = tmp.path;
});
tearDown(() {
BackupService.baseDirForTesting = kBackupDir;
tmp.deleteSync(recursive: true);
});
test('returns early and does not restore when the DB has data', () async {
await _svc.saveSession(
date: '2026-07-10',
workoutType: 'A',
durationSeconds: 60,
succeeded: true,
json: '{}',
);
// A backup that would add a second history row if (wrongly) applied.
await BackupService.instance.export({
'workout_history': [
{
'date': '2000-01-01',
'workout_type': 'B',
'duration_seconds': 1,
'succeeded': 0,
'json': '{}',
},
],
});
await _svc.restoreFromBackupIfNeeded();
// Early-return path: existing history is untouched, backup ignored.
final hist = await _svc.getWorkoutHistory();
expect(hist.length, 1);
expect(hist.first['date'], '2026-07-10');
});
test('restores exercise_state, history and settings when empty', () async {
await BackupService.instance.export({
'exercise_state': [
{
'name': 'Dumbbell Lunge',
'weight': 42.5,
'reps': 9,
'success_streak': 0,
'fail_streak': 0,
'max_weight': 100.0,
'success_threshold': 3,
'fail_threshold': 2,
},
],
'workout_history': [
{
'date': '2026-07-01',
'workout_type': 'A',
'duration_seconds': 120,
'succeeded': 1,
'json': '{}',
},
],
'settings': [
{'key': 'last_workout_type', 'value': 'A'},
],
});
await _svc.restoreFromBackupIfNeeded();
// Settings restored: last was A → next is B.
expect(await _svc.getNextWorkoutType(), 'B');
// History restored.
final hist = await _svc.getWorkoutHistory();
expect(hist.length, 1);
expect(hist.first['date'], '2026-07-01');
// Exercise state overwritten from the backup.
final st = await _svc.getExerciseState('Dumbbell Lunge');
expect(st!.weight, 42.5);
});
test('does nothing when the DB is empty and no backup exists', () async {
// No export() call → readBackup returns null → early return after the
// has-data check.
await _svc.restoreFromBackupIfNeeded();
expect(await _svc.getWorkoutHistory(), isEmpty);
});
});
}