Files
patbond-flutter/test/features/community/post_compose_page_test.dart
T
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

572 lines
21 KiB
Dart
Raw 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/core/widgets/post_media_grid.dart';
import 'package:patbond_flutter/features/community/community_controller.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
import 'package:patbond_flutter/features/community/post_compose_page.dart';
import '../../helpers/community_test_helpers.dart';
import '../../helpers/media_test_helpers.dart';
/// 发布页(T3-17):gating、两条提交路径(直接发布 / 存草稿退出)、
/// 三条失败语义(40905 / 42203 / 网络可重试同键)、删格重排、草稿恢复与
/// 发布漏斗埋点断言。
void main() {
late FakeCommunityRepository repository;
late CommunityController controller;
late FakeMediaImagePicker picker;
late FakeMediaCompressor compressor;
late FakeDirectUploadClient direct;
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
late List<CreatePostRequest> created;
late List<UpdatePostRequest> updated;
bool? popResult;
int uploadSeq = 0;
setUp(() {
repository = FakeCommunityRepository();
controller = CommunityController(repository: repository);
picker = FakeMediaImagePicker([
decodablePickedImage(seed: 1),
decodablePickedImage(seed: 2),
decodablePickedImage(seed: 3),
]);
compressor = FakeMediaCompressor();
direct = FakeDirectUploadClient();
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
created = [];
updated = [];
popResult = null;
uploadSeq = 0;
repository.onCreateMediaUpload = (_) async =>
credentials(assetId: 'a-${++uploadSeq}');
repository.onCompleteMediaUpload = (assetId) async =>
readyAsset(assetId: assetId);
repository.onCreatePost = (request, _) async {
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
repository.onUpdatePost = (postId, request) async {
updated.add(request);
return Post.fromJson(
samplePostJson(id: postId, version: request.version + 1),
);
};
});
List<Map<String, dynamic>?> eventsNamed(String name) =>
events.where((event) => event.$1 == name).map((e) => e.$2).toList();
MediaUploader uploaderFactory(
CommunityRepository repo,
PostAnalytics? postAnalytics,
) => MediaUploader(
repository: repo,
picker: picker,
compressor: compressor,
directUpload: direct,
analytics: postAnalytics,
);
Future<void> pumpCompose(
WidgetTester tester, {
PostEntryPoint entryPoint = PostEntryPoint.createTab,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
popResult = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => PostComposePage(
controller: controller,
entryPoint: entryPoint,
analytics: analytics,
uploaderFactory: uploaderFactory,
),
),
);
},
child: const Text('打开发布页'),
),
),
),
),
),
);
await tester.tap(find.text('打开发布页'));
await tester.pumpAndSettle();
}
Finder publishButton() => find.widgetWithText(FilledButton, '发布');
bool publishEnabled(WidgetTester tester) =>
tester.widget<FilledButton>(publishButton()).onPressed != null;
Future<void> writeContent(
WidgetTester tester, [
String text = '带豆豆去了公园',
]) async {
await tester.enterText(find.byType(TextField), text);
await tester.pump();
}
/// 选 [count] 张图并等上传完成。
Future<void> pickImages(WidgetTester tester, int count) async {
picker.results
..clear()
..addAll(List.generate(count, (i) => decodablePickedImage(seed: i + 1)));
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
await tester.pumpAndSettle();
}
group('gating05 §2.2/§2.3 + 后端 content 必填)', () {
testWidgets('空正文禁用;输入即启用;post_create_started 只报一次', (tester) async {
await pumpCompose(tester);
expect(publishEnabled(tester), isFalse);
await writeContent(tester, '豆');
expect(publishEnabled(tester), isTrue);
await writeContent(tester, '豆豆');
expect(eventsNamed('post_create_started'), hasLength(1));
expect(eventsNamed('post_create_started').single, {
'entryPoint': 'create_tab',
});
});
testWidgets('图片在途禁用发布,全部 ready 才放行(孤儿防护)', (tester) async {
direct.manual = true;
await pumpCompose(tester, entryPoint: PostEntryPoint.feed);
await writeContent(tester);
picker.results
..clear()
..addAll([decodablePickedImage()]);
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
await tester.pumpAndSettle();
expect(publishEnabled(tester), isFalse);
// 页级汇总条在途可见。
expect(find.textContaining('正在上传'), findsOneWidget);
direct.calls.single.succeed();
await tester.pumpAndSettle();
expect(publishEnabled(tester), isTrue);
expect(find.textContaining('正在上传'), findsNothing);
// 选媒体也算首次输入(entryPoint 归因 feed)。
expect(eventsNamed('post_create_started').single, {'entryPoint': 'feed'});
});
});
group('直接发布路径(建草稿 → PATCH 迁移)', () {
testWidgets('纯文字帖:createPost(draft) + PATCH publish → pop(true) + 漏斗事件', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester, '带豆豆去了公园');
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created.single.status, PostStatus.draft);
expect(created.single.content, '带豆豆去了公园');
expect(created.single.category, PostCategory.general);
expect(created.single.media, isNull);
expect(updated.single.publish, isTrue);
expect(updated.single.version, 1);
// 建草稿刚落,media 与服务端一致 → PATCH 缺席不动。
expect(updated.single.media, isNull);
expect(popResult, isTrue);
final succeeded = eventsNamed('post_publish_succeeded').single!;
expect(succeeded['mediaCount'], 0);
expect(succeeded['topicCount'], 0);
expect(succeeded['textLengthBucket'], 'short');
expect(succeeded['fromDraft'], isFalse);
expect(eventsNamed('post_publish_failed'), isEmpty);
});
testWidgets('求助类目 + 两图:position 0..1、首图封面、mediaCount 2', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.text('求助'));
await tester.pump();
await pickImages(tester, 2);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created.single.category, PostCategory.help);
final media = created.single.media!;
expect(media.map((item) => item.position), [0, 1]);
expect(media.map((item) => item.isCover), [true, false]);
expect(media.map((item) => item.assetId), ['a-1', 'a-2']);
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 2);
// 媒体三段逐文件各一对。
expect(eventsNamed('post_media_upload_started'), hasLength(2));
expect(eventsNamed('post_media_upload_succeeded'), hasLength(2));
});
testWidgets('删格重排:3 图删中间 → position 重发号 0..1,弃掉的 asset 不被引用', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 3);
expect(find.byType(Image), findsNWidgets(3));
// 第二格删除角标(角标按格序渲染)。
await tester.tap(find.byIcon(Icons.close).at(1));
await tester.pumpAndSettle();
expect(find.byType(Image), findsNWidgets(2));
await tester.tap(publishButton());
await tester.pumpAndSettle();
final media = created.single.media!;
expect(media.map((item) => item.position), [0, 1]);
expect(media.map((item) => item.assetId), ['a-1', 'a-3']);
expect(media.map((item) => item.isCover), [true, false]);
});
});
group('发布失败三语义', () {
testWidgets('网络失败:横幅可重试 + 同键重放(不重复建帖)', (tester) async {
var attempts = 0;
repository.onCreatePost = (request, _) async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException();
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.byType(InlineErrorBanner), findsOneWidget);
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
// 草稿还没落服务端 → 不谎称已保存。
expect(find.text('草稿已保存,可稍后继续发布'), findsNothing);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'network_error',
'attemptSeq': 1,
});
expect(popResult, isNull);
await tester.tap(publishButton());
await tester.pumpAndSettle();
// 两次尝试同一幂等键(服务端命中首帖,不重复建帖)。
expect(repository.idempotencyKeys, hasLength(2));
expect(repository.idempotencyKeys[0], isNotNull);
expect(repository.idempotencyKeys[0], repository.idempotencyKeys[1]);
expect(popResult, isTrue);
});
testWidgets('迁移发布失败:明确「草稿已保存」,重试只补 PATCH', (tester) async {
var patches = 0;
repository.onUpdatePost = (postId, request) async {
patches += 1;
if (patches == 1) throw const ApiNetworkException();
updated.add(request);
return Post.fromJson(samplePostJson(id: postId));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('草稿已保存,可稍后继续发布'), findsOneWidget);
expect(created, hasLength(1));
await tester.tap(publishButton());
await tester.pumpAndSettle();
// 草稿不重建,只重发 PATCH。
expect(created, hasLength(1));
expect(patches, 2);
expect(popResult, isTrue);
expect(eventsNamed('post_publish_failed').single!['attemptSeq'], 1);
expect(eventsNamed('post_publish_succeeded'), hasLength(1));
});
testWidgets('40905 同键异 hash:提示已重置提交标识,再点即换新键', (tester) async {
var attempts = 0;
repository.onCreatePost = (request, _) async {
attempts += 1;
if (attempts == 1) {
throw const IdempotencyMismatchException(message: 'mismatch');
}
created.add(request);
return Post.fromJson(samplePostJson(id: 'p-new', status: 'draft'));
};
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」'), findsOneWidget);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'validation_error',
'attemptSeq': 1,
'errorCode': 40905,
'httpStatus': 409,
});
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(repository.idempotencyKeys, hasLength(2));
expect(
repository.idempotencyKeys[0],
isNot(repository.idempotencyKeys[1]),
);
expect(popResult, isTrue);
});
testWidgets('42203 asset 未 ready:提示等图片就绪 + media_upload_incomplete', (
tester,
) async {
repository.onCreatePost = (_, _) async =>
throw const MediaNotReadyException(message: 'not ready');
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 1);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(find.text('有图片还没上传完成,请等图片就绪后再发布'), findsOneWidget);
expect(eventsNamed('post_publish_failed').single, {
'failureReason': 'media_upload_incomplete',
'attemptSeq': 1,
'errorCode': 42203,
'httpStatus': 422,
});
expect(popResult, isNull);
});
});
group('存草稿退出路径', () {
testWidgets('「存草稿」按钮:createPost(draft) + post_draft_saved(manual)', (
tester,
) async {
await pumpCompose(tester);
await writeContent(tester);
await pickImages(tester, 1);
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
await tester.pumpAndSettle();
expect(created.single.status, PostStatus.draft);
expect(updated, isEmpty);
expect(find.text('已保存草稿 ✓'), findsOneWidget);
expect(eventsNamed('post_draft_saved').single, {
'trigger': 'manual',
'mediaCount': 1,
});
});
testWidgets('「取消」有内容 → 保留草稿:on_exit 保存后离页', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
expect(find.text('保留草稿?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '保留'));
await tester.pumpAndSettle();
expect(created, hasLength(1));
expect(eventsNamed('post_draft_saved').single, {
'trigger': 'on_exit',
'mediaCount': 0,
});
expect(popResult, isFalse);
expect(find.byType(PostComposePage), findsNothing);
});
testWidgets('「不保留」:已落服务端的草稿软删 + post_deleted', (tester) async {
final deleted = <String>[];
repository.onDeletePost = (postId) async => deleted.add(postId);
await pumpCompose(tester);
await writeContent(tester);
// 先显式存一次草稿,使服务端已有草稿。
await tester.tap(find.widgetWithText(TextButton, '存草稿'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '不保留'));
await tester.pumpAndSettle();
expect(deleted, ['p-new']);
expect(eventsNamed('post_deleted'), hasLength(1));
expect(popResult, isFalse);
});
testWidgets('「继续编辑」留在页内,不动服务端', (tester) async {
await pumpCompose(tester);
await writeContent(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '继续编辑'));
await tester.pumpAndSettle();
expect(find.byType(PostComposePage), findsOneWidget);
expect(created, isEmpty);
expect(
repository.calls.where((c) => c.startsWith('deletePost')),
isEmpty,
);
});
testWidgets('空表单「取消」直接离页,无弹窗无请求', (tester) async {
await pumpCompose(tester);
await tester.tap(find.widgetWithText(TextButton, '取消'));
await tester.pumpAndSettle();
expect(find.text('保留草稿?'), findsNothing);
expect(created, isEmpty);
expect(popResult, isFalse);
});
});
group('草稿恢复(最小实现:最新一条)', () {
setUp(() {
repository.onListMyPosts = (status) async =>
postPage([sampleDraft(version: 3, content: '上次没写完的草稿')]);
});
testWidgets('进页恢复:提示条 + 正文预填 + 已有图片说明', (tester) async {
await pumpCompose(tester);
expect(find.text('已恢复上次草稿'), findsOneWidget);
expect(find.text('上次没写完的草稿'), findsOneWidget);
expect(find.textContaining('草稿已含 1 张图片'), findsOneWidget);
// 恢复不是用户输入 → 不发 post_create_started。
expect(eventsNamed('post_create_started'), isEmpty);
expect(repository.calls, contains('listMyPosts:draft'));
});
testWidgets('恢复后发布:不再建草稿,PATCH 带 version 与 fromDraft=true', (tester) async {
await pumpCompose(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created, isEmpty);
expect(updated.single.version, 3);
expect(updated.single.publish, isTrue);
// 未重新选图 → media 缺席不动(服务端既有图保留)。
expect(updated.single.media, isNull);
final succeeded = eventsNamed('post_publish_succeeded').single!;
expect(succeeded['fromDraft'], isTrue);
expect(succeeded['mediaCount'], 1);
expect(popResult, isTrue);
});
testWidgets('恢复后重新选图:PATCH 整组替换', (tester) async {
await pumpCompose(tester);
await pickImages(tester, 1);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(updated.single.media!.map((item) => item.assetId), ['a-1']);
expect(eventsNamed('post_publish_succeeded').single!['mediaCount'], 1);
});
testWidgets('40902 乐观锁:自动取新 version 重提一次即成功', (tester) async {
var patches = 0;
repository.onUpdatePost = (postId, request) async {
patches += 1;
if (patches == 1) {
throw const PostVersionConflictException(message: 'stale');
}
updated.add(request);
return Post.fromJson(samplePostJson(id: postId));
};
repository.onGetPost = (postId) async => Post.fromJson(
samplePostJson(id: postId, status: 'draft', version: 9),
);
await pumpCompose(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(updated.single.version, 9);
expect(eventsNamed('post_publish_failed'), isEmpty);
expect(popResult, isTrue);
});
testWidgets('「清空」清掉恢复内容与提示条', (tester) async {
await pumpCompose(tester);
await tester.tap(find.widgetWithText(TextButton, '清空'));
await tester.pumpAndSettle();
expect(find.text('已恢复上次草稿'), findsNothing);
expect(find.text('上次没写完的草稿'), findsNothing);
expect(publishEnabled(tester), isFalse);
});
testWidgets('草稿恢复失败静默降级为新建(不打断发布)', (tester) async {
repository.onListMyPosts = (_) async => throw const ApiNetworkException();
await pumpCompose(tester);
expect(find.text('已恢复上次草稿'), findsNothing);
expect(find.byType(InlineErrorBanner), findsNothing);
await writeContent(tester);
await tester.tap(publishButton());
await tester.pumpAndSettle();
expect(created, hasLength(1));
expect(popResult, isTrue);
});
});
group('页面结构(05 §2.3', () {
testWidgets('AppBar 三件套 + 媒体编辑格 + 类目二选 + 位置占位', (tester) async {
await pumpCompose(tester);
expect(find.text('发布动态'), findsOneWidget);
expect(find.widgetWithText(TextButton, '取消'), findsOneWidget);
expect(find.widgetWithText(TextButton, '存草稿'), findsOneWidget);
expect(publishButton(), findsOneWidget);
expect(find.byType(PostMediaEditGrid), findsOneWidget);
expect(find.text('日常分享'), findsOneWidget);
expect(find.text('求助'), findsOneWidget);
// ai_creation 是 M4 预留读侧值,不给提交面。
expect(find.textContaining('AI'), findsNothing);
await tester.drag(find.byType(ListView), const Offset(0, -240));
await tester.pump();
await tester.tap(find.text('添加位置(选填)'));
await tester.pump();
expect(find.text('位置功能即将上线'), findsOneWidget);
});
testWidgets('正文 1000 字上限计数器在位', (tester) async {
await pumpCompose(tester);
await writeContent(tester, '豆豆');
expect(find.text('2/1000'), findsOneWidget);
});
});
}