f873acf9a3
CI / flutter-gates (push) Failing after 20s
- 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>
468 lines
17 KiB
Dart
468 lines
17 KiB
Dart
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<String, dynamic>?)> events;
|
||
late FeedAnalytics analytics;
|
||
late int createTaps;
|
||
late List<String> 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<Map<String, dynamic>?> eventsNamed(String name) =>
|
||
events.where((e) => e.$1 == name).map((e) => e.$2).toList();
|
||
|
||
Future<void> 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<CursorPage<FeedCard>>();
|
||
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<CursorPage<FeedCard>>();
|
||
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<LikeState>();
|
||
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<LikeState>();
|
||
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);
|
||
});
|
||
}
|