diff --git a/screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-18-00-00_Running.tcx b/screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-18-00-00_Running.tcx
deleted file mode 100644
index 5beaca8..0000000
--- a/screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-18-00-00_Running.tcx
+++ /dev/null
@@ -1,811 +0,0 @@
-
-
-
-
- 2026-11-09T17:00:00Z
-
- 1800
- 5400.009
- 0
- Active
- Manual
-
-
-
- kuhy workout_app
- 0
- 0
-
- 1
- 0
-
-
-
-
-
- kuhy workout_app
-
-
- 1
- 0
-
-
- en
- 000-00000-00
-
-
\ No newline at end of file
diff --git a/screen_locker/tests/test_run_tcx_golden.py b/screen_locker/tests/test_run_tcx_golden.py
index 55a9d3d..d0366f8 100644
--- a/screen_locker/tests/test_run_tcx_golden.py
+++ b/screen_locker/tests/test_run_tcx_golden.py
@@ -152,36 +152,6 @@ class TestGoldenWalk:
assert by_name["walking"] is True
-class TestGoldenDropout:
- """A run whose GPS dropped out for 2 minutes, written by the app's filter.
-
- The app bridges a dropout it did not pause the way RunnerUp's export does
- (straight-line metres across the gap), so the PC's dropout rule keeps both
- the distance and the moving time. Pinned by the Dart twin
- ``test/services/run_dropout_tcx_test.dart``.
- """
-
- NAME = "RunnerUp_2026-11-09-18-00-00_Running.tcx"
-
- def test_pc_credits_the_bridged_dropouts_metres_and_moving_time(
- self, inbox: Path
- ) -> None:
- """30 min at 3 m/s with seconds 600-720 unseen: 5400 m and 1800 s."""
- _drop(inbox, self.NAME)
- data = Verifier()._pull_and_parse_tcx(str(inbox / self.NAME))
- assert data is not None
- # Zeroed across the gap (the old app) this was 5040 m and 1680 s.
- assert data["track_distance_m"] == pytest.approx(5400, abs=1)
- assert data["moving_seconds"] == pytest.approx(1800)
-
- def test_the_run_is_verified_on_its_day(self, inbox: Path) -> None:
- """The whole file passes the real file-based verification."""
- _drop(inbox, self.NAME)
- result = _verify(inbox, "2026-11-09")
- assert result is not None
- assert result[0] == "verified"
-
-
class TestRunnerUpSample:
"""The trimmed real RunnerUp export parses to the numbers Dart's decoder sees."""
diff --git a/stronglift_replacement/workout_app/lib/services/gps_filter.dart b/stronglift_replacement/workout_app/lib/services/gps_filter.dart
index d307c45..f6fe82f 100644
--- a/stronglift_replacement/workout_app/lib/services/gps_filter.dart
+++ b/stronglift_replacement/workout_app/lib/services/gps_filter.dart
@@ -64,12 +64,11 @@ enum FixVerdict {
/// Accumulates accepted points and their cumulative credited distance.
///
-/// A step between two accepted points adds its straight-line distance only
-/// when it is a moving step (see [isMovingStep]). A signal dropout (a gap over
-/// [kMaxCreditGapSeconds] with no pause) is bridged the way RunnerUp's export
-/// bridges it, provided it implies [kMinDropoutSpeedMps] or faster; anything
-/// above the jump cap is rejected, and standing-still jitter credits nothing.
-/// A user pause ([breakSegment]) never bridges: pauses earn nothing.
+/// A step between two accepted points adds distance only when it is a moving
+/// step (see [isMovingStep]): a dropout longer than [kMaxCreditGapSeconds]
+/// and standing-still jitter credit nothing. The PC judges windows of 10 s or
+/// more and keeps a dropout moving at 1 m/s or faster, so it can credit more
+/// than this does (see `run_math.dart`).
class GpsFilter {
/// Creates a filter; every recording uses the running jump cap.
GpsFilter();
@@ -128,7 +127,8 @@ class GpsFilter {
_accept(fix, time, 0);
return FixVerdict.accepted;
}
- final moving = step / gap >= minStepSpeed(gap);
+ final moving =
+ gap <= kMaxCreditGapSeconds && step / gap >= kMinMovingSpeedMps;
_accept(fix, time, moving ? step : 0);
return FixVerdict.accepted;
}
diff --git a/stronglift_replacement/workout_app/lib/services/position_source.dart b/stronglift_replacement/workout_app/lib/services/position_source.dart
index 815b802..94dd8e9 100644
--- a/stronglift_replacement/workout_app/lib/services/position_source.dart
+++ b/stronglift_replacement/workout_app/lib/services/position_source.dart
@@ -39,7 +39,6 @@ abstract class PositionSource {
/// Fixes roughly every [interval], best accuracy, until cancelled.
///
/// [interval] must stay well under the PC's 30 s credit gap
- /// (`kMaxCreditGapSeconds`): a quieter stream is treated as a dropout and
- /// only counts at 1 m/s or faster.
+ /// (`kMaxCreditGapSeconds`): a quieter stream earns nothing.
Stream fixes({Duration interval = const Duration(seconds: 2)});
}
diff --git a/stronglift_replacement/workout_app/lib/services/run_math.dart b/stronglift_replacement/workout_app/lib/services/run_math.dart
index ce8fff7..d8938b9 100644
--- a/stronglift_replacement/workout_app/lib/services/run_math.dart
+++ b/stronglift_replacement/workout_app/lib/services/run_math.dart
@@ -10,14 +10,11 @@
/// 30 s or less earns moving time at 0.5 m/s or faster; a longer one (a GPS
/// dropout) at 1 m/s or faster; paused time earns nothing.
///
-/// The app mirrors those bounds per pair of its 2 s fixes, and bridges a
-/// dropout the way a RunnerUp export does: the straight-line distance across a
-/// gap over 30 s that the runner did not pause is credited when it implies
-/// 1 m/s to 25 km/h (`GpsFilter`); a user pause credits nothing. The
-/// on-screen distance, the saved run and the TCX `DistanceMeters` all come from
-/// that one cumulative figure. The only remaining difference is that the PC
-/// judges 10 s windows rather than single pairs, so its moving time can differ
-/// slightly on a jittery or stop-and-go stretch.
+/// The app mirrors only the bounds, per pair of its 2 s fixes, and is stricter:
+/// a pair over 30 s credits nothing, where the PC keeps a dropout at 1 m/s or
+/// faster. The app writes zero distance across such a gap, so the PC does not
+/// credit a dropout in an app-recorded run. Its on-screen moving time is
+/// therefore an estimate, not the number the PC will credit.
library;
import 'dart:math' as math;
@@ -27,8 +24,8 @@ import 'package:workout_app/models/run_track.dart';
/// Mean Earth radius in metres (IUGG), the haversine constant.
const double kEarthRadiusM = 6371008.8;
-/// A pair of fixes further apart in time than this is a GPS dropout whose
-/// motion is unknown: it counts only at [kMinDropoutSpeedMps] or faster.
+/// A pair of fixes further apart in time than this earns neither distance
+/// nor moving time: a GPS dropout whose motion is unknown.
///
/// Mirrors `WALK_MAX_GAP_SECONDS` in the PC's `screen_locker/_constants.py`.
const int kMaxCreditGapSeconds = 30;
@@ -38,12 +35,6 @@ const int kMaxCreditGapSeconds = 30;
/// Mirrors `MIN_MOVING_SPEED_MPS` on the PC.
const double kMinMovingSpeedMps = 0.5;
-/// A dropout (a gap over [kMaxCreditGapSeconds]) counts only when the straight
-/// line across it implies at least this speed; slower is standing or a pause.
-///
-/// Mirrors `MIN_DROPOUT_SPEED_MPS` on the PC.
-const double kMinDropoutSpeedMps = 1;
-
/// Speed cap above which a fix is a GPS jump, 25 km/h.
///
/// Mirrors `MAX_MOVING_SPEED_MPS` on the PC, which applies it to each window
@@ -65,23 +56,18 @@ double haversineM(double lat1, double lon1, double lat2, double lon2) {
return 2 * kEarthRadiusM * math.asin(math.min(1, math.sqrt(a)));
}
-/// The slowest speed that counts as moving across a [gapSeconds] step.
-double minStepSpeed(num gapSeconds) => gapSeconds > kMaxCreditGapSeconds
- ? kMinDropoutSpeedMps
- : kMinMovingSpeedMps;
-
-/// Whether the step from [a] to [b] counts as moving: travelled at a speed in
-/// `[kMinMovingSpeedMps, maxSpeed]`, or in `[kMinDropoutSpeedMps, maxSpeed]`
-/// when the fixes are further apart than [kMaxCreditGapSeconds] (a dropout).
+/// Whether the step from [a] to [b] counts as moving: seen within
+/// [kMaxCreditGapSeconds] and travelled at a speed in
+/// `[kMinMovingSpeedMps, maxSpeed]`.
///
/// Speed comes from the cumulative `distanceM` difference, as the PC reads a
/// TCX, but over one pair of fixes rather than a window of 10 s or more, so the
/// two can disagree on a jittery or stop-and-go stretch.
bool isMovingStep(RunPoint a, RunPoint b, double maxSpeed) {
final gap = b.time.difference(a.time).inMilliseconds / 1000;
- if (gap <= 0) return false;
+ if (gap <= 0 || gap > kMaxCreditGapSeconds) return false;
final speed = (b.distanceM - a.distanceM) / gap;
- return speed >= minStepSpeed(gap) && speed <= maxSpeed;
+ return speed >= kMinMovingSpeedMps && speed <= maxSpeed;
}
/// Moving seconds over [points]: the summed gaps of every moving step.
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 8725e11..f3b7d6a 100644
--- a/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart
+++ b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart
@@ -13,16 +13,10 @@ import 'package:workout_app/services/tcx_codec.dart';
const String kFixtureDir = '../../screen_locker/tests/fixtures/run_tcx';
-/// The GPS-dropout export: shares the folder for the golden twin, but is not
-/// one of the three exports these importer and backup tests count on.
-const String kDropoutFixture = 'RunnerUp_2026-11-09-18-00-00_Running.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 != kDropoutFixture)
- f.uri.pathSegments.last: f.readAsBytesSync(),
+ if (f.path.endsWith('.tcx')) f.uri.pathSegments.last: f.readAsBytesSync(),
};
final DufsCredentials kLogin = DufsCredentials(
diff --git a/stronglift_replacement/workout_app/test/services/gps_filter_test.dart b/stronglift_replacement/workout_app/test/services/gps_filter_test.dart
index 3216231..770eb3d 100644
--- a/stronglift_replacement/workout_app/test/services/gps_filter_test.dart
+++ b/stronglift_replacement/workout_app/test/services/gps_filter_test.dart
@@ -90,14 +90,14 @@ void main() {
expect(f.points.first.altitudeM, 30);
});
- test('standing-still jitter and standing through a gap earn nothing', () {
+ test('standing-still jitter and a long dropout earn no distance', () {
final f = GpsFilter()
..add(_fix(0, 0))
..add(_fix(10, 2)); // 0.2 m/s: standing
expect(f.distanceM, 0);
- f.add(_fix(60, 2)); // 50 s later, same spot: nothing to bridge
+ f.add(_fix(60, 100)); // 50 s later: unseen motion
expect(f.distanceM, 0);
- f.add(_fix(70, 29)); // seen again, moving
+ f.add(_fix(70, 127)); // seen again, moving
expect(f.distanceM, closeTo(27, 0.2));
expect(f.points, hasLength(4));
});
@@ -129,60 +129,6 @@ void main() {
});
});
- group('signal dropout (a gap over 30 s while running)', () {
- // Mirrors the PC: WALK_MAX_GAP_SECONDS 30, MIN_DROPOUT_SPEED_MPS 1.0,
- // MAX_MOVING_SPEED_MPS 25 km/h.
- GpsFilter runningTo30m() => GpsFilter()
- ..add(_fix(0, 0))
- ..add(_fix(10, 30));
-
- test('at 3 m/s the straight-line distance is bridged', () {
- final f = runningTo30m();
- expect(f.add(_fix(70, 210)), FixVerdict.accepted); // 60 s, 180 m
- expect(f.distanceM, closeTo(210, 0.5));
- expect(f.points.last.distanceM, closeTo(210, 0.5));
- });
-
- test('just over the dropout floor (1 m/s) is bridged', () {
- final f = runningTo30m()..add(_fix(70, 91)); // 60 s, 61 m: 1.02 m/s
- expect(f.distanceM, closeTo(91, 0.5));
- });
-
- test('0.5 m/s is standing about, not a run: nothing is bridged', () {
- final f = runningTo30m();
- expect(f.add(_fix(70, 60)), FixVerdict.accepted); // 60 s, 30 m
- expect(f.distanceM, closeTo(30, 0.5));
- });
-
- test('40 km/h is a GPS jump: rejected, nothing is bridged', () {
- final f = runningTo30m();
- expect(f.add(_fix(70, 30 + 666)), FixVerdict.tooFast); // 11.1 m/s
- expect(f.distanceM, closeTo(30, 0.5));
- });
-
- test('31 s is a dropout needing 1 m/s, 30 s still needs only 0.5', () {
- final slow = runningTo30m()..add(_fix(41, 50)); // 31 s, 20 m: 0.65 m/s
- expect(slow.distanceM, closeTo(30, 0.5));
- final ok = runningTo30m()..add(_fix(40, 50)); // 30 s, 20 m: 0.67 m/s
- expect(ok.distanceM, closeTo(50, 0.5));
- });
-
- test('a user pause with the same geometry bridges nothing', () {
- final f = runningTo30m()..breakSegment();
- expect(f.add(_fix(70, 210)), FixVerdict.accepted);
- expect(f.distanceM, closeTo(30, 0.5));
- });
-
- test('moving time and distance agree across the bridged dropout', () {
- final f = runningTo30m()
- ..add(_fix(70, 210))
- ..add(_fix(80, 240));
- expect(f.distanceM, closeTo(240, 0.5));
- // 10 s + 60 s dropout + 10 s, as the PC counts it.
- expect(movingSeconds(f.points), 80);
- });
- });
-
group('breakSegment', () {
test('the fix after a pause earns nothing, then crediting resumes', () {
final f = GpsFilter()
diff --git a/stronglift_replacement/workout_app/test/services/run_dropout_tcx_test.dart b/stronglift_replacement/workout_app/test/services/run_dropout_tcx_test.dart
deleted file mode 100644
index aa9a93d..0000000
--- a/stronglift_replacement/workout_app/test/services/run_dropout_tcx_test.dart
+++ /dev/null
@@ -1,79 +0,0 @@
-@TestOn('vm')
-library;
-
-import 'dart:io';
-
-import 'package:flutter_test/flutter_test.dart';
-import 'package:workout_app/models/run_track.dart';
-import 'package:workout_app/services/gps_filter.dart';
-import 'package:workout_app/services/run_math.dart';
-import 'package:workout_app/services/tcx_codec.dart';
-
-import '../run_test_fixtures.dart';
-
-/// A run with a GPS dropout, written by the app's own filter and TCX writer.
-///
-/// 30 min at 3 m/s, a fix every 20 s, except that the signal is gone between
-/// second 600 and 720. The PC credits a dropout that moved at 1 m/s or more
-/// (`MIN_DROPOUT_SPEED_MPS`), so the cumulative `DistanceMeters` must jump
-/// 360 m across it. The Python twin, `screen_locker/tests/test_run_tcx_golden.py`,
-/// feeds the SAME checked-in file to the real verifier. Regenerate it with
-/// `UPDATE_GOLDEN=1 flutter test test/services/run_dropout_tcx_test.dart`.
-const String _dir = '../../screen_locker/tests/fixtures/run_tcx';
-const String _file = 'RunnerUp_2026-11-09-18-00-00_Running.tcx';
-
-RunTrack _recordedRun() {
- final filter = GpsFilter();
- for (var s = 0; s <= 1800; s += 20) {
- if (s > 600 && s < 720) continue; // the dropout
- final fix = fixAt(s, s * 3.0);
- filter.add(
- GpsFix(
- time: DateTime.utc(2026, 11, 9, 17).add(Duration(seconds: s)),
- latitude: fix.latitude,
- longitude: fix.longitude,
- altitudeM: 30,
- accuracyM: 5,
- ),
- );
- }
- final points = filter.points;
- return RunTrack.started(
- sport: RunSport.running,
- startedAt: points.first.time,
- points: points,
- totalSeconds: movingSeconds(points),
- distanceM: filter.distanceM,
- );
-}
-
-void main() {
- final track = _recordedRun();
-
- test('cumulative DistanceMeters jumps across the dropout', () {
- final back = decodeTcx(encodeTcx(track), fileName: _file);
- final before = back.points.firstWhere(
- (p) => p.time.minute == 10 && p.time.second == 0,
- );
- final after = back.points.firstWhere(
- (p) => p.time.minute == 12 && p.time.second == 0,
- );
- expect(before.distanceM, closeTo(1800, 0.5));
- expect(after.distanceM - before.distanceM, closeTo(360, 0.5));
- });
-
- test('lap distance and moving time count the dropout', () {
- expect(track.distanceM, closeTo(5400, 1));
- expect(track.totalSeconds, 1800);
- });
-
- test('encodes to exactly the checked-in file the PC test reads', () {
- final encoded = encodeTcx(track);
- final file = File('$_dir/$_file');
- if (Platform.environment['UPDATE_GOLDEN'] == '1') {
- file.writeAsStringSync(encoded);
- }
- expect(file.readAsStringSync(), encoded);
- expect(tcxFilename(track, utcOffset: const Duration(minutes: 60)), _file);
- });
-}
diff --git a/stronglift_replacement/workout_app/test/services/run_math_test.dart b/stronglift_replacement/workout_app/test/services/run_math_test.dart
index 1704744..96ff005 100644
--- a/stronglift_replacement/workout_app/test/services/run_math_test.dart
+++ b/stronglift_replacement/workout_app/test/services/run_math_test.dart
@@ -50,17 +50,9 @@ void main() {
expect(isMovingStep(_p(10, 0), _p(5, 10), cap), isFalse);
});
- test('30 s exactly needs 0.5 m/s, a longer dropout needs 1 m/s', () {
+ test('a gap over 30 s earns nothing, 30 s exactly does', () {
expect(isMovingStep(_p(0, 0), _p(30, 40), cap), isTrue);
- expect(isMovingStep(_p(0, 0), _p(30, 16), cap), isTrue);
- // 31 s at 0.65 m/s: a pause-like stand, not a bridged dropout.
- expect(isMovingStep(_p(0, 0), _p(31, 20), cap), isFalse);
- // 60 s at 1 m/s and at 3 m/s: bridged dropouts.
- expect(isMovingStep(_p(0, 0), _p(60, 60), cap), isTrue);
- expect(isMovingStep(_p(0, 0), _p(60, 180), cap), isTrue);
- // A dropout faster than the cap or with no distance (pause) is not.
- expect(isMovingStep(_p(0, 0), _p(60, 400), cap), isFalse);
- expect(isMovingStep(_p(0, 0), _p(60, 0), cap), isFalse);
+ expect(isMovingStep(_p(0, 0), _p(31, 40), cap), isFalse);
});
test('movingSeconds sums only the moving steps', () {
@@ -68,11 +60,10 @@ void main() {
_p(0, 0),
_p(10, 13), // moving, 10 s
_p(20, 13), // standing, 0 distance
- _p(55, 23), // 35 s gap at 0.29 m/s, not credited
- _p(65, 36), // moving, 10 s
- _p(125, 216), // 60 s dropout at 3 m/s, bridged
+ _p(55, 60), // 35 s gap, not credited
+ _p(65, 73), // moving, 10 s
];
- expect(movingSeconds(points), 80);
+ expect(movingSeconds(points), 20);
expect(movingSeconds(const []), 0);
});
});
diff --git a/stronglift_replacement/workout_app/test/services/run_recorder_test.dart b/stronglift_replacement/workout_app/test/services/run_recorder_test.dart
index e2ab8e0..d574cb9 100644
--- a/stronglift_replacement/workout_app/test/services/run_recorder_test.dart
+++ b/stronglift_replacement/workout_app/test/services/run_recorder_test.dart
@@ -184,43 +184,6 @@ void main() {
recorder.dispose();
});
- test(
- 'a signal dropout is bridged, a pause of the same length is not',
- () async {
- Future run({required bool paused}) async {
- final source = ManualPositionSource();
- final recorder = _recorder(source);
- await recorder.start();
- source.controller
- ..add(fixAt(0, 0))
- ..add(fixAt(2, 6));
- await pumpEventQueue();
- if (paused) recorder.pause();
- // 58 s later and 174 m further: 3 m/s, as if the signal dropped out.
- if (paused) recorder.resume();
- source.controller
- ..add(fixAt(60, 180))
- ..add(fixAt(62, 186));
- await pumpEventQueue();
- return recorder;
- }
-
- final dropout = await run(paused: false);
- expect(dropout.distanceM, closeTo(186, 0.5));
- expect(dropout.seconds, 62);
- final track = (await dropout.finish())!;
- expect(track.distanceM, closeTo(186, 0.5));
- expect(track.totalSeconds, 62);
- expect(track.points.last.distanceM, closeTo(186, 0.5));
-
- final pause = await run(paused: true);
- expect(pause.distanceM, closeTo(12, 0.5));
- expect(pause.seconds, 4);
- dropout.dispose();
- pause.dispose();
- },
- );
-
test(
'finish works from paused; pause and resume elsewhere are no-ops',
() async {