diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 08:16:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 08:16:59 +0300 |
| commit | 63fc4369917ab548c5cdc4dddccf0ad4ded11a2f (patch) | |
| tree | 7c7641c29a543cfebc4260cc590fe8bd5938ba43 /player-android/test | |
| parent | 6365d47f8257efa830d390c092ee9586aded9040 (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.dart | 445 |
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)); + }); + }); +} |
