Files
patbond-flutter/test/features/home/home_page_test.dart
T
lixi 8aac8c52cc
CI / flutter-gates (push) Successful in 2m41s
新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
- home_page Feed segment 改接 CommunityController:骨架屏首载、空态 CTA、
  错误横幅重试、下拉刷新(失败保留旧列表 + SnackBar)、触底游标翻页
  (失败态只走显式重试,不随滚动风暴自动重打)
- PostCard 三形态(单图通栏 4:3 / 多图折叠封面 +N 角标 / 纯文字 6 行)+
  PostMediaGrid(列数规则 + ink 80% scrim 精算)+ LikeButton(error 族修订
  Colors.red,T3-14 纯展示禁用态,ToggleSync 接线留 T3-15/16)+ FeedSkeleton
  (呼吸动效尊重减弱动态设置);降级作者统一「宠友」占位
- SignedNetworkImage:预签名 URL 缓存 key 剥离 X-Amz-* 签名参数,
  RemoteImage 全仓换用(同图不同签名命中同一缓存条目)
- feed 域埋点:feed_viewed 聚合曝光(浏览段开/结算于切 Tab、切分段、
  退后台、销毁;≥50% 可见 ≥500ms 段内按帖去重,postId 不上报)+
  feed_load_failed(refresh/load_more 双路,会话失效不报)
- integration_test/feed_live_test.dart:compose 六容器 Linux 桌面真链路
  实测入口(PATBOND_FEED_LIVE=1 门控,默认跳过)
- 测试 379 → 421(+42):四态/尾部三态/翻页不丢不重/降级作者/曝光结算
  时机/缓存 key/事件属性全覆盖;analyze 0,format 无 diff

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

394 lines
14 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_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;
setUp(() {
repository = FakeCommunityRepository();
controller = CommunityController(repository: repository);
events = [];
analytics = FeedAnalytics(
(name, [props]) async => events.add((name, props)),
);
createTaps = 0;
});
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++,
),
),
),
);
}
// 页面纵向主列表(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:横幅 + 重试恢复 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-14 取舍:整卡点按提示详情接入中(不导航 demo 详情)', (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('帖子详情正在接入真实数据,敬请期待'), findsOneWidget);
});
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);
});
}