mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
372 lines
11 KiB
Dart
372 lines
11 KiB
Dart
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(extension.searchBehavior?.filterIdForKind('track'), 'songs');
|
|
expect(extension.searchBehavior?.filterIdForKind('album'), 'records');
|
|
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<int> 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<SearchFilter> 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,
|
|
),
|
|
);
|
|
}
|