Main coves client
1import 'package:coves_flutter/models/feed_state.dart';
2import 'package:coves_flutter/models/post.dart';
3import 'package:coves_flutter/providers/auth_provider.dart';
4import 'package:coves_flutter/providers/multi_feed_provider.dart';
5import 'package:coves_flutter/providers/vote_provider.dart';
6import 'package:coves_flutter/screens/home/feed_screen.dart';
7import 'package:coves_flutter/services/vote_service.dart';
8import 'package:flutter/material.dart';
9import 'package:flutter_test/flutter_test.dart';
10import 'package:provider/provider.dart';
11
12// Fake AuthProvider for testing
13class FakeAuthProvider extends AuthProvider {
14 bool _isAuthenticated = false;
15 bool _isLoading = false;
16
17 @override
18 bool get isAuthenticated => _isAuthenticated;
19
20 @override
21 bool get isLoading => _isLoading;
22
23 void setAuthenticated({required bool value}) {
24 _isAuthenticated = value;
25 notifyListeners();
26 }
27
28 void setLoading({required bool value}) {
29 _isLoading = value;
30 notifyListeners();
31 }
32}
33
34// Fake VoteProvider for testing
35class FakeVoteProvider extends VoteProvider {
36 FakeVoteProvider()
37 : super(
38 voteService: VoteService(
39 sessionGetter: () async => null,
40 didGetter: () => null,
41 ),
42 authProvider: FakeAuthProvider(),
43 );
44
45 final Map<String, bool> _likes = {};
46
47 @override
48 bool isLiked(String postUri) => _likes[postUri] ?? false;
49
50 void setLiked(String postUri, {required bool value}) {
51 _likes[postUri] = value;
52 notifyListeners();
53 }
54}
55
56// Fake MultiFeedProvider for testing
57class FakeMultiFeedProvider extends MultiFeedProvider {
58 FakeMultiFeedProvider() : super(FakeAuthProvider());
59
60 final Map<FeedType, FeedState> _states = {
61 FeedType.discover: FeedState.initial(),
62 FeedType.forYou: FeedState.initial(),
63 };
64
65 int _loadFeedCallCount = 0;
66 int _retryCallCount = 0;
67
68 int get loadFeedCallCount => _loadFeedCallCount;
69 int get retryCallCount => _retryCallCount;
70
71 @override
72 FeedState getState(FeedType type) => _states[type] ?? FeedState.initial();
73
74 void setStateForType(FeedType type, FeedState state) {
75 _states[type] = state;
76 notifyListeners();
77 }
78
79 void setPosts(FeedType type, List<FeedViewPost> posts) {
80 _states[type] = _states[type]!.copyWith(posts: posts);
81 notifyListeners();
82 }
83
84 void setLoading(FeedType type, {required bool value}) {
85 _states[type] = _states[type]!.copyWith(isLoading: value);
86 notifyListeners();
87 }
88
89 void setLoadingMore(FeedType type, {required bool value}) {
90 _states[type] = _states[type]!.copyWith(isLoadingMore: value);
91 notifyListeners();
92 }
93
94 void setError(FeedType type, String? value) {
95 _states[type] = _states[type]!.copyWith(error: value);
96 notifyListeners();
97 }
98
99 void setHasMore(FeedType type, {required bool value}) {
100 _states[type] = _states[type]!.copyWith(hasMore: value);
101 notifyListeners();
102 }
103
104 @override
105 Future<void> loadFeed(FeedType type, {bool refresh = false}) async {
106 _loadFeedCallCount++;
107 }
108
109 @override
110 Future<void> retry(FeedType type) async {
111 _retryCallCount++;
112 }
113
114 @override
115 Future<void> loadMore(FeedType type) async {
116 // No-op for testing
117 }
118
119 @override
120 void saveScrollPosition(FeedType type, double position) {
121 // No-op for testing
122 }
123}
124
125void main() {
126 group('FeedScreen Widget Tests', () {
127 late FakeAuthProvider fakeAuthProvider;
128 late FakeMultiFeedProvider fakeFeedProvider;
129 late FakeVoteProvider fakeVoteProvider;
130
131 setUp(() {
132 fakeAuthProvider = FakeAuthProvider();
133 fakeFeedProvider = FakeMultiFeedProvider();
134 fakeVoteProvider = FakeVoteProvider();
135 });
136
137 Widget createTestWidget() {
138 return MultiProvider(
139 providers: [
140 ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
141 ChangeNotifierProvider<MultiFeedProvider>.value(
142 value: fakeFeedProvider,
143 ),
144 ChangeNotifierProvider<VoteProvider>.value(value: fakeVoteProvider),
145 ],
146 child: const MaterialApp(home: FeedScreen()),
147 );
148 }
149
150 testWidgets('should display loading indicator when loading', (
151 tester,
152 ) async {
153 fakeFeedProvider.setLoading(FeedType.discover, value: true);
154
155 await tester.pumpWidget(createTestWidget());
156
157 expect(find.byType(CircularProgressIndicator), findsOneWidget);
158 });
159
160 testWidgets('should display error state with retry button', (tester) async {
161 fakeFeedProvider.setError(FeedType.discover, 'Network error');
162
163 await tester.pumpWidget(createTestWidget());
164
165 expect(find.text('Failed to load feed'), findsOneWidget);
166 // Error message is transformed to user-friendly message
167 expect(
168 find.text('Please check your internet connection'),
169 findsOneWidget,
170 );
171 expect(find.text('Retry'), findsOneWidget);
172
173 // Test retry button
174 await tester.tap(find.text('Retry'));
175 await tester.pump();
176
177 expect(fakeFeedProvider.retryCallCount, 1);
178 });
179
180 testWidgets('should display empty state when no posts', (tester) async {
181 fakeFeedProvider.setPosts(FeedType.discover, []);
182 fakeAuthProvider.setAuthenticated(value: false);
183
184 await tester.pumpWidget(createTestWidget());
185
186 expect(find.text('No posts to discover'), findsOneWidget);
187 expect(find.text('Check back later for new posts'), findsOneWidget);
188 });
189
190 testWidgets('should display different empty state when authenticated', (
191 tester,
192 ) async {
193 fakeFeedProvider.setPosts(FeedType.discover, []);
194 fakeAuthProvider.setAuthenticated(value: true);
195
196 await tester.pumpWidget(createTestWidget());
197
198 expect(find.text('No posts yet'), findsOneWidget);
199 expect(
200 find.text('Subscribe to communities to see posts in your feed'),
201 findsOneWidget,
202 );
203 });
204
205 testWidgets('should display posts when available', (tester) async {
206 final mockPosts = [
207 _createMockPost('Test Post 1'),
208 _createMockPost('Test Post 2'),
209 ];
210
211 fakeFeedProvider.setPosts(FeedType.discover, mockPosts);
212
213 await tester.pumpWidget(createTestWidget());
214
215 expect(find.text('Test Post 1'), findsOneWidget);
216 expect(find.text('Test Post 2'), findsOneWidget);
217 });
218
219 testWidgets('should display feed type tabs when authenticated', (
220 tester,
221 ) async {
222 fakeAuthProvider.setAuthenticated(value: true);
223
224 await tester.pumpWidget(createTestWidget());
225
226 expect(find.text('Discover'), findsOneWidget);
227 expect(find.text('For You'), findsOneWidget);
228 });
229
230 testWidgets('should display only Discover tab when not authenticated', (
231 tester,
232 ) async {
233 fakeAuthProvider.setAuthenticated(value: false);
234
235 await tester.pumpWidget(createTestWidget());
236
237 expect(find.text('Discover'), findsOneWidget);
238 expect(find.text('For You'), findsNothing);
239 });
240
241 testWidgets('should handle pull-to-refresh', (tester) async {
242 final mockPosts = [_createMockPost('Test Post')];
243 fakeFeedProvider.setPosts(FeedType.discover, mockPosts);
244
245 await tester.pumpWidget(createTestWidget());
246 await tester.pumpAndSettle();
247
248 // Verify RefreshIndicator exists
249 expect(find.byType(RefreshIndicator), findsOneWidget);
250
251 // loadFeed is called once for initial load (or twice if authenticated)
252 expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1));
253 });
254
255 testWidgets('should show loading indicator at bottom when loading more', (
256 tester,
257 ) async {
258 final mockPosts = [_createMockPost('Test Post')];
259 fakeFeedProvider
260 ..setPosts(FeedType.discover, mockPosts)
261 ..setLoadingMore(FeedType.discover, value: true);
262
263 await tester.pumpWidget(createTestWidget());
264
265 // Should show the post and a loading indicator
266 expect(find.text('Test Post'), findsOneWidget);
267 expect(find.byType(CircularProgressIndicator), findsOneWidget);
268 });
269
270 testWidgets('should have SafeArea wrapping body', (tester) async {
271 await tester.pumpWidget(createTestWidget());
272
273 // Should have SafeArea widget(s) in the tree
274 expect(find.byType(SafeArea), findsWidgets);
275 });
276
277 testWidgets('should display post stats correctly', (tester) async {
278 final mockPost = FeedViewPost(
279 post: PostView(
280 uri: 'at://test',
281 cid: 'test-cid',
282 rkey: 'test-rkey',
283 author: AuthorView(
284 did: 'did:plc:author',
285 handle: 'test.user',
286 displayName: 'Test User',
287 ),
288 community: CommunityRef(
289 did: 'did:plc:community',
290 name: 'test-community',
291 handle: 'test-community.community.coves.social',
292 ),
293 createdAt: DateTime.now(),
294 indexedAt: DateTime.now(),
295 text: 'Test body',
296 title: 'Test Post',
297 stats: PostStats(
298 score: 42,
299 upvotes: 50,
300 downvotes: 8,
301 commentCount: 5,
302 ),
303 facets: [],
304 ),
305 );
306
307 fakeFeedProvider.setPosts(FeedType.discover, [mockPost]);
308
309 await tester.pumpWidget(createTestWidget());
310
311 expect(find.text('42'), findsOneWidget); // score
312 expect(find.text('5'), findsOneWidget); // comment count
313 });
314
315 testWidgets('should display community and author info', (tester) async {
316 final mockPost = _createMockPost('Test Post');
317 fakeFeedProvider.setPosts(FeedType.discover, [mockPost]);
318
319 await tester.pumpWidget(createTestWidget());
320
321 // Check for community handle parts (displayed as !test-community@...)
322 expect(find.textContaining('!test-community'), findsOneWidget);
323 expect(find.text('@test.user'), findsOneWidget);
324 });
325
326 testWidgets('should call loadFeed on init', (tester) async {
327 await tester.pumpWidget(createTestWidget());
328 await tester.pumpAndSettle();
329
330 expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1));
331 });
332
333 testWidgets('should have proper accessibility semantics', (tester) async {
334 final mockPost = _createMockPost('Accessible Post');
335 fakeFeedProvider.setPosts(FeedType.discover, [mockPost]);
336
337 await tester.pumpWidget(createTestWidget());
338 await tester.pumpAndSettle();
339
340 // Check for Semantics widgets (post should have semantic label)
341 expect(find.byType(Semantics), findsWidgets);
342
343 // Verify post card exists (which contains Semantics wrapper)
344 expect(find.text('Accessible Post'), findsOneWidget);
345 // Check for community handle parts
346 expect(find.textContaining('!test-community'), findsOneWidget);
347 expect(find.textContaining('@coves.social'), findsOneWidget);
348 });
349
350 testWidgets('should properly dispose scroll controller', (tester) async {
351 await tester.pumpWidget(createTestWidget());
352 await tester.pumpAndSettle();
353
354 // Change to a different widget to trigger dispose
355 await tester.pumpWidget(const MaterialApp(home: Scaffold()));
356
357 // If we get here without errors, dispose was called properly
358 expect(true, true);
359 });
360
361 testWidgets('should support swipe navigation when authenticated', (
362 tester,
363 ) async {
364 fakeAuthProvider.setAuthenticated(value: true);
365 fakeFeedProvider
366 ..setPosts(FeedType.discover, [_createMockPost('Post 1')])
367 ..setPosts(FeedType.forYou, [_createMockPost('Post 2')]);
368
369 await tester.pumpWidget(createTestWidget());
370 await tester.pumpAndSettle();
371
372 // PageView should exist for authenticated users
373 expect(find.byType(PageView), findsOneWidget);
374 });
375
376 testWidgets('should not have PageView when not authenticated', (
377 tester,
378 ) async {
379 fakeAuthProvider.setAuthenticated(value: false);
380 fakeFeedProvider.setPosts(FeedType.discover, [_createMockPost('Post 1')]);
381
382 await tester.pumpWidget(createTestWidget());
383 await tester.pumpAndSettle();
384
385 // PageView should not exist for unauthenticated users
386 expect(find.byType(PageView), findsNothing);
387 });
388 });
389}
390
391// Helper function to create mock posts
392FeedViewPost _createMockPost(String title) {
393 return FeedViewPost(
394 post: PostView(
395 uri: 'at://did:plc:test/app.bsky.feed.post/test',
396 cid: 'test-cid',
397 rkey: 'test-rkey',
398 author: AuthorView(
399 did: 'did:plc:author',
400 handle: 'test.user',
401 displayName: 'Test User',
402 ),
403 community: CommunityRef(
404 did: 'did:plc:community',
405 name: 'test-community',
406 handle: 'test-community.community.coves.social',
407 ),
408 createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
409 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
410 text: 'Test body',
411 title: title,
412 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
413 facets: [],
414 ),
415 );
416}