feat(home): 首页按内容类型分频道 Feed 并支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-21 17:33:52 +08:00
1 parent 4d0be47ee3
commit ae61d9ec4c
22 files changed
+1001 -71

No files matched your search

@@ -0,0 +1,175 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/home_feed_controller.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
void main() {
test(
'should_seed_recommend_without_requesting_when_initial_feed_is_ready',
() async {
final repository = _ScriptedFeedRepository();
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
expect(controller.channel, HomeFeedChannel.recommend);
expect(controller.dramas.map((drama) => drama.id), ['seed']);
expect(controller.loaded, isTrue);
expect(repository.calls, isEmpty);
controller.dispose();
},
);
test('should_request_anime_live_and_recommend_with_expected_types', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('anime')]))
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() async => FeedPage(dramas: [_drama('recommend')]));
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.recommend);
await pumpEventQueue();
expect(repository.calls.map((call) => call.types), [
['ANIME'],
['LIVE'],
[],
]);
controller.dispose();
});
test(
'should_keep_cached_data_while_silently_refreshing_cached_channel',
() async {
final refresh = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('anime-old')]))
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() => refresh.future);
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.dramas.single.id, 'anime-old');
refresh.complete(FeedPage(dramas: [_drama('anime-new')]));
await pumpEventQueue();
expect(controller.dramas.single.id, 'anime-new');
controller.dispose();
},
);
test('should_append_unique_items_and_stop_when_has_more_is_false', () async {
final repository = _ScriptedFeedRepository()
..enqueue(
() async => FeedPage(
dramas: [_drama('a'), _drama('b')],
nextCursor: 'next',
hasMore: true,
),
)
..enqueue(
() async =>
FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false),
);
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.loadMore();
await controller.loadMore();
expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']);
expect(repository.calls, hasLength(2));
expect(repository.calls.last.cursor, 'next');
controller.dispose();
});
test('should_ignore_stale_response_when_channels_switch_quickly', () async {
final anime = Completer<FeedPage>();
final live = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(() => anime.future)
..enqueue(() => live.future);
final controller = HomeFeedController(repository);
final animeLoad = controller.selectChannel(HomeFeedChannel.anime);
final liveLoad = controller.selectChannel(HomeFeedChannel.live);
live.complete(FeedPage(dramas: [_drama('live')]));
await liveLoad;
anime.complete(FeedPage(dramas: [_drama('anime')]));
await animeLoad;
expect(controller.channel, HomeFeedChannel.live);
expect(controller.dramas.single.id, 'live');
controller.dispose();
});
test('should_keep_old_data_when_refresh_fails', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('old')]))
..enqueue(() async => throw StateError('offline'));
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
final refreshed = await controller.refreshCurrent();
expect(refreshed, isFalse);
expect(controller.dramas.single.id, 'old');
expect(controller.lastError, contains('offline'));
controller.dispose();
});
}
class FeedCall {
const FeedCall({
required this.types,
required this.cursor,
required this.size,
});
final List<String> types;
final String? cursor;
final int size;
}
class _ScriptedFeedRepository implements CatalogRepository {
final List<FeedCall> calls = [];
final List<Future<FeedPage> Function()> _responses = [];
void enqueue(Future<FeedPage> Function() response) {
_responses.add(response);
}
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) {
calls.add(FeedCall(types: types, cursor: cursor, size: size));
return _responses.removeAt(0)();
}
@override
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<Drama> fetchDrama(String dramaId) async => _drama(dramaId);
}
Drama _drama(String id) =>
Drama(id: id, title: id, synopsis: id, posterUrl: '');