diff --git a/lib/data/db.dart b/lib/data/db.dart index 71f7b17cd..3e6ce0505 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2886,6 +2886,28 @@ class LocalDb { ); } + /// Replace an imported workout with the band session scored over its window + /// (#325). Its route, if the store had one, moves to [sessionId] instead of + /// being dropped; the row itself goes, so the workout is listed once. + static Future supersedeImportedWorkout( + String uuid, + String sessionId, + ) async { + final db = await instance; + await db.transaction((txn) async { + // A session that already has its own route keeps it whole: moving only + // the non-colliding points would splice two routes into one. + await txn.rawUpdate( + 'UPDATE workout_route SET session_id = ? WHERE session_id = ? ' + 'AND NOT EXISTS (SELECT 1 FROM workout_route WHERE session_id = ?)', + [sessionId, uuid, sessionId]); + await txn.delete('workout_route', + where: 'session_id = ?', whereArgs: [uuid]); + await txn.delete('imported_workout', + where: 'uuid = ?', whereArgs: [uuid]); + }); + } + /// Drop every imported workout whose source is one of [sources], AND its /// route, like [deleteImportedWorkout]. static Future deleteImportedWorkoutsFrom(Set sources) async { diff --git a/lib/health/health_workout_import.dart b/lib/health/health_workout_import.dart index e998cc58d..e49c8641b 100644 --- a/lib/health/health_workout_import.dart +++ b/lib/health/health_workout_import.dart @@ -19,6 +19,10 @@ // numbers, next to its name, and are never summed into ours: two devices' // calorie models added together is one number neither of them would agree with. // +// The one way across (#325): tapping an import re-logs its window as a band +// session scored from our own 1 Hz heart rate, and that session REPLACES the +// import (`LocalDb.supersedeImportedWorkout`), so nothing is counted twice. +// // The refusal is structural, not a filter someone has to remember: these rows // live in their own table (see db.dart `_createImportedWorkout`), so nothing // that reads `sessions` can reach them by accident. The screen opts a row IN, @@ -239,7 +243,14 @@ Future> deletedUuids() async { return {...(prefs.getStringList(_kTombstonesPref) ?? const [])}; } -Future rememberDeletedUuid(String uuid) async { +// Serialized: each write replaces the whole list, so two overlapping calls +// would each drop the other's uuid. +Future _tombstoneWrites = Future.value(); + +Future rememberDeletedUuid(String uuid) => _tombstoneWrites = + _tombstoneWrites.catchError((_) {}).then((_) => _remember(uuid)); + +Future _remember(String uuid) async { final prefs = await SharedPreferences.getInstance(); final set = await deletedUuids(); if (set.contains(uuid)) return; @@ -362,6 +373,13 @@ class HealthWorkoutImporter { await LocalDb.putImportedWorkouts([for (final r in alive) r.toRow()]); final withRoutes = await _importRoutes(start, end, skip: {...tombstones, ...ownUuids}, prompt: prompt); + // Re-read: a workout scored or deleted while this pass was in flight, or + // one an interrupted pass tombstoned without removing, must not stay + // listed (or keep a route) beside the session that replaced it. + final dead = await deletedUuids(); + for (final r in rows) { + if (dead.contains(r.uuid)) await LocalDb.deleteImportedWorkout(r.uuid); + } return WorkoutImportResult( workouts: alive.length, withRoutes: withRoutes, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 16c4ccabe..c368c8895 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -692,6 +692,8 @@ "logWorkoutWindowInvalidTitle": "Dieser Zeitraum lässt sich nicht speichern", "logWorkoutTimesUpdatedTitle": "Zeiten aktualisiert", "logWorkoutLoggedTitle": "Training gespeichert", + "logWorkoutScoreWithBand": "Mit Puls vom Band bewerten", + "logWorkoutImportedHrGone": "Die Herzfrequenz für dieses Training ist nicht mehr gespeichert, daher kann das Band es nicht bewerten.", "logWorkoutUnscoredSaved": "Gespeichert. In diesem Zeitraum wurde keine Herzfrequenz aufgezeichnet, daher gibt es weder Strain noch Kalorien – nur die Zeiten sind hinterlegt.", "logWorkoutCouldNotSave": "Konnte nicht gespeichert werden – versuch es noch einmal.", "logWorkoutScoredTitle": "Ausgewertet anhand der Aufzeichnung des Bands", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30be7e8cb..5ef488c88 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3362,6 +3362,14 @@ "@logWorkoutLoggedTitle": { "description": "Status card title after successfully logging a new workout" }, + "logWorkoutScoreWithBand": "Score with band heart rate", + "@logWorkoutScoreWithBand": { + "description": "Title of the log form when scoring an imported workout from the band's own heart rate." + }, + "logWorkoutImportedHrGone": "Heart rate for this workout is no longer stored, so the band cannot score it.", + "@logWorkoutImportedHrGone": { + "description": "Shown when an imported workout's window has no stored band heart rate left to score." + }, "logWorkoutUnscoredSaved": "Saved. No heart rate was recorded over that window, so it has no strain and no calorie figure — the times are all this one carries.", "@logWorkoutUnscoredSaved": { "description": "Status card body when a saved workout has no heart-rate data so it could not be scored" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5148594b9..2b016ac91 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -655,6 +655,8 @@ "logWorkoutWindowInvalidTitle": "Esa ventana no se puede guardar", "logWorkoutTimesUpdatedTitle": "Horarios actualizados", "logWorkoutLoggedTitle": "Entrenamiento registrado", + "logWorkoutScoreWithBand": "Puntuar con el pulso de la pulsera", + "logWorkoutImportedHrGone": "El pulso de este entrenamiento ya no está guardado, así que la pulsera no puede puntuarlo.", "logWorkoutUnscoredSaved": "Guardado. No se registró frecuencia cardíaca en esa ventana, así que no tiene esfuerzo ni calorías: los horarios son todo lo que conserva.", "logWorkoutCouldNotSave": "No se pudo guardar. Inténtalo de nuevo.", "logWorkoutScoredTitle": "Calculado a partir de lo que registró la banda", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4e83afb21..e62e928da 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -655,6 +655,8 @@ "logWorkoutWindowInvalidTitle": "Cette plage ne pourra pas être enregistrée", "logWorkoutTimesUpdatedTitle": "Horaires mis à jour", "logWorkoutLoggedTitle": "Séance enregistrée", + "logWorkoutScoreWithBand": "Évaluer avec le pouls du bracelet", + "logWorkoutImportedHrGone": "Le pouls de cette séance n’est plus enregistré, le bracelet ne peut donc pas l’évaluer.", "logWorkoutUnscoredSaved": "Enregistré. Aucune fréquence cardiaque n’a été relevée sur cette plage, donc elle n’a ni effort ni calories — seuls les horaires sont conservés.", "logWorkoutCouldNotSave": "Impossible d’enregistrer — réessayez.", "logWorkoutScoredTitle": "Calculé à partir de ce que le bracelet a enregistré", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 1d1a08913..078c74019 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -655,6 +655,8 @@ "logWorkoutWindowInvalidTitle": "यह समय-सीमा सेव नहीं होगी", "logWorkoutTimesUpdatedTitle": "समय अपडेट किया गया", "logWorkoutLoggedTitle": "वर्कआउट लॉग किया गया", + "logWorkoutScoreWithBand": "बैंड की हृदय गति से स्कोर करें", + "logWorkoutImportedHrGone": "इस वर्कआउट की हृदय गति अब संग्रहीत नहीं है, इसलिए बैंड इसे स्कोर नहीं कर सकता।", "logWorkoutUnscoredSaved": "सेव कर दिया गया। उस समय-सीमा में कोई हृदय गति दर्ज नहीं हुई, इसलिए इसमें न कोई एक्सर्शन स्कोर है न कैलोरी — केवल समय ही दर्ज है।", "logWorkoutCouldNotSave": "सेव नहीं हो सका — फिर से कोशिश करें।", "logWorkoutScoredTitle": "बैंड द्वारा दर्ज डेटा से स्कोर किया गया", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 52e0c6e85..f96a0867a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1505,6 +1505,8 @@ "logWorkoutWindowInvalidTitle": "Не удаётся сохранить этот период", "logWorkoutTimesUpdatedTitle": "Время обновлено", "logWorkoutLoggedTitle": "Тренировка записана", + "logWorkoutScoreWithBand": "Оценить по пульсу с браслета", + "logWorkoutImportedHrGone": "Пульс за эту тренировку больше не хранится, поэтому браслет не может её оценить.", "logWorkoutUnscoredSaved": "Сохранено. В этом отрезке пульс не записывался, поэтому нагрузка и калории не рассчитаны — сохранено только время.", "logWorkoutCouldNotSave": "Не удалось сохранить. Повторите попытку.", "logWorkoutScoredTitle": "Рассчитано по записи браслета", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7cf590d8c..2031d4a3b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -655,6 +655,8 @@ "logWorkoutWindowInvalidTitle": "该时间段无法保存", "logWorkoutTimesUpdatedTitle": "时间已更新", "logWorkoutLoggedTitle": "训练已记录", + "logWorkoutScoreWithBand": "用手环心率评分", + "logWorkoutImportedHrGone": "这次训练的心率已不再保存,手环无法为其评分。", "logWorkoutUnscoredSaved": "已保存。该时间段内没有记录到心率,因此没有用力值和卡路里数据——只保留了时间信息。", "logWorkoutCouldNotSave": "保存失败,请重试。", "logWorkoutScoredTitle": "根据手环记录的数据计算", diff --git a/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart index 606af56b9..6f700b93b 100644 --- a/lib/ui2/screens/log_workout.dart +++ b/lib/ui2/screens/log_workout.dart @@ -38,7 +38,9 @@ import '../../models/activity_suggestion.dart'; import 'detected_activities.dart'; import '../../data/db.dart'; import '../../data/journal_fields.dart' show formatMinuteOfDay; +import '../../data/local_repository.dart'; import '../../health/health_export.dart'; +import '../../health/health_workout_import.dart' show rememberDeletedUuid; import '../../l10n/app_localizations.dart'; import '../../l10n/date_text.dart'; import '../../state/app_state.dart'; @@ -126,6 +128,7 @@ class LogWorkout extends StatefulWidget { this.title, this.spans, this.now, + this.importedUuid, }); final String? sessionId; @@ -145,6 +148,10 @@ class LogWorkout extends StatefulWidget { /// Injected in tests so "that hasn't happened yet" is deterministic. final DateTime? now; + /// Set when this scores an imported workout (#325): the saved session + /// replaces that import, so the workout is not listed and counted twice. + final String? importedUuid; + @override State createState() => _LogWorkoutState(); } @@ -198,8 +205,12 @@ class _LogWorkoutState extends State { existing: _spans, // A retime must not collide with itself; a new entry's id is derived // from its start second, so re-logging the same window updates that - // row rather than colliding with it. - editingId: widget.sessionId ?? manualSessionId(_startSec), + // row rather than colliding with it. Not when scoring an import: a + // session already at that start would be overwritten by the scored + // copy (and deleted with it if that saves unscored), so every saved + // session counts as a collision. + editingId: widget.sessionId ?? + (widget.importedUuid == null ? manualSessionId(_startSec) : null), ); Future _pickDate() async { @@ -282,15 +293,39 @@ class _LogWorkoutState extends State { if (mounted) nav.pop(true); return; } + if (widget.importedUuid != null) { + // The spans this form opened with may still have been loading: check + // the window against what is saved now, before writing over it. + final spans = await repo.savedSessionSpans(); + if (!mounted) return; + setState(() => _spans = spans); + if (_invalid != null) { + setState(() => _saving = false); + return; + } + } final r = widget.sessionId == null ? await repo.logManualWorkout( startTs: _startSec, endTs: _endSec, type: _activity.typeKey) : await repo.setWorkoutWindow(widget.sessionId!, startTs: _startSec, endTs: _endSec); - // Both branches: a new session and a RETIMED one both change what the - // health store should hold for that window (#130). - await HealthExporter.exportWorkoutId( - (r['workout_id'] ?? widget.sessionId) as String?); + if (widget.importedUuid case final uuid?) { + if (!await replaceImportWithScored(repo, uuid, r)) { + if (!mounted) return; + setState(() { + _saving = false; + _wrote = l?.logWorkoutImportedHrGone ?? + 'Heart rate for this workout is no longer stored, so the band ' + 'cannot score it.'; + }); + return; + } + } else { + // Both branches: a new session and a RETIMED one both change what the + // health store should hold for that window (#130). + await HealthExporter.exportWorkoutId( + (r['workout_id'] ?? widget.sessionId) as String?); + } // Say what was actually banked. A window with no 1 Hz substrate left // behind it — anything past the `rawRetentionDays` retention, or a // stretch the band was off — is saved UNSCORED, and a screen that pops @@ -526,6 +561,65 @@ AppState? appOf(BuildContext c) { } } +/// Settle a scoring save against the import it was opened from (#325). +/// +/// Only a SCORED session supersedes the import. The heart-rate check runs +/// before the form opens, so a retimed window (or samples pruned meanwhile) +/// can still save unscored; that copy carries less than the import, so it is +/// removed and the import kept. False when that happened. +Future replaceImportWithScored( + LocalRepository repo, String uuid, Map saved) async { + final id = saved['workout_id'] as String; + if (saved['unscored'] == true) { + await repo.deleteWorkout(id); + return false; + } + // Superseded first: a tombstone left by a failed supersede would have the + // next import pass delete an import that is still the only copy. The other + // order's failure only re-imports it beside the scored session. + await LocalDb.supersedeImportedWorkout(uuid, id); + // The original already sits in the health store; exporting ours too would + // put the same workout there twice. + await rememberDeletedUuid(uuid); + return true; +} + +/// Tapping an imported workout (#325): score its window from the band's own +/// 1 Hz heart rate through the ordinary manual-log form, or say plainly that +/// the heart rate behind it is gone. [hasHr] is injected in tests. +Future scoreImportedWorkout( + BuildContext c, { + required String uuid, + required DateTime start, + required DateTime end, + required Activity activity, + Future Function(int startSec, int endSec)? hasHr, +}) async { + final l = AppLocalizations.of(c); + final s = start.millisecondsSinceEpoch ~/ 1000; + final e = end.millisecondsSinceEpoch ~/ 1000; + final stored = await (hasHr ?? + (s, e) async => (await LocalDb.hrSamplesInRange(s, e)).isNotEmpty)(s, e); + if (!c.mounted) return; + if (!stored) { + ScaffoldMessenger.of(c).showSnackBar(SnackBar( + content: Text(l?.logWorkoutImportedHrGone ?? + 'Heart rate for this workout is no longer stored, so the band ' + 'cannot score it.'), + )); + return; + } + await Navigator.of(c).push(MaterialPageRoute( + builder: (_) => LogWorkout( + start: start, + end: end, + activity: activity, + importedUuid: uuid, + title: l?.logWorkoutScoreWithBand ?? 'Score with band heart rate', + ), + )); +} + /// Pending workouts for the History tab, independent of push preferences. Future> activeSuggestions() async { try { diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 21631514a..4df6459c3 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -607,6 +607,16 @@ class _WorkoutScreenState extends State with RevisionReload { onDelete: w.id.isEmpty ? null : () => _confirmDeleteWorkout(c, w), + onScore: w.importedFrom != null && w.id.isNotEmpty + ? () async { + await scoreImportedWorkout(c, + uuid: w.id, + start: w.start, + end: w.start.add(w.duration), + activity: w.activity); + if (mounted) reload(); + } + : null, // A retime is a re-score over the new window, so it is offered // only where there is something of ours to re-score: an imported // row's times belong to the app that recorded it, and this band @@ -965,8 +975,11 @@ class _HistoryRow extends StatelessWidget { /// Remove this session locally. Null hides the control (no id to delete). final VoidCallback? onDelete; + /// An imported row's tap: score its window from the band (#325). + final VoidCallback? onScore; + const _HistoryRow(this.w, - {this.weightKg, this.onRetime, this.onDelete}); + {this.weightKg, this.onRetime, this.onDelete, this.onScore}); Future _open(BuildContext c) async { final nav = Navigator.of(c); @@ -982,13 +995,11 @@ class _HistoryRow extends StatelessWidget { final a = w.activity; final stats = _stats(c); return Surface( - // An imported row does not open. The summary screen behind this tap is - // built to show a session THIS band measured — its rating control, its - // heart-rate trace, its zone split — and it has nowhere to say whose - // workout it is. A screen that presents an Apple Watch run exactly like - // one of ours is the fabrication this whole table exists to avoid, so - // the row stays a row until that screen can name its source. - onTap: w.importedFrom == null ? () => _open(c) : null, + // An imported row does not open the summary: that screen shows a + // session THIS band measured and has nowhere to say whose workout it is. + // Its tap offers to score the window from the band's own heart rate + // instead, which makes it one of ours and replaces the import (#325). + onTap: w.importedFrom == null ? () => _open(c) : onScore, child: Column(children: [ Row(children: [ Container( @@ -1998,9 +2009,11 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { if (r is! Map) continue; final ts = (r['start_ts'] as num?)?.toInt(); if (ts == null) continue; + // An unknown type keeps its own name: a scored import is saved + // under the store's sport (`surfing`), which the catalogue may lack. final a = activityByName(r['type'] as String?) ?? - const Activity('Workout', LucideIcons.activity, C.purple, - Track.duration, 5.0); + Activity(importedWorkoutTitle(r['type']), LucideIcons.activity, + C.purple, Track.duration, 5.0); past.add(_PastWorkout( (r['id'] as String?) ?? '', a, @@ -2049,9 +2062,12 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { // type. The NAME always comes from the store — `activityByName` // resolves the ~40 types this app can start, and the fallback would // print "Workout" over a surf. + // Named after the store's type, not "Workout": scoring this row + // saves `activity.typeKey`, and a generic fallback would replace + // the import's sport with `workout`. activityByName(title) ?? - const Activity('Workout', LucideIcons.activity, C.purple, - Track.duration, 5.0), + Activity(title, LucideIcons.activity, C.purple, Track.duration, + 5.0), at, Motion.tick * (endTs - ts), // No strain, ever. It is not omitted pending a better idea — there diff --git a/test/log_workout_test.dart b/test/log_workout_test.dart index 7a0e3a623..66c29943d 100644 --- a/test/log_workout_test.dart +++ b/test/log_workout_test.dart @@ -153,6 +153,22 @@ void main() { ); }); + testWidgets('scoring an import never writes over a session at its start ' + '(#325)', (t) async { + final s = _start.millisecondsSinceEpoch ~/ 1000; + final e = _end.millisecondsSinceEpoch ~/ 1000; + final same = [SessionSpan(manualSessionId(s), s, e)]; + await _pump(t, LogWorkout(now: _now, start: _start, end: _end, spans: same)); + expect(find.text('That window will not save'), findsNothing, + reason: 're-logging the same window updates that row'); + await _pump( + t, + LogWorkout( + now: _now, start: _start, end: _end, spans: same, importedUuid: 'x'), + ); + expect(find.text('That window will not save'), findsOneWidget); + }); + testWidgets('a retime keeps the type and does not offer to change it', ( t, ) async { diff --git a/test/score_imported_workout_test.dart b/test/score_imported_workout_test.dart new file mode 100644 index 000000000..4c356da0b --- /dev/null +++ b/test/score_imported_workout_test.dart @@ -0,0 +1,147 @@ +// #325 — an imported workout can be scored from the band's own 1 Hz heart +// rate, and the scored session replaces the import rather than sitting beside +// it. Both tap paths, and the replacement itself. +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/ui2/activity/catalogue.dart'; +import 'package:openstrap_edge/ui2/screens/log_workout.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _Repo implements LocalRepository { + final deleted = []; + @override + Future deleteWorkout(String id) async => deleted.add(id); + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +final _start = DateTime(2026, 8, 1, 9); +final _end = DateTime(2026, 8, 1, 10, 36); + +Future _tap(WidgetTester t, {required bool stored}) async { + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (c) => TextButton( + onPressed: () => scoreImportedWorkout(c, + uuid: 'hevy-1', + start: _start, + end: _end, + activity: quickStart.first, + hasHr: (_, _) async => stored), + child: const Text('row'), + ), + ), + ), + )); + await t.tap(find.text('row')); + await t.pumpAndSettle(); +} + +void main() { + testWidgets('with band heart rate stored, opens the log form on its window', + (t) async { + await _tap(t, stored: true); + final form = t.widget(find.byType(LogWorkout)); + expect(form.start, _start); + expect(form.end, _end); + expect(form.importedUuid, 'hevy-1'); + expect(form.sessionId, isNull); + expect(find.text('Score with band heart rate'), findsOneWidget); + }); + + testWidgets('with the heart rate pruned, says so and opens nothing', + (t) async { + await _tap(t, stored: false); + expect(find.byType(LogWorkout), findsNothing); + expect(find.textContaining('no longer stored'), findsOneWidget); + }); + + group('supersedeImportedWorkout', () { + late Directory dir; + setUp(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_score_imported_test.db'; + dir = Directory(await databaseFactory.getDatabasesPath()); + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir.path, LocalDb.dbName)); + }); + tearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir.path, LocalDb.dbName)); + }); + + test('drops the import and hands its route to the scored session', + () async { + await LocalDb.putImportedWorkouts([ + { + 'uuid': 'hevy-1', + 'start_ts': 100, + 'end_ts': 200, + 'kind': 'RUNNING', + 'source': 'Hevy', + }, + ]); + final db = await LocalDb.instance; + await db.insert('workout_route', { + 'session_id': 'hevy-1', + 'seq': 0, + 'ts_ms': 100000, + 'lat': 51.5, + 'lng': -0.1, + }); + + await LocalDb.supersedeImportedWorkout('hevy-1', 'manual-100'); + + expect(await LocalDb.importedWorkouts(), isEmpty); + final route = await db.query('workout_route'); + expect(route.single['session_id'], 'manual-100'); + }); + + test('a session with its own route keeps it whole', () async { + final db = await LocalDb.instance; + Map pt(String id, int seq, double lat) => { + 'session_id': id, + 'seq': seq, + 'ts_ms': 100000 + seq, + 'lat': lat, + 'lng': -0.1, + }; + await db.insert('workout_route', pt('manual-100', 0, 1)); + await db.insert('workout_route', pt('hevy-1', 0, 2)); + await db.insert('workout_route', pt('hevy-1', 1, 2)); + + await LocalDb.supersedeImportedWorkout('hevy-1', 'manual-100'); + + final route = await db.query('workout_route'); + expect(route.single['lat'], 1); + }); + + test('an unscored save keeps the import and drops its own copy', + () async { + await LocalDb.putImportedWorkouts([ + { + 'uuid': 'hevy-1', + 'start_ts': 100, + 'end_ts': 200, + 'kind': 'SURFING', + 'source': 'Hevy', + }, + ]); + final repo = _Repo(); + + final replaced = await replaceImportWithScored( + repo, 'hevy-1', {'workout_id': 'manual-100', 'unscored': true}); + + expect(replaced, isFalse); + expect(repo.deleted, ['manual-100']); + expect((await LocalDb.importedWorkouts()).single['uuid'], 'hevy-1'); + }); + }); +}