mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-07-06 20:57:54 +02:00
Add offline-first tile system with per-provider caching and error retry
- Add ServicePolicy framework with OSM-specific rate limiting and TTL - Add per-provider disk tile cache (ProviderTileCacheStore) with O(1) lookup, oldest-modified eviction, and ETag/304 revalidation - Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider) and offline-first (disk cache -> local tiles -> network with caching) - Add zoom-aware offline routing so tiles outside offline area zoom ranges use the efficient common path instead of the overhead-heavy offline path - Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map widget recycling; shutdown() handles permanent teardown - Add TileLayerManager with exponential backoff retry (2s->60s cap), provider switch detection, and backoff reset - Guard null provider/tileType in download dialog with localized error - Fix Nominatim cache key to use normalized viewbox values - Comprehensive test coverage (1800+ lines across 6 test files) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,46 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/app_state.dart';
|
||||
import 'package:deflockapp/models/tile_provider.dart' as models;
|
||||
import 'package:deflockapp/services/deflock_tile_provider.dart';
|
||||
import 'package:deflockapp/services/provider_tile_cache_store.dart';
|
||||
|
||||
class MockAppState extends Mock implements AppState {}
|
||||
class MockMapCachingProvider extends Mock implements MapCachingProvider {}
|
||||
|
||||
void main() {
|
||||
late DeflockTileProvider provider;
|
||||
late MockAppState mockAppState;
|
||||
|
||||
final osmTileType = models.TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final mapboxTileType = models.TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate:
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockAppState = MockAppState();
|
||||
AppState.instance = mockAppState;
|
||||
|
||||
// Default stubs: online, OSM provider selected, no offline areas
|
||||
// Default stubs: online, no offline areas
|
||||
when(() => mockAppState.offlineMode).thenReturn(false);
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(
|
||||
const models.TileProvider(
|
||||
id: 'openstreetmap',
|
||||
name: 'OpenStreetMap',
|
||||
tileTypes: [],
|
||||
),
|
||||
);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(
|
||||
const models.TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
),
|
||||
);
|
||||
|
||||
provider = DeflockTileProvider();
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await provider.dispose();
|
||||
provider.shutdown();
|
||||
AppState.instance = MockAppState();
|
||||
});
|
||||
|
||||
@@ -49,7 +60,7 @@ void main() {
|
||||
expect(provider.supportsCancelLoading, isTrue);
|
||||
});
|
||||
|
||||
test('getTileUrl() delegates to TileType.getTileUrl()', () {
|
||||
test('getTileUrl() uses frozen tileType config', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
@@ -58,23 +69,12 @@ void main() {
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
|
||||
test('getTileUrl() includes API key when present', () {
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(
|
||||
const models.TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
apiKey: 'test_key_123',
|
||||
tileTypes: [],
|
||||
),
|
||||
);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(
|
||||
const models.TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate:
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox',
|
||||
),
|
||||
test('getTileUrl() includes API key when present', () async {
|
||||
provider.shutdown();
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'mapbox',
|
||||
tileType: mapboxTileType,
|
||||
apiKey: 'test_key_123',
|
||||
);
|
||||
|
||||
const coords = TileCoordinates(1, 2, 10);
|
||||
@@ -86,19 +86,6 @@ void main() {
|
||||
expect(url, contains('/10/1/2@2x'));
|
||||
});
|
||||
|
||||
test('getTileUrl() falls back to super when no provider selected', () {
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(null);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(null);
|
||||
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'https://example.com/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
|
||||
// Super implementation uses the urlTemplate from TileLayer options
|
||||
expect(url, equals('https://example.com/3/1/2'));
|
||||
});
|
||||
|
||||
test('routes to network path when no offline areas exist', () {
|
||||
// offlineMode = false, OfflineAreaService not initialized → no offline areas
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
@@ -136,10 +123,19 @@ void main() {
|
||||
expect(offlineProvider.providerId, equals('openstreetmap'));
|
||||
expect(offlineProvider.tileTypeId, equals('osm_street'));
|
||||
});
|
||||
|
||||
test('frozen config is independent of AppState', () {
|
||||
// Provider was created with OSM config — changing AppState should not affect it
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider', () {
|
||||
test('equal for same coordinates and provider/type', () {
|
||||
test('equal for same coordinates, provider/type, and offlineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
@@ -161,7 +157,7 @@ void main() {
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: true, // different — but not in ==
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'https://other.com/3/1/2', // different — but not in ==
|
||||
@@ -171,6 +167,37 @@ void main() {
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('not equal for different isOfflineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final online = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
final offline = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: true,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
expect(online, isNot(equals(offline)));
|
||||
});
|
||||
|
||||
test('not equal for different coordinates', () {
|
||||
const coords1 = TileCoordinates(1, 2, 3);
|
||||
const coords2 = TileCoordinates(1, 2, 4);
|
||||
@@ -247,5 +274,298 @@ void main() {
|
||||
expect(base, isNot(equals(diffType)));
|
||||
expect(base.hashCode, isNot(equals(diffType.hashCode)));
|
||||
});
|
||||
|
||||
test('equality ignores cachingProvider and onNetworkSuccess', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final withCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
cachingProvider: MockMapCachingProvider(),
|
||||
onNetworkSuccess: () {},
|
||||
);
|
||||
final withoutCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
expect(withCaching, equals(withoutCaching));
|
||||
expect(withCaching.hashCode, equals(withoutCaching.hashCode));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockTileProvider caching integration', () {
|
||||
test('passes cachingProvider through to offline path', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
final mockCaching = MockMapCachingProvider();
|
||||
var successCalled = false;
|
||||
|
||||
final cachingProvider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
cachingProvider: mockCaching,
|
||||
onNetworkSuccess: () => successCalled = true,
|
||||
);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = cachingProvider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, same(mockCaching));
|
||||
expect(offlineProvider.onNetworkSuccess, isNotNull);
|
||||
|
||||
// Invoke the callback to verify it's wired correctly
|
||||
offlineProvider.onNetworkSuccess!();
|
||||
expect(successCalled, isTrue);
|
||||
|
||||
cachingProvider.shutdown();
|
||||
});
|
||||
|
||||
test('offline provider has null caching when not provided', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = provider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, isNull);
|
||||
expect(offlineProvider.onNetworkSuccess, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider caching helpers', () {
|
||||
late Directory tempDir;
|
||||
late ProviderTileCacheStore cacheStore;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
|
||||
cacheStore = ProviderTileCacheStore(cacheDirectory: tempDir.path);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('disk cache integration: putTile then getTile round-trip', () async {
|
||||
const url = 'https://tile.example.com/3/1/2.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: DateTime.utc(2026, 2, 20),
|
||||
etag: '"tile-etag"',
|
||||
);
|
||||
|
||||
// Write to cache
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
// Read back
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes));
|
||||
expect(cached.metadata.etag, equals('"tile-etag"'));
|
||||
expect(cached.metadata.isStale, isFalse);
|
||||
});
|
||||
|
||||
test('disk cache: stale tiles are detectable', () async {
|
||||
const url = 'https://tile.example.com/stale.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.metadata.isStale, isTrue);
|
||||
// Bytes are still available even when stale (for conditional revalidation)
|
||||
expect(cached.bytes, equals(bytes));
|
||||
});
|
||||
|
||||
test('disk cache: metadata-only update preserves bytes', () async {
|
||||
const url = 'https://tile.example.com/revalidated.png';
|
||||
final bytes = Uint8List.fromList([10, 20, 30]);
|
||||
|
||||
// Initial write with bytes
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v1"',
|
||||
),
|
||||
bytes: bytes,
|
||||
);
|
||||
|
||||
// Metadata-only update (simulating 304 Not Modified revalidation)
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v2"',
|
||||
),
|
||||
// No bytes — metadata only
|
||||
);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes)); // original bytes preserved
|
||||
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
|
||||
expect(cached.metadata.isStale, isFalse); // now fresh
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider load error paths', () {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
/// Load the tile via [loadImage] and return the first error from the
|
||||
/// image stream. The decode callback should never be reached on error
|
||||
/// paths, so we throw if it is.
|
||||
Future<Object> loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider provider) {
|
||||
final completer = Completer<Object>();
|
||||
final stream = provider.loadImage(
|
||||
provider,
|
||||
(buffer, {getTargetSize}) async =>
|
||||
throw StateError('decode should not be called'),
|
||||
);
|
||||
stream.addListener(ImageStreamListener(
|
||||
(_, _) {
|
||||
if (!completer.isCompleted) {
|
||||
completer
|
||||
.completeError(StateError('expected error but got image'));
|
||||
}
|
||||
},
|
||||
onError: (error, _) {
|
||||
if (!completer.isCompleted) completer.complete(error);
|
||||
},
|
||||
));
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
test('offline both-miss throws TileNotAvailableOfflineException',
|
||||
() async {
|
||||
// No offline areas, no cache → both miss.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileNotAvailableOfflineException>());
|
||||
});
|
||||
|
||||
test('cancelled offline tile throws TileLoadCancelledException',
|
||||
() async {
|
||||
// cancelLoading already resolved → _loadAsync catch block detects
|
||||
// cancellation and throws TileLoadCancelledException instead of
|
||||
// the underlying TileNotAvailableOfflineException.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('online cancel before network throws TileLoadCancelledException',
|
||||
() async {
|
||||
// Online mode: cache miss, local miss, then cancelled check fires
|
||||
// before reaching the network fetch.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('network error throws HttpException', () async {
|
||||
// Online mode: cache miss, local miss, not cancelled, network
|
||||
// returns 500 → HttpException with tile coordinates and status.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(4, 5, 6),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: MockClient((_) async => http.Response('', 500)),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/6/4/5.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<HttpException>());
|
||||
expect((error as HttpException).message, contains('6/4/5'));
|
||||
expect(error.message, contains('500'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import 'package:deflockapp/services/offline_area_service.dart';
|
||||
import 'package:deflockapp/services/offline_areas/offline_area_models.dart';
|
||||
|
||||
OfflineArea _makeArea({
|
||||
String providerId = 'osm',
|
||||
String tileTypeId = 'standard',
|
||||
int minZoom = 5,
|
||||
int maxZoom = 12,
|
||||
OfflineAreaStatus status = OfflineAreaStatus.complete,
|
||||
}) {
|
||||
return OfflineArea(
|
||||
id: 'test-$providerId-$tileTypeId-$minZoom-$maxZoom',
|
||||
bounds: LatLngBounds(const LatLng(0, 0), const LatLng(1, 1)),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: '/tmp/test-area',
|
||||
status: status,
|
||||
tileProviderId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final service = OfflineAreaService();
|
||||
|
||||
setUp(() {
|
||||
service.setAreasForTesting([]);
|
||||
});
|
||||
|
||||
group('hasOfflineAreasForProviderAtZoom', () {
|
||||
test('returns true for zoom within range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 5), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 12), isTrue);
|
||||
});
|
||||
|
||||
test('returns false for zoom outside range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 4), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 14), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong provider', () {
|
||||
service.setAreasForTesting([_makeArea(providerId: 'osm')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('other', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong tile type', () {
|
||||
service.setAreasForTesting([_makeArea(tileTypeId: 'standard')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'satellite', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for non-complete areas', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(status: OfflineAreaStatus.downloading),
|
||||
_makeArea(status: OfflineAreaStatus.error),
|
||||
]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false when initialized with no areas', () {
|
||||
service.setAreasForTesting([]);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('matches when any area covers the zoom level', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(minZoom: 5, maxZoom: 8),
|
||||
_makeArea(minZoom: 10, maxZoom: 14),
|
||||
]);
|
||||
|
||||
// In first area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 6), isTrue);
|
||||
// In gap between areas
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 9), isFalse);
|
||||
// In second area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isTrue);
|
||||
// Beyond both areas
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:deflockapp/services/provider_tile_cache_store.dart';
|
||||
import 'package:deflockapp/services/provider_tile_cache_manager.dart';
|
||||
import 'package:deflockapp/services/service_policy.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
await ProviderTileCacheManager.resetAll();
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore', () {
|
||||
late ProviderTileCacheStore store;
|
||||
|
||||
setUp(() {
|
||||
store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
);
|
||||
});
|
||||
|
||||
test('isSupported is true', () {
|
||||
expect(store.isSupported, isTrue);
|
||||
});
|
||||
|
||||
test('getTile returns null for uncached URL', () async {
|
||||
final result = await store.getTile('https://tile.example.com/1/2/3.png');
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('putTile and getTile round-trip', () async {
|
||||
const url = 'https://tile.example.com/1/2/3.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||
final staleAt = DateTime.utc(2026, 3, 1);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: staleAt,
|
||||
lastModified: DateTime.utc(2026, 2, 20),
|
||||
etag: '"abc123"',
|
||||
);
|
||||
|
||||
await store.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes));
|
||||
expect(
|
||||
cached.metadata.staleAt.millisecondsSinceEpoch,
|
||||
equals(staleAt.millisecondsSinceEpoch),
|
||||
);
|
||||
expect(cached.metadata.etag, equals('"abc123"'));
|
||||
expect(cached.metadata.lastModified, isNotNull);
|
||||
});
|
||||
|
||||
test('putTile without bytes updates metadata only', () async {
|
||||
const url = 'https://tile.example.com/1/2/3.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
final metadata1 = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: '"v1"',
|
||||
);
|
||||
|
||||
// Write with bytes first
|
||||
await store.putTile(url: url, metadata: metadata1, bytes: bytes);
|
||||
|
||||
// Update metadata only
|
||||
final metadata2 = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 4, 1),
|
||||
lastModified: null,
|
||||
etag: '"v2"',
|
||||
);
|
||||
await store.putTile(url: url, metadata: metadata2);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes)); // bytes unchanged
|
||||
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
|
||||
});
|
||||
|
||||
test('handles null lastModified and etag', () async {
|
||||
const url = 'https://tile.example.com/simple.png';
|
||||
final bytes = Uint8List.fromList([10, 20, 30]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.metadata.lastModified, isNull);
|
||||
expect(cached.metadata.etag, isNull);
|
||||
});
|
||||
|
||||
test('creates cache directory lazily on first putTile', () async {
|
||||
final subDir = p.join(tempDir.path, 'lazy', 'nested');
|
||||
final lazyStore = ProviderTileCacheStore(cacheDirectory: subDir);
|
||||
|
||||
// Directory should not exist yet
|
||||
expect(await Directory(subDir).exists(), isFalse);
|
||||
|
||||
await lazyStore.putTile(
|
||||
url: 'https://example.com/tile.png',
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
),
|
||||
bytes: Uint8List.fromList([1]),
|
||||
);
|
||||
|
||||
// Directory should now exist
|
||||
expect(await Directory(subDir).exists(), isTrue);
|
||||
});
|
||||
|
||||
test('clear deletes all cached tiles', () async {
|
||||
// Write some tiles
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await store.putTile(
|
||||
url: 'https://example.com/$i.png',
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
),
|
||||
bytes: Uint8List.fromList([i]),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify tiles exist
|
||||
expect(await store.getTile('https://example.com/0.png'), isNotNull);
|
||||
|
||||
// Clear
|
||||
await store.clear();
|
||||
|
||||
// Directory should be gone
|
||||
expect(await Directory(tempDir.path).exists(), isFalse);
|
||||
|
||||
// getTile should return null (directory gone)
|
||||
expect(await store.getTile('https://example.com/0.png'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore TTL override', () {
|
||||
test('overrideFreshAge bumps staleAt forward', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
overrideFreshAge: const Duration(days: 7),
|
||||
);
|
||||
|
||||
const url = 'https://tile.example.com/osm.png';
|
||||
// Server says stale in 1 hour, but policy requires 7 days
|
||||
final serverMetadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(
|
||||
url: url,
|
||||
metadata: serverMetadata,
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
|
||||
// staleAt should be ~7 days from now, not 1 hour
|
||||
final expectedMin = DateTime.timestamp().add(const Duration(days: 6));
|
||||
expect(cached!.metadata.staleAt.isAfter(expectedMin), isTrue);
|
||||
});
|
||||
|
||||
test('without overrideFreshAge, server staleAt is preserved', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
// No overrideFreshAge
|
||||
);
|
||||
|
||||
const url = 'https://tile.example.com/bing.png';
|
||||
final serverStaleAt = DateTime.utc(2026, 3, 15, 12, 0);
|
||||
final serverMetadata = CachedMapTileMetadata(
|
||||
staleAt: serverStaleAt,
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(
|
||||
url: url,
|
||||
metadata: serverMetadata,
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(
|
||||
cached!.metadata.staleAt.millisecondsSinceEpoch,
|
||||
equals(serverStaleAt.millisecondsSinceEpoch),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore isolation', () {
|
||||
test('separate directories do not interfere', () async {
|
||||
final dirA = p.join(tempDir.path, 'provider_a', 'type_1');
|
||||
final dirB = p.join(tempDir.path, 'provider_b', 'type_1');
|
||||
|
||||
final storeA = ProviderTileCacheStore(cacheDirectory: dirA);
|
||||
final storeB = ProviderTileCacheStore(cacheDirectory: dirB);
|
||||
|
||||
const url = 'https://tile.example.com/shared-url.png';
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await storeA.putTile(
|
||||
url: url,
|
||||
metadata: metadata,
|
||||
bytes: Uint8List.fromList([1, 1, 1]),
|
||||
);
|
||||
await storeB.putTile(
|
||||
url: url,
|
||||
metadata: metadata,
|
||||
bytes: Uint8List.fromList([2, 2, 2]),
|
||||
);
|
||||
|
||||
final cachedA = await storeA.getTile(url);
|
||||
final cachedB = await storeB.getTile(url);
|
||||
|
||||
expect(cachedA!.bytes, equals(Uint8List.fromList([1, 1, 1])));
|
||||
expect(cachedB!.bytes, equals(Uint8List.fromList([2, 2, 2])));
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheManager', () {
|
||||
test('getOrCreate returns same instance for same key', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeA = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
final storeB = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(storeA, storeB), isTrue);
|
||||
});
|
||||
|
||||
test('getOrCreate returns different instances for different keys', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeA = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
final storeB = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'bing',
|
||||
tileTypeId: 'satellite',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(storeA, storeB), isFalse);
|
||||
});
|
||||
|
||||
test('passes overrideFreshAge from policy.minCacheTtl', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy.osmTileServer(),
|
||||
);
|
||||
|
||||
expect(store.overrideFreshAge, equals(const Duration(days: 7)));
|
||||
});
|
||||
|
||||
test('custom maxCacheBytes is applied', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'big',
|
||||
tileTypeId: 'tiles',
|
||||
policy: const ServicePolicy(),
|
||||
maxCacheBytes: 1024 * 1024 * 1024, // 1 GB
|
||||
);
|
||||
|
||||
expect(store.maxCacheBytes, equals(1024 * 1024 * 1024));
|
||||
});
|
||||
|
||||
test('resetAll clears all stores from registry', () async {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeBefore = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'bing',
|
||||
tileTypeId: 'satellite',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
await ProviderTileCacheManager.resetAll();
|
||||
|
||||
// After reset, must set base dir again before creating stores
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
final storeAfter = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
// New instance should be created (not the old cached one)
|
||||
expect(identical(storeBefore, storeAfter), isFalse);
|
||||
});
|
||||
|
||||
test('unregister removes store from registry', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store1 = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
ProviderTileCacheManager.unregister('osm', 'street');
|
||||
|
||||
// Should create a new instance after unregistering
|
||||
final store2 = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(store1, store2), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore eviction', () {
|
||||
/// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes.
|
||||
/// Uses small delays between writes so modification times are
|
||||
/// distinguishable for oldest-modified ordering.
|
||||
Future<void> fillCache(
|
||||
ProviderTileCacheStore store, {
|
||||
required int count,
|
||||
required int bytesPerTile,
|
||||
String prefix = '',
|
||||
}) async {
|
||||
final bytes = Uint8List.fromList(List.filled(bytesPerTile, 42));
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
for (var i = 0; i < count; i++) {
|
||||
await store.putTile(
|
||||
url: 'https://tile.example.com/$prefix$i.png',
|
||||
metadata: metadata,
|
||||
bytes: bytes,
|
||||
);
|
||||
// Small delay so modification times are distinguishable for eviction order
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
test('eviction reduces cache when exceeding maxCacheBytes', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write tiles that exceed the limit
|
||||
await fillCache(store, count: 10, bytesPerTile: 100);
|
||||
|
||||
// Explicitly trigger eviction (bypasses throttle)
|
||||
await store.forceEviction();
|
||||
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
expect(sizeAfter, lessThanOrEqualTo(500),
|
||||
reason: 'Eviction should reduce cache to at or below limit');
|
||||
});
|
||||
|
||||
test('eviction targets 80% of maxCacheBytes', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 1000,
|
||||
);
|
||||
|
||||
await fillCache(store, count: 10, bytesPerTile: 200);
|
||||
await store.forceEviction();
|
||||
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
// Target is 80% of 1000 = 800 bytes
|
||||
expect(sizeAfter, lessThanOrEqualTo(800),
|
||||
reason: 'Eviction should target 80% of maxCacheBytes');
|
||||
});
|
||||
|
||||
test('oldest-modified tiles are evicted first', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write old tiles first (these should be evicted)
|
||||
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'old_');
|
||||
|
||||
// Write newer tiles (these should survive)
|
||||
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'new_');
|
||||
|
||||
await store.forceEviction();
|
||||
|
||||
// Newest tile should still be present
|
||||
final newestTile = await store.getTile('https://tile.example.com/new_4.png');
|
||||
expect(newestTile, isNotNull,
|
||||
reason: 'Newest tiles should survive eviction');
|
||||
|
||||
// Oldest tile should have been evicted
|
||||
final oldestTile = await store.getTile('https://tile.example.com/old_0.png');
|
||||
expect(oldestTile, isNull,
|
||||
reason: 'Oldest tiles should be evicted first');
|
||||
});
|
||||
|
||||
test('orphan .meta files are cleaned up during eviction', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write a tile to create the directory
|
||||
await fillCache(store, count: 1, bytesPerTile: 50);
|
||||
|
||||
// Manually create an orphan .meta file (no matching .tile)
|
||||
final orphanMetaFile = File(p.join(tempDir.path, 'orphan_key.meta'));
|
||||
await orphanMetaFile.writeAsString('{"staleAt":0}');
|
||||
expect(await orphanMetaFile.exists(), isTrue);
|
||||
|
||||
// Write enough tiles to exceed the limit, then force eviction
|
||||
await fillCache(store, count: 10, bytesPerTile: 100, prefix: 'trigger_');
|
||||
await store.forceEviction();
|
||||
|
||||
// The orphan .meta file should have been cleaned up
|
||||
expect(await orphanMetaFile.exists(), isFalse,
|
||||
reason: 'Orphan .meta file should be cleaned up during eviction');
|
||||
});
|
||||
|
||||
test('evicted tiles have their .meta files removed too', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 300,
|
||||
);
|
||||
|
||||
await fillCache(store, count: 10, bytesPerTile: 100);
|
||||
await store.forceEviction();
|
||||
|
||||
// After eviction, count remaining .tile and .meta files
|
||||
final dir = Directory(tempDir.path);
|
||||
final files = await dir.list().toList();
|
||||
final tileFiles = files
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.endsWith('.tile'))
|
||||
.length;
|
||||
final metaFiles = files
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.endsWith('.meta'))
|
||||
.length;
|
||||
|
||||
// Every remaining .tile should have a matching .meta (1:1)
|
||||
expect(metaFiles, equals(tileFiles),
|
||||
reason: '.meta count should match .tile count after eviction');
|
||||
});
|
||||
|
||||
test('no eviction when cache is under limit', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 100000, // 100KB — way more than we'll write
|
||||
);
|
||||
|
||||
await fillCache(store, count: 3, bytesPerTile: 50);
|
||||
final sizeBefore = await store.estimatedSizeBytes;
|
||||
|
||||
await store.forceEviction();
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
|
||||
expect(sizeAfter, equals(sizeBefore),
|
||||
reason: 'No eviction needed when under limit');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:deflockapp/services/service_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('ServicePolicyResolver', () {
|
||||
setUp(() {
|
||||
ServicePolicyResolver.clearCustomPolicies();
|
||||
});
|
||||
|
||||
group('resolveType', () {
|
||||
test('resolves OSM editing API from production URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM editing API from sandbox URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api06.dev.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM editing API from dev URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://master.apis.dev.openstreetmap.org/api/0.6/user/details'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM tile server from tile URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://tile.openstreetmap.org/12/1234/5678.png'),
|
||||
ServiceType.osmTileServer,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Nominatim from geocoding URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://nominatim.openstreetmap.org/search?q=London'),
|
||||
ServiceType.nominatim,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Overpass API', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://overpass-api.de/api/interpreter'),
|
||||
ServiceType.overpass,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves TagInfo', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://taginfo.openstreetmap.org/api/4/key/values'),
|
||||
ServiceType.tagInfo,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Bing tiles from virtualearth URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://ecn.t0.tiles.virtualearth.net/tiles/a12345.jpeg'),
|
||||
ServiceType.bingTiles,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Mapbox tiles', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api.mapbox.com/v4/mapbox.satellite/12/1234/5678@2x.jpg90'),
|
||||
ServiceType.mapboxTiles,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for unknown host', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://tiles.myserver.com/12/1234/5678.png'),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for empty string', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType(''),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for malformed URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('not-a-url'),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolve', () {
|
||||
test('OSM tile server policy disallows offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, false);
|
||||
});
|
||||
|
||||
test('OSM tile server policy requires 7-day min cache TTL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.minCacheTtl, const Duration(days: 7));
|
||||
});
|
||||
|
||||
test('OSM tile server has attribution URL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('Nominatim policy enforces 1-second rate limit', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.minRequestInterval, const Duration(seconds: 1));
|
||||
});
|
||||
|
||||
test('Nominatim policy requires client caching', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.requiresClientCaching, true);
|
||||
});
|
||||
|
||||
test('Nominatim has attribution URL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('OSM editing API allows max 2 concurrent requests', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 2);
|
||||
});
|
||||
|
||||
test('Bing tiles allow offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://ecn.t0.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('Mapbox tiles allow offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('custom/unknown host gets permissive defaults', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tiles.myserver.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
expect(policy.requiresClientCaching, false);
|
||||
expect(policy.attributionUrl, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolve with URL templates', () {
|
||||
test('handles {z}/{x}/{y} template variables', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, false);
|
||||
});
|
||||
|
||||
test('handles {quadkey} template variable', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('handles {0_3} subdomain template', () {
|
||||
final type = ServicePolicyResolver.resolveType(
|
||||
'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg',
|
||||
);
|
||||
expect(type, ServiceType.bingTiles);
|
||||
});
|
||||
|
||||
test('handles {api_key} template variable', () {
|
||||
final type = ServicePolicyResolver.resolveType(
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
);
|
||||
expect(type, ServiceType.mapboxTiles);
|
||||
});
|
||||
});
|
||||
|
||||
group('custom policy overrides', () {
|
||||
test('custom override takes precedence over built-in', () {
|
||||
ServicePolicyResolver.registerCustomPolicy(
|
||||
'overpass-api.de',
|
||||
const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true),
|
||||
);
|
||||
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 20);
|
||||
});
|
||||
|
||||
test('custom policy for self-hosted tiles allows offline', () {
|
||||
ServicePolicyResolver.registerCustomPolicy(
|
||||
'tiles.myserver.com',
|
||||
const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16),
|
||||
);
|
||||
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tiles.myserver.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.maxConcurrentRequests, 16);
|
||||
});
|
||||
|
||||
test('removing custom override restores built-in policy', () {
|
||||
ServicePolicyResolver.registerCustomPolicy(
|
||||
'overpass-api.de',
|
||||
const ServicePolicy.custom(maxConcurrent: 20),
|
||||
);
|
||||
expect(
|
||||
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
|
||||
20,
|
||||
);
|
||||
|
||||
ServicePolicyResolver.removeCustomPolicy('overpass-api.de');
|
||||
// Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere)
|
||||
expect(
|
||||
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('clearCustomPolicies removes all overrides', () {
|
||||
ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1));
|
||||
ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2));
|
||||
|
||||
ServicePolicyResolver.clearCustomPolicies();
|
||||
|
||||
// Both should now return custom (default) policy
|
||||
expect(
|
||||
ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests,
|
||||
8, // default custom maxConcurrent
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ServiceRateLimiter', () {
|
||||
setUp(() {
|
||||
ServiceRateLimiter.reset();
|
||||
});
|
||||
|
||||
test('acquire and release work for editing API (2 concurrent)', () async {
|
||||
// Should be able to acquire 2 slots without blocking
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
// Release both
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
});
|
||||
|
||||
test('third acquire blocks until a slot is released', () async {
|
||||
// Fill both slots (osmEditingApi maxConcurrentRequests = 2)
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
// Third acquire should block
|
||||
var thirdCompleted = false;
|
||||
final thirdFuture = ServiceRateLimiter.acquire(ServiceType.osmEditingApi).then((_) {
|
||||
thirdCompleted = true;
|
||||
});
|
||||
|
||||
// Give microtasks a chance to run — third should still be blocked
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(thirdCompleted, false);
|
||||
|
||||
// Release one slot — third should now complete
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
await thirdFuture;
|
||||
expect(thirdCompleted, true);
|
||||
|
||||
// Clean up
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
});
|
||||
|
||||
test('Nominatim rate limiting delays rapid requests', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var acquireCount = 0;
|
||||
|
||||
// First request should be immediate
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1);
|
||||
|
||||
// Second request should be delayed by ~1 second
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1, reason: 'second acquire should be blocked');
|
||||
|
||||
// Advance past the 1-second rate limit
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(acquireCount, 2, reason: 'second acquire should have completed');
|
||||
});
|
||||
});
|
||||
|
||||
test('services with no rate limit pass through immediately', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var acquireCount = 0;
|
||||
|
||||
// Overpass has maxConcurrentRequests: 0, so acquire should not apply
|
||||
// any artificial rate limiting delays.
|
||||
ServiceRateLimiter.acquire(ServiceType.overpass).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.overpass);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1);
|
||||
|
||||
ServiceRateLimiter.acquire(ServiceType.overpass).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.overpass);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('Nominatim enforces min interval under concurrent callers', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var completedCount = 0;
|
||||
|
||||
// Start two concurrent callers; only one should run at a time and
|
||||
// the minRequestInterval of ~1s should still be enforced.
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
completedCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
completedCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
|
||||
async.flushMicrotasks();
|
||||
expect(completedCount, 1, reason: 'only first caller should complete immediately');
|
||||
|
||||
// Advance past the 1-second rate limit
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(completedCount, 2, reason: 'second caller should complete after interval');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ServicePolicy', () {
|
||||
test('osmTileServer policy has correct values', () {
|
||||
const policy = ServicePolicy.osmTileServer();
|
||||
expect(policy.allowsOfflineDownload, false);
|
||||
expect(policy.minCacheTtl, const Duration(days: 7));
|
||||
expect(policy.requiresClientCaching, true);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
expect(policy.maxConcurrentRequests, 0); // managed by flutter_map
|
||||
});
|
||||
|
||||
test('nominatim policy has correct values', () {
|
||||
const policy = ServicePolicy.nominatim();
|
||||
expect(policy.minRequestInterval, const Duration(seconds: 1));
|
||||
expect(policy.maxConcurrentRequests, 1);
|
||||
expect(policy.requiresClientCaching, true);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('osmEditingApi policy has correct values', () {
|
||||
const policy = ServicePolicy.osmEditingApi();
|
||||
expect(policy.maxConcurrentRequests, 2);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
});
|
||||
|
||||
test('custom policy uses permissive defaults', () {
|
||||
const policy = ServicePolicy();
|
||||
expect(policy.maxConcurrentRequests, 8);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
expect(policy.requiresClientCaching, false);
|
||||
expect(policy.minCacheTtl, isNull);
|
||||
expect(policy.attributionUrl, isNull);
|
||||
});
|
||||
|
||||
test('custom policy accepts overrides', () {
|
||||
const policy = ServicePolicy.custom(
|
||||
maxConcurrent: 20,
|
||||
allowsOffline: false,
|
||||
attribution: 'https://example.com/license',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 20);
|
||||
expect(policy.allowsOfflineDownload, false);
|
||||
expect(policy.attributionUrl, 'https://example.com/license');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:deflockapp/services/map_data_submodules/tiles_from_local.dart';
|
||||
import 'package:deflockapp/services/offline_areas/offline_tile_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('normalizeBounds', () {
|
||||
test('swapped corners are normalized', () {
|
||||
// NE as first arg, SW as second (swapped)
|
||||
final swapped = LatLngBounds(
|
||||
const LatLng(52.0, 1.0), // NE corner passed as SW
|
||||
const LatLng(51.0, -1.0), // SW corner passed as NE
|
||||
);
|
||||
final normalized = normalizeBounds(swapped);
|
||||
expect(normalized.south, closeTo(51.0, 1e-6));
|
||||
expect(normalized.north, closeTo(52.0, 1e-6));
|
||||
expect(normalized.west, closeTo(-1.0, 1e-6));
|
||||
expect(normalized.east, closeTo(1.0, 1e-6));
|
||||
});
|
||||
|
||||
test('degenerate (zero-width) bounds are expanded', () {
|
||||
final point = LatLngBounds(
|
||||
const LatLng(51.5, -0.1),
|
||||
const LatLng(51.5, -0.1),
|
||||
);
|
||||
final normalized = normalizeBounds(point);
|
||||
expect(normalized.south, lessThan(51.5));
|
||||
expect(normalized.north, greaterThan(51.5));
|
||||
expect(normalized.west, lessThan(-0.1));
|
||||
expect(normalized.east, greaterThan(-0.1));
|
||||
});
|
||||
|
||||
test('already-normalized bounds are unchanged', () {
|
||||
final normal = LatLngBounds(
|
||||
const LatLng(40.0, -10.0),
|
||||
const LatLng(60.0, 30.0),
|
||||
);
|
||||
final normalized = normalizeBounds(normal);
|
||||
expect(normalized.south, closeTo(40.0, 1e-6));
|
||||
expect(normalized.north, closeTo(60.0, 1e-6));
|
||||
expect(normalized.west, closeTo(-10.0, 1e-6));
|
||||
expect(normalized.east, closeTo(30.0, 1e-6));
|
||||
});
|
||||
});
|
||||
|
||||
group('tileInBounds', () {
|
||||
/// Helper: compute expected tile range for [bounds] at [z] using the same
|
||||
/// Mercator projection math and return whether (x, y) is within range.
|
||||
bool referenceTileInBounds(
|
||||
LatLngBounds bounds, int z, int x, int y) {
|
||||
final n = pow(2.0, z);
|
||||
final minX = ((bounds.west + 180.0) / 360.0 * n).floor();
|
||||
final maxX = ((bounds.east + 180.0) / 360.0 * n).floor();
|
||||
final minY = ((1.0 -
|
||||
log(tan(bounds.north * pi / 180.0) +
|
||||
1.0 / cos(bounds.north * pi / 180.0)) /
|
||||
pi) /
|
||||
2.0 *
|
||||
n)
|
||||
.floor();
|
||||
final maxY = ((1.0 -
|
||||
log(tan(bounds.south * pi / 180.0) +
|
||||
1.0 / cos(bounds.south * pi / 180.0)) /
|
||||
pi) /
|
||||
2.0 *
|
||||
n)
|
||||
.floor();
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
test('zoom 0: single tile covers the whole world', () {
|
||||
final world = LatLngBounds(
|
||||
const LatLng(-85, -180),
|
||||
const LatLng(85, 180),
|
||||
);
|
||||
expect(tileInBounds(world, 0, 0, 0), isTrue);
|
||||
});
|
||||
|
||||
test('zoom 1: London area covers NW and NE quadrants', () {
|
||||
// Bounds straddling the prime meridian in the northern hemisphere
|
||||
final londonArea = LatLngBounds(
|
||||
const LatLng(51.0, -1.0),
|
||||
const LatLng(52.0, 1.0),
|
||||
);
|
||||
|
||||
// NW quadrant (x=0, y=0) — should be in bounds
|
||||
expect(tileInBounds(londonArea, 1, 0, 0), isTrue);
|
||||
// NE quadrant (x=1, y=0) — should be in bounds
|
||||
expect(tileInBounds(londonArea, 1, 1, 0), isTrue);
|
||||
// SW quadrant (x=0, y=1) — southern hemisphere, out of bounds
|
||||
expect(tileInBounds(londonArea, 1, 0, 1), isFalse);
|
||||
// SE quadrant (x=1, y=1) — southern hemisphere, out of bounds
|
||||
expect(tileInBounds(londonArea, 1, 1, 1), isFalse);
|
||||
});
|
||||
|
||||
test('zoom 2: London area covers specific tiles', () {
|
||||
final londonArea = LatLngBounds(
|
||||
const LatLng(51.0, -1.0),
|
||||
const LatLng(52.0, 1.0),
|
||||
);
|
||||
|
||||
// Expected: X 1-2, Y 1
|
||||
expect(tileInBounds(londonArea, 2, 1, 1), isTrue);
|
||||
expect(tileInBounds(londonArea, 2, 2, 1), isTrue);
|
||||
// Outside X range
|
||||
expect(tileInBounds(londonArea, 2, 0, 1), isFalse);
|
||||
expect(tileInBounds(londonArea, 2, 3, 1), isFalse);
|
||||
// Outside Y range
|
||||
expect(tileInBounds(londonArea, 2, 1, 0), isFalse);
|
||||
expect(tileInBounds(londonArea, 2, 1, 2), isFalse);
|
||||
});
|
||||
|
||||
test('southern hemisphere: Sydney area', () {
|
||||
final sydneyArea = LatLngBounds(
|
||||
const LatLng(-34.0, 151.0),
|
||||
const LatLng(-33.5, 151.5),
|
||||
);
|
||||
|
||||
// At zoom 1, Sydney is in the SE quadrant (x=1, y=1)
|
||||
expect(tileInBounds(sydneyArea, 1, 1, 1), isTrue);
|
||||
expect(tileInBounds(sydneyArea, 1, 0, 0), isFalse);
|
||||
expect(tileInBounds(sydneyArea, 1, 0, 1), isFalse);
|
||||
expect(tileInBounds(sydneyArea, 1, 1, 0), isFalse);
|
||||
});
|
||||
|
||||
test('western hemisphere: NYC area at zoom 4', () {
|
||||
final nycArea = LatLngBounds(
|
||||
const LatLng(40.5, -74.5),
|
||||
const LatLng(41.0, -73.5),
|
||||
);
|
||||
|
||||
// At zoom 4 (16x16), NYC should be around x=4-5, y=6
|
||||
// x = floor((-74.5+180)/360 * 16) = floor(105.5/360*16) = floor(4.69) = 4
|
||||
// x = floor((-73.5+180)/360 * 16) = floor(106.5/360*16) = floor(4.73) = 4
|
||||
// So x range is just 4
|
||||
expect(tileInBounds(nycArea, 4, 4, 6), isTrue);
|
||||
expect(tileInBounds(nycArea, 4, 5, 6), isFalse);
|
||||
expect(tileInBounds(nycArea, 4, 3, 6), isFalse);
|
||||
});
|
||||
|
||||
test('higher zoom: smaller area at zoom 10', () {
|
||||
// Small area around central London
|
||||
final centralLondon = LatLngBounds(
|
||||
const LatLng(51.49, -0.13),
|
||||
const LatLng(51.52, -0.08),
|
||||
);
|
||||
|
||||
// Compute expected tile range at zoom 10 using reference
|
||||
const z = 10;
|
||||
final n = pow(2.0, z);
|
||||
final expectedMinX =
|
||||
((-0.13 + 180.0) / 360.0 * n).floor();
|
||||
final expectedMaxX =
|
||||
((-0.08 + 180.0) / 360.0 * n).floor();
|
||||
|
||||
// Tiles inside the computed range should be in bounds
|
||||
for (var x = expectedMinX; x <= expectedMaxX; x++) {
|
||||
expect(
|
||||
referenceTileInBounds(centralLondon, z, x, 340),
|
||||
equals(tileInBounds(centralLondon, z, x, 340)),
|
||||
reason: 'Mismatch at tile ($x, 340, $z)',
|
||||
);
|
||||
}
|
||||
|
||||
// Tiles outside X range should not be in bounds
|
||||
expect(tileInBounds(centralLondon, z, expectedMinX - 1, 340), isFalse);
|
||||
expect(tileInBounds(centralLondon, z, expectedMaxX + 1, 340), isFalse);
|
||||
});
|
||||
|
||||
test('tile exactly at boundary is included', () {
|
||||
// Bounds whose edges align exactly with tile boundaries at zoom 1
|
||||
// At zoom 1: x=0 covers lon -180 to 0, x=1 covers lon 0 to 180
|
||||
final halfWorld = LatLngBounds(
|
||||
const LatLng(0.0, 0.0),
|
||||
const LatLng(60.0, 180.0),
|
||||
);
|
||||
|
||||
// Tile (1, 0, 1) should be in bounds (NE quadrant)
|
||||
expect(tileInBounds(halfWorld, 1, 1, 0), isTrue);
|
||||
});
|
||||
|
||||
test('anti-meridian: bounds crossing 180° longitude', () {
|
||||
// Bounds from eastern Russia (170°E) to Alaska (170°W = -170°)
|
||||
// After normalization, west=170 east=-170 which is swapped —
|
||||
// normalizeBounds will swap to west=-170 east=170, which covers
|
||||
// nearly the whole world. This is the expected behavior since
|
||||
// LatLngBounds doesn't support anti-meridian wrapping.
|
||||
final antiMeridian = normalizeBounds(LatLngBounds(
|
||||
const LatLng(50.0, 170.0),
|
||||
const LatLng(70.0, -170.0),
|
||||
));
|
||||
|
||||
// After normalization, west=-170 east=170 (covers most longitudes)
|
||||
// At zoom 2, tiles 0-3 along X axis
|
||||
// Since the normalized bounds cover lon -170 to 170 (340° of 360°),
|
||||
// almost all tiles should be in bounds
|
||||
expect(tileInBounds(antiMeridian, 2, 0, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 1, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 2, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 3, 0), isTrue);
|
||||
});
|
||||
|
||||
test('exhaustive check at zoom 3 matches reference', () {
|
||||
final bounds = LatLngBounds(
|
||||
const LatLng(40.0, -10.0),
|
||||
const LatLng(60.0, 30.0),
|
||||
);
|
||||
|
||||
// Check all 64 tiles at zoom 3 against reference implementation
|
||||
const z = 3;
|
||||
final tilesPerSide = pow(2, z).toInt();
|
||||
for (var x = 0; x < tilesPerSide; x++) {
|
||||
for (var y = 0; y < tilesPerSide; y++) {
|
||||
expect(
|
||||
tileInBounds(bounds, z, x, y),
|
||||
equals(referenceTileInBounds(bounds, z, x, y)),
|
||||
reason: 'Mismatch at tile ($x, $y, $z)',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/services/deflock_tile_provider.dart';
|
||||
import 'package:deflockapp/widgets/map/tile_layer_manager.dart';
|
||||
|
||||
class MockTileImage extends Mock implements TileImage {}
|
||||
|
||||
void main() {
|
||||
group('TileLayerManager exponential backoff', () {
|
||||
test('initial retry delay is 2 seconds', () {
|
||||
final manager = TileLayerManager();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('scheduleRetry fires reset stream after delay', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.scheduleRetry();
|
||||
|
||||
expect(resets, isEmpty);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, isEmpty);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, hasLength(1));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay doubles after each retry fires', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// First retry: 2s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 4)));
|
||||
|
||||
// Second retry: 4s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Third retry: 8s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 8));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 16)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay caps at 60 seconds', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// Drive through cycles: 2 → 4 → 8 → 16 → 32 → 60 → 60
|
||||
var currentDelay = manager.retryDelay;
|
||||
while (currentDelay < const Duration(seconds: 60)) {
|
||||
manager.scheduleRetry();
|
||||
async.elapse(currentDelay);
|
||||
currentDelay = manager.retryDelay;
|
||||
}
|
||||
|
||||
// Should be capped at 60s
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 60)));
|
||||
|
||||
// Another cycle stays at 60s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 60));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 60)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('onTileLoadSuccess resets delay to minimum', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// Drive up the delay
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 4)));
|
||||
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Reset on success
|
||||
manager.onTileLoadSuccess();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('rapid errors debounce: only last timer fires', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
// Fire 3 errors in quick succession (each cancels the previous timer)
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(milliseconds: 500));
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(milliseconds: 500));
|
||||
manager.scheduleRetry();
|
||||
|
||||
// 1s elapsed total since first error, but last timer started 0ms ago
|
||||
// Need to wait 2s from *last* scheduleRetry call
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, isEmpty, reason: 'Timer should not fire yet');
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, hasLength(1), reason: 'Only one reset should fire');
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay stays at minimum if no retries have fired', () {
|
||||
final manager = TileLayerManager();
|
||||
// Just calling onTileLoadSuccess without any errors
|
||||
manager.onTileLoadSuccess();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('backoff progression: 2 → 4 → 8 → 16 → 32 → 60 → 60', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
final expectedDelays = [
|
||||
const Duration(seconds: 2),
|
||||
const Duration(seconds: 4),
|
||||
const Duration(seconds: 8),
|
||||
const Duration(seconds: 16),
|
||||
const Duration(seconds: 32),
|
||||
const Duration(seconds: 60),
|
||||
const Duration(seconds: 60), // capped
|
||||
];
|
||||
|
||||
for (var i = 0; i < expectedDelays.length; i++) {
|
||||
expect(manager.retryDelay, equals(expectedDelays[i]),
|
||||
reason: 'Step $i');
|
||||
manager.scheduleRetry();
|
||||
async.elapse(expectedDelays[i]);
|
||||
}
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('dispose cancels pending retry timer', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
late StreamSubscription<void> sub;
|
||||
sub = manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.scheduleRetry();
|
||||
// Dispose before timer fires
|
||||
sub.cancel();
|
||||
manager.dispose();
|
||||
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty, reason: 'Timer should be cancelled by dispose');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager checkAndClearCacheIfNeeded', () {
|
||||
late TileLayerManager manager;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('first call triggers clear (initial null differs from provided values)', () {
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// First call: internal state is (null, null, false) → (osm, street, false)
|
||||
// provider null→osm triggers clear. Harmless: no tiles to clear yet.
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('same values on second call returns false', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('different provider triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('different tile type triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('different offline mode triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: true,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('cache clear increments mapRebuildKey', () {
|
||||
final initialKey = manager.mapRebuildKey;
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// First call increments (null → osm)
|
||||
expect(manager.mapRebuildKey, equals(initialKey + 1));
|
||||
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// Type change should increment again
|
||||
expect(manager.mapRebuildKey, equals(initialKey + 2));
|
||||
});
|
||||
|
||||
test('no cache clear does not increment mapRebuildKey', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final keyAfterFirst = manager.mapRebuildKey;
|
||||
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(manager.mapRebuildKey, equals(keyAfterFirst));
|
||||
});
|
||||
|
||||
test('null to non-null transition triggers clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: null,
|
||||
currentTileTypeId: null,
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// null → osm is a change — triggers clear so stale tiles are flushed
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('non-null to null to non-null triggers clear both times', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
// Provider goes null (e.g., during reload)
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: null,
|
||||
currentTileTypeId: null,
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// Provider returns — should still trigger clear
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching back and forth triggers clear each time', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching providers with same tile type triggers clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'standard',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'standard',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('provider switch resets retry delay and cancels pending timer', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
// Escalate backoff: 2s → 4s → 8s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Start another retry timer (hasn't fired yet)
|
||||
manager.scheduleRetry();
|
||||
|
||||
// Switch provider — should reset delay and cancel pending timer
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
|
||||
// The pending 8s timer should have been cancelled
|
||||
final resetsBefore = resets.length;
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets.length, equals(resetsBefore),
|
||||
reason: 'Old retry timer should be cancelled on provider switch');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager error-type filtering', () {
|
||||
late TileLayerManager manager;
|
||||
late MockTileImage mockTile;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
mockTile = MockTileImage();
|
||||
when(() => mockTile.coordinates)
|
||||
.thenReturn(const TileCoordinates(1, 2, 3));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('skips retry for TileLoadCancelledException', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const TileLoadCancelledException(),
|
||||
null,
|
||||
);
|
||||
|
||||
// Even after waiting well past the retry delay, no reset should fire.
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('skips retry for TileNotAvailableOfflineException', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const TileNotAvailableOfflineException(),
|
||||
null,
|
||||
);
|
||||
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('schedules retry for other errors (e.g. HttpException)', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const HttpException('tile fetch failed'),
|
||||
null,
|
||||
);
|
||||
|
||||
// Should fire after the initial 2s retry delay.
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(resets, hasLength(1));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user