2026-09-19 03:22:42 +08:00
|
|
|
|
/// 传输层测试(P3 计划 Task 7)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 覆盖:
|
|
|
|
|
|
/// - NativeTransport:真实通道名 + 测试 messenger(TestDefaultBinaryMessenger
|
|
|
|
|
|
/// 的 mock method/stream handler)——registerTask 参数形状、事件映射
|
|
|
|
|
|
/// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、
|
|
|
|
|
|
/// 注册失败归一、多任务路由;
|
|
|
|
|
|
/// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络);
|
2026-09-19 03:42:49 +08:00
|
|
|
|
/// - 传输选择:Android/iOS 默认原生(Task 8 起 iOS 并入同一矩阵)、
|
|
|
|
|
|
/// FORCE_DART_TRANSPORT 覆盖、VM 缺省 DartHttp。
|
2026-09-19 03:22:42 +08:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-19 03:42:49 +08:00
|
|
|
|
group('传输选择(Android/iOS 原生 / FORCE_DART_TRANSPORT)', () {
|
|
|
|
|
|
test('选择矩阵:Android/iOS→native;强制 Dart / 桌面→dart', () {
|
|
|
|
|
|
// Android / iOS:原生桥接(Task 7 / Task 8——协议一致,同一通道)。
|
2026-09-19 03:22:42 +08:00
|
|
|
|
expect(
|
2026-09-19 03:42:49 +08:00
|
|
|
|
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),
|
2026-09-19 03:22:42 +08:00
|
|
|
|
isFalse,
|
2026-09-19 03:42:49 +08:00
|
|
|
|
reason: 'FORCE_DART_TRANSPORT 覆盖(iOS)',
|
2026-09-19 03:22:42 +08:00
|
|
|
|
);
|
|
|
|
|
|
expect(
|
2026-09-19 03:42:49 +08:00
|
|
|
|
preferNativeTransport(isAndroid: true, isIOS: false, forceDartTransport: false),
|
2026-09-19 03:22:42 +08:00
|
|
|
|
isTrue,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-19 03:42:49 +08:00
|
|
|
|
test('缺省解析:VM 测试环境(非 Android/iOS)→ DartHttpTransport', () {
|
2026-09-19 03:22:42 +08:00
|
|
|
|
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}) {}
|
|
|
|
|
|
}
|