Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48433f01d | ||
|
|
f2ae0c70b6 | ||
|
|
57f3029018 | ||
|
|
9093700a04 | ||
|
|
78114fa46d |
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# 收藏卡片简介
|
||||
|
||||
## 目标
|
||||
|
||||
- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。
|
||||
- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。
|
||||
- 保持封面 80x106 和现有卡片高度,不引入布局溢出。
|
||||
|
||||
## 实现
|
||||
|
||||
1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。
|
||||
2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间,
|
||||
`maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。
|
||||
3. 无简介时继续使用原 Spacer,保持原布局。
|
||||
4. widget 测试覆盖有简介、空简介和卡片高度稳定性。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不改接口、后端、收藏分页。
|
||||
- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。
|
||||
@@ -20,3 +20,12 @@
|
||||
- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。
|
||||
- 空页立即停止加载;`loaded >= total` 后不再请求。
|
||||
- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。
|
||||
|
||||
## 2026-10-22 范围修订
|
||||
|
||||
- 分页协议和 Controller 分页状态保持不改。
|
||||
- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。
|
||||
- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。
|
||||
- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。
|
||||
- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页,
|
||||
只在 PROGRESS 记录证据,不做前端全量兜底。
|
||||
+76
-2
@@ -628,8 +628,7 @@ UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException
|
||||
- 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。
|
||||
- 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`;
|
||||
标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。
|
||||
- API 兼容滚动发布窗口:后端仍返回旧 List 时包装为 `PageResult(total=list.length)`;
|
||||
服务端新 PageResult 正常解析。
|
||||
- API 严格解析服务端 PageResult;旧 List 不做客户端全量包装,由后端部署新契约解决。
|
||||
- MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。
|
||||
|
||||
### 门禁
|
||||
@@ -656,3 +655,78 @@ $ flutter test --no-pub
|
||||
观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。
|
||||
|
||||
未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。
|
||||
|
||||
### 范围修订:观看标题优先与按需详情
|
||||
|
||||
- 分页协议、Repository 与 Controller 分页状态保持不变。
|
||||
- `_hydrate` 只对 `dramaTitle` 为空的记录补拉详情;后端已有标题时不再请求详情。
|
||||
- 有标题的记录点击播放时才调用 `detailForOpen` 按需加载详情,避免列表阶段 N+1。
|
||||
- 标题仍为空时使用本地化“第 N 集”,封面缺失继续显示占位。
|
||||
- 移除了旧 List 自动包装为 PageResult 的前端全量兜底,避免掩盖后端契约问题。
|
||||
|
||||
真实后端分页取证(仅测试账号写入,不启停服务):
|
||||
|
||||
```text
|
||||
GET /app/watch/recent?pageIndex=1&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/watch/recent?pageIndex=2&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/favorites?pageIndex=1&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/favorites?pageIndex=2&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
```
|
||||
|
||||
解读:当前 8081 运行实例仍是旧包,返回 List 且忽略 pageIndex/pageSize;第 1/2 页
|
||||
均返回相同两条数据。客户端不做全量兜底;live smoke 检测到旧 List 后明确标记 skipped,
|
||||
等待后端新包部署后再执行分页真实联调。
|
||||
|
||||
门禁:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/integration/live_watch_flow_test.dart test/library/library_api_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart
|
||||
00:02 +22 ~1: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:52 +630 ~1: All tests passed!
|
||||
```
|
||||
|
||||
解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、
|
||||
第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务
|
||||
能力探测跳过。`pubspec.lock` 既有改动未提交。
|
||||
|
||||
## 2026-10-22 收藏卡片简介
|
||||
|
||||
- 分支:`feature/favorite-card-synopsis`
|
||||
- 基线:`develop` / `origin/develop` = `9093700`
|
||||
- 计划:`docs/plans/2026-10-22-favorite-card-synopsis.md`
|
||||
|
||||
### 实现
|
||||
|
||||
- `_FavoriteCard` 在标题/标签下方条件渲染 `drama.synopsis.trim()`。
|
||||
- 简介最多 2 行、`TextOverflow.ellipsis`,使用 `AppColors.muted`、12 号字和
|
||||
`1.15` 行高,由 `Flexible` 占据剩余空间,保持封面与卡片原高度。
|
||||
- 简介为空或纯空白时不创建文本节点,继续使用原 Spacer,不占高度。
|
||||
- 未改接口、后端和收藏分页逻辑。
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/library/favorites_page_test.dart
|
||||
00:01 +7: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:51 +631 ~1: All tests passed!
|
||||
```
|
||||
|
||||
解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致;
|
||||
全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。
|
||||
`pubspec.lock` 既有改动未提交。
|
||||
@@ -3,6 +3,8 @@
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 服务端标准分页响应 `{total, list}`。
|
||||
///
|
||||
/// 兼容旧版「data 直接是数组」的响应:按数组长度作为 total,避免列表空白。
|
||||
class PageResult<T> {
|
||||
const PageResult({this.total = 0, this.list = const []});
|
||||
|
||||
@@ -14,10 +16,32 @@ class PageResult<T> {
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) parseItem,
|
||||
) {
|
||||
final items = (json['list'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(parseItem)
|
||||
.toList(growable: false);
|
||||
final items = _parseItemMaps(json['list']).map(parseItem).toList(
|
||||
growable: false,
|
||||
);
|
||||
return PageResult(total: jsonToInt(json['total']), list: items);
|
||||
}
|
||||
|
||||
/// 兼容分页 Map 与旧版纯 List;其它类型视为空页。
|
||||
factory PageResult.parse(
|
||||
Object? data,
|
||||
T Function(Map<String, dynamic>) parseItem,
|
||||
) {
|
||||
if (data is Map) {
|
||||
return PageResult.fromJson(Map<String, dynamic>.from(data), parseItem);
|
||||
}
|
||||
if (data is List) {
|
||||
final items = _parseItemMaps(data).map(parseItem).toList(growable: false);
|
||||
return PageResult(total: items.length, list: items);
|
||||
}
|
||||
return PageResult<T>();
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _parseItemMaps(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [
|
||||
for (final item in raw)
|
||||
if (item is Map) Map<String, dynamic>.from(item),
|
||||
];
|
||||
}
|
||||
@@ -29,17 +29,8 @@ class LibraryApi {
|
||||
'/app/favorites',
|
||||
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
|
||||
);
|
||||
if (data is Map<String, dynamic>) {
|
||||
return PageResult.fromJson(data, Drama.fromCardJson);
|
||||
}
|
||||
if (data is List) {
|
||||
final list = data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(growable: false);
|
||||
return PageResult(total: list.length, list: list);
|
||||
}
|
||||
return const PageResult();
|
||||
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
|
||||
return PageResult.parse(data, Drama.fromCardJson);
|
||||
}
|
||||
|
||||
Future<void> removeFavorite(String dramaId) async {
|
||||
|
||||
@@ -49,17 +49,8 @@ class WatchApi {
|
||||
'/app/watch/recent',
|
||||
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
|
||||
);
|
||||
if (data is Map<String, dynamic>) {
|
||||
return PageResult.fromJson(data, WatchProgress.fromJson);
|
||||
}
|
||||
if (data is List) {
|
||||
final list = data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(WatchProgress.fromJson)
|
||||
.toList(growable: false);
|
||||
return PageResult(total: list.length, list: list);
|
||||
}
|
||||
return const PageResult();
|
||||
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
|
||||
return PageResult.parse(data, WatchProgress.fromJson);
|
||||
}
|
||||
|
||||
/// 删除某部剧的观看记录(记录页清单条用)。
|
||||
|
||||
@@ -130,6 +130,15 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
|
||||
Future<Drama?> detailForOpen(String dramaId) async {
|
||||
final cached = _detailCache[dramaId];
|
||||
if (cached != null) return cached;
|
||||
await _loadDetail(dramaId);
|
||||
_notify();
|
||||
return _detailCache[dramaId];
|
||||
}
|
||||
|
||||
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
@@ -201,11 +210,12 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _hydrate(List<String> dramaIds) async {
|
||||
for (final id in dramaIds) {
|
||||
final missingTitles = dramaIds.where(_hasMissingTitle).toSet().toList();
|
||||
for (final id in missingTitles) {
|
||||
if (_detailCache.containsKey(id)) await _loadDetail(id);
|
||||
}
|
||||
final queue = [
|
||||
for (final id in dramaIds)
|
||||
for (final id in missingTitles)
|
||||
if (!_detailCache.containsKey(id)) id,
|
||||
];
|
||||
final workerCount = queue.length < detailConcurrency
|
||||
@@ -222,9 +232,15 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _loadDetail(String dramaId) async {
|
||||
bool _hasMissingTitle(String dramaId) {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return;
|
||||
if (index < 0) return false;
|
||||
return (records[index].progress.dramaTitle?.trim() ?? '').isEmpty;
|
||||
}
|
||||
|
||||
Future<Drama?> _loadDetail(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return null;
|
||||
final cached = _detailCache[dramaId];
|
||||
if (cached != null) {
|
||||
_replaceAt(
|
||||
@@ -235,14 +251,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
detailFailed: false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
final detail = await _catalog.fetchDrama(dramaId);
|
||||
if (_disposed) return;
|
||||
if (_disposed) return null;
|
||||
_detailCache[dramaId] = detail;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return;
|
||||
if (current < 0) return detail;
|
||||
_replaceAt(
|
||||
current,
|
||||
records[current].copyWith(
|
||||
@@ -251,6 +267,7 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
detailFailed: false,
|
||||
),
|
||||
);
|
||||
return detail;
|
||||
} catch (failure, stackTrace) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||
@@ -260,13 +277,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
if (_disposed) return;
|
||||
if (_disposed) return null;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return;
|
||||
if (current < 0) return null;
|
||||
_replaceAt(
|
||||
current,
|
||||
records[current].copyWith(detailLoading: false, detailFailed: true),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ const double _removeIconSize = 20;
|
||||
const double _statsFontSize = 12;
|
||||
const double _metaFontSize = 11;
|
||||
const double _titleFontSize = 15;
|
||||
const double _synopsisFontSize = 12;
|
||||
const double _synopsisLineHeight = 1.15;
|
||||
const double _synopsisGap = 4;
|
||||
const int _maxTags = 2;
|
||||
const double _emptyListHeight = 360;
|
||||
const double _loadMoreThreshold = 240;
|
||||
@@ -252,6 +255,7 @@ class _FavoriteCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final tags = _tags();
|
||||
final synopsis = drama.synopsis.trim();
|
||||
return Container(
|
||||
key: ValueKey('favorite-card-${drama.id}'),
|
||||
decoration: BoxDecoration(
|
||||
@@ -345,7 +349,25 @@ class _FavoriteCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: _synopsisGap),
|
||||
Flexible(
|
||||
child: Text(
|
||||
synopsis,
|
||||
key: ValueKey(
|
||||
'favorite-synopsis-${drama.id}',
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: _synopsisFontSize,
|
||||
height: _synopsisLineHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
|
||||
@@ -140,7 +140,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
||||
record: record,
|
||||
onRetry: () => controller.retryDetail(record.dramaId),
|
||||
onRemove: () => _remove(record.dramaId),
|
||||
onOpen: record.drama == null ? null : () => _open(record),
|
||||
onOpen: () => _open(record),
|
||||
),
|
||||
],
|
||||
PagedListFooter(
|
||||
@@ -219,10 +219,20 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
||||
}
|
||||
|
||||
Future<void> _open(WatchRecord record) async {
|
||||
final drama =
|
||||
record.drama ?? await controller.detailForOpen(record.dramaId);
|
||||
if (!mounted) return;
|
||||
if (drama == null) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed)));
|
||||
return;
|
||||
}
|
||||
final episodeId = record.progress.episodeId;
|
||||
await playDramaFromCatalog(
|
||||
context,
|
||||
record.drama!,
|
||||
drama,
|
||||
episodeId: episodeId.isEmpty ? null : episodeId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/page_result.dart';
|
||||
|
||||
void main() {
|
||||
group('PageResult.parse', () {
|
||||
test('should_parse_page_map_when_total_and_list_present', () {
|
||||
final page = PageResult.parse({
|
||||
'total': '3',
|
||||
'list': [
|
||||
{'id': '1', 'title': 'A'},
|
||||
{'id': '2', 'title': 'B'},
|
||||
],
|
||||
}, _item);
|
||||
|
||||
expect(page.total, 3);
|
||||
expect(page.list.map((e) => e['id']), ['1', '2']);
|
||||
});
|
||||
|
||||
test('should_parse_legacy_list_when_data_is_array', () {
|
||||
final page = PageResult.parse([
|
||||
{'id': '9', 'title': '收藏剧'},
|
||||
{'id': '8', 'title': '另一部'},
|
||||
], _item);
|
||||
|
||||
expect(page.total, 2);
|
||||
expect(page.list, hasLength(2));
|
||||
expect(page.list.first['title'], '收藏剧');
|
||||
});
|
||||
|
||||
test('should_return_empty_page_when_data_is_null_or_empty_list', () {
|
||||
expect(PageResult.parse(null, _item).total, 0);
|
||||
expect(PageResult.parse(null, _item).list, isEmpty);
|
||||
expect(PageResult.parse(const <Object>[], _item).total, 0);
|
||||
expect(PageResult.parse(const <Object>[], _item).list, isEmpty);
|
||||
});
|
||||
|
||||
test('should_return_empty_page_when_payload_is_invalid', () {
|
||||
final page = PageResult.parse('broken', _item);
|
||||
expect(page.total, 0);
|
||||
expect(page.list, isEmpty);
|
||||
});
|
||||
|
||||
test('should_skip_non_map_entries_in_legacy_list', () {
|
||||
final page = PageResult.parse([
|
||||
{'id': '1'},
|
||||
'noise',
|
||||
42,
|
||||
{'id': '2'},
|
||||
], _item);
|
||||
|
||||
expect(page.total, 2);
|
||||
expect(page.list.map((e) => e['id']), ['1', '2']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _item(Map<String, dynamic> json) => json;
|
||||
@@ -58,6 +58,20 @@ void main() {
|
||||
}
|
||||
expect(session?.user.uid, isNotEmpty);
|
||||
|
||||
final recentProbe = await client.get(
|
||||
'/app/watch/recent',
|
||||
query: {'pageIndex': '1', 'pageSize': '1'},
|
||||
);
|
||||
final favoriteProbe = await client.get(
|
||||
'/app/favorites',
|
||||
query: {'pageIndex': '1', 'pageSize': '1'},
|
||||
);
|
||||
if (recentProbe is! Map<String, dynamic> ||
|
||||
favoriteProbe is! Map<String, dynamic>) {
|
||||
markTestSkipped('运行中的后端仍返回旧版 List,分页契约联调已跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 首页 → 取第一部剧
|
||||
final feed = await catalog.homeFeed();
|
||||
expect(feed.dramas, isNotEmpty);
|
||||
|
||||
@@ -32,10 +32,10 @@ import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/fake_notice_repository.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
Drama _card(String id, {String synopsis = 'synopsis'}) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: 'synopsis',
|
||||
synopsis: synopsis,
|
||||
posterUrl: '/files/$id.png',
|
||||
totalEpisodes: 12,
|
||||
);
|
||||
@@ -73,6 +73,14 @@ void main() {
|
||||
);
|
||||
expect(title.style?.color, AppColors.onSurface);
|
||||
expect(title.style?.fontSize, 15);
|
||||
final synopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('favorite-synopsis-signal')),
|
||||
);
|
||||
expect(synopsis.data, 'synopsis');
|
||||
expect(synopsis.maxLines, 2);
|
||||
expect(synopsis.overflow, TextOverflow.ellipsis);
|
||||
expect(synopsis.style?.color, AppColors.muted);
|
||||
expect(synopsis.style?.fontSize, 12);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('favorite-cover-signal'))),
|
||||
const Size(80, 106),
|
||||
@@ -100,6 +108,38 @@ void main() {
|
||||
expect(find.byKey(const Key('favorite-card-signal')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_hide_blank_synopsis_without_changing_card_height', (
|
||||
tester,
|
||||
) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final longSynopsis = List.filled(12, '很长的剧情简介').join();
|
||||
final favorites = FavoriteController(
|
||||
InMemoryFavoriteRepository(
|
||||
saved: {'with-synopsis', 'without-synopsis'},
|
||||
cards: [
|
||||
_card('with-synopsis', synopsis: longSynopsis),
|
||||
_card('without-synopsis', synopsis: ' '),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||
|
||||
final withSynopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('favorite-synopsis-with-synopsis')),
|
||||
);
|
||||
expect(withSynopsis.maxLines, 2);
|
||||
expect(withSynopsis.overflow, TextOverflow.ellipsis);
|
||||
expect(
|
||||
find.byKey(const Key('favorite-synopsis-without-synopsis')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('favorite-card-with-synopsis'))),
|
||||
tester.getSize(find.byKey(const Key('favorite-card-without-synopsis'))),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_card_and_show_failure_when_remove_fails', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -39,28 +39,66 @@ void main() {
|
||||
expect(page.list.single.title, '收藏剧');
|
||||
});
|
||||
|
||||
test(
|
||||
'should_wrap_legacy_list_when_backend_has_not_deployed_paging',
|
||||
() async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': [
|
||||
{'id': '3', 'title': '旧收藏'},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = LibraryApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
test('should_parse_favorites_when_server_returns_legacy_list', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': [
|
||||
{'id': '5', 'title': '闪婚后', 'cover': '/files/5.png'},
|
||||
{'id': '4', 'title': '另一部', 'cover': '/files/4.png'},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = LibraryApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.favorites();
|
||||
final page = await api.favorites();
|
||||
|
||||
expect(page.total, 1);
|
||||
expect(page.list.single.title, '旧收藏');
|
||||
},
|
||||
);
|
||||
expect(page.total, 2);
|
||||
expect(page.list.map((drama) => drama.id), ['5', '4']);
|
||||
expect(page.list.first.title, '闪婚后');
|
||||
});
|
||||
|
||||
test('should_return_empty_favorites_when_data_is_null', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
|
||||
),
|
||||
);
|
||||
final api = LibraryApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.favorites();
|
||||
|
||||
expect(page.total, 0);
|
||||
expect(page.list, isEmpty);
|
||||
});
|
||||
|
||||
test('should_throw_api_exception_when_favorites_business_code_failed', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
|
||||
),
|
||||
);
|
||||
final api = LibraryApi(
|
||||
ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
// 无刷新回调时,401 直接抛业务异常,避免重试逻辑。
|
||||
tokenRefresher: null,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
() => api.favorites(),
|
||||
throwsA(
|
||||
isA<ApiException>().having((e) => e.code, 'code', 401),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -51,33 +51,51 @@ void main() {
|
||||
expect(page.list.single.totalEpisodes, 68);
|
||||
});
|
||||
|
||||
test(
|
||||
'should_wrap_legacy_list_when_backend_has_not_deployed_paging',
|
||||
() async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': [
|
||||
{
|
||||
'dramaId': '5',
|
||||
'episodeId': '7',
|
||||
'positionMs': '1000',
|
||||
'durationMs': '2000',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = WatchApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
test('should_parse_recent_when_server_returns_legacy_list', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': [
|
||||
{
|
||||
'dramaId': '5',
|
||||
'episodeId': '7',
|
||||
'positionMs': '203904',
|
||||
'durationMs': '227882',
|
||||
'finished': false,
|
||||
'playCount': 8,
|
||||
'updatedAt': '2026-10-21T15:17:05.419',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = WatchApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.recent();
|
||||
final page = await api.recent();
|
||||
|
||||
expect(page.total, 1);
|
||||
expect(page.list.single.dramaId, '5');
|
||||
},
|
||||
);
|
||||
expect(page.total, 1);
|
||||
expect(page.list.single.dramaId, '5');
|
||||
expect(page.list.single.episodeId, '7');
|
||||
expect(page.list.single.position.inMilliseconds, 203904);
|
||||
});
|
||||
|
||||
test('should_return_empty_recent_when_data_is_null', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
|
||||
),
|
||||
);
|
||||
final api = WatchApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.recent();
|
||||
|
||||
expect(page.total, 0);
|
||||
expect(page.list, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -16,11 +16,13 @@ import 'package:fruitshorts/core/data/services/watch_records_controller.dart';
|
||||
WatchProgress _progress(
|
||||
String dramaId, {
|
||||
String episodeId = 'episode-1',
|
||||
String? dramaTitle,
|
||||
int positionMs = 12000,
|
||||
int durationMs = 60000,
|
||||
}) => WatchProgress(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
dramaTitle: dramaTitle,
|
||||
position: Duration(milliseconds: positionMs),
|
||||
duration: Duration(milliseconds: durationMs),
|
||||
updatedAt: DateTime(2026, 10, 17, 12),
|
||||
@@ -74,6 +76,27 @@ void main() {
|
||||
expect(controller.error, isNull);
|
||||
});
|
||||
|
||||
test('should_use_backend_title_and_skip_detail_until_opened', () async {
|
||||
final history = ControlledWatchHistory([
|
||||
_progress('12', dramaTitle: '后端标题'),
|
||||
]);
|
||||
final catalog = ScriptedWatchCatalog();
|
||||
final controller = WatchRecordsController(
|
||||
history: history,
|
||||
catalog: catalog,
|
||||
);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.records.single.displayTitle, '后端标题');
|
||||
expect(catalog.calls, isEmpty);
|
||||
|
||||
final detail = await controller.detailForOpen('12');
|
||||
|
||||
expect(detail?.id, '12');
|
||||
expect(catalog.calls, ['12']);
|
||||
});
|
||||
|
||||
test('should_reuse_cached_details_when_records_are_loaded_again', () async {
|
||||
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
|
||||
final catalog = ScriptedWatchCatalog();
|
||||
|
||||
@@ -111,16 +111,13 @@ void main() {
|
||||
episodeNumber: 3,
|
||||
),
|
||||
]);
|
||||
final catalog = _AlwaysFailingWatchCatalog();
|
||||
|
||||
await _pumpPage(
|
||||
tester,
|
||||
auth: auth,
|
||||
history: history,
|
||||
catalog: _AlwaysFailingWatchCatalog(),
|
||||
);
|
||||
await _pumpPage(tester, auth: auth, history: history, catalog: catalog);
|
||||
|
||||
expect(find.text('Backend Title'), findsOneWidget);
|
||||
expect(find.text('Episode 3'), findsOneWidget);
|
||||
expect(catalog.calls, isEmpty);
|
||||
expect(
|
||||
find.byKey(const Key('watch-record-cover-metadata')),
|
||||
findsOneWidget,
|
||||
@@ -421,8 +418,11 @@ class _FailingWatchCatalog implements CatalogRepository {
|
||||
}
|
||||
|
||||
class _AlwaysFailingWatchCatalog implements CatalogRepository {
|
||||
final List<String> calls = [];
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
calls.add(dramaId);
|
||||
throw StateError('missing detail');
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user