新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
CI / flutter-gates (push) Successful in 2m41s

- 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>
This commit is contained in:
2026-09-09 13:01:43 +08:00
parent 1441f0148f
commit 8aac8c52cc
22 changed files with 2401 additions and 212 deletions
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/signed_network_image.dart';
void main() {
group('presignedImageCacheKey', () {
test('剥离全部 X-Amz-* 签名参数(大小写不敏感)', () {
const url =
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg'
'?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=cred'
'&X-Amz-Date=20260909T000000Z&X-Amz-Expires=3600'
'&X-Amz-SignedHeaders=host&x-amz-signature=deadbeef';
expect(
presignedImageCacheKey(url),
'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg',
);
});
test('保留非签名 query 参数', () {
const url = 'https://cdn.example.com/p.jpg?w=300&X-Amz-Signature=sig';
expect(
presignedImageCacheKey(url),
'https://cdn.example.com/p.jpg?w=300',
);
});
test('无签名参数原样返回', () {
expect(
presignedImageCacheKey('https://cdn.example.com/p.jpg?w=300&q=80'),
'https://cdn.example.com/p.jpg?w=300&q=80',
);
expect(
presignedImageCacheKey('https://cdn.example.com/p.jpg'),
'https://cdn.example.com/p.jpg',
);
});
test('非法 URL 不炸,退回原串', () {
expect(presignedImageCacheKey('::not a url::'), '::not a url::');
});
});
group('SignedNetworkImage', () {
test('同对象不同签名 → 判等(命中同一 ImageCache 条目)', () {
final first = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig1&X-Amz-Date=d1',
);
final second = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig2&X-Amz-Date=d2',
);
expect(first, second);
expect(first.hashCode, second.hashCode);
});
test('不同对象 → 不判等', () {
final first = SignedNetworkImage(
'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig',
);
final second = SignedNetworkImage(
'http://127.0.0.1:9000/m/b.jpg?X-Amz-Signature=sig',
);
expect(first, isNot(second));
});
test('scale 参与判等', () {
final first = SignedNetworkImage('http://h/m/a.jpg');
final second = SignedNetworkImage('http://h/m/a.jpg', scale: 2);
expect(first, isNot(second));
});
});
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/like_button.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/widgets/common.dart';
import '../../helpers/community_test_helpers.dart';
FeedCard cardFrom(Map<String, dynamic> overrides) =>
FeedCard.fromJson({...sampleFeedCardJson(), ...overrides});
Widget wrap(Widget child) => MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: SingleChildScrollView(child: child)),
);
void main() {
testWidgets('单图形态:通栏 4:3 出血,不走九宫格,预览 2 行', (tester) async {
final card = cardFrom({'mediaCount': 1});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.byType(AspectRatio), findsOneWidget);
expect(find.byType(PostMediaGrid), findsNothing);
final preview = tester.widget<Text>(find.text('晒了一下午太阳。'));
expect(preview.maxLines, 2);
});
testWidgets('多图形态:九宫格折叠封面 + 「+N」角标(契约只带封面与计数)', (tester) async {
final card = cardFrom({'mediaCount': 5});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.byType(PostMediaGrid), findsOneWidget);
expect(find.text('+4'), findsOneWidget);
});
testWidgets('纯文字形态:无媒体区,正文放宽 6 行、15/1.6', (tester) async {
final card = cardFrom({'coverImage': null, 'mediaCount': 0});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.byType(PostMediaGrid), findsNothing);
expect(find.byType(AspectRatio), findsNothing);
final preview = tester.widget<Text>(find.text('晒了一下午太阳。'));
expect(preview.maxLines, 6);
expect(preview.style?.fontSize, 15);
});
testWidgets('头部行:作者名 + 相对时间;求助帖追加 accent 标', (tester) async {
final card = cardFrom({
'category': 'help',
'publishedAt': DateTime.now()
.subtract(const Duration(hours: 2))
.toUtc()
.toIso8601String(),
});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.text('毛毛的铲屎官'), findsOneWidget);
expect(find.text('2 小时前'), findsOneWidget);
expect(find.widgetWithText(TagPill, '求助'), findsOneWidget);
});
testWidgets('降级作者:占位头像 + 「宠友」默认名', (tester) async {
final card = cardFrom({
'author': sampleAuthorJson(nickname: null, avatarUrl: null),
});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.text('宠友'), findsOneWidget);
expect(find.text('毛毛的铲屎官'), findsNothing);
});
testWidgets('操作行:计数与激活态展示;T3-14 纯展示点按无副作用', (tester) async {
final card = cardFrom({
'likedByMe': true,
'likeCount': 6,
'bookmarkedByMe': false,
'bookmarkCount': 2,
'commentCount': 3,
});
await tester.pumpWidget(wrap(PostCard(card: card)));
expect(find.text('6'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
// 点赞激活:实心 favorite + error 色(Colors.red 修订 D6)。
final likeIcon = tester.widget<Icon>(find.byIcon(Icons.favorite));
expect(likeIcon.color, AppColors.error);
// 收藏未激活:描边 + inkSoft。
final bookmarkIcon = tester.widget<Icon>(
find.byIcon(Icons.bookmark_border),
);
expect(bookmarkIcon.color, AppColors.inkSoft);
// 回调未接(null):点按不抛错、无状态变化。
await tester.tap(find.byType(LikeButton).first, warnIfMissed: false);
await tester.pump();
expect(find.byIcon(Icons.favorite), findsOneWidget);
});
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/post_media_grid.dart';
import 'package:patbond_flutter/widgets/common.dart';
Widget wrap(Widget child, {bool disableAnimations = false}) => MaterialApp(
theme: buildAppTheme(),
home: MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: Scaffold(body: SingleChildScrollView(child: child)),
),
);
List<String> urls(int count) => List.generate(
count,
(i) => 'https://minio.local/p$i.jpg?X-Amz-Signature=s',
);
void main() {
group('PostMediaGrid 列数规则(05 §3.2', () {
test('2、4 图 → 2 列;3、5–9 图 → 3 列', () {
expect(PostMediaGrid.columnsFor(2), 2);
expect(PostMediaGrid.columnsFor(4), 2);
expect(PostMediaGrid.columnsFor(3), 3);
for (var n = 5; n <= 9; n++) {
expect(PostMediaGrid.columnsFor(n), 3);
}
});
testWidgets('4 图渲染 4 格网格', (tester) async {
await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(4))));
expect(find.byType(RemoteImage), findsNWidgets(4));
expect(find.byType(GridView), findsOneWidget);
});
testWidgets('超 9 图折叠:显 9 格,末格 ink 80% scrim + 「+N」', (tester) async {
await tester.pumpWidget(
wrap(PostMediaGrid(urls: urls(12), totalCount: 12)),
);
expect(find.byType(RemoteImage), findsNWidgets(9));
expect(find.text('+3'), findsOneWidget);
final scrim = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((w) => w.decoration)
.whereType<BoxDecoration>()
.where((d) => d.color == AppColors.ink.withAlpha(204));
expect(scrim, isNotEmpty);
});
testWidgets('单图折叠形态(Feed 封面 + 总数):4:3 单格 + 角标胶囊', (tester) async {
await tester.pumpWidget(
wrap(PostMediaGrid(urls: urls(1), totalCount: 3)),
);
expect(find.byType(GridView), findsNothing);
expect(find.byType(AspectRatio), findsOneWidget);
expect(find.text('+2'), findsOneWidget);
});
testWidgets('单图无折余不出角标', (tester) async {
await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(1))));
expect(find.textContaining('+'), findsNothing);
});
});
group('FeedSkeleton05 §3.7', () {
testWidgets('骨架单元:头部圆 + 4:3 块 + 正文横条,呼吸动效运行', (tester) async {
await tester.pumpWidget(wrap(const FeedSkeleton()));
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(AspectRatio), findsOneWidget);
final fade = tester.widget<FadeTransition>(
find
.descendant(
of: find.byType(FeedSkeleton),
matching: find.byType(FadeTransition),
)
.first,
);
final controller = fade.opacity as AnimationController;
expect(controller.isAnimating, isTrue);
});
testWidgets('系统减弱动态效果:动画静止在 1.0(pumpAndSettle 可收敛)', (tester) async {
await tester.pumpWidget(
wrap(const FeedSkeleton(), disableAnimations: true),
);
await tester.pumpAndSettle();
final fade = tester.widget<FadeTransition>(
find
.descendant(
of: find.byType(FeedSkeleton),
matching: find.byType(FadeTransition),
)
.first,
);
expect(fade.opacity.value, 1.0);
});
});
}
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/feed_analytics.dart';
void main() {
late List<(String, Map<String, dynamic>?)> events;
late FeedAnalytics analytics;
setUp(() {
events = [];
analytics = FeedAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
test('feed_viewed:五个专有属性逐一上报(06 §1.4 白名单)', () {
analytics.feedViewed(
feedTab: FeedTab.home,
durationMs: 12345,
impressionCount: 7,
loadMoreCount: 2,
refreshCount: 1,
);
expect(events, hasLength(1));
expect(events.single.$1, 'feed_viewed');
expect(events.single.$2, {
'feedTab': 'home',
'durationMs': 12345,
'impressionCount': 7,
'loadMoreCount': 2,
'refreshCount': 1,
});
});
test('feed_load_failed:业务码带 errorCode 并推导 httpStatus', () {
analytics.feedLoadFailed(
feedTab: FeedTab.home,
loadType: FeedLoadType.loadMore,
reason: FeedLoadFailureReason.serverError,
errorCode: 40403,
);
expect(events.single.$1, 'feed_load_failed');
expect(events.single.$2, {
'feedTab': 'home',
'loadType': 'load_more',
'failureReason': 'server_error',
'errorCode': 40403,
'httpStatus': 404,
});
});
test('feed_load_failed:网络错误缺席 errorCode/httpStatus', () {
analytics.feedLoadFailedFrom(
const ApiNetworkException(),
feedTab: FeedTab.home,
loadType: FeedLoadType.refresh,
);
expect(events.single.$2, {
'feedTab': 'home',
'loadType': 'refresh',
'failureReason': 'network_error',
});
});
test('异常映射:限流/网络/业务兜底;会话失效不上报', () {
expect(
feedLoadFailureReasonOf(const ApiRateLimitException()),
FeedLoadFailureReason.rateLimited,
);
expect(
feedLoadFailureReasonOf(const ApiNetworkException()),
FeedLoadFailureReason.networkError,
);
expect(
feedLoadFailureReasonOf(
const ApiBusinessException(code: 40000, message: 'x'),
),
FeedLoadFailureReason.serverError,
);
expect(feedLoadFailureReasonOf(const SessionExpiredException()), isNull);
analytics.feedLoadFailedFrom(
const SessionExpiredException(),
feedTab: FeedTab.home,
loadType: FeedLoadType.refresh,
);
expect(events, isEmpty);
});
}
@@ -0,0 +1,77 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/community/feed_exposure.dart';
void main() {
test('曝光判定:≥50% 驻留满 500ms 记一次,段内按帖去重', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
segment.updateVisibility('p-1', 0.8);
async.elapse(const Duration(milliseconds: 600));
expect(segment.impressionCount, 1);
// 再次可见不重复计。
segment.updateVisibility('p-1', 0.9);
async.elapse(const Duration(milliseconds: 600));
expect(segment.impressionCount, 1);
});
});
test('快速滑过(<500ms 跌破阈值)不计曝光', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
segment.updateVisibility('p-1', 0.8);
async.elapse(const Duration(milliseconds: 300));
segment.updateVisibility('p-1', 0.2); // 滚出视口
async.elapse(const Duration(seconds: 1));
expect(segment.impressionCount, 0);
});
});
test('可见面积不足 50% 不起计时', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
segment.updateVisibility('p-1', 0.49);
async.elapse(const Duration(seconds: 1));
expect(segment.impressionCount, 0);
});
});
test('结算:冻结计数、取消在途驻留、幂等只出一份', () {
fakeAsync((async) {
final clock = async.getClock(DateTime(2026));
final segment = FeedViewSegment(now: clock.now);
segment.updateVisibility('p-1', 1);
async.elapse(const Duration(milliseconds: 600));
segment.updateVisibility('p-2', 1); // 在途驻留,结算时未满 500ms
async.elapse(const Duration(milliseconds: 100));
segment.recordRefresh();
segment.recordLoadMore();
segment.recordLoadMore();
final summary = segment.settle();
expect(summary, isNotNull);
expect(summary!.impressionCount, 1);
expect(summary.refreshCount, 1);
expect(summary.loadMoreCount, 2);
expect(summary.durationMs, 700);
// 幂等:二次结算无第二条;结算后计数与曝光全部作废。
expect(segment.settle(), isNull);
segment.updateVisibility('p-3', 1);
async.elapse(const Duration(seconds: 1));
expect(segment.impressionCount, 1);
});
});
test('durationMs 上限截断 30 分钟(防挂机污染 H8)', () {
fakeAsync((async) {
final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now);
async.elapse(const Duration(minutes: 45));
expect(
segment.settle()!.durationMs,
const Duration(minutes: 30).inMilliseconds,
);
});
});
}
+393
View File
@@ -0,0 +1,393 @@
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);
});
}
+3
View File
@@ -4,6 +4,7 @@ import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'helpers/auth_test_helpers.dart';
import 'helpers/community_test_helpers.dart';
import 'helpers/pet_test_helpers.dart';
void main() {
@@ -17,6 +18,8 @@ void main() {
sessionManager: session,
authRepository: FakeAuthRepository(),
petsRepository: FakePetsRepository(),
communityRepository: FakeCommunityRepository()
..onFeed = (_, _) async => feedPage(const []),
),
);
await tester.pumpAndSettle();