Files
patbond-flutter/test/features/post/post_detail_page_test.dart
lixi f873acf9a3
CI / flutter-gates (push) Failing after 20s
新增:帖子详情页真实数据整页替换——四态/真九宫格大图/评论区/关注双态,Feed 导航与互动全接线,demo 详情退役(T3-15/16)
- post_detail_page 整页重写:内存副本先渲染 + getPost 拉新、四态
 (loading/error+重试/ready/40403 提示后返回 Feed 并刷新);媒体全量
  ——单图原比例(高度钳制 [0.75w,1.33w])、多图真九宫格(D11 轮播
  弃用,本单裁定)+ InteractiveViewer 全屏大图(03 号拍板 E)
- 评论区:游标列表触底补页 + 底部输入条创建(幂等键在数据层)+
  仅本人评论渲染删除入口(40301/40404 服务端兜底);成败埋点对
 (durationMs 取输入会话起点、attemptSeq 会话内递增);CommentTile
  升共享组件(@ 回复前缀呈现、降级作者「宠友」占位)
- 关注双态钮(tonal +关注 / Outlined 已关注 + 取关确认)乐观翻转、
  回滚零动画 + SnackBar,user_followed/unfollowed(post_detail) 挂接;
  本人帖不渲染
- Feed 接通:整卡与评论钮导航详情(T3-14 占位提示移除)、点赞/收藏
  接共享 ToggleSync(详情与卡片同一实例跨页一致)、toggleError
  一次性 SnackBar 消费
- 主壳/装配:openPost 按 postId push;create demo 流不再导航已退役的
  demo 详情(发布页真实化留 T3-17);AppState 在 post_detail/Feed 面
  消费清零
- compose 冒烟(env 门控默认跳过):真链路发帖/点赞幂等/收藏/评论/
  删评一轮 + 断网点赞回滚(生产 ApiClient 异常链)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 13:39:10 +08:00

