Split progression_sync_service_test.dart (426) into three

Same two-step shape as the workout-sync split, done in one commit because the
fixtures here are smaller and have a single consumer each.

  _progression_test_fixtures.dart   101  FakeStore, ExplodingStore, seedRemote,
                                         hlcAt, markSynced
  progression_sync_push_test.dart   213  pushProgression, active session, result
  progression_sync_service_test.dart 155  paths, pullProgression

`_ExplodingStore` was the file's one post-`main()` declaration — the thing
that kept it outside the splitter's envelope — and is now a fixture.
`markSynced` was a local function inside `main()`, so it moved too rather than
being copied into both halves where the two could drift.

402 tests still pass. The test-name set gains exactly two entries,
`(setUpAll)` and `(tearDownAll)` for the new file; those are framework
lifecycle records in the JSON reporter, not tests, and the +402 count is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 22:12:04 +02:00
parent 1e72b61826
commit a4b189ee3b
3 changed files with 334 additions and 291 deletions

View File

@ -0,0 +1,101 @@
// Shared fixtures for the progression-sync test files.
//
// A sibling library rather than a `part`, so each split test file imports what
// it needs: the in-memory Firebase stand-in, the remote-seeding helper, and
// the HLC reader every group asserts against.
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
/// In-memory stand-in for [FirebaseRestClient].
///
/// Records every write so a test can assert not just the final state but that
/// no write happened at all -- the difference between "the guard held" and
/// "the guard wrote defaults and then wrote them back".
class FakeStore implements FirebaseRestClient {
final Map<String, String> files = {};
int writes = 0;
bool closed = false;
Object? throwOnGet;
@override
Future<String?> getFileText(String path) async {
if (throwOnGet != null) throw throwOnGet!;
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes++;
files[path] = text;
}
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((k) => k.startsWith('$path/'))
.map((k) => k.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
void close() => closed = true;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void seedRemote(
FakeStore store,
String name, {
double weight = 20,
int reps = 12,
double maxWeight = 27.5,
}) {
final record = Record(
id: 'exercise_state:$name',
fields: {
'payload': (
{
'name': name,
'weight': weight,
'reps': reps,
'success_streak': 0,
'fail_streak': 0,
'max_weight': maxWeight,
'success_threshold': 3,
'fail_threshold': 2,
},
Hlc.newTick('remote-device'),
),
},
);
store.files[ProgressionSyncService.pathForExercise(name)] = jsonEncode(
record.toJson(),
);
}
Hlc hlcAt(FakeStore store, String name) => Record.fromJson(
jsonDecode(store.files[ProgressionSyncService.pathForExercise(name)]!)
as Map<String, dynamic>,
).fields['payload']!.$2;
/// A store whose writes always fail, for the push-error paths.
class ExplodingStore extends FakeStore {
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async => throw FirebaseSyncError('write refused');
}
/// Marks this install as having reconciled with Firebase, which is what
/// unblocks pushing.
Future<void> markSynced() => StorageService.instance.markProgressionSynced();

View File

@ -0,0 +1,213 @@
// pushProgression, the active-session publish, and the result type.
//
// Split out of progression_sync_service_test.dart, which keeps paths + pull.
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import '_progression_test_fixtures.dart';
void main() {
late FakeStore store;
late ProgressionSyncService sync;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
store = FakeStore();
sync = ProgressionSyncService(firebaseFactory: () async => store);
StorageService.resetForTesting();
await StorageService.init();
});
group('pushProgression', () {
test('writes every exercise from a real install', () async {
await markSynced();
final result = await sync.pushProgression();
expect(result.changed, isTrue);
expect(result.count, 7);
expect(store.files.keys, contains(endsWith('/Situp')));
});
test(
'a fresh install writes NOTHING when Firebase already holds progression',
() async {
// The clobber guard. Evaluated once, before any write: a per-exercise
// check still leaks factory defaults for every exercise Firebase
// happens not to hold yet -- exactly the ones with no copy to lose.
seedRemote(store, 'Dumbbell Bench Press', weight: 20);
final before = Map<String, String>.from(store.files);
final result = await sync.pushProgression();
expect(result.changed, isFalse);
expect(result.reason, contains('never pulled from Firebase'));
expect(store.writes, 0, reason: 'not one write, not even a partial');
expect(store.files, before);
expect(
store.files.containsKey(
ProgressionSyncService.pathForExercise('Dumbbell Lunge'),
),
isFalse,
reason: 'an un-backed exercise must not gain a default record',
);
},
);
test(
'finishing a workout on a never-pulled install cannot clobber remote',
() async {
// The exact production sequence that made the old guard useless:
// _finishWorkout writes a workout_history row AND last_workout_type
// BEFORE it pushes, so looksFreshlyInstalled() was already false by
// the time the guard ran and factory defaults went up regardless.
// Reachable without a fresh install too: connect Firebase late (the
// uninstall wipes the keystore, so startup's pull found no account).
seedRemote(store, 'Dumbbell Bench Press', weight: 60);
await StorageService.instance.saveSession(
date: '2026-08-10',
workoutType: 'A',
durationSeconds: 60,
succeeded: true,
json: '{}',
);
await StorageService.instance.setLastWorkoutType('A');
expect(
await StorageService.instance.looksFreshlyInstalled(),
isFalse,
reason: 'this is why the freshness test could not protect anything',
);
final result = await sync.pushProgression();
expect(result.changed, isFalse);
expect(store.writes, 0);
expect(
store.files[ProgressionSyncService.pathForExercise(
'Dumbbell Bench Press',
)],
contains('60'),
reason: 'the real remote progression is untouched',
);
},
);
test('a fresh install with an empty backend still seeds it', () async {
final result = await sync.pushProgression();
expect(result.changed, isTrue, reason: 'nothing to clobber');
});
test('a deliberate reset still reaches Firebase', () async {
// resetExerciseToDefaults leaves reps and max_weight alone, so resetting
// an exercise already at its default reps produces a row identical to a
// seeded one. A value-based guard would refuse to push it and then
// revert it on the next launch.
seedRemote(store, 'Dumbbell Bench Press', weight: 20);
await markSynced();
await StorageService.instance.resetExerciseToDefaults(
'Dumbbell Bench Press',
);
final result = await sync.pushProgression();
expect(result.changed, isTrue);
expect(
store.files[ProgressionSyncService.pathForExercise(
'Dumbbell Bench Press',
)],
contains('22.5'),
);
});
test('stamps chain causally per record', () async {
await markSynced();
await sync.pushProgression();
final first = hlcAt(store, 'Situp');
await sync.pushProgression();
final second = hlcAt(store, 'Situp');
expect(first < second, isTrue);
});
test('says why when no Firebase account is configured', () async {
final offline = ProgressionSyncService(firebaseFactory: () async => null);
final result = await offline.pushProgression();
expect(result.changed, isFalse);
expect(result.reason, contains('no Firebase account'));
});
test('reports a backend failure instead of swallowing it', () async {
await markSynced();
store.throwOnGet = FirebaseSyncError('rtdb exploded');
final result = await sync.pushProgression();
expect(result.changed, isFalse);
expect(result.reason, contains('push failed'));
});
});
group('active session', () {
test('round-trips through Firebase', () async {
expect(await sync.readActiveSession(), isNull);
await sync.pushActiveSession({'workoutType': 'B', 'startTimeMs': 123});
final read = await sync.readActiveSession();
expect(read!['workoutType'], 'B');
});
test('clearing it cannot resurrect a finished workout', () async {
await sync.pushActiveSession({'workoutType': 'A'});
final result = await sync.pushActiveSession(null);
expect(result.changed, isTrue);
expect(result.reason, contains('cleared'));
expect(await sync.readActiveSession(), isNull);
});
test('says why when no Firebase account is configured', () async {
final offline = ProgressionSyncService(firebaseFactory: () async => null);
final result = await offline.pushActiveSession({'a': 1});
expect(result.changed, isFalse);
expect(result.reason, contains('no Firebase account'));
expect(await offline.readActiveSession(), isNull);
});
test('reports a push failure instead of swallowing it', () async {
store.throwOnGet = FirebaseSyncError('rtdb exploded');
final result = await sync.pushActiveSession({'a': 1});
// The write path does not read first, so this one succeeds; the read
// path is what surfaces the error.
expect(result.changed, isTrue);
expect(await sync.readActiveSession(), isNull);
});
test('a failed write is reported, not swallowed', () async {
final result = await ProgressionSyncService(
firebaseFactory: () async => ExplodingStore(),
).pushActiveSession({'a': 1});
expect(result.changed, isFalse);
expect(result.reason, contains('active session push failed'));
});
});
test('ProgressionSyncResult prints its fields for debugging', () {
const result = ProgressionSyncResult(
changed: true,
count: 3,
reason: 'pushed',
);
expect(
result.toString(),
'ProgressionSyncResult(changed: true, count: 3, reason: pushed)',
);
});
}

