// desktop_vpn_bridge_m4m5_test.dart — M4/M5 功能单元测试 // // 验收条件(tsk_xAQhC1xuCd8x): // M4 URLTest: // 1. ClashApiClient.extractUrltestResults 正确解析 /proxies 响应 // 2. extractUrltestResults 在无 URLTest 组时返回空列表 // 3. extractUrltestResults 跳过无 history 的成员 // 4. ClashApiClient.getGroupDelay 发送正确 GET 请求并解析结果 // 5. selectOutbound 通过 Clash API PUT /proxies/proxy 切换出口 // 6. getActiveOutbound 从 /proxies 读取 proxy.now // 7. getActiveOutbound 在内核未运行时返回 'auto' // M5 Kill-switch: // 8. applyKillSwitchToConfig(true) 设置 TUN strict_route=true // 9. applyKillSwitchToConfig(false) 设置 TUN strict_route=false // 10. 无 TUN inbound 时 applyKillSwitchToConfig 原样返回 // 11. setKillSwitch 保存 _killSwitchEnabled 并在 start 时注入 config // M5 退避重连: // 12. 内核崩溃推 error → UI 不崩(error 事件流通) // 13. 自动重连调度:error 后 _scheduleReconnect 在 1s 内触发(首次延迟) // 14. stop() 取消自动重连(不再触发重连) // 15. 重连成功后 reconnectAttempt 重置为 0 // ignore_for_file: avoid_print import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/bridge/kernel_process.dart'; import 'package:pangolin_vpn/bridge/desktop_vpn_bridge.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; // ══════════════════════════════════════════════════════════════════ // FakeKernelProcess(复用自 kernel_process_test.dart,支持崩溃模拟) // ══════════════════════════════════════════════════════════════════ class FakeKernelProcess implements KernelProcess { FakeKernelProcess({ this.shouldFailSpawn = false, this.crashAfterMs, }); final bool shouldFailSpawn; final int? crashAfterMs; // 若非 null,spawn 成功后在此延迟后模拟崩溃 bool _running = false; String? lastConfigPath; int spawnCount = 0; int killCount = 0; ClashApiClient? _fakeClashApi; final _statusCtrl = StreamController.broadcast(); final _logCtrl = StreamController.broadcast(); final _statsCtrl = StreamController.broadcast(); @override bool get isRunning => _running; @override ClashApiClient get clashApiClient => _fakeClashApi ?? (throw StateError('FakeKernelProcess.clashApiClient: set fakeClashApi first')); set fakeClashApi(ClashApiClient c) => _fakeClashApi = c; @override Stream get logStream => _logCtrl.stream; @override Stream get statusStream => _statusCtrl.stream; @override Stream get statsStream => _statsCtrl.stream; @override Future spawn(String configPath) async { lastConfigPath = configPath; spawnCount++; if (shouldFailSpawn) { _emitStatus(VpnStatus.error); throw Exception('fake spawn failure'); } _running = true; _emitStatus(VpnStatus.connecting); await Future.delayed(const Duration(milliseconds: 10)); _emitStatus(VpnStatus.on); if (crashAfterMs != null) { Future.delayed(Duration(milliseconds: crashAfterMs!)).then((_) { if (_running) { _running = false; _emitStatus(VpnStatus.error); } }); } } @override Future kill({Duration gracePeriod = const Duration(seconds: 5)}) async { killCount++; if (_running) { _running = false; _emitStatus(VpnStatus.off); } } void emitStats(VpnStatsEvent e) { if (!_statsCtrl.isClosed) _statsCtrl.add(e); } /// 手动触发崩溃(error 状态) void simulateCrash() { if (_running) { _running = false; _emitStatus(VpnStatus.error); } } void disposeFake() { _statusCtrl.close(); _logCtrl.close(); _statsCtrl.close(); } void _emitStatus(VpnStatus s) { if (!_statusCtrl.isClosed) _statusCtrl.add(s); } } // ══════════════════════════════════════════════════════════════════ // TestBridge: 覆盖 writeConfig,避免写真实文件系统 // ══════════════════════════════════════════════════════════════════ class _TestBridge extends DesktopVpnBridge { _TestBridge(this.fakeKernel) : super(kernel: fakeKernel); final FakeKernelProcess fakeKernel; String? lastWrittenConfig; List allWrittenConfigs = []; @override Future writeConfig(String configJson) async { lastWrittenConfig = configJson; allWrittenConfigs.add(configJson); final tmp = Directory.systemTemp.createTempSync('pangolin_m4m5_test_'); final f = File('${tmp.path}/config.json')..writeAsStringSync(configJson); return f.path; } @override void dispose() { fakeKernel.disposeFake(); super.dispose(); } } // ══════════════════════════════════════════════════════════════════ // 测试套件 // ══════════════════════════════════════════════════════════════════ void main() { // ── M4: ClashApiClient.extractUrltestResults ────────────────── group('M4 ClashApiClient.extractUrltestResults', () { test('1. 正确解析单 URLTest 组的成员延迟', () { final proxiesResp = { 'proxies': { 'auto-select': { 'type': 'URLTest', 'now': 'hk-1', 'all': ['hk-1', 'sg-1'], }, 'hk-1': { 'type': 'VMess', 'alive': true, 'history': [ {'time': '2024-01-01T00:00:00Z', 'delay': 18}, ], }, 'sg-1': { 'type': 'VMess', 'alive': true, 'history': [ {'time': '2024-01-01T00:00:00Z', 'delay': 54}, ], }, }, }; final results = ClashApiClient.extractUrltestResults(proxiesResp); expect(results.length, 2); final tags = results.map((r) => r.tag).toList(); expect(tags, containsAll(['hk-1', 'sg-1'])); final hk = results.firstWhere((r) => r.tag == 'hk-1'); expect(hk.delayMs, 18); final sg = results.firstWhere((r) => r.tag == 'sg-1'); expect(sg.delayMs, 54); }); test('2. 无 URLTest 组时返回空列表', () { final proxiesResp = { 'proxies': { 'GLOBAL': { 'type': 'Selector', 'all': ['DIRECT'], }, 'DIRECT': {'type': 'Direct'}, }, }; final results = ClashApiClient.extractUrltestResults(proxiesResp); expect(results, isEmpty); }); test('3. 跳过无 history 的成员(不崩溃)', () { final proxiesResp = { 'proxies': { 'auto-select': { 'type': 'URLTest', 'all': ['ok-node', 'no-history-node'], }, 'ok-node': { 'type': 'VMess', 'history': [ {'time': '2024-01-01T00:00:00Z', 'delay': 45}, ], }, 'no-history-node': { 'type': 'VMess', 'history': >[], }, }, }; final results = ClashApiClient.extractUrltestResults(proxiesResp); // 只有 ok-node 有 history expect(results.length, 1); expect(results.first.tag, 'ok-node'); expect(results.first.delayMs, 45); }); test('4. proxies 为空 Map 时返回空列表', () { final results = ClashApiClient.extractUrltestResults({'proxies': {}}); expect(results, isEmpty); }); test('4b. 顶层无 proxies key 时返回空列表', () { final results = ClashApiClient.extractUrltestResults({}); expect(results, isEmpty); }); }); // ── M4: ClashApiClient.getGroupDelay ───────────────────────── group('M4 ClashApiClient.getGroupDelay', () { test('5. 发送正确 GET 请求并解析 tag→delayMs', () async { http.Request? capturedReq; final mock = MockClient((req) async { capturedReq = req; return http.Response( jsonEncode({'hk-1': 18, 'sg-1': 54}), 200, ); }); final client = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', httpClient: mock); final result = await client.getGroupDelay('auto-select'); expect(capturedReq?.method, 'GET'); expect(capturedReq?.url.path, '/group/auto-select/delay'); expect(capturedReq?.url.queryParameters['url'], 'http://www.gstatic.com/generate_204'); expect(result['hk-1'], 18); expect(result['sg-1'], 54); client.dispose(); }); test('5b. 自定义 testUrl 和 timeoutMs', () async { http.Request? capturedReq; final mock = MockClient((req) async { capturedReq = req; return http.Response(jsonEncode({}), 200); }); final client = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', httpClient: mock); await client.getGroupDelay('auto-select', testUrl: 'http://example.com', timeoutMs: 5000); expect(capturedReq?.url.queryParameters['url'], 'http://example.com'); expect(capturedReq?.url.queryParameters['timeout'], '5000'); client.dispose(); }); test('5c. 非200状态码抛出 HttpException', () async { final mock = MockClient((_) async => http.Response('Not Found', 404)); final client = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', httpClient: mock); await expectLater( client.getGroupDelay('auto-select'), throwsA(isA()), ); client.dispose(); }); }); // ── M4: selectOutbound & getActiveOutbound ─────────────────── group('M4 selectOutbound & getActiveOutbound', () { test('6. selectOutbound 调用 PUT /proxies/proxy', () async { Map? sentBody; String? capturedTag; final fake = FakeKernelProcess(); final mock = MockClient((req) async { if (req.method == 'PUT') { capturedTag = req.url.pathSegments.last; sentBody = jsonDecode(req.body) as Map; return http.Response('', 204); } // GET /connections (readiness) return http.Response( jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}), 200); }); fake.fakeClashApi = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', httpClient: mock); final bridge = _TestBridge(fake); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 30)); await bridge.selectOutbound('sg-1'); expect(capturedTag, 'proxy'); expect(sentBody?['name'], 'sg-1'); bridge.dispose(); }); test('6b. selectOutbound 用 auto-select 可切回 URLTest 自动模式', () async { String? selectedProxy; final fake = FakeKernelProcess(); final mock = MockClient((req) async { if (req.method == 'PUT') { final body = jsonDecode(req.body) as Map; selectedProxy = body['name'] as String?; return http.Response('', 204); } return http.Response( jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}), 200); }); fake.fakeClashApi = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', httpClient: mock); final bridge = _TestBridge(fake); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 30)); await bridge.selectOutbound('auto-select'); // 切回自动 expect(selectedProxy, 'auto-select'); bridge.dispose(); }); test('7. getActiveOutbound 在内核未运行时返回 "auto"', () async { final fake = FakeKernelProcess(); final bridge = _TestBridge(fake); final active = await bridge.getActiveOutbound(); expect(active, 'auto'); bridge.dispose(); }); test('7b. getActiveOutbound 从 proxies[proxy][now] 读取', () async { final fake = FakeKernelProcess(); final mock = MockClient((req) async { if (req.url.path == '/proxies') { return http.Response( jsonEncode({ 'proxies': { 'proxy': {'type': 'Selector', 'now': 'sg-1'}, }, }), 200, ); } return http.Response( jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}), 200); }); fake.fakeClashApi = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', httpClient: mock); final bridge = _TestBridge(fake); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 30)); final active = await bridge.getActiveOutbound(); expect(active, 'sg-1'); bridge.dispose(); }); }); // ── M5: applyKillSwitchToConfig ────────────────────────────── group('M5 applyKillSwitchToConfig', () { const configWithTun = '''{ "inbounds": [ {"type": "tun", "tag": "tun-in", "strict_route": false} ], "outbounds": [{"type": "direct", "tag": "direct"}] }'''; test('8. killSwitch=true 设置 TUN strict_route=true', () { final result = DesktopVpnBridge.applyKillSwitchToConfig(configWithTun, true); final decoded = jsonDecode(result) as Map; final inbounds = decoded['inbounds'] as List; final tun = inbounds.firstWhere((e) => e['type'] == 'tun') as Map; expect(tun['strict_route'], true); }); test('9. killSwitch=false 设置 TUN strict_route=false', () { const configWithKsOn = '''{ "inbounds": [{"type": "tun", "strict_route": true}] }'''; final result = DesktopVpnBridge.applyKillSwitchToConfig(configWithKsOn, false); final decoded = jsonDecode(result) as Map; final inbounds = decoded['inbounds'] as List; final tun = inbounds.first as Map; expect(tun['strict_route'], false); }); test('10. 无 TUN inbound 时原样返回(不抛异常)', () { const noTun = '{"inbounds": [{"type": "http", "listen_port": 7890}]}'; final result = DesktopVpnBridge.applyKillSwitchToConfig(noTun, true); // 无 TUN,原样返回 expect(jsonDecode(result)['inbounds'][0]['type'], 'http'); }); test('10b. 无效 JSON 原样返回(不抛异常)', () { const invalid = 'not-json'; expect(() => DesktopVpnBridge.applyKillSwitchToConfig(invalid, true), returnsNormally); }); test('10c. 保留非 TUN inbound 及其他 config 字段', () { const multi = '''{ "log": {"level": "info"}, "inbounds": [ {"type": "mixed", "listen_port": 7890}, {"type": "tun", "strict_route": false} ] }'''; final result = DesktopVpnBridge.applyKillSwitchToConfig(multi, true); final decoded = jsonDecode(result) as Map; expect((decoded['log'] as Map)['level'], 'info'); final inbounds = decoded['inbounds'] as List; expect(inbounds.length, 2); expect(inbounds[0]['type'], 'mixed'); // 非 TUN 保留不变 expect(inbounds[1]['strict_route'], true); }); }); // ── M5: setKillSwitch 注入行为 ─────────────────────────────── group('M5 setKillSwitch config injection', () { test('11. start 时默认 killSwitch=false 保持原 strict_route 值', () async { final fake = FakeKernelProcess(); final bridge = _TestBridge(fake); const cfg = '{"inbounds":[{"type":"tun","strict_route":true}]}'; await bridge.start(cfg); await Future.delayed(const Duration(milliseconds: 30)); // killSwitch=false → strict_route=false final written = jsonDecode(bridge.lastWrittenConfig!) as Map; final tun = (written['inbounds'] as List) .firstWhere((e) => e['type'] == 'tun') as Map; expect(tun['strict_route'], false); bridge.dispose(); }); test('11b. setKillSwitch(on: true) 后 start 注入 strict_route=true', () async { final fake = FakeKernelProcess(); final bridge = _TestBridge(fake); await bridge.setKillSwitch(on: true); const cfg = '{"inbounds":[{"type":"tun","strict_route":false}]}'; await bridge.start(cfg); await Future.delayed(const Duration(milliseconds: 30)); final written = jsonDecode(bridge.lastWrittenConfig!) as Map; final tun = (written['inbounds'] as List) .firstWhere((e) => e['type'] == 'tun') as Map; expect(tun['strict_route'], true); bridge.dispose(); }); test('11c. setKillSwitch(on: X) 相同值时不重复重载', () async { final fake = FakeKernelProcess(); final bridge = _TestBridge(fake); const cfg = '{"inbounds":[{"type":"tun","strict_route":false}]}'; await bridge.start(cfg); await Future.delayed(const Duration(milliseconds: 30)); final spawnsBefore = fake.spawnCount; await bridge.setKillSwitch(on: false); // 已经是 false,不触发重载 await Future.delayed(const Duration(milliseconds: 30)); expect(fake.spawnCount, spawnsBefore); // spawn 次数不变 bridge.dispose(); }); }); // ── M5: 自动退避重连 ───────────────────────────────────────── group('M5 auto-reconnect with backoff', () { test('12. 内核崩溃推 error → UI 不崩,error 事件可订阅', () async { final fake = FakeKernelProcess(); final bridge = _TestBridge(fake); final events = []; final sub = bridge.statusStream.listen(events.add); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 30)); fake.simulateCrash(); // 模拟内核崩溃 await Future.delayed(const Duration(milliseconds: 10)); expect(events, contains(VpnStatus.error)); expect(() => bridge.dispose(), returnsNormally); await sub.cancel(); }); test('13. error 后在首次退避延迟内触发重连(spawn 次数 +1)', () async { final fake = FakeKernelProcess(crashAfterMs: 50); final bridge = _TestBridge(fake); await bridge.start('{"log":{}}'); final spawnsAfterStart = fake.spawnCount; // 等待崩溃(50ms)+ 首次退避(1s)+ buffer await Future.delayed(const Duration(milliseconds: 1200)); expect(fake.spawnCount, greaterThan(spawnsAfterStart), reason: '应该触发至少一次自动重连'); bridge.dispose(); }); test('14. stop() 之后不再触发自动重连', () async { final fake = FakeKernelProcess(); final bridge = _TestBridge(fake); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 30)); await bridge.stop(); // 先停止 final spawnsAfterStop = fake.spawnCount; fake.simulateCrash(); // 此时已 stop,不应触发重连 await Future.delayed(const Duration(milliseconds: 1200)); expect(fake.spawnCount, equals(spawnsAfterStop), reason: 'stop 后崩溃不应再触发自动重连'); bridge.dispose(); }); test('15. stats 流在 on 状态下转发内核事件', () async { final fake = FakeKernelProcess(); final bridge = _TestBridge(fake); final statsReceived = []; final sub = bridge.statsStream.listen(statsReceived.add); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 30)); const frame = VpnStatsEvent( uploadBytes: 2048, downloadBytes: 8192, uploadSpeed: 1024.0, downloadSpeed: 4096.0, urltestResults: [ UrltestResult(tag: 'hk-1', delayMs: 18), UrltestResult(tag: 'sg-1', delayMs: 54), ], ); fake.emitStats(frame); await Future.delayed(const Duration(milliseconds: 10)); expect(statsReceived, hasLength(1)); expect(statsReceived[0].urltestResults, hasLength(2)); expect(statsReceived[0].urltestResults[0].tag, 'hk-1'); expect(statsReceived[0].urltestResults[0].delayMs, 18); await sub.cancel(); bridge.dispose(); }); }); // ── M4: UrltestResult / VpnStatsEvent 模型 ─────────────────── group('M4 UrltestResult model', () { test('fromMap / toMap 往返', () { const r = UrltestResult(tag: 'hk-01', delayMs: 18); final map = r.toMap(); final r2 = UrltestResult.fromMap(map); expect(r2.tag, 'hk-01'); expect(r2.delayMs, 18); }); test('VpnStatsEvent.fromMap 解析 urltestResults 列表', () { final map = { 'uploadBytes': 100, 'downloadBytes': 200, 'uploadSpeed': 10.0, 'downloadSpeed': 20.0, 'urltestResults': [ {'tag': 'hk-1', 'delayMs': 18}, {'tag': 'sg-1', 'delayMs': 54}, ], }; final event = VpnStatsEvent.fromMap(map); expect(event.urltestResults.length, 2); expect(event.urltestResults[0].tag, 'hk-1'); expect(event.urltestResults[1].delayMs, 54); }); test('VpnStatsEvent.fromNativeMap 处理 Map', () { final rawMap = { 'uploadBytes': 1024, 'downloadBytes': 2048, 'uploadSpeed': 512.0, 'downloadSpeed': 1024.0, 'urltestResults': [ {'tag': 'jp-1', 'delayMs': 32}, ], }; final event = VpnStatsEvent.fromNativeMap(rawMap); expect(event.urltestResults.length, 1); expect(event.urltestResults[0].tag, 'jp-1'); }); }); }