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}