// connection_watchdog_test.dart — 连通看门狗(返工版,数据面 IP 探测已撤): // 路径 A 意外内核掉线(非用户主动)→ 置「节点异常」提示; // 路径 B 服务端判所连节点 down → 智能切节点 / 手动断开提示;健康则保持连接。 import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/models/node.dart'; import 'package:pangolin_vpn/services/connect_api.dart'; import 'package:pangolin_vpn/state/connection_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; // 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。 class _FakeBridge implements VpnBridge { final _status = StreamController.broadcast(); final _stats = StreamController.broadcast(); void emit(VpnStatus s) => _status.add(s); @override Stream get statusStream => _status.stream; @override Stream get statsStream => _stats.stream; @override Future start(String configJson) async {} @override Future stop() async {} @override Future getStatus() async => VpnStatus.off; @override Future selectOutbound(String tag) async {} @override Future getActiveOutbound() async => 'auto'; @override Future setKillSwitch({required bool on}) async {} @override void dispose() { _status.close(); _stats.close(); } } // 重连时 fetchConfig 立即抛错,绕开真实网络(不留超时计时器)。 class _FakeConnectApi extends ConnectApi { _FakeConnectApi() : super(baseUrl: 'http://test.local', authToken: ''); @override Future fetchConfig({required String nodeId, required String deviceId, bool splitCN = false}) async { throw const ConnectApiException(statusCode: 0, messageZh: 'x', messageEn: 'x'); } @override void dispose() {} } // 两节点都可用(status=up)。 class _StubNodes extends NodesNotifier { @override Future> build() async => const [ Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 20, uuid: 'hk-uuid', host: 'hk', port: 443), Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443), ]; @override Future refresh() async {} } // 当前最优节点 HK 被服务端判 down(status≠up),JP 仍可用。 class _StubNodesHKDown extends NodesNotifier { @override Future> build() async => const [ Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 20, uuid: 'hk-uuid', host: 'hk', port: 443, status: 'down'), Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443), ]; @override Future refresh() async {} } void main() { const t = StringsZh(); ProviderContainer makeContainer(_FakeBridge bridge, {NodesNotifier Function()? nodes}) => ProviderContainer(overrides: [ vpnBridgeProvider.overrideWithValue(bridge), nodesProvider.overrideWith(nodes ?? _StubNodes.new), connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()), connectionProvider.overrideWith( (ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider))), ]); // 加载节点 + 建 controller + 推 on,并让「即时首查」跑完。 Future driveOn(WidgetTester tester, ProviderContainer c, _FakeBridge bridge) async { c.read(nodesProvider); await tester.pump(); c.read(connectionProvider); bridge.emit(VpnStatus.on); await tester.pump(); await tester.pump(); await tester.pump(); } testWidgets('路径A:意外内核掉线(非用户主动)→ 置「节点异常」提示', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge); // 节点都 up,排除路径B addTearDown(bridge.dispose); addTearDown(c.dispose); await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); await driveOn(tester, c, bridge); expect(c.read(connectionProvider).phase, VpnPhase.on); bridge.emit(VpnStatus.off); // 模拟节点数据面死 → REALITY 断 → 内核自报 off await tester.pump(); final st = c.read(connectionProvider); expect(st.phase, VpnPhase.off, reason: '内核掉线应回 off'); expect(st.error, t.nodeUnhealthyError, reason: '非用户主动掉线应给「节点异常」提示'); }); testWidgets('路径B:服务端判当前节点 down(智能)→ 切到其他可用节点', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge, nodes: _StubNodesHKDown.new); addTearDown(bridge.dispose); addTearDown(c.dispose); await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); // 默认智能:有效节点取最优 HK,被判 down → 应切到 JP。 await driveOn(tester, c, bridge); expect(c.read(selectedNodeCodeProvider), 'JP', reason: '智能模式应自动切到其他可用节点'); }); testWidgets('路径B:服务端判当前节点 down(手动)→ 断开 + 提示,不自动换', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge, nodes: _StubNodesHKDown.new); addTearDown(bridge.dispose); addTearDown(c.dispose); await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动选定 HK(已 down) await driveOn(tester, c, bridge); final st = c.read(connectionProvider); expect(st.phase, VpnPhase.off, reason: '手动模式节点 down 应断开'); expect(st.error, t.nodeUnhealthyError, reason: '应给节点异常提示'); expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点'); }); testWidgets('节点健康 → 保持连接,不触发任何动作', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge); // 都 up addTearDown(bridge.dispose); addTearDown(c.dispose); await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); c.read(selectedNodeCodeProvider.notifier).select('HK'); await driveOn(tester, c, bridge); for (var i = 0; i < 3; i++) { await tester.pump(const Duration(seconds: 15)); await tester.pump(); } expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '健康时应保持连接'); expect(c.read(selectedNodeCodeProvider), 'HK'); bridge.emit(VpnStatus.off); // 收尾:停看门狗/计时器 await tester.pump(); }); }