9892b65a19
CI / flutter-gates (push) Successful in 3m9s
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目 (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay, 删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready) - 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿 已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、 网络失败同键重放不重复建帖;40902 自动取新 version 重提一次 - 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新 一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点) - 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典 v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq; pageName 枚举补 post_form - PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 + 进度覆盖层 + 失败整格重试;页级上传汇总条 - create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts / publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态 CTA 改为 push 真实发布页 - 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态 九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控) - flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过 (选图上传 → 发布 → Feed 置顶 → 第二客户端可见) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
151 lines
4.6 KiB
Dart
151 lines
4.6 KiB
Dart
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<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;
|
||
}
|
||
}
|