mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-07-07 13:17:53 +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:
@@ -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