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" title when authenticated', (
219 tester,
220 ) async {
221 fakeAuthProvider.setAuthenticated(value: true);
222
223 await tester.pumpWidget(createTestWidget());
224
225 expect(find.text('Feed'), findsOneWidget);
226 });
227
228 testWidgets('should display "Explore" title when not authenticated', (
229 tester,
230 ) async {
231 fakeAuthProvider.setAuthenticated(value: false);
232
233 await tester.pumpWidget(createTestWidget());
234
235 expect(find.text('Explore'), findsOneWidget);
236 });
237
238 testWidgets('should handle pull-to-refresh', (tester) async {
239 final mockPosts = [_createMockPost('Test Post')];
240 fakeFeedProvider.setPosts(mockPosts);
241
242 await tester.pumpWidget(createTestWidget());
243 await tester.pumpAndSettle();
244
245 // Verify RefreshIndicator exists
246 expect(find.byType(RefreshIndicator), findsOneWidget);
247
248 // The loadFeed is called once on init
249 expect(fakeFeedProvider.loadFeedCallCount, 1);
250 });
251
252 testWidgets('should show loading indicator at bottom when loading more', (
253 tester,
254 ) async {
255 final mockPosts = [_createMockPost('Test Post')];
256 fakeFeedProvider
257 ..setPosts(mockPosts)
258 ..setLoadingMore(value: true);
259
260 await tester.pumpWidget(createTestWidget());
261
262 // Should show the post and a loading indicator
263 expect(find.text('Test Post'), findsOneWidget);
264 expect(find.byType(CircularProgressIndicator), findsOneWidget);
265 });
266
267 testWidgets('should have SafeArea wrapping body', (tester) async {
268 await tester.pumpWidget(createTestWidget());
269
270 // Should have SafeArea widget(s) in the tree
271 expect(find.byType(SafeArea), findsWidgets);
272 });
273
274 testWidgets('should display post stats correctly', (tester) async {
275 final mockPost = FeedViewPost(
276 post: PostView(
277 uri: 'at://test',
278 cid: 'test-cid',
279 rkey: 'test-rkey',
280 author: AuthorView(
281 did: 'did:plc:author',
282 handle: 'test.user',
283 displayName: 'Test User',
284 ),
285 community: CommunityRef(
286 did: 'did:plc:community',
287 name: 'test-community',
288 handle: 'test-community.community.coves.social',
289 ),
290 createdAt: DateTime.now(),
291 indexedAt: DateTime.now(),
292 text: 'Test body',
293 title: 'Test Post',
294 stats: PostStats(
295 score: 42,
296 upvotes: 50,
297 downvotes: 8,
298 commentCount: 5,
299 ),
300 facets: [],
301 ),
302 );
303
304 fakeFeedProvider.setPosts([mockPost]);
305
306 await tester.pumpWidget(createTestWidget());
307
308 expect(find.text('42'), findsOneWidget); // score
309 expect(find.text('5'), findsOneWidget); // comment count
310 });
311
312 testWidgets('should display community and author info', (tester) async {
313 final mockPost = _createMockPost('Test Post');
314 fakeFeedProvider.setPosts([mockPost]);
315
316 await tester.pumpWidget(createTestWidget());
317
318 // Check for community handle parts (displayed as !test-community@coves.social)
319 expect(find.textContaining('!test-community'), findsOneWidget);
320 expect(find.text('@test.user'), findsOneWidget);
321 });
322
323 testWidgets('should call loadFeed on init', (tester) async {
324 await tester.pumpWidget(createTestWidget());
325 await tester.pumpAndSettle();
326
327 expect(fakeFeedProvider.loadFeedCallCount, 1);
328 });
329
330 testWidgets('should have proper accessibility semantics', (tester) async {
331 final mockPost = _createMockPost('Accessible Post');
332 fakeFeedProvider.setPosts([mockPost]);
333
334 await tester.pumpWidget(createTestWidget());
335 await tester.pumpAndSettle();
336
337 // Check for Semantics widgets (post should have semantic label)
338 expect(find.byType(Semantics), findsWidgets);
339
340 // Verify post card exists (which contains Semantics wrapper)
341 expect(find.text('Accessible Post'), findsOneWidget);
342 // Check for community handle parts (displayed as !test-community@coves.social)
343 expect(find.textContaining('!test-community'), findsOneWidget);
344 expect(find.textContaining('@coves.social'), findsOneWidget);
345 });
346
347 testWidgets('should properly dispose scroll controller', (tester) async {
348 await tester.pumpWidget(createTestWidget());
349 await tester.pumpAndSettle();
350
351 // Change to a different widget to trigger dispose
352 await tester.pumpWidget(const MaterialApp(home: Scaffold()));
353
354 // If we get here without errors, dispose was called properly
355 expect(true, true);
356 });
357 });
358}
359
360// Helper function to create mock posts
361FeedViewPost _createMockPost(String title) {
362 return FeedViewPost(
363 post: PostView(
364 uri: 'at://did:plc:test/app.bsky.feed.post/test',
365 cid: 'test-cid',
366 rkey: 'test-rkey',
367 author: AuthorView(
368 did: 'did:plc:author',
369 handle: 'test.user',
370 displayName: 'Test User',
371 ),
372 community: CommunityRef(
373 did: 'did:plc:community',
374 name: 'test-community',
375 handle: 'test-community.community.coves.social',
376 ),
377 createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
378 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
379 text: 'Test body',
380 title: title,
381 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
382 facets: [],
383 ),
384 );
385}