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 分支补全
This commit is contained in:
wangjia
2026-06-13 14:58:38 +08:00
parent 30e73b31c2
commit c84b6ea7da
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);
}
});
});
}