mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 21:28:20 +02:00
1b8d6ce7fa
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.
681 lines
22 KiB
Dart
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,
|
|
);
|
|
});
|
|
});
|
|
}
|