refactor(paging): 抽取分页控制器基类收敛四个列表页
收藏、观看记录、剧单、公告各自手写 loading 互斥、请求序号、跨页去重与 错误映射,修一处要同步改四份。统一到 PagedListController 后子类只提供 取页与条目标识;空页按已加载数收口,避免 total 虚大时反复请求。
This commit is contained in:
1 parent
9c9a277217
commit
a4d08e7c1e
7 files changed
+503
-456
No files matched your search
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user