import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_state.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/screens/home_search_logic.dart'; import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; import 'package:spotiflac_android/services/ffmpeg_models.dart'; import 'package:spotiflac_android/services/id3v23_lyrics.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; void main() { Track track({ required String id, required String name, String artist = 'Artist', int duration = 180, String? releaseDate, String? itemType, }) { return Track( id: id, name: name, artistName: artist, albumName: 'Album', duration: duration, releaseDate: releaseDate, itemType: itemType, ); } group('home search logic', () { test('separates playable tracks from collection result types', () { final results = [ track(id: 'track', name: 'Track'), track(id: 'album', name: 'Album', itemType: 'album'), track(id: 'playlist', name: 'Playlist', itemType: 'playlist'), track(id: 'artist', name: 'Artist', itemType: 'artist'), ]; final buckets = bucketHomeSearchResults(results); expect(buckets.realTracks.map((item) => item.id), ['track']); expect(buckets.realTrackIndexes, [0]); expect(buckets.albumItems.single.id, 'album'); expect(buckets.playlistItems.single.id, 'playlist'); expect(buckets.artistItems.single.id, 'artist'); }); test('sorts a copy and preserves default result identity', () { final results = [ track( id: 'later', name: 'Zulu', artist: 'Beta', duration: 220, releaseDate: '2026-02-01', ), track( id: 'earlier', name: 'Alpha', artist: 'Alpha', duration: 120, releaseDate: '2025-01-01', ), ]; final unchanged = sortHomeSearchItems( items: results, option: HomeSearchSortOption.defaultOrder, nameOf: (item) => item.name, artistOf: (item) => item.artistName, durationOf: (item) => item.duration, dateOf: (item) => item.releaseDate, ); final sorted = sortHomeSearchItems( items: results, option: HomeSearchSortOption.titleAsc, nameOf: (item) => item.name, artistOf: (item) => item.artistName, durationOf: (item) => item.duration, dateOf: (item) => item.releaseDate, ); expect(identical(unchanged, results), isTrue); expect(identical(sorted, results), isFalse); expect(sorted.map((item) => item.id), ['earlier', 'later']); expect(results.first.id, 'later'); }); test('recognizes URLs and Spotify URIs without treating text as a URL', () { expect(looksLikeUrlOrSpotifyUri('https://example.test/track'), isTrue); expect(looksLikeUrlOrSpotifyUri('spotify:track:123'), isTrue); expect(looksLikeUrlOrSpotifyUri('artist track title'), isFalse); }); test('uses the primary enabled extension as the provider fallback', () { final secondary = _searchExtension(id: 'secondary'); final primary = _searchExtension(id: 'primary', primary: true); final disabled = _searchExtension(id: 'disabled', enabled: false); final extensions = [secondary, primary, disabled]; expect( HomeSearchProviderPolicy.resolveProvider(null, extensions), 'primary', ); expect( HomeSearchProviderPolicy.resolveProvider('secondary', extensions), 'secondary', ); expect( HomeSearchProviderPolicy.resolveProvider('disabled', extensions), 'primary', ); expect( HomeSearchProviderPolicy.hasProvider('missing', extensions), isTrue, ); }); test('keeps Search visible across transient provider reconciliation', () { expect( HomeSearchProviderPolicy.shouldShowSearchBar( hasSearchProvider: false, isSearchProviderLoading: false, hasHomeFeedExtension: true, hasExploreContent: true, hasSearchInput: false, ), isTrue, ); expect( HomeSearchProviderPolicy.shouldShowSearchBar( hasSearchProvider: false, isSearchProviderLoading: false, hasHomeFeedExtension: false, hasExploreContent: false, hasSearchInput: true, ), isTrue, ); expect( HomeSearchProviderPolicy.shouldShowSearchBar( hasSearchProvider: false, isSearchProviderLoading: false, hasHomeFeedExtension: false, hasExploreContent: false, hasSearchInput: false, ), isFalse, ); }); test('canonicalizes built-in and extension search filters', () { final extension = _searchExtension( id: 'provider', filters: const [ SearchFilter(id: 'songs', label: 'Tracks', icon: 'music'), SearchFilter(id: 'records', label: 'Albums', icon: 'album'), ], ); expect( HomeSearchProviderPolicy.sanitizeFilter('Music', null, const []), 'track', ); expect( HomeSearchProviderPolicy.sanitizeFilter('track', 'provider', [ extension, ]), 'songs', ); expect( HomeSearchProviderPolicy.displayFilterSelection( null, 'album', 'provider', [extension], ), 'records', ); expect( HomeSearchProviderPolicy.sanitizeFilter('podcast', 'provider', [ extension, ]), isNull, ); }); }); group('FFmpeg value models', () { test('normalizes structured and legacy decryption descriptors', () { final structured = DownloadDecryptionDescriptor.fromDownloadResult({ 'output_extension': 'M4A', 'decryption': { 'strategy': 'ffmpeg_mov_key', 'key': ' secret ', 'options': {'repair_ac4': true}, }, }); final legacy = DownloadDecryptionDescriptor.fromDownloadResult({ 'decryption_key': 'legacy', 'output_extension': '.flac', }); expect(structured, isNotNull); expect(structured!.normalizedStrategy, 'ffmpeg.mov_key'); expect(structured.normalizedOutputExtension, '.m4a'); expect(structured.key, 'secret'); expect(legacy!.inputFormat, 'mov'); expect(legacy.normalizedOutputExtension, '.flac'); }); test('rejects empty or unsupported structured decryption requests', () { expect( DownloadDecryptionDescriptor.fromDownloadResult({ 'decryption': {'strategy': 'custom', 'key': 'secret'}, }), isNull, ); expect( DownloadDecryptionDescriptor.fromDownloadResult({ 'decryption': {'strategy': 'ffmpeg.mov_key', 'key': ' '}, }), isNull, ); }); }); group('audio metadata modules', () { test('maps shared tags consistently for native, M4A, and ID3 writers', () { final metadata = { 'ALBUM_ARTIST': 'Album Artist', 'TRACKNUMBER': '3/12', 'DISCNUMBER': '1/2', 'ISRC': 'TEST12345678', 'UNSYNCEDLYRICS': 'Lyrics', 'REPLAYGAIN_TRACK_GAIN': '-5.00 dB', 'BIT_DEPTH': '24', }; final native = AudioMetadataMapper.vorbisToNativeChunkFields(metadata); final m4a = AudioMetadataMapper.convertToM4aTags(metadata); final id3 = AudioMetadataMapper.convertToId3Tags(metadata); expect(native['album_artist'], 'Album Artist'); expect(native['track_number'], '3'); expect(native['track_total'], '12'); expect(native['disc_number'], '1'); expect(native['disc_total'], '2'); expect(native['replaygain_track_gain'], '-5.00 dB'); expect(m4a['isrc'], 'TEST12345678'); expect(m4a['lyrics'], 'Lyrics'); expect(id3['TSRC'], 'TEST12345678'); expect(id3['REPLAYGAIN_TRACK_GAIN'], '-5.00 dB'); expect(native, isNot(contains('bit_depth'))); }); test('builds split Vorbis artist entries without losing other tags', () { final entries = AudioMetadataMapper.buildVorbisMetadataEntries({ 'ARTIST': 'First & Second', 'TITLE': 'Track', }, artistTagMode: artistTagModeSplitVorbis); expect( entries.where((entry) => entry.key == 'ARTIST').map((e) => e.value), ['First', 'Second'], ); expect( entries.where((entry) => entry.key == 'TITLE').single.value, 'Track', ); }); test('writes one ID3v2.3 USLT frame and replaces it on update', () { final audio = Uint8List.fromList([0xff, 0xfb, 0x90, 0x64]); final first = Id3v23Lyrics.writeUnsyncedLyrics(audio, 'First lyrics'); final second = Id3v23Lyrics.writeUnsyncedLyrics( first!, 'Replacement lyrics', ); expect(second, isNotNull); expect(second!.sublist(0, 4), [0x49, 0x44, 0x33, 0x03]); expect(_sequenceCount(second, ascii.encode('USLT')), 1); expect(second.sublist(second.length - audio.length), audio); }); test('leaves unsupported ID3 versions unchanged', () { final id3v24 = Uint8List.fromList([ 0x49, 0x44, 0x33, 0x04, 0, 0, 0, 0, 0, 0, ]); expect(Id3v23Lyrics.writeUnsyncedLyrics(id3v24, 'Lyrics'), isNull); }); }); test('queue state owns lookup rebuilding independently of notifier', () { final queued = DownloadItem( id: 'one', track: track(id: 'track-one', name: 'One'), service: 'extension.test', createdAt: DateTime.utc(2026), ); final completed = queued.copyWith(status: DownloadStatus.completed); final initial = const DownloadQueueState().copyWith(items: [queued]); final next = initial.copyWith( items: [completed], currentDownload: completed, ); final cleared = next.copyWith(currentDownload: null); expect(initial.queuedCount, 1); expect(next.completedCount, 1); expect(next.lookup.byItemId['one']?.status, DownloadStatus.completed); expect(next.currentDownload, completed); expect(cleared.currentDownload, isNull); }); } int _sequenceCount(Uint8List bytes, List sequence) { var count = 0; for (var index = 0; index <= bytes.length - sequence.length; index++) { var matches = true; for (var offset = 0; offset < sequence.length; offset++) { if (bytes[index + offset] != sequence[offset]) { matches = false; break; } } if (matches) count++; } return count; } Extension _searchExtension({ required String id, bool enabled = true, bool primary = false, List filters = const [], }) { return Extension( id: id, name: id, displayName: id, version: '1.0.0', description: 'test', enabled: enabled, status: 'loaded', searchBehavior: SearchBehavior( enabled: true, primary: primary, filters: filters, ), ); }