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 pdsUrlGetter: () => 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, bool value) {
51 _likes[postUri] = value;
52 notifyListeners();
53 }
54}
55
56// Fake FeedProvider for testing
57class FakeFeedProvider extends FeedProvider {
58 FakeFeedProvider() : super(FakeAuthProvider());
59
60 List<FeedViewPost> _posts = [];
61 bool _isLoading = false;
62 bool _isLoadingMore = false;
63 String? _error;
64 bool _hasMore = true;
65 int _loadFeedCallCount = 0;
66 int _retryCallCount = 0;
67
68 @override
69 List<FeedViewPost> get posts => _posts;
70
71 @override
72 bool get isLoading => _isLoading;
73
74 @override
75 bool get isLoadingMore => _isLoadingMore;
76
77 @override
78 String? get error => _error;
79
80 @override
81 bool get hasMore => _hasMore;
82
83 int get loadFeedCallCount => _loadFeedCallCount;
84 int get retryCallCount => _retryCallCount;
85
86 void setPosts(List<FeedViewPost> value) {
87 _posts = value;
88 notifyListeners();
89 }
90
91 void setLoading({required bool value}) {
92 _isLoading = value;
93 notifyListeners();
94 }
95
96 void setLoadingMore({required bool value}) {
97 _isLoadingMore = value;
98 notifyListeners();
99 }
100
101 void setError(String? value) {
102 _error = value;
103 notifyListeners();
104 }
105
106 void setHasMore({required bool value}) {
107 _hasMore = value;
108 notifyListeners();
109 }
110
111 @override
112 Future<void> loadFeed({bool refresh = false}) async {
113 _loadFeedCallCount++;
114 }
115
116 @override
117 Future<void> retry() async {
118 _retryCallCount++;
119 }
120
121 @override
122 Future<void> loadMore() async {
123 // No-op for testing
124 }
125}
126
127void main() {
128 group('FeedScreen Widget Tests', () {
129 late FakeAuthProvider fakeAuthProvider;
130 late FakeFeedProvider fakeFeedProvider;
131 late FakeVoteProvider fakeVoteProvider;
132
133 setUp(() {
134 fakeAuthProvider = FakeAuthProvider();
135 fakeFeedProvider = FakeFeedProvider();
136 fakeVoteProvider = FakeVoteProvider();
137 });
138
139 Widget createTestWidget() {
140 return MultiProvider(
141 providers: [
142 ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
143 ChangeNotifierProvider<FeedProvider>.value(value: fakeFeedProvider),
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(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('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([]);
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([]);
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(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" title when authenticated', (
220 tester,
221 ) async {
222 fakeAuthProvider.setAuthenticated(value: true);
223
224 await tester.pumpWidget(createTestWidget());
225
226 expect(find.text('Feed'), findsOneWidget);
227 });
228
229 testWidgets('should display "Explore" title when not authenticated', (
230 tester,
231 ) async {
232 fakeAuthProvider.setAuthenticated(value: false);
233
234 await tester.pumpWidget(createTestWidget());
235
236 expect(find.text('Explore'), findsOneWidget);
237 });
238
239 testWidgets('should handle pull-to-refresh', (tester) async {
240 final mockPosts = [_createMockPost('Test Post')];
241 fakeFeedProvider.setPosts(mockPosts);
242
243 await tester.pumpWidget(createTestWidget());
244 await tester.pumpAndSettle();
245
246 // Verify RefreshIndicator exists
247 expect(find.byType(RefreshIndicator), findsOneWidget);
248
249 // The loadFeed is called once on init
250 expect(fakeFeedProvider.loadFeedCallCount, 1);
251 });
252
253 testWidgets('should show loading indicator at bottom when loading more', (
254 tester,
255 ) async {
256 final mockPosts = [_createMockPost('Test Post')];
257 fakeFeedProvider
258 ..setPosts(mockPosts)
259 ..setLoadingMore(value: true);
260
261 await tester.pumpWidget(createTestWidget());
262
263 // Should show the post and a loading indicator
264 expect(find.text('Test Post'), findsOneWidget);
265 expect(find.byType(CircularProgressIndicator), findsOneWidget);
266 });
267
268 testWidgets('should have SafeArea wrapping body', (tester) async {
269 await tester.pumpWidget(createTestWidget());
270
271 // Should have SafeArea widget(s) in the tree
272 expect(find.byType(SafeArea), findsWidgets);
273 });
274
275 testWidgets('should display post stats correctly', (tester) async {
276 final mockPost = FeedViewPost(
277 post: PostView(
278 uri: 'at://test',
279 cid: 'test-cid',
280 rkey: 'test-rkey',
281 author: AuthorView(
282 did: 'did:plc:author',
283 handle: 'test.user',
284 displayName: 'Test User',
285 ),
286 community: CommunityRef(
287 did: 'did:plc:community',
288 name: 'test-community',
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 expect(find.text('c/test-community'), findsOneWidget);
319 expect(find.text('@test.user'), findsOneWidget);
320 });
321
322 testWidgets('should call loadFeed on init', (tester) async {
323 await tester.pumpWidget(createTestWidget());
324 await tester.pumpAndSettle();
325
326 expect(fakeFeedProvider.loadFeedCallCount, 1);
327 });
328
329 testWidgets('should have proper accessibility semantics', (tester) async {
330 final mockPost = _createMockPost('Accessible Post');
331 fakeFeedProvider.setPosts([mockPost]);
332
333 await tester.pumpWidget(createTestWidget());
334 await tester.pumpAndSettle();
335
336 // Check for Semantics widgets (post should have semantic label)
337 expect(find.byType(Semantics), findsWidgets);
338
339 // Verify post card exists (which contains Semantics wrapper)
340 expect(find.text('Accessible Post'), findsOneWidget);
341 expect(find.text('c/test-community'), findsOneWidget);
342 });
343
344 testWidgets('should properly dispose scroll controller', (tester) async {
345 await tester.pumpWidget(createTestWidget());
346 await tester.pumpAndSettle();
347
348 // Change to a different widget to trigger dispose
349 await tester.pumpWidget(const MaterialApp(home: Scaffold()));
350
351 // If we get here without errors, dispose was called properly
352 expect(true, true);
353 });
354 });
355}
356
357// Helper function to create mock posts
358FeedViewPost _createMockPost(String title) {
359 return FeedViewPost(
360 post: PostView(
361 uri: 'at://did:plc:test/app.bsky.feed.post/test',
362 cid: 'test-cid',
363 rkey: 'test-rkey',
364 author: AuthorView(
365 did: 'did:plc:author',
366 handle: 'test.user',
367 displayName: 'Test User',
368 ),
369 community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
370 createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
371 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
372 text: 'Test body',
373 title: title,
374 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
375 facets: [],
376 ),
377 );
378}