新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s
CI / flutter-gates (push) Successful in 2m26s
- 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>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user