import 'dart:async'; 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/community_models.dart'; import 'package:patbond_flutter/features/community/media_direct_upload.dart'; import 'package:patbond_flutter/features/community/media_uploader.dart'; import '../../helpers/community_test_helpers.dart'; import '../../helpers/media_test_helpers.dart'; /// MediaUploader 状态机全路径(T3-13): /// 选图→压缩→createUpload→直传→confirm→ready 编排、并发顺序保持、 /// 弱网失败语义(凭据过期重取 / 403 换凭据 / 42205 重试)与孤儿防护。 void main() { late FakeCommunityRepository repository; late FakeMediaImagePicker picker; late FakeMediaCompressor compressor; late FakeDirectUploadClient direct; int uploadSeq = 0; MediaUploader build({ int maxConcurrentUploads = 2, int maxByteSize = 10 * 1024 * 1024, DateTime Function()? now, }) { return MediaUploader( repository: repository, picker: picker, compressor: compressor, directUpload: direct, maxConcurrentUploads: maxConcurrentUploads, maxByteSize: maxByteSize, now: now, ); } setUp(() { repository = FakeCommunityRepository(); picker = FakeMediaImagePicker([]); compressor = FakeMediaCompressor(); direct = FakeDirectUploadClient(); uploadSeq = 0; // 默认脚本:凭据按序发号,confirm 返回同 id 的 ready asset。 repository.onCreateMediaUpload = (request) async => credentials(assetId: 'a-${++uploadSeq}'); repository.onCompleteMediaUpload = (assetId) async => readyAsset(assetId: assetId); }); group('happy path', () { test('单图全程:阶段序列 + 进度 + ready assetId 交付', () async { direct.manual = true; final uploader = build(); final phases = []; uploader.addListener(() { if (uploader.items.isNotEmpty) phases.add(uploader.items.single.phase); }); uploader.addImages([pickedImage()]); await pumpEventQueue(); final call = direct.calls.single; expect(uploader.items.single.phase, MediaItemPhase.uploading); call.emitProgress(512, 1024); expect(uploader.items.single.progress, 0.5); // 上传中 assetId 不可见(孤儿防护)。 expect(uploader.items.single.assetId, isNull); call.succeed(); await pumpEventQueue(); final item = uploader.items.single; expect(item.phase, MediaItemPhase.ready); expect(item.assetId, 'a-1'); expect(item.progress, 1); expect(uploader.allReady, isTrue); expect(phases.first, MediaItemPhase.queued); expect( phases, containsAllInOrder([ MediaItemPhase.queued, MediaItemPhase.compressing, MediaItemPhase.uploading, MediaItemPhase.confirming, MediaItemPhase.ready, ]), ); expect(repository.calls, ['createUpload:image/jpeg:64', 'complete:a-1']); }); test('直传按凭据原样携带 requiredHeaders 与压缩产物字节', () async { final uploader = build(); uploader.addImages([pickedImage(seed: 7, size: 128)]); await pumpEventQueue(); final call = direct.calls.single; expect(call.headers, {'Content-Type': 'image/jpeg'}); expect(call.url, contains('X-Amz-Signature')); expect(call.bytes.length, 128); // 登记 byteSize 与直传本体一致。 expect(repository.calls.first, 'createUpload:image/jpeg:128'); }); test('压缩降质阶梯:80 超限降 60,登记与直传用 60 档产物', () async { compressor.sizePerQuality = {80: 11 * 1024 * 1024, 60: 9 * 1024 * 1024}; final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(compressor.qualities, [80, 60]); expect(uploader.items.single.phase, MediaItemPhase.ready); expect(direct.calls.single.bytes.length, 9 * 1024 * 1024); }); }); group('多图并发与顺序保持', () { test('并发上限 2:第三张等槽位;完成乱序不影响 position 语义', () async { direct.manual = true; final uploader = build(); uploader.addImages([ pickedImage(seed: 1), pickedImage(seed: 2), pickedImage(seed: 3), ]); await pumpEventQueue(); // 只有前两张进入直传,第三张排队等槽位。 expect(direct.calls, hasLength(2)); expect(uploader.items[2].phase, MediaItemPhase.queued); // 第二张先完成(乱序),释放槽位后第三张才发起。 direct.calls[1].succeed(); await pumpEventQueue(); expect(direct.calls, hasLength(3)); direct.calls[0].succeed(); direct.calls[2].succeed(); await pumpEventQueue(); expect(uploader.allReady, isTrue); final attach = uploader.buildAttachRequests(); // position 按加入序 0..2,与完成先后无关;isCover 恰好首图。 expect(attach.map((a) => a.position), [0, 1, 2]); expect(attach.map((a) => a.isCover), [true, false, false]); // 完成序:第 2 张先确认(a-2),仍归位 index 1。 expect(attach[1].assetId, 'a-2'); expect(attach[0].assetId, 'a-1'); expect(attach[2].assetId, 'a-3'); }); test('单图失败不拖垮整批:另一张照常 ready', () async { direct.manual = true; final uploader = build(); uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]); await pumpEventQueue(); direct.calls[0].fail(const MediaDirectUploadException(message: '断连')); direct.calls[1].succeed(); await pumpEventQueue(); expect(uploader.items[0].phase, MediaItemPhase.failed); expect(uploader.items[0].retryable, isTrue); expect(uploader.items[1].phase, MediaItemPhase.ready); expect(uploader.hasFailure, isTrue); expect(uploader.allReady, isFalse); }); }); group('弱网 / 失败语义', () { test('直传网络中断 → failed 可重试;retry 复用压缩产物、换新 asset', () async { direct.scriptedOutcomes.add( const MediaDirectUploadException(message: '断连'), ); final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(uploader.items.single.phase, MediaItemPhase.failed); expect(uploader.items.single.retryable, isTrue); uploader.retry(uploader.items.single.localId); await pumpEventQueue(); final item = uploader.items.single; expect(item.phase, MediaItemPhase.ready); // 重试从 createUpload 全新开始(a-2),旧 a-1 弃引用。 expect(item.assetId, 'a-2'); // 压缩只做一次(产物缓存)。 expect(compressor.qualities, [80]); expect(repository.calls, [ 'createUpload:image/jpeg:64', 'createUpload:image/jpeg:64', 'complete:a-2', ]); }); test('凭据过期预检:PUT 前重新 createUpload 换新凭据', () async { final clock = DateTime.utc(2026, 9, 9, 12); var issued = 0; repository.onCreateMediaUpload = (request) async { issued++; return credentials( assetId: 'a-$issued', // 首张凭据已进入 30 秒安全边距,第二张充足。 expiresAt: issued == 1 ? clock.add(const Duration(seconds: 10)) : clock.add(const Duration(minutes: 10)), ); }; final uploader = build(now: () => clock); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(issued, 2); expect(direct.calls.single.url, contains('/a-2?')); expect(uploader.items.single.assetId, 'a-2'); }); test('换新凭据仍过期 → failed 可重试,不再无限重取', () async { final clock = DateTime.utc(2026, 9, 9, 12); repository.onCreateMediaUpload = (request) async => credentials(expiresAt: clock); final uploader = build(now: () => clock); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(uploader.items.single.phase, MediaItemPhase.failed); expect(uploader.items.single.retryable, isTrue); expect(direct.calls, isEmpty); }); test('存储侧 403(签名过期)→ 自动换新凭据重传一次后 ready', () async { direct.scriptedOutcomes.add( const MediaDirectUploadException(statusCode: 403, message: '签名过期'), ); final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(direct.calls, hasLength(2)); expect(uploader.items.single.phase, MediaItemPhase.ready); expect(uploader.items.single.assetId, 'a-2'); expect(repository.calls.last, 'complete:a-2'); }); test('换新凭据后仍 403 → failed 可重试,不无限自动重传', () async { direct.scriptedOutcomes.addAll([ const MediaDirectUploadException(statusCode: 403, message: '签名过期'), const MediaDirectUploadException(statusCode: 403, message: '签名过期'), ]); final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(direct.calls, hasLength(2)); expect(uploader.items.single.phase, MediaItemPhase.failed); expect(uploader.items.single.retryable, isTrue); }); test('confirm 42205(对象未上传/内容不符)→ failed 可重试,重试换新 asset', () async { var confirms = 0; repository.onCompleteMediaUpload = (assetId) async { if (++confirms == 1) { throw const MediaUploadStateException(message: '上传未完成'); } return readyAsset(assetId: assetId); }; final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(uploader.items.single.phase, MediaItemPhase.failed); expect(uploader.items.single.retryable, isTrue); expect(uploader.items.single.assetId, isNull); uploader.retry(uploader.items.single.localId); await pumpEventQueue(); expect(uploader.items.single.phase, MediaItemPhase.ready); expect(uploader.items.single.assetId, 'a-2'); }); test('confirm 返回非 ready 状态(防御)→ failed 可重试', () async { repository.onCompleteMediaUpload = (assetId) async => MediaAsset.fromJson({ ...readyAssetJson(assetId: assetId), 'status': 'uploading', 'url': null, 'readyAt': null, }); final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(uploader.items.single.phase, MediaItemPhase.failed); expect(uploader.items.single.retryable, isTrue); }); test('createUpload 40000(参数拒绝)→ failed 终态不可重试', () async { repository.onCreateMediaUpload = (request) async { throw const ApiBusinessException(code: 40000, message: 'mime 不允许'); }; final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); final item = uploader.items.single; expect(item.phase, MediaItemPhase.failed); expect(item.retryable, isFalse); uploader.retry(item.localId); await pumpEventQueue(); // 终态失败 retry 为 no-op。 expect(repository.calls, hasLength(1)); }); test('压缩阶梯到底仍超限 → failed 终态,未发起任何网络调用', () async { compressor.sizePerQuality = {80: 12 * 1024 * 1024, 60: 11 * 1024 * 1024}; final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); final item = uploader.items.single; expect(item.phase, MediaItemPhase.failed); expect(item.retryable, isFalse); expect(repository.calls, isEmpty); expect(direct.calls, isEmpty); }); test('压缩异常 → failed 可重试', () async { compressor.error = Exception('原生编解码失败'); final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); expect(uploader.items.single.phase, MediaItemPhase.failed); expect(uploader.items.single.retryable, isTrue); }); }); group('孤儿防护(未 confirm 的 asset 不得被引用)', () { test('非全员 ready 时 buildAttachRequests 抛 StateError', () async { direct.manual = true; final uploader = build(); expect(uploader.buildAttachRequests, throwsStateError); uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]); await pumpEventQueue(); // 在途(uploading)。 expect(uploader.buildAttachRequests, throwsStateError); direct.calls[0].succeed(); direct.calls[1].fail(const MediaDirectUploadException(message: '断连')); await pumpEventQueue(); // 一 ready 一 failed:失败项在场仍禁止引用。 expect(uploader.readyCount, 1); expect(uploader.buildAttachRequests, throwsStateError); }); test('全生命周期快照 assetId 仅 ready 态非空', () async { direct.manual = true; final uploader = build(); final observed = {}; uploader.addListener(() { for (final item in uploader.items) { observed[item.phase] = item.assetId; } }); uploader.addImages([pickedImage()]); await pumpEventQueue(); direct.calls.single.succeed(); await pumpEventQueue(); expect(observed[MediaItemPhase.ready], 'a-1'); for (final MapEntry(:key, :value) in observed.entries) { if (key != MediaItemPhase.ready) expect(value, isNull); } }); test('直传在途 remove → 结果作废,不发 confirm', () async { direct.manual = true; final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); uploader.remove(uploader.items.single.localId); expect(uploader.isEmpty, isTrue); direct.calls.single.succeed(); await pumpEventQueue(); // 已移除的图完成直传也不确认(asset 留给服务端超时清理)。 expect(repository.calls.where((c) => c.startsWith('complete')), isEmpty); }); test('reset 后在途 confirm 结果作废', () async { direct.manual = true; final uploader = build(); uploader.addImages([pickedImage()]); await pumpEventQueue(); uploader.reset(); direct.calls.single.succeed(); await pumpEventQueue(); expect(uploader.isEmpty, isTrue); expect(repository.calls.where((c) => c.startsWith('complete')), isEmpty); }); }); group('选图与容量', () { test('pickAndAdd:picking 态、limit=剩余槽位、产物入列', () async { picker.results.addAll([pickedImage(seed: 1), pickedImage(seed: 2)]); picker.gate = Completer(); final uploader = build(); final picking = uploader.pickAndAdd(); expect(uploader.isPicking, isTrue); picker.gate!.complete(); await picking; await pumpEventQueue(); expect(uploader.isPicking, isFalse); expect(picker.limits, [9]); expect(uploader.items, hasLength(2)); expect(uploader.allReady, isTrue); }); test('用户取消选择(空结果)→ 恢复空闲,无副作用', () async { final uploader = build(); await uploader.pickAndAdd(); await pumpEventQueue(); expect(uploader.isPicking, isFalse); expect(uploader.isEmpty, isTrue); expect(repository.calls, isEmpty); }); test('满 9 张后 pickAndAdd no-op;addImages 超量截断', () async { final uploader = build(); uploader.addImages(List.generate(11, (i) => pickedImage(seed: i))); await pumpEventQueue(); expect(uploader.items, hasLength(9)); expect(uploader.remainingSlots, 0); await uploader.pickAndAdd(); expect(picker.limits, isEmpty); }); test('overallProgress 汇总:ready 计 1、uploading 计进度', () async { direct.manual = true; final uploader = build(); uploader.addImages([pickedImage(seed: 1), pickedImage(seed: 2)]); await pumpEventQueue(); direct.calls[0].succeed(); await pumpEventQueue(); direct.calls[1].emitProgress(1, 2); expect(uploader.overallProgress, closeTo(0.75, 0.001)); }); }); } Map readyAssetJson({required String assetId}) => { 'id': assetId, 'kind': 'image', 'purpose': 'post_image', 'mimeType': 'image/jpeg', 'byteSize': 1024, 'widthPx': 1080, 'heightPx': 810, 'status': 'ready', 'url': 'http://minio.local/p.jpg?X-Amz-Signature=sig', 'readyAt': '2026-09-09T00:00:00.000Z', 'createdAt': '2026-09-09T00:00:00.000Z', };