refactor(paging): 抽取分页控制器基类收敛四个列表页

收藏、观看记录、剧单、公告各自手写 loading 互斥、请求序号、跨页去重与
错误映射,修一处要同步改四份。统一到 PagedListController 后子类只提供
取页与条目标识;空页按已加载数收口,避免 total 虚大时反复请求。
This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-29 10:44:41 +08:00
1 parent 9c9a277217
commit a4d08e7c1e
7 files changed
+503 -456

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/page_result.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/services/paged_list_controller.dart';
/// 用最薄的子类验证基类契约:分页、去重、空页收口与错误映射。
class _NumberController extends PagedListController<int> {
_NumberController(this.pages, {super.fallbackErrorKey});
final List<PageResult<int>> pages;
final List<int> requested = [];
Object? failure;
@override
Future<PageResult<int>> fetchPage(int pageIndex) async {
requested.add(pageIndex);
if (failure != null) throw failure!;
if (pages.isEmpty) return const PageResult();
return pages.removeAt(0);
}
@override
String itemKey(int item) => '$item';
/// 基类的失效入口是 protected,测试里包一层。
void invalidate() => invalidateRequests();
}
void main() {
test('should_append_only_new_items_when_next_page_overlaps', () async {
final controller = _NumberController([
const PageResult(total: 4, list: [1, 2]),
const PageResult(total: 4, list: [2, 3]),
]);
await controller.load();
await controller.loadMore();
expect(controller.items, [1, 2, 3]);
expect(controller.requested, [1, 2]);
controller.dispose();
});
test('should_stop_paging_when_empty_page_arrives', () async {
final controller = _NumberController([
const PageResult(total: 99, list: [1]),
const PageResult(total: 99, list: []),
]);
await controller.load();
expect(controller.hasMore, isTrue);
await controller.loadMore();
expect(controller.hasMore, isFalse);
await controller.loadMore();
expect(controller.requested, [1, 2]);
controller.dispose();
});
test('should_expose_backend_message_when_api_fails', () async {
final controller = _NumberController([])
..failure = const ApiException(500, '服务暂不可用');
await controller.load();
expect(controller.error, '服务暂不可用');
expect(controller.lastErrorCode, 500);
controller.dispose();
});
test('should_fall_back_to_key_when_failure_is_not_api_error', () async {
final controller = _NumberController([], fallbackErrorKey: 'loadFailed')
..failure = StateError('boom');
await controller.load();
expect(controller.error, 'loadFailed');
controller.dispose();
});
test('should_ignore_stale_response_when_invalidated', () async {
final controller = _NumberController([
const PageResult(total: 1, list: [1]),
]);
final pending = controller.load();
controller.invalidate();
await pending;
expect(controller.items, isEmpty);
expect(controller.loaded, isFalse);
controller.dispose();
});
test('should_not_stick_loading_when_refresh_supersedes_load', () async {
final first = Completer<PageResult<int>>();
final controller = _DeferredController([
() => first.future,
() async => const PageResult(total: 1, list: [2]),
]);
final loading = controller.load();
final refreshing = controller.refresh();
first.complete(const PageResult(total: 1, list: [1]));
await Future.wait([loading, refreshing]);
expect(controller.loading, isFalse);
expect(controller.refreshing, isFalse);
expect(controller.items, [2]);
controller.dispose();
});
}
/// 让每个请求各自挂起,用来构造「后发请求抢占先发请求」的时序。
class _DeferredController extends PagedListController<int> {
_DeferredController(this._pages);
final List<Future<PageResult<int>> Function()> _pages;
@override
Future<PageResult<int>> fetchPage(int pageIndex) => _pages.removeAt(0)();
@override
String itemKey(int item) => '$item';
}
+15 -2
View File
@@ -22,12 +22,25 @@ void main() {
controller.dispose();
});
test('should_keep_duplicate_ids_when_repository_repeats_items', () async {
// 剧单不去重:服务端分页错位时按原样展示,不能悄悄吞掉条目。
final repository = _PagedPlaylists(total: 12, repeatLastOfFirstPage: true);
final controller = PlaylistController(repository);
await controller.load();
await controller.loadMore();
expect(controller.items, hasLength(12));
controller.dispose();
});
}
class _PagedPlaylists implements PlaylistRepository {
_PagedPlaylists({required this.total});
_PagedPlaylists({required this.total, this.repeatLastOfFirstPage = false});
final int total;
final bool repeatLastOfFirstPage;
@override
Future<PageResult<PlaylistDetail>> fetchPlaylists({
@@ -44,7 +57,7 @@ class _PagedPlaylists implements PlaylistRepository {
list: [
for (var index = start; index < end; index++)
PlaylistDetail(
id: '$index',
id: '${repeatLastOfFirstPage && index == pageSize ? start - 1 : index}',
name: '剧单$index',
description: '',
dramaCount: 0,