新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
CI / flutter-gates (push) Successful in 3m9s
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:
@@ -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_started:entryPoint', () {
|
||||
analytics.postCreateStarted(entryPoint: PostEntryPoint.createTab);
|
||||
expect(events.single.$1, 'post_create_started');
|
||||
expect(events.single.$2, {'entryPoint': 'create_tab'});
|
||||
});
|
||||
|
||||
test('post_draft_saved:trigger + 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_failed:failureReason/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_incomplete;40905/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_found;40902 → 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user