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 segments, List 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 = [ 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 "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(' 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(' 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 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; final coords = ((((json['features'] as List).first as Map)['geometry'] as Map)['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; final features = json['features'] as List; expect(features.length, 3); expect( ((features.first as Map)['geometry'] as Map)['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; var exported = 0; for (final f in json['features'] as List) { exported += (((f as Map)['geometry'] as Map)['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; expect((json['properties'] as Map)['name'], r'He said "go" \ fast'); }); test('geojson of an empty ride parses', () { final json = jsonDecode(geoJson(trip, const [], const [], 'Empty')) as Map; expect((json['features'] as List).length, 0); }); }); }