552 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/comment_tile.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/post/post_detail_page.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
late FakeCommunityRepository repository;
late CommunityController controller;
late List<(String, Map<String, dynamic>?)> events;
late CommunityInteractionAnalytics analytics;
/// 无媒体帖(避免测试环境网络图噪音;媒体形态单测另立)。
Post textPost({
String id = 'p-1',
bool likedByMe = false,
int likeCount = 6,
int commentCount = 3,
}) => Post.fromJson({
...samplePostJson(id: id, likedByMe: likedByMe, likeCount: likeCount),
'media': <Object>[],
'commentCount': commentCount,
});
setUp(() {
repository = FakeCommunityRepository();
events = [];
analytics = CommunityInteractionAnalytics(
(name, [props]) async => events.add((name, props)),
);
controller = CommunityController(
repository: repository,
interactionAnalytics: analytics,
);
// 缺省行为:正常帖 + 空评论 + 未关注(各测试按需覆盖)。
repository.onGetPost = (_) async => textPost();
repository.onListComments = (_, _) async => commentPage(const []);
repository.onGetFollowStats = (_) async => const FollowStats(
followerCount: 1,
followingCount: 2,
followedByMe: false,
);
});
List<Map<String, dynamic>?> eventsNamed(String name) =>
events.where((e) => e.$1 == name).map((e) => e.$2).toList();
Widget detailApp({String? currentUserId = 'me'}) => MaterialApp(
theme: buildAppTheme(),
home: PostDetailPage(
controller: controller,
postId: 'p-1',
currentUserId: currentUserId,
analytics: analytics,
),
);
/// 经启动页 push 详情(40403 返回 Feed 的 pop 断言用)。
Widget launcherApp({String? currentUserId = 'me'}) => MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PostDetailPage(
controller: controller,
postId: 'p-1',
currentUserId: currentUserId,
analytics: analytics,
),
),
),
child: const Text('打开详情'),
),
),
),
),
);
group('详情四态', () {
testWidgets('loading:请求未回渲染转圈', (tester) async {
final post = Completer<Post>();
repository.onGetPost = (_) => post.future;
await tester.pumpWidget(detailApp());
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
post.complete(textPost());
await tester.pumpAndSettle();
expect(find.text('晒了一下午太阳。'), findsOneWidget);
});
testWidgets('ready:正文/作者/求助标/评论标题齐全', (tester) async {
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
expect(find.text('今天的豆豆'), findsOneWidget);
expect(find.text('晒了一下午太阳。'), findsOneWidget);
expect(find.text('毛毛的铲屎官'), findsOneWidget);
expect(find.text('评论 (3)'), findsOneWidget);
expect(find.text('还没有评论,来抢沙发'), findsOneWidget);
});
testWidgets('error:横幅 + 重试恢复 ready', (tester) async {
var attempts = 0;
repository.onGetPost = (_) async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException();
return textPost();
};
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsNothing);
expect(find.text('晒了一下午太阳。'), findsOneWidget);
});
testWidgets('40403 不存在态:SnackBar + 返回 Feed 并触发刷新', (tester) async {
repository.onGetPost = (_) async =>
throw const PostNotFoundException(message: 'gone');
repository.onFeed = (_, _) async => feedPage(const []);
await tester.pumpWidget(launcherApp());
await tester.tap(find.text('打开详情'));
await tester.pumpAndSettle();
// 已弹回启动页 + 提示 + Feed 整体刷新(失效帖剔除)。
expect(find.text('打开详情'), findsOneWidget);
expect(find.text('帖子不存在或已被删除'), findsOneWidget);
expect(repository.calls, contains('feed:cursor=null'));
});
testWidgets('内存副本先渲染:进入即展示缓存,后台拉新不阻塞', (tester) async {
await controller.getPost('p-1'); // 预热详情副本。
final refresh = Completer<Post>();
repository.onGetPost = (_) => refresh.future;
await tester.pumpWidget(detailApp());
await tester.pump();
// 拉新未回已渲染缓存内容,无全页转圈。
expect(find.text('晒了一下午太阳。'), findsOneWidget);
refresh.complete(textPost(likeCount: 9));
await tester.pumpAndSettle();
expect(find.text('9'), findsOneWidget);
});
});
group('媒体', () {
testWidgets('多图渲染真九宫格,点格进全屏大图(页码 + 关闭)', (tester) async {
repository.onGetPost = (_) async => Post.fromJson({
...samplePostJson(),
'media': [
for (var i = 0; i < 5; i++)
samplePostMediaItemJson(
assetId: 'a-$i',
position: i,
isCover: i == 0,
),
],
});
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
expect(find.byType(PostMediaGrid), findsOneWidget);
await tester.tap(
find
.descendant(
of: find.byType(PostMediaGrid),
matching: find.byType(InkWell),
)
.first,
);
await tester.pumpAndSettle();
expect(find.text('1/5'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.byType(PostMediaGrid), findsOneWidget);
});
});
group('评论区', () {
testWidgets('游标列表:首页渲染 + 触底携游标补页', (tester) async {
repository.onListComments = (_, cursor) async {
if (cursor == null) {
return commentPage(
[
sampleComment(id: 'c-1', content: '第一条'),
sampleComment(id: 'c-2', content: '第二条'),
],
nextCursor: 'cc1',
hasMore: true,
);
}
return commentPage([sampleComment(id: 'c-3', content: '第三条')]);
};
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
expect(find.text('第一条'), findsOneWidget);
await tester.drag(find.byType(ListView), const Offset(0, -800));
await tester.pumpAndSettle();
expect(
repository.calls.where((c) => c.startsWith('comments:')).toList(),
['comments:p-1:cursor=null', 'comments:p-1:cursor=cc1'],
);
expect(find.text('第三条'), findsOneWidget);
});
testWidgets('列表失败:话术 + 点按重试恢复', (tester) async {
var attempts = 0;
repository.onListComments = (_, _) async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException();
return commentPage([sampleComment(content: '恢复后的评论')]);
};
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
expect(find.text('加载失败,点此重试'), findsOneWidget);
await tester.tap(find.text('加载失败,点此重试'));
await tester.pumpAndSettle();
expect(find.text('恢复后的评论'), findsOneWidget);
});
testWidgets('创建成功:插入列表头 + 计数 +1 + comment_create_succeeded + 清空输入', (
tester,
) async {
repository.onCreateComment = (_, request) async =>
sampleComment(id: 'c-new', content: request.content);
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '沙发!');
await tester.pump();
await tester.tap(find.byIcon(Icons.send_rounded));
await tester.pumpAndSettle();
expect(repository.calls, contains('createComment:p-1:沙发!'));
expect(find.text('沙发!'), findsOneWidget); // 列表中的新评论。
expect(find.text('评论 (4)'), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'',
);
final succeeded = eventsNamed('comment_create_succeeded').single!;
expect(succeeded['isReply'], false);
expect(succeeded['textLengthBucket'], 'short');
expect(succeeded['durationMs'], greaterThanOrEqualTo(0));
// Feed 卡片同源计数(跨页一致的另一半在 like 测试)。
expect(controller.cachedPost('p-1')!.commentCount, 4);
});
testWidgets('创建失败:SnackBar + comment_create_failedattemptSeq 递增', (
tester,
) async {
repository.onCreateComment = (_, _) async =>
throw const ApiBusinessException(code: 40000, message: 'bad');
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '不合规内容');
await tester.pump();
await tester.tap(find.byIcon(Icons.send_rounded));
await tester.pumpAndSettle();
expect(find.text('评论内容不合规,请修改后重试'), findsOneWidget);
// 等 SnackBar 退场(遮挡底部发送钮)后重试第二次。
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.send_rounded));
await tester.pumpAndSettle();
expect(eventsNamed('comment_create_failed'), [
{
'failureReason': 'validation_error',
'attemptSeq': 1,
'errorCode': 40000,
'httpStatus': 400,
},
{
'failureReason': 'validation_error',
'attemptSeq': 2,
'errorCode': 40000,
'httpStatus': 400,
},
]);
// 失败不清空输入、不入列表、不动计数。
expect(find.text('评论 (3)'), findsOneWidget);
});
testWidgets('删除权限呈现:仅本人评论有「删除」;确认后删除 + 计数 -1', (tester) async {
repository.onListComments = (_, _) async => commentPage([
sampleComment(
id: 'c-mine',
author: sampleAuthorJson(userId: 'me', nickname: '我自己'),
content: '我的评论',
),
sampleComment(id: 'c-other', content: '别人的评论'),
]);
repository.onDeleteComment = (_) async {};
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -400));
await tester.pump();
// 两条评论,删除入口恰一个(本人条目)。
expect(find.byType(CommentTile), findsNWidgets(2));
expect(find.text('删除'), findsOneWidget);
await tester.tap(find.text('删除'));
await tester.pumpAndSettle();
expect(find.text('删除这条评论?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '删除'));
await tester.pumpAndSettle();
expect(repository.calls, contains('deleteComment:c-mine'));
expect(find.text('我的评论'), findsNothing);
expect(find.text('别人的评论'), findsOneWidget);
expect(find.text('评论 (2)'), findsOneWidget);
});
testWidgets('删除失败:40301 提示无权限,条目保留', (tester) async {
repository.onListComments = (_, _) async => commentPage([
sampleComment(
id: 'c-mine',
author: sampleAuthorJson(userId: 'me', nickname: '我自己'),
content: '我的评论',
),
]);
repository.onDeleteComment = (_) async =>
throw const PostAccessDeniedException(message: 'denied');
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -400));
await tester.pump();
await tester.tap(find.text('删除'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '删除'));
await tester.pumpAndSettle();
expect(find.text('没有权限删除这条评论'), findsOneWidget);
expect(find.text('我的评论'), findsOneWidget);
expect(find.text('评论 (3)'), findsOneWidget);
});
});
group('互动(ToggleSync UI 层)', () {
testWidgets('点赞乐观翻转即时 +1,成功报 post_liked(source=post_detail)', (
tester,
) async {
final like = Completer<LikeState>();
repository.onLikeToggle = (_, _) => like.future;
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pump();
expect(find.text('7'), findsOneWidget);
expect(find.byIcon(Icons.favorite), findsOneWidget);
like.complete(const LikeState(liked: true, likeCount: 7));
await tester.pumpAndSettle();
expect(eventsNamed('post_liked'), [
{'source': 'post_detail'},
]);
});
testWidgets('点赞失败:零动画回滚成对恢复 + SnackBar,不报事件', (tester) async {
final like = Completer<LikeState>();
repository.onLikeToggle = (_, _) => like.future;
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pump();
expect(find.text('7'), findsOneWidget);
like.completeError(const ApiNetworkException());
await tester.pumpAndSettle();
expect(find.text('6'), findsOneWidget);
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
expect(find.text('操作失败,请重试'), findsOneWidget);
expect(eventsNamed('post_liked'), isEmpty);
});
testWidgets('收藏接线:成功报 post_favorited(source=post_detail)', (tester) async {
repository.onBookmarkToggle = (_, target) async =>
BookmarkState(bookmarked: target, bookmarkCount: target ? 3 : 2);
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.bookmark_border));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.bookmark), findsOneWidget);
expect(eventsNamed('post_favorited'), [
{'source': 'post_detail'},
]);
});
testWidgets('跨页一致:详情页点赞,Feed 卡片同帖同帧更新(共享同一实例)', (tester) async {
repository.onFeed = (_, _) async =>
feedPage([sampleFeedCard(likeCount: 6)]);
await controller.refresh();
repository.onLikeToggle = (_, target) async =>
LikeState(liked: target, likeCount: target ? 7 : 6);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Column(
children: [
// 模拟 Feed 侧对同一 controller 的消费。
ListenableBuilder(
listenable: controller,
builder: (context, _) {
final card = controller.feed.single;
return Text('card:${card.likedByMe}:${card.likeCount}');
},
),
Expanded(
child: PostDetailPage(
controller: controller,
postId: 'p-1',
currentUserId: 'me',
analytics: analytics,
),
),
],
),
),
);
await tester.pumpAndSettle();
expect(find.text('card:false:6'), findsOneWidget);
await tester.tap(find.byIcon(Icons.favorite_border));
await tester.pump();
// 乐观写入即同源:卡片副本同帧翻转。
expect(find.text('card:true:7'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('card:true:7'), findsOneWidget);
});
});
group('关注', () {
testWidgets('未关注 → 关注:乐观翻转 + user_followed(post_detail)', (tester) async {
repository.onFollowToggle = (_, target) async =>
FollowState(following: target, followerCount: target ? 2 : 1);
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
expect(find.text(' 关注'), findsOneWidget);
await tester.tap(find.text(' 关注'));
await tester.pump();
expect(find.text('已关注'), findsOneWidget);
await tester.pumpAndSettle();
expect(repository.calls, contains('follow:u-1'));
expect(eventsNamed('user_followed'), [
{'source': 'post_detail'},
]);
});
testWidgets('已关注 → 取关:确认弹窗 + user_unfollowed', (tester) async {
repository.onGetFollowStats = (_) async => const FollowStats(
followerCount: 2,
followingCount: 2,
followedByMe: true,
);
repository.onFollowToggle = (_, target) async =>
FollowState(following: target, followerCount: target ? 2 : 1);
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
expect(find.text('已关注'), findsOneWidget);
await tester.tap(find.text('已关注'));
await tester.pumpAndSettle();
expect(find.text('不再关注 TA'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '不再关注'));
await tester.pumpAndSettle();
expect(find.text(' 关注'), findsOneWidget);
expect(repository.calls, contains('unfollow:u-1'));
expect(eventsNamed('user_unfollowed'), [
{'source': 'post_detail'},
]);
});
testWidgets('关注失败:回滚直接跳变 + SnackBar', (tester) async {
final follow = Completer<FollowState>();
repository.onFollowToggle = (_, _) => follow.future;
await tester.pumpWidget(detailApp());
await tester.pumpAndSettle();
await tester.tap(find.text(' 关注'));
await tester.pump();
expect(find.text('已关注'), findsOneWidget);
follow.completeError(const ApiNetworkException());
await tester.pumpAndSettle();
expect(find.text(' 关注'), findsOneWidget);
expect(find.text('操作失败,请重试'), findsOneWidget);
expect(eventsNamed('user_followed'), isEmpty);
});
testWidgets('本人帖:不渲染关注钮、不拉 follow-stats', (tester) async {
await tester.pumpWidget(detailApp(currentUserId: 'u-1'));
await tester.pumpAndSettle();
expect(find.text(' 关注'), findsNothing);
expect(find.text('已关注'), findsNothing);
expect(
repository.calls.where((c) => c.startsWith('followStats:')),
isEmpty,
);
});
});
}