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 _values = {}; @override Future read(String key) async => _values[key]; @override Future write(String key, String value) async => _values[key] = value; @override Future 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> 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 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('profile-live-root'); Future shot(WidgetTester tester, String name) async { final boundary = tester.renderObject( 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 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 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 settleReal( WidgetTester tester, { Duration wait = const Duration(seconds: 2), }) async { await tester.runAsync(() => Future.delayed(wait)); for (var i = 0; i < 6; i++) { await tester.pump(const Duration(milliseconds: 120)); } } /// 在指定滚动容器里向下滚到目标可见(资料页/列表页的底部入口都需要)。 Future 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> postJson( String url, Map 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; expect(envelope['code'], 0, reason: 'POST $url 业务码非 0:$text'); return envelope['data'] as Map; } 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(field).controller?.text == entry.$2) break; } expect( tester.widget(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(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(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(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(find.byType(PetAvatar)) .map((a) => a.url) .whereType() .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(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(find.byType(PetAvatar)) .any((a) => (a.url ?? '').contains('pet_avatar')), isTrue, reason: '详情页头像应换成 pet_avatar 前缀的预签名 URL', ); debugPrint('[live] petId=${pet!['id']} username=$username'); }, skip: !enabled); }