Files
patbond-flutter/integration_test/profile_avatar_live_test.dart
T
lixi 694543647f
CI / flutter-gates (push) Failing after 1s
新增:首页问候语真实化 + 刻意保留的 demo 占位显式登记(T3.5-10,ADR-022)
- 「下午好,豆豆」改真实展示名(`nickname ?? username`,与资料页共用同一个
  ProfileController:一次 /me 供两个消费点,改昵称后两处一起变)。资料未到手
  时退化为不称名的「下午好 👋」,不编造假名。
- 按 ADR-022 决策 D3.5-1 钉死范围,**其余首页 demo 一律不动**,并在代码里逐项
  标注为「刻意保留的 demo 占位」:天气/位置(需接外部服务)、圈子=话题
  (ADR-018 剪出)、促销卡(M5 服务域)、搜索与本地服务段。
  widget 测试同时钉住「保留项仍在」,避免后续以「顺手清理」越出拍板范围。
- 新增 compose 真链路桌面实测脚本 `integration_test/profile_avatar_live_test.dart`
  (默认跳过,`PATBOND_PROFILE_LIVE=1 ... -d linux` 开启):UI 注册 → 登录 →
  资料真实化 → 设昵称 → 传用户头像 → Feed 作者名同步 → 宠物头像,逐步截图。
  只有选图与压缩两层是桌面替身,createUpload / 预签名 PUT / confirm / PATCH
  全为生产实现。两处实测坑写进注释:Feed 作者名有服务端 60s 缓存滞后;
  1×1 的极小图能上传能下载但 Flutter 解码器拒绝,会被误判成「没传上」。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:48 +08:00

