1441f0148f
CI / flutter-gates (push) Successful in 2m30s
- MediaUploader:选图→压缩(80→60 降质阶梯,10 MiB 超限拒绝)→createUpload →预签名 PUT 直传(进度回调)→confirm→ready assetId 交付;并发槽位 2、 顺序即 position、单图失败不拖垮整批;凭据过期预检与存储侧 403 各自动 换新凭据一次;失败重试复用压缩产物、从 createUpload 全新开始 - 孤儿防护:未 confirm 的 assetId 只以管线局部变量存在,快照 assetId 与 ready 态断言绑定,buildAttachRequests 非全员 ready 即抛 StateError - 线路修正:media 两步上传端点由 user 服务(:8082 MediaController)提供, T3-12 误挂 community 客户端(:8084 无 media 路由),补 mediaApi 分端口 直连,compose 真链路实测证实 - UploadProgressOverlay 四态进度层(05 号规范 §3.3);直传层本地 HttpServer 三线路测试(200/403/断连);compose 冒烟测试 (PATBOND_MEDIA_SMOKE=1 启用,默认跳过) - 新依赖:image_picker、flutter_image_compress(03 号评估选型) - flutter test 379 全绿(基线 347,+32)、analyze 0、format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
139 lines
4.1 KiB
Dart
139 lines
4.1 KiB
Dart
import 'dart:async';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:patbond_flutter/features/community/community_models.dart';
|
||
import 'package:patbond_flutter/features/community/media_compression.dart';
|
||
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
|
||
import 'package:patbond_flutter/features/community/media_picking.dart';
|
||
|
||
/// media 上传链路测试件(T3-13):假选择器/压缩器/直传客户端,
|
||
/// 全部支持 Completer 控时序(helpers 既有先例)。
|
||
|
||
PickedMediaImage pickedImage({int seed = 1, int size = 64}) => PickedMediaImage(
|
||
bytes: Uint8List.fromList(List.filled(size, seed)),
|
||
name: 'img-$seed.jpg',
|
||
);
|
||
|
||
MediaUploadCredentials credentials({
|
||
String assetId = 'a-1',
|
||
DateTime? expiresAt,
|
||
}) => MediaUploadCredentials(
|
||
assetId: assetId,
|
||
uploadUrl:
|
||
'http://minio.local/patbond-media/post_image/$assetId?X-Amz-Signature=sig',
|
||
method: 'PUT',
|
||
requiredHeaders: const {'Content-Type': 'image/jpeg'},
|
||
expiresAt: expiresAt ?? DateTime.now().add(const Duration(minutes: 10)),
|
||
);
|
||
|
||
MediaAsset readyAsset({String assetId = 'a-1'}) => MediaAsset(
|
||
id: assetId,
|
||
kind: MediaKind.image,
|
||
purpose: 'post_image',
|
||
mimeType: 'image/jpeg',
|
||
byteSize: 1024,
|
||
widthPx: 1080,
|
||
heightPx: 810,
|
||
status: MediaAssetStatus.ready,
|
||
url: 'http://minio.local/p.jpg?X-Amz-Signature=sig',
|
||
readyAt: DateTime.utc(2026, 9, 9),
|
||
createdAt: DateTime.utc(2026, 9, 9),
|
||
);
|
||
|
||
class FakeMediaImagePicker implements MediaImagePicker {
|
||
FakeMediaImagePicker(this.results);
|
||
|
||
final List<PickedMediaImage> results;
|
||
final List<int> limits = [];
|
||
|
||
/// 非 null 时 pickImages 挂起等待(picking 态观测用)。
|
||
Completer<void>? gate;
|
||
|
||
@override
|
||
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
|
||
limits.add(limit);
|
||
await gate?.future;
|
||
return results.take(limit).toList();
|
||
}
|
||
}
|
||
|
||
/// 假压缩器:默认原样透传出 image/jpeg;[sizePerQuality] 指定各降质档
|
||
/// 的产物大小(超限阶梯测试用)。
|
||
class FakeMediaCompressor implements MediaImageCompressor {
|
||
final List<int> qualities = [];
|
||
Map<int, int>? sizePerQuality;
|
||
Exception? error;
|
||
|
||
@override
|
||
Future<CompressedMediaImage> compress(
|
||
PickedMediaImage source, {
|
||
required int quality,
|
||
}) async {
|
||
qualities.add(quality);
|
||
if (error != null) throw error!;
|
||
final size = sizePerQuality?[quality];
|
||
return CompressedMediaImage(
|
||
bytes: size == null
|
||
? source.bytes
|
||
: Uint8List.fromList(List.filled(size, 0)),
|
||
mimeType: 'image/jpeg',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 一次直传调用的观测句柄(进度回调驱动 + 结局手动控制)。
|
||
class DirectUploadCall {
|
||
DirectUploadCall({
|
||
required this.url,
|
||
required this.headers,
|
||
required this.bytes,
|
||
required this.onProgress,
|
||
});
|
||
|
||
final String url;
|
||
final Map<String, String> headers;
|
||
final Uint8List bytes;
|
||
final void Function(int sent, int total)? onProgress;
|
||
final Completer<void> completer = Completer<void>();
|
||
|
||
void emitProgress(int sent, int total) => onProgress?.call(sent, total);
|
||
void succeed() => completer.complete();
|
||
void fail(MediaDirectUploadException error) => completer.completeError(error);
|
||
}
|
||
|
||
class FakeDirectUploadClient implements MediaDirectUploadClient {
|
||
final List<DirectUploadCall> calls = [];
|
||
|
||
/// 非 null 时每次 put 自动以该结果收尾(null = 自动成功);
|
||
/// 设为 manual 后由测试经 [calls] 手动驱动。
|
||
bool manual = false;
|
||
final List<MediaDirectUploadException?> scriptedOutcomes = [];
|
||
|
||
@override
|
||
Future<void> put({
|
||
required String url,
|
||
required Map<String, String> headers,
|
||
required Uint8List bytes,
|
||
void Function(int sent, int total)? onProgress,
|
||
}) {
|
||
final call = DirectUploadCall(
|
||
url: url,
|
||
headers: headers,
|
||
bytes: bytes,
|
||
onProgress: onProgress,
|
||
);
|
||
calls.add(call);
|
||
if (!manual) {
|
||
final outcome = scriptedOutcomes.isEmpty
|
||
? null
|
||
: scriptedOutcomes.removeAt(0);
|
||
if (outcome == null) {
|
||
call.succeed();
|
||
} else {
|
||
call.fail(outcome);
|
||
}
|
||
}
|
||
return call.completer.future;
|
||
}
|
||
}
|