test(feed): update tests for multi-feed architecture

Updates widget tests for the new MultiFeedProvider architecture:

- FakeMultiFeedProvider replaces FakeFeedProvider
- Supports per-feed state management (FeedType parameter)
- Uses sentinel-compatible copyWith for state mutations
- Tests cover both authenticated and unauthenticated flows
- Tests for PageView swipe navigation when authenticated
- Tests for single-feed display when not authenticated

Removes orphaned test/providers/feed_provider_test.dart that
referenced the deleted FeedProvider.

Updates test/widget_test.dart counter test with provider setup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+87 -772
test
-715
test/providers/feed_provider_test.dart
···
-
import 'package:coves_flutter/models/post.dart';
-
import 'package:coves_flutter/providers/auth_provider.dart';
-
import 'package:coves_flutter/providers/feed_provider.dart';
-
import 'package:coves_flutter/providers/vote_provider.dart';
-
import 'package:coves_flutter/services/coves_api_service.dart';
-
import 'package:flutter_test/flutter_test.dart';
-
import 'package:mockito/annotations.dart';
-
import 'package:mockito/mockito.dart';
-
-
import 'feed_provider_test.mocks.dart';
-
-
// Generate mocks
-
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
-
void main() {
-
group('FeedProvider', () {
-
late FeedProvider feedProvider;
-
late MockAuthProvider mockAuthProvider;
-
late MockCovesApiService mockApiService;
-
-
setUp(() {
-
mockAuthProvider = MockAuthProvider();
-
mockApiService = MockCovesApiService();
-
-
// Mock default auth state
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
-
// Mock the token getter
-
when(
-
mockAuthProvider.getAccessToken(),
-
).thenAnswer((_) async => 'test-token');
-
-
// Create feed provider with injected mock service
-
feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService);
-
});
-
-
tearDown(() {
-
feedProvider.dispose();
-
});
-
-
group('loadFeed', () {
-
test('should load discover feed when authenticated by default', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.loadFeed(refresh: true);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
expect(feedProvider.isLoading, false);
-
});
-
-
test('should load timeline when feed type is For You', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
expect(feedProvider.isLoading, false);
-
});
-
-
test('should load discover feed when not authenticated', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.loadFeed(refresh: true);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
});
-
});
-
-
group('fetchTimeline', () {
-
test('should fetch timeline successfully', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost(), _createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(feedProvider.posts.length, 2);
-
expect(feedProvider.hasMore, true);
-
expect(feedProvider.error, null);
-
});
-
-
test('should handle network errors', () async {
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenThrow(Exception('Network error'));
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(feedProvider.error, isNotNull);
-
expect(feedProvider.isLoading, false);
-
});
-
-
test('should append posts when not refreshing', () async {
-
// First load
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => firstResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
-
// Second load (pagination)
-
final secondResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-2',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: 'cursor-1',
-
),
-
).thenAnswer((_) async => secondResponse);
-
-
await feedProvider.fetchTimeline();
-
expect(feedProvider.posts.length, 2);
-
});
-
-
test('should replace posts when refreshing', () async {
-
// First load
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => firstResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
-
// Refresh
-
final refreshResponse = TimelineResponse(
-
feed: [_createMockPost(), _createMockPost()],
-
cursor: 'cursor-2',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
),
-
).thenAnswer((_) async => refreshResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 2);
-
});
-
-
test('should set hasMore to false when no cursor', () async {
-
final response = TimelineResponse(feed: [_createMockPost()]);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => response);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(feedProvider.hasMore, false);
-
});
-
});
-
-
group('fetchDiscover', () {
-
test('should fetch discover feed successfully', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.fetchDiscover(refresh: true);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
});
-
-
test('should handle empty feed', () async {
-
final emptyResponse = TimelineResponse(feed: []);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => emptyResponse);
-
-
await feedProvider.fetchDiscover(refresh: true);
-
-
expect(feedProvider.posts.isEmpty, true);
-
expect(feedProvider.hasMore, false);
-
});
-
});
-
-
group('loadMore', () {
-
test('should load more posts', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
// Initial load
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => firstResponse);
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
-
// Load more
-
final secondResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-2',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: 'cursor-1',
-
),
-
).thenAnswer((_) async => secondResponse);
-
-
await feedProvider.loadMore();
-
-
expect(feedProvider.posts.length, 2);
-
});
-
-
test('should not load more if already loading', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final response = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => response);
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
await feedProvider.loadMore();
-
-
// Should not make additional calls while loading
-
});
-
-
test('should not load more if hasMore is false', () async {
-
final response = TimelineResponse(feed: [_createMockPost()]);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => response);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.hasMore, false);
-
-
await feedProvider.loadMore();
-
// Should not attempt to load more
-
});
-
});
-
-
group('retry', () {
-
test('should retry after error', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
// Simulate error
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenThrow(Exception('Network error'));
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
expect(feedProvider.error, isNotNull);
-
-
// Retry
-
final successResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => successResponse);
-
-
await feedProvider.retry();
-
-
expect(feedProvider.error, null);
-
expect(feedProvider.posts.length, 1);
-
});
-
});
-
-
group('State Management', () {
-
test('should notify listeners on state change', () async {
-
var notificationCount = 0;
-
feedProvider.addListener(() {
-
notificationCount++;
-
});
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(notificationCount, greaterThan(0));
-
});
-
-
test('should manage loading states correctly', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async {
-
await Future.delayed(const Duration(milliseconds: 100));
-
return mockResponse;
-
});
-
-
final loadFuture = feedProvider.fetchTimeline(refresh: true);
-
-
// Should be loading
-
expect(feedProvider.isLoading, true);
-
-
await loadFuture;
-
-
// Should not be loading anymore
-
expect(feedProvider.isLoading, false);
-
});
-
});
-
-
group('Vote state initialization from viewer data', () {
-
late MockVoteProvider mockVoteProvider;
-
late FeedProvider feedProviderWithVotes;
-
-
setUp(() {
-
mockVoteProvider = MockVoteProvider();
-
feedProviderWithVotes = FeedProvider(
-
mockAuthProvider,
-
apiService: mockApiService,
-
voteProvider: mockVoteProvider,
-
);
-
});
-
-
tearDown(() {
-
feedProviderWithVotes.dispose();
-
});
-
-
test('should initialize vote state when viewer.vote is "up"', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteDirection: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
).called(1);
-
});
-
-
test('should initialize vote state when viewer.vote is "down"', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'down',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteDirection: 'down',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
).called(1);
-
});
-
-
test(
-
'should clear stale vote state when viewer.vote is null on refresh',
-
() async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
// Feed item with null vote (user removed vote on another device)
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: null,
-
voteUri: null,
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
// Should call setInitialVoteState with null to clear stale state
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteDirection: null,
-
voteUri: null,
-
),
-
).called(1);
-
},
-
);
-
-
test(
-
'should initialize vote state for all feed items including no viewer',
-
() async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
_createMockPost(), // No viewer state
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
// Should be called for both posts
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: anyNamed('postUri'),
-
voteDirection: anyNamed('voteDirection'),
-
voteUri: anyNamed('voteUri'),
-
),
-
).called(2);
-
},
-
);
-
-
test('should not initialize vote state when not authenticated', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchDiscover(refresh: true);
-
-
// Should NOT call setInitialVoteState when not authenticated
-
verifyNever(
-
mockVoteProvider.setInitialVoteState(
-
postUri: anyNamed('postUri'),
-
voteDirection: anyNamed('voteDirection'),
-
voteUri: anyNamed('voteUri'),
-
),
-
);
-
});
-
});
-
});
-
}
-
-
// Helper function to create mock posts
-
FeedViewPost _createMockPost() {
-
return FeedViewPost(
-
post: PostView(
-
uri: 'at://did:plc:test/app.bsky.feed.post/test',
-
cid: 'test-cid',
-
rkey: 'test-rkey',
-
author: AuthorView(
-
did: 'did:plc:author',
-
handle: 'test.user',
-
displayName: 'Test User',
-
),
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
text: 'Test body',
-
title: 'Test Post',
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
-
facets: [],
-
),
-
);
-
}
-
-
// Helper function to create mock posts with viewer state
-
FeedViewPost _createMockPostWithViewer({
-
required String uri,
-
String? vote,
-
String? voteUri,
-
}) {
-
return FeedViewPost(
-
post: PostView(
-
uri: uri,
-
cid: 'test-cid',
-
rkey: 'test-rkey',
-
author: AuthorView(
-
did: 'did:plc:author',
-
handle: 'test.user',
-
displayName: 'Test User',
-
),
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
text: 'Test body',
-
title: 'Test Post',
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
-
facets: [],
-
viewer: ViewerState(vote: vote, voteUri: voteUri),
-
),
-
);
-
}
···
+4 -2
test/widget_test.dart
···
import 'package:coves_flutter/main.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
-
import 'package:coves_flutter/providers/feed_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
···
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
-
ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)),
],
child: const CovesApp(),
),
···
import 'package:coves_flutter/main.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
+
import 'package:coves_flutter/providers/multi_feed_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
···
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
+
ChangeNotifierProvider(
+
create: (_) => MultiFeedProvider(authProvider),
+
),
],
child: const CovesApp(),
),
+83 -55
test/widgets/feed_screen_test.dart
···
import 'package:coves_flutter/models/post.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
-
import 'package:coves_flutter/providers/feed_provider.dart';
import 'package:coves_flutter/providers/vote_provider.dart';
import 'package:coves_flutter/screens/home/feed_screen.dart';
import 'package:coves_flutter/services/vote_service.dart';
···
}
}
-
// Fake FeedProvider for testing
-
class FakeFeedProvider extends FeedProvider {
-
FakeFeedProvider() : super(FakeAuthProvider());
-
List<FeedViewPost> _posts = [];
-
bool _isLoading = false;
-
bool _isLoadingMore = false;
-
String? _error;
-
bool _hasMore = true;
int _loadFeedCallCount = 0;
int _retryCallCount = 0;
-
@override
-
List<FeedViewPost> get posts => _posts;
@override
-
bool get isLoading => _isLoading;
-
@override
-
bool get isLoadingMore => _isLoadingMore;
-
@override
-
String? get error => _error;
-
-
@override
-
bool get hasMore => _hasMore;
-
-
int get loadFeedCallCount => _loadFeedCallCount;
-
int get retryCallCount => _retryCallCount;
-
-
void setPosts(List<FeedViewPost> value) {
-
_posts = value;
notifyListeners();
}
-
void setLoading({required bool value}) {
-
_isLoading = value;
notifyListeners();
}
-
void setLoadingMore({required bool value}) {
-
_isLoadingMore = value;
notifyListeners();
}
-
void setError(String? value) {
-
_error = value;
notifyListeners();
}
-
void setHasMore({required bool value}) {
-
_hasMore = value;
notifyListeners();
}
@override
-
Future<void> loadFeed({bool refresh = false}) async {
_loadFeedCallCount++;
}
@override
-
Future<void> retry() async {
_retryCallCount++;
}
@override
-
Future<void> loadMore() async {
// No-op for testing
}
}
···
void main() {
group('FeedScreen Widget Tests', () {
late FakeAuthProvider fakeAuthProvider;
-
late FakeFeedProvider fakeFeedProvider;
late FakeVoteProvider fakeVoteProvider;
setUp(() {
fakeAuthProvider = FakeAuthProvider();
-
fakeFeedProvider = FakeFeedProvider();
fakeVoteProvider = FakeVoteProvider();
});
···
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
-
ChangeNotifierProvider<FeedProvider>.value(value: fakeFeedProvider),
ChangeNotifierProvider<VoteProvider>.value(value: fakeVoteProvider),
],
child: const MaterialApp(home: FeedScreen()),
···
testWidgets('should display loading indicator when loading', (
tester,
) async {
-
fakeFeedProvider.setLoading(value: true);
await tester.pumpWidget(createTestWidget());
···
});
testWidgets('should display error state with retry button', (tester) async {
-
fakeFeedProvider.setError('Network error');
await tester.pumpWidget(createTestWidget());
···
});
testWidgets('should display empty state when no posts', (tester) async {
-
fakeFeedProvider.setPosts([]);
fakeAuthProvider.setAuthenticated(value: false);
await tester.pumpWidget(createTestWidget());
···
testWidgets('should display different empty state when authenticated', (
tester,
) async {
-
fakeFeedProvider.setPosts([]);
fakeAuthProvider.setAuthenticated(value: true);
await tester.pumpWidget(createTestWidget());
···
_createMockPost('Test Post 2'),
];
-
fakeFeedProvider.setPosts(mockPosts);
await tester.pumpWidget(createTestWidget());
···
testWidgets('should handle pull-to-refresh', (tester) async {
final mockPosts = [_createMockPost('Test Post')];
-
fakeFeedProvider.setPosts(mockPosts);
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
···
// Verify RefreshIndicator exists
expect(find.byType(RefreshIndicator), findsOneWidget);
-
// The loadFeed is called once on init
-
expect(fakeFeedProvider.loadFeedCallCount, 1);
});
testWidgets('should show loading indicator at bottom when loading more', (
···
) async {
final mockPosts = [_createMockPost('Test Post')];
fakeFeedProvider
-
..setPosts(mockPosts)
-
..setLoadingMore(value: true);
await tester.pumpWidget(createTestWidget());
···
),
);
-
fakeFeedProvider.setPosts([mockPost]);
await tester.pumpWidget(createTestWidget());
···
testWidgets('should display community and author info', (tester) async {
final mockPost = _createMockPost('Test Post');
-
fakeFeedProvider.setPosts([mockPost]);
await tester.pumpWidget(createTestWidget());
-
// Check for community handle parts (displayed as !test-community@coves.social)
expect(find.textContaining('!test-community'), findsOneWidget);
expect(find.text('@test.user'), findsOneWidget);
});
···
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
-
expect(fakeFeedProvider.loadFeedCallCount, 1);
});
testWidgets('should have proper accessibility semantics', (tester) async {
final mockPost = _createMockPost('Accessible Post');
-
fakeFeedProvider.setPosts([mockPost]);
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
···
// Verify post card exists (which contains Semantics wrapper)
expect(find.text('Accessible Post'), findsOneWidget);
-
// Check for community handle parts (displayed as !test-community@coves.social)
expect(find.textContaining('!test-community'), findsOneWidget);
expect(find.textContaining('@coves.social'), findsOneWidget);
});
···
// If we get here without errors, dispose was called properly
expect(true, true);
});
});
}
···
+
import 'package:coves_flutter/models/feed_state.dart';
import 'package:coves_flutter/models/post.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
+
import 'package:coves_flutter/providers/multi_feed_provider.dart';
import 'package:coves_flutter/providers/vote_provider.dart';
import 'package:coves_flutter/screens/home/feed_screen.dart';
import 'package:coves_flutter/services/vote_service.dart';
···
}
}
+
// Fake MultiFeedProvider for testing
+
class FakeMultiFeedProvider extends MultiFeedProvider {
+
FakeMultiFeedProvider() : super(FakeAuthProvider());
+
final Map<FeedType, FeedState> _states = {
+
FeedType.discover: FeedState.initial(),
+
FeedType.forYou: FeedState.initial(),
+
};
+
int _loadFeedCallCount = 0;
int _retryCallCount = 0;
+
int get loadFeedCallCount => _loadFeedCallCount;
+
int get retryCallCount => _retryCallCount;
@override
+
FeedState getState(FeedType type) => _states[type] ?? FeedState.initial();
+
void setStateForType(FeedType type, FeedState state) {
+
_states[type] = state;
+
notifyListeners();
+
}
+
void setPosts(FeedType type, List<FeedViewPost> posts) {
+
_states[type] = _states[type]!.copyWith(posts: posts);
notifyListeners();
}
+
void setLoading(FeedType type, {required bool value}) {
+
_states[type] = _states[type]!.copyWith(isLoading: value);
notifyListeners();
}
+
void setLoadingMore(FeedType type, {required bool value}) {
+
_states[type] = _states[type]!.copyWith(isLoadingMore: value);
notifyListeners();
}
+
void setError(FeedType type, String? value) {
+
_states[type] = _states[type]!.copyWith(error: value);
notifyListeners();
}
+
void setHasMore(FeedType type, {required bool value}) {
+
_states[type] = _states[type]!.copyWith(hasMore: value);
notifyListeners();
}
@override
+
Future<void> loadFeed(FeedType type, {bool refresh = false}) async {
_loadFeedCallCount++;
}
@override
+
Future<void> retry(FeedType type) async {
_retryCallCount++;
}
@override
+
Future<void> loadMore(FeedType type) async {
+
// No-op for testing
+
}
+
+
@override
+
void saveScrollPosition(FeedType type, double position) {
// No-op for testing
}
}
···
void main() {
group('FeedScreen Widget Tests', () {
late FakeAuthProvider fakeAuthProvider;
+
late FakeMultiFeedProvider fakeFeedProvider;
late FakeVoteProvider fakeVoteProvider;
setUp(() {
fakeAuthProvider = FakeAuthProvider();
+
fakeFeedProvider = FakeMultiFeedProvider();
fakeVoteProvider = FakeVoteProvider();
});
···
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
+
ChangeNotifierProvider<MultiFeedProvider>.value(
+
value: fakeFeedProvider,
+
),
ChangeNotifierProvider<VoteProvider>.value(value: fakeVoteProvider),
],
child: const MaterialApp(home: FeedScreen()),
···
testWidgets('should display loading indicator when loading', (
tester,
) async {
+
fakeFeedProvider.setLoading(FeedType.discover, value: true);
await tester.pumpWidget(createTestWidget());
···
});
testWidgets('should display error state with retry button', (tester) async {
+
fakeFeedProvider.setError(FeedType.discover, 'Network error');
await tester.pumpWidget(createTestWidget());
···
});
testWidgets('should display empty state when no posts', (tester) async {
+
fakeFeedProvider.setPosts(FeedType.discover, []);
fakeAuthProvider.setAuthenticated(value: false);
await tester.pumpWidget(createTestWidget());
···
testWidgets('should display different empty state when authenticated', (
tester,
) async {
+
fakeFeedProvider.setPosts(FeedType.discover, []);
fakeAuthProvider.setAuthenticated(value: true);
await tester.pumpWidget(createTestWidget());
···
_createMockPost('Test Post 2'),
];
+
fakeFeedProvider.setPosts(FeedType.discover, mockPosts);
await tester.pumpWidget(createTestWidget());
···
testWidgets('should handle pull-to-refresh', (tester) async {
final mockPosts = [_createMockPost('Test Post')];
+
fakeFeedProvider.setPosts(FeedType.discover, mockPosts);
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
···
// Verify RefreshIndicator exists
expect(find.byType(RefreshIndicator), findsOneWidget);
+
// loadFeed is called once for initial load (or twice if authenticated)
+
expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1));
});
testWidgets('should show loading indicator at bottom when loading more', (
···
) async {
final mockPosts = [_createMockPost('Test Post')];
fakeFeedProvider
+
..setPosts(FeedType.discover, mockPosts)
+
..setLoadingMore(FeedType.discover, value: true);
await tester.pumpWidget(createTestWidget());
···
),
);
+
fakeFeedProvider.setPosts(FeedType.discover, [mockPost]);
await tester.pumpWidget(createTestWidget());
···
testWidgets('should display community and author info', (tester) async {
final mockPost = _createMockPost('Test Post');
+
fakeFeedProvider.setPosts(FeedType.discover, [mockPost]);
await tester.pumpWidget(createTestWidget());
+
// Check for community handle parts (displayed as !test-community@...)
expect(find.textContaining('!test-community'), findsOneWidget);
expect(find.text('@test.user'), findsOneWidget);
});
···
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
+
expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1));
});
testWidgets('should have proper accessibility semantics', (tester) async {
final mockPost = _createMockPost('Accessible Post');
+
fakeFeedProvider.setPosts(FeedType.discover, [mockPost]);
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
···
// Verify post card exists (which contains Semantics wrapper)
expect(find.text('Accessible Post'), findsOneWidget);
+
// Check for community handle parts
expect(find.textContaining('!test-community'), findsOneWidget);
expect(find.textContaining('@coves.social'), findsOneWidget);
});
···
// If we get here without errors, dispose was called properly
expect(true, true);
+
});
+
+
testWidgets('should support swipe navigation when authenticated', (
+
tester,
+
) async {
+
fakeAuthProvider.setAuthenticated(value: true);
+
fakeFeedProvider.setPosts(FeedType.discover, [_createMockPost('Post 1')]);
+
fakeFeedProvider.setPosts(FeedType.forYou, [_createMockPost('Post 2')]);
+
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// PageView should exist for authenticated users
+
expect(find.byType(PageView), findsOneWidget);
+
});
+
+
testWidgets('should not have PageView when not authenticated', (
+
tester,
+
) async {
+
fakeAuthProvider.setAuthenticated(value: false);
+
fakeFeedProvider.setPosts(FeedType.discover, [_createMockPost('Post 1')]);
+
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// PageView should not exist for unauthenticated users
+
expect(find.byType(PageView), findsNothing);
});
});
}