mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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:
parent
1e72b61826
commit
a4b189ee3b
@ -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();
|
||||
@ -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)',
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user