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