Files
minstrel/flutter_client/test/player/album_cover_cache_test.dart
bvandeusen b6d6f22598 feat(flutter/player): AlbumCoverCache for lock-screen artwork
Fetches album cover bytes via the authenticated dio and writes to
<applicationCacheDirectory>/album_covers/<albumId>.jpg. Returns the
local path so MediaItem.artUri can point at a file:// URI — Android's
MediaSession framework fetches artUri itself without our Bearer
header, so a pre-fetched local file is the workaround.

Concurrent callers for the same albumId dedupe to one fetch via an
in-flight Future map. Any failure (network / 4xx / 5xx / disk full /
empty id) returns null without throwing — playback continues with
the system's generic music icon, same UX as today.

No eviction — covers are tiny, OS clears cache when space is tight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:53:20 -04:00

122 lines
3.7 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/player/album_cover_cache.dart';
class _RecordingAdapter implements HttpClientAdapter {
_RecordingAdapter(this.body);
final List<int> body;
int callCount = 0;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
callCount++;
return ResponseBody.fromBytes(body, 200, headers: {
Headers.contentTypeHeader: ['image/jpeg'],
});
}
@override
void close({bool force = false}) {}
}
class _FailingAdapter implements HttpClientAdapter {
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
throw DioException(
requestOptions: options,
type: DioExceptionType.connectionError,
message: 'simulated network failure',
);
}
@override
void close({bool force = false}) {}
}
Future<Directory> _tmpDirFactory() async =>
Directory.systemTemp.createTempSync('cover_cache_test_');
void main() {
test('cache miss writes file and returns path', () async {
final adapter = _RecordingAdapter([1, 2, 3, 4]);
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
final cache = AlbumCoverCache(
dioFactory: () async => dio,
cacheDirFactory: _tmpDirFactory,
);
final path = await cache.getOrFetch('alb-1');
expect(path, isNotNull);
expect(File(path!).readAsBytesSync(), [1, 2, 3, 4]);
expect(adapter.callCount, 1);
});
test('cache hit returns same path without re-fetching', () async {
final adapter = _RecordingAdapter([9, 9, 9]);
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
final tmp = await _tmpDirFactory();
final cache = AlbumCoverCache(
dioFactory: () async => dio,
cacheDirFactory: () async => tmp,
);
final p1 = await cache.getOrFetch('alb-2');
final p2 = await cache.getOrFetch('alb-2');
expect(p1, p2);
expect(adapter.callCount, 1);
});
test('concurrent calls for same albumId dedupe', () async {
final adapter = _RecordingAdapter([1, 2, 3]);
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
final cache = AlbumCoverCache(
dioFactory: () async => dio,
cacheDirFactory: _tmpDirFactory,
);
final results = await Future.wait([
cache.getOrFetch('alb-3'),
cache.getOrFetch('alb-3'),
cache.getOrFetch('alb-3'),
]);
expect(results[0], isNotNull);
expect(results[1], results[0]);
expect(results[2], results[0]);
expect(adapter.callCount, 1);
});
test('failure returns null without writing file', () async {
final adapter = _FailingAdapter();
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
final tmp = await _tmpDirFactory();
final cache = AlbumCoverCache(
dioFactory: () async => dio,
cacheDirFactory: () async => tmp,
);
final path = await cache.getOrFetch('alb-4');
expect(path, isNull);
expect(File('${tmp.path}/album_covers/alb-4.jpg').existsSync(), isFalse);
});
test('empty albumId returns null without dio call', () async {
final adapter = _RecordingAdapter([1, 2]);
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
final cache = AlbumCoverCache(
dioFactory: () async => dio,
cacheDirFactory: _tmpDirFactory,
);
final path = await cache.getOrFetch('');
expect(path, isNull);
expect(adapter.callCount, 0);
});
}