import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart' as ll; import 'package:rippr/src/domain/models.dart'; import 'package:rippr/src/ui/components/ride_map.dart'; import 'package:rippr/src/ui/components/skeleton_map_layer.dart'; import 'package:rippr/src/ui/theme.dart'; /// Tiles are never fetched here — a widget test cannot serve them — but the polyline /// geometry, which is where every real map bug in this project has lived, is fully /// exercisable. void main() { TrackPoint p(int segmentId, int i, {double speed = 40, double lat = 51.0}) => TrackPoint( id: i + 1, tripId: 1, segmentId: segmentId, timestamp: 1000 + i * 1000, latitude: lat + i * 0.0005, longitude: -114.0, speedKmh: speed, altitudeM: 1000, ); Future> render( WidgetTester tester, { required List points, required List segments, }) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: Scaffold(body: RideMap(points: points, segments: segments)), )); await tester.pump(); final layers = tester .widgetList(find.byType(PolylineLayer)) .toList(); return layers.isEmpty ? const [] : layers.first.polylines.cast(); } testWidgets('an empty ride says so instead of drawing a blank map', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: RideMap(points: [], segments: []), ), )); await tester.pump(); expect(find.text('No path recorded'), findsOneWidget); expect(find.byType(FlutterMap), findsNothing); }); testWidgets('a pause leaves a gap: segments are never joined', (tester) async { // Two segments a degree apart. If they were joined, one polyline would span ~111 km. final points = [ for (var i = 0; i < 6; i++) p(1, i, lat: 51.0), for (var i = 0; i < 6; i++) p(2, i, lat: 52.0), ]; const segments = [ Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1), Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3), ]; final polylines = await render(tester, points: points, segments: segments); expect(polylines, isNotEmpty); for (final line in polylines) { final lats = line.points.map((c) => c.latitude); // No single polyline may straddle the gap between the two rides. expect(lats.every((l) => l < 51.5) || lats.every((l) => l > 51.5), isTrue, reason: 'a polyline spanned the pause'); } }); testWidgets('decimation reduces rendered vertices but keeps the shape', (tester) async { // A dense straight run: Douglas-Peucker should collapse it hard. final points = [for (var i = 0; i < 400; i++) p(1, i)]; const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)]; final polylines = await render(tester, points: points, segments: segments); final rendered = polylines.fold(0, (a, line) => a + line.points.length); expect(rendered, lessThan(points.length), reason: 'render-only decimation should reduce vertex count'); expect(rendered, greaterThan(1)); }); testWidgets('a stationary ride still renders without zooming to infinity', (tester) async { // Every point at one spot — degenerate bounds. Fitting these would zoom past the // tile server's maximum and render an empty grid, the v2.0 short-ride bug. final points = [ for (var i = 0; i < 5; i++) TrackPoint( id: i + 1, tripId: 1, segmentId: 1, timestamp: 1000 + i * 1000, latitude: 51.0, longitude: -114.0, speedKmh: 0, altitudeM: 1000, ), ]; const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)]; await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: Scaffold(body: RideMap(points: points, segments: segments)), )); await tester.pump(); final map = tester.widget(find.byType(FlutterMap)); expect(map.options.initialZoom, shortRideZoom); expect(map.options.maxZoom, maxTileZoom, reason: 'exceeding OSM max tile zoom renders an empty grid'); }); group('skeleton mode (UI-02)', () { testWidgets('shows the skeleton and no TileLayer when skeletonMode is true', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: RideMap(points: [], segments: [], showEmptyLabel: false, skeletonMode: true), ), )); await tester.pump(); expect(find.byType(SkeletonMapLayer), findsOneWidget); expect(find.byType(TileLayer), findsNothing); }); testWidgets('shows the TileLayer and no skeleton when skeletonMode is false', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: RideMap(points: [], segments: [], showEmptyLabel: false), ), )); await tester.pump(); expect(find.byType(TileLayer), findsOneWidget); expect(find.byType(SkeletonMapLayer), findsNothing); }); testWidgets('the recorded path still renders on top of the skeleton', (tester) async { final points = [ for (var i = 0; i < 4; i++) TrackPoint( id: i + 1, tripId: 1, segmentId: 1, timestamp: 1000 + i * 1000, latitude: 51.0 + i * 0.0005, longitude: -114.0, speedKmh: 40, altitudeM: 1000, ), ]; const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)]; await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: Scaffold( body: RideMap(points: points, segments: segments, skeletonMode: true), ), )); await tester.pump(); expect(find.byType(SkeletonMapLayer), findsOneWidget); expect(find.byType(TileLayer), findsNothing); final layer = tester.widget(find.byType(PolylineLayer)); expect(layer.polylines, isNotEmpty, reason: 'the path comes from local data, not tiles -- it must not vanish ' 'just because the tile fetch is failing'); }); }); group('ambient position (FB-01)', () { testWidgets('with no recorded points, an ambient position centers the map at ' 'street level', (tester) async { const fix = ll.LatLng(51.05, -114.05); await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: RideMap( points: [], segments: [], showEmptyLabel: false, ambientPosition: fix, ), ), )); await tester.pump(); final map = tester.widget(find.byType(FlutterMap)); expect(map.options.initialCenter, fix); expect(map.options.initialZoom, ambientZoom); }); testWidgets('with neither recorded points nor an ambient fix, the map still ' 'falls back to (0, 0) at zoom 2 (no regression, no crash)', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: RideMap(points: [], segments: [], showEmptyLabel: false), ), )); await tester.pump(); final map = tester.widget(find.byType(FlutterMap)); expect(map.options.initialCenter, const ll.LatLng(0, 0)); expect(map.options.initialZoom, 2); }); testWidgets('while following, a new ambient fix re-centers the map, exactly ' 'like the recorded-path chase', (tester) async { const fix1 = ll.LatLng(51.0, -114.0); const fix2 = ll.LatLng(51.01, -114.01); Widget build(ll.LatLng ambient) => MaterialApp( theme: ripprTheme(), home: Scaffold( body: RideMap( points: const [], segments: const [], showEmptyLabel: false, follow: true, ambientPosition: ambient, ), ), ); await tester.pumpWidget(build(fix1)); await tester.pump(); await tester.pumpWidget(build(fix2)); await tester.pump(); final map = tester.widget(find.byType(FlutterMap)); final center = map.mapController!.camera.center; expect(center.latitude, closeTo(fix2.latitude, 1e-9)); expect(center.longitude, closeTo(fix2.longitude, 1e-9)); }); testWidgets('a manual pan cancels ambient following the same way it cancels ' 'recording-follow', (tester) async { const fix1 = ll.LatLng(51.0, -114.0); const fix2 = ll.LatLng(51.01, -114.01); Widget build(ll.LatLng ambient) => MaterialApp( theme: ripprTheme(), home: Scaffold( body: RideMap( points: const [], segments: const [], showEmptyLabel: false, follow: true, ambientPosition: ambient, ), ), ); await tester.pumpWidget(build(fix1)); await tester.pump(); // Simulate a real user gesture the same way flutter_map itself would report // one to `onPositionChanged` -- calling the callback directly with // `hasGesture: true` exercises the exact guard in `_RideMapState` without // needing a real pointer gesture to get past `InteractionOptions`. var map = tester.widget(find.byType(FlutterMap)); map.options.onPositionChanged!(map.mapController!.camera, true); await tester.pump(); await tester.pumpWidget(build(fix2)); await tester.pump(); map = tester.widget(find.byType(FlutterMap)); final center = map.mapController!.camera.center; expect(center.latitude, closeTo(fix1.latitude, 1e-9), reason: 'following was cancelled by the manual pan; a later ambient fix ' 'must not move the camera'); expect(center.longitude, closeTo(fix1.longitude, 1e-9)); }); testWidgets('the pulsing location marker falls back to the ambient position ' 'when there are no recorded points', (tester) async { const fix = ll.LatLng(51.0, -114.0); await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: RideMap( points: [], segments: [], showEmptyLabel: false, showLocationMarker: true, ambientPosition: fix, ), ), )); await tester.pump(); expect(find.byKey(const Key('location-marker')), findsOneWidget); }); testWidgets('no marker is shown when there is neither a recorded point nor an ' 'ambient position', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: RideMap( points: [], segments: [], showEmptyLabel: false, showLocationMarker: true, ), ), )); await tester.pump(); expect(find.byKey(const Key('location-marker')), findsNothing); }); }); }