Files
SpotiFLAC-Mobile/test/downloaded_album_reenrich_test.dart
T

355 lines
13 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
class _Settings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings();
@override
Future<void> syncLyricsSettingsToBackend({AppSettings? settings}) async {}
}
class _History extends DownloadHistoryNotifier {
final updates = <String, String?>{};
@override
DownloadHistoryState build() => DownloadHistoryState();
@override
Future<void> updateMetadataForItem({
required String id,
required String trackName,
required String artistName,
required String albumName,
String? albumArtist,
String? isrc,
int? trackNumber,
int? totalTracks,
int? discNumber,
int? totalDiscs,
String? releaseDate,
String? genre,
String? composer,
String? label,
String? copyright,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
updates[id] = isrc;
}
}
class _Library extends LocalLibraryNotifier {
int refreshes = 0;
@override
LocalLibraryState build() => LocalLibraryState();
@override
Future<void> scanAllSources({bool forceFullScan = false}) async {
refreshes++;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
for (final includeLocal in [false, true]) {
testWidgets(
'Library re-enrich includes downloaded tracks and refreshes each source (mixed: $includeLocal)',
(tester) async {
SharedPreferences.setMockInitialValues({});
final cache = Directory.systemTemp.createTempSync('library_reenrich_');
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
cache,
);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.runAsync(() async {
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
null,
);
await cache.delete(recursive: true);
});
});
final history = _History();
final library = _Library();
final requests = <Map<String, dynamic>>[];
final paths = [
'content://library/document/download.flac',
if (includeLocal) '/music/local.flac',
];
var completed = false;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'reEnrichFile') {
final request = Map<String, dynamic>.from(
jsonDecode((call.arguments as Map)['request_json'] as String)
as Map,
);
requests.add(request);
return request['preview_only'] == true
? {
'method': 'preview',
'enriched_metadata': {'isrc': 'USABC2600001'},
}
: {'method': 'native'};
}
if (call.method == 'readAudioMetadata') {
return {
'trackName': 'Downloaded',
'artistName': 'Artist',
'albumName': 'Album',
'isrc': 'USABC2600001',
};
}
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
downloadHistoryProvider.overrideWith(() => history),
localLibraryProvider.overrideWith(() => library),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () => reEnrichLibraryTracks(
context,
ref,
[
UnifiedLibraryItem.fromDownloadHistory(
DownloadHistoryItem(
id: 'download',
trackName: 'Downloaded',
artistName: 'Artist',
albumName: 'Album',
filePath: paths.first,
service: 'example-provider',
downloadedAt: DateTime(2026),
),
),
if (includeLocal)
UnifiedLibraryItem.fromLocalLibrary(
LocalLibraryItem(
id: 'local',
trackName: 'Local',
artistName: 'Artist',
albumName: 'Album',
filePath: paths.last,
scannedAt: DateTime(2026),
),
),
],
isActive: () => context.mounted,
onSelectionHide: () async {},
onSelectionRestore: () {},
onComplete: () => completed = true,
),
child: const Text('Start'),
),
),
),
),
),
);
await tester.tap(find.text('Start'));
await tester.pumpAndSettle();
await tester.tap(find.text('Review changes'));
await tester.pumpAndSettle();
expect(requests.map((request) => request['file_path']), paths);
await tester.tap(find.text('Apply changes'));
for (var attempt = 0; attempt < 50 && !completed; attempt++) {
await tester.pump(const Duration(milliseconds: 100));
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 10));
});
}
await tester.pumpAndSettle();
expect(requests.map((request) => request['file_path']), [
...paths,
...paths,
]);
expect(history.updates, {'download': 'USABC2600001'});
expect(library.refreshes, includeLocal ? 1 : 0);
expect(completed, isTrue);
expect(tester.takeException(), isNull);
},
);
}
for (final mornye in [false, true]) {
testWidgets(
'downloaded album re-enrich restores selection on cancel and updates only selected files ($mornye)',
(tester) async {
SharedPreferences.setMockInitialValues({});
final cache = Directory.systemTemp.createTempSync('album_reenrich_');
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
cache,
);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.runAsync(() async {
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
null,
);
await cache.delete(recursive: true);
});
});
const path = 'content://library/document/track.flac';
final requests = <Map<String, dynamic>>[];
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'reEnrichFile') {
final request = Map<String, dynamic>.from(
jsonDecode((call.arguments as Map)['request_json'] as String)
as Map,
);
requests.add(request);
return request['preview_only'] == true
? {
'method': 'preview',
'enriched_metadata': {'isrc': 'USABC2600001'},
}
: {'method': 'native'};
}
if (call.method == 'readAudioMetadata') {
return {
'trackName': 'Track',
'artistName': 'Artist',
'albumName': 'Album',
'isrc': 'USABC2600001',
'hasLyrics': true,
};
}
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final history = _History();
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
downloadHistoryProvider.overrideWith(() => history),
downloadedAlbumTracksProvider(
const DownloadedAlbumTracksRequest(
albumName: 'Album',
artistName: 'Artist',
),
).overrideWith(
(ref) async => [
for (final id in ['track', 'other'])
DownloadHistoryItem(
id: id,
trackName: id == 'track' ? 'Track' : 'Other',
artistName: 'Artist',
albumName: 'Album',
filePath: id == 'track' ? path : '/music/other.flac',
service: 'example-provider',
downloadedAt: DateTime(2026),
duration: 180,
),
],
),
],
child: MaterialApp(
navigatorKey: navigator,
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SelectionOverlayHost(
child: DownloadedAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
),
),
),
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Track'), 200);
await tester.longPress(find.text('Track'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(
tester.element(find.byType(SelectionBottomBar)),
);
expect(find.text(l10n.selectionShareCount(1)), findsNothing);
Future<void> openReEnrich() async {
await tester.tap(find.text('${l10n.trackReEnrich} (1)'));
await tester.pumpAndSettle();
expect(find.byType(SelectionBottomBar), findsNothing);
expect(find.text('Review changes'), findsOneWidget);
}
await openReEnrich();
navigator.currentState!.pop();
await tester.pumpAndSettle();
expect(find.byType(SelectionBottomBar), findsOneWidget);
expect(requests, isEmpty);
await openReEnrich();
await tester.ensureVisible(find.text('Review changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Review changes'));
await tester.pumpAndSettle();
expect(requests, hasLength(1));
expect(requests.single['file_path'], path);
expect(requests.single['duration_ms'], 180000);
await tester.ensureVisible(find.text('Apply changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Apply changes'));
// The history refresh also invalidates the disk-backed cover cache.
for (
var attempt = 0;
attempt < 50 && history.updates.isEmpty;
attempt++
) {
await tester.pump(const Duration(milliseconds: 100));
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 10));
});
}
await tester.pumpAndSettle();
expect(requests, hasLength(2));
expect(requests.last['file_path'], path);
expect(history.updates, {'track': 'USABC2600001'});
expect(find.byType(SelectionBottomBar), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
}