diff --git a/lib/providers/weekly_releases_provider.dart b/lib/providers/weekly_releases_provider.dart index 9a625ff2..558029ae 100644 --- a/lib/providers/weekly_releases_provider.dart +++ b/lib/providers/weekly_releases_provider.dart @@ -9,8 +9,16 @@ import 'package:spotiflac_android/utils/provider_resource_ids.dart'; final weeklyReleasesProvider = FutureProvider.autoDispose .family((ref, refresh) async { - final collections = ref.watch(libraryCollectionsProvider); - final extensions = ref.watch(extensionProvider); + final collections = ref.watch( + libraryCollectionsProvider.select( + (s) => (isLoaded: s.isLoaded, favoriteArtists: s.favoriteArtists), + ), + ); + final extensions = ref.watch( + extensionProvider.select( + (s) => (isInitialized: s.isInitialized, extensions: s.extensions), + ), + ); final preferred = ref.watch( settingsProvider.select((s) => s.searchProvider), ); diff --git a/test/weekly_releases_provider_test.dart b/test/weekly_releases_provider_test.dart new file mode 100644 index 00000000..5ea35464 --- /dev/null +++ b/test/weekly_releases_provider_test.dart @@ -0,0 +1,342 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/weekly_releases_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class _Collections extends LibraryCollectionsNotifier { + _Collections(this.initial); + final LibraryCollectionsState initial; + + @override + LibraryCollectionsState build() => initial; + + void publish( + LibraryCollectionsState Function(LibraryCollectionsState) update, + ) => state = update(state); +} + +class _Extensions extends ExtensionNotifier { + _Extensions(this.initial); + final ExtensionState initial; + + @override + ExtensionState build() => initial; + + void publish(ExtensionState Function(ExtensionState) update) => + state = update(state); +} + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(searchProvider: 'provider-a'); + + void publish(AppSettings Function(AppSettings) update) => + state = update(state); +} + +CollectionArtistEntry _artist(String id, {String? providerId = 'provider-a'}) => + CollectionArtistEntry( + key: '${providerId ?? 'legacy'}:$id', + artistId: id, + providerId: providerId, + name: id, + addedAt: DateTime(2026), + ); + +Extension _extension(String id, {bool enabled = true}) => Extension( + id: id, + name: id, + displayName: 'Example provider', + version: '1.0.0', + description: '', + enabled: enabled, + status: 'loaded', + hasMetadataProvider: true, + searchBehavior: const SearchBehavior(enabled: true), +); + +({ + ProviderContainer container, + _Collections collections, + _Extensions extensions, + _Settings settings, +}) +_scope({bool loaded = true, bool initialized = true}) { + final collections = _Collections( + LibraryCollectionsState( + isLoaded: loaded, + favoriteArtists: [_artist('first')], + ), + ); + final extensions = _Extensions( + ExtensionState( + isInitialized: initialized, + extensions: [_extension('provider-a'), _extension('provider-b')], + ), + ); + final settings = _Settings(); + final container = ProviderContainer( + overrides: [ + libraryCollectionsProvider.overrideWith(() => collections), + extensionProvider.overrideWith(() => extensions), + settingsProvider.overrideWith(() => settings), + ], + ); + addTearDown(container.dispose); + return ( + container: container, + collections: collections, + extensions: extensions, + settings: settings, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + var metadataCalls = 0; + Completer? started; + Completer? response; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + metadataCalls = 0; + started = null; + response = null; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'clearTrackCache') return null; + final arguments = call.arguments as Map; + if (call.method == 'customSearchWithExtension') { + return [ + {'id': arguments['query'], 'name': arguments['query']}, + ]; + } + expect(call.method, 'getProviderMetadata'); + metadataCalls++; + if (started?.isCompleted == false) started!.complete(); + await response?.future; + return { + 'albums': [ + { + 'id': arguments['resource_id'], + 'name': 'Example release', + 'release_date': '2026-10-02', + }, + ], + }; + }); + await PlatformBridge.clearTrackCache(); + }); + + tearDown(() async { + await PlatformBridge.clearTrackCache(); + messenger.setMockMethodCallHandler(channel, null); + }); + + for (final refresh in [false, true]) { + test( + 'unrelated publications keep the same feed (refresh=$refresh)', + () async { + final scope = _scope(); + final container = scope.container; + final provider = weeklyReleasesProvider(refresh); + started = Completer(); + response = Completer(); + addTearDown(() { + if (!response!.isCompleted) response!.complete(); + }); + final subscription = container.listen(provider, (_, _) {}); + addTearDown(subscription.close); + final future = container.read(provider.future); + await started!.future.timeout(const Duration(seconds: 5)); + final favorites = container + .read(libraryCollectionsProvider) + .favoriteArtists; + final extensions = container.read(extensionProvider).extensions; + + Future publishUnrelated() async { + for (final publish in [ + () => scope.collections.publish( + (s) => s.copyWith( + playlists: [ + UserPlaylistCollection( + id: 'example-playlist', + name: 'Example playlist', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + tracks: const [], + ), + ], + ), + ), + () => scope.collections.publish( + (s) => s.copyWith( + loved: [ + CollectionTrackEntry( + key: 'example-track', + track: const Track( + id: 'example-track', + name: 'Example track', + artistName: 'Example artist', + albumName: 'Example album', + duration: 180, + ), + addedAt: DateTime(2026), + ), + ], + ), + ), + () => scope.extensions.publish( + (s) => s.copyWith( + healthStatuses: { + 'provider-a': ExtensionHealthStatus( + extensionId: 'provider-a', + status: 'healthy', + checkedAt: DateTime(2026), + ), + }, + ), + ), + () => scope.extensions.publish((s) => s.copyWith(isLoading: true)), + () => scope.extensions.publish( + (s) => s.copyWith(error: 'Example error'), + ), + () => scope.extensions.publish((s) => s.copyWith(isLoading: false)), + () => scope.settings.publish( + (s) => s.copyWith(downloadDirectory: '/example'), + ), + ]) { + publish(); + await container.pump(); + expect( + container.read(libraryCollectionsProvider).favoriteArtists, + same(favorites), + ); + expect( + container.read(extensionProvider).extensions, + same(extensions), + ); + expect(container.read(provider.future), same(future)); + } + } + + await publishUnrelated(); + response!.complete(); + final feed = await future; + expect(feed.releases.single.id, 'first'); + await publishUnrelated(); + // Result identity catches recomputation even when either cache hides calls. + expect(await container.read(provider.future), same(feed)); + expect(metadataCalls, 1); + }, + ); + } + + for (final collectionsFirst in [false, true]) { + test( + 'waits for both readiness flags (collections first=$collectionsFirst)', + () async { + final scope = _scope(loaded: false, initialized: false); + final container = scope.container; + final provider = weeklyReleasesProvider(false); + final subscription = container.listen(provider, (_, _) {}); + addTearDown(subscription.close); + expect((await container.read(provider.future)).releases, isEmpty); + void loadCollections() => + scope.collections.publish((s) => s.copyWith(isLoaded: true)); + void initializeExtensions() => + scope.extensions.publish((s) => s.copyWith(isInitialized: true)); + (collectionsFirst ? loadCollections : initializeExtensions)(); + expect((await container.read(provider.future)).releases, isEmpty); + expect(metadataCalls, 0); + (collectionsFirst ? initializeExtensions : loadCollections)(); + expect( + (await container.read(provider.future)).releases.single.id, + 'first', + ); + expect(metadataCalls, 1); + }, + ); + } + + test('favorites reload and auto-disposal retains the daily cache', () async { + final scope = _scope(); + final container = scope.container; + final provider = weeklyReleasesProvider(false); + final subscription = container.listen(provider, (_, _) {}); + await container.read(provider.future); + scope.collections.publish( + (s) => s.copyWith(favoriteArtists: [_artist('second')]), + ); + final future = container.read(provider.future); + expect((await future).releases.single.id, 'second'); + expect(metadataCalls, 2); + subscription.close(); + await container.pump(); + expect(container.exists(provider), isFalse); + // Remove bridge caching so only the weekly catalog cache can satisfy this. + await PlatformBridge.clearTrackCache(); + final restored = container.listen(provider, (_, _) {}); + addTearDown(restored.close); + expect(container.read(provider.future), isNot(same(future))); + expect( + (await container.read(provider.future)).releases.single.id, + 'second', + ); + expect(metadataCalls, 2); + scope.collections.publish((s) => s.copyWith(favoriteArtists: [])); + expect((await container.read(provider.future)).releases, isEmpty); + }); + + test( + 'extension list and search preference reload legacy favorites', + () async { + final scope = _scope(); + final container = scope.container; + container.read(libraryCollectionsProvider); + scope.collections.publish( + (s) => + s.copyWith(favoriteArtists: [_artist('legacy', providerId: null)]), + ); + final provider = weeklyReleasesProvider(true); + final subscription = container.listen(provider, (_, _) {}); + addTearDown(subscription.close); + expect( + (await container.read(provider.future)).releases.single.providerId, + 'provider-a', + ); + scope.settings.publish((s) => s.copyWith(searchProvider: 'provider-b')); + expect( + (await container.read(provider.future)).releases.single.providerId, + 'provider-b', + ); + scope.extensions.publish( + (s) => s.copyWith( + extensions: [ + _extension('provider-a'), + _extension('provider-b', enabled: false), + ], + ), + ); + expect( + (await container.read(provider.future)).releases.single.providerId, + 'provider-a', + ); + scope.extensions.publish((s) => s.copyWith(extensions: [])); + final unavailable = await container.read(provider.future); + expect(unavailable.unavailableArtists, ['legacy']); + expect(unavailable.releases.single.providerId, 'provider-a'); + }, + ); +}