feat(client): 节点列表防误触 — 已连接时切换节点先弹确认

未连接(off)点节点直接选→跳连接页(无破坏性,不弹框);已连接/连接中点其他
节点会断开重连,先弹确认框(标题含目标节点名),取消则维持当前连接。新增
nodeSwitchTitle/Body/Confirm 三条文案 + node_connect_confirm_test 覆盖 off/on 两路。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 10:11:20 +08:00
parent 6788501e4a
commit 73da0bc9a2
5 changed files with 156 additions and 2 deletions
@@ -0,0 +1,101 @@
// 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/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/connection_provider.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
import '../helpers/harness.dart';
// 固定两节点的桩,绕过远端拉取与 10s 限流刷新。
class _StubNodes extends NodesNotifier {
@override
Future<List<Node>> build() async => _nodes;
@override
Future<void> 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),
];
void main() {
setUpAll(disableGoogleFontsFetching);
const t = StringsZh();
ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [
nodesProvider.overrideWith(_StubNodes.new),
vpnBridgeProvider.overrideWithValue(bridge),
]);
Future<void> 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();
});
}