Files
SpotiFLAC-Mobile/test/models_and_utils_test.dart
zarzet 1b8d6ce7fa feat: enhanced audio analysis with loudness, clipping, and spectral cutoff
Audio Analysis Enhancements:
- Display codec name and container format
- Show decoded sample format (s16, s32, fltp, etc.)
- Add LUFS integrated loudness measurement (broadcast standard)
- Add true peak measurement (dBTP)
- Detect and count clipping samples per channel
- Estimate spectral cutoff frequency (helps detect fake upscales)
- Show per-channel statistics (Peak, RMS, DR, Clip count)

UI Improvements:
- MetricChip now handles long text with ellipsis
- Constrained max width for better layout

Cache version bumped to 4 to force rescan with new metrics.
2026-05-14 16:28:49 +07:00

681 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
void main() {
group('Track', () {
test('exposes collection, source, and quality flags', () {
const album = Track(
id: 'album-1',
name: 'Album',
artistName: 'Artist',
albumName: 'Album',
duration: 0,
itemType: 'album',
source: 'extension.example',
audioQuality: 'FLAC 1411kbps',
audioModes: 'STEREO,DOLBY_ATMOS',
);
expect(album.isAlbumItem, isTrue);
expect(album.isPlaylistItem, isFalse);
expect(album.isArtistItem, isFalse);
expect(album.isCollection, isTrue);
expect(album.isFromExtension, isTrue);
expect(album.hasAudioQuality, isTrue);
expect(album.isDolbyAtmos, isTrue);
});
test('detects singles and eps case-insensitively', () {
const single = Track(
id: 'track-1',
name: 'Song',
artistName: 'Artist',
albumName: 'Single',
duration: 210000,
albumType: 'SINGLE',
);
const ep = Track(
id: 'track-2',
name: 'Song 2',
artistName: 'Artist',
albumName: 'EP',
duration: 180000,
albumType: 'ep',
);
const album = Track(
id: 'track-3',
name: 'Song 3',
artistName: 'Artist',
albumName: 'Album',
duration: 240000,
albumType: 'album',
);
expect(single.isSingle, isTrue);
expect(ep.isSingle, isTrue);
expect(album.isSingle, isFalse);
});
test('round-trips json with service availability', () {
final track = Track.fromJson({
'id': 'spotify:track:1',
'name': 'Song',
'artistName': 'Artist',
'albumName': 'Album',
'duration': 123456,
'availability': {'tidal': true, 'deezer': true, 'deezerId': '31337'},
});
expect(track.availability?.tidal, isTrue);
expect(track.availability?.qobuz, isFalse);
expect(track.availability?.deezerId, '31337');
expect(track.toJson()['id'], 'spotify:track:1');
expect(track.availability!.toJson()['deezer'], isTrue);
});
});
group('DownloadItem', () {
Track sampleTrack() => const Track(
id: 'track-1',
name: 'Song',
artistName: 'Artist',
albumName: 'Album',
duration: 1000,
);
test('uses defaults and preserves fields through copyWith', () {
final createdAt = DateTime.utc(2026, 5, 4, 10);
final item = DownloadItem(
id: 'download-1',
track: sampleTrack(),
service: 'tidal',
createdAt: createdAt,
);
final updated = item.copyWith(
status: DownloadStatus.downloading,
progress: 0.5,
speedMBps: 1.25,
bytesReceived: 512,
bytesTotal: 1024,
qualityOverride: 'HI_RES',
playlistName: 'Favorites',
);
expect(item.status, DownloadStatus.queued);
expect(item.progress, 0);
expect(updated.id, item.id);
expect(updated.track, item.track);
expect(updated.status, DownloadStatus.downloading);
expect(updated.progress, 0.5);
expect(updated.speedMBps, 1.25);
expect(updated.bytesReceived, 512);
expect(updated.bytesTotal, 1024);
expect(updated.qualityOverride, 'HI_RES');
expect(updated.playlistName, 'Favorites');
});
test('maps typed errors to user-facing messages', () {
final base = DownloadItem(
id: 'download-1',
track: sampleTrack(),
service: 'qobuz',
createdAt: DateTime.utc(2026),
error: 'raw backend failure',
);
expect(base.errorMessage, 'raw backend failure');
expect(
base.copyWith(errorType: DownloadErrorType.notFound).errorMessage,
'Song not found on any service',
);
expect(
base.copyWith(errorType: DownloadErrorType.rateLimit).errorMessage,
'Rate limit reached, try again later',
);
expect(
base.copyWith(errorType: DownloadErrorType.network).errorMessage,
'Connection failed, check your internet',
);
expect(
base.copyWith(errorType: DownloadErrorType.permission).errorMessage,
'Cannot write to folder, check storage permission',
);
expect(base.copyWith(error: null).errorMessage, 'raw backend failure');
});
test('decodes json defaults and enums', () {
final item = DownloadItem.fromJson({
'id': 'download-1',
'track': {
'id': 'track-1',
'name': 'Song',
'artistName': 'Artist',
'albumName': 'Album',
'duration': 1000,
},
'service': 'deezer',
'status': 'failed',
'errorType': 'network',
'createdAt': '2026-05-04T10:00:00.000Z',
});
expect(item.status, DownloadStatus.failed);
expect(item.errorType, DownloadErrorType.network);
expect(item.progress, 0);
expect(item.bytesReceived, 0);
expect(item.toJson()['status'], 'failed');
expect(item.toJson()['errorType'], 'network');
});
});
group('AppSettings', () {
test('provides stable defaults', () {
const settings = AppSettings();
expect(settings.audioQuality, 'LOSSLESS');
expect(settings.filenameFormat, '{title} - {artist}');
expect(settings.artistTagMode, artistTagModeJoined);
expect(settings.autoFallback, isTrue);
expect(settings.lyricsProviders, ['lrclib', 'apple_music']);
expect(settings.lyricsAppleElrcWordSync, isFalse);
expect(settings.deduplicateDownloads, isTrue);
});
test('copyWith updates values and can clear nullable provider fields', () {
const settings = AppSettings(
downloadFallbackExtensionIds: ['fallback.ext'],
searchProvider: 'search.ext',
homeFeedProvider: 'feed.ext',
);
final updated = settings.copyWith(
defaultService: 'tidal',
concurrentDownloads: 4,
embedReplayGain: true,
lyricsProviders: ['apple_music'],
lyricsAppleElrcWordSync: true,
deduplicateDownloads: false,
clearDownloadFallbackExtensionIds: true,
clearSearchProvider: true,
clearHomeFeedProvider: true,
);
expect(updated.defaultService, 'tidal');
expect(updated.concurrentDownloads, 4);
expect(updated.embedReplayGain, isTrue);
expect(updated.lyricsProviders, ['apple_music']);
expect(updated.lyricsAppleElrcWordSync, isTrue);
expect(updated.deduplicateDownloads, isFalse);
expect(updated.downloadFallbackExtensionIds, isNull);
expect(updated.searchProvider, isNull);
expect(updated.homeFeedProvider, isNull);
expect(updated.audioQuality, settings.audioQuality);
});
test('round-trips json including recently added settings', () {
const settings = AppSettings(
defaultService: 'qobuz',
storageMode: 'saf',
downloadTreeUri: 'content://tree/music',
downloadFallbackExtensionIds: ['ext.a', 'ext.b'],
searchProvider: 'search.ext',
homeFeedProvider: AppSettings.homeFeedProviderOff,
useAllFilesAccess: true,
networkCompatibilityMode: true,
songLinkRegion: 'ID',
localLibraryEnabled: true,
localLibraryPath: '/music',
hasCompletedTutorial: true,
musixmatchLanguage: 'id',
lyricsAppleElrcWordSync: true,
lastSeenVersion: '4.5.0',
deduplicateDownloads: false,
nativeDownloadWorkerEnabled: true,
);
final decoded = AppSettings.fromJson(settings.toJson());
expect(decoded.defaultService, 'qobuz');
expect(decoded.storageMode, 'saf');
expect(decoded.downloadTreeUri, 'content://tree/music');
expect(decoded.downloadFallbackExtensionIds, ['ext.a', 'ext.b']);
expect(decoded.searchProvider, 'search.ext');
expect(decoded.homeFeedProvider, AppSettings.homeFeedProviderOff);
expect(decoded.useAllFilesAccess, isTrue);
expect(decoded.networkCompatibilityMode, isTrue);
expect(decoded.songLinkRegion, 'ID');
expect(decoded.localLibraryEnabled, isTrue);
expect(decoded.localLibraryPath, '/music');
expect(decoded.hasCompletedTutorial, isTrue);
expect(decoded.musixmatchLanguage, 'id');
expect(decoded.lyricsAppleElrcWordSync, isTrue);
expect(decoded.lastSeenVersion, '4.5.0');
expect(decoded.deduplicateDownloads, isFalse);
expect(decoded.nativeDownloadWorkerEnabled, isTrue);
});
});
group('ThemeSettings', () {
test('serializes, deserializes, copies, and compares values', () {
const settings = ThemeSettings(
themeMode: ThemeMode.dark,
useDynamicColor: false,
seedColorValue: 0xff123456,
useAmoled: true,
);
final decoded = ThemeSettings.fromJson(settings.toJson());
final copied = decoded.copyWith(themeMode: ThemeMode.light);
expect(decoded, settings);
expect(decoded.hashCode, settings.hashCode);
expect(decoded.seedColor, const Color(0xff123456));
expect(copied.themeMode, ThemeMode.light);
expect(copied.useAmoled, isTrue);
expect(
ThemeSettings.fromJson({'theme_mode': 'invalid'}).themeMode,
ThemeMode.system,
);
});
});
group('DownloadRequestPayload', () {
test('serializes all backend field names', () {
const payload = DownloadRequestPayload(
isrc: 'ISRC123',
service: 'tidal',
spotifyId: 'spotify:track:1',
trackName: 'Song',
artistName: 'Artist',
albumName: 'Album',
albumArtist: 'Album Artist',
coverUrl: 'https://example.test/cover.jpg',
outputDir: '/downloads',
filenameFormat: '{artist} - {title}',
quality: 'HI_RES',
embedMetadata: false,
artistTagMode: artistTagModeSplitVorbis,
embedLyrics: false,
embedMaxQualityCover: false,
embedReplayGain: true,
postProcessingEnabled: true,
tidalHighFormat: 'opus_256',
trackNumber: 7,
discNumber: 2,
totalTracks: 12,
totalDiscs: 2,
releaseDate: '2026-05-04',
itemId: 'item-1',
durationMs: 250000,
source: 'extension.example',
genre: 'Pop',
label: 'Label',
copyright: 'Copyright',
composer: 'Composer',
tidalId: 'tidal-1',
qobuzId: 'qobuz-1',
deezerId: 'deezer-1',
lyricsMode: 'sidecar',
useExtensions: true,
useFallback: true,
storageMode: 'saf',
safTreeUri: 'content://tree/music',
safRelativeDir: 'Album',
safFileName: 'Song.flac',
safOutputExt: 'flac',
outputExt: '.flac',
songLinkRegion: 'ID',
);
expect(payload.toJson(), {
'contract_version': DownloadRequestPayload.nativeWorkerContractVersion,
'isrc': 'ISRC123',
'service': 'tidal',
'spotify_id': 'spotify:track:1',
'track_name': 'Song',
'artist_name': 'Artist',
'album_name': 'Album',
'album_artist': 'Album Artist',
'cover_url': 'https://example.test/cover.jpg',
'output_dir': '/downloads',
'filename_format': '{artist} - {title}',
'quality': 'HI_RES',
'embed_metadata': false,
'artist_tag_mode': artistTagModeSplitVorbis,
'embed_lyrics': false,
'embed_max_quality_cover': false,
'embed_replaygain': true,
'post_processing_enabled': true,
'tidal_high_format': 'opus_256',
'track_number': 7,
'disc_number': 2,
'total_tracks': 12,
'total_discs': 2,
'release_date': '2026-05-04',
'item_id': 'item-1',
'duration_ms': 250000,
'source': 'extension.example',
'genre': 'Pop',
'label': 'Label',
'copyright': 'Copyright',
'composer': 'Composer',
'tidal_id': 'tidal-1',
'qobuz_id': 'qobuz-1',
'deezer_id': 'deezer-1',
'lyrics_mode': 'sidecar',
'use_extensions': true,
'use_fallback': true,
'storage_mode': 'saf',
'saf_tree_uri': 'content://tree/music',
'saf_relative_dir': 'Album',
'saf_file_name': 'Song.flac',
'saf_output_ext': 'flac',
'output_ext': '.flac',
'stage_saf_output': false,
'defer_saf_publish': false,
'requires_container_conversion': false,
'songlink_region': 'ID',
});
});
test('withStrategy only changes requested strategy flags', () {
const payload = DownloadRequestPayload(
trackName: 'Song',
artistName: 'Artist',
albumName: 'Album',
outputDir: '/downloads',
filenameFormat: '{title}',
useExtensions: false,
useFallback: true,
);
final updated = payload.withStrategy(useExtensions: true);
expect(updated.useExtensions, isTrue);
expect(updated.useFallback, isTrue);
expect(updated.trackName, payload.trackName);
expect(updated.filenameFormat, payload.filenameFormat);
});
});
group('artist utils', () {
test('splits common artist separators and removes duplicates for tags', () {
expect(splitArtistNames(' A, B & C feat. D x E with F '), [
'A',
'B',
'C',
'D',
'E',
'F',
]);
expect(splitArtistTagValues('A, a & B'), ['A', 'B']);
expect(splitArtistTagValues(' '), isEmpty);
expect(shouldSplitVorbisArtistTags(artistTagModeSplitVorbis), isTrue);
expect(shouldSplitVorbisArtistTags(artistTagModeJoined), isFalse);
});
});
group('audio conversion utils', () {
test(
'detects Dolby formats from stored scan format before file extension',
() {
expect(
convertibleAudioSourceFormat(
storedFormat: 'eac3',
filePath: 'content://media/song.m4a',
),
'EAC3',
);
expect(convertibleAudioSourceFormat(fileName: 'Song.ac-3'), 'AC3');
expect(convertibleAudioSourceFormat(storedFormat: 'ac4'), 'AC4');
},
);
test('allows Dolby sources only for lossy batch conversion targets', () {
expect(
canConvertAudioFormat(sourceFormat: 'EAC3', targetFormat: 'MP3'),
isTrue,
);
expect(
canConvertAudioFormat(sourceFormat: 'EAC3', targetFormat: 'Opus'),
isTrue,
);
expect(
canConvertAudioFormat(sourceFormat: 'EAC3', targetFormat: 'AAC'),
isTrue,
);
expect(
canConvertAudioFormat(sourceFormat: 'EAC3', targetFormat: 'FLAC'),
isFalse,
);
expect(
canConvertAudioFormat(sourceFormat: 'EAC3', targetFormat: 'ALAC'),
isFalse,
);
});
});
group('string utils', () {
test('normalizes optional strings and cover references', () {
expect(normalizeOptionalString(null), isNull);
expect(normalizeOptionalString(' null '), isNull);
expect(normalizeOptionalString(' value '), 'value');
expect(
normalizeCoverReference('//cdn.example.test/a.jpg'),
'https://cdn.example.test/a.jpg',
);
expect(
normalizeCoverReference('https://example.test/a.jpg'),
'https://example.test/a.jpg',
);
expect(
normalizeCoverReference('/storage/music/a.jpg'),
'/storage/music/a.jpg',
);
expect(normalizeCoverReference('relative/a.jpg'), isNull);
expect(normalizeRemoteHttpUrl('file:///tmp/a.jpg'), isNull);
expect(
normalizeRemoteHttpUrl('http://example.test/a.jpg'),
'http://example.test/a.jpg',
);
});
test('formats display audio quality from strongest available source', () {
expect(
buildDisplayAudioQuality(
bitrateKbps: 320,
format: 'mp3',
bitDepth: 24,
sampleRate: 96000,
storedQuality: 'LOSSLESS',
),
'MP3 320kbps',
);
expect(
buildDisplayAudioQuality(bitDepth: 24, sampleRate: 96000),
'24-bit/96kHz',
);
expect(formatSampleRateKHz(44100), '44.1kHz');
expect(buildDisplayAudioQuality(storedQuality: ' Hi-Res '), 'Hi-Res');
expect(isPlaceholderQualityLabel('lossless'), isTrue);
expect(isPlaceholderQualityLabel('FLAC 1411kbps'), isFalse);
});
});
group('mime utils', () {
test('maps known audio extensions and falls back to wildcard', () {
expect(audioMimeTypeForPath('/music/song.FLAC'), 'audio/flac');
expect(audioMimeTypeForPath('/music/song.m4a'), 'audio/mp4');
expect(audioMimeTypeForPath('/music/song.mp3'), 'audio/mpeg');
expect(audioMimeTypeForPath('/music/song.ogg'), 'audio/ogg');
expect(audioMimeTypeForPath('/music/song.wav'), 'audio/wav');
expect(audioMimeTypeForPath('/music/song.aac'), 'audio/aac');
expect(audioMimeTypeForPath('/music/song'), 'audio/*');
expect(audioMimeTypeForPath('/music/song.'), 'audio/*');
expect(audioMimeTypeForPath('/music/song.txt'), 'audio/*');
});
});
group('path match keys', () {
test('builds normalized variants for local paths and file uris', () {
final keys = buildPathMatchKeys('EXISTS: /Music/A%20Song.FLAC ');
expect(keys, contains('/Music/A%20Song.FLAC'));
expect(keys, contains('/music/a%20song.flac'));
expect(keys, contains('/Music/A Song.FLAC'));
expect(keys, contains('/music/a song.flac'));
expect(keys, contains('file:///Music/A%2520Song.FLAC'));
expect(keys, contains('/Music/A%20Song'));
expect(
identical(buildPathMatchKeys('/Music/A%20Song.FLAC'), keys),
isTrue,
);
expect(buildPathMatchKeys(' '), isEmpty);
});
test('normalizes windows-style separators', () {
final keys = buildPathMatchKeys(r'C:\Music\Song.mp3');
expect(keys, contains(r'C:\Music\Song.mp3'));
expect(keys, contains('C:/Music/Song.mp3'));
expect(keys, contains('c:/music/song.mp3'));
expect(keys, contains('C:/Music/Song'));
});
});
group('AppRemoteConfig', () {
test('parses announcement and donate payloads from API JSON', () {
final config = AppRemoteConfig.fromJson({
'announcement': {
'id': 'hello-2026',
'enabled': true,
'title': 'Server message',
'message': 'A clear message for users',
'cta_enabled': true,
'cta_label': 'Donate',
'cta_url': 'https://example.test/donate',
'starts_at': '2026-05-01T00:00:00Z',
'ends_at': '2026-06-01T00:00:00Z',
'min_version': '4.5.0',
'priority': 'high',
},
'donate': {
'enabled': true,
'title': 'Support SpotiFLAC Mobile',
'message': 'Help cover infrastructure.',
'methods': [
{
'id': 'kofi',
'title': 'Ko-fi',
'subtitle': 'ko-fi.com/example',
'url': 'https://ko-fi.com/example',
'icon': 'kofi',
'color': '#FF5E5B',
},
{
'id': 'wallet',
'title': 'USDT',
'subtitle': 'TRC20',
'wallet_address': 'T123',
'icon': 'wallet',
'color': '0xFF26A17B',
},
],
'supporters': ['Alice', 'Bob'],
'notices': ['No paywalls'],
},
});
expect(config.announcement?.id, 'hello-2026');
expect(config.announcement?.hasCta, isTrue);
expect(
config.announcement?.isActive(
now: DateTime.utc(2026, 5, 11),
currentVersion: '4.5.1',
),
isTrue,
);
expect(config.donate.title, 'Support SpotiFLAC Mobile');
expect(config.donate.methods, hasLength(2));
expect(config.donate.methods.first.color, 0xFFFF5E5B);
expect(config.donate.methods.last.isWallet, isTrue);
expect(config.donate.supporters, ['Alice', 'Bob']);
expect(config.donate.notices, ['No paywalls']);
});
test('requires enabled announcement CTA with label and url', () {
final disabledCta = RemoteAnnouncement.fromJson({
'id': 'notice',
'title': 'Notice',
'message': 'No button',
'cta_label': 'Open',
'cta_url': 'https://api.zarz.moe',
});
final missingLabel = RemoteAnnouncement.fromJson({
'id': 'notice',
'title': 'Notice',
'message': 'No button',
'cta_enabled': true,
'cta_url': 'https://example.test',
});
final enabledCta = RemoteAnnouncement.fromJson({
'id': 'notice',
'title': 'Notice',
'message': 'With button',
'cta_enabled': true,
'cta_label': 'Read More',
'cta_url': 'https://example.test',
});
expect(disabledCta.hasCta, isFalse);
expect(missingLabel.hasCta, isFalse);
expect(enabledCta.hasCta, isTrue);
expect(enabledCta.ctaLabel, 'Read More');
});
test('filters inactive announcements by window and app version', () {
final announcement = RemoteAnnouncement.fromJson({
'id': 'future',
'title': 'Future',
'message': 'Not yet',
'starts_at': '2026-06-01T00:00:00Z',
'min_version': '4.6.0',
});
expect(
announcement.isActive(
now: DateTime.utc(2026, 5, 11),
currentVersion: '4.5.1',
),
isFalse,
);
expect(
announcement.isActive(
now: DateTime.utc(2026, 6, 2),
currentVersion: '4.5.1',
),
isFalse,
);
expect(
announcement.isActive(
now: DateTime.utc(2026, 6, 2),
currentVersion: '4.6.0',
),
isTrue,
);
});
});
}