From a4b189ee3b1048324e342e055838c41c57b333c4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 22:12:04 +0200 Subject: [PATCH] Split progression_sync_service_test.dart (426) into three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../services/_progression_test_fixtures.dart | 101 ++++++ .../services/progression_sync_push_test.dart | 213 ++++++++++++ .../progression_sync_service_test.dart | 311 ++---------------- 3 files changed, 334 insertions(+), 291 deletions(-) create mode 100644 stronglift_replacement/workout_app/test/services/_progression_test_fixtures.dart create mode 100644 stronglift_replacement/workout_app/test/services/progression_sync_push_test.dart diff --git a/stronglift_replacement/workout_app/test/services/_progression_test_fixtures.dart b/stronglift_replacement/workout_app/test/services/_progression_test_fixtures.dart new file mode 100644 index 0000000..1c5c14b --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/_progression_test_fixtures.dart @@ -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 files = {}; + int writes = 0; + bool closed = false; + Object? throwOnGet; + + @override + Future getFileText(String path) async { + if (throwOnGet != null) throw throwOnGet!; + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + writes++; + files[path] = text; + } + + @override + Future> 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, +).fields['payload']!.$2; + +/// A store whose writes always fail, for the push-error paths. +class ExplodingStore extends FakeStore { + @override + Future 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 markSynced() => StorageService.instance.markProgressionSynced(); diff --git a/stronglift_replacement/workout_app/test/services/progression_sync_push_test.dart b/stronglift_replacement/workout_app/test/services/progression_sync_push_test.dart new file mode 100644 index 0000000..fd2da9e --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/progression_sync_push_test.dart @@ -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.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)', + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/progression_sync_service_test.dart b/stronglift_replacement/workout_app/test/services/progression_sync_service_test.dart index db990e4..87a047f 100644 --- a/stronglift_replacement/workout_app/test/services/progression_sync_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/progression_sync_service_test.dart @@ -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 files = {}; - int writes = 0; - bool closed = false; - Object? throwOnGet; - - @override - Future getFileText(String path) async { - if (throwOnGet != null) throw throwOnGet!; - return files[path]; - } - - @override - Future putFileText( - String path, - String text, { - required String message, - }) async { - writes++; - files[path] = text; - } - - @override - Future> 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, -).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 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 `/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 `/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.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 putFileText( - String path, - String text, { - required String message, - }) async => throw FirebaseSyncError('write refused'); }