import 'dart:async'; import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; LocalLibraryItem item(String id) => LocalLibraryItem( id: id, trackName: 'Song $id', artistName: 'Artist', albumName: 'Album', filePath: '/music/$id.flac', scannedAt: DateTime(2026), ); void progress(int index, LocalLibraryItem item) {} bool running() => false; class _BatchSettings extends SettingsNotifier { final phases = []; @override AppSettings build() => const AppSettings(); @override Future syncLyricsSettingsToBackend({AppSettings? settings}) async { phases.add(settings ?? state); } } class _BatchLibrary extends LocalLibraryNotifier { final refreshed = Completer(); bool refreshStarted = false; @override LocalLibraryState build() => LocalLibraryState(); @override Future scanAllSources({bool forceFullScan = false}) { refreshStarted = true; return refreshed.future; } } void main() { test( 'lookup/apply each capture settings once, dispatch native/ffmpeg, and retain partial success', () async { var phaseCount = 0; var currentSettings = const AppSettings(lyricsMode: 'preview'); final requests = >[]; final sidecars = []; final ffmpegModes = []; final runner = BatchReEnrichRunner( beginPhase: () async { phaseCount++; return currentSettings; }, reEnrich: (request) async { requests.add(request); if (request['preview_only'] == true) { return { 'method': 'preview', 'enriched_metadata': {'isrc': 'USABC2600001'}, }; } // A mid-phase change must not alter the captured apply settings. currentSettings = currentSettings.copyWith( artistTagMode: 'later', lyricsMode: 'later', ); return switch (request['file_path']) { '/music/1.flac' => {'method': 'native'}, '/music/2.flac' => {'method': 'ffmpeg'}, '/music/3.flac' => throw StateError('write failed'), _ => {'method': 'unsupported'}, }; }, writeSidecar: ({required audioFilePath, required reEnrichResult}) async { sidecars.add(audioFilePath); }, applyFfmpeg: ({required item, required result, required artistTagMode}) async { ffmpegModes.add(artistTagMode); return true; }, ); final previews = await runner.preview( [item('1'), item('2'), item('3'), item('4')], const ReEnrichFieldSelection(mode: ReEnrichBatchMode.isrcOnly), shouldStop: running, onProgress: progress, ); expect(previews.length, 4); expect(phaseCount, 1); currentSettings = const AppSettings( lyricsMode: 'apply', artistTagMode: 'split_vorbis', ); final successes = await runner.apply( previews, shouldStop: running, onProgress: progress, ); expect(successes, 2); expect(phaseCount, 2); expect(sidecars, ['/music/1.flac']); expect(ffmpegModes, ['split_vorbis']); expect( requests.take(4).map((request) => request['lyrics_mode']), everyElement('preview'), ); expect( requests.skip(4).map((request) => request['lyrics_mode']), everyElement('apply'), ); expect( requests.skip(4).map((request) => request['search_online']), everyElement(false), ); }, ); test( 'manual previews avoid lookups; cancellation before apply performs no writes or phase sync', () async { var phases = 0; var calls = 0; final runner = BatchReEnrichRunner( beginPhase: () async { phases++; return const AppSettings(); }, reEnrich: (_) async { calls++; return {}; }, ); final previews = await runner.preview( [item('1'), item('2')], const ReEnrichFieldSelection( mode: ReEnrichBatchMode.manualValues, manualValues: {'genre': 'Jazz'}, ), shouldStop: running, onProgress: progress, ); expect(previews.length, 2); expect(calls, 0); expect( await runner.apply( previews, shouldStop: () => true, onProgress: progress, ), 0, ); expect(calls, 0); expect(phases, 1); }, ); test( 'lookup failures do not discard useful previews and cancellation stops subsequent tracks', () async { var cancelled = false; var lookups = 0; final runner = BatchReEnrichRunner( beginPhase: () async => const AppSettings(), reEnrich: (_) async { lookups++; if (lookups == 1) throw StateError('lookup failed'); cancelled = true; return { 'method': 'preview', 'enriched_metadata': {'isrc': 'USABC2600001'}, }; }, ); final previews = await runner.preview( [item('1'), item('2'), item('3')], const ReEnrichFieldSelection(mode: ReEnrichBatchMode.isrcOnly), shouldStop: () => cancelled, onProgress: progress, ); expect(lookups, 2); expect(previews.single.item.id, '2'); }, ); test( 'FLAC matching retains reliable partial results and counts failures when cancelled', () async { var cancelled = false; var calls = 0; final result = await matchLocalTracksForFlac( [item('1'), item('2'), item('3'), item('4'), item('5')], shouldStop: () => cancelled, onProgress: progress, resolve: (local) async { calls++; if (local.id == '2') throw StateError('lookup failed'); if (local.id == '4') cancelled = true; return LocalTrackRedownloadResolution( localItem: local, match: local.id == '3' ? null : Track( id: local.id, name: local.trackName, artistName: 'Artist', albumName: 'Album', duration: 1, ), score: 100, reason: 'fixture', ); }, ); expect(calls, 4); expect(result.tracks.map((track) => track.id), ['1', '4']); expect(result.skipped, 2); }, ); for (final leaveDuringRefresh in [false, true]) { testWidgets( 'batch completion respects owner lifecycle during refresh: $leaveDuringRefresh', (tester) async { const channel = MethodChannel('com.zarz.spotiflac/backend'); final settings = _BatchSettings(); final library = _BatchLibrary(); var active = true; final events = []; Future? action; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( channel, (call) async { if (call.method != 'reEnrichFile') { throw StateError('unexpected call'); } final request = jsonDecode((call.arguments as Map)['request_json'] as String) as Map; return request['preview_only'] == true ? { 'method': 'preview', 'enriched_metadata': {'isrc': 'USABC2600001'}, } : {'method': 'native'}; }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( channel, null, ), ); await tester.pumpWidget( ProviderScope( overrides: [ settingsProvider.overrideWith(() => settings), localLibraryProvider.overrideWith(() => library), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: Consumer( builder: (context, ref, _) => TextButton( onPressed: () { action = reEnrichLocalTracks( context, ref, [item('1'), item('2')], isActive: () => active, onSelectionHide: () async { events.add('hide'); }, onSelectionRestore: () => events.add('restore'), onComplete: () => events.add('complete'), ); }, child: const Text('Start'), ), ), ), ), ), ); await tester.tap(find.text('Start')); await tester.pumpAndSettle(); await tester.tap(find.text('Review changes')); await tester.pumpAndSettle(); expect(settings.phases.length, 1); await tester.tap(find.text('Apply changes')); await tester.pumpAndSettle(); expect(library.refreshStarted, isTrue); expect(settings.phases.length, 2); active = !leaveDuringRefresh; library.refreshed.complete(); await tester.pumpAndSettle(); await action; expect(events, leaveDuringRefresh ? ['hide'] : ['hide', 'complete']); expect(tester.takeException(), isNull); }, ); } for (final overlaySelection in [false, true]) { testWidgets( 'cancel restores ${overlaySelection ? 'removed overlay' : 'animated bar'} selection', (tester) async { final navigatorKey = GlobalKey(); final events = []; OverlayEntry? overlay; Future? action; Widget button(BuildContext context, WidgetRef ref) => TextButton( onPressed: () { action = reEnrichLocalTracks( context, ref, [item('1')], isActive: () => true, onSelectionHide: () async { events.add('hide'); if (overlaySelection) { overlay!.remove(); } else { await Future.delayed(const Duration(milliseconds: 300)); } }, onSelectionRestore: () => events.add('restore'), onComplete: () => events.add('complete'), ); }, child: const Text('Start'), ); await tester.pumpWidget( ProviderScope( child: MaterialApp( navigatorKey: navigatorKey, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: Consumer( builder: (context, ref, _) { if (!overlaySelection) return button(context, ref); return TextButton( onPressed: () { overlay = OverlayEntry( builder: (context) => Material( child: Center(child: button(context, ref)), ), ); Overlay.of(context).insert(overlay!); }, child: const Text('Select'), ); }, ), ), ), ), ); if (overlaySelection) { await tester.tap(find.text('Select')); await tester.pump(); } await tester.tap(find.text('Start')); await tester.pumpAndSettle(); expect(events, ['hide']); expect(navigatorKey.currentState!.canPop(), isTrue); navigatorKey.currentState!.pop(); await tester.pumpAndSettle(); await action; expect(events, ['hide', 'restore']); expect(tester.takeException(), isNull); overlay?.dispose(); }, ); } }