Files
patbond-flutter/test/features/community/community_controller_test.dart
lixi 19bd8c1810
CI / flutter-gates (push) Successful in 2m26s
新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射
  (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等),
  未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用
- community_repository:13 路径 19 操作全覆盖;createPost/createComment
  必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/
  关注走 PUT/DELETE 语义幂等
- community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射
- toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫,
  点赞/收藏共用一套参数化状态机,权威终态对账收敛
- community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 +
  游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态
- app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher,
  登出同步 reset
- 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 12:01:25 +08:00

373 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
late FakeCommunityRepository repo;
late CommunityController controller;
setUp(() {
repo = FakeCommunityRepository();
controller = CommunityController(repository: repo);
});
tearDown(() => controller.dispose());
List<String> feedIds() => controller.feed.map((card) => card.id).toList();
group('Feed 四态与多页缓存', () {
test('首屏成功:initial → loading → ready,页数据落位', () async {
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
expect(controller.phase, FeedPhase.initial);
final pending = controller.refresh();
expect(controller.phase, FeedPhase.loading);
await pending;
expect(controller.phase, FeedPhase.ready);
expect(feedIds(), ['p-1']);
expect(controller.hasMore, isTrue);
expect(controller.isEmpty, isFalse);
});
test('首屏失败:error 态供 retry;重试成功恢复 ready', () async {
var fail = true;
repo.onFeed = (limit, cursor) async {
if (fail) throw const ApiNetworkException('断网');
return feedPage([sampleFeedCard()]);
};
await controller.refresh();
expect(controller.phase, FeedPhase.error);
expect(controller.lastError, isA<ApiNetworkException>());
expect(controller.feed, isEmpty);
fail = false;
await controller.refresh();
expect(controller.phase, FeedPhase.ready);
expect(controller.lastError, isNull);
expect(feedIds(), ['p-1']);
});
test('刷新失败保留旧列表:不清空不闪空态,错误走 refreshError', () async {
var fail = false;
repo.onFeed = (limit, cursor) async {
if (fail) throw const ApiNetworkException('超时');
return feedPage([sampleFeedCard()]);
};
await controller.refresh();
fail = true;
await controller.refresh();
expect(controller.phase, FeedPhase.ready);
expect(feedIds(), ['p-1']);
expect(controller.refreshError, isA<ApiNetworkException>());
expect(controller.lastError, isNull);
});
test('ready 且列表为空 → 空态', () async {
repo.onFeed = (limit, cursor) async => feedPage(const []);
await controller.refresh();
expect(controller.isEmpty, isTrue);
expect(controller.hasMore, isFalse);
});
});
group('加载更多与游标拼接', () {
test('loadMore 携带上一页 nextCursor,追加不替换;到底后不再发', () async {
repo.onFeed = (limit, cursor) async => switch (cursor) {
null => feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true),
'c1' => feedPage([sampleFeedCard(id: 'p-2')]),
_ => fail('意外游标:$cursor'),
};
await controller.refresh();
await controller.loadMore();
expect(repo.calls, ['feed:cursor=null', 'feed:cursor=c1']);
expect(feedIds(), ['p-1', 'p-2']);
expect(controller.hasMore, isFalse);
expect(controller.loadMorePhase, LoadMorePhase.idle);
// hasMore=false:不再发请求。
await controller.loadMore();
expect(repo.calls, hasLength(2));
});
test('loadMore 失败:error 态保留列表,重试成功恢复', () async {
var fail = true;
repo.onFeed = (limit, cursor) async {
if (cursor == null) {
return feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
}
if (fail) throw const ApiNetworkException('超时');
return feedPage([sampleFeedCard(id: 'p-2')]);
};
await controller.refresh();
await controller.loadMore();
expect(controller.loadMorePhase, LoadMorePhase.error);
expect(controller.loadMoreError, isA<ApiNetworkException>());
expect(feedIds(), ['p-1']);
fail = false;
await controller.loadMore();
expect(controller.loadMorePhase, LoadMorePhase.idle);
expect(feedIds(), ['p-1', 'p-2']);
});
test('在途 loadMore 与刷新竞态:旧代次尾页丢弃,不重复不错位', () async {
final tail = Completer<CursorPage<FeedCard>>();
repo.onFeed = (limit, cursor) async {
if (cursor == null) {
return feedPage(
[sampleFeedCard(id: 'p-fresh')],
nextCursor: 'c1',
hasMore: true,
);
}
return tail.future;
};
await controller.refresh();
final pending = controller.loadMore(); // 在途旧代次尾页。
await controller.refresh(); // 期间刷新:整体替换 + 代次 +1。
tail.complete(feedPage([sampleFeedCard(id: 'p-stale')]));
await pending;
expect(feedIds(), ['p-fresh']);
expect(controller.hasMore, isTrue); // 保持新代次首页的分页状态。
});
test('loading 中重复 loadMore 只发一请求(单飞)', () async {
final tail = Completer<CursorPage<FeedCard>>();
repo.onFeed = (limit, cursor) async => cursor == null
? feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true)
: tail.future;
await controller.refresh();
final first = controller.loadMore();
final second = controller.loadMore(); // loading 中:直接返回。
tail.complete(feedPage([sampleFeedCard(id: 'p-2')]));
await first;
await second;
expect(repo.calls.where((c) => c == 'feed:cursor=c1'), hasLength(1));
expect(feedIds(), ['p-1', 'p-2']);
});
});
group('ToggleSync 乐观更新(点赞 / 收藏同构)', () {
Future<void> loadOneCard({bool liked = false, int likeCount = 6}) async {
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard(likedByMe: liked, likeCount: likeCount)]);
await controller.refresh();
}
test('成功:同帧乐观翻转,权威计数覆盖乐观计数', () async {
await loadOneCard();
repo.onLikeToggle = (id, target) async =>
const LikeState(liked: true, likeCount: 10); // 吸收他人并发 +3。
controller.toggleLike('p-1');
// 同帧反馈:乐观 +1。
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 7);
await pumpEventQueue();
// 权威终态覆盖。
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 10);
expect(controller.toggleError, isNull);
});
test('失败:恢复链起点快照,错误走 toggleError 轻提示', () async {
await loadOneCard();
repo.onLikeToggle = (id, target) async =>
throw const ApiNetworkException('断网');
controller.toggleLike('p-1');
expect(controller.feed.single.likedByMe, isTrue);
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 6);
expect(controller.toggleError, isA<ApiNetworkException>());
});
test('在途连点单飞:只发一请求,完成后按最终意图补发一次', () async {
await loadOneCard();
final completers = <Completer<LikeState>>[];
repo.onLikeToggle = (id, target) {
final completer = Completer<LikeState>();
completers.add(completer);
return completer.future;
};
controller.toggleLike('p-1'); // → true,在途。
controller.toggleLike('p-1'); // → false,只记意图不发请求。
expect(repo.calls.where((c) => c.startsWith('like')), hasLength(1));
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 6);
completers[0].complete(const LikeState(liked: true, likeCount: 7));
await pumpEventQueue();
// 确认态 true ≠ 最终意图 false → 补发 DELETE。
expect(repo.calls, contains('unlike:p-1'));
completers[1].complete(const LikeState(liked: false, likeCount: 6));
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 6);
// 全程恰两个请求(中间抖动被合并)。
expect(
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
hasLength(2),
);
});
test('补发目标与终态一致不再发:连点偶数次收敛回原意图', () async {
await loadOneCard();
final completers = <Completer<LikeState>>[];
repo.onLikeToggle = (id, target) {
final completer = Completer<LikeState>();
completers.add(completer);
return completer.future;
};
controller.toggleLike('p-1'); // → true,在途。
controller.toggleLike('p-1'); // → false。
controller.toggleLike('p-1'); // → true(最终意图与在途目标一致)。
completers.single.complete(const LikeState(liked: true, likeCount: 20));
await pumpEventQueue();
// 意图 == 确认态:不补发,权威计数覆盖。
expect(
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
hasLength(1),
);
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 20);
});
test('代次守卫:刷新后到达的旧成功响应丢弃,不覆盖新数据', () async {
await loadOneCard();
final inFlight = Completer<LikeState>();
repo.onLikeToggle = (id, target) => inFlight.future;
controller.toggleLike('p-1');
// 期间刷新:列表被服务端数据整体替换(liked=false count=0)。
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard(likeCount: 0)]);
await controller.refresh();
inFlight.complete(const LikeState(liked: true, likeCount: 99));
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 0);
});
test('代次守卫:刷新后到达的旧失败响应不回滚不提示', () async {
await loadOneCard();
final inFlight = Completer<LikeState>();
repo.onLikeToggle = (id, target) => inFlight.future;
controller.toggleLike('p-1');
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard(likeCount: 0)]);
await controller.refresh();
inFlight.completeError(const ApiNetworkException('超时'));
await pumpEventQueue();
expect(controller.feed.single.likedByMe, isFalse);
expect(controller.feed.single.likeCount, 0);
expect(controller.toggleError, isNull);
});
test('收藏同构:乐观翻转 + 权威终态覆盖', () async {
await loadOneCard();
repo.onBookmarkToggle = (id, target) async =>
const BookmarkState(bookmarked: true, bookmarkCount: 5);
controller.toggleBookmark('p-1');
expect(controller.feed.single.bookmarkedByMe, isTrue);
expect(controller.feed.single.bookmarkCount, 3);
await pumpEventQueue();
expect(controller.feed.single.bookmarkCount, 5);
});
test('对不在列表的 postId 点击作废:不发请求不崩溃', () async {
await loadOneCard();
controller.toggleLike('p-nonexistent');
await pumpEventQueue();
expect(repo.calls.where((c) => c.startsWith('like')), isEmpty);
});
});
group('详情副本与登出清态', () {
test('getPost:详情入缓存并回写 Feed 卡片互动字段', () async {
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
await controller.refresh();
repo.onGetPost = (id) async =>
Post.fromJson(samplePostJson(likedByMe: true, likeCount: 42));
final post = await controller.getPost('p-1');
expect(post.likeCount, 42);
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
expect(controller.feed.single.likedByMe, isTrue);
expect(controller.feed.single.likeCount, 42);
});
test('详情副本存在时 toggle 同步详情与卡片两份内存', () async {
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
await controller.refresh();
repo.onGetPost = (id) async => Post.fromJson(samplePostJson());
await controller.getPost('p-1');
repo.onLikeToggle = (id, target) async =>
const LikeState(liked: true, likeCount: 7);
controller.toggleLike('p-1');
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
expect(controller.feed.single.likedByMe, isTrue);
await pumpEventQueue();
expect(controller.cachedPost('p-1')!.likeCount, 7);
expect(controller.feed.single.likeCount, 7);
});
test('reset:清列表 / 游标 / 详情副本回 initial,在途响应作废', () async {
repo.onFeed = (limit, cursor) async =>
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
await controller.refresh();
final inFlight = Completer<LikeState>();
repo.onLikeToggle = (id, target) => inFlight.future;
controller.toggleLike('p-1');
controller.reset();
expect(controller.phase, FeedPhase.initial);
expect(controller.feed, isEmpty);
expect(controller.hasMore, isFalse);
expect(controller.cachedPost('p-1'), isNull);
expect(controller.toggleError, isNull);
// 登出后到达的在途响应作废,不写入任何状态。
inFlight.complete(const LikeState(liked: true, likeCount: 7));
await pumpEventQueue();
expect(controller.feed, isEmpty);
});
});
}