import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; 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/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/explore_featured_section.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('home feed cache ownership', () { const channel = MethodChannel('com.zarz.spotiflac/backend'); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; tearDown(() => messenger.setMockMethodCallHandler(channel, null)); Future< ({ ProviderContainer container, ExploreNotifier notifier, Completer> cache, }) > pendingRestore() async { SharedPreferences.setMockInitialValues({ 'explore_home_feed_cache': jsonEncode(_feed('Cached')), 'explore_home_feed_ts': DateTime.now() .subtract(const Duration(hours: 1)) .millisecondsSinceEpoch, }); final cache = Completer>(); final started = Completer(); final container = ProviderContainer( overrides: [ settingsProvider.overrideWith(_FeedSettings.new), extensionProvider.overrideWith(_FeedExtensions.new), exploreProvider.overrideWith( () => ExploreNotifier( decodeCache: (_) { started.complete(); return cache.future; }, ), ), ], ); addTearDown(container.dispose); final notifier = container.read(exploreProvider.notifier); await started.future; return (container: container, notifier: notifier, cache: cache); } for (final disabled in [false, true]) { test('late cache cannot undo ${disabled ? 'Off' : 'Clear'}', () async { final restore = await pendingRestore(); if (disabled) { (restore.container.read(settingsProvider.notifier) as _FeedSettings) .disableHomeFeed(); await restore.notifier.fetchHomeFeed(); } else { restore.notifier.clear(); } restore.cache.complete(_feed('Cached')); await Future.delayed(Duration.zero); final state = restore.container.read(exploreProvider); expect(state.hasContent, isFalse); expect(state.isLoading, isFalse); }); } test('completed refresh wins over late cache', () async { messenger.setMockMethodCallHandler(channel, (call) async { expect(call.method, 'getExtensionHomeFeed'); return jsonEncode({ 'success': true, 'sections': _feed('Fresh')['sections'], }); }); final restore = await pendingRestore(); await restore.notifier.refresh(); expect( restore.container.read(exploreProvider).sections.single.title, 'Fresh', ); restore.cache.complete(_feed('Cached')); await Future.delayed(Duration.zero); expect( restore.container.read(exploreProvider).sections.single.title, 'Fresh', ); }); test( 'stale cache stays visible without duplicating the active refresh', () async { final response = Completer(); final started = Completer(); var calls = 0; messenger.setMockMethodCallHandler(channel, (call) async { expect(call.method, 'getExtensionHomeFeed'); calls++; if (!started.isCompleted) started.complete(); return response.future; }); final restore = await pendingRestore(); final refresh = restore.notifier.refresh(); await started.future; restore.cache.complete(_feed('Cached')); await Future.delayed(Duration.zero); final cached = restore.container.read(exploreProvider); expect(cached.sections.single.title, 'Cached'); expect(cached.isLoading, isFalse); await restore.notifier.fetchHomeFeed(); expect(calls, 1); response.complete( jsonEncode({'success': true, 'sections': _feed('Fresh')['sections']}), ); await refresh; expect( restore.container.read(exploreProvider).sections.single.title, 'Fresh', ); }, ); }); test('featured layout and artwork survive cache serialization', () { final section = ExploreSection.fromJson({ 'uri': 'featured', 'title': 'New', 'layout': 'featured', 'items': [ { 'id': 'collection-1', 'type': 'playlist', 'name': 'Featured playlist', 'cover_url': 'https://example.test/cover.jpg', 'featured_cover_url': 'https://example.test/banner.jpg', 'heading': 'Updated playlist', 'provider_id': 'example-provider', 'explicit': true, }, ], }); final restored = ExploreSection.fromJson(section.toJson()); expect(restored.isFeatured, isTrue); expect(restored.items.single.heading, 'Updated playlist'); expect(restored.items.single.featuredCoverUrl, endsWith('/banner.jpg')); expect(restored.items.single.coverUrl, endsWith('/cover.jpg')); expect(restored.items.single.providerId, 'example-provider'); expect(restored.items.single.explicit, isTrue); expect(ExploreItem.fromJson({'explicit': false}).explicit, isFalse); expect(ExploreItem.fromJson({}).explicit, isNull); expect(ExploreSection.fromJson({'items': []}).isFeatured, isFalse); }); for (final (size, scale) in [ (const Size(390, 844), 1.0), (const Size(320, 844), 2.0), (const Size(1024, 768), 1.0), ]) { testWidgets('featured cards scroll and open at $size, text scale $scale', ( tester, ) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); ExploreItem? opened; final items = List.generate( 4, (index) => ExploreItem( id: 'collection-$index', uri: 'example:collection:$index', type: 'album', name: 'Featured $index', artists: 'Example artist', heading: 'New album', explicit: index == 0, description: 'An editorial description of the featured album.', ), ); await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: MediaQuery( data: MediaQueryData(textScaler: TextScaler.linear(scale)), child: Scaffold( body: SingleChildScrollView( child: ExploreFeaturedSection( section: ExploreSection( uri: 'featured', title: 'New', items: items, isFeatured: true, ), onItemTap: (item, _) => opened = item, ), ), ), ), ), ); expect(tester.takeException(), isNull); expect(find.byType(ExplicitBadge), findsOneWidget); expect( tester .widget(find.byType(ExplicitTrackTitle).first) .explicit, isTrue, ); final firstTitle = find.byWidgetPredicate( (widget) => widget is ExplicitTrackTitle && widget.title == 'Featured 0', ); final card = find.ancestor( of: firstTitle, matching: find.byType(GestureDetector), ); expect(tester.getSize(card).width, greaterThan(250)); await tester.tap(firstTitle); expect(opened?.id, 'collection-0'); await tester.drag(find.byType(ListView), const Offset(-600, 0)); await tester.pumpAndSettle(); expect(find.text('Featured 2'), findsOneWidget); expect(tester.takeException(), isNull); }); } } Map _feed(String title) => { 'provider_id': 'cache-race-provider', 'sections': >[ {'uri': 'example:feed', 'title': title, 'items': >[]}, ], }; class _FeedSettings extends SettingsNotifier { @override AppSettings build() => const AppSettings(homeFeedProvider: 'cache-race-provider'); void disableHomeFeed() { state = state.copyWith(homeFeedProvider: AppSettings.homeFeedProviderOff); } } class _FeedExtensions extends ExtensionNotifier { @override ExtensionState build() => const ExtensionState( isInitialized: true, extensions: [ Extension( id: 'cache-race-provider', name: 'cache-race-provider', displayName: 'Cache race provider', version: '1.0.0', description: '', enabled: true, status: 'loaded', capabilities: {'homeFeed': true}, ), ], ); }