新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
CI / flutter-gates (push) Successful in 2m41s
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user