feat(M4/M5): URLTest 自动选线 + Kill-switch + 弹性重连 [tsk_xAQhC1xuCd8x]
M4 URLTest 自动选线:
- ClashApiClient.getGroupDelay() — GET /group/<name>/delay 触发按需测速
- ClashApiClient.extractUrltestResults() — 从 /proxies 响应解析 URLTest
组成员最新延迟,填充 stats 帧的 urltestResults 字段
- DesktopKernelProcess._startStatsPoll() — 每秒并行拉取连接统计 + URLTest
延迟(_clashApi.getProxies()),URLTest 失败不影响主统计
- DesktopVpnBridge.selectOutbound(tag) — Clash API PUT /proxies/proxy,
tag 可传节点 tag 或 urltest 组 tag(如 "auto-select")恢复自动
- DesktopVpnBridge.getActiveOutbound() — GET /proxies 读 proxy.now
- app/kernel/poc/reality_client.config.json.tmpl — 增加 urltest("auto-select")
+ selector("proxy") 出口组,route.final 改为 "proxy"
M5 Kill-switch + 弹性重连:
- DesktopVpnBridge.setKillSwitch({required bool on}) — 将 _killSwitchEnabled
写入 TUN inbound strict_route 字段(applyKillSwitchToConfig);内核运行中
触发静默重载(kill → start(lastConfig))
- DesktopVpnBridge.applyKillSwitchToConfig() — static,killSwitch=true ↔
TUN strict_route=true,killSwitch=false ↔ strict_route=false
- 自动退避重连:kernel 崩溃推 error → _scheduleReconnect() 退避序列
1s/2s/4s/8s/16s/30s(上限),stop() 取消,重连成功后重置计数器
- DesktopKernelProcess 意外退出清理 _clashApi(避免下次 spawn 残留)
- app/pangolin/test/killswitch_checklist.md — 三端故障注入验收清单
(Desktop ✅,iOS/Android 欠账说明)
新增测试(client/test/bridge/desktop_vpn_bridge_m4m5_test.dart):
- M4: extractUrltestResults 解析/空/无 history 边界
- M4: getGroupDelay HTTP 请求格式验证
- M4: selectOutbound / getActiveOutbound Clash API 调用断言
- M5: applyKillSwitchToConfig 字段覆盖 + 无 TUN 边界
- M5: setKillSwitch 状态注入 + 同值幂等
- M5: 崩溃→error 不崩 / 首次重连在退避延迟内触发 / stop 后不再重连
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,664 @@
|
||||
// desktop_vpn_bridge_m4m5_test.dart — M4/M5 功能单元测试
|
||||
//
|
||||
// 验收条件(tsk_xAQhC1xuCd8x):
|
||||
// M4 URLTest:
|
||||
// 1. ClashApiClient.extractUrltestResults 正确解析 /proxies 响应
|
||||
// 2. extractUrltestResults 在无 URLTest 组时返回空列表
|
||||
// 3. extractUrltestResults 跳过无 history 的成员
|
||||
// 4. ClashApiClient.getGroupDelay 发送正确 GET 请求并解析结果
|
||||
// 5. selectOutbound 通过 Clash API PUT /proxies/proxy 切换出口
|
||||
// 6. getActiveOutbound 从 /proxies 读取 proxy.now
|
||||
// 7. getActiveOutbound 在内核未运行时返回 'auto'
|
||||
// M5 Kill-switch:
|
||||
// 8. applyKillSwitchToConfig(true) 设置 TUN strict_route=true
|
||||
// 9. applyKillSwitchToConfig(false) 设置 TUN strict_route=false
|
||||
// 10. 无 TUN inbound 时 applyKillSwitchToConfig 原样返回
|
||||
// 11. setKillSwitch 保存 _killSwitchEnabled 并在 start 时注入 config
|
||||
// M5 退避重连:
|
||||
// 12. 内核崩溃推 error → UI 不崩(error 事件流通)
|
||||
// 13. 自动重连调度:error 后 _scheduleReconnect 在 1s 内触发(首次延迟)
|
||||
// 14. stop() 取消自动重连(不再触发重连)
|
||||
// 15. 重连成功后 reconnectAttempt 重置为 0
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:pangolin_vpn/bridge/kernel_process.dart';
|
||||
import 'package:pangolin_vpn/bridge/desktop_vpn_bridge.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// FakeKernelProcess(复用自 kernel_process_test.dart,支持崩溃模拟)
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
class FakeKernelProcess implements KernelProcess {
|
||||
FakeKernelProcess({
|
||||
this.shouldFailSpawn = false,
|
||||
this.crashAfterMs,
|
||||
});
|
||||
|
||||
final bool shouldFailSpawn;
|
||||
final int? crashAfterMs; // 若非 null,spawn 成功后在此延迟后模拟崩溃
|
||||
|
||||
bool _running = false;
|
||||
String? lastConfigPath;
|
||||
int spawnCount = 0;
|
||||
int killCount = 0;
|
||||
ClashApiClient? _fakeClashApi;
|
||||
|
||||
final _statusCtrl = StreamController<VpnStatus>.broadcast();
|
||||
final _logCtrl = StreamController<String>.broadcast();
|
||||
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
|
||||
|
||||
@override
|
||||
bool get isRunning => _running;
|
||||
|
||||
@override
|
||||
ClashApiClient get clashApiClient =>
|
||||
_fakeClashApi ?? (throw StateError('FakeKernelProcess.clashApiClient: set fakeClashApi first'));
|
||||
|
||||
set fakeClashApi(ClashApiClient c) => _fakeClashApi = c;
|
||||
|
||||
@override
|
||||
Stream<String> get logStream => _logCtrl.stream;
|
||||
|
||||
@override
|
||||
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
|
||||
|
||||
@override
|
||||
Stream<VpnStatsEvent> get statsStream => _statsCtrl.stream;
|
||||
|
||||
@override
|
||||
Future<void> spawn(String configPath) async {
|
||||
lastConfigPath = configPath;
|
||||
spawnCount++;
|
||||
if (shouldFailSpawn) {
|
||||
_emitStatus(VpnStatus.error);
|
||||
throw Exception('fake spawn failure');
|
||||
}
|
||||
|
||||
_running = true;
|
||||
_emitStatus(VpnStatus.connecting);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
_emitStatus(VpnStatus.on);
|
||||
|
||||
if (crashAfterMs != null) {
|
||||
Future<void>.delayed(Duration(milliseconds: crashAfterMs!)).then((_) {
|
||||
if (_running) {
|
||||
_running = false;
|
||||
_emitStatus(VpnStatus.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)}) async {
|
||||
killCount++;
|
||||
if (_running) {
|
||||
_running = false;
|
||||
_emitStatus(VpnStatus.off);
|
||||
}
|
||||
}
|
||||
|
||||
void emitStats(VpnStatsEvent e) {
|
||||
if (!_statsCtrl.isClosed) _statsCtrl.add(e);
|
||||
}
|
||||
|
||||
/// 手动触发崩溃(error 状态)
|
||||
void simulateCrash() {
|
||||
if (_running) {
|
||||
_running = false;
|
||||
_emitStatus(VpnStatus.error);
|
||||
}
|
||||
}
|
||||
|
||||
void disposeFake() {
|
||||
_statusCtrl.close();
|
||||
_logCtrl.close();
|
||||
_statsCtrl.close();
|
||||
}
|
||||
|
||||
void _emitStatus(VpnStatus s) {
|
||||
if (!_statusCtrl.isClosed) _statusCtrl.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// TestBridge: 覆盖 writeConfig,避免写真实文件系统
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
class _TestBridge extends DesktopVpnBridge {
|
||||
_TestBridge(this.fakeKernel) : super(kernel: fakeKernel);
|
||||
|
||||
final FakeKernelProcess fakeKernel;
|
||||
String? lastWrittenConfig;
|
||||
List<String> allWrittenConfigs = [];
|
||||
|
||||
@override
|
||||
Future<String> writeConfig(String configJson) async {
|
||||
lastWrittenConfig = configJson;
|
||||
allWrittenConfigs.add(configJson);
|
||||
final tmp = Directory.systemTemp.createTempSync('pangolin_m4m5_test_');
|
||||
final f = File('${tmp.path}/config.json')..writeAsStringSync(configJson);
|
||||
return f.path;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
fakeKernel.disposeFake();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// 测试套件
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
void main() {
|
||||
// ── M4: ClashApiClient.extractUrltestResults ──────────────────
|
||||
group('M4 ClashApiClient.extractUrltestResults', () {
|
||||
test('1. 正确解析单 URLTest 组的成员延迟', () {
|
||||
final proxiesResp = {
|
||||
'proxies': {
|
||||
'auto-select': {
|
||||
'type': 'URLTest',
|
||||
'now': 'hk-1',
|
||||
'all': ['hk-1', 'sg-1'],
|
||||
},
|
||||
'hk-1': {
|
||||
'type': 'VMess',
|
||||
'alive': true,
|
||||
'history': [
|
||||
{'time': '2024-01-01T00:00:00Z', 'delay': 18},
|
||||
],
|
||||
},
|
||||
'sg-1': {
|
||||
'type': 'VMess',
|
||||
'alive': true,
|
||||
'history': [
|
||||
{'time': '2024-01-01T00:00:00Z', 'delay': 54},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final results = ClashApiClient.extractUrltestResults(proxiesResp);
|
||||
|
||||
expect(results.length, 2);
|
||||
final tags = results.map((r) => r.tag).toList();
|
||||
expect(tags, containsAll(['hk-1', 'sg-1']));
|
||||
final hk = results.firstWhere((r) => r.tag == 'hk-1');
|
||||
expect(hk.delayMs, 18);
|
||||
final sg = results.firstWhere((r) => r.tag == 'sg-1');
|
||||
expect(sg.delayMs, 54);
|
||||
});
|
||||
|
||||
test('2. 无 URLTest 组时返回空列表', () {
|
||||
final proxiesResp = {
|
||||
'proxies': {
|
||||
'GLOBAL': {
|
||||
'type': 'Selector',
|
||||
'all': ['DIRECT'],
|
||||
},
|
||||
'DIRECT': {'type': 'Direct'},
|
||||
},
|
||||
};
|
||||
|
||||
final results = ClashApiClient.extractUrltestResults(proxiesResp);
|
||||
expect(results, isEmpty);
|
||||
});
|
||||
|
||||
test('3. 跳过无 history 的成员(不崩溃)', () {
|
||||
final proxiesResp = {
|
||||
'proxies': {
|
||||
'auto-select': {
|
||||
'type': 'URLTest',
|
||||
'all': ['ok-node', 'no-history-node'],
|
||||
},
|
||||
'ok-node': {
|
||||
'type': 'VMess',
|
||||
'history': [
|
||||
{'time': '2024-01-01T00:00:00Z', 'delay': 45},
|
||||
],
|
||||
},
|
||||
'no-history-node': {
|
||||
'type': 'VMess',
|
||||
'history': <Map<String, dynamic>>[],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final results = ClashApiClient.extractUrltestResults(proxiesResp);
|
||||
// 只有 ok-node 有 history
|
||||
expect(results.length, 1);
|
||||
expect(results.first.tag, 'ok-node');
|
||||
expect(results.first.delayMs, 45);
|
||||
});
|
||||
|
||||
test('4. proxies 为空 Map 时返回空列表', () {
|
||||
final results =
|
||||
ClashApiClient.extractUrltestResults({'proxies': <String, dynamic>{}});
|
||||
expect(results, isEmpty);
|
||||
});
|
||||
|
||||
test('4b. 顶层无 proxies key 时返回空列表', () {
|
||||
final results = ClashApiClient.extractUrltestResults({});
|
||||
expect(results, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── M4: ClashApiClient.getGroupDelay ─────────────────────────
|
||||
group('M4 ClashApiClient.getGroupDelay', () {
|
||||
test('5. 发送正确 GET 请求并解析 tag→delayMs', () async {
|
||||
http.Request? capturedReq;
|
||||
final mock = MockClient((req) async {
|
||||
capturedReq = req;
|
||||
return http.Response(
|
||||
jsonEncode({'hk-1': 18, 'sg-1': 54}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final client = ClashApiClient(
|
||||
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
|
||||
final result = await client.getGroupDelay('auto-select');
|
||||
|
||||
expect(capturedReq?.method, 'GET');
|
||||
expect(capturedReq?.url.path, '/group/auto-select/delay');
|
||||
expect(capturedReq?.url.queryParameters['url'],
|
||||
'http://www.gstatic.com/generate_204');
|
||||
expect(result['hk-1'], 18);
|
||||
expect(result['sg-1'], 54);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('5b. 自定义 testUrl 和 timeoutMs', () async {
|
||||
http.Request? capturedReq;
|
||||
final mock = MockClient((req) async {
|
||||
capturedReq = req;
|
||||
return http.Response(jsonEncode(<String, dynamic>{}), 200);
|
||||
});
|
||||
|
||||
final client = ClashApiClient(
|
||||
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
|
||||
await client.getGroupDelay('auto-select',
|
||||
testUrl: 'http://example.com', timeoutMs: 5000);
|
||||
|
||||
expect(capturedReq?.url.queryParameters['url'], 'http://example.com');
|
||||
expect(capturedReq?.url.queryParameters['timeout'], '5000');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('5c. 非200状态码抛出 HttpException', () async {
|
||||
final mock =
|
||||
MockClient((_) async => http.Response('Not Found', 404));
|
||||
final client = ClashApiClient(
|
||||
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
|
||||
|
||||
await expectLater(
|
||||
client.getGroupDelay('auto-select'),
|
||||
throwsA(isA<HttpException>()),
|
||||
);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── M4: selectOutbound & getActiveOutbound ───────────────────
|
||||
group('M4 selectOutbound & getActiveOutbound', () {
|
||||
test('6. selectOutbound 调用 PUT /proxies/proxy', () async {
|
||||
Map<String, dynamic>? sentBody;
|
||||
String? capturedTag;
|
||||
|
||||
final fake = FakeKernelProcess();
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') {
|
||||
capturedTag = req.url.pathSegments.last;
|
||||
sentBody = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response('', 204);
|
||||
}
|
||||
// GET /connections (readiness)
|
||||
return http.Response(
|
||||
jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}),
|
||||
200);
|
||||
});
|
||||
|
||||
fake.fakeClashApi = ClashApiClient(
|
||||
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
|
||||
|
||||
final bridge = _TestBridge(fake);
|
||||
await bridge.start('{"log":{}}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
await bridge.selectOutbound('sg-1');
|
||||
|
||||
expect(capturedTag, 'proxy');
|
||||
expect(sentBody?['name'], 'sg-1');
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
|
||||
test('6b. selectOutbound 用 auto-select 可切回 URLTest 自动模式', () async {
|
||||
String? selectedProxy;
|
||||
|
||||
final fake = FakeKernelProcess();
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') {
|
||||
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
selectedProxy = body['name'] as String?;
|
||||
return http.Response('', 204);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}),
|
||||
200);
|
||||
});
|
||||
|
||||
fake.fakeClashApi = ClashApiClient(
|
||||
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
|
||||
|
||||
final bridge = _TestBridge(fake);
|
||||
await bridge.start('{"log":{}}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
await bridge.selectOutbound('auto-select'); // 切回自动
|
||||
|
||||
expect(selectedProxy, 'auto-select');
|
||||
bridge.dispose();
|
||||
});
|
||||
|
||||
test('7. getActiveOutbound 在内核未运行时返回 "auto"', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final bridge = _TestBridge(fake);
|
||||
|
||||
final active = await bridge.getActiveOutbound();
|
||||
expect(active, 'auto');
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
|
||||
test('7b. getActiveOutbound 从 proxies[proxy][now] 读取', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/proxies') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'proxies': {
|
||||
'proxy': {'type': 'Selector', 'now': 'sg-1'},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}),
|
||||
200);
|
||||
});
|
||||
|
||||
fake.fakeClashApi = ClashApiClient(
|
||||
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
|
||||
|
||||
final bridge = _TestBridge(fake);
|
||||
await bridge.start('{"log":{}}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
final active = await bridge.getActiveOutbound();
|
||||
expect(active, 'sg-1');
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── M5: applyKillSwitchToConfig ──────────────────────────────
|
||||
group('M5 applyKillSwitchToConfig', () {
|
||||
const configWithTun = '''{
|
||||
"inbounds": [
|
||||
{"type": "tun", "tag": "tun-in", "strict_route": false}
|
||||
],
|
||||
"outbounds": [{"type": "direct", "tag": "direct"}]
|
||||
}''';
|
||||
|
||||
test('8. killSwitch=true 设置 TUN strict_route=true', () {
|
||||
final result =
|
||||
DesktopVpnBridge.applyKillSwitchToConfig(configWithTun, true);
|
||||
final decoded = jsonDecode(result) as Map<String, dynamic>;
|
||||
final inbounds = decoded['inbounds'] as List;
|
||||
final tun = inbounds.firstWhere((e) => e['type'] == 'tun') as Map;
|
||||
expect(tun['strict_route'], true);
|
||||
});
|
||||
|
||||
test('9. killSwitch=false 设置 TUN strict_route=false', () {
|
||||
const configWithKsOn = '''{
|
||||
"inbounds": [{"type": "tun", "strict_route": true}]
|
||||
}''';
|
||||
final result =
|
||||
DesktopVpnBridge.applyKillSwitchToConfig(configWithKsOn, false);
|
||||
final decoded = jsonDecode(result) as Map<String, dynamic>;
|
||||
final inbounds = decoded['inbounds'] as List;
|
||||
final tun = inbounds.first as Map;
|
||||
expect(tun['strict_route'], false);
|
||||
});
|
||||
|
||||
test('10. 无 TUN inbound 时原样返回(不抛异常)', () {
|
||||
const noTun = '{"inbounds": [{"type": "http", "listen_port": 7890}]}';
|
||||
final result = DesktopVpnBridge.applyKillSwitchToConfig(noTun, true);
|
||||
// 无 TUN,原样返回
|
||||
expect(jsonDecode(result)['inbounds'][0]['type'], 'http');
|
||||
});
|
||||
|
||||
test('10b. 无效 JSON 原样返回(不抛异常)', () {
|
||||
const invalid = 'not-json';
|
||||
expect(() => DesktopVpnBridge.applyKillSwitchToConfig(invalid, true),
|
||||
returnsNormally);
|
||||
});
|
||||
|
||||
test('10c. 保留非 TUN inbound 及其他 config 字段', () {
|
||||
const multi = '''{
|
||||
"log": {"level": "info"},
|
||||
"inbounds": [
|
||||
{"type": "mixed", "listen_port": 7890},
|
||||
{"type": "tun", "strict_route": false}
|
||||
]
|
||||
}''';
|
||||
final result = DesktopVpnBridge.applyKillSwitchToConfig(multi, true);
|
||||
final decoded = jsonDecode(result) as Map<String, dynamic>;
|
||||
expect((decoded['log'] as Map)['level'], 'info');
|
||||
final inbounds = decoded['inbounds'] as List;
|
||||
expect(inbounds.length, 2);
|
||||
expect(inbounds[0]['type'], 'mixed'); // 非 TUN 保留不变
|
||||
expect(inbounds[1]['strict_route'], true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── M5: setKillSwitch 注入行为 ───────────────────────────────
|
||||
group('M5 setKillSwitch config injection', () {
|
||||
test('11. start 时默认 killSwitch=false 保持原 strict_route 值', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final bridge = _TestBridge(fake);
|
||||
|
||||
const cfg = '{"inbounds":[{"type":"tun","strict_route":true}]}';
|
||||
await bridge.start(cfg);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
// killSwitch=false → strict_route=false
|
||||
final written = jsonDecode(bridge.lastWrittenConfig!) as Map<String, dynamic>;
|
||||
final tun = (written['inbounds'] as List)
|
||||
.firstWhere((e) => e['type'] == 'tun') as Map;
|
||||
expect(tun['strict_route'], false);
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
|
||||
test('11b. setKillSwitch(on: true) 后 start 注入 strict_route=true', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final bridge = _TestBridge(fake);
|
||||
|
||||
await bridge.setKillSwitch(on: true);
|
||||
const cfg = '{"inbounds":[{"type":"tun","strict_route":false}]}';
|
||||
await bridge.start(cfg);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
final written = jsonDecode(bridge.lastWrittenConfig!) as Map<String, dynamic>;
|
||||
final tun = (written['inbounds'] as List)
|
||||
.firstWhere((e) => e['type'] == 'tun') as Map;
|
||||
expect(tun['strict_route'], true);
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
|
||||
test('11c. setKillSwitch(on: X) 相同值时不重复重载', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final bridge = _TestBridge(fake);
|
||||
|
||||
const cfg = '{"inbounds":[{"type":"tun","strict_route":false}]}';
|
||||
await bridge.start(cfg);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final spawnsBefore = fake.spawnCount;
|
||||
|
||||
await bridge.setKillSwitch(on: false); // 已经是 false,不触发重载
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
expect(fake.spawnCount, spawnsBefore); // spawn 次数不变
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── M5: 自动退避重连 ─────────────────────────────────────────
|
||||
group('M5 auto-reconnect with backoff', () {
|
||||
test('12. 内核崩溃推 error → UI 不崩,error 事件可订阅', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final bridge = _TestBridge(fake);
|
||||
final events = <VpnStatus>[];
|
||||
|
||||
final sub = bridge.statusStream.listen(events.add);
|
||||
await bridge.start('{"log":{}}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
fake.simulateCrash(); // 模拟内核崩溃
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(events, contains(VpnStatus.error));
|
||||
expect(() => bridge.dispose(), returnsNormally);
|
||||
|
||||
await sub.cancel();
|
||||
});
|
||||
|
||||
test('13. error 后在首次退避延迟内触发重连(spawn 次数 +1)', () async {
|
||||
final fake = FakeKernelProcess(crashAfterMs: 50);
|
||||
final bridge = _TestBridge(fake);
|
||||
|
||||
await bridge.start('{"log":{}}');
|
||||
final spawnsAfterStart = fake.spawnCount;
|
||||
|
||||
// 等待崩溃(50ms)+ 首次退避(1s)+ buffer
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||
|
||||
expect(fake.spawnCount, greaterThan(spawnsAfterStart),
|
||||
reason: '应该触发至少一次自动重连');
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
|
||||
test('14. stop() 之后不再触发自动重连', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final bridge = _TestBridge(fake);
|
||||
|
||||
await bridge.start('{"log":{}}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
await bridge.stop(); // 先停止
|
||||
|
||||
final spawnsAfterStop = fake.spawnCount;
|
||||
|
||||
fake.simulateCrash(); // 此时已 stop,不应触发重连
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||
|
||||
expect(fake.spawnCount, equals(spawnsAfterStop),
|
||||
reason: 'stop 后崩溃不应再触发自动重连');
|
||||
|
||||
bridge.dispose();
|
||||
});
|
||||
|
||||
test('15. stats 流在 on 状态下转发内核事件', () async {
|
||||
final fake = FakeKernelProcess();
|
||||
final bridge = _TestBridge(fake);
|
||||
final statsReceived = <VpnStatsEvent>[];
|
||||
|
||||
final sub = bridge.statsStream.listen(statsReceived.add);
|
||||
await bridge.start('{"log":{}}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
const frame = VpnStatsEvent(
|
||||
uploadBytes: 2048,
|
||||
downloadBytes: 8192,
|
||||
uploadSpeed: 1024.0,
|
||||
downloadSpeed: 4096.0,
|
||||
urltestResults: [
|
||||
UrltestResult(tag: 'hk-1', delayMs: 18),
|
||||
UrltestResult(tag: 'sg-1', delayMs: 54),
|
||||
],
|
||||
);
|
||||
fake.emitStats(frame);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(statsReceived, hasLength(1));
|
||||
expect(statsReceived[0].urltestResults, hasLength(2));
|
||||
expect(statsReceived[0].urltestResults[0].tag, 'hk-1');
|
||||
expect(statsReceived[0].urltestResults[0].delayMs, 18);
|
||||
|
||||
await sub.cancel();
|
||||
bridge.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── M4: UrltestResult / VpnStatsEvent 模型 ───────────────────
|
||||
group('M4 UrltestResult model', () {
|
||||
test('fromMap / toMap 往返', () {
|
||||
const r = UrltestResult(tag: 'hk-01', delayMs: 18);
|
||||
final map = r.toMap();
|
||||
final r2 = UrltestResult.fromMap(map);
|
||||
expect(r2.tag, 'hk-01');
|
||||
expect(r2.delayMs, 18);
|
||||
});
|
||||
|
||||
test('VpnStatsEvent.fromMap 解析 urltestResults 列表', () {
|
||||
final map = {
|
||||
'uploadBytes': 100,
|
||||
'downloadBytes': 200,
|
||||
'uploadSpeed': 10.0,
|
||||
'downloadSpeed': 20.0,
|
||||
'urltestResults': [
|
||||
{'tag': 'hk-1', 'delayMs': 18},
|
||||
{'tag': 'sg-1', 'delayMs': 54},
|
||||
],
|
||||
};
|
||||
final event = VpnStatsEvent.fromMap(map);
|
||||
expect(event.urltestResults.length, 2);
|
||||
expect(event.urltestResults[0].tag, 'hk-1');
|
||||
expect(event.urltestResults[1].delayMs, 54);
|
||||
});
|
||||
|
||||
test('VpnStatsEvent.fromNativeMap 处理 Map<Object?, Object?>', () {
|
||||
final rawMap = <Object?, Object?>{
|
||||
'uploadBytes': 1024,
|
||||
'downloadBytes': 2048,
|
||||
'uploadSpeed': 512.0,
|
||||
'downloadSpeed': 1024.0,
|
||||
'urltestResults': <Object?>[
|
||||
<Object?, Object?>{'tag': 'jp-1', 'delayMs': 32},
|
||||
],
|
||||
};
|
||||
final event = VpnStatsEvent.fromNativeMap(rawMap);
|
||||
expect(event.urltestResults.length, 1);
|
||||
expect(event.urltestResults[0].tag, 'jp-1');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user