Files
patbond-flutter/test/features/home/home_page_test.dart
T
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

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

468 lines
17 KiB
Dart
Raw 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/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 composeTaps;
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)),
);
composeTaps = 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: (_) {},
onOpenCompose: () => composeTaps++,
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(composeTaps, 1);
});
testWidgets('四态 · error:横幅 + 重试恢复 readyfeed_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);
});
}