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?)> 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': [], '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?> 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( builder: (_) => PostDetailPage( controller: controller, postId: 'p-1', currentUserId: currentUserId, analytics: analytics, ), ), ), child: const Text('打开详情'), ), ), ), ), ); group('详情四态', () { testWidgets('loading:请求未回渲染转圈', (tester) async { final post = Completer(); 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(); 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(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_failed,attemptSeq 递增', ( 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(); 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(); 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(); 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, ); }); }); }