/// 传输层测试(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 captureError(Future 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 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 { 'state': 'RUNNING', 'attempt': 1, 'progress': {'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 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 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.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()); expect((err as TransportException).statusCode, 403); }); test('failed 事件码表:NETWORK / HTTP_4XX / SERVER_ERROR / SOURCE_MISSING', () async { Future drive(Map 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 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()); 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()); 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.filled(16, 1)); } /// 打桩适配器:router 返回 (status, etag) 记录或抛异常;请求流消费后按 /// 状态构造响应(headers 携带 ETag)。 class _StubAdapter implements HttpClientAdapter { _StubAdapter(this.router); final Future Function(RequestOptions options) router; @override Future fetch( RequestOptions options, Stream? requestStream, Future? cancelFuture, ) async { if (requestStream != null) { await requestStream.drain(); } 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}) {} }