feat(bridge): 桥接 API 面 Channel 契约 + 三端原生骨架 [tsk_iP5-_Ztq5THx]

MethodChannel pangolin/vpn (start/stop/getStatus/selectOutbound/
getActiveOutbound/setKillSwitch) + 双 EventChannel (status/stats)
契约定义在 lib/bridge/vpn_bridge.dart 头部注释冻结。

- Dart: VpnBridge 抽象接口 + VpnNativeBridge 原生实现 + VpnBridgeMock
  假内核(含 connectDelay/statsInterval 可配参数)
- Dart 桌面: kernel_process.dart KernelProcess/ClashApiClient 接口骨架
- Android: PangolinVpnService (VpnService 骨架 + 前台通知占位) +
  VpnEventBus 解耦总线 + MainActivity MethodChannel/EventChannel 注册
- iOS: PacketTunnelProvider (NEPacketTunnelProvider 骨架) + VpnManager
  (NETunnelProviderManager + NEVPNStatus 订阅) + AppDelegate 通道注册
  + Xcode project.pbxproj 添加 PacketTunnel extension target
- Widget: VpnStatus enum 迁移至 bridge/vpn_bridge.dart (+error 态),
  connect_button/home_shell 更新穷举匹配
- Test: test/bridge/vpn_bridge_mock_test.dart 覆盖 start→connecting→on
  →stop→off 序列、stats 周期推帧、selectOutbound 重连序列、schema 回测

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 12:06:03 +08:00
parent 787151245e
commit 4992d916d0
16 changed files with 1677 additions and 11 deletions
@@ -0,0 +1,264 @@
// 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);
}
});
});
}