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}