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?)> 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([ '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, ); }); }); }