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 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()); 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()); 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()); 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>(); 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>(); 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 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()); }); test('在途连点单飞:只发一请求,完成后按最终意图补发一次', () async { await loadOneCard(); final completers = >[]; repo.onLikeToggle = (id, target) { final completer = Completer(); 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 = >[]; repo.onLikeToggle = (id, target) { final completer = Completer(); 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(); 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(); 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(); 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); }); }); }