Detect config drift in cached tile providers and replace stale instances

When a user edits a tile type's URL template, max zoom, or API key
without changing IDs, the cached DeflockTileProvider would keep the old
frozen config. Now _getOrCreateProvider() computes a config fingerprint
and replaces the provider when drift is detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Doug Borg
2026-03-04 10:12:49 -07:00
parent f3f40f36ef
commit 91e5177056
7 changed files with 197 additions and 13 deletions

View File

@@ -359,8 +359,8 @@ void main() {
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.
/// Sets deterministic modification times (1 second apart) so eviction
/// ordering is stable across platforms without relying on wall-clock delays.
Future<void> fillCache(
ProviderTileCacheStore store, {
required int count,
@@ -373,14 +373,22 @@ void main() {
lastModified: null,
etag: null,
);
final baseTime = DateTime.utc(2026, 1, 1);
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));
// Set deterministic mtime so eviction order is stable across platforms.
final key = ProviderTileCacheStore.keyFor(
'https://tile.example.com/$prefix$i.png',
);
final tileFile = File(p.join(store.cacheDirectory, '$key.tile'));
final metaFile = File(p.join(store.cacheDirectory, '$key.meta'));
final mtime = baseTime.add(Duration(seconds: i));
await tileFile.setLastModified(mtime);
await metaFile.setLastModified(mtime);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:deflockapp/models/tile_provider.dart' as models;
import 'package:deflockapp/services/deflock_tile_provider.dart';
import 'package:deflockapp/widgets/map/tile_layer_manager.dart';
@@ -419,6 +420,136 @@ void main() {
});
});
group('TileLayerManager config drift detection', () {
late TileLayerManager manager;
setUp(() {
manager = TileLayerManager();
});
tearDown(() {
manager.dispose();
});
models.TileProvider makeProvider({String? apiKey}) => models.TileProvider(
id: 'test_provider',
name: 'Test',
apiKey: apiKey,
tileTypes: [],
);
models.TileType makeTileType({
String urlTemplate = 'https://example.com/{z}/{x}/{y}.png',
int maxZoom = 18,
}) =>
models.TileType(
id: 'test_tile',
name: 'Test',
urlTemplate: urlTemplate,
attribution: 'Test',
maxZoom: maxZoom,
);
test('returns same provider for identical config', () {
final provider = makeProvider();
final tileType = makeTileType();
final layer1 = manager.buildTileLayer(
selectedProvider: provider,
selectedTileType: tileType,
) as TileLayer;
final layer2 = manager.buildTileLayer(
selectedProvider: provider,
selectedTileType: tileType,
) as TileLayer;
expect(
identical(layer1.tileProvider, layer2.tileProvider),
isTrue,
reason: 'Same config should return the cached provider instance',
);
});
test('replaces provider when urlTemplate changes', () {
final provider = makeProvider();
final tileTypeV1 = makeTileType(
urlTemplate: 'https://old.example.com/{z}/{x}/{y}.png',
);
final tileTypeV2 = makeTileType(
urlTemplate: 'https://new.example.com/{z}/{x}/{y}.png',
);
final layer1 = manager.buildTileLayer(
selectedProvider: provider,
selectedTileType: tileTypeV1,
) as TileLayer;
final layer2 = manager.buildTileLayer(
selectedProvider: provider,
selectedTileType: tileTypeV2,
) as TileLayer;
expect(
identical(layer1.tileProvider, layer2.tileProvider),
isFalse,
reason: 'Changed urlTemplate should create a new provider',
);
expect(
(layer2.tileProvider as DeflockTileProvider).tileType.urlTemplate,
'https://new.example.com/{z}/{x}/{y}.png',
);
});
test('replaces provider when apiKey changes', () {
final providerV1 = makeProvider(apiKey: 'old_key');
final providerV2 = makeProvider(apiKey: 'new_key');
final tileType = makeTileType();
final layer1 = manager.buildTileLayer(
selectedProvider: providerV1,
selectedTileType: tileType,
) as TileLayer;
final layer2 = manager.buildTileLayer(
selectedProvider: providerV2,
selectedTileType: tileType,
) as TileLayer;
expect(
identical(layer1.tileProvider, layer2.tileProvider),
isFalse,
reason: 'Changed apiKey should create a new provider',
);
expect(
(layer2.tileProvider as DeflockTileProvider).apiKey,
'new_key',
);
});
test('replaces provider when maxZoom changes', () {
final provider = makeProvider();
final tileTypeV1 = makeTileType(maxZoom: 18);
final tileTypeV2 = makeTileType(maxZoom: 20);
final layer1 = manager.buildTileLayer(
selectedProvider: provider,
selectedTileType: tileTypeV1,
) as TileLayer;
final layer2 = manager.buildTileLayer(
selectedProvider: provider,
selectedTileType: tileTypeV2,
) as TileLayer;
expect(
identical(layer1.tileProvider, layer2.tileProvider),
isFalse,
reason: 'Changed maxZoom should create a new provider',
);
});
});
group('TileLayerManager error-type filtering', () {
late TileLayerManager manager;
late MockTileImage mockTile;