mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-04 17:16:50 +02:00
529 lines
18 KiB
Dart
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'));
|
|
});
|
|
}
|