at main 13 kB view raw
1import 'package:coves_flutter/models/feed_state.dart'; 2import 'package:coves_flutter/models/post.dart'; 3import 'package:coves_flutter/providers/auth_provider.dart'; 4import 'package:coves_flutter/providers/multi_feed_provider.dart'; 5import 'package:coves_flutter/providers/vote_provider.dart'; 6import 'package:coves_flutter/screens/home/feed_screen.dart'; 7import 'package:coves_flutter/services/vote_service.dart'; 8import 'package:flutter/material.dart'; 9import 'package:flutter_test/flutter_test.dart'; 10import 'package:provider/provider.dart'; 11 12// Fake AuthProvider for testing 13class FakeAuthProvider extends AuthProvider { 14 bool _isAuthenticated = false; 15 bool _isLoading = false; 16 17 @override 18 bool get isAuthenticated => _isAuthenticated; 19 20 @override 21 bool get isLoading => _isLoading; 22 23 void setAuthenticated({required bool value}) { 24 _isAuthenticated = value; 25 notifyListeners(); 26 } 27 28 void setLoading({required bool value}) { 29 _isLoading = value; 30 notifyListeners(); 31 } 32} 33 34// Fake VoteProvider for testing 35class FakeVoteProvider extends VoteProvider { 36 FakeVoteProvider() 37 : super( 38 voteService: VoteService( 39 sessionGetter: () async => null, 40 didGetter: () => 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, {required bool value}) { 51 _likes[postUri] = value; 52 notifyListeners(); 53 } 54} 55 56// Fake MultiFeedProvider for testing 57class FakeMultiFeedProvider extends MultiFeedProvider { 58 FakeMultiFeedProvider() : super(FakeAuthProvider()); 59 60 final Map<FeedType, FeedState> _states = { 61 FeedType.discover: FeedState.initial(), 62 FeedType.forYou: FeedState.initial(), 63 }; 64 65 int _loadFeedCallCount = 0; 66 int _retryCallCount = 0; 67 68 int get loadFeedCallCount => _loadFeedCallCount; 69 int get retryCallCount => _retryCallCount; 70 71 @override 72 FeedState getState(FeedType type) => _states[type] ?? FeedState.initial(); 73 74 void setStateForType(FeedType type, FeedState state) { 75 _states[type] = state; 76 notifyListeners(); 77 } 78 79 void setPosts(FeedType type, List<FeedViewPost> posts) { 80 _states[type] = _states[type]!.copyWith(posts: posts); 81 notifyListeners(); 82 } 83 84 void setLoading(FeedType type, {required bool value}) { 85 _states[type] = _states[type]!.copyWith(isLoading: value); 86 notifyListeners(); 87 } 88 89 void setLoadingMore(FeedType type, {required bool value}) { 90 _states[type] = _states[type]!.copyWith(isLoadingMore: value); 91 notifyListeners(); 92 } 93 94 void setError(FeedType type, String? value) { 95 _states[type] = _states[type]!.copyWith(error: value); 96 notifyListeners(); 97 } 98 99 void setHasMore(FeedType type, {required bool value}) { 100 _states[type] = _states[type]!.copyWith(hasMore: value); 101 notifyListeners(); 102 } 103 104 @override 105 Future<void> loadFeed(FeedType type, {bool refresh = false}) async { 106 _loadFeedCallCount++; 107 } 108 109 @override 110 Future<void> retry(FeedType type) async { 111 _retryCallCount++; 112 } 113 114 @override 115 Future<void> loadMore(FeedType type) async { 116 // No-op for testing 117 } 118 119 @override 120 void saveScrollPosition(FeedType type, double position) { 121 // No-op for testing 122 } 123} 124 125void main() { 126 group('FeedScreen Widget Tests', () { 127 late FakeAuthProvider fakeAuthProvider; 128 late FakeMultiFeedProvider fakeFeedProvider; 129 late FakeVoteProvider fakeVoteProvider; 130 131 setUp(() { 132 fakeAuthProvider = FakeAuthProvider(); 133 fakeFeedProvider = FakeMultiFeedProvider(); 134 fakeVoteProvider = FakeVoteProvider(); 135 }); 136 137 Widget createTestWidget() { 138 return MultiProvider( 139 providers: [ 140 ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider), 141 ChangeNotifierProvider<MultiFeedProvider>.value( 142 value: fakeFeedProvider, 143 ), 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(FeedType.discover, 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(FeedType.discover, '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(FeedType.discover, []); 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(FeedType.discover, []); 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(FeedType.discover, 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 type tabs when authenticated', ( 220 tester, 221 ) async { 222 fakeAuthProvider.setAuthenticated(value: true); 223 224 await tester.pumpWidget(createTestWidget()); 225 226 expect(find.text('Discover'), findsOneWidget); 227 expect(find.text('For You'), findsOneWidget); 228 }); 229 230 testWidgets('should display only Discover tab when not authenticated', ( 231 tester, 232 ) async { 233 fakeAuthProvider.setAuthenticated(value: false); 234 235 await tester.pumpWidget(createTestWidget()); 236 237 expect(find.text('Discover'), findsOneWidget); 238 expect(find.text('For You'), findsNothing); 239 }); 240 241 testWidgets('should handle pull-to-refresh', (tester) async { 242 final mockPosts = [_createMockPost('Test Post')]; 243 fakeFeedProvider.setPosts(FeedType.discover, mockPosts); 244 245 await tester.pumpWidget(createTestWidget()); 246 await tester.pumpAndSettle(); 247 248 // Verify RefreshIndicator exists 249 expect(find.byType(RefreshIndicator), findsOneWidget); 250 251 // loadFeed is called once for initial load (or twice if authenticated) 252 expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1)); 253 }); 254 255 testWidgets('should show loading indicator at bottom when loading more', ( 256 tester, 257 ) async { 258 final mockPosts = [_createMockPost('Test Post')]; 259 fakeFeedProvider 260 ..setPosts(FeedType.discover, mockPosts) 261 ..setLoadingMore(FeedType.discover, value: true); 262 263 await tester.pumpWidget(createTestWidget()); 264 265 // Should show the post and a loading indicator 266 expect(find.text('Test Post'), findsOneWidget); 267 expect(find.byType(CircularProgressIndicator), findsOneWidget); 268 }); 269 270 testWidgets('should have SafeArea wrapping body', (tester) async { 271 await tester.pumpWidget(createTestWidget()); 272 273 // Should have SafeArea widget(s) in the tree 274 expect(find.byType(SafeArea), findsWidgets); 275 }); 276 277 testWidgets('should display post stats correctly', (tester) async { 278 final mockPost = FeedViewPost( 279 post: PostView( 280 uri: 'at://test', 281 cid: 'test-cid', 282 rkey: 'test-rkey', 283 author: AuthorView( 284 did: 'did:plc:author', 285 handle: 'test.user', 286 displayName: 'Test User', 287 ), 288 community: CommunityRef( 289 did: 'did:plc:community', 290 name: 'test-community', 291 handle: 'test-community.community.coves.social', 292 ), 293 createdAt: DateTime.now(), 294 indexedAt: DateTime.now(), 295 text: 'Test body', 296 title: 'Test Post', 297 stats: PostStats( 298 score: 42, 299 upvotes: 50, 300 downvotes: 8, 301 commentCount: 5, 302 ), 303 facets: [], 304 ), 305 ); 306 307 fakeFeedProvider.setPosts(FeedType.discover, [mockPost]); 308 309 await tester.pumpWidget(createTestWidget()); 310 311 expect(find.text('42'), findsOneWidget); // score 312 expect(find.text('5'), findsOneWidget); // comment count 313 }); 314 315 testWidgets('should display community and author info', (tester) async { 316 final mockPost = _createMockPost('Test Post'); 317 fakeFeedProvider.setPosts(FeedType.discover, [mockPost]); 318 319 await tester.pumpWidget(createTestWidget()); 320 321 // Check for community handle parts (displayed as !test-community@...) 322 expect(find.textContaining('!test-community'), findsOneWidget); 323 expect(find.text('@test.user'), findsOneWidget); 324 }); 325 326 testWidgets('should call loadFeed on init', (tester) async { 327 await tester.pumpWidget(createTestWidget()); 328 await tester.pumpAndSettle(); 329 330 expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1)); 331 }); 332 333 testWidgets('should have proper accessibility semantics', (tester) async { 334 final mockPost = _createMockPost('Accessible Post'); 335 fakeFeedProvider.setPosts(FeedType.discover, [mockPost]); 336 337 await tester.pumpWidget(createTestWidget()); 338 await tester.pumpAndSettle(); 339 340 // Check for Semantics widgets (post should have semantic label) 341 expect(find.byType(Semantics), findsWidgets); 342 343 // Verify post card exists (which contains Semantics wrapper) 344 expect(find.text('Accessible Post'), findsOneWidget); 345 // Check for community handle parts 346 expect(find.textContaining('!test-community'), findsOneWidget); 347 expect(find.textContaining('@coves.social'), findsOneWidget); 348 }); 349 350 testWidgets('should properly dispose scroll controller', (tester) async { 351 await tester.pumpWidget(createTestWidget()); 352 await tester.pumpAndSettle(); 353 354 // Change to a different widget to trigger dispose 355 await tester.pumpWidget(const MaterialApp(home: Scaffold())); 356 357 // If we get here without errors, dispose was called properly 358 expect(true, true); 359 }); 360 361 testWidgets('should support swipe navigation when authenticated', ( 362 tester, 363 ) async { 364 fakeAuthProvider.setAuthenticated(value: true); 365 fakeFeedProvider 366 ..setPosts(FeedType.discover, [_createMockPost('Post 1')]) 367 ..setPosts(FeedType.forYou, [_createMockPost('Post 2')]); 368 369 await tester.pumpWidget(createTestWidget()); 370 await tester.pumpAndSettle(); 371 372 // PageView should exist for authenticated users 373 expect(find.byType(PageView), findsOneWidget); 374 }); 375 376 testWidgets('should not have PageView when not authenticated', ( 377 tester, 378 ) async { 379 fakeAuthProvider.setAuthenticated(value: false); 380 fakeFeedProvider.setPosts(FeedType.discover, [_createMockPost('Post 1')]); 381 382 await tester.pumpWidget(createTestWidget()); 383 await tester.pumpAndSettle(); 384 385 // PageView should not exist for unauthenticated users 386 expect(find.byType(PageView), findsNothing); 387 }); 388 }); 389} 390 391// Helper function to create mock posts 392FeedViewPost _createMockPost(String title) { 393 return FeedViewPost( 394 post: PostView( 395 uri: 'at://did:plc:test/app.bsky.feed.post/test', 396 cid: 'test-cid', 397 rkey: 'test-rkey', 398 author: AuthorView( 399 did: 'did:plc:author', 400 handle: 'test.user', 401 displayName: 'Test User', 402 ), 403 community: CommunityRef( 404 did: 'did:plc:community', 405 name: 'test-community', 406 handle: 'test-community.community.coves.social', 407 ), 408 createdAt: DateTime.parse('2025-01-01T12:00:00Z'), 409 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), 410 text: 'Test body', 411 title: title, 412 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5), 413 facets: [], 414 ), 415 ); 416}