Files
photo-live/apps/mobile/test/features/uploads/native_transport_test.dart
T
浅念 42e1c2e34b feat(mobile): ios background upload bridge (compile-gated at G3)
Swift 侧(协议与 Android UploadPlugin.kt 逐字一致,编译/真机留 G3):
- UploadBridge.swift:通道/方法/事件/失败码常量 + registerTask 参数解析
  (镜像 UploadProtocol.kt)
- UploadEventBus.swift:单调 seq + 终态 ack 缓存重放(镜像
  UploadEventBus.kt),iOS 增强——未 ack 终态落盘(系统后台拉起投递的
  回调在进程重启后同样重放,技术方案 §6.2 持久化原生回调)
- UploadTaskStore.swift:taskId→URLSession taskIdentifier 映射 plist 持久化
  (Application Support;对应 Android WorkManager 自持映射)
- UploadPlugin.swift:URLSession background
  (com.photolive.photo_live.uploads)+ delegate 失败分类(2xx→completed+etag
  / 403→needsRenew / 4xx→HTTP_4XX / 网络→重试上限后 NETWORK,镜像
  UploadPutExecutor/UploadWorker)、原生重试(5 次上限、10s 指数退避)、
  进度 500ms 节流、queryTask/cancelTask/ackEvents、静默取消
- AppDelegate.swift:implicit engine 通道注册 + 启动重建会话 +
  handleEventsForBackgroundURLSession completionHandler 暂存
- Info.plist:UIBackgroundModes 有意不声明(background URLSession 无需;
  计划措辞按技术方案 §6.2 修正,见 README-ios.md)
- project.pbxproj 登记新 Swift 文件;README-ios.md(Xcode 步骤 + G3 验证
  清单:实际文件上传/持久化回调窗口/服务端补偿/重启重联校准/强制退出/
  低电量延迟/100 张批量)

Dart:NativeTransport 选择矩阵并入 iOS(Platform.isAndroid || isIOS,
FORCE_DART_TRANSPORT 覆盖不变)——协议两侧一致,无新通道代码。

flutter analyze 0 issue;flutter test 219 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 03:42:49 +08:00

