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?)> events; late PostAnalytics analytics; late List created; late List 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?> 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 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( MaterialPageRoute( 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(publishButton()).onPressed != null; Future writeContent( WidgetTester tester, [ String text = '带豆豆去了公园', ]) async { await tester.enterText(find.byType(TextField), text); await tester.pump(); } /// 选 [count] 张图并等上传完成。 Future 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('gating(05 §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 = []; 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); }); }); }