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:
Doug Borg
2026-03-03 14:33:26 -07:00
parent be446fbcbc
commit 2d92214bed
36 changed files with 3940 additions and 351 deletions
+372 -52
View File
@@ -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');
});
});
}
+426
View File
@@ -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');
});
});
}
+227
View File
@@ -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));
});
});
});
}