UI-02: animated skeleton map when tiles can't be fetched
Adds MapConnectivityState, a shared tracker of tile-fetch outcomes (cache miss + network failure) that flips every map into an animated skeleton after 3 consecutive failures and recovers on a single success -- either an ordinary fetch succeeding, or (once TileLayer has been fully unmounted in skeleton mode) a periodic single-tile probe every 15s. Detected at the fetch level rather than via an OS connectivity API, since a captive portal or degraded connection can report "online" while every real fetch times out. SkeletonMapLayer reuses the Stitch exports' 40px grid-overlay treatment with a shimmer sweep, replacing TileLayer entirely (never fetching underneath its own placeholder) while markers/polylines keep rendering since they come from local data. RideMap and the route planner's independent FlutterMap both wire this in via a plain skeletonMode bool. Moved the tile-source constants into a new tiles/tile_config.dart so the connectivity probe (in the app-layer composition root) doesn't need to import from ui/ to build its request URL. Verified end-to-end on a real emulator: cut network, cleared the tile cache, confirmed the skeleton renders after real fetch failures, then confirmed automatic recovery within one probe interval once network returned -- not just via the widget/unit tests that also cover this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
116
test/map_connectivity_test.dart
Normal file
116
test/map_connectivity_test.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/tiles/map_connectivity.dart';
|
||||
|
||||
/// UI-02: exercises the failure-count/threshold/probe logic with a fake probe -- no
|
||||
/// real network -- mirroring V3-11's `tile_downloader_test.dart` pattern of injecting
|
||||
/// fake fetch outcomes rather than hitting a real tile host from a test.
|
||||
void main() {
|
||||
late bool probeResult;
|
||||
late int probeCalls;
|
||||
late MapConnectivityState state;
|
||||
|
||||
setUp(() {
|
||||
probeResult = false;
|
||||
probeCalls = 0;
|
||||
state = MapConnectivityState(
|
||||
probe: () async {
|
||||
probeCalls++;
|
||||
return probeResult;
|
||||
},
|
||||
probeInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
state.dispose();
|
||||
});
|
||||
|
||||
test('starts in live mode', () {
|
||||
expect(state.skeletonMode, isFalse);
|
||||
});
|
||||
|
||||
test('fewer than the threshold of failures stays live', () {
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
expect(state.skeletonMode, isFalse);
|
||||
});
|
||||
|
||||
test('reaching the threshold of consecutive failures switches to skeleton mode', () {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
expect(state.skeletonMode, isTrue);
|
||||
});
|
||||
|
||||
test('a success before the threshold resets the failure count', () {
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
state.reportSuccess();
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
expect(state.skeletonMode, isFalse,
|
||||
reason: 'the success should have reset the run -- two more failures is not '
|
||||
'the same as four in a row');
|
||||
});
|
||||
|
||||
test('notifies listeners exactly when skeletonMode actually changes', () {
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
expect(notifications, 0, reason: 'still below threshold -- no state change yet');
|
||||
|
||||
state.reportFailure();
|
||||
expect(notifications, 1);
|
||||
expect(state.skeletonMode, isTrue);
|
||||
});
|
||||
|
||||
test('a single success while already in skeleton mode recovers immediately', () {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
expect(state.skeletonMode, isTrue);
|
||||
|
||||
state.reportSuccess();
|
||||
expect(state.skeletonMode, isFalse);
|
||||
});
|
||||
|
||||
test('further failures are ignored once already in skeleton mode', () {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
|
||||
// Recovery is the probe loop's job from here -- more failure reports (e.g. from a
|
||||
// TileLayer that hadn't yet unmounted) must not do anything further.
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
|
||||
expect(notifications, 0);
|
||||
expect(state.skeletonMode, isTrue);
|
||||
});
|
||||
|
||||
test('the probe loop recovers automatically once it starts succeeding', () async {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
expect(state.skeletonMode, isTrue);
|
||||
|
||||
probeResult = true;
|
||||
// Give the periodic probe timer a couple of intervals to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
|
||||
expect(state.skeletonMode, isFalse);
|
||||
expect(probeCalls, greaterThan(0));
|
||||
});
|
||||
|
||||
test('the probe loop does not run while already live', () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
expect(probeCalls, 0,
|
||||
reason: 'a TileLayer that is live and working generates its own fetch '
|
||||
'reports -- a periodic probe on top of that would be exactly the '
|
||||
'excess-request behaviour this ticket exists to avoid');
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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
|
||||
@@ -117,4 +118,67 @@ void main() {
|
||||
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<PolylineLayer>(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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user