mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-03-22 02:43:43 +00:00
- 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>
572 lines
19 KiB
Dart
572 lines
19 KiB
Dart
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, no offline areas
|
|
when(() => mockAppState.offlineMode).thenReturn(false);
|
|
|
|
provider = DeflockTileProvider(
|
|
providerId: 'openstreetmap',
|
|
tileType: osmTileType,
|
|
);
|
|
});
|
|
|
|
tearDown(() async {
|
|
provider.shutdown();
|
|
AppState.instance = MockAppState();
|
|
});
|
|
|
|
group('DeflockTileProvider', () {
|
|
test('supportsCancelLoading is true', () {
|
|
expect(provider.supportsCancelLoading, isTrue);
|
|
});
|
|
|
|
test('getTileUrl() uses frozen tileType config', () {
|
|
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'));
|
|
});
|
|
|
|
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);
|
|
final options = TileLayer(urlTemplate: 'ignored');
|
|
|
|
final url = provider.getTileUrl(coords, options);
|
|
|
|
expect(url, contains('access_token=test_key_123'));
|
|
expect(url, contains('/10/1/2@2x'));
|
|
});
|
|
|
|
test('routes to network path when no offline areas exist', () {
|
|
// offlineMode = false, OfflineAreaService not initialized → no offline areas
|
|
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,
|
|
);
|
|
|
|
// Should NOT be a DeflockOfflineTileImageProvider — it should be the
|
|
// NetworkTileImageProvider returned by super
|
|
expect(imageProvider, isNot(isA<DeflockOfflineTileImageProvider>()));
|
|
});
|
|
|
|
test('routes to offline path when offline mode is enabled', () {
|
|
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.isOfflineOnly, isTrue);
|
|
expect(offlineProvider.coordinates, equals(coords));
|
|
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, provider/type, and offlineOnly', () {
|
|
const coords = TileCoordinates(1, 2, 3);
|
|
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
|
final cancel = Future<void>.value();
|
|
|
|
final a = DeflockOfflineTileImageProvider(
|
|
coordinates: coords,
|
|
options: options,
|
|
httpClient: http.Client(),
|
|
headers: const {},
|
|
cancelLoading: cancel,
|
|
isOfflineOnly: false,
|
|
providerId: 'prov_a',
|
|
tileTypeId: 'type_1',
|
|
tileUrl: 'https://example.com/3/1/2',
|
|
);
|
|
final b = DeflockOfflineTileImageProvider(
|
|
coordinates: coords,
|
|
options: options,
|
|
httpClient: http.Client(),
|
|
headers: const {},
|
|
cancelLoading: cancel,
|
|
isOfflineOnly: false,
|
|
providerId: 'prov_a',
|
|
tileTypeId: 'type_1',
|
|
tileUrl: 'https://other.com/3/1/2', // different — but not in ==
|
|
);
|
|
|
|
expect(a, equals(b));
|
|
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);
|
|
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
|
final cancel = Future<void>.value();
|
|
|
|
final a = DeflockOfflineTileImageProvider(
|
|
coordinates: coords1,
|
|
options: options,
|
|
httpClient: http.Client(),
|
|
headers: const {},
|
|
cancelLoading: cancel,
|
|
isOfflineOnly: false,
|
|
providerId: 'prov_a',
|
|
tileTypeId: 'type_1',
|
|
tileUrl: 'url1',
|
|
);
|
|
final b = DeflockOfflineTileImageProvider(
|
|
coordinates: coords2,
|
|
options: options,
|
|
httpClient: http.Client(),
|
|
headers: const {},
|
|
cancelLoading: cancel,
|
|
isOfflineOnly: false,
|
|
providerId: 'prov_a',
|
|
tileTypeId: 'type_1',
|
|
tileUrl: 'url2',
|
|
);
|
|
|
|
expect(a, isNot(equals(b)));
|
|
});
|
|
|
|
test('not equal for different provider or type', () {
|
|
const coords = TileCoordinates(1, 2, 3);
|
|
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
|
final cancel = Future<void>.value();
|
|
|
|
final base = DeflockOfflineTileImageProvider(
|
|
coordinates: coords,
|
|
options: options,
|
|
httpClient: http.Client(),
|
|
headers: const {},
|
|
cancelLoading: cancel,
|
|
isOfflineOnly: false,
|
|
providerId: 'prov_a',
|
|
tileTypeId: 'type_1',
|
|
tileUrl: 'url',
|
|
);
|
|
final diffProvider = DeflockOfflineTileImageProvider(
|
|
coordinates: coords,
|
|
options: options,
|
|
httpClient: http.Client(),
|
|
headers: const {},
|
|
cancelLoading: cancel,
|
|
isOfflineOnly: false,
|
|
providerId: 'prov_b',
|
|
tileTypeId: 'type_1',
|
|
tileUrl: 'url',
|
|
);
|
|
final diffType = DeflockOfflineTileImageProvider(
|
|
coordinates: coords,
|
|
options: options,
|
|
httpClient: http.Client(),
|
|
headers: const {},
|
|
cancelLoading: cancel,
|
|
isOfflineOnly: false,
|
|
providerId: 'prov_a',
|
|
tileTypeId: 'type_2',
|
|
tileUrl: 'url',
|
|
);
|
|
|
|
expect(base, isNot(equals(diffProvider)));
|
|
expect(base.hashCode, isNot(equals(diffProvider.hashCode)));
|
|
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'));
|
|
});
|
|
});
|
|
}
|