From 76dbdcfa83cf01079f308624a52b74cd4d3c2209 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 12:13:40 +0800 Subject: [PATCH] =?UTF-8?q?test(client):=20L2=20=E7=82=B9=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E8=83=BD=E8=BF=9E=E4=B8=8A=20=E6=B5=81=E7=A8=8B=20+?= =?UTF-8?q?=20ConnectApi=20=E6=B3=A8=E5=85=A5(=E6=94=AF=E6=9F=B11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - connection_provider:加 connectApiFactoryProvider(原内部 new ConnectApi 改成 可注入工厂,支柱1 接缝即接口)——默认行为不变 - test/unit/flow_connect_test.dart:真 toggle → fetchConfig(注入 MockClient 回占位 config)→ bridge.start → statusStream 确认 → on → 再 toggle → off; 验证「非乐观翻转」红线(on 必由 bridge 确认) - 全量 unit/widget 47 全过(重构不破坏) Co-Authored-By: Claude Opus 4.8 --- client/lib/state/connection_provider.dart | 10 ++- client/test/unit/flow_connect_test.dart | 90 +++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 client/test/unit/flow_connect_test.dart diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 7c0d5be..7804fc7 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -136,7 +136,7 @@ class ConnectionController extends StateNotifier { Future doFetch() { final token = _ref.read(authProvider).accessToken ?? ''; _api?.dispose(); - _api = ConnectApi(baseUrl: kApiBaseUrl, authToken: token); + _api = _ref.read(connectApiFactoryProvider)(token); return _api!.fetchConfig( nodeId: nodeUuid, deviceId: _kDeviceId, @@ -215,3 +215,11 @@ final connectionProvider = final vpnStatsProvider = StreamProvider( (ref) => ref.watch(vpnBridgeProvider).statsStream, ); + +/// 按 authToken 造 ConnectApi 的工厂(支柱 1:接缝即接口)。默认走真控制面; +/// 测试 override 注入 MockClient,即可驱动「连接成功」路径而不打真网络。 +typedef ConnectApiFactory = ConnectApi Function(String authToken); + +final connectApiFactoryProvider = Provider( + (_) => (authToken) => ConnectApi(baseUrl: kApiBaseUrl, authToken: authToken), +); diff --git a/client/test/unit/flow_connect_test.dart b/client/test/unit/flow_connect_test.dart new file mode 100644 index 0000000..d7c848e --- /dev/null +++ b/client/test/unit/flow_connect_test.dart @@ -0,0 +1,90 @@ +// flow_connect_test.dart — L2 点连接能否连上(真 toggle → fetchConfig → bridge → on)。 +// +// 驱动真实 ConnectionController + 真实 ConnectApi(注入 MockClient,不打真网络) +// + VpnBridgeMock,验证你最初诉求「点击连接是否能连上」: +// toggle → connecting →(取配置 + bridge.start)→ statusStream 确认 → on → 再 toggle → off。 +// 关键红线:on 必须由 bridge.statusStream 确认后才置(非乐观翻转)。 +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/models/node.dart'; +import 'package:pangolin_vpn/services/connect_api.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/connection_provider.dart'; +import 'package:pangolin_vpn/state/nodes_provider.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +/// uuid 非空 = 就绪节点,_connect 会真正取配置 + 启动 bridge。 +const _readyNode = + Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 18, uuid: 'hk-01'); + +Future _until(bool Function() cond, + {Duration timeout = const Duration(seconds: 2)}) async { + final sw = Stopwatch()..start(); + while (!cond() && sw.elapsed < timeout) { + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +void main() { + test('点连接 → connecting → on → 再点 → off', () async { + // 假控制面:任何 /v1/nodes/.../connect 都回一份占位 config(200)。 + final mock = MockClient((req) async => http.Response('{"fake":"singbox-config"}', 200)); + + final c = ProviderContainer(overrides: [ + tokenStoreProvider.overrideWithValue(const _NullTokenStore()), + effectiveNodeProvider.overrideWithValue(_readyNode), + vpnBridgeProvider.overrideWithValue( + VpnBridgeMock(connectDelay: const Duration(milliseconds: 20)), + ), + connectApiFactoryProvider.overrideWithValue( + (token) => ConnectApi(baseUrl: 'http://test.local', authToken: token, client: mock), + ), + ]); + addTearDown(c.dispose); + + expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '初始 off'); + + // 点连接 + c.read(connectionProvider.notifier).toggle(); + await Future.delayed(Duration.zero); + expect(c.read(connectionProvider).phase, VpnPhase.connecting, + reason: 'toggle 立即进 connecting'); + + // 等取配置(fake)+ bridge.start + connectDelay → 内核回调 on + await _until(() => c.read(connectionProvider).phase == VpnPhase.on); + expect(c.read(connectionProvider).phase, VpnPhase.on, + reason: '连上:由 bridge.statusStream 确认后才置 on'); + expect(c.read(connectionProvider).error, isNull); + + // 再点 → 断开 + c.read(connectionProvider.notifier).toggle(); + await _until(() => c.read(connectionProvider).phase == VpnPhase.off); + expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '断开回 off'); + }); +}