新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
CI / flutter-gates (push) Successful in 3m9s

- 新增 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>
This commit is contained in:
2026-09-10 14:42:13 +08:00
parent f873acf9a3
commit 9892b65a19
23 changed files with 2760 additions and 99 deletions
@@ -0,0 +1,193 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/post_analytics.dart';
/// post 域埋点封装(T3-17):键集与 22 号白名单 v3 事件 22~29 逐一对齐、
/// 分桶边界、异常 → failureReason 映射、隐私红线(无内容 ID / 无精确字数)。
void main() {
late List<(String, Map<String, dynamic>?)> events;
late PostAnalytics analytics;
setUp(() {
events = [];
analytics = PostAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
group('键集与白名单对齐(22 号 §1', () {
test('post_create_startedentryPoint', () {
analytics.postCreateStarted(entryPoint: PostEntryPoint.createTab);
expect(events.single.$1, 'post_create_started');
expect(events.single.$2, {'entryPoint': 'create_tab'});
});
test('post_draft_savedtrigger + mediaCount', () {
analytics.postDraftSaved(trigger: DraftSaveTrigger.onExit, mediaCount: 3);
expect(events.single.$1, 'post_draft_saved');
expect(events.single.$2, {'trigger': 'on_exit', 'mediaCount': 3});
});
test('post_publish_succeeded:五键齐 + 正文只出分桶', () {
analytics.postPublishSucceeded(
durationMs: 8200,
mediaCount: 2,
topicCount: 0,
textLength: 120,
fromDraft: true,
);
final props = events.single.$2!;
expect(
props.keys,
containsAll(<String>[
'durationMs',
'mediaCount',
'topicCount',
'textLengthBucket',
'fromDraft',
]),
);
expect(props['textLengthBucket'], 'medium');
expect(props['fromDraft'], isTrue);
// 红线 1/2:不带精确字数、不带 postId。
expect(props.containsKey('textLength'), isFalse);
expect(props.containsKey('postId'), isFalse);
});
test(
'post_publish_failedfailureReason/attemptSeq/errorCode/httpStatus',
() {
analytics.postPublishFailed(
reason: PostPublishFailureReason.mediaUploadIncomplete,
attemptSeq: 2,
errorCode: 42203,
);
expect(events.single.$2, {
'failureReason': 'media_upload_incomplete',
'attemptSeq': 2,
'errorCode': 42203,
'httpStatus': 422,
});
},
);
test('post_publish_failed:网络错误无 errorCode/httpStatus', () {
analytics.postPublishFailed(
reason: PostPublishFailureReason.networkError,
attemptSeq: 1,
);
expect(events.single.$2, {
'failureReason': 'network_error',
'attemptSeq': 1,
});
});
test('post_deleted:空属性集(单事件风格)', () {
analytics.postDeleted();
expect(events.single.$1, 'post_deleted');
expect(events.single.$2, isEmpty);
});
test('媒体三段:键集齐 + 只出桶不出字节数/文件名', () {
analytics.mediaUploadStarted(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
);
analytics.mediaUploadSucceeded(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
durationMs: 4300,
);
analytics.mediaUploadFailed(
mediaType: MediaType.image,
byteSize: 3 * 1024 * 1024,
reason: MediaUploadFailureReason.cancelled,
attemptSeq: 1,
);
expect(events.map((event) => event.$1), [
'post_media_upload_started',
'post_media_upload_succeeded',
'post_media_upload_failed',
]);
expect(events[0].$2, {'mediaType': 'image', 'sizeBucket': 'mb_1_5'});
expect(events[1].$2, {
'mediaType': 'image',
'sizeBucket': 'mb_1_5',
'durationMs': 4300,
});
expect(events[2].$2, {
'mediaType': 'image',
'sizeBucket': 'mb_1_5',
'failureReason': 'cancelled',
'attemptSeq': 1,
});
for (final event in events) {
expect(event.$2!.containsKey('byteSize'), isFalse);
expect(event.$2!.containsKey('fileName'), isFalse);
}
});
});
group('分桶边界(06 §1.3 红线 4', () {
test('mediaSizeBucketOf 四档', () {
const mib = 1024 * 1024;
expect(mediaSizeBucketOf(0), 'lt_1mb');
expect(mediaSizeBucketOf(mib - 1), 'lt_1mb');
expect(mediaSizeBucketOf(mib), 'mb_1_5');
expect(mediaSizeBucketOf(5 * mib - 1), 'mb_1_5');
expect(mediaSizeBucketOf(5 * mib), 'mb_5_20');
expect(mediaSizeBucketOf(20 * mib - 1), 'mb_5_20');
expect(mediaSizeBucketOf(20 * mib), 'gte_20mb');
});
});
group('异常 → 发布失败原因', () {
test('42203 → media_upload_incomplete40905/40000 → validation_error', () {
expect(
postPublishFailureReasonOf(const MediaNotReadyException(message: 'x')),
PostPublishFailureReason.mediaUploadIncomplete,
);
expect(
postPublishFailureReasonOf(
const IdempotencyMismatchException(message: 'x'),
),
PostPublishFailureReason.validationError,
);
expect(
postPublishFailureReasonOf(
const ApiBusinessException(code: 40000, message: 'x'),
),
PostPublishFailureReason.validationError,
);
});
test('40403 → not_found40902 → server_error;限流/网络各自归位', () {
expect(
postPublishFailureReasonOf(const PostNotFoundException(message: 'x')),
PostPublishFailureReason.notFound,
);
expect(
postPublishFailureReasonOf(
const PostVersionConflictException(message: 'x'),
),
PostPublishFailureReason.serverError,
);
expect(
postPublishFailureReasonOf(const ApiRateLimitException()),
PostPublishFailureReason.rateLimited,
);
expect(
postPublishFailureReasonOf(const ApiNetworkException()),
PostPublishFailureReason.networkError,
);
});
test('会话失效不上报(null', () {
expect(
postPublishFailureReasonOf(const SessionExpiredException()),
isNull,
);
});
});
}