- 「下午好,豆豆」改真实展示名(`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>
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
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',
|
||||
);
|
||||
|
||||
/// 桌面替身:选图直接给上面那张 JPEG(Linux 无 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 + 抬手才会触发 RefreshIndicator(fling 的速度语义被当成
|
||||
// 普通滚动,本机实测过);随后必须循环 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);
|
||||
}
|
||||
Reference in New Issue
Block a user