From 8d93e4abf6efa2f0b90f600ace85ffd935dc1af4 Mon Sep 17 00:00:00 2001 From: biso Date: Sun, 3 May 2026 04:22:37 +0300 Subject: [PATCH 1/3] Feat: implemented the geo region on track card --- lib/core/data/models/track_dto.dart | 2 + lib/core/domain/entities/track.dart | 4 + lib/core/network/api_client.dart | 3 + .../widgets/home_top_track_card.dart | 137 +++++++------ .../presentation/widgets/hot_for_you.dart | 62 +++--- .../presentation/widgets/track_card.dart | 186 ++++++++++-------- 6 files changed, 219 insertions(+), 175 deletions(-) diff --git a/lib/core/data/models/track_dto.dart b/lib/core/data/models/track_dto.dart index e33b83d5..b4e87010 100644 --- a/lib/core/data/models/track_dto.dart +++ b/lib/core/data/models/track_dto.dart @@ -89,6 +89,7 @@ class TrackDto { isFeatured: data['is_featured'] as bool? ?? false, isPublic: data['is_public'] as bool? ?? true, isHidden: data['is_hidden'] as bool? ?? false, + isGeoBlocked: data['is_geo_blocked'] as bool? ?? false, geoRestrictionType: data['geo_restriction_type'] as String?, geoRegions: (data['geo_regions'] as List?) @@ -139,6 +140,7 @@ class TrackDto { 'is_featured': track.isFeatured, 'is_public': track.isPublic, 'is_hidden': track.isHidden, + 'is_geo_blocked': track.isGeoBlocked, 'geo_restriction_type': track.geoRestrictionType, 'geo_regions': track.geoRegions, 'status': track.status, diff --git a/lib/core/domain/entities/track.dart b/lib/core/domain/entities/track.dart index 38196087..c9dbab53 100644 --- a/lib/core/domain/entities/track.dart +++ b/lib/core/domain/entities/track.dart @@ -36,6 +36,7 @@ class Track { final bool isFeatured; final bool isPublic; final bool isHidden; + final bool isGeoBlocked; final String? geoRestrictionType; final List geoRegions; final DateTime createdAt; @@ -76,6 +77,7 @@ class Track { this.isFeatured = false, this.isPublic = true, this.isHidden = false, + this.isGeoBlocked = false, this.geoRestrictionType, this.geoRegions = const [], this.updatedAt, @@ -118,6 +120,7 @@ class Track { bool? isFeatured, bool? isPublic, bool? isHidden, + bool? isGeoBlocked, String? geoRestrictionType, List? geoRegions, DateTime? createdAt, @@ -157,6 +160,7 @@ class Track { isFeatured: isFeatured ?? this.isFeatured, isPublic: isPublic ?? this.isPublic, isHidden: isHidden ?? this.isHidden, + isGeoBlocked: isGeoBlocked ?? this.isGeoBlocked, geoRestrictionType: geoRestrictionType ?? this.geoRestrictionType, geoRegions: geoRegions ?? this.geoRegions, createdAt: createdAt ?? this.createdAt, diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 96f38b6d..2033c7ac 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -80,6 +80,9 @@ class ApiClient { RequestOptions options, RequestInterceptorHandler handler, ) async { + + options.headers['X-Country-Code'] = 'EG'; + final skipAuth = options.extra['skipAuth'] == true; if (!skipAuth) { final token = await getToken(); diff --git a/lib/features/feed/presentation/widgets/home_top_track_card.dart b/lib/features/feed/presentation/widgets/home_top_track_card.dart index f7f7dd76..560e6898 100644 --- a/lib/features/feed/presentation/widgets/home_top_track_card.dart +++ b/lib/features/feed/presentation/widgets/home_top_track_card.dart @@ -30,73 +30,84 @@ class HomeTopTrackCard extends ConsumerWidget { final isThisTrackLoaded = currentPlayingId == track.id; return InkWell( - onTap: () { - if (onTap != null) { - onTap!(); - return; - } - if (isThisTrackLoaded) { - ref.read(playerStateProvider.notifier).togglePlayPause(); - } else { - final tracksToPlay = allTracks ?? [track]; - final initialIndex = index ?? 0; + onTap: track.isGeoBlocked + ? null + : () { + if (onTap != null) { + onTap!(); + return; + } + if (isThisTrackLoaded) { + ref.read(playerStateProvider.notifier).togglePlayPause(); + } else { + final tracksToPlay = allTracks ?? [track]; + final initialIndex = index ?? 0; - ref - .read(queueStateProvider.notifier) - .playQueue( - tracks: tracksToPlay, - initialIndex: initialIndex, - context: const QueueContext(type: QueueSource.listeningHistory), - ); - } - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5.0), - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - // Artwork - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.0), - border: Border.all(color: Colors.grey.shade700, width: 0.7), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(7.3), - child: _buildArtwork(track.artworkUrl), + ref.read(queueStateProvider.notifier).playQueue( + tracks: tracksToPlay, + initialIndex: initialIndex, + context: + const QueueContext(type: QueueSource.listeningHistory), + ); + } + }, + child: Opacity( + opacity: track.isGeoBlocked ? 0.5 : 1.0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + // Artwork + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + border: Border.all(color: Colors.grey.shade700, width: 0.7), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7.3), + child: _buildArtwork(track.artworkUrl), + ), ), - ), - const SizedBox(width: 8), - // Info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, + const SizedBox(width: 8), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + track.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + track.isGeoBlocked + ? 'Not available in your country' + : track.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: track.isGeoBlocked + ? Colors.redAccent.withValues(alpha: 0.8) + : Colors.grey[400], + fontSize: 12, + ), ), - ), - const SizedBox(height: 2), - Text( - track.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: Colors.grey[400], fontSize: 12), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/features/feed/presentation/widgets/hot_for_you.dart b/lib/features/feed/presentation/widgets/hot_for_you.dart index 3d6185f2..c9d4ea3c 100644 --- a/lib/features/feed/presentation/widgets/hot_for_you.dart +++ b/lib/features/feed/presentation/widgets/hot_for_you.dart @@ -110,8 +110,10 @@ class _HotForYouCardState extends ConsumerState return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - key: Key('hot_track_card_${widget.track.id}'), + child: Opacity( + opacity: widget.track.isGeoBlocked ? 0.6 : 1.0, + child: Container( + key: Key('hot_track_card_${widget.track.id}'), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), @@ -188,23 +190,25 @@ class _HotForYouCardState extends ConsumerState : Icons.play_circle, color: AppTheme.textPrimary, ), - onPressed: () { - if (isThisTrack) { - ref - .read(playerStateProvider.notifier) - .togglePlayPause(); - } else { - ref - .read(queueStateProvider.notifier) - .playQueue( - tracks: [widget.track], - initialIndex: 0, - context: const QueueContext( - type: QueueSource.unknown, - ), - ); - } - }, + onPressed: widget.track.isGeoBlocked + ? null + : () { + if (isThisTrack) { + ref + .read(playerStateProvider.notifier) + .togglePlayPause(); + } else { + ref + .read(queueStateProvider.notifier) + .playQueue( + tracks: [widget.track], + initialIndex: 0, + context: const QueueContext( + type: QueueSource.unknown, + ), + ); + } + }, ), ], ), @@ -217,12 +221,18 @@ class _HotForYouCardState extends ConsumerState size: 18, ), const SizedBox(width: 6), - Text( - "${formatCount(widget.track.likeCount)} people liked this track", - key: const Key('hot_for_you_like_count_text'), - style: AppTheme.bodyNormal.copyWith( - fontSize: 12, - color: AppTheme.fadedWhite, + Expanded( + child: Text( + widget.track.isGeoBlocked + ? "Not available in your country" + : "${formatCount(widget.track.likeCount)} people liked this track", + key: const Key('hot_for_you_like_count_text'), + style: AppTheme.bodyNormal.copyWith( + fontSize: 12, + color: widget.track.isGeoBlocked + ? AppTheme.primaryBrand + : AppTheme.fadedWhite, + ), ), ), ], @@ -234,7 +244,9 @@ class _HotForYouCardState extends ConsumerState ), ), ), + ) ); + } Widget buildAlbum() { diff --git a/lib/features/track/presentation/widgets/track_card.dart b/lib/features/track/presentation/widgets/track_card.dart index fb15cf25..e8c04d33 100644 --- a/lib/features/track/presentation/widgets/track_card.dart +++ b/lib/features/track/presentation/widgets/track_card.dart @@ -77,102 +77,114 @@ class TrackCard extends ConsumerWidget { return InkWell( key: Key('track_card_${track.id}_inkwell'), - onTap: () { - if (onTap != null) { - onTap!(); - return; - } - if (isThisTrackLoaded) { - ref.read(playerStateProvider.notifier).togglePlayPause(); - } else { - ref - .read(queueStateProvider.notifier) - .playQueue( - tracks: [track], - initialIndex: 0, - context: queueContext, - ); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6.0), - border: Border.all(color: Colors.grey.shade700, width: 0.5), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(5.5), - child: _buildArtwork(track.artworkUrl), + onTap: track.isGeoBlocked + ? null + : () { + if (onTap != null) { + onTap!(); + return; + } + if (isThisTrackLoaded) { + ref.read(playerStateProvider.notifier).togglePlayPause(); + } else { + ref.read(queueStateProvider.notifier).playQueue( + tracks: [track], + initialIndex: 0, + context: queueContext, + ); + } + }, + child: Opacity( + opacity: track.isGeoBlocked ? 0.5 : 1.0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.0), + border: Border.all(color: Colors.grey.shade700, width: 0.5), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(5.5), + child: _buildArtwork(track.artworkUrl), + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), - // Middle Text Column - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Title - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, + // Middle Text Column + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Title + Text( + track.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(height: 1), + const SizedBox(height: 1), - // Artist - Text( - track.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: Colors.grey[400], fontSize: 14), - ), - const SizedBox(height: 2), + // Artist + Text( + track.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.grey[400], fontSize: 14), + ), + const SizedBox(height: 2), - // 3. Bottom Status Row (Dynamic) - if (showStats) - isThisTrackLoaded - ? _buildPlayingState(isPlaying) - : _buildStatsState(track), - ], + // Geo-blocked message or stats + if (track.isGeoBlocked) + const Text( + 'Not available in your country', + style: TextStyle( + color: AppTheme.primaryBrand, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ) + else if (showStats) + isThisTrackLoaded + ? _buildPlayingState(isPlaying) + : _buildStatsState(track), + ], + ), ), - ), - // 4. Trailing Action - if (trailing != null) - trailing! - else if (showOptions) - InkWell( - key: Key('track_card_${track.id}_more_inkwell'), - onTap: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (context) => TrackOptionsModal(track: track), - ); - }, - borderRadius: BorderRadius.circular(8), - highlightColor: Colors.white.withValues(alpha: 0.1), - splashColor: Colors.white.withValues(alpha: 0.2), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Icon(Icons.more_vert, color: Colors.grey), + // 4. Trailing Action + if (trailing != null) + trailing! + else if (showOptions && !track.isGeoBlocked) + InkWell( + key: Key('track_card_${track.id}_more_inkwell'), + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (context) => TrackOptionsModal(track: track), + ); + }, + borderRadius: BorderRadius.circular(8), + highlightColor: Colors.white.withValues(alpha: 0.1), + splashColor: Colors.white.withValues(alpha: 0.2), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Icon(Icons.more_vert, color: Colors.grey), + ), ), - ), - ], + ], + ), ), ), ); From 3bbd868a01c2022ae93c7f16a82ef2c932b67523 Mon Sep 17 00:00:00 2001 From: biso Date: Sun, 3 May 2026 04:33:03 +0300 Subject: [PATCH 2/3] All Features Done ;) --- lib/core/network/api_client.dart | 1 - .../widgets/home_top_track_card.dart | 9 +- .../presentation/widgets/hot_for_you.dart | 233 +++++++++--------- .../presentation/widgets/track_card.dart | 4 +- 4 files changed, 127 insertions(+), 120 deletions(-) diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 2033c7ac..ed312c20 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -80,7 +80,6 @@ class ApiClient { RequestOptions options, RequestInterceptorHandler handler, ) async { - options.headers['X-Country-Code'] = 'EG'; final skipAuth = options.extra['skipAuth'] == true; diff --git a/lib/features/feed/presentation/widgets/home_top_track_card.dart b/lib/features/feed/presentation/widgets/home_top_track_card.dart index 560e6898..387deef9 100644 --- a/lib/features/feed/presentation/widgets/home_top_track_card.dart +++ b/lib/features/feed/presentation/widgets/home_top_track_card.dart @@ -43,11 +43,14 @@ class HomeTopTrackCard extends ConsumerWidget { final tracksToPlay = allTracks ?? [track]; final initialIndex = index ?? 0; - ref.read(queueStateProvider.notifier).playQueue( + ref + .read(queueStateProvider.notifier) + .playQueue( tracks: tracksToPlay, initialIndex: initialIndex, - context: - const QueueContext(type: QueueSource.listeningHistory), + context: const QueueContext( + type: QueueSource.listeningHistory, + ), ); } }, diff --git a/lib/features/feed/presentation/widgets/hot_for_you.dart b/lib/features/feed/presentation/widgets/hot_for_you.dart index c9d4ea3c..830310f7 100644 --- a/lib/features/feed/presentation/widgets/hot_for_you.dart +++ b/lib/features/feed/presentation/widgets/hot_for_you.dart @@ -115,138 +115,141 @@ class _HotForYouCardState extends ConsumerState child: Container( key: Key('hot_track_card_${widget.track.id}'), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - image: DecorationImage( - image: - widget.track.coverImage != null && - widget.track.coverImage!.startsWith('http') - ? NetworkImage(widget.track.coverImage!) as ImageProvider - : AssetImage( - widget.track.coverImage ?? widget.track.artworkUrl, - ), - fit: BoxFit.cover, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + image: DecorationImage( + image: + widget.track.coverImage != null && + widget.track.coverImage!.startsWith('http') + ? NetworkImage(widget.track.coverImage!) as ImageProvider + : AssetImage( + widget.track.coverImage ?? widget.track.artworkUrl, + ), + fit: BoxFit.cover, + ), ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25), - child: Container( - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.3, - ), // Slightly dark layer for text - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Colors.white24, // Subtle white border - width: 1.0, + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.3, + ), // Slightly dark layer for text + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white24, // Subtle white border + width: 1.0, + ), ), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Row( - children: [ - buildAlbum(), - const SizedBox(width: 20), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.track.title, - key: const Key('hot_for_you_track_title_text'), - style: AppTheme.bodyNormal.copyWith( - fontSize: 16, - color: AppTheme.textPrimary, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + buildAlbum(), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.track.title, + key: const Key( + 'hot_for_you_track_title_text', + ), + style: AppTheme.bodyNormal.copyWith( + fontSize: 16, + color: AppTheme.textPrimary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - widget.track.artist, - key: const Key('hot_for_you_track_artist_text'), - style: AppTheme.bodyNormal.copyWith( - fontSize: 13, - color: AppTheme.fadedWhite, + const SizedBox(height: 2), + Text( + widget.track.artist, + key: const Key( + 'hot_for_you_track_artist_text', + ), + style: AppTheme.bodyNormal.copyWith( + fontSize: 13, + color: AppTheme.fadedWhite, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), - ), - IconButton( - key: const Key('hot_for_you_play_icon_button'), - iconSize: 60, - icon: Icon( - isPlaying && isThisTrack - ? Icons.pause_circle - : Icons.play_circle, - color: AppTheme.textPrimary, + IconButton( + key: const Key('hot_for_you_play_icon_button'), + iconSize: 60, + icon: Icon( + isPlaying && isThisTrack + ? Icons.pause_circle + : Icons.play_circle, + color: AppTheme.textPrimary, + ), + onPressed: widget.track.isGeoBlocked + ? null + : () { + if (isThisTrack) { + ref + .read(playerStateProvider.notifier) + .togglePlayPause(); + } else { + ref + .read(queueStateProvider.notifier) + .playQueue( + tracks: [widget.track], + initialIndex: 0, + context: const QueueContext( + type: QueueSource.unknown, + ), + ); + } + }, ), - onPressed: widget.track.isGeoBlocked - ? null - : () { - if (isThisTrack) { - ref - .read(playerStateProvider.notifier) - .togglePlayPause(); - } else { - ref - .read(queueStateProvider.notifier) - .playQueue( - tracks: [widget.track], - initialIndex: 0, - context: const QueueContext( - type: QueueSource.unknown, - ), - ); - } - }, - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - const Icon( - Icons.favorite, - color: AppTheme.fadedWhite, - size: 18, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.track.isGeoBlocked - ? "Not available in your country" - : "${formatCount(widget.track.likeCount)} people liked this track", - key: const Key('hot_for_you_like_count_text'), - style: AppTheme.bodyNormal.copyWith( - fontSize: 12, - color: widget.track.isGeoBlocked - ? AppTheme.primaryBrand - : AppTheme.fadedWhite, + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon( + Icons.favorite, + color: AppTheme.fadedWhite, + size: 18, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.track.isGeoBlocked + ? "Not available in your country" + : "${formatCount(widget.track.likeCount)} people liked this track", + key: const Key('hot_for_you_like_count_text'), + style: AppTheme.bodyNormal.copyWith( + fontSize: 12, + color: widget.track.isGeoBlocked + ? AppTheme.primaryBrand + : AppTheme.fadedWhite, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), ), ), ), - ) ); - } Widget buildAlbum() { diff --git a/lib/features/track/presentation/widgets/track_card.dart b/lib/features/track/presentation/widgets/track_card.dart index e8c04d33..b140bf4a 100644 --- a/lib/features/track/presentation/widgets/track_card.dart +++ b/lib/features/track/presentation/widgets/track_card.dart @@ -87,7 +87,9 @@ class TrackCard extends ConsumerWidget { if (isThisTrackLoaded) { ref.read(playerStateProvider.notifier).togglePlayPause(); } else { - ref.read(queueStateProvider.notifier).playQueue( + ref + .read(queueStateProvider.notifier) + .playQueue( tracks: [track], initialIndex: 0, context: queueContext, From fbff5b8955bf1d873db3d2e0e768a06c324ac0df Mon Sep 17 00:00:00 2001 From: biso Date: Sun, 3 May 2026 06:20:20 +0300 Subject: [PATCH 3/3] Test: Made unit testing for the player --- .../data/datasources/audio_handler.dart | 37 +- .../playback_local_data_source.dart | 5 +- ...se.dart => initiate_playback_usecase.dart} | 0 ... => record_listening_history_usecase.dart} | 0 .../domain/usecases/skip_track_usecase.dart | 10 + ...se_case.dart => sync_history_usecase.dart} | 0 .../player_dependency_providers.dart | 6 +- pubspec.lock | 4 +- pubspec.yaml | 2 + .../player/data/data_test_helper.dart | 42 ++ .../data/datasources/audio_handler_test.dart | 189 ++++++ .../playback_local_data_source_test.dart | 68 ++ .../playback_remote_data_source_test.dart | 257 ++++++++ .../models/history_record_model_test.dart | 48 ++ .../audio_repository_impl_test.dart | 215 +++++-- .../playback_repository_impl_test.dart | 240 +++++++ .../domain/entities/history_record_test.dart | 31 + .../domain/entities/player_state_test.dart | 77 +-- .../domain/entities/queue_item_test.dart | 81 +++ .../domain/entities/queue_state_test.dart | 73 +++ .../usecases/append_tracks_usecase_test.dart | 39 ++ .../fetch_queue_context_usecase_test.dart | 70 +++ .../get_player_state_stream_usecase_test.dart | 33 + .../get_related_tracks_usecase_test.dart | 73 +++ .../initiate_playback_usecase_test.dart | 48 ++ .../usecases/load_queue_usecase_test.dart | 69 +++ .../usecases/play_pause_usecase_test.dart | 66 ++ .../domain/usecases/player_usecases_test.dart | 170 ----- ...record_listening_history_usecase_test.dart | 63 ++ .../usecases/seek_position_usecase_test.dart | 45 ++ .../usecases/skip_track_usecase_test.dart | 98 +++ .../usecases/sync_history_usecase_test.dart | 39 ++ .../sync_player_state_usecase_test.dart | 76 +++ .../player/domain/usecases/test_helper.dart | 52 ++ .../update_track_info_usecase_test.dart | 44 ++ .../pages/full_player_page_test.dart | 197 ++++++ .../presentation/pages/queue_screen_test.dart | 131 ++++ .../presentation_test_helper.dart | 102 +++ .../providers/ad_provider_test.dart | 162 +++++ .../providers/dependency_providers_test.dart | 64 ++ .../providers/player_provider_test.dart | 567 +++++++++++------ .../providers/queue_provider_test.dart | 584 ++++++++++++++++++ .../widgets/mini_player_test.dart | 190 ++++++ .../widgets/track_info_box_test.dart | 37 ++ .../widgets/waveform_painter_test.dart | 74 +++ 45 files changed, 3980 insertions(+), 498 deletions(-) rename lib/features/player/domain/usecases/{initiate_playback_use_case.dart => initiate_playback_usecase.dart} (100%) rename lib/features/player/domain/usecases/{record_listening_history_use_case.dart => record_listening_history_usecase.dart} (100%) rename lib/features/player/domain/usecases/{sync_history_use_case.dart => sync_history_usecase.dart} (100%) create mode 100644 test/features/player/data/data_test_helper.dart create mode 100644 test/features/player/data/datasources/audio_handler_test.dart create mode 100644 test/features/player/data/datasources/playback_local_data_source_test.dart create mode 100644 test/features/player/data/datasources/playback_remote_data_source_test.dart create mode 100644 test/features/player/data/models/history_record_model_test.dart create mode 100644 test/features/player/data/repositories/playback_repository_impl_test.dart create mode 100644 test/features/player/domain/entities/history_record_test.dart create mode 100644 test/features/player/domain/entities/queue_item_test.dart create mode 100644 test/features/player/domain/entities/queue_state_test.dart create mode 100644 test/features/player/domain/usecases/append_tracks_usecase_test.dart create mode 100644 test/features/player/domain/usecases/fetch_queue_context_usecase_test.dart create mode 100644 test/features/player/domain/usecases/get_player_state_stream_usecase_test.dart create mode 100644 test/features/player/domain/usecases/get_related_tracks_usecase_test.dart create mode 100644 test/features/player/domain/usecases/initiate_playback_usecase_test.dart create mode 100644 test/features/player/domain/usecases/load_queue_usecase_test.dart create mode 100644 test/features/player/domain/usecases/play_pause_usecase_test.dart delete mode 100644 test/features/player/domain/usecases/player_usecases_test.dart create mode 100644 test/features/player/domain/usecases/record_listening_history_usecase_test.dart create mode 100644 test/features/player/domain/usecases/seek_position_usecase_test.dart create mode 100644 test/features/player/domain/usecases/skip_track_usecase_test.dart create mode 100644 test/features/player/domain/usecases/sync_history_usecase_test.dart create mode 100644 test/features/player/domain/usecases/sync_player_state_usecase_test.dart create mode 100644 test/features/player/domain/usecases/test_helper.dart create mode 100644 test/features/player/domain/usecases/update_track_info_usecase_test.dart create mode 100644 test/features/player/presentation/pages/full_player_page_test.dart create mode 100644 test/features/player/presentation/pages/queue_screen_test.dart create mode 100644 test/features/player/presentation/presentation_test_helper.dart create mode 100644 test/features/player/presentation/providers/ad_provider_test.dart create mode 100644 test/features/player/presentation/providers/dependency_providers_test.dart create mode 100644 test/features/player/presentation/providers/queue_provider_test.dart create mode 100644 test/features/player/presentation/widgets/mini_player_test.dart create mode 100644 test/features/player/presentation/widgets/track_info_box_test.dart create mode 100644 test/features/player/presentation/widgets/waveform_painter_test.dart diff --git a/lib/features/player/data/datasources/audio_handler.dart b/lib/features/player/data/datasources/audio_handler.dart index b6c1923d..8b10557d 100644 --- a/lib/features/player/data/datasources/audio_handler.dart +++ b/lib/features/player/data/datasources/audio_handler.dart @@ -12,22 +12,7 @@ import '../../../../core/domain/entities/track.dart'; /// hardware button support. class RythmifyAudioHandler extends BaseAudioHandler with SeekHandler { /// The underlying audio player instance. - final AudioPlayer _player = AudioPlayer( - audioLoadConfiguration: const AudioLoadConfiguration( - androidLoadControl: AndroidLoadControl( - minBufferDuration: Duration(seconds: 30), // Buffer 30 seconds ahead - maxBufferDuration: Duration(seconds: 120), // Up to 2 minutes - bufferForPlaybackDuration: Duration( - milliseconds: 500, - ), // Start playing fast - bufferForPlaybackAfterRebufferDuration: Duration(seconds: 1), - ), - - darwinLoadControl: DarwinLoadControl( - automaticallyWaitsToMinimizeStalling: true, - ), - ), - ); + final AudioPlayer _player; // ignore: deprecated_member_use final _playlist = ConcatenatingAudioSource(children: []); @@ -35,7 +20,22 @@ class RythmifyAudioHandler extends BaseAudioHandler with SeekHandler { /// The current list of tracks in the playback queue. List _currentQueue = []; - RythmifyAudioHandler() { + RythmifyAudioHandler({AudioPlayer? player}) + : _player = + player ?? + AudioPlayer( + audioLoadConfiguration: const AudioLoadConfiguration( + androidLoadControl: AndroidLoadControl( + minBufferDuration: Duration(seconds: 30), + maxBufferDuration: Duration(seconds: 120), + bufferForPlaybackDuration: Duration(milliseconds: 500), + bufferForPlaybackAfterRebufferDuration: Duration(seconds: 1), + ), + darwinLoadControl: DarwinLoadControl( + automaticallyWaitsToMinimizeStalling: true, + ), + ), + ) { _init(); } @@ -175,9 +175,10 @@ class RythmifyAudioHandler extends BaseAudioHandler with SeekHandler { List tracks, { int initialIndex = 0, Duration initialPosition = Duration.zero, + bool useCache = true, }) async { _currentQueue = List.from(tracks); - final audioSources = _convertToAudioSources(tracks); + final audioSources = _convertToAudioSources(tracks, useCache: useCache); if (audioSources.isEmpty) return; diff --git a/lib/features/player/data/datasources/playback_local_data_source.dart b/lib/features/player/data/datasources/playback_local_data_source.dart index 6ca96c10..52e419c0 100644 --- a/lib/features/player/data/datasources/playback_local_data_source.dart +++ b/lib/features/player/data/datasources/playback_local_data_source.dart @@ -9,9 +9,12 @@ abstract class PlaybackLocalDataSource { class PlaybackLocalDataSourceImpl implements PlaybackLocalDataSource { static const String _boxName = 'pending_history_v1'; + final Box? _box; + + PlaybackLocalDataSourceImpl({Box? box}) : _box = box; Future _openBox() async { - return await Hive.openBox(_boxName); + return _box ?? await Hive.openBox(_boxName); } @override diff --git a/lib/features/player/domain/usecases/initiate_playback_use_case.dart b/lib/features/player/domain/usecases/initiate_playback_usecase.dart similarity index 100% rename from lib/features/player/domain/usecases/initiate_playback_use_case.dart rename to lib/features/player/domain/usecases/initiate_playback_usecase.dart diff --git a/lib/features/player/domain/usecases/record_listening_history_use_case.dart b/lib/features/player/domain/usecases/record_listening_history_usecase.dart similarity index 100% rename from lib/features/player/domain/usecases/record_listening_history_use_case.dart rename to lib/features/player/domain/usecases/record_listening_history_usecase.dart diff --git a/lib/features/player/domain/usecases/skip_track_usecase.dart b/lib/features/player/domain/usecases/skip_track_usecase.dart index ddcba5eb..7b29c366 100644 --- a/lib/features/player/domain/usecases/skip_track_usecase.dart +++ b/lib/features/player/domain/usecases/skip_track_usecase.dart @@ -19,3 +19,13 @@ class SkipToPreviousUseCase { return await repository.skipToPrevious(); } } + +/// Intent: Jumps to a specific index in the current native queue. +class SkipToIndexUseCase { + final AudioRepository repository; + SkipToIndexUseCase(this.repository); + + Future call(int index) async { + return await repository.skipToIndex(index); + } +} diff --git a/lib/features/player/domain/usecases/sync_history_use_case.dart b/lib/features/player/domain/usecases/sync_history_usecase.dart similarity index 100% rename from lib/features/player/domain/usecases/sync_history_use_case.dart rename to lib/features/player/domain/usecases/sync_history_usecase.dart diff --git a/lib/features/player/presentation/providers/player_dependency_providers.dart b/lib/features/player/presentation/providers/player_dependency_providers.dart index 9addfc1e..e0c217d3 100644 --- a/lib/features/player/presentation/providers/player_dependency_providers.dart +++ b/lib/features/player/presentation/providers/player_dependency_providers.dart @@ -13,9 +13,9 @@ import '../../domain/usecases/seek_position_usecase.dart'; import '../../domain/usecases/get_player_state_stream_usecase.dart'; import '../../domain/usecases/load_queue_usecase.dart'; import '../../domain/usecases/update_track_info_usecase.dart'; -import '../../domain/usecases/initiate_playback_use_case.dart'; -import '../../domain/usecases/record_listening_history_use_case.dart'; -import '../../domain/usecases/sync_history_use_case.dart'; +import '../../domain/usecases/initiate_playback_usecase.dart'; +import '../../domain/usecases/record_listening_history_usecase.dart'; +import '../../domain/usecases/sync_history_usecase.dart'; import '../../domain/usecases/fetch_queue_context_usecase.dart'; import '../../domain/usecases/sync_player_state_usecase.dart'; import '../../domain/usecases/get_related_tracks_usecase.dart'; diff --git a/pubspec.lock b/pubspec.lock index 9750de17..c657d83b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -426,7 +426,7 @@ packages: source: hosted version: "4.1.1" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -1496,7 +1496,7 @@ packages: source: hosted version: "3.1.3" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" diff --git a/pubspec.yaml b/pubspec.yaml index cde2395d..273ae93e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: # State Management & Dependency Injection flutter_riverpod: ^3.2.1 equatable: ^2.0.8 + rxdart: ^0.28.0 # Profile image_picker: ^1.1.2 @@ -87,6 +88,7 @@ dev_dependencies: riverpod_lint: ^3.1.3 flutter_driver: sdk: flutter + fake_async: ^1.3.3 diff --git a/test/features/player/data/data_test_helper.dart b/test/features/player/data/data_test_helper.dart new file mode 100644 index 00000000..1bfd9685 --- /dev/null +++ b/test/features/player/data/data_test_helper.dart @@ -0,0 +1,42 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:hive/hive.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:rythmify/core/network/api_client.dart'; +import 'package:rythmify/features/player/data/datasources/playback_local_data_source.dart'; +import 'package:rythmify/features/player/data/datasources/playback_remote_data_source.dart'; +import 'package:rythmify/features/player/data/datasources/audio_handler.dart'; +import 'package:rythmify/features/player/data/models/history_record_model.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:rythmify/features/player/domain/entities/history_record.dart'; + +class MockApiClient extends Mock implements ApiClient {} + +class MockDio extends Mock implements Dio {} + +class MockBox extends Mock implements Box {} + +class MockPlaybackRemoteDataSource extends Mock + implements PlaybackRemoteDataSource {} + +class MockPlaybackLocalDataSource extends Mock + implements PlaybackLocalDataSource {} + +class MockRythmifyAudioHandler extends Mock implements RythmifyAudioHandler {} + +class MockAudioPlayer extends Mock implements AudioPlayer {} + +class MockResponse extends Mock implements Response {} + +class FakeHistoryRecordModel extends Fake implements HistoryRecordModel {} + +class FakeTrack extends Fake implements Track {} + +class FakeAudioSource extends Fake implements AudioSource {} + +void registerTestFallbacks() { + registerFallbackValue(FakeHistoryRecordModel()); + registerFallbackValue(FakeTrack()); + registerFallbackValue(FakeAudioSource()); + registerFallbackValue(Duration.zero); +} diff --git a/test/features/player/data/datasources/audio_handler_test.dart b/test/features/player/data/datasources/audio_handler_test.dart new file mode 100644 index 00000000..8b02933c --- /dev/null +++ b/test/features/player/data/datasources/audio_handler_test.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:rythmify/features/player/data/datasources/audio_handler.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import '../data_test_helper.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerTestFallbacks(); + }); + + late RythmifyAudioHandler handler; + late MockAudioPlayer mockPlayer; + + late StreamController playbackEventController; + late StreamController currentIndexController; + late StreamController processingStateController; + late StreamController playingController; + + final tTrack = Track( + id: '1', + userId: 'u1', + title: 'Track 1', + artist: 'Artist 1', + audioUrl: 'https://example.com/audio.mp3', + duration: const Duration(seconds: 180), + createdAt: DateTime.now(), + ); + + setUp(() { + mockPlayer = MockAudioPlayer(); + + playbackEventController = StreamController.broadcast(); + currentIndexController = StreamController.broadcast(); + processingStateController = StreamController.broadcast(); + playingController = StreamController.broadcast(); + + when( + () => mockPlayer.playbackEventStream, + ).thenAnswer((_) => playbackEventController.stream); + when( + () => mockPlayer.currentIndexStream, + ).thenAnswer((_) => currentIndexController.stream); + when( + () => mockPlayer.processingStateStream, + ).thenAnswer((_) => processingStateController.stream); + when( + () => mockPlayer.playingStream, + ).thenAnswer((_) => playingController.stream); + when(() => mockPlayer.positionStream).thenAnswer((_) => Stream.empty()); + when( + () => mockPlayer.bufferedPositionStream, + ).thenAnswer((_) => Stream.empty()); + + when(() => mockPlayer.playing).thenReturn(false); + when(() => mockPlayer.position).thenReturn(Duration.zero); + when(() => mockPlayer.bufferedPosition).thenReturn(Duration.zero); + when(() => mockPlayer.speed).thenReturn(1.0); + when(() => mockPlayer.processingState).thenReturn(ProcessingState.idle); + when(() => mockPlayer.currentIndex).thenReturn(0); + + when( + () => mockPlayer.setAudioSource( + any(), + initialIndex: any(named: 'initialIndex'), + initialPosition: any(named: 'initialPosition'), + preload: any(named: 'preload'), + ), + ).thenAnswer((_) async => const Duration(seconds: 180)); + + handler = RythmifyAudioHandler(player: mockPlayer); + }); + + tearDown(() { + playbackEventController.close(); + currentIndexController.close(); + processingStateController.close(); + playingController.close(); + }); + + group('RythmifyAudioHandler', () { + test('loadQueue should set audio source on player', () async { + await handler.loadQueue([tTrack], useCache: false); + + verify( + () => mockPlayer.setAudioSource( + any(), + initialIndex: 0, + initialPosition: Duration.zero, + ), + ).called(1); + expect(handler.currentQueue, [tTrack]); + }); + + test('play/pause/seek calls player methods', () async { + when(() => mockPlayer.play()).thenAnswer((_) async => {}); + when(() => mockPlayer.pause()).thenAnswer((_) async => {}); + when(() => mockPlayer.seek(any())).thenAnswer((_) async => {}); + + await handler.play(); + verify(() => mockPlayer.play()).called(1); + + await handler.pause(); + verify(() => mockPlayer.pause()).called(1); + + await handler.seek(const Duration(seconds: 10)); + verify(() => mockPlayer.seek(const Duration(seconds: 10))).called(1); + }); + + test('playbackEventStream updates playbackState', () async { + final event = PlaybackEvent( + processingState: ProcessingState.ready, + currentIndex: 0, + ); + when(() => mockPlayer.playing).thenReturn(true); + when(() => mockPlayer.processingState).thenReturn(ProcessingState.ready); + + playbackEventController.add(event); + + await expectLater( + handler.playbackState, + emitsThrough( + predicate( + (state) => + state.playing == true && + state.processingState == AudioProcessingState.ready, + ), + ), + ); + }); + + test('currentIndexStream updates mediaItem', () async { + await handler.loadQueue([tTrack], useCache: false); + + currentIndexController.add(0); + + await expectLater( + handler.mediaItem, + emitsThrough( + predicate( + (item) => + item != null && + item.id == tTrack.id && + item.title == tTrack.title, + ), + ), + ); + }); + + test('updateTrackInfo updates queue and mediaItem if current', () async { + await handler.loadQueue([tTrack], useCache: false); + final updatedTrack = tTrack.copyWith(title: 'Updated'); + + when(() => mockPlayer.currentIndex).thenReturn(0); + + await handler.updateTrackInfo(tTrack.id, updatedTrack); + + expect(handler.currentQueue[0].title, 'Updated'); + + await expectLater( + handler.mediaItem, + emitsThrough( + predicate( + (item) => item != null && item.title == 'Updated', + ), + ), + ); + }); + + test('skipToIndex calls player.seek', () async { + await handler.loadQueue([tTrack, tTrack], useCache: false); + + when( + () => mockPlayer.seek(any(), index: any(named: 'index')), + ).thenAnswer((_) async => {}); + when(() => mockPlayer.play()).thenAnswer((_) async => {}); + + await handler.skipToIndex(1); + + verify(() => mockPlayer.seek(Duration.zero, index: 1)).called(1); + verify(() => mockPlayer.play()).called(1); + }); + }); +} diff --git a/test/features/player/data/datasources/playback_local_data_source_test.dart b/test/features/player/data/datasources/playback_local_data_source_test.dart new file mode 100644 index 00000000..bfa4797f --- /dev/null +++ b/test/features/player/data/datasources/playback_local_data_source_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/data/datasources/playback_local_data_source.dart'; +import 'package:rythmify/features/player/data/models/history_record_model.dart'; +import '../data_test_helper.dart'; + +void main() { + setUpAll(() { + registerTestFallbacks(); + }); + + late PlaybackLocalDataSourceImpl dataSource; + late MockBox mockBox; + + setUp(() { + mockBox = MockBox(); + dataSource = PlaybackLocalDataSourceImpl(box: mockBox); + }); + + final tHistoryRecordModel = HistoryRecordModel( + trackId: '1', + playedAt: DateTime.parse('2023-01-01T00:00:00.000'), + durationPlayedSeconds: 120, + ); + + final tKey = '1_${tHistoryRecordModel.playedAt.millisecondsSinceEpoch}'; + + group('cacheHistoryRecord', () { + test('should call box.put with correct key and data', () async { + // arrange + when(() => mockBox.put(any(), any())).thenAnswer((_) async => {}); + + // act + await dataSource.cacheHistoryRecord(tHistoryRecordModel); + + // assert + verify(() => mockBox.put(tKey, tHistoryRecordModel.toJson())).called(1); + }); + }); + + group('getPendingHistoryRecords', () { + test('should return list of history records from box', () async { + // arrange + final tJson = tHistoryRecordModel.toJson(); + when(() => mockBox.values).thenReturn([tJson]); + + // act + final result = await dataSource.getPendingHistoryRecords(); + + // assert + expect(result, [tHistoryRecordModel]); + verify(() => mockBox.values).called(1); + }); + }); + + group('removeHistoryRecords', () { + test('should call box.delete for each record', () async { + // arrange + when(() => mockBox.delete(any())).thenAnswer((_) async => {}); + + // act + await dataSource.removeHistoryRecords([tHistoryRecordModel]); + + // assert + verify(() => mockBox.delete(tKey)).called(1); + }); + }); +} diff --git a/test/features/player/data/datasources/playback_remote_data_source_test.dart b/test/features/player/data/datasources/playback_remote_data_source_test.dart new file mode 100644 index 00000000..5f8cb512 --- /dev/null +++ b/test/features/player/data/datasources/playback_remote_data_source_test.dart @@ -0,0 +1,257 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:rythmify/features/player/data/datasources/playback_remote_data_source.dart'; +import 'package:rythmify/features/player/data/models/history_record_model.dart'; +import '../data_test_helper.dart'; + +void main() { + setUpAll(() { + registerTestFallbacks(); + }); + + late PlaybackRemoteDataSourceImpl dataSource; + late MockApiClient mockApiClient; + late MockDio mockDio; + + setUp(() { + mockApiClient = MockApiClient(); + mockDio = MockDio(); + when(() => mockApiClient.dio).thenReturn(mockDio); + dataSource = PlaybackRemoteDataSourceImpl(mockApiClient); + }); + + final tHistoryRecordModel = HistoryRecordModel( + trackId: '1', + playedAt: DateTime.parse('2023-01-01T00:00:00.000'), + durationPlayedSeconds: 120, + ); + + group('initiatePlayback', () { + test('should return stream_url when the call is successful', () async { + // arrange + final response = Response( + data: { + 'data': { + 'stream_url': 'https://example.com/stream.mp3', + }, + }, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => response); + + // act + final result = await dataSource.initiatePlayback('1'); + + // assert + expect(result, 'https://example.com/stream.mp3'); + verify(() => mockDio.post('/tracks/1/play')).called(1); + }); + + test( + 'should return url when stream_url is missing but url is present', + () async { + // arrange + final response = Response( + data: { + 'data': {'url': 'https://example.com/stream.mp3'}, + }, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => response); + + // act + final result = await dataSource.initiatePlayback('1'); + + // assert + expect(result, 'https://example.com/stream.mp3'); + }, + ); + + test('should throw an exception when URL is not found', () async { + // arrange + final response = Response( + data: {'data': {}}, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => response); + + // act & assert + expect(dataSource.initiatePlayback('1'), throwsException); + }); + + test('should rethrow DioException when the call fails', () async { + // arrange + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenThrow(DioException(requestOptions: RequestOptions(path: ''))); + + // act & assert + expect( + () => dataSource.initiatePlayback('1'), + throwsA(isA()), + ); + }); + group('recordHistory', () { + test('should call post with correct data', () async { + // arrange + final response = Response( + data: {}, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => response); + + // act + await dataSource.recordHistory(tHistoryRecordModel); + + // assert + verify( + () => mockDio.post( + '/me/listening-history', + data: tHistoryRecordModel.toJson(), + ), + ).called(1); + }); + + test('should silently fail when DioException occurs', () async { + // arrange + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenThrow(DioException(requestOptions: RequestOptions(path: ''))); + + // act & assert (should not throw) + await dataSource.recordHistory(tHistoryRecordModel); + verify( + () => mockDio.post( + '/me/listening-history', + data: tHistoryRecordModel.toJson(), + ), + ).called(1); + }); + }); + + group('fetchQueueContext', () { + test('should return data when the call is successful', () async { + // arrange + final responseData = { + 'data': { + 'queue': [ + {'id': '1'}, + ], + }, + }; + final response = Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => response); + + // act + final result = await dataSource.fetchQueueContext( + interactionType: 'play', + sourceType: 'playlist', + sourceId: 'p1', + ); + + // assert + expect(result, responseData); + verify( + () => mockDio.post( + '/me/player/queue/context', + data: { + 'interaction_type': 'play', + 'source_type': 'playlist', + 'source_id': 'p1', + 'target_user_id': null, + }, + ), + ).called(1); + }); + + test('should return empty queue when DioException occurs', () async { + // arrange + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenThrow(DioException(requestOptions: RequestOptions(path: ''))); + + // act + final result = await dataSource.fetchQueueContext( + interactionType: 'play', + sourceType: 'playlist', + ); + + // assert + expect(result, { + 'data': {'queue': []}, + }); + }); + }); + + group('syncPlayerState', () { + test('should call post with correct data', () async { + // arrange + final response = Response( + data: {}, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => response); + + final tQueue = [ + {'id': '1'}, + ]; + + // act + await dataSource.syncPlayerState( + trackId: '1', + queue: tQueue, + positionSeconds: 10, + volume: 0.8, + ); + + // assert + verify( + () => mockDio.post( + '/me/player/state', + data: { + 'track_id': '1', + 'position_seconds': 10, + 'volume': 0.8, + 'queue': tQueue, + }, + ), + ).called(1); + }); + + test('should silently fail when DioException occurs', () async { + // arrange + when( + () => mockDio.post(any(), data: any(named: 'data')), + ).thenThrow(DioException(requestOptions: RequestOptions(path: ''))); + + // act & assert (should not throw) + await dataSource.syncPlayerState(trackId: '1', queue: []); + verify( + () => mockDio.post('/me/player/state', data: any(named: 'data')), + ).called(1); + }); + }); + }); +} diff --git a/test/features/player/data/models/history_record_model_test.dart b/test/features/player/data/models/history_record_model_test.dart new file mode 100644 index 00000000..3239113b --- /dev/null +++ b/test/features/player/data/models/history_record_model_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/player/data/models/history_record_model.dart'; +import 'package:rythmify/features/player/domain/entities/history_record.dart'; + +void main() { + final tHistoryRecordModel = HistoryRecordModel( + trackId: '1', + playedAt: DateTime.parse('2023-01-01T00:00:00.000'), + durationPlayedSeconds: 120, + ); + + final tHistoryRecord = HistoryRecord( + trackId: '1', + playedAt: DateTime.parse('2023-01-01T00:00:00.000'), + durationPlayedSeconds: 120, + ); + + group('HistoryRecordModel', () { + test('should be a subclass of HistoryRecord entity', () { + expect(tHistoryRecordModel, isA()); + }); + + test('fromEntity should return a valid model', () { + final result = HistoryRecordModel.fromEntity(tHistoryRecord); + expect(result, tHistoryRecordModel); + }); + + test('fromJson should return a valid model', () { + final Map jsonMap = { + 'track_id': '1', + 'played_at': '2023-01-01T00:00:00.000', + 'duration_played_seconds': 120, + }; + final result = HistoryRecordModel.fromJson(jsonMap); + expect(result, tHistoryRecordModel); + }); + + test('toJson should return a JSON map containing the proper data', () { + final result = tHistoryRecordModel.toJson(); + final expectedMap = { + 'track_id': '1', + 'played_at': '2023-01-01T00:00:00.000', + 'duration_played_seconds': 120, + }; + expect(result, expectedMap); + }); + }); +} diff --git a/test/features/player/data/repositories/audio_repository_impl_test.dart b/test/features/player/data/repositories/audio_repository_impl_test.dart index 205682a5..991b4d6b 100644 --- a/test/features/player/data/repositories/audio_repository_impl_test.dart +++ b/test/features/player/data/repositories/audio_repository_impl_test.dart @@ -2,16 +2,16 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:rythmify/core/domain/entities/track.dart'; -import 'package:rythmify/features/player/domain/entities/player_state.dart'; -import 'package:rythmify/features/player/data/datasources/audio_handler.dart'; import 'package:rythmify/features/player/data/repositories/audio_repository_impl.dart'; - -class MockRythmifyAudioHandler extends Mock implements RythmifyAudioHandler {} - -class MockPlaybackEvent extends Mock implements PlaybackEvent {} +import 'package:rythmify/features/player/domain/entities/player_state.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import '../data_test_helper.dart'; void main() { + setUpAll(() { + registerTestFallbacks(); + }); + late AudioRepositoryImpl repository; late MockRythmifyAudioHandler mockHandler; @@ -21,12 +21,12 @@ void main() { late StreamController playingController; final tTrack = Track( - id: 'track-1', - userId: 'user-1', - title: 'Test Track', - artist: 'Artist', - audioUrl: 'http://example.com/audio.mp3', - duration: const Duration(minutes: 3), + id: '1', + userId: 'u1', + title: 'Track 1', + artist: 'Artist 1', + audioUrl: 'https://example.com/audio.mp3', + duration: const Duration(seconds: 180), createdAt: DateTime.now(), ); @@ -51,7 +51,7 @@ void main() { () => mockHandler.playingStream, ).thenAnswer((_) => playingController.stream); - when(() => mockHandler.currentQueue).thenReturn([]); + when(() => mockHandler.currentQueue).thenReturn([tTrack]); when(() => mockHandler.playing).thenReturn(false); when(() => mockHandler.processingState).thenReturn(ProcessingState.idle); @@ -66,30 +66,122 @@ void main() { }); group('AudioRepositoryImpl', () { - test('initial state is correct', () { + test('initial state should be correct', () { expect(repository.currentState, const AppPlayerState()); }); - test('loadQueue sets loading state and calls handler', () async { + test('should update position when positionStream emits', () async { + final tPosition = const Duration(seconds: 10); + + // We expect the playerStateStream to emit a new state + final future = expectLater( + repository.playerStateStream, + emits( + predicate((state) => state.position == tPosition), + ), + ); + + positionController.add(tPosition); + await future; + + expect(repository.currentState.position, tPosition); + }); + + test('should update current track when currentIndexStream emits', () async { + final tIndex = 0; + + final future = expectLater( + repository.playerStateStream, + emits( + predicate( + (state) => + state.currentTrack == tTrack && state.queueIndex == tIndex, + ), + ), + ); + + currentIndexController.add(tIndex); + await future; + + expect(repository.currentState.currentTrack, tTrack); + expect(repository.currentState.queueIndex, tIndex); + }); + + test('should update status when playingStream emits', () async { + when(() => mockHandler.processingState).thenReturn(ProcessingState.ready); + + final future = expectLater( + repository.playerStateStream, + emits( + predicate( + (state) => state.status == PlayerStatus.playing, + ), + ), + ); + + playingController.add(true); + await future; + + expect(repository.currentState.status, PlayerStatus.playing); + }); + + test( + 'should update status and bufferedPosition when playbackEventStream emits', + () async { + final tBufferedPosition = const Duration(seconds: 20); + final tDuration = const Duration(seconds: 180); + + final event = PlaybackEvent( + processingState: ProcessingState.buffering, + bufferedPosition: tBufferedPosition, + duration: tDuration, + ); + + final future = expectLater( + repository.playerStateStream, + emits( + predicate( + (state) => + state.status == PlayerStatus.loading && + state.bufferedPosition == tBufferedPosition && + state.duration == tDuration, + ), + ), + ); + + playbackEventController.add(event); + await future; + + expect(repository.currentState.status, PlayerStatus.loading); + expect(repository.currentState.bufferedPosition, tBufferedPosition); + expect(repository.currentState.duration, tDuration); + }, + ); + + test('loadQueue should call handler and update queueStream', () async { + final tracks = [tTrack]; when( () => mockHandler.loadQueue( any(), initialIndex: any(named: 'initialIndex'), + initialPosition: any(named: 'initialPosition'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => {}); + + final future = expectLater(repository.queueStream, emits(tracks)); - await repository.loadQueue([tTrack], initialIndex: 0); + await repository.loadQueue(tracks); + await future; - verify(() => mockHandler.loadQueue([tTrack], initialIndex: 0)).called(1); - expect(repository.currentState.status, PlayerStatus.loading); + verify(() => mockHandler.loadQueue(tracks)).called(1); }); - test('play, pause, seek, skip delegate to handler', () async { - when(() => mockHandler.play()).thenAnswer((_) async {}); - when(() => mockHandler.pause()).thenAnswer((_) async {}); - when(() => mockHandler.seek(any())).thenAnswer((_) async {}); - when(() => mockHandler.skipToNext()).thenAnswer((_) async {}); - when(() => mockHandler.skipToPrevious()).thenAnswer((_) async {}); + test('play/pause/seek/skip calls handler', () async { + when(() => mockHandler.play()).thenAnswer((_) async => {}); + when(() => mockHandler.pause()).thenAnswer((_) async => {}); + when(() => mockHandler.seek(any())).thenAnswer((_) async => {}); + when(() => mockHandler.skipToNext()).thenAnswer((_) async => {}); + when(() => mockHandler.skipToPrevious()).thenAnswer((_) async => {}); await repository.play(); verify(() => mockHandler.play()).called(1); @@ -97,8 +189,8 @@ void main() { await repository.pause(); verify(() => mockHandler.pause()).called(1); - await repository.seek(const Duration(seconds: 10)); - verify(() => mockHandler.seek(const Duration(seconds: 10))).called(1); + await repository.seek(const Duration(seconds: 5)); + verify(() => mockHandler.seek(const Duration(seconds: 5))).called(1); await repository.skipToNext(); verify(() => mockHandler.skipToNext()).called(1); @@ -107,52 +199,61 @@ void main() { verify(() => mockHandler.skipToPrevious()).called(1); }); - test('setShuffleMode and setLoopMode update state', () async { + test('appendTracks should call handler and update queueStream', () async { + final tracks = [tTrack]; + when(() => mockHandler.appendTracks(any())).thenAnswer((_) async => {}); + when(() => mockHandler.currentQueue).thenReturn([tTrack, tTrack]); + + final future = expectLater( + repository.queueStream, + emits([tTrack, tTrack]), + ); + + await repository.appendTracks(tracks); + await future; + + verify(() => mockHandler.appendTracks(tracks)).called(1); + }); + + test('setShuffleMode/setLoopMode update state', () async { + final future = expectLater( + repository.playerStateStream, + emits( + predicate( + (state) => state.isShuffleModeEnabled == true, + ), + ), + ); await repository.setShuffleMode(true); + await future; expect(repository.currentState.isShuffleModeEnabled, true); + final future2 = expectLater( + repository.playerStateStream, + emits(predicate((state) => state.loopMode == 'all')), + ); await repository.setLoopMode('all'); + await future2; expect(repository.currentState.loopMode, 'all'); }); test( - 'updateTrackInfo updates handler and state if track is current', + 'updateTrackInfo updates handler and state if current track', () async { + final updatedTrack = tTrack.copyWith(title: 'Updated Title'); when( () => mockHandler.updateTrackInfo(any(), any()), - ).thenAnswer((_) async {}); - when(() => mockHandler.currentQueue).thenReturn([tTrack]); + ).thenAnswer((_) async => {}); + when(() => mockHandler.currentQueue).thenReturn([updatedTrack]); - // We need to set the current track manually for the test - // Since it's internal, we simulate it via the index stream + // Set current track first currentIndexController.add(0); - await Future.delayed(Duration.zero); - final updatedTrack = tTrack.copyWith(title: 'Updated Title'); - await repository.updateTrackInfo('track-1', updatedTrack); + await repository.updateTrackInfo('1', updatedTrack); - verify( - () => mockHandler.updateTrackInfo('track-1', updatedTrack), - ).called(1); + verify(() => mockHandler.updateTrackInfo('1', updatedTrack)).called(1); expect(repository.currentState.currentTrack?.title, 'Updated Title'); }, ); - - test('positionStream updates position in state', () async { - positionController.add(const Duration(seconds: 5)); - await Future.delayed(Duration.zero); - expect(repository.currentState.position, const Duration(seconds: 5)); - }); - - test('playingStream updates status in state', () async { - when(() => mockHandler.processingState).thenReturn(ProcessingState.ready); - playingController.add(true); - await Future.delayed(Duration.zero); - expect(repository.currentState.status, PlayerStatus.playing); - - playingController.add(false); - await Future.delayed(Duration.zero); - expect(repository.currentState.status, PlayerStatus.paused); - }); }); } diff --git a/test/features/player/data/repositories/playback_repository_impl_test.dart b/test/features/player/data/repositories/playback_repository_impl_test.dart new file mode 100644 index 00000000..8dd38957 --- /dev/null +++ b/test/features/player/data/repositories/playback_repository_impl_test.dart @@ -0,0 +1,240 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/data/repositories/playback_repository_impl.dart'; +import 'package:rythmify/features/player/data/models/history_record_model.dart'; +import 'package:rythmify/features/player/domain/entities/history_record.dart'; +import '../data_test_helper.dart'; + +void main() { + late PlaybackRepositoryImpl repository; + late MockPlaybackRemoteDataSource mockRemoteDataSource; + late MockPlaybackLocalDataSource mockLocalDataSource; + + setUpAll(() { + registerTestFallbacks(); + }); + + setUp(() { + mockRemoteDataSource = MockPlaybackRemoteDataSource(); + mockLocalDataSource = MockPlaybackLocalDataSource(); + repository = PlaybackRepositoryImpl( + mockRemoteDataSource, + mockLocalDataSource, + ); + }); + + final tHistoryRecord = HistoryRecord( + trackId: '1', + playedAt: DateTime.parse('2023-01-01T00:00:00.000'), + durationPlayedSeconds: 120, + ); + final tHistoryRecordModel = HistoryRecordModel.fromEntity(tHistoryRecord); + + group('initiatePlayback', () { + test('should return stream URL when remote call is successful', () async { + // arrange + when( + () => mockRemoteDataSource.initiatePlayback(any()), + ).thenAnswer((_) async => 'https://example.com/stream.mp3'); + + // act + final result = await repository.initiatePlayback('1'); + + // assert + expect(result, 'https://example.com/stream.mp3'); + verify(() => mockRemoteDataSource.initiatePlayback('1')).called(1); + }); + + test('should rethrow exception when remote call fails', () async { + // arrange + when( + () => mockRemoteDataSource.initiatePlayback(any()), + ).thenThrow(Exception('Failed')); + + // act & assert + expect(() => repository.initiatePlayback('1'), throwsException); + }); + }); + + group('recordListeningHistory', () { + test('should call remote data source when successful', () async { + // arrange + when( + () => mockRemoteDataSource.recordHistory(any()), + ).thenAnswer((_) async => {}); + + // act + await repository.recordListeningHistory(tHistoryRecord); + + // assert + verify( + () => mockRemoteDataSource.recordHistory(tHistoryRecordModel), + ).called(1); + verifyNever(() => mockLocalDataSource.cacheHistoryRecord(any())); + }); + + test('should cache locally when remote call fails', () async { + // arrange + when( + () => mockRemoteDataSource.recordHistory(any()), + ).thenThrow(Exception('Network Error')); + when( + () => mockLocalDataSource.cacheHistoryRecord(any()), + ).thenAnswer((_) async => {}); + + // act + await repository.recordListeningHistory(tHistoryRecord); + + // assert + verify( + () => mockRemoteDataSource.recordHistory(tHistoryRecordModel), + ).called(1); + verify( + () => mockLocalDataSource.cacheHistoryRecord(tHistoryRecordModel), + ).called(1); + }); + }); + + group('syncPendingHistory', () { + test( + 'should sync all pending records and remove them from local storage', + () async { + // arrange + final records = [tHistoryRecordModel]; + when( + () => mockLocalDataSource.getPendingHistoryRecords(), + ).thenAnswer((_) async => records); + when( + () => mockRemoteDataSource.recordHistory(any()), + ).thenAnswer((_) async => {}); + when( + () => mockLocalDataSource.removeHistoryRecords(any()), + ).thenAnswer((_) async => {}); + + // act + await repository.syncPendingHistory(); + + // assert + verify( + () => mockRemoteDataSource.recordHistory(tHistoryRecordModel), + ).called(1); + verify( + () => mockLocalDataSource.removeHistoryRecords(records), + ).called(1); + }, + ); + + test('should stop syncing on first error', () async { + // arrange + final records = [tHistoryRecordModel, tHistoryRecordModel]; + when( + () => mockLocalDataSource.getPendingHistoryRecords(), + ).thenAnswer((_) async => records); + when( + () => mockRemoteDataSource.recordHistory(any()), + ).thenThrow(Exception('Error')); + when( + () => mockLocalDataSource.removeHistoryRecords(any()), + ).thenAnswer((_) async => {}); + + // act + await repository.syncPendingHistory(); + + // assert + verify( + () => mockRemoteDataSource.recordHistory(tHistoryRecordModel), + ).called(1); + verifyNever(() => mockLocalDataSource.removeHistoryRecords(any())); + }); + + test('should remove only successfully synced records', () async { + // arrange + final records = [ + HistoryRecordModel( + trackId: '1', + playedAt: DateTime.now(), + durationPlayedSeconds: 10, + ), + HistoryRecordModel( + trackId: '2', + playedAt: DateTime.now(), + durationPlayedSeconds: 10, + ), + ]; + when( + () => mockLocalDataSource.getPendingHistoryRecords(), + ).thenAnswer((_) async => records); + + when( + () => mockRemoteDataSource.recordHistory(records[0]), + ).thenAnswer((_) async => {}); + when( + () => mockRemoteDataSource.recordHistory(records[1]), + ).thenThrow(Exception('Error')); + + when( + () => mockLocalDataSource.removeHistoryRecords(any()), + ).thenAnswer((_) async => {}); + + // act + await repository.syncPendingHistory(); + + // assert + verify(() => mockRemoteDataSource.recordHistory(records[0])).called(1); + verify(() => mockRemoteDataSource.recordHistory(records[1])).called(1); + verify( + () => mockLocalDataSource.removeHistoryRecords([records[0]]), + ).called(1); + }); + }); + + group('fetchQueueContext', () { + test('should call remote data source', () async { + // arrange + when( + () => mockRemoteDataSource.fetchQueueContext( + interactionType: any(named: 'interactionType'), + sourceType: any(named: 'sourceType'), + sourceId: any(named: 'sourceId'), + targetUserId: any(named: 'targetUserId'), + ), + ).thenAnswer((_) async => {'data': {}}); + + // act + await repository.fetchQueueContext( + interactionType: 'play', + sourceType: 'playlist', + ); + + // assert + verify( + () => mockRemoteDataSource.fetchQueueContext( + interactionType: 'play', + sourceType: 'playlist', + ), + ).called(1); + }); + }); + + group('syncPlayerState', () { + test('should call remote data source', () async { + // arrange + when( + () => mockRemoteDataSource.syncPlayerState( + trackId: any(named: 'trackId'), + queue: any(named: 'queue'), + positionSeconds: any(named: 'positionSeconds'), + volume: any(named: 'volume'), + ), + ).thenAnswer((_) async => {}); + + // act + await repository.syncPlayerState(trackId: '1', queue: []); + + // assert + verify( + () => mockRemoteDataSource.syncPlayerState(trackId: '1', queue: []), + ).called(1); + }); + }); +} diff --git a/test/features/player/domain/entities/history_record_test.dart b/test/features/player/domain/entities/history_record_test.dart new file mode 100644 index 00000000..9afcf7bb --- /dev/null +++ b/test/features/player/domain/entities/history_record_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/player/domain/entities/history_record.dart'; + +void main() { + group('HistoryRecord', () { + final tPlayedAt = DateTime(2023, 1, 1); + test('should support value equality', () { + expect( + HistoryRecord( + trackId: '1', + playedAt: tPlayedAt, + durationPlayedSeconds: 100, + ), + HistoryRecord( + trackId: '1', + playedAt: tPlayedAt, + durationPlayedSeconds: 100, + ), + ); + }); + + test('props should contain all fields', () { + final record = HistoryRecord( + trackId: '1', + playedAt: tPlayedAt, + durationPlayedSeconds: 100, + ); + expect(record.props, ['1', tPlayedAt, 100]); + }); + }); +} diff --git a/test/features/player/domain/entities/player_state_test.dart b/test/features/player/domain/entities/player_state_test.dart index c471dca2..f28f7b8e 100644 --- a/test/features/player/domain/entities/player_state_test.dart +++ b/test/features/player/domain/entities/player_state_test.dart @@ -1,83 +1,60 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:rythmify/core/domain/entities/track.dart'; import 'package:rythmify/features/player/domain/entities/player_state.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; void main() { final tTrack = Track( - id: 'track-1', - userId: 'user-1', + id: '1', + userId: 'u1', title: 'Test Track', - artist: 'Artist', - audioUrl: 'http://example.com/audio.mp3', + artist: 'Test Artist', + audioUrl: 'https://test.com/audio.mp3', duration: const Duration(minutes: 3), - createdAt: DateTime(2023, 10, 15), + createdAt: DateTime.now(), ); group('AppPlayerState', () { - test('initial state should have correct default values', () { - const state = AppPlayerState(); - - expect(state.status, PlayerStatus.initial); - expect(state.currentTrack, isNull); - expect(state.position, Duration.zero); - expect(state.bufferedPosition, Duration.zero); - expect(state.duration, Duration.zero); - expect(state.isShuffleModeEnabled, false); - expect(state.loopMode, 'off'); + test('should support value equality', () { + expect(const AppPlayerState(), const AppPlayerState()); }); - test('copyWith should replace fields properly', () { + test('copyWith should return a copy with updated values', () { const state = AppPlayerState(); - final updatedState = state.copyWith( status: PlayerStatus.playing, currentTrack: tTrack, position: const Duration(seconds: 10), - bufferedPosition: const Duration(seconds: 20), - duration: const Duration(minutes: 3), - isShuffleModeEnabled: true, - loopMode: 'all', ); expect(updatedState.status, PlayerStatus.playing); expect(updatedState.currentTrack, tTrack); expect(updatedState.position, const Duration(seconds: 10)); - expect(updatedState.bufferedPosition, const Duration(seconds: 20)); - expect(updatedState.duration, const Duration(minutes: 3)); - expect(updatedState.isShuffleModeEnabled, true); - expect(updatedState.loopMode, 'all'); + // Other fields should remain default + expect(updatedState.isShuffleModeEnabled, false); }); - test('copyWith should retain old values if not provided', () { + test('props should contain all fields', () { final state = AppPlayerState( status: PlayerStatus.playing, currentTrack: tTrack, position: const Duration(seconds: 10), + bufferedPosition: const Duration(seconds: 5), + duration: const Duration(seconds: 100), + isShuffleModeEnabled: true, + loopMode: 'one', + queueIndex: 1, ); - final updatedState = state.copyWith(status: PlayerStatus.paused); - - expect(updatedState.status, PlayerStatus.paused); - expect(updatedState.currentTrack, tTrack); - expect(updatedState.position, const Duration(seconds: 10)); - expect(updatedState.bufferedPosition, state.bufferedPosition); - }); - - test('props should contain all fields', () { - const state = AppPlayerState(); - expect(state.props.length, 7); - expect( - state.props, - containsAll([ - state.status, - state.currentTrack, - state.position, - state.bufferedPosition, - state.duration, - state.isShuffleModeEnabled, - state.loopMode, - ]), - ); + expect(state.props, [ + PlayerStatus.playing, + tTrack, + const Duration(seconds: 10), + const Duration(seconds: 5), + const Duration(seconds: 100), + true, + 'one', + 1, + ]); }); }); } diff --git a/test/features/player/domain/entities/queue_item_test.dart b/test/features/player/domain/entities/queue_item_test.dart new file mode 100644 index 00000000..b29e124f --- /dev/null +++ b/test/features/player/domain/entities/queue_item_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/player/domain/entities/queue_item.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; + +void main() { + final tTrack = Track( + id: '1', + userId: 'u1', + title: 'Test Track', + artist: 'Test Artist', + audioUrl: 'https://test.com/audio.mp3', + duration: const Duration(minutes: 3), + createdAt: DateTime(2023, 1, 1), + ); + + group('QueueItem', () { + test('should support value equality', () { + final item1 = QueueItem(track: tTrack, queueItemId: 'qi1'); + final item2 = QueueItem(track: tTrack, queueItemId: 'qi1'); + expect(item1, item2); + }); + + test('copyWith should return a copy with updated values', () { + final item = QueueItem(track: tTrack, queueItemId: 'qi1'); + final updatedItem = item.copyWith( + queueBucket: 'next_up', + isRecommended: true, + ); + + expect(updatedItem.queueBucket, 'next_up'); + expect(updatedItem.isRecommended, true); + expect(updatedItem.queueItemId, 'qi1'); + }); + + test('fromJson should return a valid model', () { + final Map jsonMap = { + 'id': '1', + 'user_id': 'u1', + 'title': 'Test Track', + 'artist': 'Test Artist', + 'audio_url': 'https://test.com/audio.mp3', + 'duration': 180, + 'created_at': '2023-01-01T00:00:00.000', + 'queue_item_id': 'qi1', + 'queue_bucket': 'next_up', + 'source_type': 'album', + 'source_id': 'a1', + 'is_recommended': true, + }; + + final result = QueueItem.fromJson(jsonMap); + + expect(result.track.id, '1'); + expect(result.queueItemId, 'qi1'); + expect(result.queueBucket, 'next_up'); + expect(result.sourceType, 'album'); + expect(result.sourceId, 'a1'); + expect(result.isRecommended, true); + }); + + test('toJson should return a JSON map containing proper data', () { + final item = QueueItem( + track: tTrack, + queueItemId: 'qi1', + queueBucket: 'next_up', + sourceType: 'album', + sourceId: 'a1', + isRecommended: true, + ); + + final result = item.toJson(); + + expect(result['id'], '1'); + expect(result['queue_item_id'], 'qi1'); + expect(result['queue_bucket'], 'next_up'); + expect(result['source_type'], 'album'); + expect(result['source_id'], 'a1'); + expect(result['is_recommended'], true); + }); + }); +} diff --git a/test/features/player/domain/entities/queue_state_test.dart b/test/features/player/domain/entities/queue_state_test.dart new file mode 100644 index 00000000..f95503f8 --- /dev/null +++ b/test/features/player/domain/entities/queue_state_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/player/domain/entities/queue_state.dart'; +import 'package:rythmify/features/player/domain/entities/queue_item.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; + +void main() { + final tTrack = Track( + id: '1', + userId: 'u1', + title: 'Test Track', + artist: 'Test Artist', + audioUrl: 'https://test.com/audio.mp3', + duration: const Duration(minutes: 3), + createdAt: DateTime(2023, 1, 1), + ); + + final tQueueItem = QueueItem(track: tTrack, queueItemId: 'qi1'); + + group('QueueContext', () { + test('should support value equality', () { + expect( + const QueueContext(type: QueueSource.playlist, sourceId: 'p1'), + const QueueContext(type: QueueSource.playlist, sourceId: 'p1'), + ); + }); + }); + + group('AppQueueState', () { + test('should support value equality', () { + expect(const AppQueueState(), const AppQueueState()); + }); + + test('copyWith should return a copy with updated values', () { + const state = AppQueueState(); + final updatedState = state.copyWith( + currentTrack: tQueueItem, + isShuffled: true, + isLoadingRecommendations: true, + ); + + expect(updatedState.currentTrack, tQueueItem); + expect(updatedState.isShuffled, true); + expect(updatedState.isLoadingRecommendations, true); + }); + + test('props should contain all fields', () { + const context = QueueContext(type: QueueSource.playlist, sourceId: 'p1'); + final state = AppQueueState( + context: context, + history: [tQueueItem], + currentTrack: tQueueItem, + upcomingTracks: [tQueueItem], + unShuffledUpcomingTracks: [tQueueItem], + isShuffled: true, + hasMore: true, + currentPage: 2, + isLoadingRecommendations: true, + ); + + expect(state.props, [ + context, + [tQueueItem], + tQueueItem, + [tQueueItem], + [tQueueItem], + true, + true, + 2, + true, + ]); + }); + }); +} diff --git a/test/features/player/domain/usecases/append_tracks_usecase_test.dart b/test/features/player/domain/usecases/append_tracks_usecase_test.dart new file mode 100644 index 00000000..a56d9db9 --- /dev/null +++ b/test/features/player/domain/usecases/append_tracks_usecase_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/append_tracks_usecase.dart'; +import 'test_helper.dart'; + +void main() { + late AppendTracksUseCase useCase; + late MockAudioRepository mockAudioRepository; + + setUp(() { + mockAudioRepository = MockAudioRepository(); + useCase = AppendTracksUseCase(mockAudioRepository); + }); + + test('should call appendTracks on the repository', () async { + // arrange + when( + () => mockAudioRepository.appendTracks(any()), + ).thenAnswer((_) async => {}); + + // act + await useCase(dummyTracks); + + // assert + verify(() => mockAudioRepository.appendTracks(dummyTracks)).called(1); + verifyNoMoreInteractions(mockAudioRepository); + }); + + test('should throw exception when repository fails', () async { + // arrange + when( + () => mockAudioRepository.appendTracks(any()), + ).thenThrow(Exception('Failed to append tracks')); + + // act & assert + expect(() => useCase(dummyTracks), throwsException); + verify(() => mockAudioRepository.appendTracks(dummyTracks)).called(1); + }); +} diff --git a/test/features/player/domain/usecases/fetch_queue_context_usecase_test.dart b/test/features/player/domain/usecases/fetch_queue_context_usecase_test.dart new file mode 100644 index 00000000..8e49b5eb --- /dev/null +++ b/test/features/player/domain/usecases/fetch_queue_context_usecase_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/fetch_queue_context_usecase.dart'; +import 'test_helper.dart'; + +void main() { + late FetchQueueContextUseCase useCase; + late MockPlaybackRepository mockPlaybackRepository; + + setUp(() { + mockPlaybackRepository = MockPlaybackRepository(); + useCase = FetchQueueContextUseCase(mockPlaybackRepository); + }); + + const tInteractionType = 'play'; + const tSourceType = 'playlist'; + const tSourceId = 'playlist123'; + const tTargetUserId = 'user456'; + final tResult = {'tracks': []}; + + test('should call fetchQueueContext on the repository', () async { + // arrange + when( + () => mockPlaybackRepository.fetchQueueContext( + interactionType: any(named: 'interactionType'), + sourceType: any(named: 'sourceType'), + sourceId: any(named: 'sourceId'), + targetUserId: any(named: 'targetUserId'), + ), + ).thenAnswer((_) async => tResult); + + // act + final result = await useCase( + interactionType: tInteractionType, + sourceType: tSourceType, + sourceId: tSourceId, + targetUserId: tTargetUserId, + ); + + // assert + expect(result, tResult); + verify( + () => mockPlaybackRepository.fetchQueueContext( + interactionType: tInteractionType, + sourceType: tSourceType, + sourceId: tSourceId, + targetUserId: tTargetUserId, + ), + ).called(1); + verifyNoMoreInteractions(mockPlaybackRepository); + }); + + test('should throw exception when repository fails', () async { + // arrange + when( + () => mockPlaybackRepository.fetchQueueContext( + interactionType: any(named: 'interactionType'), + sourceType: any(named: 'sourceType'), + sourceId: any(named: 'sourceId'), + targetUserId: any(named: 'targetUserId'), + ), + ).thenThrow(Exception('Failed to fetch queue context')); + + // act & assert + expect( + () => useCase(interactionType: tInteractionType, sourceType: tSourceType), + throwsException, + ); + }); +} diff --git a/test/features/player/domain/usecases/get_player_state_stream_usecase_test.dart b/test/features/player/domain/usecases/get_player_state_stream_usecase_test.dart new file mode 100644 index 00000000..df766e7f --- /dev/null +++ b/test/features/player/domain/usecases/get_player_state_stream_usecase_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/get_player_state_stream_usecase.dart'; +import 'test_helper.dart'; + +void main() { + late GetPlayerStateStreamUseCase useCase; + late MockAudioRepository mockAudioRepository; + + setUp(() { + mockAudioRepository = MockAudioRepository(); + useCase = GetPlayerStateStreamUseCase(mockAudioRepository); + }); + + test( + 'should return a stream of AppPlayerState from the repository', + () async { + // arrange + final tStream = Stream.fromIterable([dummyPlayerState]); + when( + () => mockAudioRepository.playerStateStream, + ).thenAnswer((_) => tStream); + + // act + final result = useCase(); + + // assert + expect(result, tStream); + verify(() => mockAudioRepository.playerStateStream).called(1); + verifyNoMoreInteractions(mockAudioRepository); + }, + ); +} diff --git a/test/features/player/domain/usecases/get_related_tracks_usecase_test.dart b/test/features/player/domain/usecases/get_related_tracks_usecase_test.dart new file mode 100644 index 00000000..895f2260 --- /dev/null +++ b/test/features/player/domain/usecases/get_related_tracks_usecase_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/get_related_tracks_usecase.dart'; +import 'package:rythmify/features/playlist/data/datasources/playlist_remote_datasource.dart'; +import 'package:rythmify/features/playlist/domain/entities/playlist_track.dart'; + +import 'test_helper.dart'; + +class MockPlaylistRemoteDatasource extends Mock + implements PlaylistRemoteDatasource {} + +void main() { + late GetRelatedTracksUseCase useCase; + late MockPlaylistRemoteDatasource mockDatasource; + + setUp(() { + mockDatasource = MockPlaylistRemoteDatasource(); + useCase = GetRelatedTracksUseCase(mockDatasource); + }); + + const tTrackId = '1'; + final tPlaylistTracks = [ + const PlaylistTrack( + id: '1', + title: 'Track 1', + artistName: 'Artist 1', + duration: Duration(seconds: 210), + playCount: 100, + position: 1, + audioUrl: 'https://example.com/audio1.mp3', + ), + ]; + + test( + 'should get related tracks from datasource and convert to tracks', + () async { + // arrange + when( + () => mockDatasource.fetchRelatedTracks( + any(), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => tPlaylistTracks); + + // act + final result = await useCase(tTrackId); + + // assert + expect(result.length, 1); + expect(result.first.id, tPlaylistTracks.first.id); + expect(result.first.title, tPlaylistTracks.first.title); + expect(result.first.artist, tPlaylistTracks.first.artistName); + verify(() => mockDatasource.fetchRelatedTracks(tTrackId)).called(1); + verifyNoMoreInteractions(mockDatasource); + }, + ); + + test('should throw an exception when datasource fails', () async { + // arrange + when( + () => + mockDatasource.fetchRelatedTracks(any(), limit: any(named: 'limit')), + ).thenThrow(Exception()); + + // act + final call = useCase(tTrackId); + + // assert + expect(() => call, throwsA(isA())); + verify(() => mockDatasource.fetchRelatedTracks(tTrackId)).called(1); + verifyNoMoreInteractions(mockDatasource); + }); +} diff --git a/test/features/player/domain/usecases/initiate_playback_usecase_test.dart b/test/features/player/domain/usecases/initiate_playback_usecase_test.dart new file mode 100644 index 00000000..a16da9a3 --- /dev/null +++ b/test/features/player/domain/usecases/initiate_playback_usecase_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/initiate_playback_usecase.dart'; + +import 'test_helper.dart'; + +void main() { + late InitiatePlaybackUseCase useCase; + late MockPlaybackRepository mockRepository; + + setUp(() { + mockRepository = MockPlaybackRepository(); + useCase = InitiatePlaybackUseCase(mockRepository); + }); + + const tTrackId = '1'; + const tResponse = 'success'; + + test( + 'should initiate playback and return response from repository', + () async { + // arrange + when( + () => mockRepository.initiatePlayback(any()), + ).thenAnswer((_) async => tResponse); + + // act + final result = await useCase(tTrackId); + + // assert + expect(result, tResponse); + verify(() => mockRepository.initiatePlayback(tTrackId)).called(1); + verifyNoMoreInteractions(mockRepository); + }, + ); + + test('should throw an exception when repository fails', () async { + // arrange + when( + () => mockRepository.initiatePlayback(any()), + ).thenAnswer((_) async => throw Exception()); + + // act & assert + expect(useCase(tTrackId), throwsA(isA())); + verify(() => mockRepository.initiatePlayback(tTrackId)).called(1); + verifyNoMoreInteractions(mockRepository); + }); +} diff --git a/test/features/player/domain/usecases/load_queue_usecase_test.dart b/test/features/player/domain/usecases/load_queue_usecase_test.dart new file mode 100644 index 00000000..585b6ae3 --- /dev/null +++ b/test/features/player/domain/usecases/load_queue_usecase_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/load_queue_usecase.dart'; +import 'test_helper.dart'; + +void main() { + late LoadQueueUseCase useCase; + late MockAudioRepository mockAudioRepository; + + setUp(() { + mockAudioRepository = MockAudioRepository(); + useCase = LoadQueueUseCase(mockAudioRepository); + }); + + test('should load queue with default initialIndex', () async { + // arrange + when( + () => mockAudioRepository.loadQueue( + any(), + initialIndex: any(named: 'initialIndex'), + ), + ).thenAnswer((_) async => {}); + + // act + await useCase(dummyTracks); + + // assert + verify( + () => mockAudioRepository.loadQueue(dummyTracks, initialIndex: 0), + ).called(1); + verifyNoMoreInteractions(mockAudioRepository); + }); + + test('should load queue with custom initialIndex', () async { + // arrange + const tIndex = 1; + when( + () => mockAudioRepository.loadQueue( + any(), + initialIndex: any(named: 'initialIndex'), + ), + ).thenAnswer((_) async => {}); + + // act + await useCase(dummyTracks, initialIndex: tIndex); + + // assert + verify( + () => mockAudioRepository.loadQueue(dummyTracks, initialIndex: tIndex), + ).called(1); + verifyNoMoreInteractions(mockAudioRepository); + }); + + test('should throw exception when repository fails', () async { + // arrange + when( + () => mockAudioRepository.loadQueue( + any(), + initialIndex: any(named: 'initialIndex'), + ), + ).thenThrow(Exception('Failed to load queue')); + + // act & assert + expect(() => useCase(dummyTracks), throwsException); + verify( + () => mockAudioRepository.loadQueue(dummyTracks, initialIndex: 0), + ).called(1); + }); +} diff --git a/test/features/player/domain/usecases/play_pause_usecase_test.dart b/test/features/player/domain/usecases/play_pause_usecase_test.dart new file mode 100644 index 00000000..911b7868 --- /dev/null +++ b/test/features/player/domain/usecases/play_pause_usecase_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/play_pause_usecase.dart'; +import 'test_helper.dart'; + +void main() { + late MockAudioRepository mockAudioRepository; + late PlayTrackUseCase playUseCase; + late PauseTrackUseCase pauseUseCase; + + setUp(() { + mockAudioRepository = MockAudioRepository(); + playUseCase = PlayTrackUseCase(mockAudioRepository); + pauseUseCase = PauseTrackUseCase(mockAudioRepository); + }); + + group('PlayTrackUseCase', () { + test('should call play on the repository', () async { + // arrange + when(() => mockAudioRepository.play()).thenAnswer((_) async => {}); + + // act + await playUseCase(); + + // assert + verify(() => mockAudioRepository.play()).called(1); + verifyNoMoreInteractions(mockAudioRepository); + }); + + test('should throw exception when play fails', () async { + // arrange + when( + () => mockAudioRepository.play(), + ).thenThrow(Exception('Failed to play')); + + // act & assert + expect(() => playUseCase(), throwsException); + verify(() => mockAudioRepository.play()).called(1); + }); + }); + + group('PauseTrackUseCase', () { + test('should call pause on the repository', () async { + // arrange + when(() => mockAudioRepository.pause()).thenAnswer((_) async => {}); + + // act + await pauseUseCase(); + + // assert + verify(() => mockAudioRepository.pause()).called(1); + verifyNoMoreInteractions(mockAudioRepository); + }); + + test('should throw exception when pause fails', () async { + // arrange + when( + () => mockAudioRepository.pause(), + ).thenThrow(Exception('Failed to pause')); + + // act & assert + expect(() => pauseUseCase(), throwsException); + verify(() => mockAudioRepository.pause()).called(1); + }); + }); +} diff --git a/test/features/player/domain/usecases/player_usecases_test.dart b/test/features/player/domain/usecases/player_usecases_test.dart deleted file mode 100644 index 3fe8bb58..00000000 --- a/test/features/player/domain/usecases/player_usecases_test.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:rythmify/core/domain/entities/track.dart'; -import 'package:rythmify/features/player/domain/entities/player_state.dart'; -import 'package:rythmify/features/player/domain/repositories/audio_repository.dart'; -import 'package:rythmify/features/player/domain/usecases/get_player_state_stream_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/load_queue_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/play_pause_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/seek_position_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/skip_track_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/update_track_info_usecase.dart'; - -class MockAudioRepository extends Mock implements AudioRepository {} - -void main() { - late MockAudioRepository mockRepository; - - final tTrack = Track( - id: 'track-1', - userId: 'user-1', - title: 'Test Track', - artist: 'Artist', - audioUrl: 'http://example.com/audio.mp3', - duration: const Duration(minutes: 3), - createdAt: DateTime.now(), - ); - - setUp(() { - mockRepository = MockAudioRepository(); - }); - - group('GetPlayerStateStreamUseCase', () { - late GetPlayerStateStreamUseCase usecase; - - setUp(() { - usecase = GetPlayerStateStreamUseCase(mockRepository); - }); - - test('should return stream from repository', () { - final tStream = Stream.value(const AppPlayerState()); - when(() => mockRepository.playerStateStream).thenAnswer((_) => tStream); - - final result = usecase(); - - expect(result, tStream); - verify(() => mockRepository.playerStateStream).called(1); - }); - }); - - group('LoadQueueUseCase', () { - late LoadQueueUseCase usecase; - - setUp(() { - usecase = LoadQueueUseCase(mockRepository); - }); - - test('should call loadQueue on repository', () async { - when( - () => mockRepository.loadQueue( - any(), - initialIndex: any(named: 'initialIndex'), - ), - ).thenAnswer((_) async {}); - - await usecase([tTrack], initialIndex: 1); - - verify( - () => mockRepository.loadQueue([tTrack], initialIndex: 1), - ).called(1); - }); - }); - - group('PlayTrackUseCase', () { - late PlayTrackUseCase usecase; - - setUp(() { - usecase = PlayTrackUseCase(mockRepository); - }); - - test('should call play on repository', () async { - when(() => mockRepository.play()).thenAnswer((_) async {}); - - await usecase(); - - verify(() => mockRepository.play()).called(1); - }); - }); - - group('PauseTrackUseCase', () { - late PauseTrackUseCase usecase; - - setUp(() { - usecase = PauseTrackUseCase(mockRepository); - }); - - test('should call pause on repository', () async { - when(() => mockRepository.pause()).thenAnswer((_) async {}); - - await usecase(); - - verify(() => mockRepository.pause()).called(1); - }); - }); - - group('SeekPositionUseCase', () { - late SeekPositionUseCase usecase; - - setUp(() { - usecase = SeekPositionUseCase(mockRepository); - }); - - test('should call seek on repository', () async { - when(() => mockRepository.seek(any())).thenAnswer((_) async {}); - - await usecase(const Duration(seconds: 10)); - - verify(() => mockRepository.seek(const Duration(seconds: 10))).called(1); - }); - }); - - group('SkipToNextUseCase', () { - late SkipToNextUseCase usecase; - - setUp(() { - usecase = SkipToNextUseCase(mockRepository); - }); - - test('should call skipToNext on repository', () async { - when(() => mockRepository.skipToNext()).thenAnswer((_) async {}); - - await usecase(); - - verify(() => mockRepository.skipToNext()).called(1); - }); - }); - - group('SkipToPreviousUseCase', () { - late SkipToPreviousUseCase usecase; - - setUp(() { - usecase = SkipToPreviousUseCase(mockRepository); - }); - - test('should call skipToPrevious on repository', () async { - when(() => mockRepository.skipToPrevious()).thenAnswer((_) async {}); - - await usecase(); - - verify(() => mockRepository.skipToPrevious()).called(1); - }); - }); - - group('UpdateTrackInfoUseCase', () { - late UpdateTrackInfoUseCase usecase; - - setUp(() { - usecase = UpdateTrackInfoUseCase(mockRepository); - }); - - test('should call updateTrackInfo on repository', () async { - when( - () => mockRepository.updateTrackInfo(any(), any()), - ).thenAnswer((_) async {}); - - await usecase('track-1', tTrack); - - verify(() => mockRepository.updateTrackInfo('track-1', tTrack)).called(1); - }); - }); -} diff --git a/test/features/player/domain/usecases/record_listening_history_usecase_test.dart b/test/features/player/domain/usecases/record_listening_history_usecase_test.dart new file mode 100644 index 00000000..9183f869 --- /dev/null +++ b/test/features/player/domain/usecases/record_listening_history_usecase_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/record_listening_history_usecase.dart'; +import 'package:rythmify/features/player/domain/entities/history_record.dart'; +import 'test_helper.dart'; + +void main() { + late RecordListeningHistoryUseCase useCase; + late MockPlaybackRepository mockPlaybackRepository; + + setUp(() { + mockPlaybackRepository = MockPlaybackRepository(); + useCase = RecordListeningHistoryUseCase(mockPlaybackRepository); + registerFallbackValue(dummyHistoryRecord); + }); + + test( + 'should call recordListeningHistory on the repository when duration > 0', + () async { + // arrange + when( + () => mockPlaybackRepository.recordListeningHistory(any()), + ).thenAnswer((_) async => {}); + + // act + await useCase(dummyHistoryRecord); + + // assert + verify( + () => mockPlaybackRepository.recordListeningHistory(dummyHistoryRecord), + ).called(1); + verifyNoMoreInteractions(mockPlaybackRepository); + }, + ); + + test('should NOT call recordListeningHistory when duration <= 0', () async { + // arrange + final invalidRecord = HistoryRecord( + trackId: '1', + playedAt: DateTime.now(), + durationPlayedSeconds: 0, + ); + + // act + await useCase(invalidRecord); + + // assert + verifyNever(() => mockPlaybackRepository.recordListeningHistory(any())); + }); + + test('should throw exception when repository fails', () async { + // arrange + when( + () => mockPlaybackRepository.recordListeningHistory(any()), + ).thenThrow(Exception('Failed to record history')); + + // act & assert + expect(() => useCase(dummyHistoryRecord), throwsException); + verify( + () => mockPlaybackRepository.recordListeningHistory(dummyHistoryRecord), + ).called(1); + }); +} diff --git a/test/features/player/domain/usecases/seek_position_usecase_test.dart b/test/features/player/domain/usecases/seek_position_usecase_test.dart new file mode 100644 index 00000000..439067a2 --- /dev/null +++ b/test/features/player/domain/usecases/seek_position_usecase_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/seek_position_usecase.dart'; + +import 'test_helper.dart'; + +void main() { + setUpAll(() { + registerTestFallbackValues(); + }); + + late SeekPositionUseCase useCase; + late MockAudioRepository mockRepository; + + setUp(() { + mockRepository = MockAudioRepository(); + useCase = SeekPositionUseCase(mockRepository); + }); + + const tPosition = Duration(seconds: 30); + + test('should seek to position using repository', () async { + // arrange + when(() => mockRepository.seek(any())).thenAnswer((_) async => {}); + + // act + await useCase(tPosition); + + // assert + verify(() => mockRepository.seek(tPosition)).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should throw an exception when repository fails', () async { + // arrange + when( + () => mockRepository.seek(any()), + ).thenAnswer((_) async => throw Exception()); + + // act & assert + expect(useCase(tPosition), throwsA(isA())); + verify(() => mockRepository.seek(tPosition)).called(1); + verifyNoMoreInteractions(mockRepository); + }); +} diff --git a/test/features/player/domain/usecases/skip_track_usecase_test.dart b/test/features/player/domain/usecases/skip_track_usecase_test.dart new file mode 100644 index 00000000..4b2e9b0e --- /dev/null +++ b/test/features/player/domain/usecases/skip_track_usecase_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/skip_track_usecase.dart'; + +import 'test_helper.dart'; + +void main() { + late SkipToNextUseCase skipToNextUseCase; + late SkipToPreviousUseCase skipToPreviousUseCase; + late SkipToIndexUseCase skipToIndexUseCase; + late MockAudioRepository mockRepository; + + setUp(() { + mockRepository = MockAudioRepository(); + skipToNextUseCase = SkipToNextUseCase(mockRepository); + skipToPreviousUseCase = SkipToPreviousUseCase(mockRepository); + skipToIndexUseCase = SkipToIndexUseCase(mockRepository); + }); + + group('SkipToNextUseCase', () { + test('should skip to next using repository', () async { + // arrange + when(() => mockRepository.skipToNext()).thenAnswer((_) async => {}); + + // act + await skipToNextUseCase(); + + // assert + verify(() => mockRepository.skipToNext()).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should throw an exception when repository fails', () async { + // arrange + when(() => mockRepository.skipToNext()).thenThrow(Exception()); + + // act + final call = skipToNextUseCase(); + + // assert + expect(() => call, throwsA(isA())); + verify(() => mockRepository.skipToNext()).called(1); + }); + }); + + group('SkipToPreviousUseCase', () { + test('should skip to previous using repository', () async { + // arrange + when(() => mockRepository.skipToPrevious()).thenAnswer((_) async => {}); + + // act + await skipToPreviousUseCase(); + + // assert + verify(() => mockRepository.skipToPrevious()).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should throw an exception when repository fails', () async { + // arrange + when(() => mockRepository.skipToPrevious()).thenThrow(Exception()); + + // act + final call = skipToPreviousUseCase(); + + // assert + expect(() => call, throwsA(isA())); + verify(() => mockRepository.skipToPrevious()).called(1); + }); + }); + + group('SkipToIndexUseCase', () { + const tIndex = 2; + test('should skip to index using repository', () async { + // arrange + when(() => mockRepository.skipToIndex(any())).thenAnswer((_) async => {}); + + // act + await skipToIndexUseCase(tIndex); + + // assert + verify(() => mockRepository.skipToIndex(tIndex)).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should throw an exception when repository fails', () async { + // arrange + when(() => mockRepository.skipToIndex(any())).thenThrow(Exception()); + + // act + final call = skipToIndexUseCase(tIndex); + + // assert + expect(() => call, throwsA(isA())); + verify(() => mockRepository.skipToIndex(tIndex)).called(1); + }); + }); +} diff --git a/test/features/player/domain/usecases/sync_history_usecase_test.dart b/test/features/player/domain/usecases/sync_history_usecase_test.dart new file mode 100644 index 00000000..0755bffd --- /dev/null +++ b/test/features/player/domain/usecases/sync_history_usecase_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/sync_history_usecase.dart'; + +import 'test_helper.dart'; + +void main() { + late SyncHistoryUseCase useCase; + late MockPlaybackRepository mockRepository; + + setUp(() { + mockRepository = MockPlaybackRepository(); + useCase = SyncHistoryUseCase(mockRepository); + }); + + test('should sync pending history using repository', () async { + // arrange + when(() => mockRepository.syncPendingHistory()).thenAnswer((_) async => {}); + + // act + await useCase(); + + // assert + verify(() => mockRepository.syncPendingHistory()).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should throw an exception when repository fails', () async { + // arrange + when( + () => mockRepository.syncPendingHistory(), + ).thenAnswer((_) async => throw Exception()); + + // act & assert + expect(useCase(), throwsA(isA())); + verify(() => mockRepository.syncPendingHistory()).called(1); + verifyNoMoreInteractions(mockRepository); + }); +} diff --git a/test/features/player/domain/usecases/sync_player_state_usecase_test.dart b/test/features/player/domain/usecases/sync_player_state_usecase_test.dart new file mode 100644 index 00000000..98838108 --- /dev/null +++ b/test/features/player/domain/usecases/sync_player_state_usecase_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/sync_player_state_usecase.dart'; +import 'test_helper.dart'; + +void main() { + late SyncPlayerStateUseCase useCase; + late MockPlaybackRepository mockRepository; + + setUp(() { + mockRepository = MockPlaybackRepository(); + useCase = SyncPlayerStateUseCase(mockRepository); + }); + + const tTrackId = 'track_1'; + final tQueue = [ + {'id': 'track_1', 'title': 'Track 1'}, + {'id': 'track_2', 'title': 'Track 2'}, + ]; + const tPositionSeconds = 45; + const tVolume = 0.8; + + test('should call syncPlayerState on the repository', () async { + // arrange + when( + () => mockRepository.syncPlayerState( + trackId: any(named: 'trackId'), + queue: any(named: 'queue'), + positionSeconds: any(named: 'positionSeconds'), + volume: any(named: 'volume'), + ), + ).thenAnswer((_) async => {}); + + // act + await useCase( + trackId: tTrackId, + queue: tQueue, + positionSeconds: tPositionSeconds, + volume: tVolume, + ); + + // assert + verify( + () => mockRepository.syncPlayerState( + trackId: tTrackId, + queue: tQueue, + positionSeconds: tPositionSeconds, + volume: tVolume, + ), + ).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should throw an exception when repository call fails', () async { + // arrange + when( + () => mockRepository.syncPlayerState( + trackId: any(named: 'trackId'), + queue: any(named: 'queue'), + positionSeconds: any(named: 'positionSeconds'), + volume: any(named: 'volume'), + ), + ).thenThrow(Exception('Sync failed')); + + // act & assert + expect( + () => useCase( + trackId: tTrackId, + queue: tQueue, + positionSeconds: tPositionSeconds, + volume: tVolume, + ), + throwsException, + ); + }); +} diff --git a/test/features/player/domain/usecases/test_helper.dart b/test/features/player/domain/usecases/test_helper.dart new file mode 100644 index 00000000..f5c26eb7 --- /dev/null +++ b/test/features/player/domain/usecases/test_helper.dart @@ -0,0 +1,52 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/repositories/audio_repository.dart'; +import 'package:rythmify/features/player/domain/repositories/playback_repository.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:rythmify/features/player/domain/entities/history_record.dart'; +import 'package:rythmify/features/player/domain/entities/player_state.dart'; + +class MockAudioRepository extends Mock implements AudioRepository {} + +class MockPlaybackRepository extends Mock implements PlaybackRepository {} + +class FakeTrack extends Fake implements Track {} + +void registerTestFallbackValues() { + registerFallbackValue(Duration.zero); + registerFallbackValue(FakeTrack()); +} + +final dummyTrack = Track( + id: '1', + userId: 'user1', + title: 'Track 1', + artist: 'Artist 1', + audioUrl: 'https://example.com/audio1.mp3', + duration: const Duration(seconds: 210), + createdAt: DateTime.now(), +); + +final dummyTrack2 = Track( + id: '2', + userId: 'user1', + title: 'Track 2', + artist: 'Artist 1', + audioUrl: 'https://example.com/audio2.mp3', + duration: const Duration(seconds: 180), + createdAt: DateTime.now(), +); + +final dummyTracks = [dummyTrack, dummyTrack2]; + +final dummyHistoryRecord = HistoryRecord( + trackId: '1', + playedAt: DateTime.now(), + durationPlayedSeconds: 30, +); + +const dummyPlayerState = AppPlayerState( + status: PlayerStatus.playing, + currentTrack: null, + position: Duration(seconds: 10), + duration: Duration(seconds: 100), +); diff --git a/test/features/player/domain/usecases/update_track_info_usecase_test.dart b/test/features/player/domain/usecases/update_track_info_usecase_test.dart new file mode 100644 index 00000000..9c6a5544 --- /dev/null +++ b/test/features/player/domain/usecases/update_track_info_usecase_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/usecases/update_track_info_usecase.dart'; +import 'test_helper.dart'; + +void main() { + setUpAll(() { + registerTestFallbackValues(); + }); + + late UpdateTrackInfoUseCase useCase; + late MockAudioRepository mockRepository; + + setUp(() { + mockRepository = MockAudioRepository(); + useCase = UpdateTrackInfoUseCase(mockRepository); + }); + + const tId = '1'; + + test('should call updateTrackInfo on the repository', () async { + // arrange + when( + () => mockRepository.updateTrackInfo(any(), any()), + ).thenAnswer((_) async => {}); + + // act + await useCase(tId, dummyTrack); + + // assert + verify(() => mockRepository.updateTrackInfo(tId, dummyTrack)).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should throw an exception when repository call fails', () async { + // arrange + when( + () => mockRepository.updateTrackInfo(any(), any()), + ).thenThrow(Exception('Update failed')); + + // act & assert + expect(() => useCase(tId, dummyTrack), throwsException); + }); +} diff --git a/test/features/player/presentation/pages/full_player_page_test.dart b/test/features/player/presentation/pages/full_player_page_test.dart new file mode 100644 index 00000000..f1780d3e --- /dev/null +++ b/test/features/player/presentation/pages/full_player_page_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/entities/player_state.dart'; +import 'package:rythmify/features/player/domain/entities/queue_state.dart'; +import 'package:rythmify/features/player/domain/entities/queue_item.dart'; +import 'package:rythmify/features/player/presentation/providers/player_provider.dart'; +import 'package:rythmify/features/player/presentation/providers/queue_provider.dart'; +import 'package:rythmify/features/player/presentation/providers/ad_provider.dart'; +import 'package:rythmify/features/player/presentation/pages/full_player_page.dart'; +import 'package:rythmify/features/premium/presentation/providers/premium_provider.dart'; +import 'package:rythmify/features/track/presentation/providers/track_provider.dart'; +import 'package:rythmify/features/track/presentation/providers/track_interaction_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; +import 'package:rythmify/features/notifications/presentation/providers/follow_state_provider.dart'; +import 'package:rythmify/features/comments/presentation/providers/floating_comments_provider.dart'; +import '../presentation_test_helper.dart'; + +class MockQueueNotifier extends Notifier + with Mock + implements QueueNotifier {} + +class MockAdNotifier extends Notifier + with Mock + implements AdNotifier {} + +class MockFollowStateNotifier extends StateNotifier> + with Mock + implements FollowStateNotifier { + MockFollowStateNotifier() : super({}); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockQueueNotifier mockQueueNotifier; + late MockAdNotifier mockAdNotifier; + late MockTrackInteractionNotifier mockTrackInteractionNotifier; + + setUpAll(() { + registerPlayerFallbackValues(); + }); + + setUp(() { + mockQueueNotifier = MockQueueNotifier(); + mockAdNotifier = MockAdNotifier(); + mockTrackInteractionNotifier = MockTrackInteractionNotifier(); + + // Default stub for ad notifier build + when( + () => mockAdNotifier.build(), + ).thenReturn(const AdState(trackCount: 0, showAd: false)); + }); + + Widget createWidget({ + required AppPlayerState playerState, + required AppQueueState queueState, + AdState adState = const AdState(trackCount: 0, showAd: false), + bool isPremium = false, + }) { + // Override the build stub for the specific test case + when(() => mockAdNotifier.build()).thenReturn(adState); + + return ProviderScope( + overrides: [ + playerStateProvider.overrideWith( + () => MockPlayerNotifierWrapper(playerState), + ), + queueStateProvider.overrideWith( + () => MockQueueNotifierWrapper(queueState, mockQueueNotifier), + ), + adProvider.overrideWith(() => mockAdNotifier), + isPremiumProvider.overrideWithValue(isPremium), + trackDetailsProvider( + testTrack.id, + ).overrideWithValue(AsyncValue.data(testTrack)), + trackInteractionProvider.overrideWithValue( + mockTrackInteractionNotifier, + ), + authProvider.overrideWith(() => MockAuthNotifier()), + followStateProvider.overrideWith((ref) => MockFollowStateNotifier()), + floatingCommentsProvider( + testTrack.id, + ).overrideWithValue(const AsyncValue.data({})), + ], + child: MaterialApp(home: FullPlayerPage()), + ); + } + + group('FullPlayerPage', () { + testWidgets('renders "No track playing" when currentTrack is null', ( + tester, + ) async { + await tester.pumpWidget( + createWidget( + playerState: const AppPlayerState(), + queueState: const AppQueueState(), + ), + ); + expect(find.text("No track playing"), findsOneWidget); + }); + + testWidgets('renders player content when track is playing', (tester) async { + final currentItem = QueueItem(track: testTrack, queueItemId: 'current'); + final playerState = AppPlayerState(currentTrack: testTrack); + final queueState = AppQueueState(currentTrack: currentItem); + + await tester.pumpWidget( + createWidget(playerState: playerState, queueState: queueState), + ); + + expect(find.byType(PageView), findsOneWidget); + expect(find.text(testTrack.title), findsOneWidget); + expect(find.text(testTrack.artist), findsOneWidget); + }); + + testWidgets('shows ad overlay when showAd is true and not premium', ( + tester, + ) async { + final currentItem = QueueItem(track: testTrack, queueItemId: 'current'); + final playerState = AppPlayerState(currentTrack: testTrack); + final queueState = AppQueueState(currentTrack: currentItem); + final adState = const AdState(trackCount: 0, showAd: true); + + await tester.pumpWidget( + createWidget( + playerState: playerState, + queueState: queueState, + adState: adState, + ), + ); + + await tester.pump(); + + // Verification by finding any widget that might be in the overlay stack + // Since showAd is true, the Stack in FullPlayerPage will have AdBannerOverlay + expect(find.byType(Stack), findsAtLeast(1)); + }); + + testWidgets('swiping PageView calls skipToIndex', (tester) async { + final currentItem = QueueItem(track: testTrack, queueItemId: 'current'); + final upcomingItem = QueueItem( + track: testTrack.copyWith(id: 'upcoming'), + queueItemId: 'upcoming', + ); + final playerState = AppPlayerState(currentTrack: testTrack); + final queueState = AppQueueState( + currentTrack: currentItem, + upcomingTracks: [upcomingItem], + ); + + when(() => mockQueueNotifier.skipToIndex(any())).thenReturn(null); + + await tester.pumpWidget( + createWidget(playerState: playerState, queueState: queueState), + ); + + await tester.drag(find.byType(PageView), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + verify(() => mockQueueNotifier.skipToIndex(1)).called(1); + }); + }); +} + +class MockPlayerNotifierWrapper extends PlayerNotifier { + final AppPlayerState _state; + MockPlayerNotifierWrapper(this._state); + @override + AppPlayerState build() => _state; +} + +class MockQueueNotifierWrapper extends QueueNotifier { + final AppQueueState _state; + final MockQueueNotifier _mock; + MockQueueNotifierWrapper(this._state, this._mock); + @override + AppQueueState build() => _state; + @override + void skipToIndex(int index) => _mock.skipToIndex(index); +} + +class MockAdNotifierWrapper extends AdNotifier { + final AdState _state; + final MockAdNotifier _mock; + MockAdNotifierWrapper(this._state, this._mock); + @override + AdState build() => _state; +} + +class MockAuthNotifier extends AuthNotifier { + @override + AuthState build() => const AuthUnauthenticated(); +} diff --git a/test/features/player/presentation/pages/queue_screen_test.dart b/test/features/player/presentation/pages/queue_screen_test.dart new file mode 100644 index 00000000..9bbfe548 --- /dev/null +++ b/test/features/player/presentation/pages/queue_screen_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/entities/queue_state.dart'; +import 'package:rythmify/features/player/domain/entities/queue_item.dart'; +import 'package:rythmify/features/player/presentation/providers/queue_provider.dart'; +import 'package:rythmify/features/player/presentation/pages/queue_screen.dart'; +import '../presentation_test_helper.dart'; + +class MockQueueNotifier extends Notifier + with Mock + implements QueueNotifier {} + +void main() { + late MockQueueNotifier mockQueueNotifier; + + setUpAll(() { + registerPlayerFallbackValues(); + }); + + setUp(() { + mockQueueNotifier = MockQueueNotifier(); + }); + + Widget createWidget({required AppQueueState state}) { + return ProviderScope( + overrides: [ + queueStateProvider.overrideWith( + () => MockQueueNotifierWrapper(state, mockQueueNotifier), + ), + ], + child: const MaterialApp(home: QueueScreen()), + ); + } + + group('QueueScreen', () { + testWidgets('renders empty state when queue is empty', (tester) async { + await tester.pumpWidget(createWidget(state: const AppQueueState())); + expect(find.text('Queue is empty'), findsOneWidget); + }); + + testWidgets('renders sections when queue is not empty', (tester) async { + final currentItem = QueueItem(track: testTrack, queueItemId: 'current'); + final historyItem = QueueItem( + track: testTrack.copyWith(id: 'history', title: 'History Track'), + queueItemId: 'history', + ); + final upcomingItem = QueueItem( + track: testTrack.copyWith(id: 'upcoming', title: 'Upcoming Track'), + queueItemId: 'upcoming', + ); + + final state = AppQueueState( + currentTrack: currentItem, + history: [historyItem], + upcomingTracks: [upcomingItem], + ); + + await tester.pumpWidget(createWidget(state: state)); + + expect(find.text('Recently Played'), findsOneWidget); + expect(find.text('Now Playing'), findsOneWidget); + expect(find.text('Next Up'), findsOneWidget); + + expect(find.text('History Track'), findsOneWidget); + expect(find.text(testTrack.title), findsOneWidget); + expect(find.text('Upcoming Track'), findsOneWidget); + }); + + testWidgets('calls toggleShuffle when shuffle button is pressed', ( + tester, + ) async { + final state = AppQueueState( + currentTrack: QueueItem(track: testTrack, queueItemId: 'current'), + ); + + when(() => mockQueueNotifier.toggleShuffle()).thenReturn(null); + + await tester.pumpWidget(createWidget(state: state)); + + await tester.tap( + find.byKey(const Key('player_queue_shuffle_icon_button')), + ); + + verify(() => mockQueueNotifier.toggleShuffle()).called(1); + }); + + testWidgets('calls playFromQueue when upcoming track is tapped', ( + tester, + ) async { + final upcomingItem = QueueItem( + track: testTrack.copyWith(id: 'upcoming'), + queueItemId: 'upcoming', + ); + final state = AppQueueState( + currentTrack: QueueItem(track: testTrack, queueItemId: 'current'), + upcomingTracks: [upcomingItem], + ); + + when(() => mockQueueNotifier.playFromQueue(any())).thenReturn(null); + + await tester.pumpWidget(createWidget(state: state)); + + await tester.tap( + find.text('Test Track').last, + ); // Tapping the upcoming one + + verify(() => mockQueueNotifier.playFromQueue(0)).called(1); + }); + }); +} + +class MockQueueNotifierWrapper extends QueueNotifier { + final AppQueueState _state; + final MockQueueNotifier _mock; + + MockQueueNotifierWrapper(this._state, this._mock); + + @override + AppQueueState build() => _state; + + @override + void toggleShuffle() => _mock.toggleShuffle(); + + @override + void playFromQueue(int index) => _mock.playFromQueue(index); + + @override + void reorder(int oldIndex, int newIndex) => _mock.reorder(oldIndex, newIndex); +} diff --git a/test/features/player/presentation/presentation_test_helper.dart b/test/features/player/presentation/presentation_test_helper.dart new file mode 100644 index 00000000..13649491 --- /dev/null +++ b/test/features/player/presentation/presentation_test_helper.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:rythmify/features/player/domain/repositories/audio_repository.dart'; +import 'package:rythmify/features/player/domain/usecases/play_pause_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/skip_track_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/seek_position_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/get_player_state_stream_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/load_queue_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/update_track_info_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/initiate_playback_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/record_listening_history_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/sync_history_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/fetch_queue_context_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/sync_player_state_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/get_related_tracks_usecase.dart'; +import 'package:rythmify/features/player/domain/usecases/append_tracks_usecase.dart'; +import 'package:rythmify/features/track/domain/usecases/get_track_details.dart'; +import 'package:rythmify/features/track/domain/usecases/get_waveform.dart'; +import 'package:rythmify/features/player/data/datasources/audio_handler.dart'; +import 'package:rythmify/features/track/presentation/providers/track_interaction_provider.dart'; +import 'package:rythmify/features/premium/presentation/providers/premium_provider.dart'; +import 'package:rythmify/features/player/domain/entities/player_state.dart'; +import 'package:rythmify/features/player/domain/entities/history_record.dart'; + +class MockAudioRepository extends Mock implements AudioRepository {} + +class MockRythmifyAudioHandler extends Mock implements RythmifyAudioHandler {} + +class MockPlayTrackUseCase extends Mock implements PlayTrackUseCase {} + +class MockPauseTrackUseCase extends Mock implements PauseTrackUseCase {} + +class MockSkipToNextUseCase extends Mock implements SkipToNextUseCase {} + +class MockSkipToPreviousUseCase extends Mock implements SkipToPreviousUseCase {} + +class MockSeekPositionUseCase extends Mock implements SeekPositionUseCase {} + +class MockGetPlayerStateStreamUseCase extends Mock + implements GetPlayerStateStreamUseCase {} + +class MockLoadQueueUseCase extends Mock implements LoadQueueUseCase {} + +class MockUpdateTrackInfoUseCase extends Mock + implements UpdateTrackInfoUseCase {} + +class MockInitiatePlaybackUseCase extends Mock + implements InitiatePlaybackUseCase {} + +class MockRecordListeningHistoryUseCase extends Mock + implements RecordListeningHistoryUseCase {} + +class MockSyncHistoryUseCase extends Mock implements SyncHistoryUseCase {} + +class MockFetchQueueContextUseCase extends Mock + implements FetchQueueContextUseCase {} + +class MockSyncPlayerStateUseCase extends Mock + implements SyncPlayerStateUseCase {} + +class MockGetRelatedTracksUseCase extends Mock + implements GetRelatedTracksUseCase {} + +class MockAppendTracksUseCase extends Mock implements AppendTracksUseCase {} + +class MockGetTrackDetails extends Mock implements GetTrackDetails {} + +class MockGetWaveform extends Mock implements GetWaveform {} + +class MockTrackInteractionNotifier extends Mock + implements TrackInteractionNotifier {} + +class MockPremiumNotifier extends Mock implements PremiumNotifier {} + +class FakeAppPlayerState extends Fake implements AppPlayerState {} + +class FakeTrack extends Fake implements Track {} + +class FakeHistoryRecord extends Fake implements HistoryRecord {} + +void registerPlayerFallbackValues() { + registerFallbackValue(FakeAppPlayerState()); + registerFallbackValue(FakeTrack()); + registerFallbackValue(FakeHistoryRecord()); + registerFallbackValue(Duration.zero); +} + +final testTrack = Track( + id: 'track1', + userId: 'user1', + title: 'Test Track', + artist: 'Test Artist', + audioUrl: 'https://example.com/audio.mp3', + coverImage: 'https://example.com/cover.jpg', + duration: const Duration(seconds: 180), + createdAt: DateTime.now(), +); + +final testTrackWithWaveform = testTrack.copyWith( + waveformData: [0.1, 0.5, 0.2, 0.8], +); diff --git a/test/features/player/presentation/providers/ad_provider_test.dart b/test/features/player/presentation/providers/ad_provider_test.dart new file mode 100644 index 00000000..3ce9324e --- /dev/null +++ b/test/features/player/presentation/providers/ad_provider_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:rythmify/features/player/presentation/providers/ad_provider.dart'; +import 'package:rythmify/features/premium/presentation/providers/premium_provider.dart'; +import 'package:fake_async/fake_async.dart'; + +class MockPremiumNotifier extends PremiumNotifier with Mock {} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + ProviderContainer createContainer({ + bool isPremium = false, + bool isInitialized = true, + }) { + return ProviderContainer( + overrides: [ + isPremiumProvider.overrideWithValue(isPremium), + premiumProvider.overrideWith( + () => MockPremiumNotifierWrapper( + PremiumState(isInitialized: isInitialized, subscription: null), + ), + ), + ], + ); + } + + group('AdNotifier', () { + test('initial state has trackCount 0 and showAd false', () async { + final container = createContainer(); + expect(container.read(adProvider).trackCount, 0); + expect(container.read(adProvider).showAd, false); + }); + + test('incrementTrackCount increments count when not premium', () async { + final container = createContainer(); + + // Wait for build to complete (it's async due to SharedPreferences) + await container.read(adProvider.notifier).build(); + + await container.read(adProvider.notifier).incrementTrackCount(); + + expect(container.read(adProvider).trackCount, 1); + }); + + test( + 'incrementTrackCount shows ad and resets count at threshold', + () async { + final container = createContainer(); + await container.read(adProvider.notifier).build(); + + // Threshold is 5 + for (int i = 0; i < 4; i++) { + await container.read(adProvider.notifier).incrementTrackCount(); + } + expect(container.read(adProvider).trackCount, 4); + expect(container.read(adProvider).showAd, false); + + await container.read(adProvider.notifier).incrementTrackCount(); + + expect(container.read(adProvider).trackCount, 0); + expect(container.read(adProvider).showAd, true); + }, + ); + + test('incrementTrackCount does nothing when premium', () async { + final container = createContainer(isPremium: true); + await container.read(adProvider.notifier).build(); + + await container.read(adProvider.notifier).incrementTrackCount(); + + expect(container.read(adProvider).trackCount, 0); + expect(container.read(adProvider).showAd, false); + }); + + test('incrementTrackCount clears state if user becomes premium', () async { + final container = createContainer(isPremium: false); + await container.read(adProvider.notifier).build(); + + // Increment count + await container.read(adProvider.notifier).incrementTrackCount(); + expect(container.read(adProvider).trackCount, 1); + + // Now set premium to true in a new container or override + final premiumContainer = ProviderContainer( + overrides: [ + isPremiumProvider.overrideWithValue(true), + premiumProvider.overrideWith( + () => MockPremiumNotifierWrapper( + const PremiumState(isInitialized: true, subscription: null), + ), + ), + ], + ); + await premiumContainer.read(adProvider.notifier).build(); + + // Set count manually via SharedPreferences to simulate state before being premium + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('ad_track_count', 3); + await premiumContainer.read(adProvider.notifier).incrementTrackCount(); + + expect(premiumContainer.read(adProvider).trackCount, 0); + expect(premiumContainer.read(adProvider).showAd, false); + expect(prefs.getInt('ad_track_count'), 0); + }); + + test('_waitForPremiumInitialization timeouts after 3 seconds', () async { + final container = createContainer(isInitialized: false); + + fakeAsync((async) { + // Trigger build + container.read(adProvider); + async.flushMicrotasks(); + + bool completed = false; + bool? result; + // Accessing the private method via incrementTrackCount + container.read(adProvider.notifier).incrementTrackCount().then((_) { + completed = true; + }); + + // Advance time to 4 seconds - should be completed by timeout (3s) + async.elapse(const Duration(seconds: 4)); + + // Use flushMicrotasks again to ensure then() is called + async.flushMicrotasks(); + + expect(completed, true); + // Even if it timed out, it should still have proceeded to check isPremium + // In our container isPremium is false, so it should have incremented count from 0 to 1 + expect(container.read(adProvider).trackCount, 1); + }); + }); + + test('dismissAd sets showAd to false', () async { + final container = createContainer(); + await container.read(adProvider.notifier).build(); + + // Reach threshold + for (int i = 0; i < 5; i++) { + await container.read(adProvider.notifier).incrementTrackCount(); + } + expect(container.read(adProvider).showAd, true); + + container.read(adProvider.notifier).dismissAd(); + + expect(container.read(adProvider).showAd, false); + }); + }); +} + +class MockPremiumNotifierWrapper extends PremiumNotifier { + final PremiumState _initialState; + MockPremiumNotifierWrapper(this._initialState); + + @override + PremiumState build() => _initialState; +} diff --git a/test/features/player/presentation/providers/dependency_providers_test.dart b/test/features/player/presentation/providers/dependency_providers_test.dart new file mode 100644 index 00000000..34212982 --- /dev/null +++ b/test/features/player/presentation/providers/dependency_providers_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/presentation/providers/player_dependency_providers.dart'; +import 'package:rythmify/features/player/data/datasources/audio_handler.dart'; +import 'package:rythmify/features/playlist/presentation/providers/playlist_provider.dart'; +import 'package:rythmify/features/playlist/data/datasources/playlist_remote_datasource.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart'; + +class MockRythmifyAudioHandler extends Mock implements RythmifyAudioHandler {} + +class MockPlaylistRemoteDatasource extends Mock + implements PlaylistRemoteDatasource {} + +void main() { + test('dependency providers can be instantiated', () { + final mockAudioHandler = MockRythmifyAudioHandler(); + final mockPlaylistDataSource = MockPlaylistRemoteDatasource(); + + // Use proper subjects and avoid const issues with PlaybackState + when( + () => mockAudioHandler.playbackEventStream, + ).thenAnswer((_) => Stream.empty()); + when( + () => mockAudioHandler.mediaItem, + ).thenAnswer((_) => BehaviorSubject()); + when( + () => mockAudioHandler.currentIndexStream, + ).thenAnswer((_) => Stream.empty()); + when( + () => mockAudioHandler.playbackState, + ).thenAnswer((_) => BehaviorSubject.seeded(PlaybackState())); + when(() => mockAudioHandler.currentQueue).thenReturn([]); + + final container = ProviderContainer( + overrides: [ + audioHandlerProvider.overrideWithValue(mockAudioHandler), + playlistDatasourceProvider.overrideWithValue(mockPlaylistDataSource), + ], + ); + + expect(container.read(audioRepositoryProvider), isNotNull); + expect(container.read(playbackRemoteDataSourceProvider), isNotNull); + expect(container.read(playbackLocalDataSourceProvider), isNotNull); + expect(container.read(playbackRepositoryProvider), isNotNull); + expect(container.read(playTrackUseCaseProvider), isNotNull); + expect(container.read(pauseTrackUseCaseProvider), isNotNull); + expect(container.read(skipNextUseCaseProvider), isNotNull); + expect(container.read(skipPrevUseCaseProvider), isNotNull); + expect(container.read(seekPositionUseCaseProvider), isNotNull); + expect(container.read(getPlayerStateStreamUseCaseProvider), isNotNull); + expect(container.read(loadQueueUseCaseProvider), isNotNull); + expect(container.read(updateTrackInfoUseCaseProvider), isNotNull); + expect(container.read(initiatePlaybackUseCaseProvider), isNotNull); + expect(container.read(recordListeningHistoryUseCaseProvider), isNotNull); + expect(container.read(syncHistoryUseCaseProvider), isNotNull); + expect(container.read(fetchQueueContextUseCaseProvider), isNotNull); + expect(container.read(syncPlayerStateUseCaseProvider), isNotNull); + expect(container.read(getRelatedTracksUseCaseProvider), isNotNull); + expect(container.read(appendTracksUseCaseProvider), isNotNull); + }); +} diff --git a/test/features/player/presentation/providers/player_provider_test.dart b/test/features/player/presentation/providers/player_provider_test.dart index aca600fe..b5818e30 100644 --- a/test/features/player/presentation/providers/player_provider_test.dart +++ b/test/features/player/presentation/providers/player_provider_test.dart @@ -2,268 +2,441 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:rythmify/features/player/domain/entities/player_state.dart'; -import 'package:rythmify/features/player/domain/usecases/get_player_state_stream_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/load_queue_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/play_pause_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/seek_position_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/skip_track_usecase.dart'; -import 'package:rythmify/features/player/domain/usecases/update_track_info_usecase.dart'; -import 'package:rythmify/features/player/presentation/providers/player_dependency_providers.dart'; import 'package:rythmify/features/player/presentation/providers/player_provider.dart'; -import 'package:rythmify/features/track/domain/usecases/get_track_details.dart'; -import 'package:rythmify/features/track/domain/usecases/get_waveform.dart'; +import 'package:rythmify/features/player/presentation/providers/player_dependency_providers.dart'; import 'package:rythmify/features/track/presentation/providers/track_dependency_providers.dart'; - -class MockGetPlayerStateStreamUseCase extends Mock - implements GetPlayerStateStreamUseCase {} - -class MockLoadQueueUseCase extends Mock implements LoadQueueUseCase {} - -class MockPlayTrackUseCase extends Mock implements PlayTrackUseCase {} - -class MockPauseTrackUseCase extends Mock implements PauseTrackUseCase {} - -class MockSkipToNextUseCase extends Mock implements SkipToNextUseCase {} - -class MockSkipToPreviousUseCase extends Mock implements SkipToPreviousUseCase {} - -class MockSeekPositionUseCase extends Mock implements SeekPositionUseCase {} - -class MockUpdateTrackInfoUseCase extends Mock - implements UpdateTrackInfoUseCase {} - -class MockGetTrackDetailsUseCase extends Mock implements GetTrackDetails {} - -class MockGetWaveformUseCase extends Mock implements GetWaveform {} +import 'package:rythmify/features/player/presentation/providers/ad_provider.dart'; +import '../presentation_test_helper.dart'; + +// Use a wrapper to safely mock Notifiers in Riverpod +class MockAdNotifier extends Mock implements AdNotifier {} + +class MockAdNotifierWrapper extends AdNotifier { + final AdState _state; + final MockAdNotifier _mock; + MockAdNotifierWrapper(this._state, this._mock); + @override + AdState build() => _state; + @override + Future incrementTrackCount() => _mock.incrementTrackCount(); +} void main() { - late ProviderContainer container; - late MockGetPlayerStateStreamUseCase mockGetPlayerStateStream; - late MockLoadQueueUseCase mockLoadQueue; - late MockPlayTrackUseCase mockPlayTrack; - late MockPauseTrackUseCase mockPauseTrack; - late MockSkipToNextUseCase mockSkipNext; - late MockSkipToPreviousUseCase mockSkipPrev; - late MockSeekPositionUseCase mockSeekPosition; - late MockUpdateTrackInfoUseCase mockUpdateTrackInfo; - late MockGetTrackDetailsUseCase mockGetTrackDetails; - late MockGetWaveformUseCase mockGetWaveform; - - late StreamController streamController; - - final tTrack = Track( - id: 'track-1', - userId: 'user-1', - title: 'Test Track', - artist: 'Artist', - audioUrl: 'http://example.com/audio.mp3', - duration: const Duration(minutes: 3), - createdAt: DateTime.now(), - ); + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAudioRepository mockAudioRepository; + late MockPlayTrackUseCase mockPlayTrackUseCase; + late MockPauseTrackUseCase mockPauseTrackUseCase; + late MockSkipToNextUseCase mockSkipNextUseCase; + late MockSkipToPreviousUseCase mockSkipPrevUseCase; + late MockSeekPositionUseCase mockSeekPositionUseCase; + late MockGetPlayerStateStreamUseCase mockGetPlayerStateStreamUseCase; + late MockLoadQueueUseCase mockLoadQueueUseCase; + late MockUpdateTrackInfoUseCase mockUpdateTrackInfoUseCase; + late MockInitiatePlaybackUseCase mockInitiatePlaybackUseCase; + late MockRecordListeningHistoryUseCase mockRecordListeningHistoryUseCase; + late MockSyncHistoryUseCase mockSyncHistoryUseCase; + late MockGetTrackDetails mockGetTrackDetails; + late MockGetWaveform mockGetWaveform; + late MockAdNotifier mockAdNotifier; + + late StreamController playerStateController; + + setUpAll(() { + registerPlayerFallbackValues(); + SharedPreferences.setMockInitialValues({}); + }); setUp(() { - mockGetPlayerStateStream = MockGetPlayerStateStreamUseCase(); - mockLoadQueue = MockLoadQueueUseCase(); - mockPlayTrack = MockPlayTrackUseCase(); - mockPauseTrack = MockPauseTrackUseCase(); - mockSkipNext = MockSkipToNextUseCase(); - mockSkipPrev = MockSkipToPreviousUseCase(); - mockSeekPosition = MockSeekPositionUseCase(); - mockUpdateTrackInfo = MockUpdateTrackInfoUseCase(); - mockGetTrackDetails = MockGetTrackDetailsUseCase(); - mockGetWaveform = MockGetWaveformUseCase(); - - streamController = StreamController.broadcast(); + mockAudioRepository = MockAudioRepository(); + mockPlayTrackUseCase = MockPlayTrackUseCase(); + mockPauseTrackUseCase = MockPauseTrackUseCase(); + mockSkipNextUseCase = MockSkipToNextUseCase(); + mockSkipPrevUseCase = MockSkipToPreviousUseCase(); + mockSeekPositionUseCase = MockSeekPositionUseCase(); + mockGetPlayerStateStreamUseCase = MockGetPlayerStateStreamUseCase(); + mockLoadQueueUseCase = MockLoadQueueUseCase(); + mockUpdateTrackInfoUseCase = MockUpdateTrackInfoUseCase(); + mockInitiatePlaybackUseCase = MockInitiatePlaybackUseCase(); + mockRecordListeningHistoryUseCase = MockRecordListeningHistoryUseCase(); + mockSyncHistoryUseCase = MockSyncHistoryUseCase(); + mockGetTrackDetails = MockGetTrackDetails(); + mockGetWaveform = MockGetWaveform(); + mockAdNotifier = MockAdNotifier(); + + playerStateController = StreamController.broadcast(); + + when( + () => mockGetPlayerStateStreamUseCase.call(), + ).thenAnswer((_) => playerStateController.stream); + when(() => mockSyncHistoryUseCase.call()).thenAnswer((_) async {}); + when( + () => mockRecordListeningHistoryUseCase.call(any()), + ).thenAnswer((_) async {}); + when(() => mockPlayTrackUseCase.call()).thenAnswer((_) async {}); + when(() => mockPauseTrackUseCase.call()).thenAnswer((_) async {}); + when(() => mockSkipNextUseCase.call()).thenAnswer((_) async {}); + when(() => mockSkipPrevUseCase.call()).thenAnswer((_) async {}); + when(() => mockSeekPositionUseCase.call(any())).thenAnswer((_) async {}); + when( + () => mockLoadQueueUseCase.call( + any(), + initialIndex: any(named: 'initialIndex'), + ), + ).thenAnswer((_) async {}); when( - () => mockGetPlayerStateStream.call(), - ).thenAnswer((_) => streamController.stream); + () => mockInitiatePlaybackUseCase.call(any()), + ).thenAnswer((_) async => 'url'); + when( + () => mockUpdateTrackInfoUseCase.call(any(), any()), + ).thenAnswer((_) async {}); + when( + () => mockGetTrackDetails.call(any()), + ).thenAnswer((_) async => testTrack); + when(() => mockGetWaveform.call(any())).thenAnswer((_) async => [0.1, 0.5]); - container = ProviderContainer( + // Stub the mock instance + when(() => mockAdNotifier.incrementTrackCount()).thenAnswer((_) async {}); + }); + + tearDown(() { + playerStateController.close(); + }); + + ProviderContainer createContainer() { + final container = ProviderContainer( overrides: [ + audioRepositoryProvider.overrideWithValue(mockAudioRepository), + playTrackUseCaseProvider.overrideWithValue(mockPlayTrackUseCase), + pauseTrackUseCaseProvider.overrideWithValue(mockPauseTrackUseCase), + skipNextUseCaseProvider.overrideWithValue(mockSkipNextUseCase), + skipPrevUseCaseProvider.overrideWithValue(mockSkipPrevUseCase), + seekPositionUseCaseProvider.overrideWithValue(mockSeekPositionUseCase), getPlayerStateStreamUseCaseProvider.overrideWithValue( - mockGetPlayerStateStream, + mockGetPlayerStateStreamUseCase, + ), + loadQueueUseCaseProvider.overrideWithValue(mockLoadQueueUseCase), + updateTrackInfoUseCaseProvider.overrideWithValue( + mockUpdateTrackInfoUseCase, + ), + initiatePlaybackUseCaseProvider.overrideWithValue( + mockInitiatePlaybackUseCase, ), - loadQueueUseCaseProvider.overrideWithValue(mockLoadQueue), - playTrackUseCaseProvider.overrideWithValue(mockPlayTrack), - pauseTrackUseCaseProvider.overrideWithValue(mockPauseTrack), - skipNextUseCaseProvider.overrideWithValue(mockSkipNext), - skipPrevUseCaseProvider.overrideWithValue(mockSkipPrev), - seekPositionUseCaseProvider.overrideWithValue(mockSeekPosition), - updateTrackInfoUseCaseProvider.overrideWithValue(mockUpdateTrackInfo), + recordListeningHistoryUseCaseProvider.overrideWithValue( + mockRecordListeningHistoryUseCase, + ), + syncHistoryUseCaseProvider.overrideWithValue(mockSyncHistoryUseCase), getTrackDetailsUseCaseProvider.overrideWithValue(mockGetTrackDetails), getWaveformUseCaseProvider.overrideWithValue(mockGetWaveform), + adProvider.overrideWith( + () => MockAdNotifierWrapper( + const AdState(trackCount: 0, showAd: false), + mockAdNotifier, + ), + ), ], ); - }); - - tearDown(() { - streamController.close(); - container.dispose(); - }); + return container; + } - group('SeekDragNotifier', () { - test('initial state is null', () { - final state = container.read(seekDragPositionProvider); - expect(state, isNull); + group('PlayerNotifier', () { + test('initial state is AppPlayerState()', () { + final container = createContainer(); + expect(container.read(playerStateProvider), const AppPlayerState()); }); - test('setPosition updates state', () { - final notifier = container.read(seekDragPositionProvider.notifier); + test('syncs with domain stream', () async { + final container = createContainer(); + container.listen(playerStateProvider, (_, __) {}); - notifier.setPosition(const Duration(seconds: 10)); - expect( - container.read(seekDragPositionProvider), - const Duration(seconds: 10), + final newState = AppPlayerState( + currentTrack: testTrack, + status: PlayerStatus.playing, ); - notifier.setPosition(null); - expect(container.read(seekDragPositionProvider), isNull); + playerStateController.add(newState); + await pumpEventQueue(); + + expect(container.read(playerStateProvider), newState); }); - }); - group('PlayerNotifier', () { - test('initial state uses stream to update itself', () async { - final sub = container.listen(playerStateProvider, (prev, next) {}); + test('loadAndPlayQueue calls correct use cases', () async { + final container = createContainer(); + final tracks = [testTrack]; - streamController.add(const AppPlayerState(status: PlayerStatus.playing)); - await Future.delayed(Duration.zero); + await container + .read(playerStateProvider.notifier) + .loadAndPlayQueue(tracks); - final state = container.read(playerStateProvider); - expect(state.status, PlayerStatus.playing); + verify(() => mockInitiatePlaybackUseCase.call(testTrack.id)).called(1); + verify(() => mockLoadQueueUseCase.call(any(), initialIndex: 0)).called(1); + verify(() => mockPlayTrackUseCase.call()).called(1); + }); - sub.close(); + test('addToQueueNext appends track when nothing playing', () async { + final container = createContainer(); + await container + .read(playerStateProvider.notifier) + .addToQueueNext(testTrack); + verify(() => mockLoadQueueUseCase.call(any(), initialIndex: 0)).called(1); }); - test('ignores stream updates when dragging', () async { - final sub = container.listen(playerStateProvider, (prev, next) {}); - final notifier = container.read(playerStateProvider.notifier); + test('addToQueueNext inserts track after current when playing', () async { + final container = createContainer(); + container.listen(playerStateProvider, (_, __) {}); - notifier.setDragging(true); + // Load initial track + await container.read(playerStateProvider.notifier).loadAndPlayQueue([ + testTrack, + ]); - streamController.add(const AppPlayerState(status: PlayerStatus.playing)); - await Future.delayed(Duration.zero); + // Update state to reflect current track + playerStateController.add( + AppPlayerState(currentTrack: testTrack, status: PlayerStatus.playing), + ); + await pumpEventQueue(); - final state = container.read(playerStateProvider); - expect(state.status, PlayerStatus.initial); // Unchanged + final nextTrack = testTrack.copyWith(id: 'next'); + await container + .read(playerStateProvider.notifier) + .addToQueueNext(nextTrack); - sub.close(); + verify(() => mockInitiatePlaybackUseCase.call(nextTrack.id)).called(1); + // loadQueueUseCase called once in loadAndPlayQueue and once in addToQueueNext + verify(() => mockLoadQueueUseCase.call(any(), initialIndex: 0)).called(2); }); - test('loadAndPlayQueue calls correct use cases', () async { + test('addToQueueLast appends track when playing', () async { + final container = createContainer(); + container.listen(playerStateProvider, (_, __) {}); + + // Load initial track + await container.read(playerStateProvider.notifier).loadAndPlayQueue([ + testTrack, + ]); + + // Update state to reflect current track + playerStateController.add( + AppPlayerState(currentTrack: testTrack, status: PlayerStatus.playing), + ); + await pumpEventQueue(); + + final lastTrack = testTrack.copyWith(id: 'last'); + await container + .read(playerStateProvider.notifier) + .addToQueueLast(lastTrack); + + verify(() => mockInitiatePlaybackUseCase.call(lastTrack.id)).called(1); + verify(() => mockLoadQueueUseCase.call(any(), initialIndex: 0)).called(2); + }); + + test('playOptimistic initiates and loads', () async { + final container = createContainer(); + await container + .read(playerStateProvider.notifier) + .playOptimistic(testTrack); + verify( + () => mockInitiatePlaybackUseCase.call(testTrack.id), + ).called(2); // once in playOptimistic, once in loadAndPlayQueue + verify(() => mockLoadQueueUseCase.call(any(), initialIndex: 0)).called(1); + }); + + test('_updateTrackInBackground handles errors gracefully', () async { + final container = createContainer(); + container.listen(playerStateProvider, (_, __) {}); + + final failingTrack = testTrack.copyWith(id: 'failing_track'); + when( - () => - mockLoadQueue.call(any(), initialIndex: any(named: 'initialIndex')), - ).thenAnswer((_) async {}); - when(() => mockPlayTrack.call()).thenAnswer((_) async {}); + () => mockGetTrackDetails.call('failing_track'), + ).thenThrow(Exception('Details fail')); + when( + () => mockGetWaveform.call('failing_track'), + ).thenThrow(Exception('Waveform fail')); - final notifier = container.read(playerStateProvider.notifier); - await notifier.loadAndPlayQueue([tTrack], initialIndex: 1); + playerStateController.add(AppPlayerState(currentTrack: failingTrack)); + await pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 100)); - verify(() => mockLoadQueue.call([tTrack], initialIndex: 1)).called(1); - verify(() => mockPlayTrack.call()).called(1); + // Should not call updateTrackInfoUseCase if both fail + verifyNever( + () => mockUpdateTrackInfoUseCase.call('failing_track', any()), + ); }); - test( - 'playOptimistic loads queue and updates track in background', - () async { - when( - () => mockLoadQueue.call( - any(), - initialIndex: any(named: 'initialIndex'), - ), - ).thenAnswer((_) async {}); - when(() => mockPlayTrack.call()).thenAnswer((_) async {}); - - when( - () => mockGetTrackDetails.call(any()), - ).thenAnswer((_) async => tTrack); - when( - () => mockGetWaveform.call(any()), - ).thenAnswer((_) async => [0.1, 0.2]); - when( - () => mockUpdateTrackInfo.call(any(), any()), - ).thenAnswer((_) async {}); - - final notifier = container.read(playerStateProvider.notifier); - await notifier.playOptimistic(tTrack); - - verify(() => mockLoadQueue.call([tTrack], initialIndex: 0)).called(1); - verify(() => mockPlayTrack.call()).called(1); - - // The background task needs time to run - await Future.delayed(Duration.zero); - - verify(() => mockGetTrackDetails.call('track-1')).called(1); - verify(() => mockGetWaveform.call('track-1')).called(1); - - final updatedTrack = tTrack.copyWith(waveformData: [0.1, 0.2]); - verify( - () => mockUpdateTrackInfo.call('track-1', updatedTrack), - ).called(1); - }, - ); + test('_recordCurrentSession records history when time has elapsed', () async { + final container = createContainer(); + container.listen(playerStateProvider, (_, __) {}); - test('togglePlayPause calls pause if playing', () { - final notifier = container.read(playerStateProvider.notifier); + // 1. Start session + playerStateController.add( + AppPlayerState(currentTrack: testTrack, status: PlayerStatus.playing), + ); + await pumpEventQueue(); - // Simulate stream update to change state to playing - streamController.add(const AppPlayerState(status: PlayerStatus.playing)); + // 2. Wait for time to elapse (using real time here as it's easier than fakeAsync for Notifier) + await Future.delayed(const Duration(seconds: 1)); - notifier.togglePlayPause(); + // 3. Change track to trigger recording + playerStateController.add( + AppPlayerState( + currentTrack: testTrack.copyWith(id: 'new_track'), + status: PlayerStatus.playing, + ), + ); + await pumpEventQueue(); - verify(() => mockPauseTrack.call()).called(1); + verify(() => mockRecordListeningHistoryUseCase.call(any())).called(1); }); - test('skipToNext calls skipNextUseCase', () { - when(() => mockSkipNext.call()).thenAnswer((_) async {}); + test('_updateTrackInBackground updates metadata if changed', () async { + final container = createContainer(); + container.listen(playerStateProvider, (_, __) {}); + + playerStateController.add(AppPlayerState(currentTrack: testTrack)); + await pumpEventQueue(); + + final updatedTrack = testTrack.copyWith(artistUsername: 'new_user'); + when( + () => mockGetTrackDetails.call(any()), + ).thenAnswer((_) async => updatedTrack); - final notifier = container.read(playerStateProvider.notifier); - notifier.skipToNext(); + // Trigger by a stream event with same track but no waveform to trigger _updateTrackInBackground + playerStateController.add(AppPlayerState(currentTrack: testTrack)); + await pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 100)); - verify(() => mockSkipNext.call()).called(1); + verify( + () => mockUpdateTrackInfoUseCase.call(testTrack.id, any()), + ).called(1); }); - test('skipToPrevious calls skipPrevUseCase', () { - when(() => mockSkipPrev.call()).thenAnswer((_) async {}); + test('skipToAbsoluteIndex resolves URL JIT if missing', () async { + final container = createContainer(); + final trackNoUrl = testTrack.copyWith(audioUrl: '', streamUrl: ''); + + when(() => mockAudioRepository.currentQueue).thenReturn([trackNoUrl]); + when(() => mockAudioRepository.skipToIndex(0)).thenAnswer((_) async {}); + when( + () => mockAudioRepository.updateTrackInfo(any(), any()), + ).thenAnswer((_) async {}); + when( + () => mockInitiatePlaybackUseCase.call(trackNoUrl.id), + ).thenAnswer((_) async => 'resolved-url'); + + // Populate internal _queue + await container.read(playerStateProvider.notifier).loadAndPlayQueue([ + trackNoUrl, + ]); - final notifier = container.read(playerStateProvider.notifier); - notifier.skipToPrevious(); + await container.read(playerStateProvider.notifier).skipToAbsoluteIndex(0); - verify(() => mockSkipPrev.call()).called(1); + verify( + () => mockInitiatePlaybackUseCase.call(trackNoUrl.id), + ).called(2); // once in loadAndPlayQueue, once in skipToAbsoluteIndex + verify( + () => mockAudioRepository.updateTrackInfo(trackNoUrl.id, any()), + ).called(1); + verify(() => mockAudioRepository.skipToIndex(0)).called(1); }); - test('seek calls seekPositionUseCase', () { - when(() => mockSeekPosition.call(any())).thenAnswer((_) async {}); + test('updateNativeQueue with newIndex calls loadQueue', () async { + final container = createContainer(); + final tracks = [testTrack]; + when( + () => mockAudioRepository.loadQueue( + any(), + initialIndex: any(named: 'initialIndex'), + ), + ).thenAnswer((_) async {}); - final notifier = container.read(playerStateProvider.notifier); - notifier.seek(const Duration(seconds: 10)); + await container + .read(playerStateProvider.notifier) + .updateNativeQueue(tracks, newIndex: 0); verify( - () => mockSeekPosition.call(const Duration(seconds: 10)), + () => mockAudioRepository.loadQueue(tracks, initialIndex: 0), ).called(1); }); - test('updatePosition updates local state position', () { - final notifier = container.read(playerStateProvider.notifier); - notifier.updatePosition(const Duration(seconds: 15)); + test('updateNativeQueue without newIndex calls updateQueue', () async { + final container = createContainer(); + final tracks = [testTrack]; + when( + () => mockAudioRepository.updateQueue(any()), + ).thenAnswer((_) async {}); - expect( - container.read(playerStateProvider).position, - const Duration(seconds: 15), - ); + await container + .read(playerStateProvider.notifier) + .updateNativeQueue(tracks); + + verify(() => mockAudioRepository.updateQueue(tracks)).called(1); + }); + + test('moveTrack removes and inserts and calls repository', () async { + final container = createContainer(); + when( + () => mockAudioRepository.moveTrack(any(), any()), + ).thenAnswer((_) async {}); + + // Populate internal _queue + await container.read(playerStateProvider.notifier).loadAndPlayQueue([ + testTrack, + testTrack.copyWith(id: 't2'), + ]); + + await container.read(playerStateProvider.notifier).moveTrack(0, 1); + verify(() => mockAudioRepository.moveTrack(0, 1)).called(1); }); - test('stopPlayback pauses and seeks to zero', () async { - when(() => mockPauseTrack.call()).thenAnswer((_) async {}); - when(() => mockSeekPosition.call(any())).thenAnswer((_) async {}); + test('updatePosition updates state and sets local seek time', () { + final container = createContainer(); + const position = Duration(seconds: 30); + container.read(playerStateProvider.notifier).updatePosition(position); + expect(container.read(playerStateProvider).position, position); + }); + + test('setDragging updates internal flag', () { + final container = createContainer(); + container.read(playerStateProvider.notifier).setDragging(true); + }); - final notifier = container.read(playerStateProvider.notifier); - await notifier.stopPlayback(); + test('stopPlayback records history and pauses', () async { + final container = createContainer(); + container.listen(playerStateProvider, (_, __) {}); - verify(() => mockPauseTrack.call()).called(1); - verify(() => mockSeekPosition.call(Duration.zero)).called(1); + playerStateController.add( + AppPlayerState(currentTrack: testTrack, status: PlayerStatus.playing), + ); + await pumpEventQueue(); + + await container.read(playerStateProvider.notifier).stopPlayback(); + + verify(() => mockPauseTrackUseCase.call()).called(1); + verify(() => mockSeekPositionUseCase.call(Duration.zero)).called(1); + }); + + test('loadAndPlayPreview calls loadQueue', () async { + final container = createContainer(); + await container + .read(playerStateProvider.notifier) + .loadAndPlayPreview(testTrack); + verify( + () => mockLoadQueueUseCase.call([testTrack], initialIndex: 0), + ).called(1); + }); + + test('SeekDragNotifier sets position', () { + final container = createContainer(); + container + .read(seekDragPositionProvider.notifier) + .setPosition(const Duration(seconds: 10)); + expect( + container.read(seekDragPositionProvider), + const Duration(seconds: 10), + ); }); }); } diff --git a/test/features/player/presentation/providers/queue_provider_test.dart b/test/features/player/presentation/providers/queue_provider_test.dart new file mode 100644 index 00000000..82ec7795 --- /dev/null +++ b/test/features/player/presentation/providers/queue_provider_test.dart @@ -0,0 +1,584 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:rythmify/core/domain/entities/track.dart'; +import 'package:rythmify/features/player/domain/entities/player_state.dart'; +import 'package:rythmify/features/player/domain/entities/queue_state.dart'; +import 'package:rythmify/features/player/domain/entities/queue_item.dart'; +import 'package:rythmify/features/player/presentation/providers/queue_provider.dart'; +import 'package:rythmify/features/player/presentation/providers/player_provider.dart'; +import 'package:rythmify/features/player/presentation/providers/player_dependency_providers.dart'; +import 'package:rythmify/features/track/presentation/providers/track_dependency_providers.dart'; +import 'package:rythmify/features/player/presentation/providers/ad_provider.dart'; +import '../presentation_test_helper.dart'; + +class MockAdNotifier extends Notifier + with Mock + implements AdNotifier {} + +class MockPlayerNotifier extends Notifier + with Mock + implements PlayerNotifier {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAudioRepository mockAudioRepository; + late MockPlayTrackUseCase mockPlayTrackUseCase; + late MockGetPlayerStateStreamUseCase mockGetPlayerStateStreamUseCase; + late MockLoadQueueUseCase mockLoadQueueUseCase; + late MockSyncPlayerStateUseCase mockSyncPlayerStateUseCase; + late MockGetRelatedTracksUseCase mockGetRelatedTracksUseCase; + late MockAppendTracksUseCase mockAppendTracksUseCase; + late MockSyncHistoryUseCase mockSyncHistoryUseCase; + late MockInitiatePlaybackUseCase mockInitiatePlaybackUseCase; + late MockRecordListeningHistoryUseCase mockRecordListeningHistoryUseCase; + late MockFetchQueueContextUseCase mockFetchQueueContextUseCase; + late MockUpdateTrackInfoUseCase mockUpdateTrackInfoUseCase; + late MockGetTrackDetails mockGetTrackDetails; + late MockGetWaveform mockGetWaveform; + late MockAdNotifier mockAdNotifier; + late MockPlayerNotifier mockPlayerNotifier; + + late StreamController playerStateController; + + setUpAll(() { + registerPlayerFallbackValues(); + SharedPreferences.setMockInitialValues({}); + }); + + setUp(() { + mockAudioRepository = MockAudioRepository(); + mockPlayTrackUseCase = MockPlayTrackUseCase(); + mockGetPlayerStateStreamUseCase = MockGetPlayerStateStreamUseCase(); + mockLoadQueueUseCase = MockLoadQueueUseCase(); + mockSyncPlayerStateUseCase = MockSyncPlayerStateUseCase(); + mockGetRelatedTracksUseCase = MockGetRelatedTracksUseCase(); + mockAppendTracksUseCase = MockAppendTracksUseCase(); + mockSyncHistoryUseCase = MockSyncHistoryUseCase(); + mockInitiatePlaybackUseCase = MockInitiatePlaybackUseCase(); + mockRecordListeningHistoryUseCase = MockRecordListeningHistoryUseCase(); + mockFetchQueueContextUseCase = MockFetchQueueContextUseCase(); + mockUpdateTrackInfoUseCase = MockUpdateTrackInfoUseCase(); + mockGetTrackDetails = MockGetTrackDetails(); + mockGetWaveform = MockGetWaveform(); + mockAdNotifier = MockAdNotifier(); + mockPlayerNotifier = MockPlayerNotifier(); + + playerStateController = StreamController.broadcast(); + + when( + () => mockGetPlayerStateStreamUseCase.call(), + ).thenAnswer((_) => playerStateController.stream); + when(() => mockSyncHistoryUseCase.call()).thenAnswer((_) async {}); + when( + () => mockRecordListeningHistoryUseCase.call(any()), + ).thenAnswer((_) async {}); + when( + () => mockSyncPlayerStateUseCase.call( + trackId: any(named: 'trackId'), + queue: any(named: 'queue'), + ), + ).thenAnswer((_) async {}); + when( + () => mockLoadQueueUseCase.call( + any(), + initialIndex: any(named: 'initialIndex'), + ), + ).thenAnswer((_) async {}); + when( + () => mockInitiatePlaybackUseCase.call(any()), + ).thenAnswer((_) async => 'url'); + when(() => mockPlayTrackUseCase.call()).thenAnswer((_) async {}); + when( + () => mockGetRelatedTracksUseCase.call(any()), + ).thenAnswer((_) async => []); + when(() => mockAppendTracksUseCase.call(any())).thenAnswer((_) async {}); + when( + () => mockUpdateTrackInfoUseCase.call(any(), any()), + ).thenAnswer((_) async {}); + when( + () => mockGetTrackDetails.call(any()), + ).thenAnswer((_) async => testTrack); + when(() => mockGetWaveform.call(any())).thenAnswer((_) async => [0.1, 0.5]); + + // Fix: Stub incrementTrackCount + when(() => mockAdNotifier.incrementTrackCount()).thenAnswer((_) async {}); + + // Default stubs for notifiers + when( + () => mockAdNotifier.build(), + ).thenReturn(const AdState(trackCount: 0, showAd: false)); + when(() => mockPlayerNotifier.build()).thenReturn(const AppPlayerState()); + + // Explicit stubs for PlayerNotifier methods used in QueueNotifier + when( + () => mockPlayerNotifier.updateNativeQueue( + any(), + newIndex: any(named: 'newIndex'), + ), + ).thenAnswer((_) async {}); + when( + () => mockPlayerNotifier.updateNativeQueue(any()), + ).thenAnswer((_) async {}); + when( + () => mockPlayerNotifier.loadAndPlayQueue( + any(), + initialIndex: any(named: 'initialIndex'), + ), + ).thenAnswer((_) async {}); + when( + () => mockPlayerNotifier.skipToAbsoluteIndex(any()), + ).thenAnswer((_) async {}); + when( + () => mockPlayerNotifier.moveTrack(any(), any()), + ).thenAnswer((_) async {}); + }); + + tearDown(() { + playerStateController.close(); + }); + + ProviderContainer createContainer() { + return ProviderContainer( + overrides: [ + audioRepositoryProvider.overrideWithValue(mockAudioRepository), + playTrackUseCaseProvider.overrideWithValue(mockPlayTrackUseCase), + getPlayerStateStreamUseCaseProvider.overrideWithValue( + mockGetPlayerStateStreamUseCase, + ), + loadQueueUseCaseProvider.overrideWithValue(mockLoadQueueUseCase), + syncPlayerStateUseCaseProvider.overrideWithValue( + mockSyncPlayerStateUseCase, + ), + getRelatedTracksUseCaseProvider.overrideWithValue( + mockGetRelatedTracksUseCase, + ), + appendTracksUseCaseProvider.overrideWithValue(mockAppendTracksUseCase), + syncHistoryUseCaseProvider.overrideWithValue(mockSyncHistoryUseCase), + initiatePlaybackUseCaseProvider.overrideWithValue( + mockInitiatePlaybackUseCase, + ), + recordListeningHistoryUseCaseProvider.overrideWithValue( + mockRecordListeningHistoryUseCase, + ), + fetchQueueContextUseCaseProvider.overrideWithValue( + mockFetchQueueContextUseCase, + ), + updateTrackInfoUseCaseProvider.overrideWithValue( + mockUpdateTrackInfoUseCase, + ), + getTrackDetailsUseCaseProvider.overrideWithValue(mockGetTrackDetails), + getWaveformUseCaseProvider.overrideWithValue(mockGetWaveform), + adProvider.overrideWith(() => mockAdNotifier), + playerStateProvider.overrideWith(() => mockPlayerNotifier), + ], + ); + } + + group('QueueNotifier', () { + test('initial state is AppQueueState()', () { + final container = createContainer(); + expect(container.read(queueStateProvider), const AppQueueState()); + }); + + test('syncs with hardware when playerState changes track', () async { + final container = ProviderContainer( + overrides: [ + audioRepositoryProvider.overrideWithValue(mockAudioRepository), + playTrackUseCaseProvider.overrideWithValue(mockPlayTrackUseCase), + getPlayerStateStreamUseCaseProvider.overrideWithValue( + mockGetPlayerStateStreamUseCase, + ), + loadQueueUseCaseProvider.overrideWithValue(mockLoadQueueUseCase), + syncPlayerStateUseCaseProvider.overrideWithValue( + mockSyncPlayerStateUseCase, + ), + getRelatedTracksUseCaseProvider.overrideWithValue( + mockGetRelatedTracksUseCase, + ), + appendTracksUseCaseProvider.overrideWithValue( + mockAppendTracksUseCase, + ), + syncHistoryUseCaseProvider.overrideWithValue(mockSyncHistoryUseCase), + initiatePlaybackUseCaseProvider.overrideWithValue( + mockInitiatePlaybackUseCase, + ), + recordListeningHistoryUseCaseProvider.overrideWithValue( + mockRecordListeningHistoryUseCase, + ), + fetchQueueContextUseCaseProvider.overrideWithValue( + mockFetchQueueContextUseCase, + ), + updateTrackInfoUseCaseProvider.overrideWithValue( + mockUpdateTrackInfoUseCase, + ), + getTrackDetailsUseCaseProvider.overrideWithValue(mockGetTrackDetails), + getWaveformUseCaseProvider.overrideWithValue(mockGetWaveform), + adProvider.overrideWith(() => mockAdNotifier), + ], + ); + + final List tracks = [testTrack, testTrack.copyWith(id: 'track2')]; + + container.listen(playerStateProvider, (_, __) {}); + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: tracks, initialIndex: 0); + + expect( + container.read(queueStateProvider).currentTrack?.track.id, + testTrack.id, + ); + + playerStateController.add( + AppPlayerState(currentTrack: tracks[1], queueIndex: 1), + ); + + await pumpEventQueue(); + + expect( + container.read(queueStateProvider).currentTrack?.track.id, + 'track2', + ); + expect(container.read(queueStateProvider).history.length, 1); + }); + + test('_checkAndFetchRelated fetches and filters duplicates', () async { + // Let's use a real PlayerNotifier for this test to be sure + final containerWithRealPlayer = ProviderContainer( + overrides: [ + audioRepositoryProvider.overrideWithValue(mockAudioRepository), + playTrackUseCaseProvider.overrideWithValue(mockPlayTrackUseCase), + getPlayerStateStreamUseCaseProvider.overrideWithValue( + mockGetPlayerStateStreamUseCase, + ), + loadQueueUseCaseProvider.overrideWithValue(mockLoadQueueUseCase), + syncPlayerStateUseCaseProvider.overrideWithValue( + mockSyncPlayerStateUseCase, + ), + getRelatedTracksUseCaseProvider.overrideWithValue( + mockGetRelatedTracksUseCase, + ), + appendTracksUseCaseProvider.overrideWithValue( + mockAppendTracksUseCase, + ), + syncHistoryUseCaseProvider.overrideWithValue(mockSyncHistoryUseCase), + initiatePlaybackUseCaseProvider.overrideWithValue( + mockInitiatePlaybackUseCase, + ), + recordListeningHistoryUseCaseProvider.overrideWithValue( + mockRecordListeningHistoryUseCase, + ), + fetchQueueContextUseCaseProvider.overrideWithValue( + mockFetchQueueContextUseCase, + ), + updateTrackInfoUseCaseProvider.overrideWithValue( + mockUpdateTrackInfoUseCase, + ), + getTrackDetailsUseCaseProvider.overrideWithValue(mockGetTrackDetails), + getWaveformUseCaseProvider.overrideWithValue(mockGetWaveform), + adProvider.overrideWith(() => mockAdNotifier), + ], + ); + + final relatedTrack = testTrack.copyWith(id: 'related1'); + + when( + () => mockGetRelatedTracksUseCase.call(any()), + ).thenAnswer((_) async => [testTrack, relatedTrack]); + when( + () => mockGetTrackDetails.call(any()), + ).thenAnswer((_) async => relatedTrack); + when( + () => mockGetWaveform.call(any()), + ).thenAnswer((_) async => [0.1, 0.2]); + + containerWithRealPlayer.listen(playerStateProvider, (_, __) {}); + + await containerWithRealPlayer + .read(queueStateProvider.notifier) + .playQueue(tracks: [testTrack], initialIndex: 0); + + // Trigger track change which calls _checkAndFetchRelated + playerStateController.add( + AppPlayerState(currentTrack: testTrack, queueIndex: 0), + ); + + await pumpEventQueue(); + await Future.delayed( + const Duration(milliseconds: 100), + ); // wait for background resolution + await pumpEventQueue(); + + final state = containerWithRealPlayer.read(queueStateProvider); + expect(state.upcomingTracks.any((t) => t.track.id == 'related1'), true); + expect( + state.upcomingTracks.where((t) => t.track.id == testTrack.id).length, + 0, + ); + }); + + test('addToNextUp fetches and inserts tracks', () async { + final container = createContainer(); + final nextUpTrack = testTrack.copyWith(id: 'nextUp'); + + when( + () => mockFetchQueueContextUseCase.call( + interactionType: 'next_up', + sourceType: any(named: 'sourceType'), + sourceId: any(named: 'sourceId'), + ), + ).thenAnswer( + (_) async => { + 'data': { + 'queue': [ + { + 'id': nextUpTrack.id, + 'user_id': nextUpTrack.userId, + 'title': nextUpTrack.title, + 'artist': nextUpTrack.artist, + 'audio_url': nextUpTrack.audioUrl, + 'duration_ms': nextUpTrack.duration.inMilliseconds, + 'created_at': nextUpTrack.createdAt.toIso8601String(), + 'queue_item_id': 'next1', + }, + ], + }, + }, + ); + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: [testTrack], initialIndex: 0); + + await container + .read(queueStateProvider.notifier) + .addToNextUp(sourceType: 'album'); + + final state = container.read(queueStateProvider); + expect(state.upcomingTracks.any((t) => t.track.id == 'nextUp'), true); + }); + + test('addMultipleToQueueNext inserts tracks after current', () async { + final container = createContainer(); + final tracks = [ + testTrack.copyWith(id: 'next1'), + testTrack.copyWith(id: 'next2'), + ]; + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: [testTrack], initialIndex: 0); + + await container + .read(queueStateProvider.notifier) + .addMultipleToQueueNext(tracks); + + final state = container.read(queueStateProvider); + expect(state.upcomingTracks.length, 2); + expect(state.upcomingTracks[0].track.id, 'next1'); + expect(state.upcomingTracks[1].track.id, 'next2'); + }); + + test( + 'addMultipleToQueueLast appends tracks before recommendations', + () async { + final container = createContainer(); + final tracks = [ + testTrack.copyWith(id: 'last1'), + testTrack.copyWith(id: 'last2'), + ]; + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: [testTrack], initialIndex: 0); + + // Add a recommended track + final recItem = QueueItem( + track: testTrack.copyWith(id: 'rec1'), + isRecommended: true, + ); + container.read(queueStateProvider.notifier).state = container + .read(queueStateProvider) + .copyWith(upcomingTracks: [recItem]); + + await container + .read(queueStateProvider.notifier) + .addMultipleToQueueLast(tracks); + + final state = container.read(queueStateProvider); + expect(state.upcomingTracks.length, 3); + expect(state.upcomingTracks[0].track.id, 'last1'); + expect(state.upcomingTracks[1].track.id, 'last2'); + expect(state.upcomingTracks[2].isRecommended, true); + }, + ); + + test('playFromQueue skips to correct absolute index', () async { + final container = createContainer(); + + await container + .read(queueStateProvider.notifier) + .playQueue( + tracks: [ + testTrack, + testTrack.copyWith(id: 't2'), + testTrack.copyWith(id: 't3'), + ], + initialIndex: 0, + ); + + // Current state: history=[], current=testTrack, upcoming=[t2, t3] + // playFromQueue(0) should play t2. Absolute index = 0 (history) + 1 (current) + 0 (index) = 1 + container.read(queueStateProvider.notifier).playFromQueue(0); + + verify(() => mockPlayerNotifier.skipToAbsoluteIndex(1)).called(1); + }); + + test('playFromRecommended handles invalid index', () { + final container = createContainer(); + container.read(queueStateProvider.notifier).playFromRecommended(0); + verifyNever(() => mockPlayerNotifier.skipToAbsoluteIndex(any())); + }); + + test('toggleShuffle shuffles and unshuffles', () async { + final container = createContainer(); + final tracks = [ + testTrack, + testTrack.copyWith(id: 't2'), + testTrack.copyWith(id: 't3'), + ]; + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: tracks, initialIndex: 0); + + container.read(queueStateProvider.notifier).toggleShuffle(); + expect(container.read(queueStateProvider).isShuffled, true); + + // Verify calls to updateNativeQueue + verify(() => mockPlayerNotifier.updateNativeQueue(any())).called(1); + + container.read(queueStateProvider.notifier).toggleShuffle(); + expect(container.read(queueStateProvider).isShuffled, false); + expect( + container + .read(queueStateProvider) + .upcomingTracks + .map((e) => e.track.id), + ['t2', 't3'], + ); + }); + + test('reorder moves tracks and syncs hardware', () async { + final container = createContainer(); + final tracks = [ + testTrack, + testTrack.copyWith(id: 't2'), + testTrack.copyWith(id: 't3'), + ]; + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: tracks, initialIndex: 0); + + // upcoming: [t2, t3]. Move t3 to top of upcoming. + container.read(queueStateProvider.notifier).reorder(1, 0); + + expect( + container.read(queueStateProvider).upcomingTracks[0].track.id, + 't3', + ); + // hardwareOldIndex = 0 (hist) + 1 (curr) + 1 (oldIdx) = 2 + // hardwareNewIndex = 0 (hist) + 1 (curr) + 0 (newIdx) = 1 + verify(() => mockPlayerNotifier.moveTrack(2, 1)).called(1); + }); + + test('reorder moves tracks forward', () async { + final container = createContainer(); + final tracks = [ + testTrack, + testTrack.copyWith(id: 't2'), + testTrack.copyWith(id: 't3'), + testTrack.copyWith(id: 't4'), + ]; + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: tracks, initialIndex: 0); + + // upcoming: [t2, t3, t4]. Move t2 to after t3. old=0, new=2 (which becomes 1) + container.read(queueStateProvider.notifier).reorder(0, 2); + + expect( + container.read(queueStateProvider).upcomingTracks[0].track.id, + 't3', + ); + expect( + container.read(queueStateProvider).upcomingTracks[1].track.id, + 't2', + ); + verify(() => mockPlayerNotifier.moveTrack(1, 2)).called(1); + }); + + test('addToNextUp handles empty/null response gracefully', () async { + final container = createContainer(); + when( + () => mockFetchQueueContextUseCase.call( + interactionType: 'next_up', + sourceType: any(named: 'sourceType'), + sourceId: any(named: 'sourceId'), + ), + ).thenThrow(Exception('API Error')); + + await container + .read(queueStateProvider.notifier) + .addToNextUp(sourceType: 'album'); + // Should not crash + }); + + test('playFromRecommended skips to correct index', () async { + final container = createContainer(); + + await container + .read(queueStateProvider.notifier) + .playQueue(tracks: [testTrack], initialIndex: 0); + + final recItem = QueueItem( + track: testTrack.copyWith(id: 'rec1'), + isRecommended: true, + ); + container.read(queueStateProvider.notifier).state = container + .read(queueStateProvider) + .copyWith(upcomingTracks: [recItem]); + + container.read(queueStateProvider.notifier).playFromRecommended(0); + + verify(() => mockPlayerNotifier.skipToAbsoluteIndex(1)).called(1); + }); + + test('reorder handles edge cases and invalid indices', () async { + final container = createContainer(); + + await container + .read(queueStateProvider.notifier) + .playQueue( + tracks: [ + testTrack, + testTrack.copyWith(id: 't2'), + ], + initialIndex: 0, + ); + + container.read(queueStateProvider.notifier).reorder(-1, 0); + container.read(queueStateProvider.notifier).reorder(0, -1); + container.read(queueStateProvider.notifier).reorder(10, 0); + + verifyNever(() => mockPlayerNotifier.moveTrack(any(), any())); + }); + }); +} diff --git a/test/features/player/presentation/widgets/mini_player_test.dart b/test/features/player/presentation/widgets/mini_player_test.dart new file mode 100644 index 00000000..0ae02245 --- /dev/null +++ b/test/features/player/presentation/widgets/mini_player_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/player/domain/entities/player_state.dart'; +import 'package:rythmify/features/player/presentation/providers/player_provider.dart'; +import 'package:rythmify/features/player/presentation/providers/queue_provider.dart'; +import 'package:rythmify/features/player/presentation/widgets/mini_player.dart'; +import 'package:rythmify/features/track/presentation/providers/track_interaction_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; +import 'package:rythmify/features/notifications/presentation/providers/follow_state_provider.dart'; +import 'package:rythmify/features/player/domain/entities/queue_state.dart'; +import '../presentation_test_helper.dart'; + +class MockQueueNotifier extends Notifier + with Mock + implements QueueNotifier {} + +class MockFollowStateNotifier extends StateNotifier> + with Mock + implements FollowStateNotifier { + MockFollowStateNotifier() : super({}); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockTrackInteractionNotifier mockTrackInteractionNotifier; + late MockQueueNotifier mockQueueNotifier; + + setUpAll(() { + registerPlayerFallbackValues(); + }); + + setUp(() { + mockTrackInteractionNotifier = MockTrackInteractionNotifier(); + mockQueueNotifier = MockQueueNotifier(); + + // Stubs for void methods + when(() => mockQueueNotifier.nextTrack()).thenReturn(null); + when(() => mockQueueNotifier.previousTrack()).thenReturn(null); + }); + + Widget createWidget({ + required AppPlayerState playerState, + VoidCallback? onTap, + }) { + return ProviderScope( + overrides: [ + playerStateProvider.overrideWith( + () => MockPlayerNotifierWrapper(playerState), + ), + queueStateProvider.overrideWith( + () => MockQueueNotifierWrapper( + const AppQueueState(), + mockQueueNotifier, + ), + ), + trackInteractionProvider.overrideWithValue( + mockTrackInteractionNotifier, + ), + authProvider.overrideWith(() => MockAuthNotifier()), + followStateProvider.overrideWith((ref) => MockFollowStateNotifier()), + ], + child: MaterialApp( + home: Scaffold(body: MiniPlayer(onTap: onTap)), + ), + ); + } + + group('MiniPlayer', () { + testWidgets('renders nothing when currentTrack is null', (tester) async { + await tester.pumpWidget( + createWidget(playerState: const AppPlayerState()), + ); + expect(find.byType(MiniPlayer), findsOneWidget); + expect( + find.byKey(const Key('player_mini_player_gesture_detector')), + findsNothing, + ); + }); + + testWidgets('renders track info when currentTrack is not null', ( + tester, + ) async { + final playerState = AppPlayerState(currentTrack: testTrack); + await tester.pumpWidget(createWidget(playerState: playerState)); + + expect(find.text(testTrack.title), findsOneWidget); + expect(find.text(testTrack.artist), findsOneWidget); + }); + + testWidgets('triggers onTap when tapped', (tester) async { + bool tapped = false; + final playerState = AppPlayerState(currentTrack: testTrack); + await tester.pumpWidget( + createWidget(playerState: playerState, onTap: () => tapped = true), + ); + + await tester.tap( + find.byKey(const Key('player_mini_player_gesture_detector')), + ); + expect(tapped, true); + }); + + testWidgets('calls nextTrack on swipe left', (tester) async { + final playerState = AppPlayerState(currentTrack: testTrack); + + await tester.pumpWidget(createWidget(playerState: playerState)); + + // Use flick for better gesture detection + await tester.fling( + find.byKey(const Key('player_mini_player_gesture_detector')), + const Offset(-1000, 0), + 3000, + ); + await tester.pumpAndSettle(); + + verify(() => mockQueueNotifier.nextTrack()).called(1); + }); + + testWidgets('calls previousTrack on swipe right', (tester) async { + final playerState = AppPlayerState(currentTrack: testTrack); + + await tester.pumpWidget(createWidget(playerState: playerState)); + + await tester.fling( + find.byKey(const Key('player_mini_player_gesture_detector')), + const Offset(1000, 0), + 3000, + ); + await tester.pumpAndSettle(); + + verify(() => mockQueueNotifier.previousTrack()).called(1); + }); + + testWidgets('calls handleToggleLike when like button pressed', ( + tester, + ) async { + final playerState = AppPlayerState(currentTrack: testTrack); + when( + () => mockTrackInteractionNotifier.handleToggleLike( + any(), + any(), + currentTrack: any(named: 'currentTrack'), + ), + ).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidget(playerState: playerState)); + + await tester.tap( + find.byKey(const Key('player_mini_player_like_icon_button')), + ); + + verify( + () => mockTrackInteractionNotifier.handleToggleLike( + testTrack.id, + testTrack.isLiked, + currentTrack: any(named: 'currentTrack'), + ), + ).called(1); + }); + }); +} + +class MockPlayerNotifierWrapper extends PlayerNotifier { + final AppPlayerState _state; + MockPlayerNotifierWrapper(this._state); + @override + AppPlayerState build() => _state; +} + +class MockQueueNotifierWrapper extends QueueNotifier { + final AppQueueState _state; + final MockQueueNotifier _mock; + MockQueueNotifierWrapper(this._state, this._mock); + @override + AppQueueState build() => _state; + @override + void nextTrack() => _mock.nextTrack(); + @override + void previousTrack() => _mock.previousTrack(); +} + +class MockAuthNotifier extends AuthNotifier { + @override + AuthState build() => const AuthUnauthenticated(); +} diff --git a/test/features/player/presentation/widgets/track_info_box_test.dart b/test/features/player/presentation/widgets/track_info_box_test.dart new file mode 100644 index 00000000..54458608 --- /dev/null +++ b/test/features/player/presentation/widgets/track_info_box_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/player/presentation/widgets/track_info_box.dart'; +import '../presentation_test_helper.dart'; + +void main() { + testWidgets('TrackInfoBox displays title and artist', (tester) async { + bool navigated = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TrackInfoBox( + trackInfo: testTrack, + onNavigateBehindTrack: () => navigated = true, + ), + ), + ), + ); + + expect(find.text(testTrack.title), findsOneWidget); + expect(find.text(testTrack.artist), findsOneWidget); + expect(find.text('Behind this track'), findsOneWidget); + + await tester.tap( + find.byKey(const Key('player_track_info_box_details_gesturedetector')), + ); + expect(navigated, true); + + navigated = false; + await tester.tap( + find.byKey( + const Key('player_track_info_box_behind_track_gesturedetector'), + ), + ); + expect(navigated, true); + }); +} diff --git a/test/features/player/presentation/widgets/waveform_painter_test.dart b/test/features/player/presentation/widgets/waveform_painter_test.dart new file mode 100644 index 00000000..da852c1b --- /dev/null +++ b/test/features/player/presentation/widgets/waveform_painter_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/player/presentation/widgets/waveform/waveform_painter.dart'; + +void main() { + group('WaveformPainter', () { + test('shouldRepaint returns true when properties change', () { + final painter1 = WaveformPainter( + amplitudes: [0.1, 0.5], + duration: const Duration(seconds: 100), + activePosition: const Duration(seconds: 10), + actualPosition: const Duration(seconds: 10), + heightMultiplier: 1.0, + ); + + final painter2 = WaveformPainter( + amplitudes: [0.1, 0.5], + duration: const Duration(seconds: 100), + activePosition: const Duration(seconds: 20), + actualPosition: const Duration(seconds: 10), + heightMultiplier: 1.0, + ); + + expect(painter1.shouldRepaint(painter2), true); + }); + + test('shouldRepaint returns false when properties are same', () { + final painter1 = WaveformPainter( + amplitudes: [0.1, 0.5], + duration: const Duration(seconds: 100), + activePosition: const Duration(seconds: 10), + actualPosition: const Duration(seconds: 10), + heightMultiplier: 1.0, + ); + + final painter2 = WaveformPainter( + amplitudes: [0.1, 0.5], + duration: const Duration(seconds: 100), + activePosition: const Duration(seconds: 10), + actualPosition: const Duration(seconds: 10), + heightMultiplier: 1.0, + ); + + expect(painter1.shouldRepaint(painter2), false); + }); + + testWidgets('paints correctly (sanity check)', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint( + size: const Size(200, 100), + painter: WaveformPainter( + amplitudes: [0.1, 0.5, 0.2, 0.8], + duration: const Duration(seconds: 100), + activePosition: const Duration(seconds: 50), + actualPosition: const Duration(seconds: 50), + heightMultiplier: 1.0, + ), + ), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (widget) => + widget is CustomPaint && widget.painter is WaveformPainter, + ), + findsOneWidget, + ); + }); + }); +}