新增:首页 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,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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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('FeedSkeleton(05 §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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user