From 694543647ff8436a393bed07f3df28dafdad94a7 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 15:37:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E9=97=AE=E5=80=99=E8=AF=AD=E7=9C=9F=E5=AE=9E=E5=8C=96=20+=20?= =?UTF-8?q?=E5=88=BB=E6=84=8F=E4=BF=9D=E7=95=99=E7=9A=84=20demo=20?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E6=98=BE=E5=BC=8F=E7=99=BB=E8=AE=B0=EF=BC=88?= =?UTF-8?q?T3.5-10=EF=BC=8CADR-022=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「下午好,豆豆」改真实展示名(`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 --- .../profile_avatar_live_test.dart | 471 ++++++++++++++++++ lib/features/home/home_page.dart | 58 ++- test/features/home/home_greeting_test.dart | 144 ++++++ 3 files changed, 667 insertions(+), 6 deletions(-) create mode 100644 integration_test/profile_avatar_live_test.dart create mode 100644 test/features/home/home_greeting_test.dart diff --git a/integration_test/profile_avatar_live_test.dart b/integration_test/profile_avatar_live_test.dart new file mode 100644 index 0000000..9e032fd --- /dev/null +++ b/integration_test/profile_avatar_live_test.dart @@ -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 _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); +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index 7c4a0b6..f0a5161 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -10,6 +10,7 @@ import 'package:patbond_flutter/features/community/community_display.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; import 'package:patbond_flutter/features/community/feed_exposure.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; import 'package:patbond_flutter/models/models.dart'; import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; @@ -17,8 +18,19 @@ import 'package:patbond_flutter/widgets/common.dart'; enum HomeSegment { feed, services } /// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据 -/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与 -/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。 +/// (四态 + 游标翻页 + 聚合曝光埋点);问候语自 T3.5-10 起用真实展示名。 +/// +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1 钉死范围,留待对应里程碑; +/// 在此登记以免实测重复反馈): +/// - **天气条与地区选择**([_WeatherStatusBar] / [_AreaPickerSheet] / +/// [_WeatherDetailsSheet]):需接外部天气服务(含 API key 与配额管理), +/// 不属本迭代。 +/// - **story 环的「柴犬圈 / 猫咪圈 / 救助站」**([_StoryRow]):实为话题, +/// ADR-018 已把话题剪出 M3 范围;环内「发布」是真入口。 +/// - **促销卡「新用户首单立减 ¥20」**([_PromoCard]):属 M5 服务域 +/// (优惠/订单能力尚不存在)。 +/// - **搜索框与「本地服务」段**:搜索只过滤已加载页(契约无检索端点); +/// 服务商数据为 demo 常量,同属 M5。 class HomePage extends StatefulWidget { const HomePage({ required this.appState, @@ -26,6 +38,7 @@ class HomePage extends StatefulWidget { required this.onOpenServices, required this.onOpenCompose, super.key, + this.profileController, this.onOpenPost, this.feedAnalytics, this.isActive = true, @@ -36,6 +49,11 @@ class HomePage extends StatefulWidget { /// Feed 数据源(Tab 级单例,app.dart 装配注入)。 final CommunityController communityController; + /// 展示名数据源(T3.5-10):与资料页**共用同一控制器**, + /// 故改昵称后两处一起变,且全程只发一次 `/me`。 + /// 未注入或资料尚未到手时问候语不带名字(不编造「豆豆」这类假名)。 + final ProfileController? profileController; + final ValueChanged onOpenServices; /// 发布页入口(T3-17:story 环「发布」与空态 CTA 均 push 真实发布页, @@ -79,6 +97,8 @@ class _HomePageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + // 展示名变化(资料首次到手 / 用户改昵称后返回)即重建问候语。 + widget.profileController?.addListener(_onProfileChanged); // 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。 // 首屏自动预取不计入浏览段 refreshCount(非用户动作)。 if (_feed.phase == FeedPhase.initial) { @@ -87,6 +107,10 @@ class _HomePageState extends State with WidgetsBindingObserver { if (_feedSurfaceVisible) _startSegment(); } + void _onProfileChanged() { + if (mounted) setState(() {}); + } + @override void didUpdateWidget(HomePage oldWidget) { super.didUpdateWidget(oldWidget); @@ -114,6 +138,7 @@ class _HomePageState extends State with WidgetsBindingObserver { @override void dispose() { _settleSegment(); + widget.profileController?.removeListener(_onProfileChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -341,7 +366,10 @@ class _HomePageState extends State with WidgetsBindingObserver { const SizedBox(height: 12), _PetGreetingCard( greeting: greeting, - petName: widget.appState.pet.name, + // T3.5-10:真实展示名(`nickname ?? username`,与资料页 + // 同源同规则)取代硬编码的 demo 宠物名「豆豆」。 + // 资料未到手时为 null → 只问候不称名,不编造假名。 + displayName: widget.profileController?.displayName, petAvatar: widget.appState.pet.avatarUrl, advice: widget.appState.locationWeather.petAdvice, ), @@ -552,6 +580,8 @@ class _HomePageState extends State with WidgetsBindingObserver { } } +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):定位与天气需接外部 +/// 服务(含 API key 与配额管理),本迭代不做;地区选择只改本机 demo 数据。 class _WeatherStatusBar extends StatelessWidget { const _WeatherStatusBar({ required this.weather, @@ -641,14 +671,23 @@ class _WeatherStatusBar extends StatelessWidget { class _PetGreetingCard extends StatelessWidget { const _PetGreetingCard({ required this.greeting, - required this.petName, + required this.displayName, required this.petAvatar, required this.advice, }); final String greeting; - final String petName; + + /// 当前用户展示名;null 即资料未到手(问候语退化为不称名形态)。 + final String? displayName; + + /// **刻意保留的 demo 占位**:卡右侧大图仍是 demo 宠物图(用户头像与宠物 + /// 头像本单已真实化,但「首页该显示哪只宠物」需要一个『当前宠物』概念, + /// 尚不存在——不在 ADR-022 范围内)。 final String petAvatar; + + /// **刻意保留的 demo 占位**:养宠建议来自 demo 天气数据(外部天气服务 + /// 未接,ADR-022 决策 D3.5-1)。 final String advice; @override @@ -691,7 +730,9 @@ class _PetGreetingCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$greeting,$petName 👋', + displayName == null + ? '$greeting 👋' + : '$greeting,$displayName 👋', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -886,6 +927,9 @@ Color _weatherColor(WeatherCondition condition) { }; } +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):「柴犬圈 / 猫咪圈 / +/// 救助站」三个圈子实为**话题**,ADR-018 已把话题能力剪出 M3 范围, +/// 留待话题里程碑。首格「发布」是真入口(T3-17 起 push 真实发布页)。 class _StoryRow extends StatelessWidget { const _StoryRow({required this.onCreate}); @@ -956,6 +1000,8 @@ class _StoryRow extends StatelessWidget { } } +/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):促销文案与「去使用」 +/// 属 **M5 服务域**——优惠券与订单能力尚不存在,当前点按只跳本地服务段。 class _PromoCard extends StatelessWidget { const _PromoCard({required this.onTap}); diff --git a/test/features/home/home_greeting_test.dart b/test/features/home/home_greeting_test.dart new file mode 100644 index 0000000..ae20334 --- /dev/null +++ b/test/features/home/home_greeting_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/home/home_page.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/state/app_state.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; + +/// T3.5-10 首页问候语真实化。 +/// +/// ADR-022 决策 D3.5-1:本迭代**只做问候语**,天气 / 位置 / 圈子 / 促销卡 +/// 刻意保留为 demo 占位——本文件同时钉住「保留项仍在」,避免后续有人以 +/// 「顺手清理」为名越出拍板范围(真要动它们得先改 ADR)。 +void main() { + late FakeCommunityRepository community; + late CommunityController feed; + + setUp(() { + community = FakeCommunityRepository(); + community.onFeed = (_, _) async => feedPage(const []); + feed = CommunityController(repository: community); + }); + + Future pumpHome( + WidgetTester tester, { + required FakeAuthRepository auth, + }) async { + tester.view.physicalSize = const Size(700, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + final profile = ProfileController( + authRepository: auth, + communityRepository: community, + ); + addTearDown(profile.dispose); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: HomePage( + appState: AppState(), + communityController: feed, + profileController: profile, + onOpenServices: (_) {}, + onOpenCompose: () {}, + ), + ), + ), + ); + return profile; + } + + /// 当前时段问候前缀(与页面实现同一套阈值;避免测试跟着挂钟漂移)。 + String greetingPrefix([DateTime? now]) { + final hour = (now ?? DateTime.now()).hour; + if (hour < 6) return '夜深了'; + if (hour < 11) return '早上好'; + if (hour < 14) return '中午好'; + if (hour < 18) return '下午好'; + return '晚上好'; + } + + testWidgets('有昵称:问候语用昵称,demo 宠物名「豆豆」不再出现', (tester) async { + final profile = await pumpHome( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx', nickname: '小柴'), + ), + ); + await profile.refresh(); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()},小柴 👋'), findsOneWidget); + expect(find.textContaining('豆豆'), findsNothing); + }); + + testWidgets('无昵称:问候语回退 username(与资料页同规则)', (tester) async { + final profile = await pumpHome( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + ), + ); + await profile.refresh(); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()},llx 👋'), findsOneWidget); + }); + + testWidgets('资料未到手:只问候不称名(不编造假名)', (tester) async { + await pumpHome( + tester, + auth: FakeAuthRepository( + meHandler: () async => throw const ApiNetworkException('断网'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()} 👋'), findsOneWidget); + // 不出现「问候,某某」形态(更不会退回 demo 宠物名)。 + expect(find.textContaining('${greetingPrefix()},'), findsNothing); + }); + + testWidgets('改昵称后首页同步(与资料页共用同一控制器,无需手动刷新)', (tester) async { + final auth = FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + updateMeHandler: (_) async => + buildProfile(username: 'llx', nickname: '小柴'), + ); + final profile = await pumpHome(tester, auth: auth); + await profile.refresh(); + await tester.pumpAndSettle(); + expect(find.text('${greetingPrefix()},llx 👋'), findsOneWidget); + + await profile.save( + const UpdateMeRequest(nickname: PatchField.value('小柴')), + ); + await tester.pumpAndSettle(); + + expect(find.text('${greetingPrefix()},小柴 👋'), findsOneWidget); + }); + + testWidgets('刻意保留的 demo 占位仍在(ADR-022 钉死的范围)', (tester) async { + final profile = await pumpHome( + tester, + auth: FakeAuthRepository(meHandler: () async => buildProfile()), + ); + await profile.refresh(); + await tester.pumpAndSettle(); + + // 天气 / 位置(外部服务未接)。 + expect(find.text('北京 · 朝阳区'), findsOneWidget); + expect(find.text('28°C 晴'), findsOneWidget); + // 圈子 = 话题(ADR-018 剪出)。 + expect(find.text('柴犬圈'), findsOneWidget); + // 促销卡(M5 服务域)。 + expect(find.text('新用户首单立减 ¥20'), findsOneWidget); + }); +}