From b4e69dbff43661dd9fcfe29b90223ae63b7f1d5a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:00:01 +0100 Subject: [PATCH 1/8] Add RunMap, run detail and runs in history RunMap.route/.live (flutter_map, OSM tiles cached on disk, collapsed icon-only attribution, one warning when tiles fail), RunDetailScreen (full-bleed map + numbers, no scrolling), a runs strip in the history, and day+sport dedupe that hides the PC's runnerup_verified entry once the phone has the run itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- .../lib/screens/history_screen.dart | 69 +++--- .../lib/screens/history_screen_data.dart | 5 + .../lib/screens/history_screen_runs.dart | 126 ++++++++++ .../lib/screens/run_detail_screen.dart | 160 ++++++++++++ .../lib/services/map_tile_cache.dart | 66 +++++ .../workout_app/lib/services/run_format.dart | 40 +++ .../lib/services/run_history_merge.dart | 59 +++++ .../workout_app/lib/widgets/run_map.dart | 204 ++++++++++++++++ .../lib/widgets/run_map_layers.dart | 82 +++++++ .../test/screens/_history_test_fixtures.dart | 3 + .../test/screens/history_runs_test.dart | 157 ++++++++++++ .../test/screens/run_detail_screen_test.dart | 131 ++++++++++ .../test/services/map_tile_cache_test.dart | 55 +++++ .../test/services/run_format_test.dart | 31 +++ .../test/services/run_history_merge_test.dart | 81 +++++++ .../test/widgets/_run_map_fixtures.dart | 119 +++++++++ .../test/widgets/run_map_test.dart | 229 ++++++++++++++++++ 17 files changed, 1581 insertions(+), 36 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/screens/history_screen_runs.dart create mode 100644 stronglift_replacement/workout_app/lib/screens/run_detail_screen.dart create mode 100644 stronglift_replacement/workout_app/lib/services/map_tile_cache.dart create mode 100644 stronglift_replacement/workout_app/lib/services/run_format.dart create mode 100644 stronglift_replacement/workout_app/lib/services/run_history_merge.dart create mode 100644 stronglift_replacement/workout_app/lib/widgets/run_map.dart create mode 100644 stronglift_replacement/workout_app/lib/widgets/run_map_layers.dart create mode 100644 stronglift_replacement/workout_app/test/screens/history_runs_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/run_detail_screen_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/run_format_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/run_history_merge_test.dart create mode 100644 stronglift_replacement/workout_app/test/widgets/_run_map_fixtures.dart create mode 100644 stronglift_replacement/workout_app/test/widgets/run_map_test.dart diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart index 21cef3f..d1f940c 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart @@ -11,8 +11,14 @@ import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:workout_app/models/manual_workout.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/screens/run_detail_screen.dart'; import 'package:workout_app/services/done_today.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/services/run_format.dart'; +import 'package:workout_app/services/run_history_merge.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; @@ -23,6 +29,7 @@ part 'history_screen_charts.dart'; part 'history_screen_data.dart'; part 'history_screen_painter.dart'; part 'history_screen_planned.dart'; +part 'history_screen_runs.dart'; part 'history_screen_tiles.dart'; const _kTotal = 'Total (all workouts)'; @@ -44,6 +51,7 @@ class HistoryScreen extends StatefulWidget { this.httpClient, this.firebaseFactory, this.clock, + this.tileProviderOpener = openOsmTileProvider, }); /// Overrides the HTTP client used to read synced workouts (tests only). @@ -56,6 +64,9 @@ class HistoryScreen extends StatefulWidget { /// defaults to [DateTime.now], injected by tests. final DateTime Function()? clock; + /// Where a run's map tiles come from; a fake in tests. + final TileProviderOpener tileProviderOpener; + @override State createState() => _HistoryScreenState(); } @@ -64,6 +75,7 @@ class _HistoryScreenState extends State { List> _rows = []; List> _syncedRows = []; List> _plannedRows = []; + List _runs = []; bool _loading = true; String _selected = _kTotal; List _exerciseNames = []; @@ -103,13 +115,16 @@ class _HistoryScreenState extends State { // coverage:ignore-end } final today = dayKey((widget.clock ?? DateTime.now)()); - final synced = _syncedOnly(remote, today); + final runs = await StorageService.instance.listRuns(); + // The phone's own copy of a run (it has the map) replaces the PC's entry. + final synced = withoutLocalRuns(_syncedOnly(remote, today), runs); final planned = _plannedOnly(remote, today); if (mounted) { setState(() { _rows = rows; _syncedRows = synced; _plannedRows = planned; + _runs = runs; _exerciseNames = names; _selectedState = state; _loading = false; @@ -137,8 +152,6 @@ class _HistoryScreenState extends State { } } - // ── Data helpers ────────────────────────────────────────────────────────── - @override Widget build(BuildContext context) { final allNames = [_kTotal, ..._exerciseNames]; @@ -155,7 +168,10 @@ class _HistoryScreenState extends State { ? const Center(child: CircularProgressIndicator()) // Synced and planned entries count too: a phone with no local // sessions must still list (and let the user delete) its plans. - : _rows.isEmpty && _syncedRows.isEmpty && _plannedRows.isEmpty + : _rows.isEmpty && + _syncedRows.isEmpty && + _plannedRows.isEmpty && + _runs.isEmpty ? Center( child: Text( 'No workouts yet.', @@ -180,28 +196,24 @@ class _HistoryScreenState extends State { ); } + void _shiftMonth(int delta) => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month + delta, + ); + }); + List _buildTotalView() => [ ..._plannedSection(), + if (_runs.isNotEmpty) ...[ + _RunsStrip(runs: _runs, onOpen: _openRun), + const SizedBox(height: 16), + ], const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), const SizedBox(height: 6), _WeightChart(points: _rollingAvg2(_totalVolumePoints(_rows))), const SizedBox(height: 16), - WorkoutCalendar( - workoutDates: _allWorkoutDates(_rows, _syncedRows), - month: _calendarMonth, - onPrevMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month - 1, - ); - }), - onNextMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month + 1, - ); - }), - ), + _calendar(_allWorkoutDates(_rows, _syncedRows, _runs)), const SizedBox(height: 16), const _SectionLabel('ALL SESSIONS'), const SizedBox(height: 8), @@ -223,22 +235,7 @@ class _HistoryScreenState extends State { const SizedBox(height: 6), _WeightChart(points: _exerciseWeightPoints(_rows, name)), const SizedBox(height: 16), - WorkoutCalendar( - workoutDates: _exerciseDates(_rows, name), - month: _calendarMonth, - onPrevMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month - 1, - ); - }), - onNextMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month + 1, - ); - }), - ), + _calendar(_exerciseDates(_rows, name)), const SizedBox(height: 16), _SectionLabel(name.toUpperCase()), const SizedBox(height: 8), diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart index 792f34a..8b59cb1 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart @@ -117,12 +117,17 @@ List<(DateTime, double)> _rollingAvg2(List<(DateTime, double)> pts) { /// /// Synced dates are included so a day you only worked out on the PC (a /// RunnerUp run) still marks the calendar here. +/// +/// Runs recorded here count by their local start day, which is also the day +/// of the PC entry they replaced. Set _allWorkoutDates( List> rows, List> syncedRows, + List runs, ) => { ...rows.map((r) => r['date'] as String), ...syncedRows.map((r) => '${r['date']}'), + ...runs.map((r) => dayKey(r.track.startedAt.toLocal())), }; /// Synced kinds this phone has no local record of, so showing them can't diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_runs.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_runs.dart new file mode 100644 index 0000000..da00e81 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_runs.dart @@ -0,0 +1,126 @@ +// The runs overview on HistoryScreen: one fixed-height strip of compact +// tiles (newest first, swipe sideways for older ones) so it costs no +// vertical space (R7). No words: icons and numbers only. +// +// See history_screen_charts.dart for why these are `part` files. +part of 'history_screen.dart'; + +/// Pieces of the State kept here so history_screen.dart stays under the line +/// cap: the month-paged calendar and opening a run's detail. +extension _RunsAndCalendar on _HistoryScreenState { + Widget _calendar(Set dates) => WorkoutCalendar( + workoutDates: dates, + month: _calendarMonth, + onPrevMonth: () => _shiftMonth(-1), + onNextMonth: () => _shiftMonth(1), + ); + + Future _openRun(RunRecord record) async { + final full = await StorageService.instance.getRun(record.track.id); + if (full == null || !mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RunDetailScreen( + track: full.track, + tileProviderOpener: widget.tileProviderOpener, + ), + ), + ); + } +} + +class _RunsStrip extends StatelessWidget { + const _RunsStrip({required this.runs, required this.onOpen}); + + final List runs; + final void Function(RunRecord) onOpen; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return SizedBox( + key: const Key('runs_strip'), + height: 92, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: runs.length, + separatorBuilder: (_, _) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (_, i) => _RunTile( + run: runs[i].track, + scheme: scheme, + onTap: () => onOpen(runs[i]), + ), + ), + ); + } +} + +class _RunTile extends StatelessWidget { + const _RunTile({ + required this.run, + required this.scheme, + required this.onTap, + }); + + final RunTrack run; + final ColorScheme scheme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isRun = run.sport == RunSport.running; + final value = TextStyle( + color: scheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: AppTextSize.label, + ); + return Semantics( + button: true, + label: isRun ? 'Run' : 'Walk', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + width: 104, + padding: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: scheme.outline), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + isRun ? Icons.directions_run : Icons.directions_walk, + size: 18, + color: scheme.primary, + ), + const Spacer(), + Text( + formatRunDay(run.startedAt), + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ), + Text(formatDistanceKm(run.distanceM), style: value), + Text( + formatDuration(run.totalSeconds), + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/run_detail_screen.dart b/stronglift_replacement/workout_app/lib/screens/run_detail_screen.dart new file mode 100644 index 0000000..c64c494 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/run_detail_screen.dart @@ -0,0 +1,160 @@ +/// One run on one screen: the whole route on a full-bleed map with its +/// numbers (distance, moving time, average pace, date) in a panel over the +/// bottom edge. Nothing scrolls (R7); no words (textgate), icons name each +/// value. +library; + +import 'package:flutter/material.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/services/run_format.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/run_map.dart'; + +/// Height reserved under the route for the stats panel, so the fitted route +/// is not hidden behind it. +const _panelClearance = 190.0; + +/// Detail of the finished [track] (it must carry its points). +class RunDetailScreen extends StatelessWidget { + /// Creates the screen; [tileProviderOpener] is swapped by tests. + const RunDetailScreen({ + required this.track, + super.key, + this.tileProviderOpener = openOsmTileProvider, + }); + + /// The run, with points (see `StorageServiceRuns.getRun`). + final RunTrack track; + + /// Where the map's tiles come from. + final TileProviderOpener tileProviderOpener; + + @override + Widget build(BuildContext context) { + final top = MediaQuery.paddingOf(context).top; + return Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + RunMap.route( + points: track.points, + fitPadding: EdgeInsets.fromLTRB( + AppSpacing.xl, + top + 64, + AppSpacing.xl, + _panelClearance, + ), + tileProviderOpener: tileProviderOpener, + ), + Positioned( + left: AppSpacing.sm, + top: top + AppSpacing.sm, + child: IconButton.filledTonal( + onPressed: () => Navigator.maybePop(context), + icon: const Icon(Icons.arrow_back, semanticLabel: 'Back'), + ), + ), + Positioned( + left: AppSpacing.md, + right: AppSpacing.md, + bottom: MediaQuery.paddingOf(context).bottom + AppSpacing.md, + child: _StatsPanel(track: track), + ), + ], + ), + ); + } +} + +class _StatsPanel extends StatelessWidget { + const _StatsPanel({required this.track}); + + final RunTrack track; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final isRun = track.sport == RunSport.running; + return Container( + key: const Key('run_stats_panel'), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: scheme.outline), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + isRun ? Icons.directions_run : Icons.directions_walk, + color: scheme.primary, + semanticLabel: isRun ? 'Run' : 'Walk', + ), + const SizedBox(width: AppSpacing.sm), + Text( + formatFullDate(track.startedAt), + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const Spacer(), + if (track.source == RunSource.runnerupImport) + Icon( + Icons.history, + color: scheme.onSurfaceVariant, + semanticLabel: 'Imported', + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + _Stat(Icons.straighten, formatDistanceKm(track.distanceM)), + _Stat(Icons.timer_outlined, formatDuration(track.totalSeconds)), + _Stat( + Icons.speed, + formatPace( + seconds: track.totalSeconds, + distanceM: track.distanceM, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _Stat extends StatelessWidget { + const _Stat(this.icon, this.value); + + final IconData icon; + final String value; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Expanded( + child: Column( + children: [ + Icon(icon, size: 20, color: scheme.onSurfaceVariant), + const SizedBox(height: AppSpacing.xs), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value, + style: TextStyle( + color: scheme.onSurface, + fontSize: AppTextSize.title, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart new file mode 100644 index 0000000..a5a6cdf --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart @@ -0,0 +1,66 @@ +/// The OSM tile source and its on-disk cache. +/// +/// Tiles come from tile.openstreetmap.org and are kept in the app's cache +/// directory (flutter_map_cache over dio_cache_interceptor) with a one-year +/// max-stale, so an area visited once draws offline later. OSM's tile usage +/// policy needs a real User-Agent: the [TileLayer] sends +/// [osmUserAgentPackage]. +library; + +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_cache/flutter_map_cache.dart'; +import 'package:http_cache_file_store/http_cache_file_store.dart'; +import 'package:path_provider/path_provider.dart'; + +/// The OSM raster tile template. +const osmTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + +/// Sent as the tile User-Agent, as OSM's tile policy asks. +const osmUserAgentPackage = 'com.kuhy.workout_app'; + +/// How long a cached tile may be served when the network cannot refresh it. +const tileMaxStale = Duration(days: 365); + +/// Opens a [TileProvider]; swapped for a fake in tests. +typedef TileProviderOpener = Future Function(); + +/// A provider that caches into [cacheDir] (and serves stale tiles offline). +TileProvider buildCachedTileProvider(String cacheDir, {Dio? dio}) => + CachedTileProvider( + store: FileCacheStore(cacheDir), + maxStale: tileMaxStale, + dio: dio, + ); + +Future? _opened; + +/// The shared disk-cached OSM provider; opened once per process. +/// +/// If the cache directory cannot be had, the map still works over the +/// network without a cache, and the reason is logged at warning level. +Future openOsmTileProvider() => _opened ??= _open(); + +/// Forgets the opened provider (tests only). +@visibleForTesting +void resetOsmTileProvider() => _opened = null; + +Future _open() async { + try { + final dir = await getApplicationCacheDirectory(); + return buildCachedTileProvider('${dir.path}/osm_tiles'); + } on Object catch (error) { + logMapWarning('map tile cache unavailable, tiles will not persist: $error'); + return NetworkTileProvider(); + } +} + +/// Logs [message] at warning level (900), loud in `flutter logs` too. +void logMapWarning(String message) { + developer.log(message, name: 'RunMap', level: 900); + debugPrint('RunMap WARNING: $message'); +} diff --git a/stronglift_replacement/workout_app/lib/services/run_format.dart b/stronglift_replacement/workout_app/lib/services/run_format.dart new file mode 100644 index 0000000..1386da3 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/run_format.dart @@ -0,0 +1,40 @@ +/// Number formatting for run tiles and the run detail screen. +/// +/// Numbers only: textgate bans words in the UI, so there are no unit +/// suffixes. The icon next to a value (distance, clock, pace) names it. +library; + +String _two(int v) => v.toString().padLeft(2, '0'); + +/// Kilometres with two decimals, e.g. `5.20`. +String formatDistanceKm(double distanceM) => + (distanceM / 1000).toStringAsFixed(2); + +/// `m:ss`, or `h:mm:ss` from an hour up; [seconds] is moving time. +String formatDuration(int seconds) { + final h = seconds ~/ 3600; + final m = (seconds ~/ 60).remainder(60); + final s = seconds % 60; + return h > 0 ? '$h:${_two(m)}:${_two(s)}' : '$m:${_two(s)}'; +} + +/// Average pace as `m:ss` per kilometre, or `-:--` when under 10 m was +/// covered (the quotient is meaningless, and 0 would divide by zero). +String formatPace({required int seconds, required double distanceM}) { + if (distanceM < 10) return '-:--'; + final perKm = (seconds / (distanceM / 1000)).round(); + return '${perKm ~/ 60}:${_two(perKm % 60)}'; +} + +/// `dd.MM` of [when] in the device's local time. +String formatRunDay(DateTime when) { + final l = when.toLocal(); + return '${_two(l.day)}.${_two(l.month)}'; +} + +/// `yyyy-MM-dd HH:mm` of [when] in the device's local time. +String formatFullDate(DateTime when) { + final l = when.toLocal(); + return '${l.year}-${_two(l.month)}-${_two(l.day)} ' + '${_two(l.hour)}:${_two(l.minute)}'; +} diff --git a/stronglift_replacement/workout_app/lib/services/run_history_merge.dart b/stronglift_replacement/workout_app/lib/services/run_history_merge.dart new file mode 100644 index 0000000..7cc197d --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/run_history_merge.dart @@ -0,0 +1,59 @@ +/// Merges the phone's own runs into the history list without doubling them. +/// +/// The PC pushes a `runnerup_verified` calendar entry for every day it +/// credited a RunnerUp run or walk. Once the app records (or imports) the same +/// run itself, that entry says nothing the local run does not, and the local +/// one has the map. This hides the duplicate. +/// +/// The key is **day + sport**, not a start time, because that is all the PC +/// entry carries: its record id is `runnerup_verified:` (one slot +/// per day, runs and walks merged), and the payload holds `date`, +/// `distance_km`, `duration_minutes`, an optional `completed_at` (the *end*, +/// often absent) and `activity: walking` for a day of walks. There is no +/// start time to match within a window. Sport keeps a stock-RunnerUp run +/// visible next to an app-recorded walk on the same day. +/// +/// When nothing matches, both stay visible: a duplicate is a nuisance, a +/// hidden credited run is a lie. +library; + +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/done_today.dart'; + +/// The `kind` the PC gives its run/walk entries. +const kRunnerUpVerifiedKind = 'runnerup_verified'; + +String _key(RunSport sport, String day) => '${sport.tcxName}|$day'; + +/// The `YYYY-MM-DD` part of a PC entry's `date`. +String _entryDay(Map payload) { + final date = '${payload['date']}'; + return date.length > 10 ? date.substring(0, 10) : date; +} + +/// The sport a PC entry stands for: a day of walks says so, anything else is +/// a run. +RunSport _entrySport(Map payload) => + payload['activity'] == 'walking' ? RunSport.walking : RunSport.running; + +/// [synced] without the `runnerup_verified` entries that [runs] already +/// cover (same local day and same sport). +/// +/// Days are the device's local calendar days, the calendar the PC dates its +/// records by (`dayKey`). Other kinds pass through untouched. +List> withoutLocalRuns( + List> synced, + Iterable runs, +) { + final covered = { + for (final r in runs) + _key(r.track.sport, dayKey(r.track.startedAt.toLocal())), + }; + return [ + for (final p in synced) + if (p['kind'] != kRunnerUpVerifiedKind || + !covered.contains(_key(_entrySport(p), _entryDay(p)))) + p, + ]; +} diff --git a/stronglift_replacement/workout_app/lib/widgets/run_map.dart b/stronglift_replacement/workout_app/lib/widgets/run_map.dart new file mode 100644 index 0000000..582d3db --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/run_map.dart @@ -0,0 +1,204 @@ +/// The map of a run: route polyline, start/end markers, live position. +/// +/// Two constructors, both drop-ins (no other required argument): +/// +/// * `RunMap.route(points: track.points)` -- a finished run. The camera +/// fits the whole route ([fitPadding] keeps it clear of overlays). +/// * `RunMap.live(trackListenable: notifier)` -- the live screen. Pass a +/// `ValueListenable` (a `ValueNotifier`, or the +/// recorder's own, anything that holds the current track); the route +/// redraws on every change and, in follow mode (the default), the camera +/// keeps the last point centred. Dragging the map ends follow mode; the +/// recentre button (icon only) turns it back on. The listenable must stay +/// the same object for the widget's lifetime (give it a new `Key` to swap). +/// +/// Tiles are OSM raster tiles from tile.openstreetmap.org, cached on disk +/// (`map_tile_cache.dart`) so revisited areas draw offline. The OSM credit is +/// a collapsed info icon; its text shows only when tapped. When no tile +/// loads (offline, first visit) the route still draws on the plain +/// background and ONE warning-level line is logged per map. +/// +/// Tests inject `tileProviderOpener` with a fake provider: nothing here +/// touches the network by itself. +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/run_map_layers.dart'; + +/// Where an empty map starts: Poland, zoomed out, until a first fix. +const _fallbackCenter = LatLng(52, 19); +const _fallbackZoom = 5.0; + +/// The zoom a one-point track or a first live fix gets. +const _closeZoom = 17.0; + +/// A map of a run; see the library comment for the two constructors. +class RunMap extends StatefulWidget { + /// A finished run: [points] drawn and fitted. + const RunMap.route({ + required List this.points, + super.key, + this.fitPadding = const EdgeInsets.all(40), + this.tileProviderOpener = openOsmTileProvider, + }) : trackListenable = null; + + /// A live run: follows the last point of the track in [trackListenable]. + const RunMap.live({ + required ValueListenable this.trackListenable, + super.key, + this.tileProviderOpener = openOsmTileProvider, + }) : points = null, + fitPadding = const EdgeInsets.all(40); + + /// The route in detail mode; null in live mode. + final List? points; + + /// The current track in live mode; null in detail mode. + final ValueListenable? trackListenable; + + /// Space kept free around the fitted route (detail mode). + final EdgeInsets fitPadding; + + /// Where the tile provider comes from; tests pass a fake. + final TileProviderOpener tileProviderOpener; + + @override + State createState() => _RunMapState(); +} + +class _RunMapState extends State { + final _controller = MapController(); + TileProvider? _tiles; + bool _ready = false; + bool _follow = true; + bool _warned = false; + + bool get _live => widget.trackListenable != null; + + List get _points => _live + ? widget.trackListenable!.value?.points ?? const [] + : widget.points!; + + @override + void initState() { + super.initState(); + widget.trackListenable?.addListener(_followLast); + unawaited( + widget.tileProviderOpener().then((p) { + if (mounted) setState(() => _tiles = p); + }), + ); + } + + @override + void dispose() { + widget.trackListenable?.removeListener(_followLast); + _controller.dispose(); + super.dispose(); + } + + void _followLast() { + final coords = toLatLngs(_points); + if (!_ready || !_follow || coords.isEmpty) return; + final zoom = _controller.camera.zoom; + _controller.move(coords.last, zoom < 12 ? _closeZoom : zoom); + } + + void _warnOnce(Object error) { + if (_warned) return; + _warned = true; + logMapWarning( + 'map tiles failed to load ($error); drawing the route on a plain ' + 'background', + ); + } + + MapOptions _options(List coords, ColorScheme scheme) { + final fit = !_live && coords.length > 1 + ? CameraFit.coordinates( + coordinates: coords, + padding: widget.fitPadding, + maxZoom: _closeZoom, + ) + : null; + return MapOptions( + initialCenter: coords.isEmpty ? _fallbackCenter : coords.last, + initialZoom: coords.isEmpty ? _fallbackZoom : _closeZoom, + initialCameraFit: fit, + backgroundColor: scheme.surfaceContainerHigh, + onMapReady: () { + _ready = true; + _followLast(); + }, + onPositionChanged: (camera, byGesture) { + if (_live && byGesture && _follow) setState(() => _follow = false); + }, + ); + } + + Widget _map(BuildContext context, List points) { + final scheme = Theme.of(context).colorScheme; + final coords = toLatLngs(points); + return FlutterMap( + mapController: _controller, + options: _options(coords, scheme), + children: [ + if (_tiles != null) + TileLayer( + urlTemplate: osmTileUrl, + userAgentPackageName: osmUserAgentPackage, + tileProvider: _tiles, + errorTileCallback: (tile, error, stack) => _warnOnce(error), + ), + if (coords.length > 1) routeLayer(coords, scheme), + if (coords.isNotEmpty) + endpointLayer( + coords, + live: _live, + scheme: scheme, + status: Theme.of(context).extension()!, + ), + attributionButton(scheme), + ], + ); + } + + @override + Widget build(BuildContext context) { + final listenable = widget.trackListenable; + if (listenable == null) return _map(context, widget.points!); + return Stack( + fit: StackFit.expand, + children: [ + ValueListenableBuilder( + valueListenable: listenable, + builder: (context, track, _) => + _map(context, track?.points ?? const []), + ), + if (!_follow) + Positioned( + left: AppSpacing.sm, + bottom: AppSpacing.sm, + child: IconButton.filled( + onPressed: () { + setState(() => _follow = true); + _followLast(); + }, + icon: const Icon( + Icons.my_location, + semanticLabel: 'Follow position', + ), + ), + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/run_map_layers.dart b/stronglift_replacement/workout_app/lib/widgets/run_map_layers.dart new file mode 100644 index 0000000..57c4c82 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/run_map_layers.dart @@ -0,0 +1,82 @@ +/// The route, endpoint markers and attribution button drawn by `RunMap`. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/ui/theme.dart'; + +/// [points] as map coordinates. +List toLatLngs(List points) => [ + for (final p in points) LatLng(p.latitude, p.longitude), +]; + +/// The route as one polyline; drawn even when no tile ever loads. +PolylineLayer routeLayer(List coords, ColorScheme scheme) => + PolylineLayer( + polylines: [ + Polyline( + points: coords, + strokeWidth: 5, + color: scheme.primary, + borderStrokeWidth: 2, + borderColor: scheme.surface, + ), + ], + ); + +Marker _dot(LatLng at, Color fill, IconData icon, Color ink) => Marker( + point: at, + child: DecoratedBox( + decoration: BoxDecoration( + color: fill, + shape: BoxShape.circle, + border: Border.all(color: ink, width: 2), + ), + child: Icon(icon, size: 16, color: ink), + ), +); + +/// Start marker (green play), and either the live position (accent dot) or +/// the finish flag (red); no end marker for a one-point track. +MarkerLayer endpointLayer( + List coords, { + required bool live, + required ColorScheme scheme, + required AppStatusColors status, +}) { + final start = _dot( + coords.first, + status.success, + Icons.play_arrow, + scheme.surface, + ); + final last = coords.last; + return MarkerLayer( + markers: [ + start, + if (live) + _dot(last, scheme.primary, Icons.navigation, scheme.surface) + else if (coords.length > 1) + _dot(last, scheme.error, Icons.flag, scheme.surface), + ], + ); +} + +/// The collapsed, icon-only OSM credit; the (c) text shows only when tapped. +RichAttributionWidget attributionButton(ColorScheme scheme) => + RichAttributionWidget( + showFlutterMapAttribution: false, + attributions: const [TextSourceAttribution('OpenStreetMap contributors')], + popupBackgroundColor: scheme.surfaceContainerHigh, + openButton: (context, open) => IconButton( + onPressed: open, + icon: Icon( + Icons.info_outline, + size: 20, + color: scheme.onSurface, + semanticLabel: 'Map attribution', + ), + ), + ); diff --git a/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart index 0f2fe0d..f8a1754 100644 --- a/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:workout_app/screens/history_screen.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; @@ -35,12 +36,14 @@ Widget wrapHistory({ http.Client? httpClient, Future Function()? firebaseFactory, DateTime Function()? clock, + TileProviderOpener? tileProviderOpener, }) => MaterialApp( theme: buildAppTheme(), home: HistoryScreen( httpClient: httpClient, firebaseFactory: firebaseFactory ?? () async => null, clock: clock, + tileProviderOpener: tileProviderOpener ?? openOsmTileProvider, ), ); diff --git a/stronglift_replacement/workout_app/test/screens/history_runs_test.dart b/stronglift_replacement/workout_app/test/screens/history_runs_test.dart new file mode 100644 index 0000000..a32d981 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/history_runs_test.dart @@ -0,0 +1,157 @@ +// Runs in the history: the overview strip, the one-entry-per-run merge with +// the PC's runnerup_verified rows, the calendar, and opening the detail. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/screens/run_detail_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/widgets/calendar_widget.dart'; + +import '../fake_secure_storage.dart'; +import '../widgets/_run_map_fixtures.dart'; +import '_history_test_fixtures.dart'; + +// historySyncMock() holds a PC run and a PC manual workout, both dated this. +final _pcDay = DateTime(2026, 7, 13, 12); + +Future _seedRun( + WidgetTester tester, + DateTime startedAt, { + RunSport sport = RunSport.running, +}) async { + final track = buildTrack( + startedAt: startedAt.toUtc(), + sport: sport, + count: 3, + ); + await tester.runAsync( + () => StorageService.instance.insertRun( + track, + tcxFilename: 'run_${track.id}.tcx', + tcxSha256: tcxSha256(encodeTcx(track)), + uploadState: RunUploadState.pending, + ), + ); +} + +Future _pump(WidgetTester tester, {bool withPc = false}) async { + if (withPc) installFakeSecureStorage(initial: {'sync.token': 'tok'}); + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.625; + addTearDown(tester.view.reset); + await pumpHistory( + tester, + wrapHistory( + httpClient: withPc ? historySyncMock() : null, + tileProviderOpener: fakeTiles(), + ), + ); +} + +Future _scrollToSynced(WidgetTester tester) => tester.scrollUntilVisible( + find.text('SYNCED FROM PC'), + 200, + scrollable: find.byType(Scrollable).first, +); + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + + testWidgets('runs alone are history: no empty state, a tile per run', ( + tester, + ) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _seedRun(tester, DateTime(2026, 7, 14, 12), sport: RunSport.walking); + await _pump(tester); + expect(find.text('No workouts yet.'), findsNothing); + expect(find.byKey(const Key('runs_strip')), findsOneWidget); + expect(find.text('5.20'), findsNWidgets(2)); + expect(find.text('32:10'), findsNWidgets(2)); + expect(find.text('14.07'), findsOneWidget); + expect(find.byIcon(Icons.directions_walk), findsOneWidget); + }); + + testWidgets('no runs: no strip', (tester) async { + await _pump(tester); + expect(find.byKey(const Key('runs_strip')), findsNothing); + }); + + testWidgets('the local run replaces the PC entry of its day, once', ( + tester, + ) async { + await _seedRun(tester, _pcDay); + await _pump(tester, withPc: true); + await _scrollToSynced(tester); + // The PC's manual workout of that day stays; its run entry is gone. + expect(find.textContaining('2026-07-13 · Manual'), findsOneWidget); + expect(find.textContaining('2026-07-13 · Run'), findsNothing); + expect(find.text('5.20'), findsOneWidget); + }); + + testWidgets('a run on another day leaves the PC entry visible', ( + tester, + ) async { + await _seedRun(tester, DateTime(2026, 7, 20, 12)); + await _pump(tester, withPc: true); + await _scrollToSynced(tester); + expect(find.textContaining('2026-07-13 · Run'), findsOneWidget); + expect(find.text('5.20'), findsOneWidget); + }); + + testWidgets('a run marks its day on the calendar', (tester) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _pump(tester); + final calendar = tester.widget( + find.byType(WorkoutCalendar), + ); + expect(calendar.workoutDates, contains('2026-07-13')); + }); + + testWidgets('tapping a run opens its detail, back returns', (tester) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _pump(tester); + await tester.tap(find.text('5.20')); + await tester.pumpAndSettle(); + // getRun is real sqflite: let it finish on the real event loop. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsOneWidget); + expect(find.text('2026-07-13 12:00'), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsNothing); + expect(find.byKey(const Key('runs_strip')), findsOneWidget); + }); + + testWidgets('a run deleted before the tap lands opens nothing', ( + tester, + ) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _pump(tester); + await tester.runAsync( + () => StorageService.instance.deleteRun( + RunTrack.idFor(DateTime(2026, 7, 13, 12).toUtc()), + ), + ); + await tester.tap(find.text('5.20')); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsNothing); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/run_detail_screen_test.dart b/stronglift_replacement/workout_app/test/screens/run_detail_screen_test.dart new file mode 100644 index 0000000..e09c2c9 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/run_detail_screen_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/screens/run_detail_screen.dart'; +import 'package:workout_app/widgets/run_map.dart'; + +import '../widgets/_run_map_fixtures.dart'; + +// Pixel 6a, physical px (adb dumpsys window), per build-mobile-app R7. +const _phonePhysical = Size(1080, 2400); +const _phoneDpr = 2.625; + +Future _pump(WidgetTester tester, RunTrack track) async { + await tester.pumpWidget( + themed(RunDetailScreen(track: track, tileProviderOpener: fakeTiles())), + ); + await tester.pump(); + await tester.pump(); +} + +void _phone(WidgetTester tester) { + tester.view.physicalSize = _phonePhysical; + tester.view.devicePixelRatio = _phoneDpr; + tester.view.padding = const FakeViewPadding(top: 132, bottom: 63); + addTearDown(tester.view.reset); +} + +void main() { + setUpAll(loadRoboto); + + testWidgets('shows the route map and the four numbers', (tester) async { + _phone(tester); + final track = buildTrack(startedAt: DateTime(2026, 11, 10, 6, 30).toUtc()); + await _pump(tester, track); + expect(find.byType(RunMap), findsOneWidget); + expect(find.text('5.20'), findsOneWidget); + expect(find.text('32:10'), findsOneWidget); + expect(find.text('6:11'), findsOneWidget); + expect(find.text('2026-11-10 06:30'), findsOneWidget); + expect(find.byIcon(Icons.directions_run), findsOneWidget); + expect(find.byIcon(Icons.history), findsNothing); + }); + + testWidgets('a walk gets the walking icon, an import the history mark', ( + tester, + ) async { + _phone(tester); + await _pump( + tester, + buildTrack(sport: RunSport.walking, source: RunSource.runnerupImport), + ); + expect(find.byIcon(Icons.directions_walk), findsOneWidget); + expect(find.byIcon(Icons.directions_run), findsNothing); + expect(find.byIcon(Icons.history), findsOneWidget); + }); + + testWidgets('no words: every visible text is digits and separators', ( + tester, + ) async { + _phone(tester); + await _pump(tester, buildTrack(totalSeconds: 3725)); + final numeric = RegExp(r'^[0-9:.\- ]+$'); + final words = tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + // The OSM credit is mandatory; it is fully transparent until the + // collapsed info icon is tapped (see run_map_test.dart). + .where((t) => !numeric.hasMatch(t) && !t.contains('OpenStreetMap')); + expect(words, isEmpty); + expect(find.text('1:02:05'), findsOneWidget); + }); + + testWidgets('a zero-distance run has a dash pace, not a crash', ( + tester, + ) async { + _phone(tester); + await _pump(tester, buildTrack(count: 1, distanceM: 0, totalSeconds: 0)); + expect(find.text('-:--'), findsOneWidget); + expect(find.text('0.00'), findsOneWidget); + }); + + testWidgets('the back button leaves the screen', (tester) async { + _phone(tester); + final track = buildTrack(); + await tester.pumpWidget( + themed( + Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RunDetailScreen( + track: track, + tileProviderOpener: fakeTiles(), + ), + ), + ), + child: const Icon(Icons.add), + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsNothing); + }); + + testWidgets('Pixel 6a insets: one screen, nothing scrolls', (tester) async { + _phone(tester); + await _pump(tester, buildTrack(totalSeconds: 3725)); + final scrollables = tester.stateList( + find.byType(Scrollable), + ); + for (final s in scrollables) { + expect(s.position.maxScrollExtent, 0); + } + // With no list on the screen the loop above is vacuously true, so also + // pin the geometry: panel above the nav bar, back button below the + // status bar. + final viewport = _phonePhysical / _phoneDpr; + final panel = tester.getRect(find.byKey(const Key('run_stats_panel'))); + expect(panel.bottom, lessThanOrEqualTo(viewport.height - 63 / _phoneDpr)); + expect(panel.left, greaterThanOrEqualTo(0)); + expect(panel.right, lessThanOrEqualTo(viewport.width)); + final back = tester.getRect(find.byIcon(Icons.arrow_back)); + expect(back.top, greaterThanOrEqualTo(132 / _phoneDpr)); + expect(tester.takeException(), isNull); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart b/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart new file mode 100644 index 0000000..9b12089 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_cache/flutter_map_cache.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; + +import '../fake_path_provider.dart'; +import '../widgets/_run_map_fixtures.dart'; + +/// A platform with no cache directory (getApplicationCacheDirectory throws). +class _NoCacheDir extends PathProviderPlatform { + @override + Future getApplicationCachePath() async => null; +} + +void main() { + setUp(resetOsmTileProvider); + + test('the OSM source and policy constants', () { + expect(osmTileUrl, 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'); + expect(osmUserAgentPackage, 'com.kuhy.workout_app'); + expect(tileMaxStale, const Duration(days: 365)); + }); + + test('buildCachedTileProvider caches on disk under the given dir', () { + final dir = Directory.systemTemp.createTempSync('tile_cache_test_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final provider = buildCachedTileProvider('${dir.path}/osm_tiles'); + expect(provider, isA()); + expect(provider.supportsCancelLoading, isTrue); + }); + + test('openOsmTileProvider opens a cached provider once', () async { + final dir = installFakePathProvider(); + addTearDown(() => dir.deleteSync(recursive: true)); + final first = await openOsmTileProvider(); + expect(first, isA()); + expect(identical(await openOsmTileProvider(), first), isTrue); + }); + + test('without a cache dir: network provider and a warning', () async { + PathProviderPlatform.instance = _NoCacheDir(); + late TileProvider provider; + final lines = await capturingDebugPrint(() async { + provider = await openOsmTileProvider(); + }); + expect(provider, isA()); + expect( + lines.where((l) => l.contains('WARNING') && l.contains('persist')), + hasLength(1), + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_format_test.dart b/stronglift_replacement/workout_app/test/services/run_format_test.dart new file mode 100644 index 0000000..9e00fdb --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_format_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/run_format.dart'; + +void main() { + test('distance is km with two decimals', () { + expect(formatDistanceKm(5200), '5.20'); + expect(formatDistanceKm(0), '0.00'); + expect(formatDistanceKm(12345.6), '12.35'); + }); + + test('duration is m:ss under an hour, h:mm:ss from it', () { + expect(formatDuration(0), '0:00'); + expect(formatDuration(65), '1:05'); + expect(formatDuration(3599), '59:59'); + expect(formatDuration(3600), '1:00:00'); + expect(formatDuration(3725), '1:02:05'); + }); + + test('pace is m:ss per km, and a dash when nothing was covered', () { + expect(formatPace(seconds: 1800, distanceM: 5000), '6:00'); + expect(formatPace(seconds: 1930, distanceM: 5200), '6:11'); + expect(formatPace(seconds: 100, distanceM: 9.9), '-:--'); + expect(formatPace(seconds: 100, distanceM: 0), '-:--'); + }); + + test('dates are shown in local time', () { + final when = DateTime(2026, 11, 3, 7, 5); + expect(formatRunDay(when.toUtc()), '03.11'); + expect(formatFullDate(when.toUtc()), '2026-11-03 07:05'); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_history_merge_test.dart b/stronglift_replacement/workout_app/test/services/run_history_merge_test.dart new file mode 100644 index 0000000..7a0e1f1 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_history_merge_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/run_history_merge.dart'; + +RunRecord _run( + DateTime startedAt, { + RunSport sport = RunSport.running, + RunSource source = RunSource.app, +}) => RunRecord( + track: RunTrack.started( + sport: sport, + startedAt: startedAt, + points: const [], + totalSeconds: 1800, + distanceM: 5000, + source: source, + ), + uploadState: RunUploadState.never, + tcxFilename: 'x.tcx', + tcxSha256: 'x', +); + +Map _pc( + String date, { + String kind = kRunnerUpVerifiedKind, + String? activity, +}) => { + 'kind': kind, + 'date': date, + 'distance_km': 5.0, + if (activity != null) 'activity': activity, +}; + +void main() { + // Local noon: the same calendar day in every time zone the suite may run in. + final noon = DateTime(2026, 11, 9, 12); + + test('a local run hides the PC run entry of its day', () { + final out = withoutLocalRuns([_pc('2026-11-09')], [_run(noon)]); + expect(out, isEmpty); + }); + + test('another day, or no local run, keeps the PC entry', () { + final entries = [_pc('2026-11-08'), _pc('2026-11-09')]; + expect(withoutLocalRuns(entries, [_run(noon)]), [_pc('2026-11-08')]); + expect(withoutLocalRuns(entries, const []), entries); + }); + + test('an imported run covers the day too', () { + final out = withoutLocalRuns([ + _pc('2026-11-09'), + ], [_run(noon, source: RunSource.runnerupImport)]); + expect(out, isEmpty); + }); + + test('sport must match: a walk does not hide a run, nor a run a walk', () { + final walk = _run(noon, sport: RunSport.walking); + final pcRun = _pc('2026-11-09'); + final pcWalk = _pc('2026-11-09', activity: 'walking'); + expect(withoutLocalRuns([pcRun], [walk]), [pcRun]); + expect(withoutLocalRuns([pcWalk], [walk]), isEmpty); + expect(withoutLocalRuns([pcWalk], [_run(noon)]), [pcWalk]); + }); + + test('other kinds are never hidden', () { + final manual = _pc('2026-11-09', kind: 'manual_workout'); + expect(withoutLocalRuns([manual], [_run(noon)]), [manual]); + }); + + test('a full timestamp date still matches on its day', () { + final out = withoutLocalRuns([_pc('2026-11-09T06:30:00')], [_run(noon)]); + expect(out, isEmpty); + }); + + test('the day is the local one, not the UTC one', () { + // 00:30 local on the 9th is the 8th in UTC for any zone east of UTC. + final early = DateTime(2026, 11, 9, 0, 30); + expect(withoutLocalRuns([_pc('2026-11-09')], [_run(early)]), isEmpty); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/_run_map_fixtures.dart b/stronglift_replacement/workout_app/test/widgets/_run_map_fixtures.dart new file mode 100644 index 0000000..be89ed8 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/_run_map_fixtures.dart @@ -0,0 +1,119 @@ +// Shared fixtures for the run map / run detail / history-runs tests. +// +// The map never gets a real tile provider here: [FakeTileProvider] serves a +// 1x1 transparent PNG (or an image that fails to load, to play "offline"), +// so no test can reach tile.openstreetmap.org. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/ui/theme.dart'; + +final _transparentPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA' + '60e6kgAAAABJRU5ErkJggg==', +); + +/// An image that always fails, as a tile does with no network and no cache. +class FailingImage extends ImageProvider { + /// Creates the failing image. + const FailingImage(); + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + FailingImage key, + ImageDecoderCallback decode, + ) => OneFrameImageStreamCompleter( + Future.error(StateError('no network')), + ); +} + +/// A [TileProvider] that never touches the network. +class FakeTileProvider extends TileProvider { + /// Serves transparent tiles, or failing ones when [offline]. + FakeTileProvider({this.offline = false}); + + /// Whether every tile fails to load. + final bool offline; + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => + offline ? const FailingImage() : MemoryImage(_transparentPng); +} + +/// Opener returning a fresh [FakeTileProvider]. +Future Function() fakeTiles({bool offline = false}) => + () async => FakeTileProvider(offline: offline); + +/// A track of [count] fixes heading north-east from Warsaw. +RunTrack buildTrack({ + int count = 5, + RunSport sport = RunSport.running, + RunSource source = RunSource.app, + DateTime? startedAt, + double distanceM = 5200, + int totalSeconds = 1930, +}) { + final start = startedAt ?? DateTime.utc(2026, 11, 10, 6, 30); + return RunTrack.started( + sport: sport, + startedAt: start, + totalSeconds: totalSeconds, + distanceM: distanceM, + source: source, + points: [ + for (var i = 0; i < count; i++) + RunPoint( + time: start.add(Duration(seconds: i * 10)), + latitude: 52.2297 + i * 0.001, + longitude: 21.0122 + i * 0.0015, + distanceM: i * 100.0, + ), + ], + ); +} + +/// Wraps [child] in the app theme, filling a phone-sized surface. +Widget themed(Widget child) => + MaterialApp(theme: buildAppTheme(), home: child); + +/// Runs [body] and returns every `debugPrint` line it wrote. +/// +/// Restored before returning: the test binding fails a test that leaves +/// `debugPrint` swapped, and it checks that before any tearDown runs. +Future> capturingDebugPrint(Future Function() body) async { + final lines = []; + final original = debugPrint; + debugPrint = (message, {wrapWidth}) => lines.add(message ?? ''); + try { + await body(); + } finally { + debugPrint = original; + } + return lines; +} + +/// Registers the SDK's Roboto as the test font, so widths mean something. +/// +/// The default test font draws every glyph one em wide; this is the same +/// loader as in `workout_layout_test.dart`. +Future loadRoboto() async { + final cache = File(Platform.resolvedExecutable).parent.parent.parent.parent; + final loader = FontLoader('Roboto'); + for (final weight in ['Regular', 'Medium', 'Bold']) { + final ttf = File( + '${cache.path}/artifacts/material_fonts/Roboto-$weight.ttf', + ); + loader.addFont(Future.value(ByteData.sublistView(ttf.readAsBytesSync()))); + } + await loader.load(); +} diff --git a/stronglift_replacement/workout_app/test/widgets/run_map_test.dart b/stronglift_replacement/workout_app/test/widgets/run_map_test.dart new file mode 100644 index 0000000..28b0b61 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/run_map_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/widgets/run_map.dart'; + +import '_run_map_fixtures.dart'; + +Future _pump(WidgetTester tester, Widget map) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(themed(map)); + await tester.pump(); + await tester.pump(); +} + +MapCamera _camera(WidgetTester tester) => + MapCamera.of(tester.element(find.byType(RichAttributionWidget))); + +void main() { + group('RunMap.route', () { + testWidgets('draws one polyline, start and end markers', (tester) async { + final track = buildTrack(); + await _pump( + tester, + RunMap.route(points: track.points, tileProviderOpener: fakeTiles()), + ); + final line = tester.widget(find.byType(PolylineLayer)); + expect(line.polylines, hasLength(1)); + expect(line.polylines.single.points, hasLength(track.points.length)); + final markers = tester.widget(find.byType(MarkerLayer)); + expect(markers.markers, hasLength(2)); + }); + + testWidgets('asks OSM with the real user agent package', (tester) async { + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: fakeTiles(), + ), + ); + final tiles = tester.widget(find.byType(TileLayer)); + expect(tiles.urlTemplate, contains('tile.openstreetmap.org')); + // TileLayer folds userAgentPackageName into the provider's headers. + expect( + tiles.tileProvider.headers['User-Agent'], + contains('com.kuhy.workout_app'), + ); + }); + + testWidgets('fits every point on screen', (tester) async { + final track = buildTrack(); + await _pump( + tester, + RunMap.route(points: track.points, tileProviderOpener: fakeTiles()), + ); + final camera = _camera(tester); + final area = Offset.zero & const Size(400, 600); + for (final p in track.points) { + final at = camera.latLngToScreenOffset( + LatLng(p.latitude, p.longitude), + ); + expect(area.contains(at), isTrue, reason: '$at is off screen'); + } + }); + + testWidgets('collapsed attribution shows no words until tapped', ( + tester, + ) async { + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: fakeTiles(), + ), + ); + // flutter_map keeps the credit in the tree and fades it in on tap, so + // "no visible words" means: fully transparent until the icon is tapped. + final credit = find.textContaining('OpenStreetMap'); + expect(find.byType(Text), findsOneWidget); + double opacity() => tester + .widget( + find + .ancestor(of: credit, matching: find.byType(AnimatedOpacity)) + .first, + ) + .opacity; + expect(opacity(), 0); + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + expect(opacity(), 1); + }); + + testWidgets('one point: a marker, no line, no crash', (tester) async { + await _pump( + tester, + RunMap.route( + points: buildTrack(count: 1).points, + tileProviderOpener: fakeTiles(), + ), + ); + expect(find.byType(PolylineLayer), findsNothing); + expect( + tester.widget(find.byType(MarkerLayer)).markers, + hasLength(1), + ); + }); + + testWidgets('no points: plain map, no layers', (tester) async { + await _pump( + tester, + RunMap.route(points: const [], tileProviderOpener: fakeTiles()), + ); + expect(find.byType(PolylineLayer), findsNothing); + expect(find.byType(MarkerLayer), findsNothing); + expect(find.byType(FlutterMap), findsOneWidget); + }); + + testWidgets('no tile layer until the provider has opened', (tester) async { + final opening = Completer(); + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: () => opening.future, + ), + ); + expect(find.byType(TileLayer), findsNothing); + expect(find.byType(PolylineLayer), findsOneWidget); + opening.complete(FakeTileProvider()); + await tester.pump(); + await tester.pump(); + expect(find.byType(TileLayer), findsOneWidget); + }); + + testWidgets('offline: route still drawn, one loud warning', (tester) async { + final lines = await capturingDebugPrint(() async { + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: fakeTiles(offline: true), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + }); + expect(find.byType(PolylineLayer), findsOneWidget); + final warnings = lines.where((l) => l.contains('WARNING')); + expect(warnings, hasLength(1), reason: lines.join('\n')); + expect(warnings.single, contains('plain background')); + }); + }); + + group('RunMap.live', () { + testWidgets('follows the last point and draws as the track grows', ( + tester, + ) async { + final track = ValueNotifier(null); + addTearDown(track.dispose); + await _pump( + tester, + RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()), + ); + expect(find.byType(PolylineLayer), findsNothing); + expect(find.byType(MarkerLayer), findsNothing); + + final full = buildTrack(count: 4); + track.value = full; + await tester.pump(); + expect(find.byType(PolylineLayer), findsOneWidget); + expect( + tester.widget(find.byType(MarkerLayer)).markers, + hasLength(2), + ); + final last = full.points.last; + final center = _camera(tester).center; + expect(center.latitude, closeTo(last.latitude, 1e-6)); + expect(center.longitude, closeTo(last.longitude, 1e-6)); + expect(find.byIcon(Icons.my_location), findsNothing); + }); + + testWidgets('a drag ends follow mode, the button resumes it', ( + tester, + ) async { + final full = buildTrack(count: 4); + final track = ValueNotifier(full); + addTearDown(track.dispose); + await _pump( + tester, + RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()), + ); + await tester.drag(find.byType(FlutterMap), const Offset(120, 0)); + await tester.pump(); + expect(find.byIcon(Icons.my_location), findsOneWidget); + final dragged = _camera(tester).center.longitude; + expect(dragged, isNot(closeTo(full.points.last.longitude, 1e-6))); + + // New fixes no longer pull the camera while follow is off. + track.value = buildTrack(count: 6); + await tester.pump(); + expect(_camera(tester).center.longitude, closeTo(dragged, 1e-9)); + + await tester.tap(find.byIcon(Icons.my_location)); + await tester.pump(); + expect(find.byIcon(Icons.my_location), findsNothing); + final last = track.value!.points.last; + expect(_camera(tester).center.longitude, closeTo(last.longitude, 1e-6)); + }); + + testWidgets('stops listening when disposed', (tester) async { + final track = ValueNotifier(buildTrack()); + addTearDown(track.dispose); + await _pump( + tester, + RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()), + ); + await tester.pumpWidget(const SizedBox()); + track.value = buildTrack(count: 7); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + }); +} From b174339979d6a929b413d0847c145b8d07e1b68c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:01:17 +0100 Subject: [PATCH 2/8] Add RunnerUp retirement logic Anchor = PC credit time of an app-authored run (joined on TCX end time in log.json), due 7 days later, gated on a validated import marker, then adb backup + uninstall. Typed results, --status/--dry-run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- screen_locker/_runnerup_retire.py | 249 +++++++++++++++++ screen_locker/_runnerup_retire_adb.py | 137 +++++++++ screen_locker/_runnerup_retire_state.py | 71 +++++ screen_locker/_runnerup_retire_tcx.py | 204 ++++++++++++++ screen_locker/_runnerup_tcx.py | 79 +++--- screen_locker/runnerup_retire.py | 95 +++++++ screen_locker/tests/_isolated_state.py | 16 ++ screen_locker/tests/_retire_helpers.py | 132 +++++++++ screen_locker/tests/test_runnerup_retire.py | 181 ++++++++++++ .../tests/test_runnerup_retire_adb.py | 188 +++++++++++++ .../tests/test_runnerup_retire_cli.py | 129 +++++++++ .../tests/test_runnerup_retire_tcx.py | 262 ++++++++++++++++++ 12 files changed, 1706 insertions(+), 37 deletions(-) create mode 100644 screen_locker/_runnerup_retire.py create mode 100644 screen_locker/_runnerup_retire_adb.py create mode 100644 screen_locker/_runnerup_retire_state.py create mode 100644 screen_locker/_runnerup_retire_tcx.py create mode 100644 screen_locker/runnerup_retire.py create mode 100644 screen_locker/tests/_retire_helpers.py create mode 100644 screen_locker/tests/test_runnerup_retire.py create mode 100644 screen_locker/tests/test_runnerup_retire_adb.py create mode 100644 screen_locker/tests/test_runnerup_retire_cli.py create mode 100644 screen_locker/tests/test_runnerup_retire_tcx.py diff --git a/screen_locker/_runnerup_retire.py b/screen_locker/_runnerup_retire.py new file mode 100644 index 0000000..d139c2b --- /dev/null +++ b/screen_locker/_runnerup_retire.py @@ -0,0 +1,249 @@ +"""Retire the stock RunnerUp app a week after the PC first credits an app run. + +kuhy's workout_app records runs itself now. Once the PC has credited one of +them, RunnerUp is redundant: seven days later (``GRACE``) it is backed up and +uninstalled, but only if the app has imported the RunnerUp history (a marker +the app writes) and that import matches what ``processed/`` holds. With no +anchor (no app run ever credited) it never uninstalls. + +The phone is not always on USB, so ``runnerup-retire.timer`` retries daily +until this succeeds. Every outcome is a typed :class:`RetireResult` whose +reason is logged at the level it deserves: ``info`` for the normal "not due +yet", ``warning`` for a skip that needs attention, ``error`` for a failure. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import UTC, datetime, timedelta +from enum import StrEnum +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from screen_locker._runnerup_retire_adb import PACKAGE, AdbError +from screen_locker._runnerup_retire_state import load_state, save_state +from screen_locker._runnerup_retire_tcx import check_marker, find_anchor + +if TYPE_CHECKING: + from screen_locker._runnerup_retire_adb import Phone + from screen_locker._runnerup_retire_state import RetireState + +_logger = logging.getLogger(__name__) + +GRACE = timedelta(days=7) +STATE_FILE = Path.home() / ".local" / "state" / "screen-locker" / "runnerup_retire.json" +WATCH_DIR = Path.home() / "data" / "cloud" / "RunnerUp" +LOG_FILE = Path(__file__).resolve().parent / "log.json" +MARKER_NAME = ".workout_app_import.json" + + +class Outcome(StrEnum): + """How a run ended; the value is what the journal shows.""" + + UNINSTALLED = "uninstalled" + ALREADY_GONE = "already-gone" + ALREADY_DONE = "already-done" + NO_ANCHOR = "no-anchor" + NOT_DUE = "not-due" + DRY_RUN = "dry-run" + DEVICE_ABSENT = "device-absent" + MARKER_INVALID = "marker-invalid" + ADB_MISSING = "adb-missing" + PHONE_ERROR = "phone-error" + BACKUP_FAILED = "backup-failed" + UNINSTALL_FAILED = "uninstall-failed" + + +# Level per outcome; the failures also make the unit exit non-zero. +_LEVEL = { + Outcome.DEVICE_ABSENT: logging.WARNING, + Outcome.MARKER_INVALID: logging.WARNING, + Outcome.ADB_MISSING: logging.ERROR, + Outcome.PHONE_ERROR: logging.ERROR, + Outcome.BACKUP_FAILED: logging.ERROR, + Outcome.UNINSTALL_FAILED: logging.ERROR, +} +_NONZERO = frozenset( + { + Outcome.MARKER_INVALID, + Outcome.ADB_MISSING, + Outcome.PHONE_ERROR, + Outcome.BACKUP_FAILED, + Outcome.UNINSTALL_FAILED, + } +) + + +@dataclass(frozen=True) +class RetireResult: + """What happened and why, in a sentence a human can act on.""" + + outcome: Outcome + reason: str + logged: bool = False # the failure was already logged where it was caught + + @property + def ok(self) -> bool: + """False for outcomes the unit should surface as failed.""" + return self.outcome not in _NONZERO + + +@dataclass(frozen=True) +class Paths: + """Every on-disk location, so tests (and ``--status``) can redirect them.""" + + state_file: Path + watch_dir: Path + log_file: Path + + @classmethod + def default(cls) -> Paths: + """The live locations (read at call time so tests can patch them).""" + return cls(STATE_FILE, WATCH_DIR, LOG_FILE) + + @property + def processed(self) -> Path: + """Where endurain-import files the RunnerUp exports it has handled.""" + return self.watch_dir / "processed" + + @property + def marker(self) -> Path: + """The app's historic-import marker.""" + return self.watch_dir / MARKER_NAME + + def anchor_dirs(self) -> tuple[Path, Path]: + """Where an app run's TCX can sit: the drop dir, then ``processed/``.""" + return (self.watch_dir, self.processed) + + +def derive_anchor(paths: Paths, state: RetireState) -> datetime | None: + """The stored anchor, else the first credit of an app run found on disk.""" + return state.anchor or find_anchor(paths.log_file, paths.anchor_dirs()) + + +def _finish(paths: Paths, state: RetireState, now: datetime, why: str) -> None: + save_state( + paths.state_file, replace(state, done_at=now.isoformat(), done_reason=why) + ) + + +def _probe(phone: Phone) -> RetireResult | tuple[str, bool]: + """``(serial, installed)`` if the phone answers, else the result explaining why.""" + if not phone.available(): + return RetireResult( + Outcome.ADB_MISSING, "adb is not installed (pacman -S android-tools)" + ) + try: + device = phone.find_device() + if device.serial is None: + return RetireResult( + Outcome.DEVICE_ABSENT, f"{device.reason}; retrying next run" + ) + return device.serial, phone.installed(device.serial) + except AdbError as exc: + _logger.exception("RunnerUp retirement: asking the phone failed") + return RetireResult( + Outcome.PHONE_ERROR, f"asking the phone failed: {exc}", logged=True + ) + + +def _backup( + paths: Paths, phone: Phone, state: RetireState, serial: str, now: datetime +) -> RetireState | RetireResult: + """Back the phone's exports up once; a later retry reuses a finished backup.""" + if state.backup_path and Path(state.backup_path).is_dir(): + return state + final = paths.watch_dir / f"runnerup_phone_backup_{now:%Y-%m-%d}" + try: + made, files = phone.backup(serial, final) + except (AdbError, OSError) as exc: + _logger.exception("RunnerUp retirement: backup failed") + return RetireResult(Outcome.BACKUP_FAILED, f"backup failed: {exc}", logged=True) + _logger.info("Backed up %d RunnerUp TCX files to %s", files, made) + state = replace(state, backup_path=str(made)) + save_state(paths.state_file, state) + return state + + +def _retire( + paths: Paths, phone: Phone, state: RetireState, now: datetime, *, dry_run: bool +) -> RetireResult: + """Everything after "it is due and the marker is good": the phone steps.""" + probed = _probe(phone) + if isinstance(probed, RetireResult): + return probed + serial, present = probed + if not present: + if not dry_run: + _finish(paths, state, now, f"{PACKAGE} was already absent") + return RetireResult(Outcome.ALREADY_GONE, f"{PACKAGE} is already absent") + if dry_run: + return RetireResult( + Outcome.DRY_RUN, "would back up and uninstall (no changes made)" + ) + backed = _backup(paths, phone, state, serial, now) + if isinstance(backed, RetireResult): + return backed + try: + phone.uninstall(serial) + except AdbError as exc: + _logger.exception("RunnerUp retirement: uninstall failed") + return RetireResult( + Outcome.UNINSTALL_FAILED, f"uninstall failed: {exc}", logged=True + ) + _finish(paths, backed, now, f"uninstalled after backup to {backed.backup_path}") + return RetireResult( + Outcome.UNINSTALLED, + f"{PACKAGE} uninstalled; backup in {backed.backup_path}", + ) + + +def _gate( + paths: Paths, state: RetireState, now: datetime, *, dry_run: bool +) -> RetireState | RetireResult: + """The no-phone checks; a state back means "due, marker good: go on".""" + if state.done_at: + return RetireResult( + Outcome.ALREADY_DONE, f"done {state.done_at}: {state.done_reason}" + ) + anchor = derive_anchor(paths, state) + if anchor is None: + return RetireResult( + Outcome.NO_ANCHOR, "no app-recorded run credited yet; nothing to retire" + ) + if not state.anchor_at: + state = replace(state, anchor_at=anchor.isoformat()) + if not dry_run: + save_state(paths.state_file, state) + due = anchor + GRACE + if now < due: + return RetireResult( + Outcome.NOT_DUE, f"due {due:%Y-%m-%d %H:%M} (anchor {anchor:%Y-%m-%d})" + ) + marker = check_marker(paths.marker, paths.processed) + if not marker.ok: + return RetireResult( + Outcome.MARKER_INVALID, f"{marker.reason}; NOT uninstalling" + ) + return state + + +def run_retire( + paths: Paths, phone: Phone, now: datetime | None = None, *, dry_run: bool = False +) -> RetireResult: + """One pass: uninstall RunnerUp if (and only if) every condition holds.""" + when = now or datetime.now(UTC) + gated = _gate(paths, load_state(paths.state_file), when, dry_run=dry_run) + if isinstance(gated, RetireResult): + result = gated + else: + result = _retire(paths, phone, gated, when, dry_run=dry_run) + if not result.logged: + _logger.log( + _LEVEL.get(result.outcome, logging.INFO), + "RunnerUp retirement: %s: %s", + result.outcome.value, + result.reason, + ) + return result diff --git a/screen_locker/_runnerup_retire_adb.py b/screen_locker/_runnerup_retire_adb.py new file mode 100644 index 0000000..54d25a7 --- /dev/null +++ b/screen_locker/_runnerup_retire_adb.py @@ -0,0 +1,137 @@ +"""The phone side of the RunnerUp retirement: find it, back it up, uninstall. + +Every call carries ``-s `` and goes through one ``run`` seam, so tests +never touch a device. adb's exit code is not trusted for the uninstall: it +counts only when the output says ``Success`` AND ``pm list packages`` no +longer lists the package. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from pathlib import Path +import shutil +import subprocess +from typing import TYPE_CHECKING, Any, NoReturn + +if TYPE_CHECKING: + from collections.abc import Callable + +_logger = logging.getLogger(__name__) + +PHONE_SERIAL = "23181JEGR08034" +PACKAGE = "org.runnerup.free" +REMOTE_DIR = "/sdcard/Documents/RunnerUp" +_ADB_TIMEOUT_S = 50 + + +class AdbError(RuntimeError): + """An adb call failed; the message says what and why.""" + + +def _fail(message: str) -> NoReturn: + raise AdbError(message) + + +@dataclass(frozen=True) +class Device: + """Outcome of looking for the phone: the adb serial to use, or why not.""" + + serial: str | None + reason: str + + +class Phone: + """adb calls against one phone, with the process runner injectable.""" + + def __init__( + self, + serial: str = PHONE_SERIAL, + *, + which: Callable[[str], str | None] = shutil.which, + run: Callable[..., Any] = subprocess.run, + ) -> None: + """Pin the phone's serial; ``which``/``run`` are the test seams.""" + self.serial = serial + self._which = which + self._run = run + + def available(self) -> bool: + """Whether an ``adb`` binary is on PATH.""" + return self._which("adb") is not None + + def _adb(self, *args: str) -> str: + cmd = ["adb", *args] + try: + done = self._run( + cmd, capture_output=True, text=True, timeout=_ADB_TIMEOUT_S, check=False + ) + except (OSError, subprocess.TimeoutExpired) as exc: + msg = f"{' '.join(cmd)} did not run: {exc}" + raise AdbError(msg) from exc + if done.returncode: + detail = (done.stdout + done.stderr).strip() + _fail(f"{' '.join(cmd)} exited {done.returncode}: {detail}") + return str(done.stdout) + + def find_device(self) -> Device: + """The attached phone, over USB or wifi (a wireless serial embeds it).""" + listing = self._adb("devices") + states: list[str] = [] + for line in listing.splitlines()[1:]: + serial, _, state = line.partition("\t") + if self.serial not in serial: + continue + if state.strip() == "device": + return Device(serial, "attached") + states.append(state.strip()) + if states: + return Device(None, f"phone is {'/'.join(states)}, not ready") + return Device(None, f"phone {self.serial} is not attached") + + def installed(self, dev: str) -> bool: + """Whether the package is on the phone (exact ``package:`` line).""" + out = self._adb("-s", dev, "shell", "pm", "list", "packages", PACKAGE) + return f"package:{PACKAGE}" in out.split() + + def backup(self, dev: str, final: Path) -> tuple[Path, int]: + """Pull the phone's export folder next to ``final``; return (dir, tcx count). + + Pulled into a staging dir and renamed only once every phone ``.tcx`` + is on disk, so a half backup never looks like a finished one. The + result is a subfolder, never top-level: a top-level ``*.tcx`` would be + credited by runnerup-watch. + """ + listing = self._adb("-s", dev, "shell", "ls", REMOTE_DIR) + wanted = sum(1 for n in listing.split() if n.endswith(".tcx")) + staging = final.with_name(final.name + ".partial") + shutil.rmtree(staging, ignore_errors=True) + staging.mkdir(parents=True) + self._adb("-s", dev, "pull", REMOTE_DIR, str(staging)) + pulled = staging / Path(REMOTE_DIR).name + got = len(list(pulled.rglob("*.tcx"))) if pulled.is_dir() else -1 + if got < wanted: + _fail(f"backup pulled {got} of {wanted} TCX files into {staging}") + target, n = final, 1 + while target.exists(): + n += 1 + target = final.with_name(f"{final.name}-{n}") + pulled.rename(target) + staging.rmdir() + return target, got + + def uninstall(self, dev: str) -> None: + """Uninstall the package and prove it is gone, or raise ``AdbError``.""" + try: + out = self._adb("-s", dev, "uninstall", PACKAGE) + except AdbError as exc: + # adb exits 1 with "Failure [DELETE_FAILED_...]": keep that reason + # (a device-owner policy can block it) in the error. + msg = f"uninstall refused: {exc}" + raise AdbError(msg) from exc + if "Success" not in out: + _fail(f"uninstall did not report Success: {out.strip()!r}") + if self.installed(dev): + _fail(f"{PACKAGE} is still listed after uninstall") + _logger.info("Uninstalled %s from %s", PACKAGE, dev) diff --git a/screen_locker/_runnerup_retire_state.py b/screen_locker/_runnerup_retire_state.py new file mode 100644 index 0000000..97186f0 --- /dev/null +++ b/screen_locker/_runnerup_retire_state.py @@ -0,0 +1,71 @@ +"""The retirement's own memory: the anchor, the backup, the done stamp. + +One small JSON file (path: ``_runnerup_retire.STATE_FILE``). Written +atomically and only by a real run (``--status`` and ``--dry-run`` never write), +so the file is also the proof that a run happened. The anchor is stored once +and never moved: a later, earlier-looking credit must not push the deadline. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +import json +import logging +import os +from pathlib import Path +import tempfile + +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class RetireState: + """What a previous run recorded (all ISO strings, empty until known).""" + + anchor_at: str = "" + backup_path: str = "" + done_at: str = "" + done_reason: str = "" + + @property + def anchor(self) -> datetime | None: + """The stored anchor as an aware datetime, or None.""" + return datetime.fromisoformat(self.anchor_at) if self.anchor_at else None + + +def load_state(path: Path) -> RetireState: + """Read the state; a missing file is a fresh start, a bad one is loud.""" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return RetireState() + except (OSError, ValueError) as exc: + _logger.warning( + "Retire state %s is unreadable (%s): starting from scratch, so the " + "anchor is re-derived and the uninstall re-checked", + path, + exc, + ) + return RetireState() + if not isinstance(raw, dict): + _logger.warning("Retire state %s is not a JSON object: ignored", path) + return RetireState() + fields = RetireState.__dataclass_fields__ + clean = {k: v for k, v in raw.items() if k in fields and isinstance(v, str)} + try: + state = RetireState(**clean) + _ = state.anchor + except ValueError as exc: + _logger.warning("Retire state %s holds a bad anchor (%s): ignored", path, exc) + return RetireState() + return state + + +def save_state(path: Path, state: RetireState) -> None: + """Atomically replace the state file (creates the directory on first write).""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".retire-", suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(asdict(state), handle, indent=2) + Path(tmp).replace(path) diff --git a/screen_locker/_runnerup_retire_tcx.py b/screen_locker/_runnerup_retire_tcx.py new file mode 100644 index 0000000..885da7a --- /dev/null +++ b/screen_locker/_runnerup_retire_tcx.py @@ -0,0 +1,204 @@ +"""What the RunnerUp retirement reads off disk: app runs, the marker, the anchor. + +Split from :mod:`screen_locker._runnerup_retire` (250-line cap). Everything +here is read-only; nothing touches the phone. + +Telling the two sources apart is done on the TCX *content* (```` or +```` named ``kuhy workout_app``, the app's ``kRunSourceName``), never +on the file name: the app may name its files however it likes. + +The anchor is "when the PC first CREDITED an app run". The credit chokepoint +(``log.json``) stores a run's TCX end as ``completed_at`` on its +``runnerup_verified`` entry and stamps the entry itself with the moment the PC +wrote it. So the anchor is the ``timestamp`` of the entry whose +``completed_at`` equals an app TCX's last trackpoint. Joining on the end time +(not the day) is what makes a same-day RunnerUp run, which fills the day's one +credit slot first, correctly yield no anchor for the app run. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import json +import logging +import re +from typing import TYPE_CHECKING, Any + +from screen_locker._log_io import load_workout_log +from screen_locker._runnerup_tcx import parse_tcx_file + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + +_logger = logging.getLogger(__name__) + +APP_SOURCE_NAME = "kuhy workout_app" +_APP_RE = re.compile( + rb"<(?:Author|Creator)\b[^>]*>\s*\s*" + + re.escape(APP_SOURCE_NAME.encode()) + + rb"\s*" +) +# The WebDAV copy of a RunnerUp export may carry the device name +# (RunnerUp_Pixel_6a_2026-...) that the phone's own copy lacks. +_DEVICE_PREFIX = re.compile(r"^RunnerUp_(?:[A-Za-z0-9]+_)*?(?=\d{4}-\d{2}-\d{2}-)") +_END_TOLERANCE_S = 1.0 + + +def run_key(name: str) -> str: + """A run's identity: its file name without any device-name prefix.""" + return _DEVICE_PREFIX.sub("RunnerUp_", name) + + +def list_tcx(directory: Path) -> list[Path]: + """Finished ``*.tcx`` files directly in ``directory`` (dotfiles are partial).""" + if not directory.is_dir(): + return [] + return sorted( + p for p in directory.glob("*.tcx") if p.is_file() and not p.name.startswith(".") + ) + + +def is_app_tcx(path: Path) -> bool: + """Whether the TCX names the new app as its author or creator.""" + try: + data = path.read_bytes() + except OSError as exc: + _logger.warning( + "Cannot read %s (%s): counting it as a RunnerUp file", path, exc + ) + return False + return bool(_APP_RE.search(data)) + + +def _ended_at(path: Path) -> float | None: + """The run's end exactly as the credit path computed it (same parser).""" + data = parse_tcx_file(str(path)) + if data is None or data.get("ended_at") is None: + _logger.warning("App TCX %s has no usable end time: it cannot anchor", path) + return None + return float(data["ended_at"]) + + +def _credited_at(entry: dict[str, Any]) -> datetime | None: + try: + stamp = datetime.fromisoformat(str(entry["timestamp"])) + except (KeyError, ValueError) as exc: + _logger.warning("Credit entry has no usable timestamp (%r): skipped", exc) + return None + return stamp if stamp.tzinfo else stamp.astimezone() + + +def _as_unix(raw: object) -> float | None: + """A unix time from a number or numeric string (the log stores both).""" + if isinstance(raw, int | float) and not isinstance(raw, bool): + return float(raw) + if isinstance(raw, str) and re.fullmatch(r"\d+(\.\d+)?", raw): + return float(raw) + return None + + +def _credit_ends(log_file: Path) -> list[tuple[float, datetime]]: + """``(TCX end, PC credit time)`` of every credited run that recorded its end.""" + found: list[tuple[float, datetime]] = [] + for entries in load_workout_log(log_file).values(): + for entry in entries: + data = entry.get("workout_data") or {} + if data.get("type") != "runnerup_verified": + continue + end = _as_unix(data.get("completed_at")) + if end is None: + continue # an older entry that never stored its run's end + when = _credited_at(entry) + if when is not None: + found.append((end, when)) + return found + + +def find_anchor(log_file: Path, dirs: Iterable[Path]) -> datetime | None: + """When the PC first credited an app-recorded run, or None if it never did.""" + ends = [ + end + for directory in dirs + for path in list_tcx(directory) + if is_app_tcx(path) and (end := _ended_at(path)) is not None + ] + if not ends: + return None + credited = [ + when + for run_end, when in _credit_ends(log_file) + if any(abs(run_end - end) <= _END_TOLERANCE_S for end in ends) + ] + return min(credited) if credited else None + + +@dataclass(frozen=True) +class MarkerCheck: + """Verdict on the app's historic-import marker.""" + + ok: bool + reason: str + + +def _bad(reason: str) -> MarkerCheck: + return MarkerCheck(ok=False, reason=reason) + + +def _marker_names(marker: object) -> tuple[list[str] | None, str]: + """The marker's ``tcx_names`` if its shape is valid, else a reason.""" + if not isinstance(marker, dict): + return None, "marker is not a JSON object" + names, count = marker.get("tcx_names"), marker.get("count") + if not isinstance(marker.get("completed_at"), str) or not marker["completed_at"]: + return None, "marker has no completed_at" + if not isinstance(names, list) or not all(isinstance(n, str) for n in names): + return None, "marker tcx_names is not a list of names" + if isinstance(count, bool) or not isinstance(count, int) or count != len(names): + return None, f"marker count {count!r} != {len(names)} listed names" + if any("/" in n or n.startswith(".") for n in names): + return None, "marker tcx_names must be bare file names" + return names, "" + + +def _read_marker(marker_file: Path) -> tuple[object, str]: + """The parsed marker JSON, or ``(None, why it could not be read)``.""" + try: + return json.loads(marker_file.read_text(encoding="utf-8")), "" + except FileNotFoundError: + why = f"{marker_file} is missing: the app has not imported yet" + except (OSError, ValueError) as exc: + why = f"{marker_file} is unreadable ({exc})" + _logger.warning("Import marker problem: %s", why) + return None, why + + +def _compare(names: list[str], processed: Path) -> MarkerCheck: + listed = {run_key(n) for n in names} + if len(listed) != len(names): + return _bad("marker lists the same run more than once") + have = {run_key(p.name) for p in list_tcx(processed) if not is_app_tcx(p)} + if absent := sorted(listed - have): + return _bad(f"{len(absent)} listed run(s) not in processed/: {absent[:3]}") + if unlisted := sorted(have - listed): + return _bad( + f"{len(unlisted)} RunnerUp run(s) in processed/ are not in the " + f"marker ({unlisted[:3]}): re-run the app's import" + ) + return MarkerCheck(ok=True, reason=f"{len(listed)} runs match") + + +def check_marker(marker_file: Path, processed: Path) -> MarkerCheck: + """Validate the app's import marker against what ``processed/`` really holds. + + Valid means: the file parses, ``count`` equals the number of listed names, + and the listed runs are exactly the RunnerUp-recorded (non-app) runs in + ``processed/``. A run is counted once however many copies exist (the + device-prefixed WebDAV twin and the phone-named copy are one run). + """ + marker, why = _read_marker(marker_file) + if why: + return _bad(why) + names, reason = _marker_names(marker) + return _compare(names, processed) if names is not None else _bad(reason) diff --git a/screen_locker/_runnerup_tcx.py b/screen_locker/_runnerup_tcx.py index 0727478..bca4539 100644 --- a/screen_locker/_runnerup_tcx.py +++ b/screen_locker/_runnerup_tcx.py @@ -65,48 +65,53 @@ class RunnerUpTcxMixin: shutil.rmtree(tmp_dir, ignore_errors=True) def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None: - """Parse a local TCX file and return activity summary dict. + """Parse a local TCX file (see :func:`parse_tcx_file`).""" + return parse_tcx_file(tcx_path) - Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so - multi-segment runs (pause/resume) are counted in full. - """ - try: - tree = ET.parse(tcx_path) - except ET.ParseError as exc: - _logger.warning( - "TCX file %s is not valid XML (%s) — the run it describes CANNOT " - "be verified and will not count", - tcx_path, - exc, - ) - return None - root = tree.getroot() - activity = root.find(f".//{{{_TCX_NS}}}Activity") - if activity is None: - _logger.info("No Activity element in TCX file") - return None +def parse_tcx_file(tcx_path: str) -> dict[str, Any] | None: + """Parse a local TCX file and return activity summary dict. - sport_str = activity.get("Sport", "") - sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1) + Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so + multi-segment runs (pause/resume) are counted in full. + """ + try: + tree = ET.parse(tcx_path) + except ET.ParseError as exc: + _logger.warning( + "TCX file %s is not valid XML (%s) — the run it describes CANNOT " + "be verified and will not count", + tcx_path, + exc, + ) + return None - total_seconds = 0.0 - total_distance = 0.0 - for lap in activity.findall(f"{{{_TCX_NS}}}Lap"): - t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds") - d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters") - if t_elem is not None and t_elem.text: - total_seconds += float(t_elem.text) - if d_elem is not None and d_elem.text: - total_distance += float(d_elem.text) + root = tree.getroot() + activity = root.find(f".//{{{_TCX_NS}}}Activity") + if activity is None: + _logger.info("No Activity element in TCX file") + return None - return { - "sport": sport_int, - "duration_seconds": int(total_seconds), - "distance_m": total_distance, - "walking": is_walking_export(Path(tcx_path).name, sport_str), - **track_summary(activity), - } + sport_str = activity.get("Sport", "") + sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1) + + total_seconds = 0.0 + total_distance = 0.0 + for lap in activity.findall(f"{{{_TCX_NS}}}Lap"): + t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds") + d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters") + if t_elem is not None and t_elem.text: + total_seconds += float(t_elem.text) + if d_elem is not None and d_elem.text: + total_distance += float(d_elem.text) + + return { + "sport": sport_int, + "duration_seconds": int(total_seconds), + "distance_m": total_distance, + "walking": is_walking_export(Path(tcx_path).name, sport_str), + **track_summary(activity), + } def _mark_walk(data: dict[str, Any] | None, name: str) -> dict[str, Any] | None: diff --git a/screen_locker/runnerup_retire.py b/screen_locker/runnerup_retire.py new file mode 100644 index 0000000..646eaeb --- /dev/null +++ b/screen_locker/runnerup_retire.py @@ -0,0 +1,95 @@ +"""``python3 -m screen_locker.runnerup_retire``: uninstall RunnerUp, a week on. + +What ``runnerup-retire.service`` runs. ``--status`` prints anchor, due date, +marker validity and (with ``--query-phone``) whether RunnerUp is installed; +it never writes and never talks to the phone unless asked. ``--dry-run`` runs +every check, including the read-only adb queries, but writes nothing, pulls +nothing and uninstalls nothing. The exit code is non-zero only for outcomes +that need a human (bad marker, adb missing, failed backup or uninstall). +""" + +from __future__ import annotations + +import argparse +from datetime import UTC, datetime +import logging +import sys + +from screen_locker._runnerup_retire import ( + GRACE, + Paths, + derive_anchor, + run_retire, +) +from screen_locker._runnerup_retire_adb import PHONE_SERIAL, AdbError, Phone +from screen_locker._runnerup_retire_state import load_state +from screen_locker._runnerup_retire_tcx import check_marker + +__all__ = ["main", "status_lines"] + +_logger = logging.getLogger(__name__) + + +def _installed_line(phone: Phone, *, query: bool) -> str: + if not query: + return "installed: not queried (add --query-phone)" + try: + device = phone.find_device() + if device.serial is None: + return f"installed: unknown ({device.reason})" + return f"installed: {'yes' if phone.installed(device.serial) else 'no'}" + except AdbError as exc: + _logger.warning("--status could not ask the phone: %s", exc) + return f"installed: unknown ({exc})" + + +def status_lines( + paths: Paths, phone: Phone, now: datetime, *, query_phone: bool = False +) -> list[str]: + """The ``--status`` report. Read-only: nothing is created or saved.""" + state = load_state(paths.state_file) + anchor = derive_anchor(paths, state) + if anchor is None: + anchor_line, due_line = "anchor: none (no app run credited yet)", "due: never" + else: + origin = "stored" if state.anchor_at else "derived, not yet stored" + due = anchor + GRACE + days = (due - now).total_seconds() / 86400 + anchor_line = f"anchor: {anchor.isoformat()} ({origin})" + due_line = f"due: {due:%Y-%m-%d %H:%M} ({days:+.1f} days)" + marker = check_marker(paths.marker, paths.processed) + return [ + anchor_line, + due_line, + f"marker: {'valid' if marker.ok else 'INVALID'} ({marker.reason})", + f"done: {state.done_at or 'no'}", + _installed_line(phone, query=query_phone), + ] + + +def main(argv: list[str] | None = None) -> int: + """CLI entry; returns the process exit code.""" + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument( + "--status", action="store_true", help="print state, change nothing" + ) + parser.add_argument( + "--dry-run", action="store_true", help="check everything, change nothing" + ) + parser.add_argument( + "--query-phone", action="store_true", help="with --status: ask adb" + ) + parser.add_argument("--serial", default=PHONE_SERIAL, help="phone adb serial") + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + paths, phone = Paths.default(), Phone(args.serial) + if args.status: + now = datetime.now(UTC) + lines = status_lines(paths, phone, now, query_phone=args.query_phone) + sys.stdout.write("\n".join(lines) + "\n") + return 0 + return 0 if run_retire(paths, phone, dry_run=args.dry_run).ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/screen_locker/tests/_isolated_state.py b/screen_locker/tests/_isolated_state.py index 79995ed..8284313 100644 --- a/screen_locker/tests/_isolated_state.py +++ b/screen_locker/tests/_isolated_state.py @@ -105,4 +105,20 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( "workday_penalty.json", ("_constants.WORKDAY_PENALTY_FILE", "_workday_penalty.WORKDAY_PENALTY_FILE"), ), + # RunnerUp retirement: the anchor/done stamp, the dufs drop directory it + # reads the marker and TCX files from (and writes the phone backup into), + # and the credit log the anchor is derived from. A test must never see, or + # create files under, the user's real RunnerUp folder. + ( + "runnerup_retire.json", + ("_runnerup_retire.STATE_FILE",), + ), + ( + "RunnerUp", + ("_runnerup_retire.WATCH_DIR",), + ), + ( + "retire_log.json", + ("_runnerup_retire.LOG_FILE",), + ), ) diff --git a/screen_locker/tests/_retire_helpers.py b/screen_locker/tests/_retire_helpers.py new file mode 100644 index 0000000..50798c0 --- /dev/null +++ b/screen_locker/tests/_retire_helpers.py @@ -0,0 +1,132 @@ +"""Builders for the RunnerUp retirement tests: TCX files, marker, credit log.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +import json +from typing import TYPE_CHECKING, Any + +from screen_locker._runnerup_retire import Paths +from screen_locker._runnerup_retire_adb import AdbError, Device + +if TYPE_CHECKING: + from pathlib import Path + +_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" +APP = "kuhy workout_app" +# 2026-11-01T10:30:00Z, the end of every TCX built by default. +END_UNIX = 1793529000.0 + + +def tcx_text( + *, author: str = "RunnerUp", creator: str = "", end: str = "2026-11-01T10:30:00Z" +) -> str: + """A tiny TCX: two trackpoints (the last is the run's end) and an author.""" + creator_xml = f"{creator}" if creator else "" + return ( + f'' + '2026-11-01T10:00:00Z' + "1800" + "5000" + "" + "0" + f"5000" + f"{creator_xml}" + f'{author}' + "" + ) + + +def put_tcx(folder: Path, name: str, **kwargs: str) -> Path: + """Write a TCX named ``name`` into ``folder`` (created if needed).""" + folder.mkdir(parents=True, exist_ok=True) + path = folder / name + path.write_text(tcx_text(**kwargs), encoding="utf-8") + return path + + +def put_marker(watch: Path, names: list[str], count: int | None = None) -> Path: + """Write the app's import marker into the drop folder.""" + watch.mkdir(parents=True, exist_ok=True) + marker = watch / ".workout_app_import.json" + body = { + "completed_at": "2026-11-02T08:00:00Z", + "count": len(names) if count is None else count, + "tcx_names": names, + } + marker.write_text(json.dumps(body), encoding="utf-8") + return marker + + +def put_log(log_file: Path, entries: dict[str, list[dict[str, Any]]]) -> None: + """Write a day-keyed credit log.""" + log_file.write_text(json.dumps(entries), encoding="utf-8") + + +def credit( + timestamp: str, completed_at: object = None, kind: str = "runnerup_verified" +) -> dict[str, Any]: + """One log entry as the credit chokepoint writes it.""" + data: dict[str, Any] = {"type": kind} + if completed_at is not None: + data["completed_at"] = completed_at + return {"timestamp": timestamp, "workout_data": data} + + +ANCHOR = datetime(2026, 11, 1, 10, 45, tzinfo=UTC) +DUE = ANCHOR + timedelta(days=7) +RUN = "RunnerUp_2026-08-12-17-42-11_Running.tcx" + + +@dataclass +class FakePhone: + """Records what the retirement asked of the phone.""" + + adb: bool = True + device: Device = field(default_factory=lambda: Device("S", "attached")) + present: bool = True + ask_error: AdbError | None = None + backup_error: AdbError | OSError | None = None + uninstall_error: AdbError | None = None + calls: list[str] = field(default_factory=list) + + def available(self) -> bool: + return self.adb + + def find_device(self) -> Device: + if self.ask_error: + raise self.ask_error + return self.device + + def installed(self, dev: str) -> bool: + self.calls.append(f"installed {dev}") + return self.present + + def backup(self, dev: str, final: Path) -> tuple[Path, int]: + self.calls.append("backup") + if self.backup_error: + raise self.backup_error + final.mkdir(parents=True) + return final, 3 + + def uninstall(self, dev: str) -> None: + self.calls.append("uninstall") + if self.uninstall_error: + raise self.uninstall_error + + +def world(tmp_path: Path, *, anchored: bool = True, marker: bool = True) -> Paths: + """A drop folder with one RunnerUp run, one app run, and the credit log.""" + paths = Paths( + tmp_path / "state" / "s.json", tmp_path / "drop", tmp_path / "log.json" + ) + put_tcx(paths.processed, RUN) + put_tcx(paths.processed, "app.tcx", author=APP) + put_log( + paths.log_file, + {"d": [credit(ANCHOR.isoformat(), END_UNIX)]} if anchored else {}, + ) + if marker: + put_marker(paths.watch_dir, [RUN]) + return paths diff --git a/screen_locker/tests/test_runnerup_retire.py b/screen_locker/tests/test_runnerup_retire.py new file mode 100644 index 0000000..1ccbfd0 --- /dev/null +++ b/screen_locker/tests/test_runnerup_retire.py @@ -0,0 +1,181 @@ +"""RunnerUp retirement: when it fires, what it refuses, and what it records.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import TYPE_CHECKING + +import pytest + +from screen_locker import _runnerup_retire as rr +from screen_locker._runnerup_retire import Outcome, Paths, run_retire +from screen_locker._runnerup_retire_adb import AdbError, Device +from screen_locker._runnerup_retire_state import RetireState, load_state, save_state +from screen_locker.tests._retire_helpers import ( + ANCHOR, + DUE, + RUN, + FakePhone, + put_marker, + world, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def go(paths: Paths, phone: FakePhone, now: datetime = DUE, **kw: bool): + return run_retire(paths, phone, now, **kw) # type: ignore[arg-type] + + +def test_no_anchor_never_touches_anything(tmp_path: Path) -> None: + paths = world(tmp_path, anchored=False) + phone = FakePhone() + result = go(paths, phone, DUE + timedelta(days=900)) + assert result.outcome is Outcome.NO_ANCHOR + assert result.ok + assert phone.calls == [] + assert not paths.state_file.exists() + + +def test_not_due_persists_the_anchor_and_says_when( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + paths = world(tmp_path) + with caplog.at_level(logging.INFO): + result = go(paths, FakePhone(), DUE - timedelta(minutes=1)) + assert result.outcome is Outcome.NOT_DUE + assert "2026-11-08 10:45" in result.reason + assert load_state(paths.state_file).anchor == ANCHOR + assert [r.levelno for r in caplog.records] == [logging.INFO] + + +def test_stored_anchor_wins_over_the_log(tmp_path: Path) -> None: + paths = world(tmp_path, anchored=False) + save_state(paths.state_file, RetireState(anchor_at=ANCHOR.isoformat())) + assert go(paths, FakePhone(), ANCHOR).outcome is Outcome.NOT_DUE + + +def test_dry_run_writes_nothing_and_stops_before_the_phone_changes( + tmp_path: Path, +) -> None: + paths = world(tmp_path) + phone = FakePhone() + result = go(paths, phone, dry_run=True) + assert result.outcome is Outcome.DRY_RUN + assert phone.calls == ["installed S"] + assert not paths.state_file.exists() + gone = go(paths, FakePhone(present=False), dry_run=True) + assert gone.outcome is Outcome.ALREADY_GONE + assert not paths.state_file.exists() + + +@pytest.mark.parametrize("variant", ["missing", "mismatch"]) +def test_invalid_marker_blocks_the_uninstall_loudly( + tmp_path: Path, caplog: pytest.LogCaptureFixture, variant: str +) -> None: + paths = world(tmp_path, marker=variant == "mismatch") + if variant == "mismatch": + put_marker(paths.watch_dir, [RUN], count=2) + phone = FakePhone() + with caplog.at_level(logging.WARNING): + result = go(paths, phone) + assert result.outcome is Outcome.MARKER_INVALID + assert not result.ok + assert "NOT uninstalling" in result.reason + assert phone.calls == [] + assert caplog.records[-1].levelno == logging.WARNING + + +def test_adb_missing_is_a_failure(tmp_path: Path) -> None: + result = go(world(tmp_path), FakePhone(adb=False)) + assert result.outcome is Outcome.ADB_MISSING + assert not result.ok + + +def test_absent_phone_retries_quietly_but_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + phone = FakePhone(device=Device(None, "phone S is not attached")) + with caplog.at_level(logging.WARNING): + result = go(world(tmp_path), phone) + assert result.outcome is Outcome.DEVICE_ABSENT + assert result.ok + assert "not attached" in caplog.text + assert phone.calls == [] + + +def test_adb_error_while_asking_is_a_logged_failure( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + phone = FakePhone(ask_error=AdbError("server died")) + with caplog.at_level(logging.ERROR): + result = go(world(tmp_path), phone) + assert result.outcome is Outcome.PHONE_ERROR + assert not result.ok + assert len(caplog.records) == 1 # logged once, where it was caught + + +def test_already_uninstalled_is_recorded_done(tmp_path: Path) -> None: + paths = world(tmp_path) + result = go(paths, FakePhone(present=False)) + assert result.outcome is Outcome.ALREADY_GONE + assert load_state(paths.state_file).done_at + assert go(paths, FakePhone()).outcome is Outcome.ALREADY_DONE + + +def test_full_run_backs_up_then_uninstalls_then_never_again(tmp_path: Path) -> None: + paths = world(tmp_path) + phone = FakePhone() + result = go(paths, phone) + assert result.outcome is Outcome.UNINSTALLED + assert phone.calls == ["installed S", "backup", "uninstall"] + backup = paths.watch_dir / "runnerup_phone_backup_2026-11-08" + assert backup.is_dir() + assert list(paths.watch_dir.glob("*.tcx")) == [] # nothing top-level + state = load_state(paths.state_file) + assert state.anchor == ANCHOR + assert state.backup_path == str(backup) + assert state.done_at + again = FakePhone() + assert go(paths, again).outcome is Outcome.ALREADY_DONE + assert again.calls == [] + + +@pytest.mark.parametrize("error", [AdbError("short pull"), OSError("disk full")]) +def test_failed_backup_blocks_the_uninstall( + tmp_path: Path, caplog: pytest.LogCaptureFixture, error: Exception +) -> None: + phone = FakePhone(backup_error=error) # type: ignore[arg-type] + with caplog.at_level(logging.ERROR): + result = go(world(tmp_path), phone) + assert result.outcome is Outcome.BACKUP_FAILED + assert not result.ok + assert "uninstall" not in phone.calls + assert str(error) in caplog.text + + +def test_failed_uninstall_keeps_the_backup_for_the_retry(tmp_path: Path) -> None: + paths = world(tmp_path) + first = FakePhone(uninstall_error=AdbError("DELETE_FAILED_DEVICE_POLICY_MANAGER")) + result = go(paths, first) + assert result.outcome is Outcome.UNINSTALL_FAILED + assert not result.ok + assert not load_state(paths.state_file).done_at + retry = FakePhone() + assert go(paths, retry, DUE + timedelta(days=1)).outcome is Outcome.UNINSTALLED + assert retry.calls == ["installed S", "uninstall"] # no second backup + + +def test_default_paths_are_the_module_constants() -> None: + paths = Paths.default() + assert paths.state_file == rr.STATE_FILE + assert paths.marker == rr.WATCH_DIR / rr.MARKER_NAME + assert paths.processed == rr.WATCH_DIR / "processed" + assert paths.anchor_dirs() == (rr.WATCH_DIR, rr.WATCH_DIR / "processed") + + +def test_run_retire_defaults_to_the_real_clock(tmp_path: Path) -> None: + paths = world(tmp_path) + assert run_retire(paths, FakePhone(), dry_run=True).outcome is Outcome.DRY_RUN diff --git a/screen_locker/tests/test_runnerup_retire_adb.py b/screen_locker/tests/test_runnerup_retire_adb.py new file mode 100644 index 0000000..3786a89 --- /dev/null +++ b/screen_locker/tests/test_runnerup_retire_adb.py @@ -0,0 +1,188 @@ +"""RunnerUp retirement: the adb wrapper and the state file (no real adb).""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +from types import SimpleNamespace +from typing import Any + +import pytest + +from screen_locker import _runnerup_retire_adb as ra +from screen_locker._runnerup_retire_state import RetireState, load_state, save_state + +SERIAL = ra.PHONE_SERIAL +PKG_LINE = f"package:{ra.PACKAGE}\n" + + +class FakeAdb: + """A ``subprocess.run`` stand-in: replies by the adb sub-command.""" + + def __init__(self, replies: dict[str, Any] | None = None) -> None: + self.replies = replies or {} + self.calls: list[list[str]] = [] + + def __call__(self, cmd: list[str], **_: object) -> SimpleNamespace: + self.calls.append(cmd) + for key, reply in self.replies.items(): + if key in " ".join(cmd): + if isinstance(reply, BaseException): + raise reply + code, out, err = reply(cmd) if callable(reply) else reply + return SimpleNamespace(returncode=code, stdout=out, stderr=err) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + +def phone(fake: FakeAdb, adb: str | None = "/usr/bin/adb") -> ra.Phone: + return ra.Phone(run=fake, which=lambda _name: adb) + + +def test_available_follows_which() -> None: + assert phone(FakeAdb()).available() is True + assert phone(FakeAdb(), adb=None).available() is False + + +@pytest.mark.parametrize( + "boom", [FileNotFoundError("adb"), subprocess.TimeoutExpired("adb", 1)] +) +def test_adb_failing_to_run_is_an_adb_error(boom: BaseException) -> None: + with pytest.raises(ra.AdbError, match="did not run"): + phone(FakeAdb({"devices": boom})).find_device() + + +def test_nonzero_exit_carries_the_output() -> None: + fake = FakeAdb({"devices": (1, "", "daemon not running")}) + with pytest.raises(ra.AdbError, match="daemon not running"): + phone(fake).find_device() + + +@pytest.mark.parametrize( + ("listing", "serial", "reason"), + [ + (f"List of devices attached\n{SERIAL}\tdevice\n", SERIAL, "attached"), + ( + f"List of devices attached\nadb-{SERIAL}-x._adb-tls-connect._tcp\tdevice\n", + f"adb-{SERIAL}-x._adb-tls-connect._tcp", + "attached", + ), + (f"List of devices attached\n{SERIAL}\tunauthorized\n", None, "unauthorized"), + ("List of devices attached\nOTHER\tdevice\n", None, "not attached"), + ("List of devices attached\n", None, "not attached"), + ], +) +def test_find_device(listing: str, serial: str | None, reason: str) -> None: + device = phone(FakeAdb({"devices": (0, listing, "")})).find_device() + assert device.serial == serial + assert reason in device.reason + + +def test_installed_needs_the_exact_package_line() -> None: + yes = FakeAdb({"pm list": (0, PKG_LINE, "")}) + assert phone(yes).installed("dev") is True + assert yes.calls[0][:3] == ["adb", "-s", "dev"] + other = FakeAdb({"pm list": (0, f"package:{ra.PACKAGE}.pro\n", "")}) + assert phone(other).installed("dev") is False + + +def _pull_creates(count: int, root: str = "RunnerUp"): + def make(cmd: list[str]) -> tuple[int, str, str]: + folder = Path(cmd[-1]) / root + folder.mkdir(parents=True) + for i in range(count): + (folder / f"r{i}.tcx").write_text("x") + return 0, "", "" + + return make + + +def test_backup_stages_then_renames_and_counts(tmp_path: Path) -> None: + fake = FakeAdb({"ls": (0, "a.tcx\nb.tcx\nb.gpx\n", ""), "pull": _pull_creates(2)}) + final = tmp_path / "backup_2026" + target, count = phone(fake).backup("dev", final) + assert (target, count) == (final, 2) + assert len(list(final.glob("*.tcx"))) == 2 + assert not (tmp_path / "backup_2026.partial").exists() + + +def test_backup_never_overwrites_an_existing_folder(tmp_path: Path) -> None: + final = tmp_path / "backup" + final.mkdir() + (tmp_path / "backup.partial").mkdir() # a stale half-pull is cleared + fake = FakeAdb({"ls": (0, "a.tcx\n", ""), "pull": _pull_creates(1)}) + target, _ = phone(fake).backup("dev", final) + assert target == tmp_path / "backup-2" + + +def test_backup_short_pull_is_an_error_and_not_renamed(tmp_path: Path) -> None: + fake = FakeAdb({"ls": (0, "a.tcx\nb.tcx\n", ""), "pull": _pull_creates(1)}) + with pytest.raises(ra.AdbError, match="pulled 1 of 2"): + phone(fake).backup("dev", tmp_path / "b") + assert not (tmp_path / "b").exists() + + +def test_backup_pull_that_creates_nothing_is_an_error(tmp_path: Path) -> None: + fake = FakeAdb({"ls": (0, "a.tcx\n", "")}) + with pytest.raises(ra.AdbError, match="pulled -1 of 1"): + phone(fake).backup("dev", tmp_path / "b") + + +def test_uninstall_succeeds_only_when_the_package_is_gone() -> None: + fake = FakeAdb({"uninstall": (0, "Success\n", ""), "pm list": (0, "", "")}) + phone(fake).uninstall("dev") + assert ["adb", "-s", "dev", "uninstall", ra.PACKAGE] in fake.calls + + +def test_uninstall_refusal_keeps_adbs_reason() -> None: + fail = "Failure [DELETE_FAILED_DEVICE_POLICY_MANAGER]" + fake = FakeAdb({"uninstall": (1, fail, "")}) + with pytest.raises(ra.AdbError, match="DELETE_FAILED_DEVICE_POLICY_MANAGER"): + phone(fake).uninstall("dev") + + +def test_uninstall_without_success_text_is_an_error() -> None: + fake = FakeAdb({"uninstall": (0, "weird\n", "")}) + with pytest.raises(ra.AdbError, match="did not report Success"): + phone(fake).uninstall("dev") + + +def test_uninstall_that_leaves_the_package_is_an_error() -> None: + fake = FakeAdb({"uninstall": (0, "Success\n", ""), "pm list": (0, PKG_LINE, "")}) + with pytest.raises(ra.AdbError, match="still listed"): + phone(fake).uninstall("dev") + + +def test_state_roundtrip_and_anchor(tmp_path: Path) -> None: + path = tmp_path / "deep" / "s.json" + assert load_state(path) == RetireState() + save_state(path, RetireState(anchor_at="2026-11-01T10:45:00+00:00")) + assert load_state(path).anchor is not None + assert RetireState().anchor is None + + +@pytest.mark.parametrize( + "body", ["{nope", "[]", json.dumps({"anchor_at": "not a date"})] +) +def test_bad_state_is_loud_and_ignored( + tmp_path: Path, body: str, caplog: pytest.LogCaptureFixture +) -> None: + path = tmp_path / "s.json" + path.write_text(body) + with caplog.at_level("WARNING"): + assert load_state(path) == RetireState() + assert "Retire state" in caplog.text + + +def test_unreadable_state_is_loud( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level("WARNING"): + assert load_state(tmp_path) == RetireState() + assert "unreadable" in caplog.text + + +def test_state_ignores_unknown_and_non_string_fields(tmp_path: Path) -> None: + path = tmp_path / "s.json" + path.write_text(json.dumps({"done_at": 5, "extra": "x", "backup_path": "/b"})) + assert load_state(path) == RetireState(backup_path="/b") diff --git a/screen_locker/tests/test_runnerup_retire_cli.py b/screen_locker/tests/test_runnerup_retire_cli.py new file mode 100644 index 0000000..9b5a2ea --- /dev/null +++ b/screen_locker/tests/test_runnerup_retire_cli.py @@ -0,0 +1,129 @@ +"""RunnerUp retirement CLI: ``--status`` is read-only, exit codes follow outcomes.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +import pytest + +from screen_locker import runnerup_retire as cli +from screen_locker._runnerup_retire import Outcome, Paths, RetireResult +from screen_locker._runnerup_retire_adb import AdbError, Device +from screen_locker._runnerup_retire_state import RetireState, save_state +from screen_locker.tests._retire_helpers import put_marker +from screen_locker.tests.test_runnerup_retire import ANCHOR, DUE, RUN, FakePhone, world + +if TYPE_CHECKING: + from pathlib import Path + + +def _tree(root: Path) -> set[str]: + return {str(p.relative_to(root)) for p in root.rglob("*")} + + +def test_status_without_anchor(tmp_path: Path) -> None: + paths = world(tmp_path, anchored=False, marker=False) + lines = cli.status_lines(paths, FakePhone(), DUE) # type: ignore[arg-type] + assert lines[0].startswith("anchor: none") + assert lines[1] == "due: never" + assert "INVALID" in lines[2] + assert lines[3] == "done: no" + assert "not queried" in lines[4] + + +def test_status_is_strictly_read_only_and_marks_a_derived_anchor( + tmp_path: Path, +) -> None: + paths = world(tmp_path) + before = _tree(tmp_path) + lines = cli.status_lines(paths, FakePhone(), DUE - timedelta(days=1)) # type: ignore[arg-type] + assert _tree(tmp_path) == before + assert not paths.state_file.parent.exists() + assert "derived, not yet stored" in lines[0] + assert "2026-11-08 10:45 (+1.0 days)" in lines[1] + assert lines[2] == "marker: valid (1 runs match)" + + +def test_status_with_stored_anchor_and_done(tmp_path: Path) -> None: + paths = world(tmp_path, anchored=False) + save_state( + paths.state_file, + RetireState(anchor_at=ANCHOR.isoformat(), done_at="2026-11-09T00:00:00+00:00"), + ) + lines = cli.status_lines(paths, FakePhone(), DUE) # type: ignore[arg-type] + assert "(stored)" in lines[0] + assert lines[3] == "done: 2026-11-09T00:00:00+00:00" + + +@pytest.mark.parametrize( + ("phone", "expected"), + [ + (FakePhone(present=True), "installed: yes"), + (FakePhone(present=False), "installed: no"), + ( + FakePhone(device=Device(None, "phone S is not attached")), + "installed: unknown (phone S is not attached)", + ), + (FakePhone(ask_error=AdbError("boom")), "installed: unknown (boom)"), + ], +) +def test_status_query_phone(tmp_path: Path, phone: FakePhone, expected: str) -> None: + paths = world(tmp_path) + lines = cli.status_lines(paths, phone, DUE, query_phone=True) # type: ignore[arg-type] + assert lines[4] == expected + + +def test_main_status_prints_and_exits_zero( + capsys: pytest.CaptureFixture[str], +) -> None: + assert cli.main(["--status"]) == 0 # the isolated defaults: an empty world + out = capsys.readouterr().out + assert "anchor: none" in out + assert "not queried" in out + + +def test_main_status_query_phone_uses_the_serial( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + seen: list[str] = [] + + def fake_phone(serial: str) -> FakePhone: + seen.append(serial) + return FakePhone() + + monkeypatch.setattr(cli, "Phone", fake_phone) + assert cli.main(["--status", "--query-phone", "--serial", "XYZ"]) == 0 + assert seen == ["XYZ"] + assert "installed: yes" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("outcome", "code"), + [(Outcome.NOT_DUE, 0), (Outcome.DEVICE_ABSENT, 0), (Outcome.UNINSTALL_FAILED, 1)], +) +def test_main_exit_code_follows_the_outcome( + monkeypatch: pytest.MonkeyPatch, outcome: Outcome, code: int +) -> None: + calls: list[bool] = [] + + def fake_run(paths: Paths, phone: object, *, dry_run: bool) -> RetireResult: + calls.append(dry_run) + return RetireResult(outcome, "why") + + monkeypatch.setattr(cli, "run_retire", fake_run) + assert cli.main(["--dry-run"]) == code + assert calls == [True] + + +def test_main_with_nothing_to_retire_is_a_clean_noop() -> None: + assert cli.main([]) == 0 + + +def test_status_marker_line_for_a_valid_marker_uses_real_files( + tmp_path: Path, +) -> None: + paths = world(tmp_path, marker=False) + put_marker(paths.watch_dir, [RUN]) + now = datetime.now(UTC) + assert cli.status_lines(paths, FakePhone(), now)[2].startswith("marker: valid") # type: ignore[arg-type] diff --git a/screen_locker/tests/test_runnerup_retire_tcx.py b/screen_locker/tests/test_runnerup_retire_tcx.py new file mode 100644 index 0000000..17d8982 --- /dev/null +++ b/screen_locker/tests/test_runnerup_retire_tcx.py @@ -0,0 +1,262 @@ +"""RunnerUp retirement: app-run detection, anchor derivation, marker validation.""" + +from __future__ import annotations + +from datetime import UTC, datetime +import logging +from typing import TYPE_CHECKING + +import pytest + +from screen_locker import _runnerup_retire_tcx as rt +from screen_locker.tests._retire_helpers import ( + APP, + END_UNIX, + credit, + put_log, + put_marker, + put_tcx, +) + +if TYPE_CHECKING: + from pathlib import Path + +_AT = "2026-11-01T10:45:00+00:00" + + +def test_run_key_drops_device_prefix_only() -> None: + plain = "RunnerUp_2026-08-22-23-51-04_Running.tcx" + twin = "RunnerUp_Pixel_6a_2026-08-22-23-51-04_Running.tcx" + assert rt.run_key(twin) == plain + assert rt.run_key(plain) == plain + + +def test_list_tcx_skips_dotfiles_other_types_and_missing_dir(tmp_path: Path) -> None: + assert rt.list_tcx(tmp_path / "nope") == [] + put_tcx(tmp_path, "a.tcx") + put_tcx(tmp_path, ".partial.tcx") + (tmp_path / "a.gpx").write_text("x") + (tmp_path / "dir.tcx").mkdir() + assert [p.name for p in rt.list_tcx(tmp_path)] == ["a.tcx"] + + +@pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({"author": APP}, True), + ({"creator": APP}, True), + ({"author": "RunnerUp"}, False), + ({"author": f"{APP} fork"}, False), + ], +) +def test_is_app_tcx_reads_author_and_creator( + tmp_path: Path, kwargs: dict[str, str], expected: bool +) -> None: + assert rt.is_app_tcx(put_tcx(tmp_path, "r.tcx", **kwargs)) is expected + + +def test_is_app_tcx_ignores_the_name_in_free_text(tmp_path: Path) -> None: + path = tmp_path / "n.tcx" + path.write_text(f"{APP}") + assert rt.is_app_tcx(path) is False + + +def test_is_app_tcx_unreadable_is_a_loud_non_app( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + assert rt.is_app_tcx(tmp_path) is False + assert "Cannot read" in caplog.text + + +def _app_run(tmp_path: Path, name: str = "app.tcx", where: str = "") -> Path: + return put_tcx(tmp_path / where if where else tmp_path, name, author=APP) + + +def test_anchor_is_the_credit_time_of_the_entry_matching_the_run_end( + tmp_path: Path, +) -> None: + _app_run(tmp_path, where="processed") + log = tmp_path / "log.json" + put_log( + log, + { + "2026-11-01": [credit(_AT, END_UNIX)], + "2026-11-02": [credit("2026-11-02T09:00:00+00:00", "1700000000.0")], + }, + ) + found = rt.find_anchor(log, [tmp_path, tmp_path / "processed"]) + assert found == datetime(2026, 11, 1, 10, 45, tzinfo=UTC) + + +def test_anchor_accepts_a_stringified_end_and_the_earliest_credit( + tmp_path: Path, +) -> None: + _app_run(tmp_path) + log = tmp_path / "log.json" + put_log( + log, + { + "a": [credit("2026-11-03T00:00:00+00:00", str(END_UNIX))], + "b": [credit("2026-11-02T00:00:00+00:00", END_UNIX + 0.4)], + }, + ) + found = rt.find_anchor(log, [tmp_path]) + assert found == datetime(2026, 11, 2, tzinfo=UTC) + + +def test_same_day_runnerup_credit_does_not_anchor_the_app_run(tmp_path: Path) -> None: + _app_run(tmp_path) + log = tmp_path / "log.json" + put_log(log, {"2026-11-01": [credit(_AT, END_UNIX - 3600)]}) + assert rt.find_anchor(log, [tmp_path]) is None + + +def test_no_app_run_means_no_anchor(tmp_path: Path) -> None: + put_tcx(tmp_path, "r.tcx") + log = tmp_path / "log.json" + put_log(log, {"d": [credit(_AT, END_UNIX)]}) + assert rt.find_anchor(log, [tmp_path]) is None + + +def test_unreadable_or_trackless_app_tcx_cannot_anchor( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + (tmp_path / "bad.tcx").write_text(f"{APP}") + log = tmp_path / "log.json" + put_log(log, {"d": [credit(_AT, END_UNIX)]}) + with caplog.at_level(logging.WARNING): + assert rt.find_anchor(log, [tmp_path]) is None + assert "cannot anchor" in caplog.text + + +def test_log_entries_that_cannot_match_are_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + _app_run(tmp_path) + log = tmp_path / "log.json" + put_log( + log, + { + "d": [ + credit(_AT), # an older entry without completed_at + credit(_AT, True), + credit(_AT, "soon"), + credit(_AT, END_UNIX, kind="manual_workout"), + { + "timestamp": "garbage", + "workout_data": { + "type": "runnerup_verified", + "completed_at": END_UNIX, + }, + }, + {"workout_data": None}, + ] + }, + ) + with caplog.at_level(logging.WARNING): + assert rt.find_anchor(log, [tmp_path]) is None + assert "no usable timestamp" in caplog.text + + +def test_naive_credit_timestamps_are_read_as_local_time(tmp_path: Path) -> None: + _app_run(tmp_path) + log = tmp_path / "log.json" + put_log(log, {"d": [credit("2026-11-01T10:45:00", END_UNIX)]}) + found = rt.find_anchor(log, [tmp_path]) + assert found is not None + assert found.tzinfo is not None + + +def _processed(tmp_path: Path) -> Path: + processed = tmp_path / "processed" + put_tcx(processed, "RunnerUp_2026-08-12-17-42-11_Running.tcx") + put_tcx(processed, "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx") + put_tcx(processed, "RunnerUp_2026-08-14-00-58-45_Running.tcx") + put_tcx(processed, "app_2026-11-01.tcx", author=APP) + return processed + + +_LISTED = [ + "RunnerUp_2026-08-12-17-42-11_Running.tcx", + "RunnerUp_2026-08-14-00-58-45_Running.tcx", +] + + +def test_marker_valid_counts_device_twins_and_ignores_app_files( + tmp_path: Path, +) -> None: + processed = _processed(tmp_path) + marker = put_marker(tmp_path, _LISTED) + check = rt.check_marker(marker, processed) + assert check.ok + assert "2 runs" in check.reason + + +def test_marker_may_name_the_device_prefixed_copy(tmp_path: Path) -> None: + processed = _processed(tmp_path) + names = ["RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx", _LISTED[1]] + assert rt.check_marker(put_marker(tmp_path, names), processed).ok + + +def test_marker_missing_is_not_valid( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + check = rt.check_marker(tmp_path / "m.json", tmp_path) + assert not check.ok + assert "has not imported yet" in check.reason + assert "marker problem" in caplog.text + + +def test_marker_unreadable_and_bad_json(tmp_path: Path) -> None: + folder_as_marker = tmp_path / "m" + folder_as_marker.mkdir() + assert "unreadable" in rt.check_marker(folder_as_marker, tmp_path).reason + bad = tmp_path / "bad.json" + bad.write_text("{nope") + assert "unreadable" in rt.check_marker(bad, tmp_path).reason + + +@pytest.mark.parametrize( + ("body", "fragment"), + [ + ("[]", "not a JSON object"), + ('{"count": 0, "tcx_names": []}', "no completed_at"), + ('{"completed_at": "", "count": 0, "tcx_names": []}', "no completed_at"), + ('{"completed_at": "t", "count": 0, "tcx_names": "x"}', "not a list"), + ('{"completed_at": "t", "count": 0, "tcx_names": [1]}', "not a list"), + ('{"completed_at": "t", "count": 3, "tcx_names": ["a"]}', "count 3"), + ('{"completed_at": "t", "count": true, "tcx_names": ["a"]}', "count True"), + ('{"completed_at": "t", "count": "1", "tcx_names": ["a"]}', "count '1'"), + ('{"completed_at": "t", "count": 1, "tcx_names": ["x/a"]}', "bare file"), + ('{"completed_at": "t", "count": 1, "tcx_names": [".a"]}', "bare file"), + ], +) +def test_marker_shape_errors(tmp_path: Path, body: str, fragment: str) -> None: + marker = tmp_path / "m.json" + marker.write_text(body) + check = rt.check_marker(marker, tmp_path) + assert not check.ok + assert fragment in check.reason + + +def test_marker_listing_one_run_twice_is_invalid(tmp_path: Path) -> None: + processed = _processed(tmp_path) + twin = "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx" + marker = put_marker(tmp_path, [_LISTED[0], twin]) + assert "more than once" in rt.check_marker(marker, processed).reason + + +def test_marker_naming_a_run_not_in_processed_is_invalid(tmp_path: Path) -> None: + processed = _processed(tmp_path) + marker = put_marker(tmp_path, [*_LISTED, "RunnerUp_2099-01-01-00-00-00_X.tcx"]) + reason = rt.check_marker(marker, processed).reason + assert "not in processed/" in reason + + +def test_marker_missing_a_processed_run_asks_for_a_reimport(tmp_path: Path) -> None: + processed = _processed(tmp_path) + marker = put_marker(tmp_path, _LISTED[:1]) + reason = rt.check_marker(marker, processed).reason + assert "re-run the app's import" in reason From 50faee673f393a25d7605b0b0b7597a4a62039cc Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:01:37 +0100 Subject: [PATCH 3/8] Wire runnerup-retire units into installer Daily timer (+10 min after boot) running the retirement, installed and removed with the other screen-locker units; README section. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- README.md | 7 +++++++ install_systemd.sh | 11 +++++++++++ remove_systemd.sh | 2 ++ runnerup-retire.service | 15 +++++++++++++++ runnerup-retire.timer | 18 ++++++++++++++++++ 5 files changed, 53 insertions(+) create mode 100644 runnerup-retire.service create mode 100644 runnerup-retire.timer diff --git a/README.md b/README.md index 01b0d4d..6a2b0df 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ so a run finished after 20:00 pushes the hour later without the phone being plugged in; the per-minute shutdown check then lifts the night lockdown on its own once the hour is past "now" (digital-wellbeing's check script). +**Retiring RunnerUp.** A week after the PC first credits a run recorded by the +workout app (TCX author `kuhy workout_app`), `runnerup-retire.timer` (daily, retries +until the phone is attached) backs `/sdcard/Documents/RunnerUp` up to +`~/data/cloud/RunnerUp/runnerup_phone_backup_/` and uninstalls `org.runnerup.free`, +once the app's `.workout_app_import.json` matches `processed/`; no app run, no uninstall. +`python3 -m screen_locker.runnerup_retire --status` / `--dry-run` change nothing. + ## MCP server (Claude Code integration) screen-locker exposes a **read-only** MCP server (`screen_locker._mcp`) so diff --git a/install_systemd.sh b/install_systemd.sh index c4ce5df..f13b812 100755 --- a/install_systemd.sh +++ b/install_systemd.sh @@ -17,6 +17,8 @@ BONUS_SERVICE_NAME="earner-bonus.service" BONUS_PATH_NAME="earner-bonus.path" RUNNERUP_WATCH_NAME="runnerup-watch.service" WORKOUT_POKE_NAME="workout-poke.service" +RETIRE_SERVICE_NAME="runnerup-retire.service" +RETIRE_TIMER_NAME="runnerup-retire.timer" # Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch # the tkinter MODULE ships inside the `python` package, but the shared library @@ -147,6 +149,12 @@ cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME" bash "$SCRIPT_DIR/scripts/workout_poke_keygen.sh" cp "$SCRIPT_DIR/$WORKOUT_POKE_NAME" "$USER_SERVICE_DIR/$WORKOUT_POKE_NAME" +# The RunnerUp retirement: a week after the workout app's first credited run +# it backs the old app's exports up and uninstalls it from the phone. A daily +# timer, because the phone is not always attached; a no-op until then. +cp "$SCRIPT_DIR/$RETIRE_SERVICE_NAME" "$USER_SERVICE_DIR/$RETIRE_SERVICE_NAME" +cp "$SCRIPT_DIR/$RETIRE_TIMER_NAME" "$USER_SERVICE_DIR/$RETIRE_TIMER_NAME" + # Update paths in the service file to use absolute paths REPO_ROOT="$SCRIPT_DIR" sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME" @@ -179,6 +187,9 @@ systemctl --user restart "$RUNNERUP_WATCH_NAME" systemctl --user enable "$WORKOUT_POKE_NAME" systemctl --user restart "$WORKOUT_POKE_NAME" +# Enable the daily RunnerUp retirement retry +systemctl --user enable --now "$RETIRE_TIMER_NAME" + # Verify enforcement is actually armed. enable can silently no-op when systemd # breaks an ordering cycle by deleting the timer's job -- exactly what happened # on 2026-08-04 -- so a successful `enable` is NOT proof the locker will run. diff --git a/remove_systemd.sh b/remove_systemd.sh index b1902ea..afd2afa 100755 --- a/remove_systemd.sh +++ b/remove_systemd.sh @@ -17,6 +17,8 @@ UNITS=( "earner-bonus.service" "runnerup-watch.service" "workout-poke.service" + "runnerup-retire.timer" + "runnerup-retire.service" ) for unit in "${UNITS[@]}"; do diff --git a/runnerup-retire.service b/runnerup-retire.service new file mode 100644 index 0000000..9b8fba1 --- /dev/null +++ b/runnerup-retire.service @@ -0,0 +1,15 @@ +[Unit] +Description=Back up and uninstall the stock RunnerUp app a week after the workout app's first credited run + +[Service] +Type=oneshot +# screen_locker is pip-installed (editable), like workout-sync.service, so no +# WorkingDirectory/PYTHONPATH. Headless: no DISPLAY, no Tk, no lock screen. +# +# Exit 0 for "not due yet", "no app run credited yet" and "phone not attached" +# (the timer retries), non-zero when a human is needed: invalid import marker, +# adb missing, a failed backup or a refused uninstall. That shows up in +# `systemctl --user --failed`. Every skip says why in the journal: +# journalctl --user -u runnerup-retire +# State and the anchor: python3 -m screen_locker.runnerup_retire --status +ExecStart=/usr/bin/python3 -m screen_locker.runnerup_retire diff --git a/runnerup-retire.timer b/runnerup-retire.timer new file mode 100644 index 0000000..c853fa6 --- /dev/null +++ b/runnerup-retire.timer @@ -0,0 +1,18 @@ +[Unit] +Description=Retry the RunnerUp retirement daily until the phone has been cleaned up + +# No After=graphical-session.target: timers.target is ordered before it, and +# that After= closes a cycle systemd resolves by deleting this timer's job +# (see early-bird-workout-check.timer for the 2026-08-04 incident). + +[Timer] +# The phone is not always on USB, so a failed or skipped pass is simply tried +# again: shortly after boot and once a day. Most passes are an instant no-op +# (not due yet, or already done), so the timer stays installed. +OnBootSec=10min +OnCalendar=*-*-* 19:00:00 +# Persistent=true: a PC that was off at 19:00 still gets that day's pass. +Persistent=true + +[Install] +WantedBy=timers.target From ca205fa67e672fe0cab0a57ad0581b30dce0bf6c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:03:02 +0100 Subject: [PATCH 4/8] Build a fresh tile provider per map TileLayer disposes its provider; cache only the directory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- .../lib/services/map_tile_cache.dart | 26 ++++++++++++------- .../test/services/map_tile_cache_test.dart | 8 ++++-- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart index a5a6cdf..1dcf04c 100644 --- a/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart +++ b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart @@ -37,25 +37,33 @@ TileProvider buildCachedTileProvider(String cacheDir, {Dio? dio}) => dio: dio, ); -Future? _opened; +Future? _cacheDir; -/// The shared disk-cached OSM provider; opened once per process. +/// A disk-cached OSM provider; each map gets its own. +/// +/// Only the cache directory is looked up once per process. A provider is +/// never shared: `TileLayer` disposes its provider when the map goes away +/// (a `NetworkTileProvider` closes its HTTP client then), so a shared one +/// would be dead for the second map. /// /// If the cache directory cannot be had, the map still works over the -/// network without a cache, and the reason is logged at warning level. -Future openOsmTileProvider() => _opened ??= _open(); +/// network without a cache, and the reason is logged once at warning level. +Future openOsmTileProvider() async { + final dir = await (_cacheDir ??= _resolveCacheDir()); + return dir == null ? NetworkTileProvider() : buildCachedTileProvider(dir); +} -/// Forgets the opened provider (tests only). +/// Forgets the looked-up cache directory (tests only). @visibleForTesting -void resetOsmTileProvider() => _opened = null; +void resetOsmTileProvider() => _cacheDir = null; -Future _open() async { +Future _resolveCacheDir() async { try { final dir = await getApplicationCacheDirectory(); - return buildCachedTileProvider('${dir.path}/osm_tiles'); + return '${dir.path}/osm_tiles'; } on Object catch (error) { logMapWarning('map tile cache unavailable, tiles will not persist: $error'); - return NetworkTileProvider(); + return null; } } diff --git a/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart b/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart index 9b12089..0c3ea68 100644 --- a/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart +++ b/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart @@ -32,12 +32,14 @@ void main() { expect(provider.supportsCancelLoading, isTrue); }); - test('openOsmTileProvider opens a cached provider once', () async { + test('every open is a fresh cached provider (TileLayer disposes it)', () async { final dir = installFakePathProvider(); addTearDown(() => dir.deleteSync(recursive: true)); final first = await openOsmTileProvider(); + final second = await openOsmTileProvider(); expect(first, isA()); - expect(identical(await openOsmTileProvider(), first), isTrue); + expect(second, isA()); + expect(identical(second, first), isFalse); }); test('without a cache dir: network provider and a warning', () async { @@ -47,6 +49,8 @@ void main() { provider = await openOsmTileProvider(); }); expect(provider, isA()); + // Looked up once: the second map falls back without a second warning. + expect(await openOsmTileProvider(), isA()); expect( lines.where((l) => l.contains('WARNING') && l.contains('persist')), hasLength(1), From 8ca4c301fd3ec0d89cff00a1848273822ae5aea1 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:06:56 +0100 Subject: [PATCH 5/8] Back up and export GPS runs runs/run_points were in neither backup.json nor the user export. App-made runs go in the backup (history returns from the PC), all runs in export v3; merge keyed by id or hash, idempotent. The backup clobber guard now also protects a runs-only backup. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- .../lib/services/storage_service.dart | 1 + .../lib/services/storage_service_backup.dart | 14 +- .../lib/services/storage_service_export.dart | 6 +- .../lib/services/storage_service_import.dart | 3 +- .../lib/services/storage_service_runs.dart | 5 +- .../storage_service_runs_transfer.dart | 136 ++++++++++ .../storage_service_import_merge_test.dart | 5 +- .../services/storage_service_runs_test.dart | 3 + .../storage_service_runs_transfer_test.dart | 237 ++++++++++++++++++ 9 files changed, 400 insertions(+), 10 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart create mode 100644 stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index 57d2cb8..e096516 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -34,6 +34,7 @@ part 'storage_service_export.dart'; part 'storage_service_import.dart'; part 'storage_service_pc_link.dart'; part 'storage_service_runs.dart'; +part 'storage_service_runs_transfer.dart'; part 'storage_service_sandbox.dart'; part 'storage_service_schema.dart'; part 'storage_service_sessions.dart'; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart index 8ad8055..d0d098a 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart @@ -22,16 +22,19 @@ extension StorageServiceBackup on StorageService { final historyRows = await _db.query('workout_history'); final settingsRows = await _db.query('settings'); final eventRows = await _db.query('progression_events'); + final runRows = await _runsForTransfer(appOnly: true); - if (historyRows.isEmpty) { + if (historyRows.isEmpty && runRows.isEmpty) { final existing = await BackupService.instance.readBackup(); final existingHistory = (existing?['workout_history'] as List?) ?? const []; - if (existingHistory.isNotEmpty) { + final existingRuns = (existing?['runs'] as List?) ?? const []; + if (existingHistory.isNotEmpty || existingRuns.isNotEmpty) { debugPrint( 'WorkoutApp: REFUSING to overwrite the backup — this DB has no ' - 'workout history but the backup holds ${existingHistory.length} ' - 'session(s). A restore almost certainly failed; keeping the backup ' + 'workout history or runs but the backup holds ' + '${existingHistory.length} session(s) and ${existingRuns.length} ' + 'run(s). A restore almost certainly failed; keeping the backup ' 'so the data stays recoverable.', ); return; @@ -43,6 +46,7 @@ extension StorageServiceBackup on StorageService { 'workout_history': historyRows, 'settings': settingsRows, 'progression_events': eventRows, + 'runs': runRows, }); } @@ -173,6 +177,8 @@ extension StorageServiceBackup on StorageService { conflictAlgorithm: ConflictAlgorithm.replace, ); } + // Absent from older backups; null merges as nothing. + await _mergeRuns(txn, backup['runs']); }); } } diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_export.dart b/stronglift_replacement/workout_app/lib/services/storage_service_export.dart index eb5c93d..d41fac9 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_export.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_export.dart @@ -7,8 +7,9 @@ part of 'storage_service.dart'; const kExportFormat = 'workout_app-export'; /// The format written now. Version 2 adds `updated_at` to `exercise_state` and -/// `settings` rows plus `manual_workouts`; version 1 files still import. -const kExportVersion = 2; +/// `settings` rows plus `manual_workouts`; version 3 adds `runs` (with their +/// points); version 1 and 2 files still import. +const kExportVersion = 3; const _exportTables = [ 'workout_history', @@ -47,6 +48,7 @@ extension StorageServiceExport on StorageService { for (final row in settings) if (!_isPerInstallSetting(row['key']! as String)) row, ], + 'runs': await _runsForTransfer(appOnly: false), 'manual_workouts': manual == null ? null : [ diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_import.dart b/stronglift_replacement/workout_app/lib/services/storage_service_import.dart index 230f0b1..ab1119b 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_import.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_import.dart @@ -24,7 +24,7 @@ extension StorageServiceImport on StorageService { /// [ImportReport.manualSkipped] says so. Idempotent. Future importDataReport(Map data) async { if (!_exportTables.any((t) => data[t] is List)) { - if (data['manual_workouts'] is! List) { + if (data['manual_workouts'] is! List && data['runs'] is! List) { throw const FormatException('not a workout_app export'); } } @@ -36,6 +36,7 @@ extension StorageServiceImport on StorageService { for (final table in _exportTables) { rows += await _mergeTable(txn, table, data[table], fileWins, stamped); } + rows += await _mergeRuns(txn, data['runs']); }); if (rows > 0) unawaited(_backupNow()); final manual = await _importManual(data['manual_workouts']); diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart b/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart index b6fa8e9..c30f13b 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart @@ -61,7 +61,7 @@ extension StorageServiceRuns on StorageService { 'an imported run must be RunUploadState.never', ); } - return await _db.transaction((txn) async { + final added = await _db.transaction((txn) async { final clash = await txn.query( 'runs', columns: ['id'], @@ -95,6 +95,9 @@ extension StorageServiceRuns on StorageService { await batch.commit(noResult: true); return true; }); + // A recorded run exists nowhere else yet; the RunnerUp history does (PC). + if (added && track.source == RunSource.app) unawaited(_backupNow()); + return added; } /// Whether a run with this TCX hash is stored. diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart new file mode 100644 index 0000000..c65373c --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart @@ -0,0 +1,136 @@ +// Runs in backup.json and the user export: the `runs` / `run_points` half of +// both, written and merged by one code path. +// +// A `part` of StorageService (it needs `_db`). The generic table merge cannot +// be reused: it strips every `id` column and falls back to a progression +// event key, which would store runs with NULL ids. +part of 'storage_service.dart'; + +/// Runs as portable rows. +extension StorageServiceRunsTransfer on StorageService { + /// Every run as a `runs` row plus `points`: ONE compact JSON string of + /// `[time_ms, lat, lon, alt, distance_m]` rows. A string, not nested lists, + /// so pretty-printing the document does not put every fix on its own line + /// (a year of runs would otherwise be tens of MB, re-encoded on every + /// backup). + /// + /// [appOnly] leaves the RunnerUp history out: it is reproducible from the + /// PC, so the automatic backup carries only what exists nowhere else. + Future>> _runsForTransfer({ + required bool appOnly, + }) async { + final rows = await _db.query( + 'runs', + where: appOnly ? "source = 'app'" : null, + orderBy: 'started_at, id', + ); + return [ + for (final row in rows) + {...row, 'points': await _encodePoints(row['id']! as String)}, + ]; + } + + Future _encodePoints(String runId) async { + final points = await _db.query( + 'run_points', + where: 'run_id = ?', + whereArgs: [runId], + orderBy: 'seq', + ); + return jsonEncode([ + for (final p in points) + [p['time_ms'], p['lat'], p['lon'], p['alt'], p['distance_m']], + ]); + } + + /// Merges transferred [raw] runs into the database and returns how many + /// were new. Idempotent: a run whose id OR TCX hash is already stored is + /// left alone (and so are its points); a malformed entry is counted and + /// skipped, never fatal. An imported run is stored as `never` whatever the + /// file claims, since the table's CHECK would reject anything else. + Future _mergeRuns(Transaction txn, Object? raw) async { + if (raw is! List) { + if (raw != null) _warnSkipped('runs', 1); + return 0; + } + final known = await txn.query('runs', columns: ['id', 'tcx_sha256']); + final ids = {for (final r in known) r['id']! as String}; + final shas = {for (final r in known) r['tcx_sha256']! as String}; + var written = 0; + var malformed = 0; + for (final entry in raw) { + final parsed = entry is Map ? _parseRun(entry) : null; + if (parsed == null) { + malformed++; + continue; + } + final (row, points) = parsed; + final id = row['id']! as String; + final sha = row['tcx_sha256']! as String; + if (ids.contains(id) || shas.contains(sha)) continue; + ids.add(id); + shas.add(sha); + await txn.insert('runs', row); + final batch = txn.batch(); + for (final (seq, p) in points.indexed) { + batch.insert('run_points', { + 'run_id': row['id'], + 'seq': seq, + 'time_ms': p[0], + 'lat': p[1], + 'lon': p[2], + 'alt': p[3], + 'distance_m': p[4], + }); + } + await batch.commit(noResult: true); + written++; + } + if (malformed > 0) _warnSkipped('runs', malformed); + return written; + } + + /// The `runs` row and decoded points of [e], or null when it is malformed. + (Map, List>)? _parseRun( + Map e, + ) { + try { + final source = RunSource.fromStorageKey(e['source'] as String); + final state = source == RunSource.runnerupImport + ? RunUploadState.never + : RunUploadState.fromStorageKey(e['upload_state'] as String); + final points = [ + for (final p in jsonDecode(e['points'] as String) as List) + [ + (p as List)[0] as int, + (p[1] as num).toDouble(), + (p[2] as num).toDouble(), + (p[3] as num?)?.toDouble(), + (p[4] as num).toDouble(), + ], + ]; + final sport = RunSport.fromTcxName(e['sport'] as String)!.tcxName; + final id = e['id'] as String; + final sha = e['tcx_sha256'] as String; + if (id.isEmpty || sha.isEmpty) return null; + return ( + { + 'id': id, + 'sport': sport, + 'started_at': e['started_at'] as int, + 'total_seconds': e['total_seconds'] as int, + 'distance_m': (e['distance_m'] as num).toDouble(), + 'source': source.storageKey, + 'upload_state': state.storageKey, + 'tcx_filename': e['tcx_filename'] as String, + 'tcx_sha256': sha, + }, + 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. + return null; + } + } +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart index 3005eeb..8dee8ed 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart @@ -50,9 +50,10 @@ void main() { return await _row('exercise_state', _name); } - test('export is version 2 and rows carry updated_at', () async { + test('export is the current version (3) and rows carry updated_at', () async { final data = await _svc.exportData(); - expect(data['version'], 2); + expect(data['version'], kExportVersion); + expect(kExportVersion, 3); expect( (await _row('exercise_state', _name)).containsKey('updated_at'), true, diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart index 3bafb64..74dd345 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart @@ -169,6 +169,9 @@ void main() { await StorageService.init(); final t = _track(); await _insert(t); + // A recorded run triggers an unawaited backup; let it finish before the + // tables are dropped under it. + await Future.delayed(const Duration(milliseconds: 100)); StorageService.resetForTesting(); final raw = await databaseFactory.openDatabase(path); diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart new file mode 100644 index 0000000..c771a42 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart @@ -0,0 +1,237 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; + +import '_runnerup_fake_dufs.dart'; + +StorageService get _svc => StorageService.instance; + +/// The three fixtures, decoded: two app-made, one RunnerUp export. +List<(String, String, RunTrack)> _tracks() => [ + for (final e in fixtureTcx().entries) + ( + e.key, + utf8.decode(e.value), + decodeTcx(utf8.decode(e.value), fileName: e.key), + ), +]; + +Future _insertAll() async { + for (final (name, xml, track) in _tracks()) { + final imported = track.source == RunSource.runnerupImport; + await _svc.insertRun( + track, + tcxFilename: name, + tcxSha256: tcxSha256(xml), + uploadState: imported ? RunUploadState.never : RunUploadState.pending, + ); + } +} + +Future _freshInstall() async { + StorageService.resetForTesting(); + await StorageService.init(); +} + +Future> _backupOnceItHolds(int runs) async { + for (var i = 0; i < 150; i++) { + final b = await BackupService.instance.readBackup(); + if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('backup.json never held $runs run(s)'); +} + +void main() { + late Directory tmp; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tmp = Directory.systemTemp.createTempSync('runs_transfer'); + BackupService.baseDir = tmp.path; + await _freshInstall(); + }); + + tearDown(() { + BackupService.baseDir = kBackupDir; + tmp.deleteSync(recursive: true); + }); + + group('backup.json', () { + test( + 'wipe the DB, restore: recorded runs and their points return', + () async { + await _insertAll(); + final backup = await _backupOnceItHolds(2); + final saved = { + for (final r in await _svc.listRuns()) + if (r.track.source == RunSource.app) + r.track.id: await _svc.getRun(r.track.id), + }; + expect(saved, hasLength(2)); + // RunnerUp history is reproducible from the PC: not duplicated here. + expect( + (backup['runs'] as List).map((r) => (r as Map)['source']).toSet(), + {'app'}, + ); + final text = const JsonEncoder.withIndent(' ').convert(backup); + expect( + text.split('\n').length, + lessThan(200), + reason: 'points must stay one string per run, not one line per fix', + ); + + await _freshInstall(); // the reinstall + expect(await _svc.listRuns(), isEmpty); + await _svc.restoreFromBackupIfNeeded(); + + final restored = await _svc.listRuns(); + expect(restored.map((r) => r.track.id).toSet(), saved.keys.toSet()); + for (final run in saved.values) { + final back = (await _svc.getRun(run!.track.id))!; + expect(back.track.points, run.track.points); + expect(back.track.points, isNotEmpty); + expect(back.uploadState, run.uploadState); + expect(back.tcxSha256, run.tcxSha256); + expect(back.track.totalSeconds, run.track.totalSeconds); + expect(back.track.distanceM, run.track.distanceM); + } + }, + ); + + test('restoring twice stores nothing twice', () async { + await _insertAll(); + await _backupOnceItHolds(2); + await _freshInstall(); + await _svc.restoreFromBackupIfNeeded(); + final once = await _svc.listRuns(); + + await _svc.restoreFromBackupIfNeeded(); + + expect(await _svc.listRuns(), hasLength(once.length)); + }); + + test('a run-less fresh DB cannot overwrite a runs-only backup', () async { + await _insertAll(); + final before = await _backupOnceItHolds(2); + await _freshInstall(); + + await _svc.setLastWorkoutType('A'); // triggers _backupNow + await Future.delayed(const Duration(milliseconds: 100)); + + final after = await BackupService.instance.readBackup(); + expect( + after!['runs'] as List, + hasLength((before['runs'] as List).length), + ); + }); + }); + + group('user export / merge-import', () { + test( + 'round trip keeps every run and point; a re-import adds nothing', + () async { + await _insertAll(); + final originals = { + for (final r in await _svc.listRuns()) + r.track.id: (await _svc.getRun(r.track.id))!, + }; + final data = + jsonDecode(jsonEncode(await _svc.exportData())) + as Map; + expect(data['version'], kExportVersion); + expect(data['runs'], hasLength(3)); + + await _freshInstall(); + final report = await _svc.importDataReport(data); + + expect(report.rows, greaterThanOrEqualTo(3)); + for (final run in originals.values) { + final back = (await _svc.getRun(run.track.id))!; + expect(back.track.points, run.track.points); + expect(back.track.source, run.track.source); + expect(back.uploadState, run.uploadState); + } + final again = await _svc.importDataReport(data); + expect(again.rows, 0); + expect(await _svc.listRuns(), hasLength(3)); + }, + ); + + test('an old export without runs still imports', () async { + final report = await _svc.importDataReport({ + 'format': kExportFormat, + 'version': 2, + 'settings': [], + }); + expect(report.rows, 0); + expect(await _svc.listRuns(), isEmpty); + }); + + test( + 'a runs entry that is not a list is skipped, the rest merges', + () async { + final report = await _svc.importDataReport({ + 'settings': [], + 'runs': 5, + }); + expect(report.rows, 0); + }, + ); + + test('an export holding only runs is accepted; junk is not', () async { + await _insertAll(); + final runs = (await _svc.exportData())['runs']! as List; + await _freshInstall(); + expect((await _svc.importDataReport({'runs': runs})).rows, 3); + await expectLater( + _svc.importDataReport({'runs': 'nope'}), + throwsFormatException, + ); + }); + + test( + 'bad entries are skipped, good ones kept, imports stay never', + () async { + await _insertAll(); + final good = ((await _svc.exportData())['runs']! as List) + .cast>(); + await _freshInstall(); + final forged = { + ...good.first, + 'source': 'runnerup_import', + 'upload_state': 'pending', + }; + final clash = {...good[1], 'id': 'other-id'}; // same hash, new id + final badPoints = {...good[2], 'points': 'not json'}; + final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'}; + final noId = {...good[2], 'id': ''}; + + final report = await _svc.importDataReport({ + 'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7], + }); + + expect(report.rows, 2); + final stored = await _svc.listRuns(); + expect(stored, hasLength(2)); + final coerced = stored.firstWhere((r) => r.track.id == forged['id']); + expect(coerced.track.source, RunSource.runnerupImport); + expect(coerced.uploadState, RunUploadState.never); + }, + ); + }); +} From a6c97dab8f5e2096d749dc1a62851afbb99294ac Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:07:03 +0100 Subject: [PATCH 6/8] Import RunnerUp history into the app One-time, phone-only import of /RunnerUp/processed over dufs WebDAV as display-only runs, then a dotfile marker PUT for the PC. Never PUTs a tcx. Icon-only status badge in Settings, shared DufsCredentials. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- .../workout_app/DOCS-workout-app-context.md | 12 + .../workout_app/lib/main.dart | 4 + .../lib/screens/settings_screen.dart | 6 +- .../lib/screens/settings_screen_sections.dart | 1 + .../lib/services/dufs_credentials.dart | 74 ++++++ .../lib/services/runnerup_dav.dart | 122 ++++++++++ .../lib/services/runnerup_import_state.dart | 79 +++++++ .../lib/services/runnerup_importer.dart | 222 ++++++++++++++++++ .../lib/widgets/runnerup_import_badge.dart | 38 +++ .../test/services/_runnerup_fake_dufs.dart | 103 ++++++++ .../runnerup_importer_failures_test.dart | 174 ++++++++++++++ .../test/services/runnerup_importer_test.dart | 124 ++++++++++ .../services/runnerup_local_and_dav_test.dart | 167 +++++++++++++ .../widgets/runnerup_import_badge_test.dart | 44 ++++ 14 files changed, 1167 insertions(+), 3 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/services/dufs_credentials.dart create mode 100644 stronglift_replacement/workout_app/lib/services/runnerup_dav.dart create mode 100644 stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart create mode 100644 stronglift_replacement/workout_app/lib/services/runnerup_importer.dart create mode 100644 stronglift_replacement/workout_app/lib/widgets/runnerup_import_badge.dart create mode 100644 stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart create mode 100644 stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart create mode 100644 stronglift_replacement/workout_app/test/widgets/runnerup_import_badge_test.dart diff --git a/stronglift_replacement/workout_app/DOCS-workout-app-context.md b/stronglift_replacement/workout_app/DOCS-workout-app-context.md index 3c893ae..598bd30 100644 --- a/stronglift_replacement/workout_app/DOCS-workout-app-context.md +++ b/stronglift_replacement/workout_app/DOCS-workout-app-context.md @@ -29,3 +29,15 @@ Implementing design_v2.md improvements. - Thresholds that decide credit (3.5 km / 25 min) live on the PC only. - GPS sits behind `PositionSource` (`position_source*.dart`); imported RunnerUp runs are stored with `upload_state='never'` (table CHECK). +- RunnerUp history import (`runnerup_importer.dart`): once, on app start, on + the phone only (never the sandbox). PROPFINDs `/RunnerUp/processed/`, stores + every `.tcx` as `runnerup_import`/`never`, then PUTs the dotfile marker + `/RunnerUp/.workout_app_import.json` = `{"completed_at": ISO-UTC, "count": N, + "tcx_names": [...]}`. The importer never PUTs a `.tcx` (a top-level one would + be credited by `runnerup_watch.sh`). A reinstall re-imports and rewrites the + marker with a new `completed_at`: the PC must not use it as a clock. +- `runs`/`run_points` travel in backup.json (app-made runs only; the RunnerUp + history comes back from the PC) and in the user export (version 3, all runs). + Points are one compact JSON string per run. +- dufs login: `dufs_credentials.dart` (`dufs_url`, `dufs_user`, `dufs_password` + in secure storage). diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index c6445c6..0b8abc5 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -14,6 +14,7 @@ import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/lock_mode.dart'; import 'package:workout_app/services/pc_pairing.dart'; import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_device_id.dart'; import 'package:workout_app/services/sync_service.dart'; @@ -120,6 +121,9 @@ void main(List args) async { if (!Platform.isLinux && !Sandbox.enabled) { await HttpServerService.instance.start(); } + // 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()); runApp(const WorkoutApp()); } // coverage:ignore-end diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index 5c946dc..0fcc974 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -2,9 +2,8 @@ /// settings sheet, plus links to the sync surfaces. /// /// "Sync settings" is the shared `sync_settings_ui` package (Firebase sync; -/// no Backup section -- see the class doc on [SettingsScreen] for why). -/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]) because -/// the shared package has no GitHub surface at all. +/// no Backup section -- see [SettingsScreen]). "Advanced sync (GitHub)" stays +/// app-local ([GitHubMirrorScreen]): the shared package has no GitHub surface. library; import 'dart:async'; @@ -26,6 +25,7 @@ import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/data_transfer_buttons.dart'; import 'package:workout_app/widgets/exercise_settings_sheet.dart'; +import 'package:workout_app/widgets/runnerup_import_badge.dart'; part 'settings_screen_actions.dart'; part 'settings_screen_deload.dart'; diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart index 068a38c..1a937bd 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart @@ -179,6 +179,7 @@ class _SettingsAppBar extends StatelessWidget implements PreferredSizeWidget { title: Text('Settings', style: TextStyle(color: colorScheme.onSurface)), iconTheme: IconThemeData(color: colorScheme.onSurface), actions: [ + const RunnerUpImportBadge(), TextButton( onPressed: loading ? null : onReset, child: Text( diff --git a/stronglift_replacement/workout_app/lib/services/dufs_credentials.dart b/stronglift_replacement/workout_app/lib/services/dufs_credentials.dart new file mode 100644 index 0000000..516ebd8 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/dufs_credentials.dart @@ -0,0 +1,74 @@ +/// Login for the PC's dufs WebDAV server (`/RunnerUp`, scoped read-write). +/// +/// One shared definition so the run uploader and the RunnerUp history +/// importer read the very same secure-storage slots; provisioned once from a +/// `dufs_login.json` that carries `url`, `user` and `password`. +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Secure-storage key of [DufsCredentials.url]. +const String kDufsUrlKey = 'dufs_url'; + +/// Secure-storage key of [DufsCredentials.user]. +const String kDufsUserKey = 'dufs_user'; + +/// Secure-storage key of [DufsCredentials.password]. +const String kDufsPasswordKey = 'dufs_password'; + +/// Where the PC's dufs is and who to log in as. +@immutable +class DufsCredentials { + /// Creates a login; trailing slashes of [url] are dropped so + /// `'$url/RunnerUp/...'` is always well formed. + DufsCredentials({ + required String url, + required this.user, + required this.password, + }) : url = url.replaceFirst(RegExp(r'/+$'), ''); + + /// Parses a `dufs_login.json` document; null when a field is missing. + static DufsCredentials? fromJson(Map json) { + final url = json['url']; + final user = json['user']; + final password = json['password']; + if (url is! String || user is! String || password is! String) return null; + if (url.isEmpty || user.isEmpty || password.isEmpty) return null; + return DufsCredentials(url: url, user: user, password: password); + } + + /// The origin, e.g. `https://kuhy-cloud.duckdns.org` (no trailing slash). + final String url; + + /// The Basic-auth user. + final String user; + + /// The Basic-auth password. + final String password; + + /// The `Authorization` header value. + String get authorization => + 'Basic ${base64Encode(utf8.encode('$user:$password'))}'; + + /// The stored login, or null when any of the three slots is empty. + static Future load({ + FlutterSecureStorage storage = const FlutterSecureStorage(), + }) async { + final url = await storage.read(key: kDufsUrlKey); + final user = await storage.read(key: kDufsUserKey); + final password = await storage.read(key: kDufsPasswordKey); + return fromJson({'url': url, 'user': user, 'password': password}); + } + + /// Stores this login. + Future save({ + FlutterSecureStorage storage = const FlutterSecureStorage(), + }) async { + await storage.write(key: kDufsUrlKey, value: url); + await storage.write(key: kDufsUserKey, value: user); + await storage.write(key: kDufsPasswordKey, value: password); + } +} diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_dav.dart b/stronglift_replacement/workout_app/lib/services/runnerup_dav.dart new file mode 100644 index 0000000..4375903 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/runnerup_dav.dart @@ -0,0 +1,122 @@ +/// The WebDAV half of the RunnerUp history import: list, fetch, marker PUT. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:xml/xml.dart'; + +/// Folder on the PC that holds the already-handled RunnerUp exports. +const String kRunnerUpProcessedPath = '/RunnerUp/processed/'; + +/// The ONE file the app ever writes to the PC for the import: the +/// import-complete marker. +/// +/// A dotfile at the top of `/RunnerUp` is invisible to `runnerup_watch.sh` +/// (it skips names starting with '.') and to every `*.tcx` glob, so it can +/// never be credited as a workout. [putImportMarker] is the importer's only +/// PUT and its path is this constant, never built from a file name. +const String kImportMarkerPath = '/RunnerUp/.workout_app_import.json'; + +/// Per-request ceiling, so a half-open connection cannot hang the import. +const Duration kDavTimeout = Duration(seconds: 20); + +/// A request the server did not answer with success. +class DavException implements Exception { + /// Creates the exception. + const DavException(this.what); + + /// A sentence naming the request and why it failed. + final String what; + + @override + String toString() => what; +} + +/// The `.tcx` file names directly inside `/RunnerUp/processed/`. +/// +/// One PROPFIND with `Depth: 1`. Throws [DavException] on a non-multistatus +/// answer (401 included) and an `XmlException` on unreadable XML. +Future> listProcessedTcx( + http.Client client, + DufsCredentials login, +) async { + final request = http.Request( + 'PROPFIND', + Uri.parse('${login.url}$kRunnerUpProcessedPath'), + )..headers.addAll({'Authorization': login.authorization, 'Depth': '1'}); + final response = await _send(client, request); + if (response.statusCode != 207) { + throw DavException( + 'PROPFIND $kRunnerUpProcessedPath answered ${response.statusCode}', + ); + } + return tcxNamesFromMultistatus(utf8.decode(response.bodyBytes)); +} + +/// The `.tcx` basenames named by the `href`s of a multistatus [xml]. +/// +/// Hrefs may be absolute paths or full URLs and are percent-encoded; the +/// collection itself (a trailing slash) has no name and is skipped. +List tcxNamesFromMultistatus(String xml) { + final names = {}; + for (final href in XmlDocument.parse( + xml, + ).descendantElements.where((e) => e.localName == 'href')) { + final path = Uri.decodeFull(Uri.parse(href.innerText.trim()).path); + final name = path.substring(path.lastIndexOf('/') + 1); + if (name.toLowerCase().endsWith('.tcx') && !name.startsWith('.')) { + names.add(name); + } + } + return names.toList()..sort(); +} + +/// Downloads one export from `/RunnerUp/processed/` as raw bytes (the caller +/// decides what a non-UTF-8 file means). +Future fetchProcessedTcx( + http.Client client, + DufsCredentials login, + String name, +) async { + final request = http.Request( + 'GET', + Uri.parse( + '${login.url}$kRunnerUpProcessedPath${Uri.encodeComponent(name)}', + ), + )..headers['Authorization'] = login.authorization; + final response = await _send(client, request); + if (response.statusCode != 200) { + throw DavException('GET $name answered ${response.statusCode}'); + } + return response.bodyBytes; +} + +/// PUTs [body] to [kImportMarkerPath]; throws [DavException] unless 2xx. +Future putImportMarker( + http.Client client, + DufsCredentials login, + String body, +) async { + final request = + http.Request('PUT', Uri.parse('${login.url}$kImportMarkerPath')) + ..headers.addAll({ + 'Authorization': login.authorization, + 'Content-Type': 'application/json', + }) + ..body = body; + final response = await _send(client, request); + if (response.statusCode < 200 || response.statusCode > 299) { + throw DavException( + 'PUT $kImportMarkerPath answered ${response.statusCode}', + ); + } +} + +Future _send(http.Client client, http.Request request) async { + final streamed = await client.send(request).timeout(kDavTimeout); + return await http.Response.fromStream(streamed); +} diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart b/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart new file mode 100644 index 0000000..4d7c169 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart @@ -0,0 +1,79 @@ +/// What the RunnerUp import remembers and reports: prefs keys, the live +/// status the badge shows, and the per-pass tally. +library; + +import 'package:flutter/foundation.dart'; + +/// Where RunnerUp itself leaves exports on the phone. +const String kRunnerUpLocalDir = '/sdcard/Documents/RunnerUp'; + +/// `shared_preferences` key: every remote export is in the database. +const String kImportDoneKey = 'runnerup_import_done'; + +/// `shared_preferences` key: the marker JSON, kept until the PC has it. +const String kImportMarkerKey = 'runnerup_import_marker'; + +/// What the badge shows. +enum RunnerUpImportStatus { + /// Not started, or not applicable here (sandbox, desktop). + idle, + + /// Importing right now. + running, + + /// Imported and the PC has been told. + done, + + /// Something failed; the next app start retries. + failed, +} + +/// The live status; the badge listens to it. +final ValueNotifier runnerUpImportStatus = ValueNotifier( + RunnerUpImportStatus.idle, +); + +/// What one [RunnerUpImporter.runIfNeeded] pass did. +typedef RunnerUpImportReport = ({ + int imported, + int duplicates, + int skipped, + List failures, +}); + +/// The report of a pass that did nothing. +const RunnerUpImportReport kNothingImported = ( + imported: 0, + duplicates: 0, + skipped: 0, + failures: [], +); + +/// Counts one import pass and logs each failure as it happens. +class ImportTally { + /// Runs stored this pass. + int imported = 0; + + /// Exports already stored (same hash or id). + int duplicates = 0; + + /// Files that are no usable run, skipped for good. + int skipped = 0; + + /// Why the pass is incomplete; empty means it can finish. + final List failures = []; + + /// Records [reason] and logs it as a warning. + void fail(String reason) { + failures.add(reason); + debugPrint('WorkoutApp: RunnerUp import WARNING: $reason'); + } + + /// The tally as a report. + RunnerUpImportReport get report => ( + imported: imported, + duplicates: duplicates, + skipped: skipped, + failures: failures, + ); +} diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart new file mode 100644 index 0000000..aed8e32 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart @@ -0,0 +1,222 @@ +/// One-time import of the RunnerUp history into the app's `runs` table. +/// +/// Imported runs are display-only: [RunSource.runnerupImport] with +/// [RunUploadState.never]. Nothing here ever PUTs a `.tcx` anywhere; the only +/// write to the PC is the dotfile marker (see [kImportMarkerPath]) that tells +/// the PC the import finished. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:workout_app/services/runnerup_dav.dart'; +import 'package:workout_app/services/runnerup_import_state.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; + +export 'package:workout_app/services/runnerup_import_state.dart'; + +/// Imports the RunnerUp history once, on app start. +class RunnerUpImporter { + /// Creates an importer; every collaborator can be replaced in tests. + RunnerUpImporter({ + http.Client? client, + Future Function()? loadLogin, + Future Function()? canReadStorage, + this.localDir = kRunnerUpLocalDir, + bool? isAndroid, + DateTime Function()? now, + }) : _client = client ?? http.Client(), + _loadLogin = loadLogin ?? DufsCredentials.load, + _canReadStorage = + canReadStorage ?? BackupService.instance.hasStoragePermission, + _isAndroid = isAndroid ?? Platform.isAndroid, + _now = now ?? DateTime.now; + + final http.Client _client; + final Future Function() _loadLogin; + final Future Function() _canReadStorage; + final bool _isAndroid; + final DateTime Function() _now; + + /// Where RunnerUp's own exports are looked for (when storage is readable). + final String localDir; + + /// Runs the import unless it already completed, this is the sandbox, or + /// this is not the phone (a desktop run would write the marker before the + /// phone ever imported). + Future runIfNeeded() async { + if (Sandbox.enabled) { + SandboxLog.event('runnerup import skipped: sandbox never imports'); + return kNothingImported; + } + if (!_isAndroid) { + debugPrint('WorkoutApp: RunnerUp import skipped: not the phone.'); + return kNothingImported; + } + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(kImportDoneKey) ?? false) { + final marker = prefs.getString(kImportMarkerKey); + if (marker == null) { + runnerUpImportStatus.value = RunnerUpImportStatus.done; + return kNothingImported; + } + return ( + imported: 0, + duplicates: 0, + skipped: 0, + failures: await _deliver(prefs, marker), + ); + } + runnerUpImportStatus.value = RunnerUpImportStatus.running; + final report = await _importAll(prefs); + runnerUpImportStatus.value = report.failures.isEmpty + ? RunnerUpImportStatus.done + : RunnerUpImportStatus.failed; + return report; + } + + Future _importAll(SharedPreferences prefs) async { + final tally = ImportTally(); + final login = await _loadLogin(); + final names = []; + if (login == null) { + tally.fail('no dufs login in secure storage, so nothing was fetched'); + } else { + names.addAll(await _importRemote(login, tally)); + } + await _importLocal(tally); + if (tally.failures.isEmpty && login != null) { + final marker = jsonEncode({ + 'completed_at': _now().toUtc().toIso8601String(), + 'count': names.length, + 'tcx_names': names, + }); + await prefs.setString(kImportMarkerKey, marker); + await prefs.setBool(kImportDoneKey, true); + tally.failures.addAll(await _deliver(prefs, marker)); + } + return tally.report; + } + + /// Fetches and stores every remote export; returns the names now accounted + /// for (stored, or already stored). A file that is not a usable run is + /// skipped for good and left out; a network failure is a failure. + Future> _importRemote( + DufsCredentials login, + ImportTally tally, + ) async { + final List remote; + try { + remote = await listProcessedTcx(_client, login); + } on Exception catch (error) { + tally.fail('listing the PC history failed: $error'); + return const []; + } + final accounted = []; + for (final name in remote) { + try { + final bytes = await fetchProcessedTcx(_client, login, name); + if (await _ingest(name, bytes, tally)) accounted.add(name); + } on Exception catch (error) { + tally.fail('$name: $error'); + } + await Future.delayed(Duration.zero); + } + return accounted; + } + + /// Best effort: only if storage is already readable, and never a failure. + Future _importLocal(ImportTally tally) async { + if (!await _canReadStorage()) { + debugPrint( + 'WorkoutApp: RunnerUp import: storage not readable, skipping ' + '$localDir (the PC copy is the source).', + ); + return; + } + try { + final dir = Directory(localDir); + if (!dir.existsSync()) return; + for (final file in dir.listSync().whereType().where( + (f) => f.path.toLowerCase().endsWith('.tcx'), + )) { + final name = file.path.substring(file.path.lastIndexOf('/') + 1); + await _ingest(name, await file.readAsBytes(), tally); + } + } on Exception catch (error) { + debugPrint( + 'WorkoutApp: RunnerUp import WARNING: reading $localDir failed ' + '($error); the PC copy still completes the import.', + ); + } + } + + /// Stores one export; false when it is not a usable run. + Future _ingest(String name, Uint8List bytes, ImportTally tally) async { + final String xml; + final RunTrack decoded; + try { + xml = utf8.decode(bytes); + decoded = decodeTcx(xml, fileName: name); + } on FormatException catch (error) { + tally.skipped++; + debugPrint( + 'WorkoutApp: RunnerUp import WARNING: $name is not a usable run ' + '($error); skipped for good.', + ); + return false; + } + // 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. + final track = RunTrack( + id: decoded.id, + sport: decoded.sport, + startedAt: decoded.startedAt, + points: decoded.points, + totalSeconds: decoded.totalSeconds, + distanceM: decoded.distanceM, + source: RunSource.runnerupImport, + ); + final added = await StorageService.instance.insertRun( + track, + tcxFilename: name, + tcxSha256: tcxSha256(xml), + uploadState: RunUploadState.never, + ); + added ? tally.imported++ : tally.duplicates++; + return true; + } + + /// PUTs the marker; returns the failures (empty once the PC has it). + Future> _deliver(SharedPreferences prefs, String marker) async { + final login = await _loadLogin(); + String? failure; + if (login == null) { + failure = 'no dufs login in secure storage, so the PC was not told'; + } else { + try { + await putImportMarker(_client, login, marker); + } on Exception catch (error) { + failure = 'telling the PC failed: $error'; + } + } + if (failure != null) { + debugPrint('WorkoutApp: RunnerUp import WARNING: $failure'); + runnerUpImportStatus.value = RunnerUpImportStatus.failed; + return [failure]; + } + await prefs.remove(kImportMarkerKey); + runnerUpImportStatus.value = RunnerUpImportStatus.done; + return const []; + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/runnerup_import_badge.dart b/stronglift_replacement/workout_app/lib/widgets/runnerup_import_badge.dart new file mode 100644 index 0000000..59717ee --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/runnerup_import_badge.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; + +/// The RunnerUp history import's state as a glyph, never as words: a small +/// spinner while it runs, a warning glyph when it failed (the next start +/// retries), nothing when it is idle or finished. +class RunnerUpImportBadge extends StatelessWidget { + /// Creates the badge; [status] defaults to the importer's live status. + const RunnerUpImportBadge({super.key, this.status}); + + /// Overrides [runnerUpImportStatus] (tests). + final ValueNotifier? status; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: status ?? runnerUpImportStatus, + builder: (context, state, _) => switch (state) { + RunnerUpImportStatus.running => const Padding( + padding: EdgeInsets.all(14), + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + RunnerUpImportStatus.failed => Padding( + padding: const EdgeInsets.all(14), + child: Icon( + Icons.sync_problem, + color: Theme.of(context).colorScheme.error, + ), + ), + RunnerUpImportStatus.idle || + RunnerUpImportStatus.done => const SizedBox.shrink(), + }, + ); + } +} diff --git a/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart new file mode 100644 index 0000000..964edd1 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:io'; +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/services/dufs_credentials.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/services/storage_service.dart'; + +const String kFixtureDir = '../../screen_locker/tests/fixtures/run_tcx'; + +/// The three golden exports, by file name. +Map fixtureTcx() => { + for (final f in Directory(kFixtureDir).listSync().whereType()) + if (f.path.endsWith('.tcx')) f.uri.pathSegments.last: f.readAsBytesSync(), +}; + +final DufsCredentials kLogin = DufsCredentials( + url: 'https://dufs.test/', + user: 'runnerup', + password: 'pw', +); + +Future fakeLogin() async => kLogin; + +/// A dufs stand-in that records every request; nothing touches a network. +class FakeDufs { + FakeDufs(this.files); + + /// `processed/` contents by name. + final Map files; + + /// `METHOD /path` of every request, in order. + final List requests = []; + + /// The marker bodies PUT so far. + final List markers = []; + + int listStatus = 207; + int markerStatus = 201; + Set failingGets = {}; + bool unreachable = false; + String? depth; + + late final MockClient client = MockClient((request) async { + requests.add('${request.method} ${request.url.path}'); + if (unreachable) throw const SocketException('network is down'); + if (request.headers['Authorization'] != kLogin.authorization) { + return http.Response('unauthorized', 401); + } + if (request.method == 'PROPFIND') { + depth = request.headers['Depth']; + return http.Response( + listStatus == 207 ? multistatus(files.keys) : 'denied', + listStatus, + ); + } + if (request.method == 'PUT') { + markers.add(request.body); + return http.Response('', markerStatus); + } + final name = Uri.decodeComponent(request.url.pathSegments.last); + if (failingGets.contains(name)) return http.Response('boom', 500); + return http.Response.bytes(files[name]!, 200); + }); + + Iterable get puts => requests.where((r) => r.startsWith('PUT ')); + Iterable get gets => requests.where((r) => r.startsWith('GET ')); +} + +/// A dufs-shaped multistatus: the collection first, then each file. +String multistatus(Iterable names) => + ''' + + +/RunnerUp/processed/ + +HTTP/1.1 200 OK +${names.map((n) => '/RunnerUp/processed/${Uri.encodeComponent(n)}HTTP/1.1 200 OK').join('\n')} +/RunnerUp/processed/same.gpx +'''; + +Uint8List utf8Bytes(String s) => Uint8List.fromList(utf8.encode(s)); + +final DateTime kNow = DateTime.utc(2026, 11, 10, 8); + +RunnerUpImporter makeImporter( + FakeDufs dufs, { + Future Function()? canRead, + String localDir = '/nonexistent/RunnerUp', + bool android = true, +}) => RunnerUpImporter( + client: dufs.client, + loadLogin: fakeLogin, + canReadStorage: canRead ?? () async => false, + localDir: localDir, + isAndroid: android, + now: () => kNow, +); + +Future> storedRuns() => StorageService.instance.listRuns(); diff --git a/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart new file mode 100644 index 0000000..97ee414 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart @@ -0,0 +1,174 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '_runnerup_fake_dufs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeDufs dufs; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + Sandbox.enabled = false; + runnerUpImportStatus.value = RunnerUpImportStatus.idle; + StorageService.resetForTesting(); + await StorageService.init(); + dufs = FakeDufs(fixtureTcx()); + }); + + tearDown(() => Sandbox.enabled = false); + + test( + '401 leaves it incomplete and failed, then a fix completes it', + () async { + dufs.listStatus = 401; + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures.single, contains('401')); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + expect(dufs.puts, isEmpty); + expect(await storedRuns(), isEmpty); + + dufs.listStatus = 207; + final retry = await makeImporter(dufs).runIfNeeded(); + expect(retry.failures, isEmpty); + expect(retry.imported, 3); + expect(dufs.puts, hasLength(1)); + }, + ); + + test( + 'one failed download keeps it incomplete; the rest are stored', + () async { + final bad = dufs.files.keys.first; + dufs.failingGets = {bad}; + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures.single, contains(bad)); + expect(report.imported, 2); + expect(dufs.puts, isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + + dufs.failingGets = {}; + final retry = await makeImporter(dufs).runIfNeeded(); + expect(retry.failures, isEmpty); + expect(retry.imported, 1); + expect(retry.duplicates, 2); + expect(dufs.puts, hasLength(1)); + expect(await storedRuns(), hasLength(3)); + }, + ); + + test('an unreachable PC is a failure, not a crash', () async { + dufs.unreachable = true; + final report = await makeImporter(dufs).runIfNeeded(); + expect(report.failures.single, contains('network is down')); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + }); + + test('a file that is no usable run is skipped for good', () async { + dufs.files['junk_Running.tcx'] = utf8Bytes('not xml'); + dufs.files['bytes_Running.tcx'] = Uint8List.fromList([0xff, 0xfe, 0xfd]); + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures, isEmpty); + expect(report.skipped, 2); + final marker = jsonDecode(dufs.markers.single) as Map; + expect(marker['count'], 3); + expect(marker['tcx_names'], isNot(contains('junk_Running.tcx'))); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + }); + + test( + 'a failed marker PUT keeps the import and retries only the PUT', + () async { + dufs.markerStatus = 500; + final first = await makeImporter(dufs).runIfNeeded(); + + expect(first.failures.single, contains('500')); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + expect(await storedRuns(), hasLength(3)); + + dufs.markerStatus = 201; + dufs.requests.clear(); + final retry = await makeImporter(dufs).runIfNeeded(); + + expect(retry.failures, isEmpty); + expect(dufs.requests, ['PUT /RunnerUp/.workout_app_import.json']); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + dufs.requests.clear(); + await makeImporter(dufs).runIfNeeded(); + expect( + dufs.requests, + isEmpty, + reason: 'marker delivered: truly finished', + ); + }, + ); + + test('without a dufs login it fails loudly and fetches nothing', () async { + final importer = RunnerUpImporter( + client: dufs.client, + loadLogin: () async => null, + canReadStorage: () async => false, + isAndroid: true, + ); + final report = await importer.runIfNeeded(); + expect(report.failures.single, contains('no dufs login')); + expect(dufs.requests, isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + }); + + test('the login vanishing before the marker PUT is a failure too', () async { + var calls = 0; + final importer = RunnerUpImporter( + client: dufs.client, + loadLogin: () async => calls++ == 0 ? kLogin : null, + canReadStorage: () async => false, + isAndroid: true, + ); + final report = await importer.runIfNeeded(); + expect(report.failures.single, contains('PC was not told')); + expect(dufs.puts, isEmpty); + }); + + test('the sandbox never imports', () async { + Sandbox.enabled = true; + final report = await makeImporter(dufs).runIfNeeded(); + await SandboxLog.flush(); + expect(report.imported, 0); + expect(dufs.requests, isEmpty); + expect(await storedRuns(), isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle); + }); + + test('off the phone it does nothing (no early marker)', () async { + await makeImporter(dufs, android: false).runIfNeeded(); + expect(dufs.requests, isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle); + }); + + test('with every default it is inert on the desktop test host', () async { + final report = await RunnerUpImporter().runIfNeeded(); + expect(report.imported, 0); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart new file mode 100644 index 0000000..d1dd586 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart @@ -0,0 +1,124 @@ +@TestOn('vm') +library; + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/services/runnerup_dav.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'; + +import '_runnerup_fake_dufs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeDufs dufs; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + Sandbox.enabled = false; + runnerUpImportStatus.value = RunnerUpImportStatus.idle; + StorageService.resetForTesting(); + await StorageService.init(); + dufs = FakeDufs(fixtureTcx()); + }); + + tearDown(() => Sandbox.enabled = false); + + test( + 'imports the PC history as display-only runs, then tells the PC', + () async { + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures, isEmpty); + expect(report.imported, 3); + expect(dufs.depth, '1'); + final runs = await storedRuns(); + expect(runs, hasLength(3)); + for (final run in runs) { + expect(run.track.source, RunSource.runnerupImport); + expect(run.uploadState, RunUploadState.never); + } + // The walk is identified by its file name alone. + expect(runs.map((r) => r.track.sport), contains(RunSport.walking)); + // The multi-lap RunnerUp file is summed like the PC sums it. + final multi = runs.firstWhere((r) => r.tcxFilename.contains('08-12')); + expect(multi.track.distanceM, greaterThan(0)); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + + expect(dufs.puts, ['PUT /RunnerUp/.workout_app_import.json']); + final marker = jsonDecode(dufs.markers.single) as Map; + expect(marker['completed_at'], '2026-11-10T08:00:00.000Z'); + expect(marker['count'], 3); + expect(marker['tcx_names'], dufs.files.keys.toList()..sort()); + }, + ); + + test( + 'an app-made TCX that came back is still history, never pending', + () async { + final name = dufs.files.keys.firstWhere((n) => n.contains('17-30-00')); + expect( + decodeTcx(utf8.decode(dufs.files[name]!), fileName: name).source, + RunSource.app, + reason: + 'fixture must be an app-made file for this test to mean anything', + ); + await makeImporter(dufs).runIfNeeded(); + final stored = (await storedRuns()).firstWhere( + (r) => r.tcxFilename == name, + ); + expect(stored.track.source, RunSource.runnerupImport); + expect(stored.uploadState, RunUploadState.never); + }, + ); + + test('never PUTs a tcx anywhere; the only write is the dotfile', () async { + await makeImporter(dufs).runIfNeeded(); + expect(dufs.puts, hasLength(1)); + expect(dufs.puts.single, startsWith('PUT /RunnerUp/.')); + expect(dufs.puts.single, isNot(contains('.tcx'))); + }); + + test('a rerun is a no-op once complete: no requests at all', () async { + await makeImporter(dufs).runIfNeeded(); + final before = dufs.requests.length; + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.imported, 0); + expect(dufs.requests, hasLength(before)); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + expect(await storedRuns(), hasLength(3)); + }); + + test( + 'forgetting completion re-imports nothing new (dedupe by hash)', + () async { + await makeImporter(dufs).runIfNeeded(); + SharedPreferences.setMockInitialValues({}); + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.imported, 0); + expect(report.duplicates, 3); + expect(await storedRuns(), hasLength(3)); + }, + ); + + test('the marker path is a dotfile at the top of /RunnerUp', () { + expect(kImportMarkerPath, '/RunnerUp/.workout_app_import.json'); + expect(kImportMarkerPath.split('/').last, startsWith('.')); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart new file mode 100644 index 0000000..3c898a5 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart @@ -0,0 +1,167 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:workout_app/services/runnerup_dav.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../fake_secure_storage.dart'; +import '_runnerup_fake_dufs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + group('the phone folder scan', () { + late Directory dir; + late FakeDufs dufs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + StorageService.resetForTesting(); + await StorageService.init(); + dufs = FakeDufs(fixtureTcx()); + dir = Directory.systemTemp.createTempSync('runnerup_local'); + addTearDown(() => dir.deleteSync(recursive: true)); + }); + + RunnerUpImporter importer({bool canRead = true}) => RunnerUpImporter( + client: dufs.client, + loadLogin: fakeLogin, + canReadStorage: () async => canRead, + localDir: dir.path, + isAndroid: true, + ); + + test('adds a phone-only export, dedupes the one the PC has', () async { + final shared = dufs.files.entries.first; + File('${dir.path}/${shared.key}').writeAsBytesSync(shared.value); + final onlyHere = utf8 + .decode(dufs.files.entries.last.value) + .replaceAll('2026-11-09T', '2026-11-08T'); + File( + '${dir.path}/RunnerUp_2026-11-08-17-30-00_Running.tcx', + ).writeAsStringSync(onlyHere); + File('${dir.path}/notes.txt').writeAsStringSync('ignored'); + + final report = await importer().runIfNeeded(); + + expect(report.failures, isEmpty); + expect(report.imported, 4); + expect(report.duplicates, 1); + final stored = await StorageService.instance.listRuns(); + 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; + expect(marker['count'], 3); + }); + + test('an unreadable file warns but never blocks completion', () async { + final locked = File('${dir.path}/locked_Running.tcx') + ..writeAsStringSync('x'); + Process.runSync('chmod', ['000', locked.path]); + + final report = await importer().runIfNeeded(); + + expect(report.failures, isEmpty); + expect(dufs.puts, hasLength(1)); + }); + + test('a missing folder is fine', () async { + final report = await RunnerUpImporter( + client: dufs.client, + loadLogin: fakeLogin, + canReadStorage: () async => true, + localDir: '${dir.path}/missing', + isAndroid: true, + ).runIfNeeded(); + expect(report.failures, isEmpty); + expect(report.imported, 3); + }); + }); + + group('multistatus parsing', () { + test('keeps tcx names only, decoded, once each', () { + const xml = ''' + + +/RunnerUp/processed/ +/RunnerUp/processed/a%20b_Running.tcx +https://h.test/RunnerUp/processed/C_Walking.TCX +/RunnerUp/processed/C_Walking.TCX +/RunnerUp/processed/a.gpx +/RunnerUp/processed/.hidden.tcx +'''; + expect(tcxNamesFromMultistatus(xml), [ + 'C_Walking.TCX', + 'a b_Running.tcx', + ]); + }); + + test('a 207 that is not XML is an error the caller can report', () { + expect(() => tcxNamesFromMultistatus('<<<'), throwsA(isA())); + }); + + test('DavException reads as its sentence', () { + expect( + const DavException('GET x answered 500').toString(), + contains('500'), + ); + }); + }); + + group('DufsCredentials', () { + test('round-trips through secure storage under the shared keys', () async { + installFakeSecureStorage(); + expect(await DufsCredentials.load(), isNull); + + await DufsCredentials( + url: 'https://h.test///', + user: 'u', + password: 'p', + ).save(); + final back = await DufsCredentials.load(); + + expect(back!.url, 'https://h.test'); + expect(back.user, 'u'); + expect(back.password, 'p'); + expect(back.authorization, 'Basic ${base64Encode(utf8.encode('u:p'))}'); + expect( + [kDufsUrlKey, kDufsUserKey, kDufsPasswordKey], + ['dufs_url', 'dufs_user', 'dufs_password'], + ); + }); + + test('a half-provisioned login is no login', () async { + installFakeSecureStorage(initial: {kDufsUrlKey: 'https://h.test'}); + expect(await DufsCredentials.load(), isNull); + }); + + test('dufs_login.json needs all three non-empty strings', () { + expect( + DufsCredentials.fromJson({'url': 'u', 'user': 'a', 'password': 'b'}), + isNotNull, + ); + expect(DufsCredentials.fromJson({'url': 'u', 'user': 'a'}), isNull); + expect( + DufsCredentials.fromJson({'url': 'u', 'user': '', 'password': 'b'}), + isNull, + ); + expect( + DufsCredentials.fromJson({'url': 1, 'user': 'a', 'password': 'b'}), + isNull, + ); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/runnerup_import_badge_test.dart b/stronglift_replacement/workout_app/test/widgets/runnerup_import_badge_test.dart new file mode 100644 index 0000000..3eabd79 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/runnerup_import_badge_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/widgets/runnerup_import_badge.dart'; + +void main() { + Future pump( + WidgetTester tester, + ValueNotifier? status, + ) => tester.pumpWidget( + MaterialApp( + home: Scaffold(body: RunnerUpImportBadge(status: status)), + ), + ); + + testWidgets('shows a spinner while importing, a glyph on failure, and ' + 'never any text', (tester) async { + final status = ValueNotifier(RunnerUpImportStatus.idle); + await pump(tester, status); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byIcon(Icons.sync_problem), findsNothing); + + status.value = RunnerUpImportStatus.running; + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + status.value = RunnerUpImportStatus.failed; + await tester.pump(); + expect(find.byIcon(Icons.sync_problem), findsOneWidget); + expect(find.byType(Text), findsNothing); + + status.value = RunnerUpImportStatus.done; + await tester.pump(); + expect(find.byIcon(Icons.sync_problem), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('listens to the live importer status by default', (tester) async { + runnerUpImportStatus.value = RunnerUpImportStatus.failed; + await pump(tester, null); + expect(find.byIcon(Icons.sync_problem), findsOneWidget); + runnerUpImportStatus.value = RunnerUpImportStatus.idle; + }); +} From 0411116f24b9ccec6a88942b60bc763ea18af328 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:08:40 +0100 Subject: [PATCH 7/8] Harden RunnerUp retirement after review Silent-failure checker, mypy, pylint now clean. Hidden packages count as installed (pm list -u), pull gets a long timeout, wifi entries are matched by ro.serialno, and a missing log/drop dir is a warning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- screen_locker/_runnerup_retire.py | 90 +++++--------- screen_locker/_runnerup_retire_adb.py | 62 ++++++++-- screen_locker/_runnerup_retire_result.py | 62 ++++++++++ screen_locker/_runnerup_retire_state.py | 10 +- screen_locker/_runnerup_retire_tcx.py | 26 ++-- screen_locker/runnerup_retire.py | 10 +- screen_locker/tests/_retire_helpers.py | 9 +- screen_locker/tests/test_runnerup_retire.py | 51 ++++++-- .../tests/test_runnerup_retire_adb.py | 111 +++++++++++------ .../tests/test_runnerup_retire_cli.py | 28 +++-- .../tests/test_runnerup_retire_marker.py | 117 ++++++++++++++++++ .../tests/test_runnerup_retire_state.py | 52 ++++++++ .../tests/test_runnerup_retire_tcx.py | 113 +++-------------- 13 files changed, 504 insertions(+), 237 deletions(-) create mode 100644 screen_locker/_runnerup_retire_result.py create mode 100644 screen_locker/tests/test_runnerup_retire_marker.py create mode 100644 screen_locker/tests/test_runnerup_retire_state.py diff --git a/screen_locker/_runnerup_retire.py b/screen_locker/_runnerup_retire.py index d139c2b..9cf2159 100644 --- a/screen_locker/_runnerup_retire.py +++ b/screen_locker/_runnerup_retire.py @@ -16,17 +16,17 @@ from __future__ import annotations from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta -from enum import StrEnum import logging from pathlib import Path from typing import TYPE_CHECKING from screen_locker._runnerup_retire_adb import PACKAGE, AdbError +from screen_locker._runnerup_retire_result import LEVEL, Outcome, RetireResult from screen_locker._runnerup_retire_state import load_state, save_state -from screen_locker._runnerup_retire_tcx import check_marker, find_anchor +from screen_locker._runnerup_retire_tcx import app_run_ends, check_marker, find_anchor if TYPE_CHECKING: - from screen_locker._runnerup_retire_adb import Phone + from screen_locker._runnerup_retire_adb import PhoneLike from screen_locker._runnerup_retire_state import RetireState _logger = logging.getLogger(__name__) @@ -38,57 +38,6 @@ LOG_FILE = Path(__file__).resolve().parent / "log.json" MARKER_NAME = ".workout_app_import.json" -class Outcome(StrEnum): - """How a run ended; the value is what the journal shows.""" - - UNINSTALLED = "uninstalled" - ALREADY_GONE = "already-gone" - ALREADY_DONE = "already-done" - NO_ANCHOR = "no-anchor" - NOT_DUE = "not-due" - DRY_RUN = "dry-run" - DEVICE_ABSENT = "device-absent" - MARKER_INVALID = "marker-invalid" - ADB_MISSING = "adb-missing" - PHONE_ERROR = "phone-error" - BACKUP_FAILED = "backup-failed" - UNINSTALL_FAILED = "uninstall-failed" - - -# Level per outcome; the failures also make the unit exit non-zero. -_LEVEL = { - Outcome.DEVICE_ABSENT: logging.WARNING, - Outcome.MARKER_INVALID: logging.WARNING, - Outcome.ADB_MISSING: logging.ERROR, - Outcome.PHONE_ERROR: logging.ERROR, - Outcome.BACKUP_FAILED: logging.ERROR, - Outcome.UNINSTALL_FAILED: logging.ERROR, -} -_NONZERO = frozenset( - { - Outcome.MARKER_INVALID, - Outcome.ADB_MISSING, - Outcome.PHONE_ERROR, - Outcome.BACKUP_FAILED, - Outcome.UNINSTALL_FAILED, - } -) - - -@dataclass(frozen=True) -class RetireResult: - """What happened and why, in a sentence a human can act on.""" - - outcome: Outcome - reason: str - logged: bool = False # the failure was already logged where it was caught - - @property - def ok(self) -> bool: - """False for outcomes the unit should surface as failed.""" - return self.outcome not in _NONZERO - - @dataclass(frozen=True) class Paths: """Every on-disk location, so tests (and ``--status``) can redirect them.""" @@ -128,7 +77,7 @@ def _finish(paths: Paths, state: RetireState, now: datetime, why: str) -> None: ) -def _probe(phone: Phone) -> RetireResult | tuple[str, bool]: +def _probe(phone: PhoneLike) -> RetireResult | tuple[str, bool]: """``(serial, installed)`` if the phone answers, else the result explaining why.""" if not phone.available(): return RetireResult( @@ -149,7 +98,7 @@ def _probe(phone: Phone) -> RetireResult | tuple[str, bool]: def _backup( - paths: Paths, phone: Phone, state: RetireState, serial: str, now: datetime + paths: Paths, phone: PhoneLike, state: RetireState, serial: str, now: datetime ) -> RetireState | RetireResult: """Back the phone's exports up once; a later retry reuses a finished backup.""" if state.backup_path and Path(state.backup_path).is_dir(): @@ -167,7 +116,7 @@ def _backup( def _retire( - paths: Paths, phone: Phone, state: RetireState, now: datetime, *, dry_run: bool + paths: Paths, phone: PhoneLike, state: RetireState, now: datetime, *, dry_run: bool ) -> RetireResult: """Everything after "it is due and the marker is good": the phone steps.""" probed = _probe(phone) @@ -199,6 +148,21 @@ def _retire( ) +def _no_anchor(paths: Paths) -> RetireResult: + """Why there is no anchor; a missing source is a warning, not "nothing yet".""" + absent = [str(p) for p in (paths.watch_dir, paths.log_file) if not p.exists()] + if absent: + return RetireResult( + Outcome.SOURCES_MISSING, + f"cannot look for app runs, missing: {', '.join(absent)}", + ) + seen = len(app_run_ends(paths.anchor_dirs())) + return RetireResult( + Outcome.NO_ANCHOR, + f"no app-recorded run credited yet ({seen} app TCX on disk); nothing to retire", + ) + + def _gate( paths: Paths, state: RetireState, now: datetime, *, dry_run: bool ) -> RetireState | RetireResult: @@ -209,9 +173,7 @@ def _gate( ) anchor = derive_anchor(paths, state) if anchor is None: - return RetireResult( - Outcome.NO_ANCHOR, "no app-recorded run credited yet; nothing to retire" - ) + return _no_anchor(paths) if not state.anchor_at: state = replace(state, anchor_at=anchor.isoformat()) if not dry_run: @@ -230,7 +192,11 @@ def _gate( def run_retire( - paths: Paths, phone: Phone, now: datetime | None = None, *, dry_run: bool = False + paths: Paths, + phone: PhoneLike, + now: datetime | None = None, + *, + dry_run: bool = False, ) -> RetireResult: """One pass: uninstall RunnerUp if (and only if) every condition holds.""" when = now or datetime.now(UTC) @@ -241,7 +207,7 @@ def run_retire( result = _retire(paths, phone, gated, when, dry_run=dry_run) if not result.logged: _logger.log( - _LEVEL.get(result.outcome, logging.INFO), + LEVEL.get(result.outcome, logging.INFO), "RunnerUp retirement: %s: %s", result.outcome.value, result.reason, diff --git a/screen_locker/_runnerup_retire_adb.py b/screen_locker/_runnerup_retire_adb.py index 54d25a7..2157a1c 100644 --- a/screen_locker/_runnerup_retire_adb.py +++ b/screen_locker/_runnerup_retire_adb.py @@ -13,7 +13,7 @@ import logging from pathlib import Path import shutil import subprocess -from typing import TYPE_CHECKING, Any, NoReturn +from typing import TYPE_CHECKING, Any, NoReturn, Protocol if TYPE_CHECKING: from collections.abc import Callable @@ -24,6 +24,9 @@ PHONE_SERIAL = "23181JEGR08034" PACKAGE = "org.runnerup.free" REMOTE_DIR = "/sdcard/Documents/RunnerUp" _ADB_TIMEOUT_S = 50 +# The whole export history (TCX + GPX) over wifi adb; the oneshot unit has no +# start timeout, so a slow pull is allowed to finish. +_PULL_TIMEOUT_S = 900 class AdbError(RuntimeError): @@ -42,6 +45,25 @@ class Device: reason: str +class PhoneLike(Protocol): + """What the retirement needs from a phone (tests substitute a fake).""" + + def available(self) -> bool: + """Whether adb can be run at all.""" + + def find_device(self) -> Device: + """The attached phone, or why there is none.""" + + def installed(self, dev: str, *, hidden: bool = True) -> bool: + """Whether RunnerUp is on the phone.""" + + def backup(self, dev: str, final: Path) -> tuple[Path, int]: + """Pull the exports to ``final``.""" + + def uninstall(self, dev: str) -> None: + """Uninstall RunnerUp or raise ``AdbError``.""" + + class Phone: """adb calls against one phone, with the process runner injectable.""" @@ -61,11 +83,11 @@ class Phone: """Whether an ``adb`` binary is on PATH.""" return self._which("adb") is not None - def _adb(self, *args: str) -> str: + def _adb(self, *args: str, timeout: int = _ADB_TIMEOUT_S) -> str: cmd = ["adb", *args] try: done = self._run( - cmd, capture_output=True, text=True, timeout=_ADB_TIMEOUT_S, check=False + cmd, capture_output=True, text=True, timeout=timeout, check=False ) except (OSError, subprocess.TimeoutExpired) as exc: msg = f"{' '.join(cmd)} did not run: {exc}" @@ -75,13 +97,30 @@ class Phone: _fail(f"{' '.join(cmd)} exited {done.returncode}: {detail}") return str(done.stdout) + def _is_phone(self, serial: str) -> bool: + """Whether an ``adb devices`` entry is the phone. + + USB and mDNS serials embed the phone's serial; a plain ``ip:port`` + wifi entry does not, so that one is asked for its ``ro.serialno``. + """ + if self.serial in serial: + return True + if ":" not in serial: + return False + try: + asked = self._adb("-s", serial, "shell", "getprop", "ro.serialno") + except AdbError as exc: + _logger.warning("Could not read the serial of %s (%s)", serial, exc) + return False + return asked.strip() == self.serial + def find_device(self) -> Device: """The attached phone, over USB or wifi (a wireless serial embeds it).""" listing = self._adb("devices") states: list[str] = [] for line in listing.splitlines()[1:]: serial, _, state = line.partition("\t") - if self.serial not in serial: + if not self._is_phone(serial): continue if state.strip() == "device": return Device(serial, "attached") @@ -90,9 +129,14 @@ class Phone: return Device(None, f"phone is {'/'.join(states)}, not ready") return Device(None, f"phone {self.serial} is not attached") - def installed(self, dev: str) -> bool: - """Whether the package is on the phone (exact ``package:`` line).""" - out = self._adb("-s", dev, "shell", "pm", "list", "packages", PACKAGE) + def installed(self, dev: str, *, hidden: bool = True) -> bool: + """Whether the package is on the phone (exact ``package:`` line). + + ``hidden`` adds ``-u`` so an app that focus mode hides or disables + still counts as present; it must not be mistaken for an uninstall. + """ + flag = ["-u"] if hidden else [] + out = self._adb("-s", dev, "shell", "pm", "list", "packages", *flag, PACKAGE) return f"package:{PACKAGE}" in out.split() def backup(self, dev: str, final: Path) -> tuple[Path, int]: @@ -108,7 +152,7 @@ class Phone: staging = final.with_name(final.name + ".partial") shutil.rmtree(staging, ignore_errors=True) staging.mkdir(parents=True) - self._adb("-s", dev, "pull", REMOTE_DIR, str(staging)) + self._adb("-s", dev, "pull", REMOTE_DIR, str(staging), timeout=_PULL_TIMEOUT_S) pulled = staging / Path(REMOTE_DIR).name got = len(list(pulled.rglob("*.tcx"))) if pulled.is_dir() else -1 if got < wanted: @@ -132,6 +176,6 @@ class Phone: raise AdbError(msg) from exc if "Success" not in out: _fail(f"uninstall did not report Success: {out.strip()!r}") - if self.installed(dev): + if self.installed(dev, hidden=False): _fail(f"{PACKAGE} is still listed after uninstall") _logger.info("Uninstalled %s from %s", PACKAGE, dev) diff --git a/screen_locker/_runnerup_retire_result.py b/screen_locker/_runnerup_retire_result.py new file mode 100644 index 0000000..28955a1 --- /dev/null +++ b/screen_locker/_runnerup_retire_result.py @@ -0,0 +1,62 @@ +"""Outcomes of one RunnerUp-retirement pass, and how loudly each is logged.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +import logging + +__all__ = ["LEVEL", "Outcome", "RetireResult"] + + +class Outcome(StrEnum): + """How a run ended; the value is what the journal shows.""" + + UNINSTALLED = "uninstalled" + ALREADY_GONE = "already-gone" + ALREADY_DONE = "already-done" + NO_ANCHOR = "no-anchor" + SOURCES_MISSING = "sources-missing" + NOT_DUE = "not-due" + DRY_RUN = "dry-run" + DEVICE_ABSENT = "device-absent" + MARKER_INVALID = "marker-invalid" + ADB_MISSING = "adb-missing" + PHONE_ERROR = "phone-error" + BACKUP_FAILED = "backup-failed" + UNINSTALL_FAILED = "uninstall-failed" + + +# Level per outcome; the failures also make the unit exit non-zero. +LEVEL = { + Outcome.DEVICE_ABSENT: logging.WARNING, + Outcome.SOURCES_MISSING: logging.WARNING, + Outcome.MARKER_INVALID: logging.WARNING, + Outcome.ADB_MISSING: logging.ERROR, + Outcome.PHONE_ERROR: logging.ERROR, + Outcome.BACKUP_FAILED: logging.ERROR, + Outcome.UNINSTALL_FAILED: logging.ERROR, +} +_NONZERO = frozenset( + { + Outcome.MARKER_INVALID, + Outcome.ADB_MISSING, + Outcome.PHONE_ERROR, + Outcome.BACKUP_FAILED, + Outcome.UNINSTALL_FAILED, + } +) + + +@dataclass(frozen=True) +class RetireResult: + """What happened and why, in a sentence a human can act on.""" + + outcome: Outcome + reason: str + logged: bool = False # the failure was already logged where it was caught + + @property + def ok(self) -> bool: + """False for outcomes the unit should surface as failed.""" + return self.outcome not in _NONZERO diff --git a/screen_locker/_runnerup_retire_state.py b/screen_locker/_runnerup_retire_state.py index 97186f0..c16b151 100644 --- a/screen_locker/_runnerup_retire_state.py +++ b/screen_locker/_runnerup_retire_state.py @@ -8,7 +8,7 @@ and never moved: a later, earlier-looking credit must not push the deadline. from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, fields from datetime import datetime import json import logging @@ -36,10 +36,10 @@ class RetireState: def load_state(path: Path) -> RetireState: """Read the state; a missing file is a fresh start, a bad one is loud.""" + if not path.exists(): + return RetireState() try: raw = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return RetireState() except (OSError, ValueError) as exc: _logger.warning( "Retire state %s is unreadable (%s): starting from scratch, so the " @@ -51,8 +51,8 @@ def load_state(path: Path) -> RetireState: if not isinstance(raw, dict): _logger.warning("Retire state %s is not a JSON object: ignored", path) return RetireState() - fields = RetireState.__dataclass_fields__ - clean = {k: v for k, v in raw.items() if k in fields and isinstance(v, str)} + known = {f.name for f in fields(RetireState)} + clean = {k: v for k, v in raw.items() if k in known and isinstance(v, str)} try: state = RetireState(**clean) _ = state.anchor diff --git a/screen_locker/_runnerup_retire_tcx.py b/screen_locker/_runnerup_retire_tcx.py index 885da7a..d9d51a2 100644 --- a/screen_locker/_runnerup_retire_tcx.py +++ b/screen_locker/_runnerup_retire_tcx.py @@ -116,16 +116,19 @@ def _credit_ends(log_file: Path) -> list[tuple[float, datetime]]: return found -def find_anchor(log_file: Path, dirs: Iterable[Path]) -> datetime | None: - """When the PC first credited an app-recorded run, or None if it never did.""" - ends = [ +def app_run_ends(dirs: Iterable[Path]) -> list[float]: + """The end time of every app-authored TCX that has a usable one.""" + return [ end for directory in dirs for path in list_tcx(directory) if is_app_tcx(path) and (end := _ended_at(path)) is not None ] - if not ends: - return None + + +def find_anchor(log_file: Path, dirs: Iterable[Path]) -> datetime | None: + """When the PC first credited an app-recorded run, or None if it never did.""" + ends = app_run_ends(dirs) credited = [ when for run_end, when in _credit_ends(log_file) @@ -164,12 +167,15 @@ def _marker_names(marker: object) -> tuple[list[str] | None, str]: def _read_marker(marker_file: Path) -> tuple[object, str]: """The parsed marker JSON, or ``(None, why it could not be read)``.""" - try: - return json.loads(marker_file.read_text(encoding="utf-8")), "" - except FileNotFoundError: + if not marker_file.exists(): why = f"{marker_file} is missing: the app has not imported yet" - except (OSError, ValueError) as exc: - why = f"{marker_file} is unreadable ({exc})" + else: + try: + return json.loads(marker_file.read_text(encoding="utf-8")), "" + except (OSError, ValueError) as exc: + why = f"{marker_file} is unreadable ({exc})" + _logger.warning("Import marker problem: %s", why) + return None, why _logger.warning("Import marker problem: %s", why) return None, why diff --git a/screen_locker/runnerup_retire.py b/screen_locker/runnerup_retire.py index 646eaeb..14cba01 100644 --- a/screen_locker/runnerup_retire.py +++ b/screen_locker/runnerup_retire.py @@ -14,6 +14,7 @@ import argparse from datetime import UTC, datetime import logging import sys +from typing import TYPE_CHECKING from screen_locker._runnerup_retire import ( GRACE, @@ -25,12 +26,15 @@ from screen_locker._runnerup_retire_adb import PHONE_SERIAL, AdbError, Phone from screen_locker._runnerup_retire_state import load_state from screen_locker._runnerup_retire_tcx import check_marker +if TYPE_CHECKING: + from screen_locker._runnerup_retire_adb import PhoneLike + __all__ = ["main", "status_lines"] _logger = logging.getLogger(__name__) -def _installed_line(phone: Phone, *, query: bool) -> str: +def _installed_line(phone: PhoneLike, *, query: bool) -> str: if not query: return "installed: not queried (add --query-phone)" try: @@ -44,7 +48,7 @@ def _installed_line(phone: Phone, *, query: bool) -> str: def status_lines( - paths: Paths, phone: Phone, now: datetime, *, query_phone: bool = False + paths: Paths, phone: PhoneLike, now: datetime, *, query_phone: bool = False ) -> list[str]: """The ``--status`` report. Read-only: nothing is created or saved.""" state = load_state(paths.state_file) @@ -69,7 +73,7 @@ def status_lines( def main(argv: list[str] | None = None) -> int: """CLI entry; returns the process exit code.""" - parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0]) parser.add_argument( "--status", action="store_true", help="print state, change nothing" ) diff --git a/screen_locker/tests/_retire_helpers.py b/screen_locker/tests/_retire_helpers.py index 50798c0..883dd45 100644 --- a/screen_locker/tests/_retire_helpers.py +++ b/screen_locker/tests/_retire_helpers.py @@ -87,23 +87,27 @@ class FakePhone: device: Device = field(default_factory=lambda: Device("S", "attached")) present: bool = True ask_error: AdbError | None = None - backup_error: AdbError | OSError | None = None + backup_error: Exception | None = None uninstall_error: AdbError | None = None calls: list[str] = field(default_factory=list) def available(self) -> bool: + """Available.""" return self.adb def find_device(self) -> Device: + """Find device.""" if self.ask_error: raise self.ask_error return self.device - def installed(self, dev: str) -> bool: + def installed(self, dev: str, *, hidden: bool = True) -> bool: + """Installed.""" self.calls.append(f"installed {dev}") return self.present def backup(self, dev: str, final: Path) -> tuple[Path, int]: + """Backup.""" self.calls.append("backup") if self.backup_error: raise self.backup_error @@ -111,6 +115,7 @@ class FakePhone: return final, 3 def uninstall(self, dev: str) -> None: + """Uninstall.""" self.calls.append("uninstall") if self.uninstall_error: raise self.uninstall_error diff --git a/screen_locker/tests/test_runnerup_retire.py b/screen_locker/tests/test_runnerup_retire.py index 1ccbfd0..82f69b0 100644 --- a/screen_locker/tests/test_runnerup_retire.py +++ b/screen_locker/tests/test_runnerup_retire.py @@ -25,23 +25,46 @@ if TYPE_CHECKING: from pathlib import Path -def go(paths: Paths, phone: FakePhone, now: datetime = DUE, **kw: bool): - return run_retire(paths, phone, now, **kw) # type: ignore[arg-type] +def go(paths: Paths, phone: FakePhone, now: datetime = DUE, *, dry_run: bool = False): + """Go.""" + return run_retire(paths, phone, now, dry_run=dry_run) def test_no_anchor_never_touches_anything(tmp_path: Path) -> None: + """Test no anchor never touches anything.""" paths = world(tmp_path, anchored=False) phone = FakePhone() result = go(paths, phone, DUE + timedelta(days=900)) assert result.outcome is Outcome.NO_ANCHOR assert result.ok - assert phone.calls == [] + assert not phone.calls assert not paths.state_file.exists() +def test_missing_sources_are_a_warning_not_a_quiet_nothing( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Test missing sources are a warning not a quiet nothing.""" + paths = Paths(tmp_path / "s.json", tmp_path / "nodrop", tmp_path / "nolog.json") + with caplog.at_level(logging.INFO): + result = go(paths, FakePhone()) + assert result.outcome is Outcome.SOURCES_MISSING + assert result.ok + assert "nodrop" in result.reason + assert "nolog.json" in result.reason + assert caplog.records[-1].levelno == logging.WARNING + + +def test_no_anchor_says_how_many_app_runs_it_saw(tmp_path: Path) -> None: + """Test no anchor says how many app runs it saw.""" + paths = world(tmp_path, anchored=False) + assert "(1 app TCX on disk)" in go(paths, FakePhone()).reason + + def test_not_due_persists_the_anchor_and_says_when( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Test not due persists the anchor and says when.""" paths = world(tmp_path) with caplog.at_level(logging.INFO): result = go(paths, FakePhone(), DUE - timedelta(minutes=1)) @@ -52,6 +75,7 @@ def test_not_due_persists_the_anchor_and_says_when( def test_stored_anchor_wins_over_the_log(tmp_path: Path) -> None: + """Test stored anchor wins over the log.""" paths = world(tmp_path, anchored=False) save_state(paths.state_file, RetireState(anchor_at=ANCHOR.isoformat())) assert go(paths, FakePhone(), ANCHOR).outcome is Outcome.NOT_DUE @@ -60,6 +84,7 @@ def test_stored_anchor_wins_over_the_log(tmp_path: Path) -> None: def test_dry_run_writes_nothing_and_stops_before_the_phone_changes( tmp_path: Path, ) -> None: + """Test dry run writes nothing and stops before the phone changes.""" paths = world(tmp_path) phone = FakePhone() result = go(paths, phone, dry_run=True) @@ -75,6 +100,7 @@ def test_dry_run_writes_nothing_and_stops_before_the_phone_changes( def test_invalid_marker_blocks_the_uninstall_loudly( tmp_path: Path, caplog: pytest.LogCaptureFixture, variant: str ) -> None: + """Test invalid marker blocks the uninstall loudly.""" paths = world(tmp_path, marker=variant == "mismatch") if variant == "mismatch": put_marker(paths.watch_dir, [RUN], count=2) @@ -84,11 +110,12 @@ def test_invalid_marker_blocks_the_uninstall_loudly( assert result.outcome is Outcome.MARKER_INVALID assert not result.ok assert "NOT uninstalling" in result.reason - assert phone.calls == [] + assert not phone.calls assert caplog.records[-1].levelno == logging.WARNING def test_adb_missing_is_a_failure(tmp_path: Path) -> None: + """Test adb missing is a failure.""" result = go(world(tmp_path), FakePhone(adb=False)) assert result.outcome is Outcome.ADB_MISSING assert not result.ok @@ -97,18 +124,20 @@ def test_adb_missing_is_a_failure(tmp_path: Path) -> None: def test_absent_phone_retries_quietly_but_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Test absent phone retries quietly but warns.""" phone = FakePhone(device=Device(None, "phone S is not attached")) with caplog.at_level(logging.WARNING): result = go(world(tmp_path), phone) assert result.outcome is Outcome.DEVICE_ABSENT assert result.ok assert "not attached" in caplog.text - assert phone.calls == [] + assert not phone.calls def test_adb_error_while_asking_is_a_logged_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Test adb error while asking is a logged failure.""" phone = FakePhone(ask_error=AdbError("server died")) with caplog.at_level(logging.ERROR): result = go(world(tmp_path), phone) @@ -118,6 +147,7 @@ def test_adb_error_while_asking_is_a_logged_failure( def test_already_uninstalled_is_recorded_done(tmp_path: Path) -> None: + """Test already uninstalled is recorded done.""" paths = world(tmp_path) result = go(paths, FakePhone(present=False)) assert result.outcome is Outcome.ALREADY_GONE @@ -126,6 +156,7 @@ def test_already_uninstalled_is_recorded_done(tmp_path: Path) -> None: def test_full_run_backs_up_then_uninstalls_then_never_again(tmp_path: Path) -> None: + """Test full run backs up then uninstalls then never again.""" paths = world(tmp_path) phone = FakePhone() result = go(paths, phone) @@ -133,21 +164,22 @@ def test_full_run_backs_up_then_uninstalls_then_never_again(tmp_path: Path) -> N assert phone.calls == ["installed S", "backup", "uninstall"] backup = paths.watch_dir / "runnerup_phone_backup_2026-11-08" assert backup.is_dir() - assert list(paths.watch_dir.glob("*.tcx")) == [] # nothing top-level + assert not list(paths.watch_dir.glob("*.tcx")) # nothing top-level state = load_state(paths.state_file) assert state.anchor == ANCHOR assert state.backup_path == str(backup) assert state.done_at again = FakePhone() assert go(paths, again).outcome is Outcome.ALREADY_DONE - assert again.calls == [] + assert not again.calls @pytest.mark.parametrize("error", [AdbError("short pull"), OSError("disk full")]) def test_failed_backup_blocks_the_uninstall( tmp_path: Path, caplog: pytest.LogCaptureFixture, error: Exception ) -> None: - phone = FakePhone(backup_error=error) # type: ignore[arg-type] + """Test failed backup blocks the uninstall.""" + phone = FakePhone(backup_error=error) with caplog.at_level(logging.ERROR): result = go(world(tmp_path), phone) assert result.outcome is Outcome.BACKUP_FAILED @@ -157,6 +189,7 @@ def test_failed_backup_blocks_the_uninstall( def test_failed_uninstall_keeps_the_backup_for_the_retry(tmp_path: Path) -> None: + """Test failed uninstall keeps the backup for the retry.""" paths = world(tmp_path) first = FakePhone(uninstall_error=AdbError("DELETE_FAILED_DEVICE_POLICY_MANAGER")) result = go(paths, first) @@ -169,6 +202,7 @@ def test_failed_uninstall_keeps_the_backup_for_the_retry(tmp_path: Path) -> None def test_default_paths_are_the_module_constants() -> None: + """Test default paths are the module constants.""" paths = Paths.default() assert paths.state_file == rr.STATE_FILE assert paths.marker == rr.WATCH_DIR / rr.MARKER_NAME @@ -177,5 +211,6 @@ def test_default_paths_are_the_module_constants() -> None: def test_run_retire_defaults_to_the_real_clock(tmp_path: Path) -> None: + """Test run retire defaults to the real clock.""" paths = world(tmp_path) assert run_retire(paths, FakePhone(), dry_run=True).outcome is Outcome.DRY_RUN diff --git a/screen_locker/tests/test_runnerup_retire_adb.py b/screen_locker/tests/test_runnerup_retire_adb.py index 3786a89..65625f0 100644 --- a/screen_locker/tests/test_runnerup_retire_adb.py +++ b/screen_locker/tests/test_runnerup_retire_adb.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from pathlib import Path import subprocess from types import SimpleNamespace @@ -11,7 +10,6 @@ from typing import Any import pytest from screen_locker import _runnerup_retire_adb as ra -from screen_locker._runnerup_retire_state import RetireState, load_state, save_state SERIAL = ra.PHONE_SERIAL PKG_LINE = f"package:{ra.PACKAGE}\n" @@ -21,10 +19,12 @@ class FakeAdb: """A ``subprocess.run`` stand-in: replies by the adb sub-command.""" def __init__(self, replies: dict[str, Any] | None = None) -> None: + """Init .""" self.replies = replies or {} self.calls: list[list[str]] = [] def __call__(self, cmd: list[str], **_: object) -> SimpleNamespace: + """Call .""" self.calls.append(cmd) for key, reply in self.replies.items(): if key in " ".join(cmd): @@ -36,10 +36,12 @@ class FakeAdb: def phone(fake: FakeAdb, adb: str | None = "/usr/bin/adb") -> ra.Phone: + """Phone.""" return ra.Phone(run=fake, which=lambda _name: adb) def test_available_follows_which() -> None: + """Test available follows which.""" assert phone(FakeAdb()).available() is True assert phone(FakeAdb(), adb=None).available() is False @@ -48,11 +50,13 @@ def test_available_follows_which() -> None: "boom", [FileNotFoundError("adb"), subprocess.TimeoutExpired("adb", 1)] ) def test_adb_failing_to_run_is_an_adb_error(boom: BaseException) -> None: + """Test adb failing to run is an adb error.""" with pytest.raises(ra.AdbError, match="did not run"): phone(FakeAdb({"devices": boom})).find_device() def test_nonzero_exit_carries_the_output() -> None: + """Test nonzero exit carries the output.""" fake = FakeAdb({"devices": (1, "", "daemon not running")}) with pytest.raises(ra.AdbError, match="daemon not running"): phone(fake).find_device() @@ -73,12 +77,69 @@ def test_nonzero_exit_carries_the_output() -> None: ], ) def test_find_device(listing: str, serial: str | None, reason: str) -> None: + """Test find device.""" device = phone(FakeAdb({"devices": (0, listing, "")})).find_device() assert device.serial == serial assert reason in device.reason +def test_wifi_entry_is_asked_for_its_hardware_serial() -> None: + """Test wifi entry is asked for its hardware serial.""" + listing = "List of devices attached\n192.168.1.5:5555\tdevice\n" + fake = FakeAdb({"devices": (0, listing, ""), "getprop": (0, f"{SERIAL}\n", "")}) + device = phone(fake).find_device() + assert device.serial == "192.168.1.5:5555" + assert ["adb", "-s", "192.168.1.5:5555", "shell", "getprop", "ro.serialno"] in ( + fake.calls + ) + + +def test_wifi_entry_of_another_phone_is_not_ours() -> None: + """Test wifi entry of another phone is not ours.""" + listing = "List of devices attached\n10.0.0.2:5555\tdevice\n" + fake = FakeAdb({"devices": (0, listing, ""), "getprop": (0, "OTHER\n", "")}) + assert phone(fake).find_device().serial is None + + +def test_wifi_entry_that_will_not_answer_is_warned_about( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test wifi entry that will not answer is warned about.""" + listing = "List of devices attached\n10.0.0.2:5555\tdevice\n" + fake = FakeAdb({"devices": (0, listing, ""), "getprop": (1, "", "offline")}) + with caplog.at_level("WARNING"): + assert phone(fake).find_device().serial is None + assert "Could not read the serial" in caplog.text + + +def test_presence_probe_sees_hidden_packages_but_the_final_check_does_not() -> None: + """Test presence probe sees hidden packages but the final check does not.""" + fake = FakeAdb({"pm list": (0, PKG_LINE, "")}) + phone(fake).installed("dev") + phone(fake).installed("dev", hidden=False) + assert "-u" in fake.calls[0] + assert "-u" not in fake.calls[1] + + +def test_pull_gets_a_long_timeout_and_other_calls_the_short_one( + tmp_path: Path, +) -> None: + """Test pull gets a long timeout and other calls the short one.""" + timeouts: dict[str, object] = {} + make = _pull_creates(1) + + def run(cmd: list[str], **kw: object) -> SimpleNamespace: + """Run.""" + timeouts[cmd[3]] = kw["timeout"] + code, out, err = make(cmd) if cmd[3] == "pull" else (0, "a.tcx\n", "") + return SimpleNamespace(returncode=code, stdout=out, stderr=err) + + ra.Phone(run=run, which=lambda _n: "adb").backup("dev", tmp_path / "b") + assert timeouts == {"shell": 50, "pull": 900} + + def test_installed_needs_the_exact_package_line() -> None: + """Test installed needs the exact package line.""" yes = FakeAdb({"pm list": (0, PKG_LINE, "")}) assert phone(yes).installed("dev") is True assert yes.calls[0][:3] == ["adb", "-s", "dev"] @@ -87,7 +148,10 @@ def test_installed_needs_the_exact_package_line() -> None: def _pull_creates(count: int, root: str = "RunnerUp"): + """Pull creates.""" + def make(cmd: list[str]) -> tuple[int, str, str]: + """Make.""" folder = Path(cmd[-1]) / root folder.mkdir(parents=True) for i in range(count): @@ -98,6 +162,7 @@ def _pull_creates(count: int, root: str = "RunnerUp"): def test_backup_stages_then_renames_and_counts(tmp_path: Path) -> None: + """Test backup stages then renames and counts.""" fake = FakeAdb({"ls": (0, "a.tcx\nb.tcx\nb.gpx\n", ""), "pull": _pull_creates(2)}) final = tmp_path / "backup_2026" target, count = phone(fake).backup("dev", final) @@ -107,6 +172,7 @@ def test_backup_stages_then_renames_and_counts(tmp_path: Path) -> None: def test_backup_never_overwrites_an_existing_folder(tmp_path: Path) -> None: + """Test backup never overwrites an existing folder.""" final = tmp_path / "backup" final.mkdir() (tmp_path / "backup.partial").mkdir() # a stale half-pull is cleared @@ -116,6 +182,7 @@ def test_backup_never_overwrites_an_existing_folder(tmp_path: Path) -> None: def test_backup_short_pull_is_an_error_and_not_renamed(tmp_path: Path) -> None: + """Test backup short pull is an error and not renamed.""" fake = FakeAdb({"ls": (0, "a.tcx\nb.tcx\n", ""), "pull": _pull_creates(1)}) with pytest.raises(ra.AdbError, match="pulled 1 of 2"): phone(fake).backup("dev", tmp_path / "b") @@ -123,18 +190,21 @@ def test_backup_short_pull_is_an_error_and_not_renamed(tmp_path: Path) -> None: def test_backup_pull_that_creates_nothing_is_an_error(tmp_path: Path) -> None: + """Test backup pull that creates nothing is an error.""" fake = FakeAdb({"ls": (0, "a.tcx\n", "")}) with pytest.raises(ra.AdbError, match="pulled -1 of 1"): phone(fake).backup("dev", tmp_path / "b") def test_uninstall_succeeds_only_when_the_package_is_gone() -> None: + """Test uninstall succeeds only when the package is gone.""" fake = FakeAdb({"uninstall": (0, "Success\n", ""), "pm list": (0, "", "")}) phone(fake).uninstall("dev") assert ["adb", "-s", "dev", "uninstall", ra.PACKAGE] in fake.calls def test_uninstall_refusal_keeps_adbs_reason() -> None: + """Test uninstall refusal keeps adbs reason.""" fail = "Failure [DELETE_FAILED_DEVICE_POLICY_MANAGER]" fake = FakeAdb({"uninstall": (1, fail, "")}) with pytest.raises(ra.AdbError, match="DELETE_FAILED_DEVICE_POLICY_MANAGER"): @@ -142,47 +212,14 @@ def test_uninstall_refusal_keeps_adbs_reason() -> None: def test_uninstall_without_success_text_is_an_error() -> None: + """Test uninstall without success text is an error.""" fake = FakeAdb({"uninstall": (0, "weird\n", "")}) with pytest.raises(ra.AdbError, match="did not report Success"): phone(fake).uninstall("dev") def test_uninstall_that_leaves_the_package_is_an_error() -> None: + """Test uninstall that leaves the package is an error.""" fake = FakeAdb({"uninstall": (0, "Success\n", ""), "pm list": (0, PKG_LINE, "")}) with pytest.raises(ra.AdbError, match="still listed"): phone(fake).uninstall("dev") - - -def test_state_roundtrip_and_anchor(tmp_path: Path) -> None: - path = tmp_path / "deep" / "s.json" - assert load_state(path) == RetireState() - save_state(path, RetireState(anchor_at="2026-11-01T10:45:00+00:00")) - assert load_state(path).anchor is not None - assert RetireState().anchor is None - - -@pytest.mark.parametrize( - "body", ["{nope", "[]", json.dumps({"anchor_at": "not a date"})] -) -def test_bad_state_is_loud_and_ignored( - tmp_path: Path, body: str, caplog: pytest.LogCaptureFixture -) -> None: - path = tmp_path / "s.json" - path.write_text(body) - with caplog.at_level("WARNING"): - assert load_state(path) == RetireState() - assert "Retire state" in caplog.text - - -def test_unreadable_state_is_loud( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - with caplog.at_level("WARNING"): - assert load_state(tmp_path) == RetireState() - assert "unreadable" in caplog.text - - -def test_state_ignores_unknown_and_non_string_fields(tmp_path: Path) -> None: - path = tmp_path / "s.json" - path.write_text(json.dumps({"done_at": 5, "extra": "x", "backup_path": "/b"})) - assert load_state(path) == RetireState(backup_path="/b") diff --git a/screen_locker/tests/test_runnerup_retire_cli.py b/screen_locker/tests/test_runnerup_retire_cli.py index 9b5a2ea..b1f589f 100644 --- a/screen_locker/tests/test_runnerup_retire_cli.py +++ b/screen_locker/tests/test_runnerup_retire_cli.py @@ -19,12 +19,14 @@ if TYPE_CHECKING: def _tree(root: Path) -> set[str]: + """Tree.""" return {str(p.relative_to(root)) for p in root.rglob("*")} def test_status_without_anchor(tmp_path: Path) -> None: + """Test status without anchor.""" paths = world(tmp_path, anchored=False, marker=False) - lines = cli.status_lines(paths, FakePhone(), DUE) # type: ignore[arg-type] + lines = cli.status_lines(paths, FakePhone(), DUE) assert lines[0].startswith("anchor: none") assert lines[1] == "due: never" assert "INVALID" in lines[2] @@ -35,9 +37,10 @@ def test_status_without_anchor(tmp_path: Path) -> None: def test_status_is_strictly_read_only_and_marks_a_derived_anchor( tmp_path: Path, ) -> None: + """Test status is strictly read only and marks a derived anchor.""" paths = world(tmp_path) before = _tree(tmp_path) - lines = cli.status_lines(paths, FakePhone(), DUE - timedelta(days=1)) # type: ignore[arg-type] + lines = cli.status_lines(paths, FakePhone(), DUE - timedelta(days=1)) assert _tree(tmp_path) == before assert not paths.state_file.parent.exists() assert "derived, not yet stored" in lines[0] @@ -46,12 +49,13 @@ def test_status_is_strictly_read_only_and_marks_a_derived_anchor( def test_status_with_stored_anchor_and_done(tmp_path: Path) -> None: + """Test status with stored anchor and done.""" paths = world(tmp_path, anchored=False) save_state( paths.state_file, RetireState(anchor_at=ANCHOR.isoformat(), done_at="2026-11-09T00:00:00+00:00"), ) - lines = cli.status_lines(paths, FakePhone(), DUE) # type: ignore[arg-type] + lines = cli.status_lines(paths, FakePhone(), DUE) assert "(stored)" in lines[0] assert lines[3] == "done: 2026-11-09T00:00:00+00:00" @@ -69,15 +73,17 @@ def test_status_with_stored_anchor_and_done(tmp_path: Path) -> None: ], ) def test_status_query_phone(tmp_path: Path, phone: FakePhone, expected: str) -> None: + """Test status query phone.""" paths = world(tmp_path) - lines = cli.status_lines(paths, phone, DUE, query_phone=True) # type: ignore[arg-type] + lines = cli.status_lines(paths, phone, DUE, query_phone=True) assert lines[4] == expected def test_main_status_prints_and_exits_zero( capsys: pytest.CaptureFixture[str], ) -> None: - assert cli.main(["--status"]) == 0 # the isolated defaults: an empty world + """Test main status prints and exits zero.""" + assert not cli.main(["--status"]) # the isolated defaults: an empty world out = capsys.readouterr().out assert "anchor: none" in out assert "not queried" in out @@ -86,14 +92,16 @@ def test_main_status_prints_and_exits_zero( def test_main_status_query_phone_uses_the_serial( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: + """Test main status query phone uses the serial.""" seen: list[str] = [] def fake_phone(serial: str) -> FakePhone: + """Fake phone.""" seen.append(serial) return FakePhone() monkeypatch.setattr(cli, "Phone", fake_phone) - assert cli.main(["--status", "--query-phone", "--serial", "XYZ"]) == 0 + assert not cli.main(["--status", "--query-phone", "--serial", "XYZ"]) assert seen == ["XYZ"] assert "installed: yes" in capsys.readouterr().out @@ -105,9 +113,11 @@ def test_main_status_query_phone_uses_the_serial( def test_main_exit_code_follows_the_outcome( monkeypatch: pytest.MonkeyPatch, outcome: Outcome, code: int ) -> None: + """Test main exit code follows the outcome.""" calls: list[bool] = [] def fake_run(paths: Paths, phone: object, *, dry_run: bool) -> RetireResult: + """Fake run.""" calls.append(dry_run) return RetireResult(outcome, "why") @@ -117,13 +127,15 @@ def test_main_exit_code_follows_the_outcome( def test_main_with_nothing_to_retire_is_a_clean_noop() -> None: - assert cli.main([]) == 0 + """Test main with nothing to retire is a clean noop.""" + assert not cli.main([]) def test_status_marker_line_for_a_valid_marker_uses_real_files( tmp_path: Path, ) -> None: + """Test status marker line for a valid marker uses real files.""" paths = world(tmp_path, marker=False) put_marker(paths.watch_dir, [RUN]) now = datetime.now(UTC) - assert cli.status_lines(paths, FakePhone(), now)[2].startswith("marker: valid") # type: ignore[arg-type] + assert cli.status_lines(paths, FakePhone(), now)[2].startswith("marker: valid") diff --git a/screen_locker/tests/test_runnerup_retire_marker.py b/screen_locker/tests/test_runnerup_retire_marker.py new file mode 100644 index 0000000..f2ecedd --- /dev/null +++ b/screen_locker/tests/test_runnerup_retire_marker.py @@ -0,0 +1,117 @@ +"""RunnerUp retirement: validating the app's import marker against processed/.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from screen_locker import _runnerup_retire_tcx as rt +from screen_locker.tests._retire_helpers import APP, put_marker, put_tcx + +if TYPE_CHECKING: + from pathlib import Path + + +def _processed(tmp_path: Path) -> Path: + """Processed.""" + processed = tmp_path / "processed" + put_tcx(processed, "RunnerUp_2026-08-12-17-42-11_Running.tcx") + put_tcx(processed, "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx") + put_tcx(processed, "RunnerUp_2026-08-14-00-58-45_Running.tcx") + put_tcx(processed, "app_2026-11-01.tcx", author=APP) + return processed + + +_LISTED = [ + "RunnerUp_2026-08-12-17-42-11_Running.tcx", + "RunnerUp_2026-08-14-00-58-45_Running.tcx", +] + + +def test_marker_valid_counts_device_twins_and_ignores_app_files( + tmp_path: Path, +) -> None: + """Test marker valid counts device twins and ignores app files.""" + processed = _processed(tmp_path) + marker = put_marker(tmp_path, _LISTED) + check = rt.check_marker(marker, processed) + assert check.ok + assert "2 runs" in check.reason + + +def test_marker_may_name_the_device_prefixed_copy(tmp_path: Path) -> None: + """Test marker may name the device prefixed copy.""" + processed = _processed(tmp_path) + names = ["RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx", _LISTED[1]] + assert rt.check_marker(put_marker(tmp_path, names), processed).ok + + +def test_marker_missing_is_not_valid( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Test marker missing is not valid.""" + with caplog.at_level(logging.WARNING): + check = rt.check_marker(tmp_path / "m.json", tmp_path) + assert not check.ok + assert "has not imported yet" in check.reason + assert "marker problem" in caplog.text + + +def test_marker_unreadable_and_bad_json(tmp_path: Path) -> None: + """Test marker unreadable and bad json.""" + folder_as_marker = tmp_path / "m" + folder_as_marker.mkdir() + assert "unreadable" in rt.check_marker(folder_as_marker, tmp_path).reason + bad = tmp_path / "bad.json" + bad.write_text("{nope") + assert "unreadable" in rt.check_marker(bad, tmp_path).reason + + +@pytest.mark.parametrize( + ("body", "fragment"), + [ + ("[]", "not a JSON object"), + ('{"count": 0, "tcx_names": []}', "no completed_at"), + ('{"completed_at": "", "count": 0, "tcx_names": []}', "no completed_at"), + ('{"completed_at": "t", "count": 0, "tcx_names": "x"}', "not a list"), + ('{"completed_at": "t", "count": 0, "tcx_names": [1]}', "not a list"), + ('{"completed_at": "t", "count": 3, "tcx_names": ["a"]}', "count 3"), + ('{"completed_at": "t", "count": true, "tcx_names": ["a"]}', "count True"), + ('{"completed_at": "t", "count": "1", "tcx_names": ["a"]}', "count '1'"), + ('{"completed_at": "t", "count": 1, "tcx_names": ["x/a"]}', "bare file"), + ('{"completed_at": "t", "count": 1, "tcx_names": [".a"]}', "bare file"), + ], +) +def test_marker_shape_errors(tmp_path: Path, body: str, fragment: str) -> None: + """Test marker shape errors.""" + marker = tmp_path / "m.json" + marker.write_text(body) + check = rt.check_marker(marker, tmp_path) + assert not check.ok + assert fragment in check.reason + + +def test_marker_listing_one_run_twice_is_invalid(tmp_path: Path) -> None: + """Test marker listing one run twice is invalid.""" + processed = _processed(tmp_path) + twin = "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx" + marker = put_marker(tmp_path, [_LISTED[0], twin]) + assert "more than once" in rt.check_marker(marker, processed).reason + + +def test_marker_naming_a_run_not_in_processed_is_invalid(tmp_path: Path) -> None: + """Test marker naming a run not in processed is invalid.""" + processed = _processed(tmp_path) + marker = put_marker(tmp_path, [*_LISTED, "RunnerUp_2099-01-01-00-00-00_X.tcx"]) + reason = rt.check_marker(marker, processed).reason + assert "not in processed/" in reason + + +def test_marker_missing_a_processed_run_asks_for_a_reimport(tmp_path: Path) -> None: + """Test marker missing a processed run asks for a reimport.""" + processed = _processed(tmp_path) + marker = put_marker(tmp_path, _LISTED[:1]) + reason = rt.check_marker(marker, processed).reason + assert "re-run the app's import" in reason diff --git a/screen_locker/tests/test_runnerup_retire_state.py b/screen_locker/tests/test_runnerup_retire_state.py new file mode 100644 index 0000000..56afca5 --- /dev/null +++ b/screen_locker/tests/test_runnerup_retire_state.py @@ -0,0 +1,52 @@ +"""RunnerUp retirement: the state file round-trip and its loud failures.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from screen_locker._runnerup_retire_state import RetireState, load_state, save_state + +if TYPE_CHECKING: + from pathlib import Path + + +def test_state_roundtrip_and_anchor(tmp_path: Path) -> None: + """Test state roundtrip and anchor.""" + path = tmp_path / "deep" / "s.json" + assert load_state(path) == RetireState() + save_state(path, RetireState(anchor_at="2026-11-01T10:45:00+00:00")) + assert load_state(path).anchor is not None + assert RetireState().anchor is None + + +@pytest.mark.parametrize( + "body", ["{nope", "[]", json.dumps({"anchor_at": "not a date"})] +) +def test_bad_state_is_loud_and_ignored( + tmp_path: Path, body: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test bad state is loud and ignored.""" + path = tmp_path / "s.json" + path.write_text(body) + with caplog.at_level("WARNING"): + assert load_state(path) == RetireState() + assert "Retire state" in caplog.text + + +def test_unreadable_state_is_loud( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Test unreadable state is loud.""" + with caplog.at_level("WARNING"): + assert load_state(tmp_path) == RetireState() + assert "unreadable" in caplog.text + + +def test_state_ignores_unknown_and_non_string_fields(tmp_path: Path) -> None: + """Test state ignores unknown and non string fields.""" + path = tmp_path / "s.json" + path.write_text(json.dumps({"done_at": 5, "extra": "x", "backup_path": "/b"})) + assert load_state(path) == RetireState(backup_path="/b") diff --git a/screen_locker/tests/test_runnerup_retire_tcx.py b/screen_locker/tests/test_runnerup_retire_tcx.py index 17d8982..171611c 100644 --- a/screen_locker/tests/test_runnerup_retire_tcx.py +++ b/screen_locker/tests/test_runnerup_retire_tcx.py @@ -14,7 +14,6 @@ from screen_locker.tests._retire_helpers import ( END_UNIX, credit, put_log, - put_marker, put_tcx, ) @@ -25,6 +24,7 @@ _AT = "2026-11-01T10:45:00+00:00" def test_run_key_drops_device_prefix_only() -> None: + """Test run key drops device prefix only.""" plain = "RunnerUp_2026-08-22-23-51-04_Running.tcx" twin = "RunnerUp_Pixel_6a_2026-08-22-23-51-04_Running.tcx" assert rt.run_key(twin) == plain @@ -32,6 +32,7 @@ def test_run_key_drops_device_prefix_only() -> None: def test_list_tcx_skips_dotfiles_other_types_and_missing_dir(tmp_path: Path) -> None: + """Test list tcx skips dotfiles other types and missing dir.""" assert rt.list_tcx(tmp_path / "nope") == [] put_tcx(tmp_path, "a.tcx") put_tcx(tmp_path, ".partial.tcx") @@ -52,10 +53,12 @@ def test_list_tcx_skips_dotfiles_other_types_and_missing_dir(tmp_path: Path) -> def test_is_app_tcx_reads_author_and_creator( tmp_path: Path, kwargs: dict[str, str], expected: bool ) -> None: + """Test is app tcx reads author and creator.""" assert rt.is_app_tcx(put_tcx(tmp_path, "r.tcx", **kwargs)) is expected def test_is_app_tcx_ignores_the_name_in_free_text(tmp_path: Path) -> None: + """Test is app tcx ignores the name in free text.""" path = tmp_path / "n.tcx" path.write_text(f"{APP}") assert rt.is_app_tcx(path) is False @@ -64,18 +67,21 @@ def test_is_app_tcx_ignores_the_name_in_free_text(tmp_path: Path) -> None: def test_is_app_tcx_unreadable_is_a_loud_non_app( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Test is app tcx unreadable is a loud non app.""" with caplog.at_level(logging.WARNING): assert rt.is_app_tcx(tmp_path) is False assert "Cannot read" in caplog.text def _app_run(tmp_path: Path, name: str = "app.tcx", where: str = "") -> Path: + """App run.""" return put_tcx(tmp_path / where if where else tmp_path, name, author=APP) def test_anchor_is_the_credit_time_of_the_entry_matching_the_run_end( tmp_path: Path, ) -> None: + """Test anchor is the credit time of the entry matching the run end.""" _app_run(tmp_path, where="processed") log = tmp_path / "log.json" put_log( @@ -92,6 +98,7 @@ def test_anchor_is_the_credit_time_of_the_entry_matching_the_run_end( def test_anchor_accepts_a_stringified_end_and_the_earliest_credit( tmp_path: Path, ) -> None: + """Test anchor accepts a stringified end and the earliest credit.""" _app_run(tmp_path) log = tmp_path / "log.json" put_log( @@ -106,6 +113,7 @@ def test_anchor_accepts_a_stringified_end_and_the_earliest_credit( def test_same_day_runnerup_credit_does_not_anchor_the_app_run(tmp_path: Path) -> None: + """Test same day runnerup credit does not anchor the app run.""" _app_run(tmp_path) log = tmp_path / "log.json" put_log(log, {"2026-11-01": [credit(_AT, END_UNIX - 3600)]}) @@ -113,6 +121,7 @@ def test_same_day_runnerup_credit_does_not_anchor_the_app_run(tmp_path: Path) -> def test_no_app_run_means_no_anchor(tmp_path: Path) -> None: + """Test no app run means no anchor.""" put_tcx(tmp_path, "r.tcx") log = tmp_path / "log.json" put_log(log, {"d": [credit(_AT, END_UNIX)]}) @@ -122,6 +131,7 @@ def test_no_app_run_means_no_anchor(tmp_path: Path) -> None: def test_unreadable_or_trackless_app_tcx_cannot_anchor( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Test unreadable or trackless app tcx cannot anchor.""" (tmp_path / "bad.tcx").write_text(f"{APP}") log = tmp_path / "log.json" put_log(log, {"d": [credit(_AT, END_UNIX)]}) @@ -133,6 +143,7 @@ def test_unreadable_or_trackless_app_tcx_cannot_anchor( def test_log_entries_that_cannot_match_are_skipped( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Test log entries that cannot match are skipped.""" _app_run(tmp_path) log = tmp_path / "log.json" put_log( @@ -160,6 +171,7 @@ def test_log_entries_that_cannot_match_are_skipped( def test_naive_credit_timestamps_are_read_as_local_time(tmp_path: Path) -> None: + """Test naive credit timestamps are read as local time.""" _app_run(tmp_path) log = tmp_path / "log.json" put_log(log, {"d": [credit("2026-11-01T10:45:00", END_UNIX)]}) @@ -168,95 +180,10 @@ def test_naive_credit_timestamps_are_read_as_local_time(tmp_path: Path) -> None: assert found.tzinfo is not None -def _processed(tmp_path: Path) -> Path: - processed = tmp_path / "processed" - put_tcx(processed, "RunnerUp_2026-08-12-17-42-11_Running.tcx") - put_tcx(processed, "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx") - put_tcx(processed, "RunnerUp_2026-08-14-00-58-45_Running.tcx") - put_tcx(processed, "app_2026-11-01.tcx", author=APP) - return processed - - -_LISTED = [ - "RunnerUp_2026-08-12-17-42-11_Running.tcx", - "RunnerUp_2026-08-14-00-58-45_Running.tcx", -] - - -def test_marker_valid_counts_device_twins_and_ignores_app_files( - tmp_path: Path, -) -> None: - processed = _processed(tmp_path) - marker = put_marker(tmp_path, _LISTED) - check = rt.check_marker(marker, processed) - assert check.ok - assert "2 runs" in check.reason - - -def test_marker_may_name_the_device_prefixed_copy(tmp_path: Path) -> None: - processed = _processed(tmp_path) - names = ["RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx", _LISTED[1]] - assert rt.check_marker(put_marker(tmp_path, names), processed).ok - - -def test_marker_missing_is_not_valid( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - with caplog.at_level(logging.WARNING): - check = rt.check_marker(tmp_path / "m.json", tmp_path) - assert not check.ok - assert "has not imported yet" in check.reason - assert "marker problem" in caplog.text - - -def test_marker_unreadable_and_bad_json(tmp_path: Path) -> None: - folder_as_marker = tmp_path / "m" - folder_as_marker.mkdir() - assert "unreadable" in rt.check_marker(folder_as_marker, tmp_path).reason - bad = tmp_path / "bad.json" - bad.write_text("{nope") - assert "unreadable" in rt.check_marker(bad, tmp_path).reason - - -@pytest.mark.parametrize( - ("body", "fragment"), - [ - ("[]", "not a JSON object"), - ('{"count": 0, "tcx_names": []}', "no completed_at"), - ('{"completed_at": "", "count": 0, "tcx_names": []}', "no completed_at"), - ('{"completed_at": "t", "count": 0, "tcx_names": "x"}', "not a list"), - ('{"completed_at": "t", "count": 0, "tcx_names": [1]}', "not a list"), - ('{"completed_at": "t", "count": 3, "tcx_names": ["a"]}', "count 3"), - ('{"completed_at": "t", "count": true, "tcx_names": ["a"]}', "count True"), - ('{"completed_at": "t", "count": "1", "tcx_names": ["a"]}', "count '1'"), - ('{"completed_at": "t", "count": 1, "tcx_names": ["x/a"]}', "bare file"), - ('{"completed_at": "t", "count": 1, "tcx_names": [".a"]}', "bare file"), - ], -) -def test_marker_shape_errors(tmp_path: Path, body: str, fragment: str) -> None: - marker = tmp_path / "m.json" - marker.write_text(body) - check = rt.check_marker(marker, tmp_path) - assert not check.ok - assert fragment in check.reason - - -def test_marker_listing_one_run_twice_is_invalid(tmp_path: Path) -> None: - processed = _processed(tmp_path) - twin = "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx" - marker = put_marker(tmp_path, [_LISTED[0], twin]) - assert "more than once" in rt.check_marker(marker, processed).reason - - -def test_marker_naming_a_run_not_in_processed_is_invalid(tmp_path: Path) -> None: - processed = _processed(tmp_path) - marker = put_marker(tmp_path, [*_LISTED, "RunnerUp_2099-01-01-00-00-00_X.tcx"]) - reason = rt.check_marker(marker, processed).reason - assert "not in processed/" in reason - - -def test_marker_missing_a_processed_run_asks_for_a_reimport(tmp_path: Path) -> None: - processed = _processed(tmp_path) - marker = put_marker(tmp_path, _LISTED[:1]) - reason = rt.check_marker(marker, processed).reason - assert "re-run the app's import" in reason +def test_app_run_ends_lists_every_app_run_in_every_folder(tmp_path: Path) -> None: + """Test app run ends lists every app run in every folder.""" + _app_run(tmp_path, "a.tcx") + _app_run(tmp_path, "b.tcx", where="processed") + put_tcx(tmp_path, "r.tcx") + ends = rt.app_run_ends([tmp_path, tmp_path / "processed"]) + assert ends == [END_UNIX, END_UNIX] From 6fa9666cddcd215d9e6204edb5a0a4dd0c348e98 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:11:59 +0100 Subject: [PATCH 8/8] 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 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- .../workout_app/lib/main.dart | 2 +- .../lib/services/runnerup_importer.dart | 47 ++++++++++++++++--- .../lib/services/storage_service_backup.dart | 8 +++- .../storage_service_runs_transfer.dart | 7 +-- .../test/services/_runnerup_fake_dufs.dart | 15 ++++++ .../runnerup_importer_failures_test.dart | 17 ++++++- .../test/services/runnerup_importer_test.dart | 27 ++++++++++- .../services/runnerup_local_and_dav_test.dart | 2 +- .../storage_service_runs_transfer_test.dart | 33 +++++++++++++ 9 files changed, 141 insertions(+), 17 deletions(-) diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index 0b8abc5..792b8b2 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -123,7 +123,7 @@ void main(List 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 diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart index aed8e32..7edbd2f 100644 --- a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart +++ b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart @@ -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 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 _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 = []; + final seen = {}; 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.delayed(Duration.zero); } @@ -161,8 +189,13 @@ class RunnerUpImporter { } } - /// Stores one export; false when it is not a usable run. - Future _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 _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). diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart index d0d098a..21208d9 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart @@ -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 ' diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart index c65373c..b5c6a90 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart @@ -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; } } diff --git a/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart index 964edd1..f3b7d6a 100644 --- a/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart +++ b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart @@ -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> 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 expectedMarkerNames(Map files) { + final seen = {}; + 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, + ]; +} diff --git a/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart index 97ee414..689069f 100644 --- a/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart @@ -92,7 +92,7 @@ void main() { expect(report.failures, isEmpty); expect(report.skipped, 2); final marker = jsonDecode(dufs.markers.single) as Map; - 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(); diff --git a/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart index d1dd586..c379b2d 100644 --- a/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart @@ -60,8 +60,10 @@ void main() { expect(dufs.puts, ['PUT /RunnerUp/.workout_app_import.json']); final marker = jsonDecode(dufs.markers.single) as Map; 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 , 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; + final names = (marker['tcx_names'] as List).cast(); + 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)); diff --git a/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart index 3c898a5..30c9907 100644 --- a/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart +++ b/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart @@ -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; - expect(marker['count'], 3); + expect(marker['count'], expectedMarkerNames(dufs.files).length); }); test('an unreadable file warns but never blocks completion', () async { diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart index c771a42..5a934e1 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart @@ -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': [], + 'workout_history': [ + { + 'date': '2026-07-17', + 'workout_type': 'B', + 'duration_seconds': 6465, + 'succeeded': 1, + 'json': '{}', + }, + ], + 'settings': [], + }); + 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.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',