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/feed_skeleton.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/post_card.dart'; import 'package:patbond_flutter/features/community/community_controller.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/community/feed_analytics.dart'; import 'package:patbond_flutter/features/home/home_page.dart'; import 'package:patbond_flutter/state/app_state.dart'; import '../../helpers/community_test_helpers.dart'; /// 短卡(纯文字)便于视口内多卡曝光断言;内容带 id 便于查重。 FeedCard textCard(String id) => FeedCard.fromJson({ ...sampleFeedCardJson(id: id), 'coverImage': null, 'mediaCount': 0, 'title': null, 'contentPreview': '动态内容 $id', }); FeedCard degradedCard(String id) => FeedCard.fromJson({ ...sampleFeedCardJson(id: id), 'coverImage': null, 'mediaCount': 0, 'title': null, 'contentPreview': '动态内容 $id', 'author': sampleAuthorJson(nickname: null, avatarUrl: null), }); void main() { late FakeCommunityRepository repository; late CommunityController controller; late List<(String, Map?)> events; late FeedAnalytics analytics; late int createTaps; late List openedPosts; setUp(() { repository = FakeCommunityRepository(); events = []; controller = CommunityController( repository: repository, interactionAnalytics: CommunityInteractionAnalytics( (name, [props]) async => events.add((name, props)), ), ); analytics = FeedAnalytics( (name, [props]) async => events.add((name, props)), ); createTaps = 0; openedPosts = []; }); List?> eventsNamed(String name) => events.where((e) => e.$1 == name).map((e) => e.$2).toList(); Future pumpHome(WidgetTester tester, {bool isActive = true}) { return tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Scaffold( body: HomePage( appState: AppState(), communityController: controller, feedAnalytics: analytics, isActive: isActive, onOpenServices: (_) {}, onOpenCreate: () => createTaps++, onOpenPost: openedPosts.add, ), ), ), ); } // 页面纵向主列表(story 环是嵌套的水平 ListView,取 first 即外层)。 Finder list() => find.byType(ListView).first; testWidgets('四态 · loading:首载渲染 3 张骨架屏', (tester) async { final completer = Completer>(); repository.onFeed = (_, _) => completer.future; await pumpHome(tester); await tester.pump(); // 首载骨架连排 3 张(列表懒加载,视口内至少 1 张被实例化)。 expect(find.byType(FeedSkeleton), findsAtLeastNWidgets(1)); completer.complete(feedPage(const [])); await tester.pumpAndSettle(); expect(find.byType(FeedSkeleton), findsNothing); }); testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 去创作', (tester) async { repository.onFeed = (_, _) async => feedPage(const []); await pumpHome(tester); await tester.pumpAndSettle(); expect(find.text('还没有动态'), findsOneWidget); await tester.drag(list(), const Offset(0, -300)); await tester.pump(); await tester.tap(find.text('发布第一条')); expect(createTaps, 1); }); testWidgets('四态 · error:横幅 + 重试恢复 ready,feed_load_failed 上报', (tester) async { var attempts = 0; repository.onFeed = (_, _) async { attempts += 1; if (attempts == 1) throw const ApiNetworkException(); return feedPage([textCard('p-1')]); }; await pumpHome(tester); await tester.pumpAndSettle(); expect(find.byType(InlineErrorBanner), findsOneWidget); expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); expect(eventsNamed('feed_load_failed'), [ { 'feedTab': 'home', 'loadType': 'refresh', 'failureReason': 'network_error', }, ]); await tester.drag(list(), const Offset(0, -200)); await tester.pump(); await tester.tap(find.text('重试')); await tester.pumpAndSettle(); expect(find.byType(InlineErrorBanner), findsNothing); expect(find.text('动态内容 p-1'), findsOneWidget); }); testWidgets('四态 · ready:卡片列表 + 降级作者「宠友」占位', (tester) async { repository.onFeed = (_, _) async => feedPage([textCard('p-1'), degradedCard('p-2')]); await pumpHome(tester); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -400)); await tester.pump(); expect(find.byType(PostCard), findsNWidgets(2)); expect(find.text('毛毛的铲屎官'), findsOneWidget); expect(find.text('宠友'), findsOneWidget); }); testWidgets('翻页:触底携游标加载下一页,不丢不重,到底显「没有更多了」', (tester) async { final page2 = Completer>(); repository.onFeed = (_, cursor) async { if (cursor == null) { return feedPage( [textCard('p-1'), textCard('p-2')], nextCursor: 'c1', hasMore: true, ); } return page2.future; }; await pumpHome(tester); await tester.pumpAndSettle(); // 触底:尾部 loading 转圈。 await tester.drag(list(), const Offset(0, -1000)); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); page2.complete(feedPage([textCard('p-3')])); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -600)); await tester.pump(); // keyset 游标不丢不重:三帖各恰一张,游标原样带出。 expect(repository.calls.where((c) => c.startsWith('feed:')).toList(), [ 'feed:cursor=null', 'feed:cursor=c1', ]); expect(find.text('动态内容 p-1'), findsOneWidget); expect(find.text('动态内容 p-2'), findsOneWidget); expect(find.text('动态内容 p-3'), findsOneWidget); expect(find.text('没有更多了'), findsOneWidget); }); testWidgets('尾部失败:重试条 + feed_load_failed(load_more),点按重试补页', (tester) async { var page2Attempts = 0; repository.onFeed = (_, cursor) async { if (cursor == null) { return feedPage([textCard('p-1')], nextCursor: 'c1', hasMore: true); } page2Attempts += 1; if (page2Attempts == 1) { throw const ApiBusinessException(code: 40000, message: 'x'); } return feedPage([textCard('p-2')]); }; await pumpHome(tester); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -1000)); await tester.pumpAndSettle(); expect(find.text('加载失败,点此重试'), findsOneWidget); expect(eventsNamed('feed_load_failed'), [ { 'feedTab': 'home', 'loadType': 'load_more', 'failureReason': 'server_error', 'errorCode': 40000, 'httpStatus': 400, }, ]); await tester.tap(find.text('加载失败,点此重试')); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -400)); await tester.pump(); expect(find.text('动态内容 p-2'), findsOneWidget); expect(find.text('加载失败,点此重试'), findsNothing); }); testWidgets('下拉刷新:整体替换列表;刷新失败保留旧列表 + SnackBar', (tester) async { var attempts = 0; repository.onFeed = (_, _) async { attempts += 1; if (attempts == 1) return feedPage([textCard('p-1')]); if (attempts == 2) throw const ApiNetworkException(); return feedPage([textCard('p-9')]); }; await pumpHome(tester); await tester.pumpAndSettle(); expect(find.text('动态内容 p-1'), findsOneWidget); // 第一次下拉:失败——旧列表保留、SnackBar 轻提示、事件上报。 await tester.fling(list(), const Offset(0, 400), 1000); await tester.pumpAndSettle(); expect(find.text('动态内容 p-1'), findsOneWidget); expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); expect(eventsNamed('feed_load_failed').single?['loadType'], 'refresh'); // 第二次下拉:成功——整体替换不残留旧卡。 await tester.fling(list(), const Offset(0, 400), 1000); await tester.pumpAndSettle(); expect(find.text('动态内容 p-9'), findsOneWidget); expect(find.text('动态内容 p-1'), findsNothing); }); testWidgets('曝光结算 · 切走 Tab:可见 ≥500ms 的卡片计入,一段恰一条 feed_viewed', ( tester, ) async { repository.onFeed = (_, _) async => feedPage([textCard('p-1'), textCard('p-2')]); await pumpHome(tester); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -1000)); // 先渲染一帧(滚动后位置在帧末扫描),再驻留满 500ms(曝光判定), // 随后切走 Tab 结算。 await tester.pump(); await tester.pump(const Duration(milliseconds: 600)); expect(eventsNamed('feed_viewed'), isEmpty); await pumpHome(tester, isActive: false); await tester.pump(); final viewed = eventsNamed('feed_viewed'); expect(viewed, hasLength(1)); expect(viewed.single?['feedTab'], 'home'); expect(viewed.single?['impressionCount'], 2); // 首屏自动预取不计刷新,本段无翻页。 expect(viewed.single?['refreshCount'], 0); expect(viewed.single?['loadMoreCount'], 0); // durationMs 取真实时钟(生产语义),widget 测试的 pump 只推进假 // 时钟,此处仅验证字段在场非负(精确口径见 feed_exposure_test)。 expect(viewed.single?['durationMs'], greaterThanOrEqualTo(0)); // 切回 Tab 开新段:再切走仍恰一条新事件(幂等不翻倍)。 await pumpHome(tester); await tester.pump(); await pumpHome(tester, isActive: false); await tester.pump(); expect(eventsNamed('feed_viewed'), hasLength(2)); }); testWidgets('曝光结算 · 快速滑过不计;刷新/翻页计数入段', (tester) async { final cards = List.generate(6, (i) => textCard('p-$i')); repository.onFeed = (_, cursor) async { if (cursor == null) { return feedPage(cards, nextCursor: 'c1', hasMore: true); } return feedPage([textCard('p-6')]); }; await pumpHome(tester); await tester.pumpAndSettle(); // 快速滑到底(各卡驻留 <500ms)→ 触发一次翻页。 await tester.drag(list(), const Offset(0, -2000)); await tester.pumpAndSettle(); // 用户下拉刷新一次。 await tester.drag(list(), const Offset(0, 2000)); await tester.pump(); await tester.fling(list(), const Offset(0, 400), 1000); await tester.pumpAndSettle(); await pumpHome(tester, isActive: false); await tester.pump(); final viewed = eventsNamed('feed_viewed').single!; expect(viewed['refreshCount'], 1); expect(viewed['loadMoreCount'], 1); // 快速滑过的中段卡片未驻留满 500ms,不应全量计曝光。 expect(viewed['impressionCount'], lessThan(cards.length)); }); testWidgets('曝光结算 · 退后台:结算一条,回前台开新段', (tester) async { repository.onFeed = (_, _) async => feedPage([textCard('p-1')]); await pumpHome(tester); await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pump(); // 级联 inactive→hidden→paused 只结算一次。 expect(eventsNamed('feed_viewed'), hasLength(1)); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pump(); expect(eventsNamed('feed_viewed'), hasLength(2)); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); }); testWidgets('曝光结算 · 切到服务分段:结算;切回开新段', (tester) async { repository.onFeed = (_, _) async => feedPage([textCard('p-1')]); await pumpHome(tester); await tester.pumpAndSettle(); await tester.tap(find.text('本地服务')); await tester.pump(); expect(eventsNamed('feed_viewed'), hasLength(1)); await tester.tap(find.text('社区动态')); await tester.pump(); await tester.tap(find.text('本地服务')); await tester.pump(); expect(eventsNamed('feed_viewed'), hasLength(2)); }); testWidgets('T3-15 导航接通:整卡与评论钮点按回调 onOpenPost(占位提示移除)', (tester) async { repository.onFeed = (_, _) async => feedPage([textCard('p-1')]); await pumpHome(tester); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -600)); await tester.pump(); await tester.tap(find.text('动态内容 p-1')); await tester.pump(); expect(find.text('帖子详情正在接入真实数据,敬请期待'), findsNothing); expect(openedPosts, ['p-1']); await tester.tap(find.byIcon(Icons.chat_bubble_outline)); await tester.pump(); expect(openedPosts, ['p-1', 'p-1']); }); testWidgets('T3-16 互动接线:点赞乐观翻转即时显示,成功报 post_liked(source=feed)', ( tester, ) async { repository.onFeed = (_, _) async => feedPage([ FeedCard.fromJson({ ...sampleFeedCardJson(likeCount: 6), 'coverImage': null, 'mediaCount': 0, }), ]); final like = Completer(); repository.onLikeToggle = (_, _) => like.future; await pumpHome(tester); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -800)); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.favorite_border)); await tester.pump(); // 乐观翻转:请求未回已 +1(同帧反馈)。 expect(find.text('7'), findsOneWidget); expect(repository.calls, contains('like:p-1')); like.complete(const LikeState(liked: true, likeCount: 7)); await tester.pumpAndSettle(); expect(eventsNamed('post_liked'), [ {'source': 'feed'}, ]); expect(find.text('7'), findsOneWidget); }); testWidgets('T3-16 互动接线:点赞失败回滚 + SnackBar「操作失败,请重试」', (tester) async { repository.onFeed = (_, _) async => feedPage([ FeedCard.fromJson({ ...sampleFeedCardJson(likeCount: 6), 'coverImage': null, 'mediaCount': 0, }), ]); final like = Completer(); repository.onLikeToggle = (_, _) => like.future; await pumpHome(tester); await tester.pumpAndSettle(); await tester.drag(list(), const Offset(0, -800)); 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(); // 回滚成对恢复 + SnackBar;不报 post_liked。 expect(find.text('6'), findsOneWidget); expect(find.text('操作失败,请重试'), findsOneWidget); expect(eventsNamed('post_liked'), isEmpty); }); testWidgets('搜索词过滤已加载卡片;无命中显搜索空态', (tester) async { repository.onFeed = (_, _) async => feedPage([textCard('p-1'), textCard('p-2')]); await pumpHome(tester); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'p-1'); await tester.pump(); expect(find.byType(PostCard), findsOneWidget); await tester.enterText(find.byType(TextField), '不存在的词'); await tester.pump(); expect(find.byType(PostCard), findsNothing); expect(find.text('没有找到相关动态'), findsOneWidget); }); }