1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
|
// Widget tests for VideoPlayerScreen (video_player_screen.dart).
//
// Tests cover:
// 1. Loading indicator shown during the initial build before initState fires.
// 2. Error view rendered when VideoPlayerController.initialize() throws.
// 3. Retry button re-triggers initialisation and ends in an error state.
// 4. Screen renders the AppBar title containing the mediaId.
// 5. Stream URL resolution (route-extra URL and client.streamUrl fallback).
//
// VideoPlayerController relies on native platform channels (ExoPlayer /
// AVPlayer) that are unavailable in the Flutter test harness. We exploit the
// fact that VideoPlayerController.initialize() throws a MissingPluginException,
// turning every initialisation attempt into a predictable error path — which
// is exactly what we need for error-state coverage.
//
// Timing notes:
// - [VideoPlayerScreen] uses addPostFrameCallback to start _initPlayer so
// that Riverpod provider overrides are fully applied before the first read.
// - pumpWidget() renders the first frame with _isLoading = true.
// - pump() processes addPostFrameCallback → _initPlayer() → throws → error.
// - Therefore the loading spinner is visible immediately after pumpWidget()
// but NOT after a subsequent pump(); check it before pumping.
//
// Run with: flutter test test/screens/video_player_screen_test.dart
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/providers/api_client_provider.dart';
import 'package:player_android/screens/video_player_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 [VideoPlayerScreen] tests.
///
/// Only the progress methods and [streamUrl] are implemented; all other
/// methods throw [UnimplementedError] to catch unexpected usage immediately.
class _FakeApiClient extends PlayerApiClient {
_FakeApiClient() : super(dio: Dio());
/// Records how many times [getMediaProgress] was called.
int getMediaProgressCallCount = 0;
/// When non-null, [getMediaProgress] returns this value.
double? progressResult;
/// Records how many times [updateProgress] was called.
int updateProgressCallCount = 0;
/// Records how many times [updateProgressStatus] was called.
int updateProgressStatusCallCount = 0;
@override
Future<double?> getMediaProgress(int mediaId) async {
getMediaProgressCallCount++;
return progressResult;
}
@override
Future<void> updateProgress({
required int mediaId,
required double positionSeconds,
}) async {
updateProgressCallCount++;
}
@override
Future<void> updateProgressStatus({
required int mediaId,
required String status,
}) async {
updateProgressStatusCallCount++;
}
/// Returns a synthetic stream URL so [VideoPlayerController.networkUrl] can
/// be constructed even though it will fail to initialise (no platform).
@override
String streamUrl(int mediaId) =>
'http://localhost:8080/api/v1/media/$mediaId/stream';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Pumps [VideoPlayerScreen] for [mediaId] inside a [ProviderScope] with
/// overridden providers, backed by a minimal [GoRouter] for navigation.
///
/// [mediaUrl] may be supplied to exercise the route-extra URL path; when null
/// the screen falls back to [PlayerApiClient.streamUrl].
///
/// The returned widget is rendered after the first frame but BEFORE
/// addPostFrameCallback fires, so _isLoading is still true.
Future<void> _pumpScreen(
WidgetTester tester,
_FakeApiClient fakeClient, {
String mediaId = '42',
String? mediaUrl,
}) async {
final router = GoRouter(
initialLocation: '/video/$mediaId',
routes: [
GoRoute(
path: '/video/:mediaId',
builder: (context, state) => VideoPlayerScreen(
mediaId: state.pathParameters['mediaId']!,
mediaUrl: mediaUrl,
),
),
],
);
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 a loading indicator immediately after pumpWidget (before initState callback fires)',
(tester) async {
final fakeClient = _FakeApiClient();
// pumpWidget renders the first frame with _isLoading == true but does
// NOT fire addPostFrameCallback yet — that fires on the next pump.
await _pumpScreen(tester, fakeClient);
// Immediately after pumpWidget the initial build has completed with
// _isLoading = true; the spinner must be visible at this point.
expect(
find.byKey(const Key('video_player_loading')),
findsOneWidget,
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Drain remaining async work to avoid "pending timers" warnings.
await tester.pumpAndSettle();
});
});
// --------------------------------------------------------------------------
// Error state
// --------------------------------------------------------------------------
group('error state', () {
testWidgets(
'shows error view when VideoPlayerController fails to initialise',
(tester) async {
final fakeClient = _FakeApiClient();
await _pumpScreen(tester, fakeClient);
// pumpAndSettle processes addPostFrameCallback → _initPlayer → throws →
// error state is rendered.
await tester.pumpAndSettle();
expect(find.byKey(const Key('video_player_error')), findsOneWidget);
});
testWidgets('error view contains a human-readable message', (tester) async {
final fakeClient = _FakeApiClient();
await _pumpScreen(tester, fakeClient);
await tester.pumpAndSettle();
// The error message key should be present and contain non-empty text.
expect(
find.byKey(const Key('video_player_error_message')),
findsOneWidget,
);
// Verify the text widget is present with a non-empty string inside the
// error_message keyed slot.
final textWidget = tester.widget<Text>(
find.byKey(const Key('video_player_error_message')),
);
expect(textWidget.data, isNotEmpty);
});
testWidgets('error view contains a retry button', (tester) async {
final fakeClient = _FakeApiClient();
await _pumpScreen(tester, fakeClient);
await tester.pumpAndSettle();
expect(find.byKey(const Key('video_player_retry')), findsOneWidget);
});
testWidgets(
'tapping retry eventually shows the error state again after re-initialisation',
(tester) async {
final fakeClient = _FakeApiClient();
await _pumpScreen(tester, fakeClient);
await tester.pumpAndSettle();
// Confirm we start in the error state.
expect(find.byKey(const Key('video_player_error')), findsOneWidget);
// Tap Retry — this calls _onRetry which calls setState then _initPlayer.
await tester.tap(find.byKey(const Key('video_player_retry')));
// Let the second initialisation attempt complete (also fails in test
// harness due to no platform plugin) and settle back into error state.
await tester.pumpAndSettle();
expect(find.byKey(const Key('video_player_error')), findsOneWidget);
});
});
// --------------------------------------------------------------------------
// AppBar
// --------------------------------------------------------------------------
group('app bar', () {
testWidgets('renders title containing the mediaId', (tester) async {
final fakeClient = _FakeApiClient();
await _pumpScreen(tester, fakeClient, mediaId: '99');
await tester.pump();
// The title includes the mediaId string somewhere in the widget tree.
expect(find.textContaining('99'), findsWidgets);
});
});
// --------------------------------------------------------------------------
// Stream URL resolution
// --------------------------------------------------------------------------
group('stream URL resolution', () {
testWidgets('transitions through loading to error when mediaUrl is null',
(tester) async {
// When mediaUrl is null the screen calls client.streamUrl(mediaId).
// We verify the full lifecycle: starts loading → error after init fails.
final fakeClient = _FakeApiClient();
await _pumpScreen(tester, fakeClient, mediaUrl: null);
// Immediately after pumpWidget the loading state is visible.
expect(find.byKey(const Key('video_player_loading')), findsOneWidget);
// After settling, error state is shown (platform plugin missing).
await tester.pumpAndSettle();
expect(find.byKey(const Key('video_player_error')), findsOneWidget);
});
testWidgets(
'transitions through loading to error when an explicit mediaUrl is given',
(tester) async {
final fakeClient = _FakeApiClient();
await _pumpScreen(
tester,
fakeClient,
mediaUrl: 'http://localhost:8080/api/v1/media/42/stream',
);
// Loading state visible immediately after pumpWidget.
expect(find.byKey(const Key('video_player_loading')), findsOneWidget);
// Settles to error state (VideoPlayerController fails in test harness).
await tester.pumpAndSettle();
expect(find.byKey(const Key('video_player_error')), findsOneWidget);
});
});
}
|