Files
lixi a4a97c03c2 新增:资料页真实化 + 编辑页——/me 读写、PATCH 三态、获赞与作品统计(T3.5-08)
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码
demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。

- `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落
  显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」
  也编码成 null,用户只改昵称就会连头像一起被服务端清掉。
- `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username`
  **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值
  预填,避免把展示约定固化成真实昵称。
- `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级
  这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。
- 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口,
  未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。
- 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐;
  纯空白是校验失败而非隐式清空。
- `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet`
  (单图、预览确认后才交付 ready assetId,孤儿防护不变)。
- 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository`
  此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被
  触发;本单是第一个真实消费者,桌面实测即暴露。

测试 526 → 588(+62)。

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

296 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '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/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import 'package:patbond_flutter/features/profile/profile_edit_page.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// T3.5-08 编辑页:**PATCH 三态载荷**是本文件的重点。
///
/// 每个成功用例都断言一次「未改字段的键不出现在 JSON 里」——这是三态与
/// 两态的唯一可观测差别,也是「只改昵称却把头像清掉」这类事故的唯一防线。
/// 昵称输入框(AppTextField 内层 TextFormField)。
Finder get nicknameField => find.byType(TextFormField);
void main() {
late FakeCommunityRepository community;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
setUp(() {
community = FakeCommunityRepository();
community.onGetMyCommunityStats = () async =>
CommunityStats.fromJson(sampleCommunityStatsJson());
community.onGetFollowStats = (_) async =>
FollowStats.fromJson(sampleFollowStatsJson());
picker = FakeMediaImagePicker([decodablePickedImage()]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
community.onCreateMediaUpload = (_) async => credentials();
community.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
});
MediaUploader buildUploader(MediaPurpose purpose) => MediaUploader(
repository: community,
purpose: purpose,
picker: picker,
compressor: compressor,
directUpload: direct,
maxImages: 1,
maxConcurrentUploads: 1,
);
/// 直接 push 编辑页(资料页链路另有测试;这里只测编辑页本身)。
Future<FakeAuthRepository> pumpEdit(
WidgetTester tester, {
String? nickname,
String? avatarUrl,
bool withUploader = true,
ApiException? saveError,
}) async {
tester.view.physicalSize = const Size(700, 1800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final auth = FakeAuthRepository(
meHandler: () async => buildProfile(
username: 'llx',
nickname: nickname,
avatarUrl: avatarUrl,
),
updateMeHandler: (_) async {
if (saveError != null) throw saveError;
return buildProfile(username: 'llx', nickname: nickname);
},
);
final controller = ProfileController(
authRepository: auth,
communityRepository: community,
);
addTearDown(controller.dispose);
await controller.refresh();
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: ProfileEditPage(
controller: controller,
avatarUploaderBuilder: withUploader ? buildUploader : null,
),
),
);
await tester.pumpAndSettle();
return auth;
}
group('三态载荷', () {
testWidgets('只改昵称 → 载荷只有 nicknameavatarAssetId 键不出现', (tester) async {
final auth = await pumpEdit(
tester,
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s',
);
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload, {'nickname': '小柴'});
expect(
payload.containsKey('avatarAssetId'),
isFalse,
reason: '发成 avatarAssetId: null 会把用户已有头像一起清掉',
);
});
testWidgets('点「清除昵称」 → nickname 显式 null,头像键不出现', (tester) async {
final auth = await pumpEdit(
tester,
nickname: '小柴',
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s',
);
await tester.tap(find.text('清除昵称'));
await tester.pumpAndSettle();
expect(find.text('保存后将清除昵称,展示名回退为用户名'), findsOneWidget);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload.containsKey('nickname'), isTrue);
expect(payload['nickname'], isNull);
expect(payload.containsKey('avatarAssetId'), isFalse);
});
testWidgets('点「清除头像」 → avatarAssetId 显式 nullnickname 键不出现', (
tester,
) async {
final auth = await pumpEdit(
tester,
nickname: '小柴',
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s',
);
await tester.tap(find.text('清除头像'));
await tester.pumpAndSettle();
expect(find.text('保存后将移除头像'), findsOneWidget);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload.containsKey('avatarAssetId'), isTrue);
expect(payload['avatarAssetId'], isNull);
expect(
payload.containsKey('nickname'),
isFalse,
reason: '昵称没碰过就不该出现在载荷里(更不该是 null)',
);
});
testWidgets('输入与原昵称相同 → 视为未改,保存钮禁用、不发请求', (tester) async {
final auth = await pumpEdit(tester, nickname: '小柴');
// 预填即原值。
expect(find.text('小柴'), findsOneWidget);
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '保存'),
);
expect(button.onPressed, isNull);
expect(auth.updateMePayloads, isEmpty);
});
testWidgets('无昵称时预填空串(不预填 username,避免把回退固化成真昵称)', (tester) async {
await pumpEdit(tester);
final field = tester.widget<TextFormField>(nicknameField);
expect(field.controller!.text, '');
expect(find.text('未设置,当前展示为用户名「llx」'), findsOneWidget);
// 无昵称时不给「清除昵称」入口(清一个不存在的东西无意义)。
expect(find.text('清除昵称'), findsNothing);
});
});
group('校验与失败语义', () {
testWidgets('33 码点昵称:字段级 errorText,不发请求', (tester) async {
final auth = await pumpEdit(tester);
await tester.enterText(nicknameField, '柴' * 33);
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.text('昵称最多 32 个字'), findsOneWidget);
expect(auth.updateMePayloads, isEmpty);
});
testWidgets('纯空格昵称:不是隐式清空,提示走显式清除入口', (tester) async {
final auth = await pumpEdit(tester, nickname: '小柴');
await tester.enterText(nicknameField, ' ');
await tester.pump();
// 空白输入被判为「不改」,保存钮禁用(不会误发清空)。
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '保存'),
);
expect(button.onPressed, isNull);
expect(auth.updateMePayloads, isEmpty);
});
testWidgets('42203 头像未就绪 → 横幅提示,页面留在编辑态可重试', (tester) async {
await pumpEdit(
tester,
saveError: const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'asset uploading',
),
);
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget);
expect(find.byType(ProfileEditPage), findsOneWidget);
});
testWidgets('40405 头像 asset 失效 → 提示重新上传', (tester) async {
await pumpEdit(
tester,
saveError: const ApiBusinessException(
code: ApiCodes.mediaNotFound,
message: 'gone',
),
);
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.text('头像已失效,请重新上传'), findsOneWidget);
});
});
group('头像上传接线', () {
testWidgets('上传全链路:purpose=user_avatarready 后确认 → 载荷带新 assetId', (
tester,
) async {
final auth = await pumpEdit(tester);
await tester.tap(find.text('更换头像'));
await tester.pumpAndSettle();
// 上传成功后 sheet 给预览 + 「使用这张」。
expect(find.text('使用这张'), findsOneWidget);
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
// 用途即引用侧的类型检查:必须是 user_avatar。
final request = community.lastMediaUploadRequest!;
expect(request.purpose, MediaPurpose.userAvatar);
expect(request.purpose.wire, 'user_avatar');
expect(find.text('已选择\n新头像'), findsOneWidget);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
final payload = auth.updateMePayloads.single;
expect(payload, {'avatarAssetId': 'a-1'});
expect(payload.containsKey('nickname'), isFalse);
});
testWidgets('上传后再改昵称 → 两键齐出(一次 PATCH 改两样)', (tester) async {
final auth = await pumpEdit(tester);
await tester.tap(find.text('更换头像'));
await tester.pumpAndSettle();
await tester.tap(find.text('使用这张'));
await tester.pumpAndSettle();
await tester.enterText(nicknameField, '小柴');
await tester.pump();
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(auth.updateMePayloads.single, {
'nickname': '小柴',
'avatarAssetId': 'a-1',
});
});
});
}