// vpn_bridge_mock_test.dart — VpnBridgeMock 单元测试 // // 验收条件(任务 tsk_iP5-_Ztq5THx): // 1. start → connecting → on 事件序列完整流到 Dart 侧 // 2. stop → off 事件 // 3. stats 流在 on 状态下周期推帧(每 statsInterval) // 4. selectOutbound 切换时产生 connecting → on 重连序列 // 5. getStatus / getActiveOutbound / setKillSwitch 返回正确值 import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; void main() { // 测试用缩短延迟,避免测试耗时过长 const connectDelay = Duration(milliseconds: 80); const statsInterval = Duration(milliseconds: 60); VpnBridgeMock newMock() => VpnBridgeMock( connectDelay: connectDelay, statsInterval: statsInterval, ); // ── 1. start → connecting → on → stop → off ───────────────── group('status event sequence', () { test('start emits connecting immediately', () async { final mock = newMock(); final events = []; final sub = mock.statusStream.listen(events.add); await mock.start('{}'); expect(events, [VpnStatus.connecting]); await sub.cancel(); mock.dispose(); }); test('start then emits on after connectDelay', () async { final mock = newMock(); final events = []; final sub = mock.statusStream.listen(events.add); await mock.start('{}'); await Future.delayed(connectDelay + const Duration(milliseconds: 20)); expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on])); await sub.cancel(); mock.dispose(); }); test('stop after on emits off', () async { final mock = newMock(); final events = []; final sub = mock.statusStream.listen(events.add); await mock.start('{}'); await Future.delayed(connectDelay + const Duration(milliseconds: 20)); await mock.stop(); expect(events.last, VpnStatus.off); await sub.cancel(); mock.dispose(); }); test('full sequence: start→connecting→on→stop→off', () async { final mock = newMock(); final events = []; final sub = mock.statusStream.listen(events.add); await mock.start('{}'); await Future.delayed(connectDelay + const Duration(milliseconds: 20)); await mock.stop(); expect(events, containsAllInOrder([ VpnStatus.connecting, VpnStatus.on, VpnStatus.off, ])); await sub.cancel(); mock.dispose(); }); test('stop before on transitions: connecting → off (no on emitted)', () async { final mock = newMock(); final events = []; final sub = mock.statusStream.listen(events.add); await mock.start('{}'); // Stop before connectDelay expires await mock.stop(); // Should be: connecting, off — no 'on' expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.off])); expect(events, isNot(contains(VpnStatus.on))); await sub.cancel(); mock.dispose(); }); }); // ── 2. stats 流 ───────────────────────────────────────────── group('stats stream', () { test('stats stream emits frames after on transition', () async { final mock = newMock(); final stats = []; final sub = mock.statsStream.listen(stats.add); await mock.start('{}'); // Wait for on + at least 2 stats ticks await Future.delayed( connectDelay + statsInterval * 2 + const Duration(milliseconds: 20), ); expect(stats, isNotEmpty); expect(stats.first.uploadSpeed, greaterThan(0)); expect(stats.first.downloadSpeed, greaterThan(0)); expect(stats.first.downloadBytes, greaterThan(0)); await sub.cancel(); mock.dispose(); }); test('stats stream stops after stop()', () async { final mock = newMock(); final stats = []; final sub = mock.statsStream.listen(stats.add); await mock.start('{}'); await Future.delayed(connectDelay + statsInterval * 2 + const Duration(milliseconds: 20)); final countBeforeStop = stats.length; await mock.stop(); await Future.delayed(statsInterval * 3); final countAfterStop = stats.length; expect(countBeforeStop, greaterThan(0)); // No new frames after stop expect(countAfterStop, equals(countBeforeStop)); await sub.cancel(); mock.dispose(); }); test('stats have urltestResults', () async { final mock = newMock(); final stats = []; final sub = mock.statsStream.listen(stats.add); await mock.start('{}'); await Future.delayed(connectDelay + statsInterval + const Duration(milliseconds: 20)); expect(stats.first.urltestResults, isNotEmpty); expect(stats.first.urltestResults.first.tag, isNotEmpty); expect(stats.first.urltestResults.first.delayMs, greaterThan(0)); await sub.cancel(); mock.dispose(); }); }); // ── 3. selectOutbound ──────────────────────────────────────── group('selectOutbound', () { test('selectOutbound while on triggers reconnect sequence', () async { final mock = newMock(); final events = []; final sub = mock.statusStream.listen(events.add); await mock.start('{}'); await Future.delayed(connectDelay + const Duration(milliseconds: 20)); // Clear previous events for clarity events.clear(); await mock.selectOutbound('jp-01'); await Future.delayed(connectDelay + const Duration(milliseconds: 20)); expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on])); await sub.cancel(); mock.dispose(); }); }); // ── 4. MethodChannel stub methods ──────────────────────────── group('stub methods', () { test('getStatus returns current status', () async { final mock = newMock(); expect(await mock.getStatus(), VpnStatus.off); await mock.start('{}'); expect(await mock.getStatus(), VpnStatus.connecting); await Future.delayed(connectDelay + const Duration(milliseconds: 20)); expect(await mock.getStatus(), VpnStatus.on); await mock.stop(); expect(await mock.getStatus(), VpnStatus.off); mock.dispose(); }); test('getActiveOutbound returns default then updated tag', () async { final mock = newMock(); expect(await mock.getActiveOutbound(), 'auto'); await mock.selectOutbound('hk-01'); expect(await mock.getActiveOutbound(), 'hk-01'); mock.dispose(); }); test('setKillSwitch completes without error', () async { final mock = newMock(); await expectLater(mock.setKillSwitch(on: true), completes); await expectLater(mock.setKillSwitch(on: false), completes); mock.dispose(); }); }); // ── 5. VpnStatsEvent schema round-trip ─────────────────────── group('VpnStatsEvent schema', () { test('fromMap / toMap round-trip', () { const original = VpnStatsEvent( uploadBytes: 1024, downloadBytes: 4096, uploadSpeed: 512.0, downloadSpeed: 2048.0, urltestResults: [ UrltestResult(tag: 'hk-01', delayMs: 18), UrltestResult(tag: 'jp-01', delayMs: 32), ], ); // toMap() returns Map — same type as fromMap expects final decoded = VpnStatsEvent.fromMap(original.toMap()); expect(decoded.uploadBytes, original.uploadBytes); expect(decoded.downloadBytes, original.downloadBytes); expect(decoded.uploadSpeed, original.uploadSpeed); expect(decoded.downloadSpeed, original.downloadSpeed); expect(decoded.urltestResults.length, 2); expect(decoded.urltestResults[0].tag, 'hk-01'); expect(decoded.urltestResults[1].delayMs, 32); }); test('VpnStatus.fromString handles all known values', () { expect(VpnStatus.fromString('off'), VpnStatus.off); expect(VpnStatus.fromString('connecting'), VpnStatus.connecting); expect(VpnStatus.fromString('on'), VpnStatus.on); expect(VpnStatus.fromString('error'), VpnStatus.error); expect(VpnStatus.fromString('unknown'), VpnStatus.error); // fallback }); test('VpnStatus.toNativeString round-trips', () { for (final s in VpnStatus.values) { expect(VpnStatus.fromString(s.toNativeString()), s); } }); }); }