- 「下午好,豆豆」改真实展示名(`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);
|
||||||
|
}
|
||||||
@@ -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/community_models.dart';
|
||||||
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/community/feed_exposure.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/models/models.dart';
|
||||||
import 'package:patbond_flutter/state/app_state.dart';
|
import 'package:patbond_flutter/state/app_state.dart';
|
||||||
import 'package:patbond_flutter/widgets/common.dart';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
@@ -17,8 +18,19 @@ import 'package:patbond_flutter/widgets/common.dart';
|
|||||||
enum HomeSegment { feed, services }
|
enum HomeSegment { feed, services }
|
||||||
|
|
||||||
/// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据
|
/// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据
|
||||||
/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与
|
/// (四态 + 游标翻页 + 聚合曝光埋点);问候语自 T3.5-10 起用真实展示名。
|
||||||
/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。
|
///
|
||||||
|
/// **刻意保留的 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 {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({
|
const HomePage({
|
||||||
required this.appState,
|
required this.appState,
|
||||||
@@ -26,6 +38,7 @@ class HomePage extends StatefulWidget {
|
|||||||
required this.onOpenServices,
|
required this.onOpenServices,
|
||||||
required this.onOpenCompose,
|
required this.onOpenCompose,
|
||||||
super.key,
|
super.key,
|
||||||
|
this.profileController,
|
||||||
this.onOpenPost,
|
this.onOpenPost,
|
||||||
this.feedAnalytics,
|
this.feedAnalytics,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
@@ -36,6 +49,11 @@ class HomePage extends StatefulWidget {
|
|||||||
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
|
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
|
||||||
final CommunityController communityController;
|
final CommunityController communityController;
|
||||||
|
|
||||||
|
/// 展示名数据源(T3.5-10):与资料页**共用同一控制器**,
|
||||||
|
/// 故改昵称后两处一起变,且全程只发一次 `/me`。
|
||||||
|
/// 未注入或资料尚未到手时问候语不带名字(不编造「豆豆」这类假名)。
|
||||||
|
final ProfileController? profileController;
|
||||||
|
|
||||||
final ValueChanged<bool> onOpenServices;
|
final ValueChanged<bool> onOpenServices;
|
||||||
|
|
||||||
/// 发布页入口(T3-17:story 环「发布」与空态 CTA 均 push 真实发布页,
|
/// 发布页入口(T3-17:story 环「发布」与空态 CTA 均 push 真实发布页,
|
||||||
@@ -79,6 +97,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// 展示名变化(资料首次到手 / 用户改昵称后返回)即重建问候语。
|
||||||
|
widget.profileController?.addListener(_onProfileChanged);
|
||||||
// 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。
|
// 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。
|
||||||
// 首屏自动预取不计入浏览段 refreshCount(非用户动作)。
|
// 首屏自动预取不计入浏览段 refreshCount(非用户动作)。
|
||||||
if (_feed.phase == FeedPhase.initial) {
|
if (_feed.phase == FeedPhase.initial) {
|
||||||
@@ -87,6 +107,10 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
|||||||
if (_feedSurfaceVisible) _startSegment();
|
if (_feedSurfaceVisible) _startSegment();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onProfileChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(HomePage oldWidget) {
|
void didUpdateWidget(HomePage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -114,6 +138,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_settleSegment();
|
_settleSegment();
|
||||||
|
widget.profileController?.removeListener(_onProfileChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -341,7 +366,10 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_PetGreetingCard(
|
_PetGreetingCard(
|
||||||
greeting: greeting,
|
greeting: greeting,
|
||||||
petName: widget.appState.pet.name,
|
// T3.5-10:真实展示名(`nickname ?? username`,与资料页
|
||||||
|
// 同源同规则)取代硬编码的 demo 宠物名「豆豆」。
|
||||||
|
// 资料未到手时为 null → 只问候不称名,不编造假名。
|
||||||
|
displayName: widget.profileController?.displayName,
|
||||||
petAvatar: widget.appState.pet.avatarUrl,
|
petAvatar: widget.appState.pet.avatarUrl,
|
||||||
advice: widget.appState.locationWeather.petAdvice,
|
advice: widget.appState.locationWeather.petAdvice,
|
||||||
),
|
),
|
||||||
@@ -552,6 +580,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):定位与天气需接外部
|
||||||
|
/// 服务(含 API key 与配额管理),本迭代不做;地区选择只改本机 demo 数据。
|
||||||
class _WeatherStatusBar extends StatelessWidget {
|
class _WeatherStatusBar extends StatelessWidget {
|
||||||
const _WeatherStatusBar({
|
const _WeatherStatusBar({
|
||||||
required this.weather,
|
required this.weather,
|
||||||
@@ -641,14 +671,23 @@ class _WeatherStatusBar extends StatelessWidget {
|
|||||||
class _PetGreetingCard extends StatelessWidget {
|
class _PetGreetingCard extends StatelessWidget {
|
||||||
const _PetGreetingCard({
|
const _PetGreetingCard({
|
||||||
required this.greeting,
|
required this.greeting,
|
||||||
required this.petName,
|
required this.displayName,
|
||||||
required this.petAvatar,
|
required this.petAvatar,
|
||||||
required this.advice,
|
required this.advice,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String greeting;
|
final String greeting;
|
||||||
final String petName;
|
|
||||||
|
/// 当前用户展示名;null 即资料未到手(问候语退化为不称名形态)。
|
||||||
|
final String? displayName;
|
||||||
|
|
||||||
|
/// **刻意保留的 demo 占位**:卡右侧大图仍是 demo 宠物图(用户头像与宠物
|
||||||
|
/// 头像本单已真实化,但「首页该显示哪只宠物」需要一个『当前宠物』概念,
|
||||||
|
/// 尚不存在——不在 ADR-022 范围内)。
|
||||||
final String petAvatar;
|
final String petAvatar;
|
||||||
|
|
||||||
|
/// **刻意保留的 demo 占位**:养宠建议来自 demo 天气数据(外部天气服务
|
||||||
|
/// 未接,ADR-022 决策 D3.5-1)。
|
||||||
final String advice;
|
final String advice;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -691,7 +730,9 @@ class _PetGreetingCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$greeting,$petName 👋',
|
displayName == null
|
||||||
|
? '$greeting 👋'
|
||||||
|
: '$greeting,$displayName 👋',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
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 {
|
class _StoryRow extends StatelessWidget {
|
||||||
const _StoryRow({required this.onCreate});
|
const _StoryRow({required this.onCreate});
|
||||||
|
|
||||||
@@ -956,6 +1000,8 @@ class _StoryRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **刻意保留的 demo 占位**(ADR-022 决策 D3.5-1):促销文案与「去使用」
|
||||||
|
/// 属 **M5 服务域**——优惠券与订单能力尚不存在,当前点按只跳本地服务段。
|
||||||
class _PromoCard extends StatelessWidget {
|
class _PromoCard extends StatelessWidget {
|
||||||
const _PromoCard({required this.onTap});
|
const _PromoCard({required this.onTap});
|
||||||
|
|
||||||
|
|||||||
@@ -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<ProfileController> 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<String>.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user