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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user