472 lines
20 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:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' show ImageByteFormat;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/community/media_compression.dart';
import 'package:patbond_flutter/features/community/media_picking.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.dart';
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
class _InMemoryTokenStore implements TokenStore {
final Map<String, String> _values = {};
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async => _values[key] = value;
@override
Future<void> delete(String key) async => _values.remove(key);
}
/// 固定测试图:16×16 棋盘 PNG(87 字节)。
///
/// **刻意不用 1×1 的极小图**1×1 的 PNG/JPEG 服务端照收、curl 也能原样取回,
/// 但走 `NetworkImage` 的解码路径会抛「Codec failed to produce an image」,
/// UI 于是落到 `RemoteImage` 的兜底占位——本机实测踩过这一格,很容易被误判
/// 成「头像根本没传上去」。用一张最小的**多像素**图即可走通到真实像素。
final Uint8List _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAHklEQVR42mOo0RV6Fc'
'FDPMlAkmogyTBqw6gNQ8YGAKJxBJBXv4+TAAAAAElFTkSuQmCC',
);
/// 桌面替身:选图直接给上面那张 JPEGLinux 无 image_picker 原生实现)。
class _DesktopPicker implements MediaImagePicker {
int calls = 0;
@override
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
calls += 1;
return [PickedMediaImage(bytes: _pngBytes, name: 'avatar.png')];
}
}
/// 桌面替身:压缩原样透传(Linux 无 flutter_image_compress 原生实现)。
class _PassthroughCompressor implements MediaImageCompressor {
const _PassthroughCompressor();
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
}
/// M3.5-08/09/10 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再:
/// PATBOND_PROFILE_LIVE=1 \
/// flutter test integration_test/profile_avatar_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面 GTK 渲染 + 真实 HTTP + 真实 MinIO)走一遍:
/// UI 注册 → 首页问候语显示用户名 → 资料页显示真实用户名与零值统计 →
/// 设昵称 → 首页问候语与资料页同步变昵称 → Feed 作者名同步(验证服务端
/// `/internal/users/profiles` 的 SQL 回退链)→ 传用户头像 → 宠物详情传头像。
///
/// 只有**选图与压缩两层**是桌面替身;createUpload / 预签名 PUT 直传 /
/// confirm / PATCH 全为生产实现。逐步截图落到 `build/profile-live/`。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_PROFILE_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
const petBase = 'http://127.0.0.1:8083';
const communityBase = 'http://127.0.0.1:8084';
final shotDir = Directory('build/profile-live');
const rootKey = ValueKey<String>('profile-live-root');
Future<void> shot(WidgetTester tester, String name) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(rootKey),
);
await tester.runAsync(() async {
if (!shotDir.existsSync()) shotDir.createSync(recursive: true);
final image = await boundary.toImage(pixelRatio: 1.5);
final bytes = await image.toByteData(format: ImageByteFormat.png);
File(
'${shotDir.path}/$name.png',
).writeAsBytesSync(bytes!.buffer.asUint8List());
debugPrint('[shot] $name (${bytes.lengthInBytes} bytes)');
});
}
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 30),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isNotEmpty) return;
}
fail('等待超时:$finder');
}
Future<void> pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 30),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isEmpty) return;
}
fail('等待消失超时:$finder');
}
/// 让出**真实时间**`pump(Duration)` 只推进一帧(把动画时钟往前拨,
/// 并不真的等 5 秒),等网络/等服务端缓存过期必须走 [WidgetTester.runAsync]。
Future<void> settleReal(
WidgetTester tester, {
Duration wait = const Duration(seconds: 2),
}) async {
await tester.runAsync(() => Future<void>.delayed(wait));
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 在指定滚动容器里向下滚到目标可见(资料页/列表页的底部入口都需要)。
Future<void> scrollTo(
WidgetTester tester,
Finder list,
Finder target, {
int maxDrags = 10,
}) async {
for (var i = 0; i < maxDrags && target.evaluate().isEmpty; i++) {
await tester.drag(list, const Offset(0, -300));
await tester.pump(const Duration(milliseconds: 300));
}
}
Future<Map<String, dynamic>> postJson(
String url,
Map<String, Object?> body, {
String? bearer,
}) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.contentType = ContentType.json;
request.headers.set('Idempotency-Key', DateTime.now().toIso8601String());
if (bearer != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer');
}
request.add(utf8.encode(jsonEncode(body)));
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
client.close();
expect(response.statusCode, anyOf(200, 201), reason: 'POST $url$text');
final envelope = jsonDecode(text) as Map<String, dynamic>;
expect(envelope['code'], 0, reason: 'POST $url 业务码非 0$text');
return envelope['data'] as Map<String, dynamic>;
}
String greetingPrefix() {
final hour = DateTime.now().hour;
if (hour < 6) return '夜深了';
if (hour < 11) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
}
testWidgets('桌面真链路:注册 → 资料真实化 → 设昵称 → 传头像 → Feed 作者名 → 宠物头像', (tester) async {
final picker = _DesktopPicker();
final seed = DateTime.now().millisecondsSinceEpoch;
final username = 'plive$seed';
final password = 'Live1234!$seed';
final phone = '+86137${(seed % 100000000).toString().padLeft(8, '0')}';
// 昵称与帖文都带 seed:Feed 是全局的,同名会让断言认错卡片。
final nickname = '实测柴${seed % 1000000}';
final postContent = '资料页实测帖 $seed';
await tester.pumpWidget(
RepaintBoundary(
key: rootKey,
child: App(
sessionManager: SessionManager(store: _InMemoryTokenStore()),
avatarUploaderFactory: (repository, purpose) => MediaUploader(
repository: repository,
purpose: purpose,
maxImages: 1,
maxConcurrentUploads: 1,
picker: picker,
compressor: const _PassthroughCompressor(),
),
),
),
);
// ---- 步骤 1:UI 注册(注册面不收昵称,ADR-022 决策 D3.5-5----
await pumpUntil(tester, find.text('立即注册'));
await tester.tap(find.text('立即注册'));
await pumpUntil(tester, find.text('创建账号'));
// live binding 下首个 enterText 常被丢(焦点还没建立),故每格「点入 →
// 输入 → 验值 → 必要时重输」,本机实测踩过这一格。
for (final entry in [
(0, username),
(1, phone.substring(3)),
(2, password),
(3, password),
]) {
final field = find.byType(TextField).at(entry.$1);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.tap(field);
await tester.pump(const Duration(milliseconds: 200));
await tester.enterText(field, entry.$2);
await tester.pump(const Duration(milliseconds: 300));
if (tester.widget<TextField>(field).controller?.text == entry.$2) break;
}
expect(
tester.widget<TextField>(field).controller?.text,
entry.$2,
reason: '注册表单第 ${entry.$1} 格未收到输入',
);
}
await shot(tester, '01-register');
await tester.tap(find.widgetWithText(FilledButton, '注册'));
// ---- 步骤 2:主壳挂载 → 等 /me 到手(资料页与首页共用这一次请求)----
await pumpUntil(tester, find.text('我的'));
await pumpUntil(tester, find.text('${greetingPrefix()}$username 👋'));
await shot(tester, '02-home-greeting-username');
// 借真实会话种一篇帖与一只宠物(UI 发帖/建档已有各自实测覆盖)。
final tokens = await tester.runAsync(
() => postJson('$authBase/api/v1/auth/login', {
'username': username,
'password': password,
}),
);
final accessToken = tokens!['accessToken'] as String;
await tester.runAsync(
() => postJson('$communityBase/api/v1/posts', {
'category': 'general',
'content': postContent,
'status': 'published',
}, bearer: accessToken),
);
final pet = await tester.runAsync(
() => postJson('$petBase/api/v1/pets', {
'name': '实测豆豆',
'species': 'dog',
'sex': 'male',
'customBreedName': '中华田园犬',
}, bearer: accessToken),
);
// ---- 步骤 2.5:登出 → UI 登录 ----
// 覆盖登录链路,同时让三个控制器重新预取:登出即 reset 是「跨账号不
// 泄漏」的既有纪律(app.dart),重登后各 Tab 从 initial 重新拉取,
// 于是上面用 API 种下的帖与宠物才会进入内存副本。
await tester.tap(find.text('我的'));
await pumpUntil(tester, find.text('@$username'));
final profileList = find
.ancestor(of: find.text('@$username'), matching: find.byType(ListView))
.first;
await scrollTo(tester, profileList, find.text('切换账号或退出登录'));
await pumpUntil(tester, find.text('切换账号或退出登录'));
await tester.tap(find.text('切换账号或退出登录'));
await pumpUntil(tester, find.text('立即注册'));
for (final entry in [(0, username), (1, password)]) {
final field = find.byType(TextField).at(entry.$1);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.tap(field);
await tester.pump(const Duration(milliseconds: 200));
await tester.enterText(field, entry.$2);
await tester.pump(const Duration(milliseconds: 300));
if (tester.widget<TextField>(field).controller?.text == entry.$2) break;
}
}
await tester.tap(find.widgetWithText(FilledButton, '登录'));
await pumpUntil(tester, find.text('我的'));
await pumpUntil(tester, find.text('${greetingPrefix()}$username 👋'));
// ---- 步骤 3:资料页展示真实用户名 + 零值统计(不是 demo 的 24/1.8k/2----
await tester.tap(find.text('我的'));
await pumpUntil(tester, find.text('@$username'));
await shot(tester, '03-profile-real-username');
expect(find.text('萌宠新手(豆豆家长)'), findsNothing);
expect(find.text('1.8k'), findsNothing);
expect(find.text('获赞'), findsOneWidget);
// 统计来自服务端:刚发了 1 帖、没人赞 → 作品 1 / 获赞 0。
expect(
find.descendant(
of: find
.ancestor(of: find.text('我的作品'), matching: find.byType(Column))
.first,
matching: find.text('1'),
),
findsOneWidget,
reason: '作品数应为服务端聚合的 1',
);
// ---- 步骤 4:设昵称 ----
await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料'));
await pumpUntil(tester, find.byType(ProfileEditPage));
expect(
find.text('未设置,当前展示为用户名「$username」'),
findsOneWidget,
reason: '编辑页不该把 username 预填进昵称框',
);
await shot(tester, '04-profile-edit-empty');
await tester.enterText(find.byType(TextField).first, nickname);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.widgetWithText(FilledButton, '保存'));
await pumpUntilGone(tester, find.byType(ProfileEditPage));
await pumpUntil(tester, find.text(nickname));
await shot(tester, '05-profile-nickname-set');
// ---- 步骤 5:上传用户头像(真实两步上传 + PATCH)----
await tester.tap(find.widgetWithText(OutlinedButton, '编辑资料'));
await pumpUntil(tester, find.byType(ProfileEditPage));
await tester.tap(find.widgetWithText(TextButton, '更换头像'));
await pumpUntil(tester, find.text('使用这张'));
await shot(tester, '06-avatar-sheet-ready');
await tester.tap(find.text('使用这张'));
await pumpUntil(tester, find.text('已选择\n新头像'));
await tester.tap(find.widgetWithText(FilledButton, '保存'));
await pumpUntilGone(tester, find.byType(ProfileEditPage));
await pumpUntil(tester, find.text('资料已更新'));
await settleReal(tester);
await shot(tester, '07-profile-avatar-uploaded');
expect(picker.calls, 1);
// ---- 步骤 6:首页问候语 + Feed 作者名同步为昵称 ----
await tester.tap(find.text('首页'));
await pumpUntil(tester, find.text('${greetingPrefix()}$nickname 👋'));
await shot(tester, '08-home-greeting-nickname');
// Feed 首屏是在设昵称之前拉的,故先下拉刷新——作者名要变必须重取,
// 这一步同时验证服务端 `/internal/users/profiles` 的 SQL 回退链
// (客户端对 FeedCard.author.nickname 不做任何拼装)。
final feedList = find.byType(ListView).first;
// ⚠️ 服务端有意的滞后:community 的 AuthorProfileGateway 把
// `/internal/users/profiles` 的结果放在 60s TTL 的进程内缓存里
// `patbond.author-profile.cache-ttl`M3 T3-05)。首屏 Feed 是设昵称
// 之前拉的,那次已把「作者名 = username」写进缓存,所以立刻下拉刷新
// 仍是旧名——这不是客户端 bug,客户端对作者名不做任何拼装。
// 故先等过 TTL,再刷新验证同步。
await settleReal(tester, wait: const Duration(seconds: 65));
var authorSynced = false;
for (var round = 0; round < 3 && !authorSynced; round++) {
// 逐段 moveBy + 抬手才会触发 RefreshIndicatorfling 的速度语义被当成
// 普通滚动,本机实测过);随后必须循环 pump 让真实 HTTP 有时间完成。
final pull = await tester.startGesture(
tester.getTopLeft(feedList) + const Offset(200, 120),
);
for (var i = 0; i < 6; i++) {
await pull.moveBy(const Offset(0, 60));
await tester.pump(const Duration(milliseconds: 80));
}
await pull.up();
await settleReal(tester, wait: const Duration(seconds: 3));
for (var i = 0; i < 8 && find.text(postContent).evaluate().isEmpty; i++) {
await tester.drag(feedList, const Offset(0, -240));
await tester.pump(const Duration(milliseconds: 400));
}
if (find.text(postContent).evaluate().isNotEmpty) {
final card = find.ancestor(
of: find.text(postContent),
matching: find.byType(PostCard),
);
debugPrint(
'[live] 第 $round 轮我的卡片文本:'
'${tester.widgetList<Text>(find.descendant(of: card, matching: find.byType(Text))).map((t) => t.data).join(' | ')}',
);
authorSynced = find
.descendant(of: card, matching: find.text(nickname))
.evaluate()
.isNotEmpty;
}
if (!authorSynced) {
await tester.drag(feedList, const Offset(0, 2000));
await tester.pump(const Duration(milliseconds: 400));
}
}
expect(
authorSynced,
isTrue,
reason: 'Feed 作者名未随昵称变化(服务端 /internal 回退链或刷新未生效)',
);
await shot(tester, '09-feed-author-nickname');
// ---- 步骤 7:宠物详情传头像(WRITE 档,纯头像 PATCH----
await tester.tap(find.text('档案'));
await pumpUntil(tester, find.text('实测豆豆'));
await shot(tester, '10a-pets-list-avatar-placeholder');
await tester.tap(find.text('实测豆豆'));
await pumpUntil(tester, find.byType(PetDetailPage));
await shot(tester, '10-pet-detail-placeholder-avatar');
expect(
tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.any((a) => a.url == null),
isTrue,
reason: '上传前应是爪印占位',
);
await tester.tap(find.byType(PetAvatar).first);
await pumpUntil(tester, find.text('使用这张'));
await tester.tap(find.text('使用这张'));
await pumpUntil(tester, find.text('头像已更新'));
await settleReal(tester, wait: const Duration(seconds: 6));
await shot(tester, '11-pet-avatar-uploaded');
final detailAvatarUrl = tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.map((a) => a.url)
.whereType<String>()
.firstOrNull;
debugPrint('[live] 详情头像 URL$detailAvatarUrl');
final imageStatus = await tester.runAsync(() async {
final client = HttpClient();
final response = await (await client.getUrl(
Uri.parse(detailAvatarUrl!),
)).close();
final bytes = await response.fold<int>(0, (n, chunk) => n + chunk.length);
client.close();
return '${response.statusCode} / $bytes bytes';
});
debugPrint('[live] 应用进程内直取头像:$imageStatus');
expect(imageStatus, startsWith('200'), reason: '预签名头像 URL 应可直取');
// 真的画出了像素:详情头像下必须有 Image,且不是 RemoteImage 的兜底
// 占位(占位是 Icon(Icons.pets),画不出图时才出现)。
final detailAvatar = find.byWidgetPredicate(
(widget) => widget is PetAvatar && widget.showEditBadge,
);
expect(
find.descendant(of: detailAvatar, matching: find.byType(Image)),
findsOneWidget,
);
expect(
find.descendant(of: detailAvatar, matching: find.byIcon(Icons.pets)),
findsNothing,
reason: '仍是爪印占位说明图没画出来(后端对了但 UI 没显示)',
);
expect(
tester
.widgetList<PetAvatar>(find.byType(PetAvatar))
.any((a) => (a.url ?? '').contains('pet_avatar')),
isTrue,
reason: '详情页头像应换成 pet_avatar 前缀的预签名 URL',
);
debugPrint('[live] petId=${pet!['id']} username=$username');
}, skip: !enabled);
}