View File

@ -6,85 +6,10 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
/// In-memory stand-in for [FirebaseRestClient].
///
/// Records every write so a test can assert not just the final state but that
/// no write happened at all -- the difference between "the guard held" and
/// "the guard wrote defaults and then wrote them back".
class _FakeStore implements FirebaseRestClient {
final Map<String, String> files = {};
int writes = 0;
bool closed = false;
Object? throwOnGet;
@override
Future<String?> getFileText(String path) async {
if (throwOnGet != null) throw throwOnGet!;
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes++;
files[path] = text;
}
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((k) => k.startsWith('$path/'))
.map((k) => k.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
void close() => closed = true;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Writes a progression record for [name] straight into [store].
void _seedRemote(
_FakeStore store,
String name, {
double weight = 20,
int reps = 12,
double maxWeight = 27.5,
}) {
final record = Record(
id: 'exercise_state:$name',
fields: {
'payload': (
{
'name': name,
'weight': weight,
'reps': reps,
'success_streak': 0,
'fail_streak': 0,
'max_weight': maxWeight,
'success_threshold': 3,
'fail_threshold': 2,
},
Hlc.newTick('remote-device'),
),
},
);
store.files[ProgressionSyncService.pathForExercise(name)] = jsonEncode(
record.toJson(),
);
}
Hlc _hlcAt(_FakeStore store, String name) => Record.fromJson(
jsonDecode(store.files[ProgressionSyncService.pathForExercise(name)]!)
as Map<String, dynamic>,
).fields['payload']!.$2;
import '_progression_test_fixtures.dart';
void main() {
late _FakeStore store;
late FakeStore store;
late ProgressionSyncService sync;
setUpAll(() {
@ -93,28 +18,26 @@ void main() {
});
setUp(() async {
store = _FakeStore();
store = FakeStore();
sync = ProgressionSyncService(firebaseFactory: () async => store);
StorageService.resetForTesting();
await StorageService.init();
});
/// Marks this install as having reconciled with Firebase, which is what
/// unblocks pushing.
Future<void> markSynced() =>
StorageService.instance.markProgressionSynced();
group('paths', () {
test('progression sits outside devices/, so workout readers cannot see it', () {
// The whole safety argument: the PC lists `screen-locker-sync/devices`
// and reads `<id>/log.json` under it. A progression record that never
// appears under that prefix cannot be miscounted as a workout, which
// would silently grant unlock credit.
final path = ProgressionSyncService.pathForExercise('Situp');
expect(path, startsWith('screen-locker-sync/exercise_state/'));
expect(path, isNot(contains('/devices/')));
expect(kActiveSessionPath, isNot(contains('/devices/')));
});
test(
'progression sits outside devices/, so workout readers cannot see it',
() {
// The whole safety argument: the PC lists `screen-locker-sync/devices`
// and reads `<id>/log.json` under it. A progression record that never
// appears under that prefix cannot be miscounted as a workout, which
// would silently grant unlock credit.
final path = ProgressionSyncService.pathForExercise('Situp');
expect(path, startsWith('screen-locker-sync/exercise_state/'));
expect(path, isNot(contains('/devices/')));
expect(kActiveSessionPath, isNot(contains('/devices/')));
},
);
test('the exercise name is used raw, so the client can escape it once', () {
// Double-encoding here would write to a different key than
@ -128,8 +51,8 @@ void main() {
group('pullProgression', () {
test('restores every remote record into a fresh install', () async {
_seedRemote(store, 'Dumbbell Bench Press', weight: 20);
_seedRemote(store, 'Situp', weight: 10, reps: 31, maxWeight: 10);
seedRemote(store, 'Dumbbell Bench Press', weight: 20);
seedRemote(store, 'Situp', weight: 10, reps: 31, maxWeight: 10);
final result = await sync.pullProgression();
@ -144,7 +67,7 @@ void main() {
});
test('declines once this install has reconciled with Firebase', () async {
_seedRemote(store, 'Dumbbell Bench Press', weight: 20);
seedRemote(store, 'Dumbbell Bench Press', weight: 20);
await markSynced();
await StorageService.instance.setExerciseWeight(
'Dumbbell Bench Press',
@ -166,7 +89,7 @@ void main() {
// setExerciseWeight writes neither history nor last_workout_type, so the
// DB still "looks fresh" afterwards and the next launch pulled the old
// remote value back over the user's own edit.
_seedRemote(store, 'Dumbbell Bench Press', weight: 60);
seedRemote(store, 'Dumbbell Bench Press', weight: 60);
await sync.pullProgression(); // first launch reconciles
await StorageService.instance.setExerciseWeight(
'Dumbbell Bench Press',
@ -229,198 +152,4 @@ void main() {
expect(result.changed, isFalse);
});
});
group('pushProgression', () {
test('writes every exercise from a real install', () async {
await markSynced();
final result = await sync.pushProgression();
expect(result.changed, isTrue);
expect(result.count, 7);
expect(store.files.keys, contains(endsWith('/Situp')));
});
test(
'a fresh install writes NOTHING when Firebase already holds progression',
() async {
// The clobber guard. Evaluated once, before any write: a per-exercise
// check still leaks factory defaults for every exercise Firebase
// happens not to hold yet -- exactly the ones with no copy to lose.
_seedRemote(store, 'Dumbbell Bench Press', weight: 20);
final before = Map<String, String>.from(store.files);
final result = await sync.pushProgression();
expect(result.changed, isFalse);
expect(result.reason, contains('never pulled from Firebase'));
expect(store.writes, 0, reason: 'not one write, not even a partial');
expect(store.files, before);
expect(
store.files.containsKey(
ProgressionSyncService.pathForExercise('Dumbbell Lunge'),
),
isFalse,
reason: 'an un-backed exercise must not gain a default record',
);
},
);
test(
'finishing a workout on a never-pulled install cannot clobber remote',
() async {
// The exact production sequence that made the old guard useless:
// _finishWorkout writes a workout_history row AND last_workout_type
// BEFORE it pushes, so looksFreshlyInstalled() was already false by
// the time the guard ran and factory defaults went up regardless.
// Reachable without a fresh install too: connect Firebase late (the
// uninstall wipes the keystore, so startup's pull found no account).
_seedRemote(store, 'Dumbbell Bench Press', weight: 60);
await StorageService.instance.saveSession(
date: '2026-08-10',
workoutType: 'A',
durationSeconds: 60,
succeeded: true,
json: '{}',
);
await StorageService.instance.setLastWorkoutType('A');
expect(
await StorageService.instance.looksFreshlyInstalled(),
isFalse,
reason: 'this is why the freshness test could not protect anything',
);
final result = await sync.pushProgression();
expect(result.changed, isFalse);
expect(store.writes, 0);
expect(
store.files[ProgressionSyncService.pathForExercise(
'Dumbbell Bench Press',
)],
contains('60'),
reason: 'the real remote progression is untouched',
);
},
);
test('a fresh install with an empty backend still seeds it', () async {
final result = await sync.pushProgression();
expect(result.changed, isTrue, reason: 'nothing to clobber');
});
test('a deliberate reset still reaches Firebase', () async {
// resetExerciseToDefaults leaves reps and max_weight alone, so resetting
// an exercise already at its default reps produces a row identical to a
// seeded one. A value-based guard would refuse to push it and then
// revert it on the next launch.
_seedRemote(store, 'Dumbbell Bench Press', weight: 20);
await markSynced();
await StorageService.instance.resetExerciseToDefaults(
'Dumbbell Bench Press',
);
final result = await sync.pushProgression();
expect(result.changed, isTrue);
expect(
store.files[ProgressionSyncService.pathForExercise(
'Dumbbell Bench Press',
)],
contains('22.5'),
);
});
test('stamps chain causally per record', () async {
await markSynced();
await sync.pushProgression();
final first = _hlcAt(store, 'Situp');
await sync.pushProgression();
final second = _hlcAt(store, 'Situp');
expect(first < second, isTrue);
});
test('says why when no Firebase account is configured', () async {
final offline = ProgressionSyncService(firebaseFactory: () async => null);
final result = await offline.pushProgression();
expect(result.changed, isFalse);
expect(result.reason, contains('no Firebase account'));
});
test('reports a backend failure instead of swallowing it', () async {
await markSynced();
store.throwOnGet = FirebaseSyncError('rtdb exploded');
final result = await sync.pushProgression();
expect(result.changed, isFalse);
expect(result.reason, contains('push failed'));
});
});
group('active session', () {
test('round-trips through Firebase', () async {
expect(await sync.readActiveSession(), isNull);
await sync.pushActiveSession({'workoutType': 'B', 'startTimeMs': 123});
final read = await sync.readActiveSession();
expect(read!['workoutType'], 'B');
});
test('clearing it cannot resurrect a finished workout', () async {
await sync.pushActiveSession({'workoutType': 'A'});
final result = await sync.pushActiveSession(null);
expect(result.changed, isTrue);
expect(result.reason, contains('cleared'));
expect(await sync.readActiveSession(), isNull);
});
test('says why when no Firebase account is configured', () async {
final offline = ProgressionSyncService(firebaseFactory: () async => null);
final result = await offline.pushActiveSession({'a': 1});
expect(result.changed, isFalse);
expect(result.reason, contains('no Firebase account'));
expect(await offline.readActiveSession(), isNull);
});
test('reports a push failure instead of swallowing it', () async {
store.throwOnGet = FirebaseSyncError('rtdb exploded');
final result = await sync.pushActiveSession({'a': 1});
// The write path does not read first, so this one succeeds; the read
// path is what surfaces the error.
expect(result.changed, isTrue);
expect(await sync.readActiveSession(), isNull);
});
test('a failed write is reported, not swallowed', () async {
final result = await ProgressionSyncService(
firebaseFactory: () async => _ExplodingStore(),
).pushActiveSession({'a': 1});
expect(result.changed, isFalse);
expect(result.reason, contains('active session push failed'));
});
});
test('ProgressionSyncResult prints its fields for debugging', () {
const result = ProgressionSyncResult(
changed: true,
count: 3,
reason: 'pushed',
);
expect(
result.toString(),
'ProgressionSyncResult(changed: true, count: 3, reason: pushed)',
);
});
}
/// A store whose writes always fail, for the push-error paths.
class _ExplodingStore extends _FakeStore {
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async => throw FirebaseSyncError('write refused');
}