Files
SpotiFLAC-Mobile/test/downloaded_embedded_cover_resolver_test.dart
T

529 lines
18 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const backendChannel = MethodChannel('com.zarz.spotiflac/backend');
final sourcePaths = <String>[];
final tempDirectories = <Directory>[];
late Directory persistentCacheDirectory;
setUp(() async {
persistentCacheDirectory = await Directory.systemTemp.createTemp(
'embedded_cover_persistent_test_',
);
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
persistentCacheDirectory,
);
});
Future<String> createAudioFixture(String name) async {
final directory = await Directory.systemTemp.createTemp(
'embedded_cover_resolver_test_',
);
tempDirectories.add(directory);
final source = File('${directory.path}${Platform.pathSeparator}$name.flac');
await source.writeAsBytes(const [0, 1, 2, 3]);
sourcePaths.add(source.path);
return source.path;
}
tearDown(() async {
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting(
preservePersistentFiles: false,
);
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, null);
sourcePaths.clear();
for (final directory in tempDirectories) {
if (await directory.exists()) {
await directory.delete(recursive: true);
}
}
tempDirectories.clear();
if (await persistentCacheDirectory.exists()) {
await persistentCacheDirectory.delete(recursive: true);
}
});
test('metadata artwork prefers embedded, then local, then remote', () {
expect(
resolveMetadataArtworkSource(
embeddedCoverPath: ' C:/covers/embedded.jpg ',
localCoverPath: 'C:/covers/local.jpg',
remoteCoverUrl: 'https://example.com/online.jpg',
),
'C:/covers/embedded.jpg',
);
expect(
resolveMetadataArtworkSource(
embeddedCoverPath: ' ',
localCoverPath: ' C:/covers/local.jpg ',
remoteCoverUrl: 'https://example.com/online.jpg',
),
'C:/covers/local.jpg',
);
expect(
resolveMetadataArtworkSource(
remoteCoverUrl: ' https://example.com/online.jpg ',
),
'https://example.com/online.jpg',
);
expect(resolveMetadataArtworkSource(), isNull);
});
test(
'cold resolve starts extraction and reports the cached preview',
() async {
final sourcePath = await createAudioFixture('cold-cache');
var extractionCalls = 0;
final changed = Completer<void>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [4, 5, 6]);
return jsonEncode({'success': true});
});
final initial = DownloadedEmbeddedCoverResolver.resolve(
sourcePath,
onChanged: () {
if (!changed.isCompleted) changed.complete();
},
);
expect(initial, isNull);
await changed.future.timeout(const Duration(seconds: 2));
final cached = DownloadedEmbeddedCoverResolver.resolve(sourcePath);
expect(extractionCalls, 1);
expect(cached, isNotNull);
expect(await File(cached!).exists(), isTrue);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(cached),
isTrue,
);
},
);
test('concurrent resolveOrExtract calls share one extraction', () async {
final sourcePath = await createAudioFixture('concurrent');
var extractionCalls = 0;
var callbacks = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
final arguments = call.arguments as Map<Object?, Object?>;
await Future<void>.delayed(const Duration(milliseconds: 20));
await File(
arguments['output_path']! as String,
).writeAsBytes(const [7, 8, 9]);
return jsonEncode({'success': true});
});
final results = await Future.wait([
DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
onChanged: () => callbacks++,
),
DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
onChanged: () => callbacks++,
),
DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
onChanged: () => callbacks++,
),
]);
expect(extractionCalls, 1);
expect(callbacks, 3);
expect(results.toSet(), hasLength(1));
expect(results.first, isNotNull);
expect(await File(results.first!).exists(), isTrue);
});
test(
'background cover extraction is limited to two concurrent jobs',
() async {
final paths = await Future.wait([
for (var index = 0; index < 5; index++)
createAudioFixture('bounded-$index'),
]);
final release = Completer<void>();
final firstTwoStarted = Completer<void>();
final allCompleted = Completer<void>();
var calls = 0;
var active = 0;
var maxActive = 0;
var completed = 0;
addTearDown(() {
if (!release.isCompleted) release.complete();
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
calls++;
active++;
if (active > maxActive) maxActive = active;
if (calls == 2 && !firstTwoStarted.isCompleted) {
firstTwoStarted.complete();
}
try {
await release.future;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [13, 14, 15]);
return jsonEncode({'success': true});
} finally {
active--;
}
});
void onChanged() {
completed++;
if (completed == paths.length && !allCompleted.isCompleted) {
allCompleted.complete();
}
}
for (final path in paths) {
expect(
DownloadedEmbeddedCoverResolver.resolve(path, onChanged: onChanged),
isNull,
);
}
await firstTwoStarted.future.timeout(const Duration(seconds: 2));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(calls, 2);
expect(maxActive, 2);
release.complete();
await allCompleted.future.timeout(const Duration(seconds: 2));
expect(calls, paths.length);
expect(maxActive, 2);
},
);
test('foreground extraction is promoted ahead of background jobs', () async {
final paths = await Future.wait([
for (var index = 0; index < 4; index++)
createAudioFixture('priority-$index'),
]);
final releaseFirst = Completer<void>();
final releaseSecond = Completer<void>();
final firstTwoStarted = Completer<void>();
final thirdStarted = Completer<void>();
final allCompleted = Completer<void>();
final startedPaths = <String>[];
var completed = 0;
addTearDown(() {
if (!releaseFirst.isCompleted) releaseFirst.complete();
if (!releaseSecond.isCompleted) releaseSecond.complete();
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
final arguments = call.arguments as Map<Object?, Object?>;
final audioPath = arguments['audio_path']! as String;
startedPaths.add(audioPath);
final callIndex = startedPaths.length - 1;
if (startedPaths.length == 2 && !firstTwoStarted.isCompleted) {
firstTwoStarted.complete();
}
if (startedPaths.length == 3 && !thirdStarted.isCompleted) {
thirdStarted.complete();
}
if (callIndex == 0) {
await releaseFirst.future;
} else if (callIndex == 1) {
await releaseSecond.future;
}
await File(
arguments['output_path']! as String,
).writeAsBytes(const [16, 17, 18]);
return jsonEncode({'success': true});
});
void onChanged() {
completed++;
if (completed == paths.length && !allCompleted.isCompleted) {
allCompleted.complete();
}
}
DownloadedEmbeddedCoverResolver.resolve(paths[0], onChanged: onChanged);
DownloadedEmbeddedCoverResolver.resolve(paths[1], onChanged: onChanged);
await firstTwoStarted.future.timeout(const Duration(seconds: 2));
DownloadedEmbeddedCoverResolver.resolve(paths[2], onChanged: onChanged);
DownloadedEmbeddedCoverResolver.resolve(paths[3], onChanged: onChanged);
final foreground = DownloadedEmbeddedCoverResolver.resolveOrExtract(
paths[3],
);
releaseFirst.complete();
await thirdStarted.future.timeout(const Duration(seconds: 2));
expect(startedPaths[2], paths[3]);
releaseSecond.complete();
expect(await foreground, isNotNull);
await allCompleted.future.timeout(const Duration(seconds: 2));
expect(startedPaths, hasLength(paths.length));
});
test('force refresh drops a stale cover when artwork is removed', () async {
final sourcePath = await createAudioFixture('removed-cover');
var extractionCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
if (extractionCalls == 1) {
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [10, 11, 12]);
return jsonEncode({'success': true});
}
return jsonEncode({'error': 'embedded cover not found'});
});
final original = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(original, isNotNull);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(original),
isTrue,
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
sourcePath,
force: true,
);
final refreshed = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(extractionCalls, 2);
expect(refreshed, isNull);
expect(await File(original!).exists(), isFalse);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(original),
isTrue,
);
expect(DownloadedEmbeddedCoverResolver.resolve(sourcePath), isNull);
});
test(
'persistent preview survives memory reset and revalidates source changes',
() async {
final sourcePath = await createAudioFixture('persistent-restart');
var extractionCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes([extractionCalls, 20, 21]);
return jsonEncode({'success': true});
});
final first = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(first, isNotNull);
expect(extractionCalls, 1);
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(first),
isTrue,
);
final reused = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(reused, first);
expect(extractionCalls, 1);
await File(sourcePath).writeAsBytes(const [9, 8, 7, 6, 5]);
await File(
sourcePath,
).setLastModified(DateTime.now().add(const Duration(seconds: 2)));
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
final refreshed = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(extractionCalls, 2);
expect(refreshed, isNotNull);
expect(refreshed, isNot(first));
expect(await File(refreshed!).exists(), isTrue);
expect(await File(first!).exists(), isFalse);
},
);
test('invalidate removes persistent variants after memory reset', () async {
final sourcePath = await createAudioFixture('persistent-invalidate');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [22, 23, 24]);
return jsonEncode({'success': true});
});
final preview = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(preview, isNotNull);
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
await DownloadedEmbeddedCoverResolver.invalidate(sourcePath);
expect(await File(preview!).exists(), isFalse);
final remainingPreviews = await persistentCacheDirectory
.list()
.where((entity) => entity is File && entity.path.endsWith('.jpg'))
.toList();
expect(remainingPreviews, isEmpty);
});
test('persistent maintenance enforces entry and byte caps', () async {
final paths = await Future.wait([
for (var index = 0; index < 3; index++)
createAudioFixture('persistent-cap-$index'),
]);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [25, 26, 27, 28]);
return jsonEncode({'success': true});
});
final previews = <String>[];
for (final path in paths) {
previews.add(
(await DownloadedEmbeddedCoverResolver.resolveOrExtract(path))!,
);
}
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
for (var index = 0; index < previews.length; index++) {
await File(previews[index]).setLastModified(DateTime(2024, 1, index + 1));
}
await DownloadedEmbeddedCoverResolver.runPersistentCacheMaintenanceForTesting(
maxEntries: 2,
maxBytes: 100,
targetBytes: 100,
);
expect(await File(previews[0]).exists(), isFalse);
expect(await File(previews[1]).exists(), isTrue);
expect(await File(previews[2]).exists(), isTrue);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(previews[1]),
isTrue,
);
await DownloadedEmbeddedCoverResolver.runPersistentCacheMaintenanceForTesting(
maxEntries: 10,
maxBytes: 5,
targetBytes: 4,
);
final survivors = await Future.wait([
for (final preview in previews) File(preview).exists(),
]);
expect(survivors.where((exists) => exists), hasLength(1));
});
test(
'persistent cache clear waits for cancelled native extraction cleanup',
() async {
final sourcePath = await createAudioFixture('clear-in-flight');
final started = Completer<void>();
final release = Completer<void>();
addTearDown(() {
if (!release.isCompleted) release.complete();
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
if (!started.isCompleted) started.complete();
await release.future;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [29, 30, 31]);
return jsonEncode({'success': true});
});
final resolution = DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
await started.future.timeout(const Duration(seconds: 2));
var clearCompleted = false;
final clear = DownloadedEmbeddedCoverResolver.clearPersistentCache().then(
(_) => clearCompleted = true,
);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(clearCompleted, isFalse);
release.complete();
expect(await resolution, isNull);
await clear.timeout(const Duration(seconds: 2));
expect(clearCompleted, isTrue);
expect(await persistentCacheDirectory.list().toList(), isEmpty);
},
);
test('metadata cover pipeline enforces shared resolver ownership', () {
final coverSource = File(
'lib/screens/track_metadata_screen_cover.dart',
).readAsStringSync();
final cardsSource = File(
'lib/screens/track_metadata_cards.dart',
).readAsStringSync();
expect(
coverSource,
contains('DownloadedEmbeddedCoverResolver.isManagedPreviewPath(path)'),
);
expect(
coverSource,
contains('DownloadedEmbeddedCoverResolver.resolveOrExtract('),
);
expect(coverSource, isNot(contains('PlatformBridge.extractCoverToFile')));
expect(
cardsSource,
contains('Downloaded-item entry points must await resolveOrExtract'),
);
expect(
cardsSource,
contains(
'Widget coverImage() => artworkImage(cacheWidth: coverCacheWidth)',
),
);
expect(cardsSource, contains('cacheHeight: backdropCacheWidth'));
});
}