V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221. The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
235 lines
7.9 KiB
Dart
235 lines
7.9 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:rippr/src/domain/models.dart';
|
|
import 'package:rippr/src/export/ride_export.dart';
|
|
import 'package:xml/xml.dart';
|
|
|
|
/// Ported from `com.rippr.export.RideExportTest`.
|
|
///
|
|
/// Like the Kotlin original, this parses the output with a **real** parser rather than
|
|
/// matching substrings. Structural validity is the whole point: a malformed export fails
|
|
/// invisibly until some other tool rejects the file.
|
|
void main() {
|
|
var nextId = 1;
|
|
|
|
const trip = Trip(
|
|
id: 1,
|
|
startedAt: 1700000000000,
|
|
endedAt: 1700000600000,
|
|
state: TripState.completed,
|
|
distanceM: 12345.6,
|
|
maxSpeedKmh: 118.4,
|
|
);
|
|
|
|
TrackPoint point(int segmentId, int i) => TrackPoint(
|
|
id: nextId++,
|
|
tripId: 1,
|
|
segmentId: segmentId,
|
|
timestamp: 1700000000000 + i * 1000,
|
|
latitude: 51.0447 + i * 0.0001,
|
|
longitude: -114.0719,
|
|
speedKmh: 72.0,
|
|
altitudeM: 1045.0,
|
|
);
|
|
|
|
({List<Segment> segments, List<TrackPoint> points, int total}) ride(
|
|
{required int segmentCount, required int perSegment}) {
|
|
final segments = List.generate(
|
|
segmentCount,
|
|
(i) => Segment(id: i + 1, tripId: 1, startedAt: 0, endedAt: 1),
|
|
);
|
|
final points = <TrackPoint>[
|
|
for (final s in segments)
|
|
for (var i = 0; i < perSegment; i++) point(s.id, i),
|
|
];
|
|
return (
|
|
segments: segments,
|
|
points: points,
|
|
total: segmentCount * perSegment
|
|
);
|
|
}
|
|
|
|
setUp(() => nextId = 1);
|
|
|
|
group('gpx', () {
|
|
test('gpx is well-formed and carries the expected structure', () {
|
|
final r = ride(segmentCount: 2, perSegment: 5);
|
|
|
|
final doc =
|
|
XmlDocument.parse(gpx(trip, r.segments, r.points, 'Morning loop'));
|
|
|
|
expect(doc.rootElement.name.local, 'gpx');
|
|
expect(doc.rootElement.getAttribute('version'), '1.1');
|
|
expect(doc.findAllElements('trk').length, 1);
|
|
});
|
|
|
|
test('one trkseg per segment, so pauses survive the round trip', () {
|
|
final r = ride(segmentCount: 3, perSegment: 4);
|
|
|
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
|
|
|
expect(doc.findAllElements('trkseg').length, 3);
|
|
});
|
|
|
|
test('every raw point is exported with no decimation', () {
|
|
final r = ride(segmentCount: 2, perSegment: 250);
|
|
|
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
|
|
|
expect(doc.findAllElements('trkpt').length, r.total,
|
|
reason: 'decimation must never reach an export');
|
|
});
|
|
|
|
test('timestamps are ISO 8601 in UTC', () {
|
|
final r = ride(segmentCount: 1, perSegment: 1);
|
|
|
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
|
final time = doc.findAllElements('time').elementAt(1).innerText;
|
|
|
|
expect(time, '2023-11-14T22:13:20Z');
|
|
expect(time.endsWith('Z'), isTrue, reason: 'must be UTC with a Z suffix');
|
|
});
|
|
|
|
test('coordinates and elevation land on the right attributes', () {
|
|
final r = ride(segmentCount: 1, perSegment: 1);
|
|
|
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
|
final trkpt = doc.findAllElements('trkpt').first;
|
|
|
|
expect(trkpt.getAttribute('lat'), '51.0447000');
|
|
expect(trkpt.getAttribute('lon'), '-114.0719000');
|
|
expect(doc.findAllElements('ele').first.innerText, '1045.0');
|
|
});
|
|
|
|
test('speed is exported in metres per second', () {
|
|
final r = ride(segmentCount: 1, perSegment: 1);
|
|
|
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
|
|
|
// 72 km/h is exactly 20 m/s.
|
|
expect(doc.findAllElements('speed').first.innerText, '20.0');
|
|
});
|
|
|
|
test('a hostile trip name still produces valid xml', () {
|
|
final r = ride(segmentCount: 1, perSegment: 2);
|
|
const nasty = 'Sam & Dave\'s <ride> "fast" ';
|
|
|
|
// Would throw if the name were interpolated unescaped.
|
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, nasty));
|
|
|
|
expect(doc.findAllElements('name').first.innerText, nasty);
|
|
});
|
|
|
|
test('escaping covers every xml metacharacter', () {
|
|
expect(escapeXml('&<>"\''), '&<>"'');
|
|
});
|
|
|
|
test('an empty ride still produces a valid document', () {
|
|
final doc = XmlDocument.parse(gpx(trip, const [], const [], 'Empty'));
|
|
|
|
expect(doc.rootElement.name.local, 'gpx');
|
|
expect(doc.findAllElements('trkpt').length, 0);
|
|
expect(doc.findAllElements('trkseg').length, 0);
|
|
});
|
|
|
|
test('points whose segment is missing are still exported', () {
|
|
final orphan = [point(99, 0)];
|
|
|
|
final doc = XmlDocument.parse(gpx(trip, const [], orphan, 'Ride'));
|
|
|
|
expect(doc.findAllElements('trkpt').length, 1,
|
|
reason: 'no point may be silently dropped');
|
|
});
|
|
|
|
test('<type> reflects the trip activity (V3-14)', () {
|
|
final r = ride(segmentCount: 1, perSegment: 1);
|
|
final bike = trip.copyWith(activity: Activity.bicycle);
|
|
|
|
final doc = XmlDocument.parse(gpx(bike, r.segments, r.points, 'Ride'));
|
|
|
|
expect(doc.findAllElements('type').single.innerText, 'cycling');
|
|
});
|
|
|
|
test('<type> is absent, not empty, for Activity.other (V3-14)', () {
|
|
final r = ride(segmentCount: 1, perSegment: 1);
|
|
final other = trip.copyWith(activity: Activity.other);
|
|
|
|
final doc = XmlDocument.parse(gpx(other, r.segments, r.points, 'Ride'));
|
|
|
|
expect(doc.findAllElements('type'), isEmpty,
|
|
reason: 'an empty <type/> would falsely claim the element exists');
|
|
});
|
|
|
|
test('gpxActivityType covers every activity without throwing', () {
|
|
for (final activity in Activity.values) {
|
|
// Must not throw; only Activity.other is allowed to map to null.
|
|
final type = gpxActivityType(activity);
|
|
expect(type == null, activity == Activity.other);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('geojson', () {
|
|
test('geojson coordinates are longitude first', () {
|
|
final r = ride(segmentCount: 1, perSegment: 1);
|
|
|
|
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
|
as Map<String, dynamic>;
|
|
final coords = ((((json['features'] as List).first
|
|
as Map<String, dynamic>)['geometry']
|
|
as Map<String, dynamic>)['coordinates'] as List).first as List;
|
|
|
|
expect(coords[0] as double, closeTo(-114.0719, 1e-6),
|
|
reason: 'longitude must come first');
|
|
expect(coords[1] as double, closeTo(51.0447, 1e-6));
|
|
expect(coords[2] as double, closeTo(1045.0, 1e-6));
|
|
});
|
|
|
|
test('geojson has one LineString feature per segment', () {
|
|
final r = ride(segmentCount: 3, perSegment: 4);
|
|
|
|
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
|
as Map<String, dynamic>;
|
|
final features = json['features'] as List;
|
|
|
|
expect(features.length, 3);
|
|
expect(
|
|
((features.first as Map<String, dynamic>)['geometry']
|
|
as Map<String, dynamic>)['type'],
|
|
'LineString');
|
|
});
|
|
|
|
test('geojson exports every raw point', () {
|
|
final r = ride(segmentCount: 2, perSegment: 120);
|
|
|
|
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
|
as Map<String, dynamic>;
|
|
var exported = 0;
|
|
for (final f in json['features'] as List) {
|
|
exported += (((f as Map<String, dynamic>)['geometry']
|
|
as Map<String, dynamic>)['coordinates'] as List).length;
|
|
}
|
|
|
|
expect(exported, r.total);
|
|
});
|
|
|
|
test('a hostile name is escaped in json too', () {
|
|
final r = ride(segmentCount: 1, perSegment: 1);
|
|
|
|
final json = jsonDecode(
|
|
geoJson(trip, r.segments, r.points, r'He said "go" \ fast'))
|
|
as Map<String, dynamic>;
|
|
|
|
expect((json['properties'] as Map<String, dynamic>)['name'],
|
|
r'He said "go" \ fast');
|
|
});
|
|
|
|
test('geojson of an empty ride parses', () {
|
|
final json = jsonDecode(geoJson(trip, const [], const [], 'Empty'))
|
|
as Map<String, dynamic>;
|
|
expect((json['features'] as List).length, 0);
|
|
});
|
|
});
|
|
}
|