fix(train): a ride by the wheel goes out with its power, cadence and distance, and learns no wheel from tunnels (ADR 0030, D49, D51)

A trainer ride exported heart rates alone (or nothing) and a ride through tunnels the wheel's totals over GPS's distances; GPX's type came before its description and TCX's watts before the cadence, against their schemas. The wheel's size was learnt from rides GPS covered only in part. The live screen parsed the whole recording again on every sensor frame; now it reads each line once and shows sensors on the second's tick. The offline maps' requests time out after 20 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 12:55:41 +01:00
commit c8f16686ec
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 636 additions and 214 deletions

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@ -124,6 +125,30 @@ void main() {
expect(offline.kept.value['route-1']!.done, tilesOf(north).length);
});
test('a tile that stalls is given up after the timeout, counted as one '
"that didn't come", () async {
server.stalling = true;
final offline = OfflineMaps(
folder,
client: server.client,
timeout: const Duration(milliseconds: 50),
);
await offline.keep('route-1', north, source);
final stalled = offline.kept.value['route-1']!;
expect(stalled.missing, stalled.total);
expect(stalled.loading, isFalse);
});
test('a style that stalls is out of reach', () async {
server.stallingStyle = true;
final offline = OfflineMaps(
folder,
client: server.client,
timeout: const Duration(milliseconds: 50),
);
expect(await offline.keep('route-1', north, source), isFalse);
});
test(
'with the style out of reach nothing is kept, and that is said',
() async {
@ -302,11 +327,19 @@ class _Tiles() {
bool empty = false;
bool offline = false;
/// Whether the tiles, or the style, are asked for and never come.
bool stalling = false;
bool stallingStyle = false;
late final http.Client client = MockClient((request) async {
if (offline) {
throw const SocketException('offline');
}
final path = request.url.path;
if ((stalling && path.startsWith('/planet/')) ||
(stallingStyle && path.startsWith('/styles/'))) {
return await Completer<http.Response>().future;
}
return switch (path) {
'/styles/liberty' ||
'/map/style/light.json' ||

View file

@ -164,6 +164,14 @@ void main() {
expect(measuredWheelMm(ride(seconds: 600, gpsMetres: 8)), isNull);
},
);
test("one whose GPS fell short of the wheel's way (a tunnel) measures "
'none: its quotient would read as a smaller wheel', () {
// 3.6 m a second by GPS, 4.2 by the wheel: 86 %, a 1.8 m wheel.
final tunnel = ride(seconds: 1500, gpsMetres: 3.6);
expect(rodeByWheel(tunnel), isTrue);
expect(measuredWheelMm(tunnel), isNull);
});
});
test("the bike's readings are series of their own, read back as they "

View file

@ -236,6 +236,46 @@ void main() {
});
});
test("samples and the sensors' readings show with the next second, not "
'each as it comes; a pause at once', () async {
await withClock(
Clock.fixed(start.add(const Duration(seconds: 5))),
() async {
final bloc = await open();
bloc.add(const RecordingEventStartRequested(Activity.cycling));
await bloc.stream.firstWhere(
(s) => s.phase == RecordingPhase.recording,
);
await pumpEventQueue();
final shown = <RecordingState>[];
final watching = bloc.stream.listen(shown.add);
for (var s = 1; s <= 5; s++) {
platform
..now = start.add(Duration(seconds: s))
..write('p @ ${s * 500} 0 500 -')
..write('h @ 140')
..write('w @ ${s * 2}')
..write('c @ 90')
..write('pw @ 200')
..write('r @ 24.0 $s ${s * 4} 200');
}
await pumpEventQueue();
expect(shown, isEmpty);
await tick(bloc);
expect(shown, hasLength(1));
expect(shown.single.live!.cadence, 90);
expect(shown.single.live!.watts, 200);
platform.write('pause @ manual');
await pumpEventQueue();
expect(shown, hasLength(2));
expect(shown.last.live!.paused, isTrue);
await watching.cancel();
},
);
});
test('stopped, it is saved as a workout with its series, and the file '
'goes', () async {
final bloc = await open();

View file

@ -307,6 +307,207 @@ void main() {
expect(record[7], 230);
});
group("a ride whose distance is the wheel's (D49)", () {
/// A ride of a minute, the wheel turning 4 times a second (2.1 m round:
/// 8.4 m/s), paused from 20 s to 30 s, the crank at 90, 200 W and a
/// heart rate each second, GPS's fixes those of [fixes].
Recording ride({List<Fix> fixes = const []}) => Recording(
activity: Activity.cycling,
startedAt: start,
fixes: fixes,
wheel: [
for (var s = 0; s <= 60; s++)
Reading(
time: at(s),
value:
(s <= 20
? s
: s < 30
? 20
: s - 10) *
4,
),
],
wheelMm: 2100,
pedalling: [
for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 90),
],
power: [for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 200)],
heartRates: [
for (var s = 0; s <= 60; s++) HeartRate(time: at(s), bpm: 130),
],
pauses: [
PauseMark(time: at(20.5)),
PauseMark(time: at(30), paused: false),
],
endedAt: at(60),
);
test('on a trainer, a point each reading of the wheel while moving, '
"with its way, the crank's cadence and the watts; FIT and TCX carry "
'them, their distances the totals', () {
final (rode, rideSeries) = recordedWorkout(ride());
final rideTrack = exportTrackOf(rode, rideSeries);
// 50 s turning: 200 turns, 420 m; 52 readings while moving.
expect(rode.distanceMetres, 420);
expect(rideTrack.located, isFalse);
expect(ExportFormat.gpx.takes(rideTrack), isFalse);
expect(rideTrack.points, hasLength(52));
expect(rideTrack.points.first.metres, 0);
expect(rideTrack.points[1].metres, closeTo(8.4, 0.01));
expect(rideTrack.points.last.metres, closeTo(420, 0.01));
expect(
rideTrack.points.where((point) => point.afterPause),
hasLength(1),
);
expect(
rideTrack.points.firstWhere((point) => point.afterPause).time,
at(30),
);
for (final point in rideTrack.points) {
expect((point.bpm, point.cadence, point.watts), (130, 90, 200));
}
final tcx = XmlDocument.parse(tcxOf(rode, rideTrack));
final points = tcx.findAllElements('Trackpoint').toList();
expect(points, hasLength(52));
expect(points.last.getElement('DistanceMeters')!.innerText, '420.0');
expect(points.last.getElement('Cadence')!.innerText, '90');
expect(points.last.findAllElements('Watts').single.innerText, '200');
expect(
tcx
.findAllElements('Lap')
.single
.getElement('DistanceMeters')!
.innerText,
'420',
);
final messages = readFit(fitOf(rode, rideTrack));
final records = [
for (final message in messages)
if (message.global == 20) message.fields,
];
expect(records, hasLength(52));
expect(records.last[4], 90);
expect(records.last[5], 42000);
expect(records.last[7], 200);
final session = messages.singleWhere((message) => message.global == 18);
expect(session.fields[9], 42000);
});
test("with GPS short of the wheel's way (a tunnel), the distances are "
"the wheel's, the fixes where it had them", () {
// GPS's first 20 s alone, at the wheel's pace.
final (rode, rideSeries) = recordedWorkout(
ride(fixes: [for (var s = 0; s <= 20; s++) north(s, s * 8.4)]),
);
final rideTrack = exportTrackOf(rode, rideSeries);
expect(rode.distanceMetres, 420);
expect(rideTrack.points, hasLength(52));
expect(rideTrack.points.last.metres, closeTo(420, 0.01));
expect(
[for (final point in rideTrack.points) point.latE7 != null],
[for (var i = 0; i < 52; i++) i <= 20],
);
expect(rideTrack.points[10].metres, closeTo(84, 0.01));
final gpx = XmlDocument.parse(
gpxOfWorkout(rode, rideTrack, name: 'Tunnel'),
);
expect(gpx.findAllElements('trkpt'), hasLength(21));
final records = [
for (final message in readFit(fitOf(rode, rideTrack)))
if (message.global == 20) message.fields,
];
expect(records.last[5], 42000);
});
test("on a trainer without the wheel's sensor, a point each reading of "
'the power meter', () {
final trainer = Recording(
activity: Activity.cycling,
startedAt: start,
pedalling: [
for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 90),
],
power: [
for (var s = 0; s <= 60; s += 2) Reading(time: at(s), value: 200),
],
endedAt: at(60),
);
final (rode, rideSeries) = recordedWorkout(trainer);
final rideTrack = exportTrackOf(rode, rideSeries);
expect(rideTrack.points, hasLength(31));
expect(rideTrack.points.last.cadence, 90);
expect(rideTrack.points.last.watts, 200);
expect(rideTrack.points.last.metres, isNull);
});
test('a ride by GPS keeps its fixes and their way', () {
// GPS all along, at the wheel's pace.
final (rode, rideSeries) = recordedWorkout(
ride(
fixes: [
for (var s = 0; s <= 60; s++)
north(
s,
(s <= 20
? s
: s < 30
? 20
: s - 10) *
8.4,
),
],
),
);
final rideTrack = exportTrackOf(rode, rideSeries);
expect(rideTrack.points.every((point) => point.latE7 != null), isTrue);
expect(
rideTrack.points.last.metres,
closeTo(rode.distanceMetres!, 0.5),
);
});
});
test("GPX: the track's name, description and type in the schema's "
'order', () {
final described = WorkoutContent(
activity: Activity.running,
startedAt: start,
endedAt: at(120),
notes: 'Easy',
);
final gpx = XmlDocument.parse(
gpxOfWorkout(described, track, name: 'Morning run'),
);
expect(
[
for (final element in gpx.findAllElements('trk').single.childElements)
element.localName,
],
['name', 'desc', 'type', 'trkseg', 'trkseg'],
);
});
test("TCX: the extension's RunCadence before the Watts, in the schema's "
'order', () {
final stepped = ExportTrack(
points: [ExportPoint(time: start, cadence: 80, watts: 250)],
laps: [
ExportLap(start: start, end: at(1), movingTime: Duration.zero),
],
);
final tcx = XmlDocument.parse(tcxOf(content, stepped));
expect(
[
for (final element in tcx.findAllElements('TPX').single.childElements)
element.localName,
],
['RunCadence', 'Watts'],
);
});
test('the file is named by when the workout started, in UTC', () {
expect(
exportNameOf(content, ExportFormat.fit),