// node_connect_confirm_test.dart — 节点列表防误触:已连接时点其他节点先弹确认。 // // off → 点节点直接选(跳连接页,无破坏性,不弹确认)。 // on → 点其他节点弹确认框,取消则不切换(防误碰把当前连接切走)。 import 'package:flutter/material.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_mock.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; import 'package:pangolin_vpn/l10n/app_text.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/models/node.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; import 'package:pangolin_vpn/screens/nodes_page.dart'; import 'package:pangolin_vpn/state/app_providers.dart'; import 'package:pangolin_vpn/state/connection_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; import 'package:pangolin_vpn/widgets/smart_select_card.dart'; import '../helpers/harness.dart'; // 固定两节点的桩,绕过远端拉取与 10s 限流刷新。 class _StubNodes extends NodesNotifier { @override Future> build() async => _nodes; @override Future refresh() async {} } const _nodes = [ 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), ]; // localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。 class _FixedLocale extends LocaleNotifier { _FixedLocale(AppLang lang) { state = lang; } } void main() { setUpAll(disableGoogleFontsFetching); const t = StringsZh(); ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [ localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)), nodesProvider.overrideWith(_StubNodes.new), vpnBridgeProvider.overrideWithValue(bridge), ]); Future pump(WidgetTester tester, ProviderContainer container) async { await tester.binding.setSurfaceSize(const Size(440, 900)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(UncontrolledProviderScope( container: container, child: MaterialApp( debugShowCheckedModeBanner: false, theme: PangolinTheme.light, home: Scaffold(body: NodesPage(isWide: false, onPicked: () {})), ), )); await tester.pumpAndSettle(); } testWidgets('未连接:点节点直接选,不弹确认', (tester) async { final container = makeContainer(VpnBridgeMock()); addTearDown(container.dispose); await pump(tester, container); expect(find.text('香港'), findsOneWidget); await tester.tap(find.text('香港')); await tester.pumpAndSettle(); expect(find.text(t.nodeSwitchConfirm), findsNothing, reason: 'off 态不应弹确认'); expect(container.read(selectedNodeCodeProvider), 'HK', reason: '直接选中该节点'); }); testWidgets('已连接:点其他节点弹确认 → 取消不切换', (tester) async { final bridge = VpnBridgeMock(connectDelay: const Duration(milliseconds: 10)); final container = makeContainer(bridge); addTearDown(bridge.dispose); addTearDown(container.dispose); await pump(tester, container); // 创建控制器(订阅状态流)后驱动到 on;此后只用 pump(固定时长), // 不能 pumpAndSettle(连接计时器每秒触发,永不 settle)。 container.read(connectionProvider); await bridge.start('{}'); await tester.pump(const Duration(milliseconds: 15)); expect(container.read(connectionProvider).phase, VpnPhase.on); await tester.tap(find.text('东京')); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); // 弹窗入场 expect(find.text(t.nodeSwitchTitle('东京')), findsOneWidget, reason: 'on 态切换前先弹确认'); expect(find.text(t.nodeSwitchConfirm), findsOneWidget); expect(container.read(selectedNodeCodeProvider), kSmartNodeCode, reason: '弹窗期间还未切'); await tester.tap(find.text(t.devCancel)); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); // 弹窗退场 expect(container.read(selectedNodeCodeProvider), kSmartNodeCode, reason: '取消后维持原节点'); // 收尾:停桥 → 取消 stats 计时器 + 触发 off 回调取消 elapsed 计时器, // 否则 fake-async 在用例结束时报「Pending timers」。 await bridge.stop(); await tester.pump(); }); testWidgets('已连接:点智能选择卡也弹确认 → 取消不切换', (tester) async { final bridge = VpnBridgeMock(connectDelay: const Duration(milliseconds: 10)); final container = makeContainer(bridge); addTearDown(bridge.dispose); addTearDown(container.dispose); // 先选具体节点(否则默认就是智能选择),便于验证取消不切到智能。 container.read(selectedNodeCodeProvider.notifier).state = 'HK'; await pump(tester, container); container.read(connectionProvider); await bridge.start('{}'); await tester.pump(const Duration(milliseconds: 15)); expect(container.read(connectionProvider).phase, VpnPhase.on); await tester.tap(find.byType(SmartSelectCard)); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); expect(find.text(t.nodeSwitchTitle(t.smartSelect)), findsOneWidget, reason: '智能选择也应弹确认'); expect(container.read(selectedNodeCodeProvider), 'HK', reason: '弹窗期间未切到智能'); await tester.tap(find.text(t.devCancel)); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); expect(container.read(selectedNodeCodeProvider), 'HK', reason: '取消后维持原节点'); await bridge.stop(); await tester.pump(); }); }