Files
pangolin/client/test/bridge/vpn_bridge_mock_test.dart
T
wangjia c84b6ea7da merge: maestro/tsk_iP5-_Ztq5THx [桥接 API 面 Channel 契约 + 三端原生骨架] [tsk_rcNKc3GQHBUd]
合并冲突补救:原分支从 merge base 7871512 分叉,仅修改 client/ 目录,
与 main 新增的 server/(apierr/idgen/CONVENTIONS.md) 完全不重叠,
无真实文件冲突,手动应用 feature branch 的全部 client/ 改动。

client 侧新增:
- lib/bridge/vpn_bridge.dart      — Dart↔原生通道契约(冻结)
- lib/bridge/vpn_bridge_mock.dart — 假内核,UI 联调用
- lib/bridge/kernel_process.dart  — 桌面子进程管理接口
- ios/PacketTunnel/{Info.plist,PacketTunnelProvider.swift} — NEPacketTunnel 骨架
- ios/Runner/VpnManager.swift     — NETunnelProviderManager 封装
- android/.../PangolinVpnService.kt — VpnService 骨架
- android/.../VpnEventBus.kt      — Application 级状态总线
- test/bridge/vpn_bridge_mock_test.dart — 桥接层单元测试

client 侧修改:
- android/.../MainActivity.kt    — 注册三通道(MethodChannel + 2×EventChannel)
- android/.../AndroidManifest.xml — 声明 FOREGROUND_SERVICE + PangolinVpnService
- ios/Runner/AppDelegate.swift   — 注册通道 + VpnManager 初始化
- ios/Runner/Info.plist          — 新增 NSVPNUsageDescription
- ios/Runner.xcodeproj/project.pbxproj — 添加 PacketTunnel extension target
- lib/widgets/connect_button.dart — VpnStatus 迁移到 bridge/vpn_bridge.dart
- lib/widgets/home_shell.dart    — error 状态 UI 分支补全
2026-06-13 14:58:38 +08:00

265 lines
8.8 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.
// 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 'dart:async';
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 = <VpnStatus>[];
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 = <VpnStatus>[];
final sub = mock.statusStream.listen(events.add);
await mock.start('{}');
await Future<void>.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 = <VpnStatus>[];
final sub = mock.statusStream.listen(events.add);
await mock.start('{}');
await Future<void>.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 = <VpnStatus>[];
final sub = mock.statusStream.listen(events.add);
await mock.start('{}');
await Future<void>.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 = <VpnStatus>[];
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 = <VpnStatsEvent>[];
final sub = mock.statsStream.listen(stats.add);
await mock.start('{}');
// Wait for on + at least 2 stats ticks
await Future<void>.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 = <VpnStatsEvent>[];
final sub = mock.statsStream.listen(stats.add);
await mock.start('{}');
await Future<void>.delayed(connectDelay + statsInterval * 2 + const Duration(milliseconds: 20));
final countBeforeStop = stats.length;
await mock.stop();
await Future<void>.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 = <VpnStatsEvent>[];
final sub = mock.statsStream.listen(stats.add);
await mock.start('{}');
await Future<void>.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 = <VpnStatus>[];
final sub = mock.statusStream.listen(events.add);
await mock.start('{}');
await Future<void>.delayed(connectDelay + const Duration(milliseconds: 20));
// Clear previous events for clarity
events.clear();
await mock.selectOutbound('jp-01');
await Future<void>.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<void>.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', () {
final original = const 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<String, dynamic> — 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);
}
});
});
}