mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Marker lists distinct runs; guard fixes
Marker: twins (Pixel_6a_ infix) listed once, app-authored files never. Backup guard: a recorded run no longer lifts the workout-history clobber guard. runGuarded: a throw outside per-file handling logs and sets failed. Silent-failure hook clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
parent
a6c97dab8f
commit
6fa9666cdd
@ -123,7 +123,7 @@ void main(List<String> args) async {
|
||||
}
|
||||
// One-time RunnerUp history import (no-op in the sandbox and off-phone,
|
||||
// and once it completed). Off the launch path: it is many downloads.
|
||||
unawaited(RunnerUpImporter().runIfNeeded());
|
||||
unawaited(RunnerUpImporter().runGuarded());
|
||||
runApp(const WorkoutApp());
|
||||
}
|
||||
// coverage:ignore-end
|
||||
|
||||
@ -85,6 +85,24 @@ class RunnerUpImporter {
|
||||
return report;
|
||||
}
|
||||
|
||||
/// [runIfNeeded] that cannot leave the badge spinning: anything thrown
|
||||
/// outside the per-file handling (a platform error, a prefs write) becomes
|
||||
/// a logged failure and the `failed` state, retried next start.
|
||||
Future<RunnerUpImportReport> runGuarded() async {
|
||||
try {
|
||||
return await runIfNeeded();
|
||||
} on Object catch (error) {
|
||||
debugPrint('WorkoutApp: RunnerUp import WARNING: aborted: $error');
|
||||
runnerUpImportStatus.value = RunnerUpImportStatus.failed;
|
||||
return (
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
skipped: 0,
|
||||
failures: ['aborted: $error'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<RunnerUpImportReport> _importAll(SharedPreferences prefs) async {
|
||||
final tally = ImportTally();
|
||||
final login = await _loadLogin();
|
||||
@ -119,16 +137,26 @@ class RunnerUpImporter {
|
||||
try {
|
||||
remote = await listProcessedTcx(_client, login);
|
||||
} on Exception catch (error) {
|
||||
tally.fail('listing the PC history failed: $error');
|
||||
debugPrint('WorkoutApp: RunnerUp import WARNING: listing failed: $error');
|
||||
tally.failures.add('listing the PC history failed: $error');
|
||||
return const [];
|
||||
}
|
||||
final accounted = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final name in remote) {
|
||||
try {
|
||||
final bytes = await fetchProcessedTcx(_client, login, name);
|
||||
if (await _ingest(name, bytes, tally)) accounted.add(name);
|
||||
final source = await _ingest(name, bytes, tally);
|
||||
// The marker lists each distinct RunnerUp run once (the phone's
|
||||
// `Pixel_6a_` twin of a file is the same run) and never an
|
||||
// app-authored file.
|
||||
if (source == RunSource.runnerupImport &&
|
||||
seen.add(name.replaceFirst('Pixel_6a_', ''))) {
|
||||
accounted.add(name);
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
tally.fail('$name: $error');
|
||||
debugPrint('WorkoutApp: RunnerUp import WARNING: $name: $error');
|
||||
tally.failures.add('$name: $error');
|
||||
}
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
@ -161,8 +189,13 @@ class RunnerUpImporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores one export; false when it is not a usable run.
|
||||
Future<bool> _ingest(String name, Uint8List bytes, ImportTally tally) async {
|
||||
/// Stores one export; returns what its file says made it (before it is
|
||||
/// forced to history), or null when it is not a usable run.
|
||||
Future<RunSource?> _ingest(
|
||||
String name,
|
||||
Uint8List bytes,
|
||||
ImportTally tally,
|
||||
) async {
|
||||
final String xml;
|
||||
final RunTrack decoded;
|
||||
try {
|
||||
@ -174,7 +207,7 @@ class RunnerUpImporter {
|
||||
'WorkoutApp: RunnerUp import WARNING: $name is not a usable run '
|
||||
'($error); skipped for good.',
|
||||
);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
// Forced, whatever the file says: an app-made TCX that came back through
|
||||
// processed/ must not be re-uploaded, so it is history, never pending.
|
||||
@ -194,7 +227,7 @@ class RunnerUpImporter {
|
||||
uploadState: RunUploadState.never,
|
||||
);
|
||||
added ? tally.imported++ : tally.duplicates++;
|
||||
return true;
|
||||
return decoded.source;
|
||||
}
|
||||
|
||||
/// PUTs the marker; returns the failures (empty once the PC has it).
|
||||
|
||||
@ -24,12 +24,16 @@ extension StorageServiceBackup on StorageService {
|
||||
final eventRows = await _db.query('progression_events');
|
||||
final runRows = await _runsForTransfer(appOnly: true);
|
||||
|
||||
if (historyRows.isEmpty && runRows.isEmpty) {
|
||||
if (historyRows.isEmpty) {
|
||||
final existing = await BackupService.instance.readBackup();
|
||||
final existingHistory =
|
||||
(existing?['workout_history'] as List?) ?? const [];
|
||||
final existingRuns = (existing?['runs'] as List?) ?? const [];
|
||||
if (existingHistory.isNotEmpty || existingRuns.isNotEmpty) {
|
||||
// A recorded run must not lift the history guard (a seeded DB that
|
||||
// then records a run would erase months of sessions); only a DB with
|
||||
// neither is also barred from erasing a runs-only backup.
|
||||
if (existingHistory.isNotEmpty ||
|
||||
(runRows.isEmpty && existingRuns.isNotEmpty)) {
|
||||
debugPrint(
|
||||
'WorkoutApp: REFUSING to overwrite the backup — this DB has no '
|
||||
'workout history or runs but the backup holds '
|
||||
|
||||
@ -127,9 +127,10 @@ extension StorageServiceRunsTransfer on StorageService {
|
||||
},
|
||||
points,
|
||||
);
|
||||
} on Object {
|
||||
// Wrong shape, unknown enum key, or a points string that is not JSON:
|
||||
// the caller counts it and logs the total via _warnSkipped.
|
||||
} on Object catch (error) {
|
||||
// Wrong shape, unknown enum key, or a points string that is not JSON;
|
||||
// the caller also logs the total via _warnSkipped.
|
||||
debugPrint('WorkoutApp: malformed run entry skipped: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,9 +5,11 @@ import 'dart:typed_data';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/dufs_credentials.dart';
|
||||
import 'package:workout_app/services/runnerup_importer.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
const String kFixtureDir = '../../screen_locker/tests/fixtures/run_tcx';
|
||||
|
||||
@ -101,3 +103,16 @@ RunnerUpImporter makeImporter(
|
||||
);
|
||||
|
||||
Future<List<RunRecord>> storedRuns() => StorageService.instance.listRuns();
|
||||
|
||||
/// What the marker must list for [files]: every RunnerUp-authored run once
|
||||
/// (a `Pixel_6a_` twin is the same run), never an app-authored file.
|
||||
List<String> expectedMarkerNames(Map<String, Uint8List> files) {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final name in files.keys.toList()..sort())
|
||||
if (decodeTcx(utf8.decode(files[name]!), fileName: name).source ==
|
||||
RunSource.runnerupImport &&
|
||||
seen.add(name.replaceFirst('Pixel_6a_', '')))
|
||||
name,
|
||||
];
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ void main() {
|
||||
expect(report.failures, isEmpty);
|
||||
expect(report.skipped, 2);
|
||||
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
|
||||
expect(marker['count'], 3);
|
||||
expect(marker['count'], expectedMarkerNames(fixtureTcx()).length);
|
||||
expect(marker['tcx_names'], isNot(contains('junk_Running.tcx')));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
|
||||
});
|
||||
@ -150,6 +150,21 @@ void main() {
|
||||
expect(dufs.puts, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'a throw outside the per-file handling fails loudly, not forever',
|
||||
() async {
|
||||
final importer = RunnerUpImporter(
|
||||
client: dufs.client,
|
||||
loadLogin: fakeLogin,
|
||||
canReadStorage: () async => throw StateError('platform down'),
|
||||
isAndroid: true,
|
||||
);
|
||||
final report = await importer.runGuarded();
|
||||
expect(report.failures.single, contains('platform down'));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
|
||||
},
|
||||
);
|
||||
|
||||
test('the sandbox never imports', () async {
|
||||
Sandbox.enabled = true;
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
@ -60,8 +60,10 @@ void main() {
|
||||
expect(dufs.puts, ['PUT /RunnerUp/.workout_app_import.json']);
|
||||
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
|
||||
expect(marker['completed_at'], '2026-11-10T08:00:00.000Z');
|
||||
expect(marker['count'], 3);
|
||||
expect(marker['tcx_names'], dufs.files.keys.toList()..sort());
|
||||
final names = expectedMarkerNames(dufs.files);
|
||||
expect(names, isNotEmpty);
|
||||
expect(marker['count'], names.length);
|
||||
expect(marker['tcx_names'], names);
|
||||
},
|
||||
);
|
||||
|
||||
@ -84,6 +86,27 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'twins are one run: stored once, listed once, app files not at all',
|
||||
() async {
|
||||
final original = dufs.files.keys.firstWhere((n) => n.contains('08-12'));
|
||||
final twin = original.replaceFirst('RunnerUp_', 'RunnerUp_Pixel_6a_');
|
||||
// Same <Id>, different bytes: the phone's own copy of the export.
|
||||
dufs.files[twin] = utf8Bytes('${utf8.decode(dufs.files[original]!)}\n');
|
||||
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(report.failures, isEmpty);
|
||||
expect(report.duplicates, 1);
|
||||
expect(await storedRuns(), hasLength(3));
|
||||
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
|
||||
final names = (marker['tcx_names'] as List<dynamic>).cast<String>();
|
||||
expect(names.where((n) => n.contains('08-12')), [original]);
|
||||
expect(marker['count'], names.length);
|
||||
expect(names.any((n) => n.contains('17-30-00')), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('never PUTs a tcx anywhere; the only write is the dotfile', () async {
|
||||
await makeImporter(dufs).runIfNeeded();
|
||||
expect(dufs.puts, hasLength(1));
|
||||
|
||||
@ -64,7 +64,7 @@ void main() {
|
||||
expect(stored, hasLength(4));
|
||||
// The phone-only file is history but is not offered to the PC's list.
|
||||
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
|
||||
expect(marker['count'], 3);
|
||||
expect(marker['count'], expectedMarkerNames(dufs.files).length);
|
||||
});
|
||||
|
||||
test('an unreadable file warns but never blocks completion', () async {
|
||||
|
||||
@ -141,6 +141,39 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'a run on a history-less DB cannot clobber a backup with history',
|
||||
() async {
|
||||
await BackupService.instance.export({
|
||||
'exercise_state': <Object?>[],
|
||||
'workout_history': [
|
||||
{
|
||||
'date': '2026-07-17',
|
||||
'workout_type': 'B',
|
||||
'duration_seconds': 6465,
|
||||
'succeeded': 1,
|
||||
'json': '{}',
|
||||
},
|
||||
],
|
||||
'settings': <Object?>[],
|
||||
});
|
||||
final (name, xml, track) = _tracks().firstWhere(
|
||||
(t) => t.$3.source == RunSource.app,
|
||||
);
|
||||
|
||||
await _svc.insertRun(
|
||||
track,
|
||||
tcxFilename: name,
|
||||
tcxSha256: tcxSha256(xml),
|
||||
uploadState: RunUploadState.pending,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
final after = await BackupService.instance.readBackup();
|
||||
expect(after!['workout_history'], hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
group('user export / merge-import', () {
|
||||
test(
|
||||
'round trip keeps every run and point; a re-import adds nothing',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user