import 'dart:async'; import 'dart:convert'; 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', ); /// 1x1 真 PNG 字节(70B):widget 测试里九宫格格子要真解码出图, /// 用可解码的最小合法图片避免 Image.memory 走 errorBuilder 兜底。 final Uint8List tinyPngBytes = base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF' 'AAH/q842iQAAAABJRU5ErkJggg==', ); /// 可解码预览的选图(发布页 widget 测试用)。 PickedMediaImage decodablePickedImage({int seed = 1}) => PickedMediaImage(bytes: tinyPngBytes, name: 'img-$seed.png'); 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 results; final List limits = []; /// 非 null 时 pickImages 挂起等待(picking 态观测用)。 Completer? gate; @override Future> 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 qualities = []; Map? sizePerQuality; Exception? error; @override Future 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 headers; final Uint8List bytes; final void Function(int sent, int total)? onProgress; final Completer completer = Completer(); 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 calls = []; /// 非 null 时每次 put 自动以该结果收尾(null = 自动成功); /// 设为 manual 后由测试经 [calls] 手动驱动。 bool manual = false; final List scriptedOutcomes = []; @override Future put({ required String url, required Map 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; } }