Files
SpotiFLAC-Mobile/test/maintainability_contracts_test.dart
T

338 lines
10 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('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(structured.options['repair_ac4'], isTrue);
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,
),
);
}