import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/preview_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; void main() { const track = Track( id: 'track-1', name: 'Track', artistName: 'Artist', albumName: 'Album', previewUrl: 'https://example.com/preview.mp3', duration: 180, ); const sparseSearchTrack = Track( id: 'search-track', name: 'Search Track', artistName: 'Artist', albumName: '', duration: 180, source: 'metadata-extension', ); Future pumpButton(WidgetTester tester, {MediaItem? currentItem}) async { await tester.pumpWidget( ProviderScope( overrides: [ currentMediaItemProvider.overrideWith( (ref) => Stream.value(currentItem), ), playbackPlayingProvider.overrideWith((ref) => false), ], child: const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: PreviewButton(track: track)), ), ), ); await tester.pump(); } void expectCenteredHitbox(WidgetTester tester, IconData iconData) { final button = find.byType(IconButton); final icon = find.byIcon(iconData); final hitbox = tester.getRect(button); expect(hitbox.width, greaterThanOrEqualTo(48)); expect(hitbox.height, greaterThanOrEqualTo(48)); expect(tester.getCenter(icon), hitbox.center); } testWidgets('preview icon is centered inside its 48dp hitbox', ( tester, ) async { await pumpButton(tester); expectCenteredHitbox(tester, Icons.play_circle_outline_rounded); }); testWidgets('current-track play icon uses the same centered hitbox', ( tester, ) async { await pumpButton( tester, currentItem: const MediaItem( id: 'track-1', title: 'Track', artist: 'Artist', ), ); expectCenteredHitbox(tester, Icons.play_circle_fill_rounded); }); testWidgets('preview progress does not rebuild track buttons', ( tester, ) async { final player = _TestPreviewPlayer(); var builds = 0; await tester.pumpWidget( ProviderScope( overrides: [ currentMediaItemProvider.overrideWith((ref) => Stream.value(null)), previewPlayerProvider.overrideWith(() => player), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: _CountingPreviewButton(track: track, onBuild: () => builds++), ), ), ), ); await tester.pumpAndSettle(); player.emit( PreviewPlayerState( activeUrl: track.previewUrl, status: PreviewStatus.playing, ), ); await tester.pump(); expect(find.byIcon(Icons.pause_circle_filled_rounded), findsOneWidget); final playingBuilds = builds; for (var second = 1; second <= 3; second++) { player.emit( PreviewPlayerState( activeUrl: track.previewUrl, status: PreviewStatus.playing, position: Duration(seconds: second), duration: const Duration(seconds: 30), ), ); await tester.pump(); } expect(builds, playingBuilds); player.emit( PreviewPlayerState( activeUrl: track.previewUrl, status: PreviewStatus.paused, ), ); await tester.pump(); expect(find.byIcon(Icons.play_circle_fill_rounded), findsOneWidget); expect(builds, playingBuilds + 1); player.emit( const PreviewPlayerState( activeUrl: 'https://example.com/another-preview.mp3', status: PreviewStatus.playing, ), ); await tester.pump(); expect(find.byIcon(Icons.play_circle_outline_rounded), findsOneWidget); }); testWidgets('overflow menu icon is centered in its adjacent hitbox', ( tester, ) async { await tester.pumpWidget( const ProviderScope( child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: TrackCollectionQuickActions(track: track)), ), ), ); await tester.pump(); expectCenteredHitbox(tester, Icons.more_vert); }); testWidgets('track options expose Go to Album for album tracks', ( tester, ) async { await tester.pumpWidget( ProviderScope( overrides: [ settingsProvider.overrideWith(_TestSettingsNotifier.new), libraryCollectionsProvider.overrideWith( _TestLibraryCollectionsNotifier.new, ), ], child: const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: TrackCollectionQuickActions(track: track)), ), ), ); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); expect(find.text('Go to Album'), findsOneWidget); expect(find.byIcon(Icons.album_outlined), findsOneWidget); }); testWidgets( 'search track options keep Go to Album when album data is sparse', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ settingsProvider.overrideWith(_TestSettingsNotifier.new), libraryCollectionsProvider.overrideWith( _TestLibraryCollectionsNotifier.new, ), ], child: const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: TrackCollectionQuickActions(track: sparseSearchTrack), ), ), ), ); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); expect(find.text('Go to Album'), findsOneWidget); expect(find.byIcon(Icons.album_outlined), findsOneWidget); }, ); } class _TestSettingsNotifier extends SettingsNotifier { @override AppSettings build() => const AppSettings(); } class _TestPreviewPlayer extends PreviewPlayerController { @override PreviewPlayerState build() => const PreviewPlayerState(); void emit(PreviewPlayerState next) => state = next; } class _CountingPreviewButton extends PreviewButton { const _CountingPreviewButton({required super.track, required this.onBuild}); final VoidCallback onBuild; @override Widget build(BuildContext context, WidgetRef ref) { onBuild(); return super.build(context, ref); } } class _TestLibraryCollectionsNotifier extends LibraryCollectionsNotifier { @override LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); }