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}