summaryrefslogtreecommitdiff
path: root/player-android/test
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 08:16:59 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 08:16:59 +0300
commit63fc4369917ab548c5cdc4dddccf0ad4ded11a2f (patch)
tree7c7641c29a543cfebc4260cc590fe8bd5938ba43 /player-android/test
parent6365d47f8257efa830d390c092ee9586aded9040 (diff)
Implement MediaGridScreen with grid, loading/empty/error states, and widget tests (ua)
- Replace placeholder MediaGridScreen with a full implementation: loads media via listMedia(setId:), renders a Material 3 2-column grid of thumbnail cards with filename, type icon, and duration overlay. - Add thumbnailUrl(int mediaId) to PlayerApiClient so screen files construct thumbnail URLs without importing Dio (DIP). - Add mediaErrorMessage() top-level helper to error_mappers.dart (Open-Closed Principle; matches setsErrorMessage pattern). - Update router.dart to forward the set name as a route extra so MediaGridScreen shows the name in the app bar without extra API calls. - Update home_screen.dart _SetCard tap to pass set name as route extra. - Add 10 widget tests covering: loading, grid render, tap navigation to /media/:id, empty state, error state + retry, and pull-to-refresh. - All 109 tests pass; flutter analyze reports no issues. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/test')
-rw-r--r--player-android/test/screens/media_grid_screen_test.dart445
1 files changed, 445 insertions, 0 deletions
diff --git a/player-android/test/screens/media_grid_screen_test.dart b/player-android/test/screens/media_grid_screen_test.dart
new file mode 100644
index 0000000..986b901
--- /dev/null
+++ b/player-android/test/screens/media_grid_screen_test.dart
@@ -0,0 +1,445 @@
+// Widget tests for MediaGridScreen (media_grid_screen.dart).
+//
+// Tests cover:
+// 1. Renders a loading indicator while listMedia is in flight.
+// 2. Renders a grid of media cards after a successful load.
+// 3. Each card shows the media title and duration.
+// 4. Tapping a card navigates to the media-detail route.
+// 5. Shows an empty-state widget when listMedia returns [].
+// 6. Shows an error view when listMedia throws a DioException.
+// 7. Pull-to-refresh calls listMedia again.
+//
+// Riverpod providers are overridden with fakes so tests run without a real
+// server or OS keychain.
+//
+// Run with: flutter test test/screens/media_grid_screen_test.dart
+
+import 'dart:async';
+
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
+import 'package:player_android/api/dio_client.dart';
+import 'package:player_android/api/player_api_client.dart';
+import 'package:player_android/models/models.dart';
+import 'package:player_android/providers/api_client_provider.dart';
+import 'package:player_android/screens/media_grid_screen.dart';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/// In-memory [TokenStorage] that returns a fixed test token.
+///
+/// Avoids the platform-specific OS keychain in widget tests.
+class _FakeTokenStorage implements TokenStorage {
+ const _FakeTokenStorage();
+
+ @override
+ Future<String?> readToken() async => 'test-token';
+
+ @override
+ Future<void> writeToken(String token) async {}
+
+ @override
+ Future<void> deleteToken() async {}
+}
+
+/// Controllable [PlayerApiClient] stub for [MediaGridScreen] tests.
+///
+/// Only [listMedia] and [thumbnailUrl] are implemented; all other methods
+/// remain [UnimplementedError] — the screen calls only these two.
+class _FakeApiClient extends PlayerApiClient {
+ _FakeApiClient() : super(dio: Dio());
+
+ /// When non-null, [listMedia] returns this list.
+ List<Media>? mediaResult;
+
+ /// When non-null, [listMedia] throws this instead of returning.
+ Object? mediaError;
+
+ /// Records every call to [listMedia] — useful for refresh tests.
+ int listMediaCallCount = 0;
+
+ @override
+ Future<List<Media>> listMedia({
+ String? search,
+ int? setId,
+ List<int>? setIds,
+ String? type,
+ bool? favorites,
+ List<String>? tags,
+ double? minDuration,
+ double? maxDuration,
+ int? fileSizeMin,
+ int? fileSizeMax,
+ String? sort,
+ int? limit,
+ int? offset,
+ String? folder,
+ String? parent,
+ }) async {
+ listMediaCallCount++;
+ if (mediaError != null) throw mediaError!;
+ return mediaResult!;
+ }
+
+ /// Returns an empty string so [_ThumbnailImage] shows the static placeholder
+ /// instead of making a network request — keeps widget tests hermetic.
+ @override
+ String thumbnailUrl(int mediaId) => '';
+}
+
+/// [PlayerApiClient] stub that delays [listMedia] until [complete] is called.
+///
+/// Used to inspect mid-flight loading state before the response arrives.
+class _DelayedFakeApiClient extends PlayerApiClient {
+ _DelayedFakeApiClient() : super(dio: Dio());
+
+ final _completer = Completer<List<Media>>();
+
+ /// Resolves the pending [listMedia] call with [items].
+ void complete(List<Media> items) => _completer.complete(items);
+
+ @override
+ Future<List<Media>> listMedia({
+ String? search,
+ int? setId,
+ List<int>? setIds,
+ String? type,
+ bool? favorites,
+ List<String>? tags,
+ double? minDuration,
+ double? maxDuration,
+ int? fileSizeMin,
+ int? fileSizeMax,
+ String? sort,
+ int? limit,
+ int? offset,
+ String? folder,
+ String? parent,
+ }) =>
+ _completer.future;
+
+ @override
+ String thumbnailUrl(int mediaId) => '';
+}
+
+// ---------------------------------------------------------------------------
+// Sample data
+// ---------------------------------------------------------------------------
+
+/// A sample video media item used across tests.
+const _kVideo = Media(
+ id: 1,
+ setId: 10,
+ relPath: 'action/movie.mp4',
+ fileName: 'movie.mp4',
+ absPath: '/media/movies/action/movie.mp4',
+ type: 'video',
+ duration: 7320.0, // 2h 2m
+ codec: 'h264/aac',
+ resolution: '1920x1080',
+ bitrate: 4500,
+ fileSizeBytes: 1073741824,
+ width: 1920,
+ height: 1080,
+ thumbnailPath: '/media/movies/.thumbs/movie.jpg',
+ playCount: 3,
+);
+
+/// A sample audio media item used across tests.
+const _kAudio = Media(
+ id: 2,
+ setId: 10,
+ relPath: 'music/song.mp3',
+ fileName: 'song.mp3',
+ absPath: '/media/music/song.mp3',
+ type: 'audio',
+ duration: 210.0, // 3m 30s
+ codec: 'mp3',
+ resolution: '',
+ bitrate: 320,
+ fileSizeBytes: 8388608,
+ width: 0,
+ height: 0,
+ thumbnailPath: '',
+ playCount: 12,
+);
+
+// ---------------------------------------------------------------------------
+// Helper: pump MediaGridScreen inside a minimal ProviderScope.
+// ---------------------------------------------------------------------------
+
+/// Destination route shown after navigating away from [MediaGridScreen].
+///
+/// Used in navigation tests: when a media card is tapped, [MediaGridScreen]
+/// calls `context.go('/media/:id')` which this route catches.
+const _kDestinationKey = Key('nav_destination');
+
+/// Builds a [GoRouter] with [MediaGridScreen] at `/sets/:setId` and a stub
+/// at `/media/:id` so navigation tests can verify the tap lands correctly.
+GoRouter _buildRouter(PlayerApiClient fakeClient) {
+ return GoRouter(
+ initialLocation: '/sets/10',
+ routes: [
+ GoRoute(
+ path: '/sets/:setId',
+ builder: (context, state) {
+ final setId = int.tryParse(state.pathParameters['setId']!) ?? 0;
+ return MediaGridScreen(setId: setId, setName: 'Movies');
+ },
+ ),
+ GoRoute(
+ path: '/media/:id',
+ builder: (context, state) => Scaffold(
+ body: Text(
+ 'Media ${state.pathParameters['id']}',
+ key: _kDestinationKey,
+ ),
+ ),
+ ),
+ ],
+ );
+}
+
+/// Pumps [MediaGridScreen] (set 10, name "Movies") inside a [ProviderScope]
+/// that overrides [apiClientProvider] and [tokenStorageProvider] with fakes.
+///
+/// Uses a [GoRouter] so `context.go('/media/:id')` works without an
+/// "unsupported ancestor" error. The `/media/:id` stub route lets
+/// navigation tests verify that the correct destination was reached.
+Future<void> _pumpScreen(
+ WidgetTester tester,
+ PlayerApiClient fakeClient,
+) async {
+ final router = _buildRouter(fakeClient);
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()),
+ apiClientProvider.overrideWithValue(fakeClient),
+ ],
+ child: MaterialApp.router(
+ routerConfig: router,
+ ),
+ ),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+void main() {
+ // --------------------------------------------------------------------------
+ // Loading state
+ // --------------------------------------------------------------------------
+
+ group('loading state', () {
+ testWidgets('shows loading indicator while listMedia is in flight',
+ (tester) async {
+ final fakeClient = _DelayedFakeApiClient();
+
+ await _pumpScreen(tester, fakeClient);
+
+ // Pump one frame: initState fires, addPostFrameCallback enqueues the
+ // load, and the Future has not resolved yet.
+ await tester.pump();
+
+ // The loading key should be visible before data arrives.
+ expect(find.byKey(const Key('media_loading')), findsOneWidget);
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+
+ // Resolve the fake to prevent "pending async work" warnings.
+ fakeClient.complete([_kVideo]);
+ await tester.pumpAndSettle();
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Renders grid
+ // --------------------------------------------------------------------------
+
+ group('renders grid', () {
+ testWidgets('shows a card for each item returned by listMedia',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..mediaResult = [_kVideo, _kAudio];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Both file names must be visible.
+ expect(find.text('movie.mp4'), findsOneWidget);
+ expect(find.text('song.mp3'), findsOneWidget);
+
+ // A card widget is rendered for each item.
+ expect(find.byKey(const Key('media_card_1')), findsOneWidget);
+ expect(find.byKey(const Key('media_card_2')), findsOneWidget);
+ });
+
+ testWidgets('renders the media grid widget after a successful load',
+ (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = [_kVideo];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // The grid itself is visible.
+ expect(find.byKey(const Key('media_grid')), findsOneWidget);
+ });
+
+ testWidgets('shows title and duration for each media card', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = [_kVideo];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Title key is present.
+ expect(find.byKey(const Key('media_title_1')), findsOneWidget);
+ // Duration key is present and shows formatted value.
+ expect(find.byKey(const Key('media_duration_1')), findsOneWidget);
+ // 7320s = 2h 2m 0s → "2:02:00"
+ expect(find.text('2:02:00'), findsOneWidget);
+ });
+
+ testWidgets('shows set name in app bar when provided', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = [];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Movies'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Tap navigation
+ // --------------------------------------------------------------------------
+
+ group('tap navigates to media detail', () {
+ testWidgets('tapping a media card navigates to /media/:id',
+ (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = [_kVideo];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // The media card must be visible before tapping.
+ expect(find.byKey(const Key('media_card_1')), findsOneWidget);
+
+ // Tap the card; go_router handles `context.go('/media/1')`.
+ await tester.tap(find.byKey(const Key('media_card_1')));
+ await tester.pumpAndSettle();
+
+ // The stub route at '/media/:id' is now on screen.
+ expect(find.byKey(_kDestinationKey), findsOneWidget);
+ expect(find.text('Media 1'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Empty state
+ // --------------------------------------------------------------------------
+
+ group('empty state', () {
+ testWidgets('shows empty-state widget when listMedia returns []',
+ (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = [];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // The empty-state text is shown; grid and loading indicator are not.
+ expect(find.byKey(const Key('media_empty')), findsOneWidget);
+ expect(find.byKey(const Key('media_grid')), findsNothing);
+ expect(find.byKey(const Key('media_loading')), findsNothing);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Error state
+ // --------------------------------------------------------------------------
+
+ group('error state', () {
+ testWidgets('shows error message when listMedia throws a network error',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..mediaError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/media'),
+ type: DioExceptionType.connectionError,
+ );
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Error widget is visible; grid and loading indicator are not.
+ expect(find.byKey(const Key('media_error')), findsOneWidget);
+ expect(find.byKey(const Key('media_grid')), findsNothing);
+
+ // The error message mentions the server/connection.
+ expect(
+ find.textContaining('Could not reach the server'),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets(
+ 'shows retry button on error and a successful retry shows the grid',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..mediaError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/media'),
+ type: DioExceptionType.connectionError,
+ );
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Retry button is present.
+ expect(find.byKey(const Key('media_retry')), findsOneWidget);
+
+ // Fix the error before tapping retry so the second call succeeds.
+ fakeClient
+ ..mediaError = null
+ ..mediaResult = [_kVideo];
+
+ await tester.tap(find.byKey(const Key('media_retry')));
+ await tester.pumpAndSettle();
+
+ // After a successful retry the grid is shown.
+ expect(find.byKey(const Key('media_grid')), findsOneWidget);
+ // listMedia was called twice: once on init, once on retry.
+ expect(fakeClient.listMediaCallCount, equals(2));
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Pull-to-refresh
+ // --------------------------------------------------------------------------
+
+ group('pull-to-refresh', () {
+ testWidgets('pull-to-refresh calls listMedia a second time', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = [_kVideo];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Verify initial load.
+ expect(fakeClient.listMediaCallCount, equals(1));
+
+ // Simulate pull-to-refresh by dragging down on the grid.
+ await tester.drag(
+ find.byKey(const Key('media_grid')),
+ const Offset(0, 300),
+ );
+ await tester.pumpAndSettle();
+
+ // listMedia must have been called a second time.
+ expect(fakeClient.listMediaCallCount, equals(2));
+ });
+ });
+}