490 lines
15 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.
/// 传输层测试(P3 计划 Task 7)。
///
/// 覆盖:
/// - NativeTransport:真实通道名 + 测试 messenger(TestDefaultBinaryMessenger
/// 的 mock method/stream handler)——registerTask 参数形状、事件映射
/// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、
/// 注册失败归一、多任务路由;
/// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络);
/// - 传输选择:Android/iOS 默认原生(Task 8 起 iOS 并入同一矩阵)、
/// FORCE_DART_TRANSPORT 覆盖、VM 缺省 DartHttp。
library;
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/uploads/native_transport.dart';
/// 捕获 Future 的异常对象(完成则失败测试)。
Future<Object> captureError(Future<Object> future) async {
try {
await future;
} on Object catch (e) {
return e;
}
throw StateError('预期失败但 Future 成功完成');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const methodChannel = MethodChannel(UploadBridgeChannels.methods);
const eventChannel = EventChannel(UploadBridgeChannels.events);
late NativeTransport transport;
late List<MethodCall> methodCalls;
MockStreamHandlerEventSink? sink;
setUp(() {
methodCalls = [];
sink = null;
messenger.setMockMethodCallHandler(methodChannel, (call) async {
methodCalls.add(call);
switch (call.method) {
case 'registerTask':
case 'cancelTask':
case 'ackEvents':
return true;
case 'queryTask':
return <String, Object?>{
'state': 'RUNNING',
'attempt': 1,
'progress': <String, Object?>{'sent': 5, 'total': 10},
};
default:
return null;
}
});
messenger.setMockStreamHandler(
eventChannel,
MockStreamHandler.inline(
onListen: (arguments, events) {
sink = events;
},
onCancel: (arguments) {},
),
);
transport = NativeTransport();
});
tearDown(() async {
transport.dispose();
messenger.setMockMethodCallHandler(methodChannel, null);
messenger.setMockStreamHandler(eventChannel, null);
});
/// 泵事件循环直到 registerTask 落账(事件由用例经 [sink] 注入)。
Future<void> untilRegistered() async {
for (var i = 0;
i < 100 && !methodCalls.any((c) => c.method == 'registerTask');
i++) {
await pumpEventQueue();
}
expect(methodCalls.any((c) => c.method == 'registerTask'), isTrue,
reason: 'registerTask 已发出');
}
void emitEvent(Map<String, Object?> event) => sink!.success(event);
group('NativeTransport 命令与事件映射', () {
test('registerTask 参数形状:taskId/url/method/headers/filePath/metadata',
() async {
final future = transport.send(
taskId: 'task-1',
url: 'http://obj.test/staging/a.jpg',
method: 'PUT',
headers: const {'Content-Type': 'image/jpeg'},
filePath: '/data/a.jpg',
metadata: const {'uploadId': 'upl-1', 'attempt': 2},
);
await untilRegistered();
emitEvent({
'event': 'completed',
'taskId': 'task-1',
'etag': '"e-1"',
'seq': 7,
});
final result = await future;
expect(result.etag, '"e-1"');
final call = methodCalls.firstWhere((c) => c.method == 'registerTask');
final args = Map<String, Object?>.from(call.arguments as Map);
expect(args['taskId'], 'task-1');
expect(args['url'], 'http://obj.test/staging/a.jpg');
expect(args['method'], 'PUT');
expect(args['headers'], {'Content-Type': 'image/jpeg'});
expect(args['filePath'], '/data/a.jpg');
expect(args['metadata'], {'uploadId': 'upl-1', 'attempt': 2});
// 终态事件消费后 ack 回传 seq。
await pumpEventQueue();
expect(
methodCalls.any(
(c) => c.method == 'ackEvents' && (c.arguments as Map)['seq'] == 7,
),
isTrue,
reason: '终态 ack 已回传',
);
});
test('progress 事件映射到 onProgress;completed 前不终结', () async {
final samples = <(int, int)>[];
final future = transport.send(
taskId: 'task-2',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/b.jpg',
onProgress: (sent, total) => samples.add((sent, total)),
);
await untilRegistered();
emitEvent({
'event': 'progress',
'taskId': 'task-2',
'sent': 10,
'total': 100,
'seq': 1,
});
emitEvent({
'event': 'progress',
'taskId': 'task-2',
'sent': 90,
'total': 100,
'seq': 2,
});
await pumpEventQueue();
expect(samples, [(10, 100), (90, 100)]);
emitEvent({
'event': 'completed',
'taskId': 'task-2',
'etag': null,
'seq': 3,
});
expect((await future).etag, isNull);
});
test('needsRenew 事件 → TransportRenewRequiredException(403 语义)',
() async {
final future = transport.send(
taskId: 'task-3',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/c.jpg',
);
await untilRegistered();
emitEvent({'event': 'needsRenew', 'taskId': 'task-3', 'seq': 1});
final err = await captureError(future);
expect(err, isA<TransportRenewRequiredException>());
expect((err as TransportException).statusCode, 403);
});
test('failed 事件码表:NETWORK / HTTP_4XX / SERVER_ERROR / SOURCE_MISSING',
() async {
Future<Object> drive(Map<String, Object?> failEvent) async {
final taskId = failEvent['taskId']! as String;
final future = transport.send(
taskId: taskId,
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/x.jpg',
);
await untilRegistered();
emitEvent(failEvent);
return captureError(future);
}
var err = await drive({
'event': 'failed',
'taskId': 't-net',
'code': 'NETWORK',
'seq': 1,
});
expect((err as TransportException).code, TransportErrorCodes.network);
expect(err.statusCode, isNull);
err = await drive({
'event': 'failed',
'taskId': 't-4xx',
'code': 'HTTP_4XX',
'status': 400,
'seq': 2,
});
expect((err as TransportException).code, TransportErrorCodes.http);
expect(err.statusCode, 400);
err = await drive({
'event': 'failed',
'taskId': 't-5xx',
'code': 'SERVER_ERROR',
'status': 502,
'seq': 3,
});
expect((err as TransportException).code, TransportErrorCodes.http);
expect(err.statusCode, 502);
err = await drive({
'event': 'failed',
'taskId': 't-src',
'code': 'SOURCE_MISSING',
'seq': 4,
});
expect(
(err as TransportException).code,
TransportErrorCodes.sourceMissing,
);
});
test('取消令牌:cancelTask 下发 + cancelled 异常收敛', () async {
final cancelToken = CancelToken();
final future = transport.send(
taskId: 'task-4',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/d.jpg',
cancelToken: cancelToken,
);
await untilRegistered();
cancelToken.cancel('用户取消');
final err = await captureError(future);
expect((err as TransportException).code, TransportErrorCodes.cancelled);
await pumpEventQueue();
expect(
methodCalls.any(
(c) =>
c.method == 'cancelTask' &&
(c.arguments as Map)['taskId'] == 'task-4',
),
isTrue,
reason: 'cancelTask 已下发原生',
);
});
test('registerTask 通道异常 → channel 归一(注册失败不悬挂)', () async {
messenger.setMockMethodCallHandler(methodChannel, (call) async {
throw PlatformException(code: 'bad_args', message: 'missing url');
});
final err = await captureError(
transport.send(
taskId: 'task-5',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/e.jpg',
),
);
expect((err as TransportException).code, TransportErrorCodes.channel);
});
test('并发多任务:事件按 taskId 路由互不串扰', () async {
final f1 = transport.send(
taskId: 'a',
url: 'http://obj.test/1',
method: 'PUT',
headers: const {},
filePath: '/1.jpg',
);
final f2 = transport.send(
taskId: 'b',
url: 'http://obj.test/2',
method: 'PUT',
headers: const {},
filePath: '/2.jpg',
);
await untilRegistered();
emitEvent({'event': 'completed', 'taskId': 'b', 'etag': '"b"', 'seq': 1});
emitEvent({
'event': 'failed',
'taskId': 'a',
'code': 'NETWORK',
'seq': 2,
});
expect((await f2).etag, '"b"');
final err = await captureError(f1);
expect((err as TransportException).code, TransportErrorCodes.network);
});
test('queryTask 透传原生 WorkInfo 概览', () async {
final info = await transport.queryTask('task-9');
expect(info!['state'], 'RUNNING');
expect((info['progress'] as Map)['sent'], 5);
});
});
group('DartHttpTransport dio 异常归一', () {
Dio stubDio(Future<Object> Function(RequestOptions) router) {
return Dio()..httpClientAdapter = _StubAdapter(router);
}
test('2xx 返回 TransportResult(etag 头捕获)', () async {
final dio = stubDio((o) async => const (200, '"etag-9"'));
final dartTransport = DartHttpTransport(dio: dio);
final file = _tempFile();
final result = await dartTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {'Content-Type': 'image/jpeg'},
filePath: file.path,
);
expect(result.etag, '"etag-9"');
dartTransport.dispose();
});
test('403 → TransportRenewRequiredException', () async {
final dio = stubDio((o) async => const (403, null));
final dartTransport = DartHttpTransport(dio: dio);
final err = await captureError(
dartTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {},
filePath: _tempFile().path,
),
);
expect(err, isA<TransportRenewRequiredException>());
dartTransport.dispose();
});
test('连接失败 → network;取消令牌 → cancelled', () async {
final file = _tempFile();
final dio = stubDio((o) async {
throw DioException(
requestOptions: o,
type: DioExceptionType.connectionError,
);
});
final dartTransport = DartHttpTransport(dio: dio);
final err = await captureError(
dartTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {},
filePath: file.path,
),
);
expect(
(err as TransportException).code,
TransportErrorCodes.network,
);
dartTransport.dispose();
final cancelDio = stubDio((o) async {
throw DioException(
requestOptions: o,
type: DioExceptionType.cancel,
);
});
final cancelTransport = DartHttpTransport(dio: cancelDio);
final token = CancelToken()..cancel();
final cerr = await captureError(
cancelTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {},
filePath: file.path,
cancelToken: token,
),
);
expect(
(cerr as TransportException).code,
TransportErrorCodes.cancelled,
);
cancelTransport.dispose();
});
});
group('传输选择(Android/iOS 原生 / FORCE_DART_TRANSPORT)', () {
test('选择矩阵:Android/iOS→native;强制 Dart / 桌面→dart', () {
// Android / iOS:原生桥接(Task 7 / Task 8——协议一致,同一通道)。
expect(
preferNativeTransport(isAndroid: true, isIOS: false),
isTrue,
reason: 'Android → WorkManager 原生',
);
expect(
preferNativeTransport(isAndroid: false, isIOS: true),
isTrue,
reason: 'iOS → URLSession background 原生(编译留 G3)',
);
// 桌面 / 测试 VM:Dart dio。
expect(preferNativeTransport(isAndroid: false, isIOS: false), isFalse);
// FORCE_DART_TRANSPORT 覆盖原生选择。
expect(
preferNativeTransport(isAndroid: true, isIOS: false, forceDartTransport: true),
isFalse,
reason: 'FORCE_DART_TRANSPORT 覆盖(Android)',
);
expect(
preferNativeTransport(isAndroid: false, isIOS: true, forceDartTransport: true),
isFalse,
reason: 'FORCE_DART_TRANSPORT 覆盖(iOS)',
);
expect(
preferNativeTransport(isAndroid: true, isIOS: false, forceDartTransport: false),
isTrue,
);
});
test('缺省解析:VM 测试环境(非 Android/iOS)→ DartHttpTransport', () {
final resolved = defaultUploadTransport();
expect(resolved, isA<DartHttpTransport>());
resolved.dispose();
});
test('kForceDartTransport 常量缺省 false(未传 dart-define 时)', () {
expect(kForceDartTransport, isFalse);
});
});
}
File _tempFile() {
final dir = Directory.systemTemp.createTempSync('transport-');
return File('${dir.path}/f.bin')..writeAsBytesSync(List<int>.filled(16, 1));
}
/// 打桩适配器:router 返回 (status, etag) 记录或抛异常;请求流消费后按
/// 状态构造响应(headers 携带 ETag)。
class _StubAdapter implements HttpClientAdapter {
_StubAdapter(this.router);
final Future<Object> Function(RequestOptions options) router;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream? requestStream,
Future? cancelFuture,
) async {
if (requestStream != null) {
await requestStream.drain<Object?>();
}
final result = await router(options);
if (result is (int, String?)) {
final (status, etag) = result;
return ResponseBody.fromString(
'',
status,
headers: {
if (etag != null) 'etag': [etag],
},
);
}
throw result;
}
@override
void close({bool force = false}) {}
}