Files
patbond-flutter/test/helpers/media_test_helpers.dart
T
lixi 1441f0148f
CI / flutter-gates (push) Successful in 2m30s
新增:媒体上传客户端——MediaUploader 编排状态机 + 预签名直传 + 进度组件,修正 media 端点误挂 community 服务(T3-13)
- 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>
2026-09-09 12:21:20 +08:00

139 lines
4.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}