// routing_reconnect_test.dart — Task 9:分流档案保存成功后,若当前连接态为 on, // 应触发一次重连(disconnect→connect)使新规则生效;off 态不触发。 // // 复用 routing_provider_test.dart 的登录桩 + connection_controller_test.dart / // flow_connect_test.dart 的连接驱动范式(VpnBridgeMock + MockClient 假 fetchConfig)。 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/models/routing_profile.dart'; import 'package:pangolin_vpn/services/account_api.dart'; import 'package:pangolin_vpn/services/api_client.dart'; import 'package:pangolin_vpn/services/connect_api.dart'; import 'package:pangolin_vpn/services/token_store.dart'; import 'package:pangolin_vpn/state/account_providers.dart'; import 'package:pangolin_vpn/state/auth_provider.dart'; import 'package:pangolin_vpn/state/connection_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; import 'package:pangolin_vpn/state/routing_provider.dart'; /// 已登录的桩 TokenStore(仿 routing_provider_test.dart)。 class _LoggedInTokenStore implements TokenStore { const _LoggedInTokenStore(); @override Future saveTokens({required String access, required String refresh}) async {} @override Future loadAccessToken() async => 'test-token'; @override Future loadRefreshToken() async => 'test-refresh'; @override Future clear() async {} @override Future markOnboarded() async {} @override Future isOnboarded() async => true; @override Future saveLastEmail(String email) async {} @override Future loadLastEmail() async => null; } /// 假 AccountApi:桩 routingProfile()/saveRoutingProfile(),记录调用便于断言。 class _FakeAccountApi extends AccountApi { _FakeAccountApi() : super(ApiClient( baseUrl: 'http://test.local', getToken: () => null, refresh: () async => false, )); RoutingProfile initial = const RoutingProfile(); final List saved = []; @override Future routingProfile() async => initial; @override Future saveRoutingProfile(RoutingProfile p) async { saved.add(p); } } /// 计数版 VpnBridgeMock:记录 start() 被调用的次数,用于断言「是否重连过」 /// (重连 = disconnect→connect,即 start() 被再次调用)。 class _CountingBridge extends VpnBridgeMock { _CountingBridge() : super(connectDelay: const Duration(milliseconds: 10)); int startCount = 0; @override Future start(String configJson) async { startCount++; await super.start(configJson); } } const _readyNode = Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 18, uuid: 'hk-01'); Future _waitLoggedIn(ProviderContainer c) async { for (var i = 0; i < 60; i++) { if (c.read(authProvider).isLoggedIn) return; await Future.delayed(const Duration(milliseconds: 10)); } throw StateError('authProvider 未在预期时间内进入已登录态'); } 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)); } } ProviderContainer _makeContainer(_FakeAccountApi api, _CountingBridge bridge) { final mock = MockClient((req) async => http.Response('{"fake":"singbox-config"}', 200)); return ProviderContainer(overrides: [ tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()), accountApiProvider.overrideWithValue(api), effectiveNodeProvider.overrideWithValue(_readyNode), vpnBridgeProvider.overrideWithValue(bridge), connectApiFactoryProvider.overrideWithValue( (token) => ConnectApi(baseUrl: 'http://test.local', authToken: token, client: mock), ), ]); } void main() { test('连接态 on 时保存档案 → 触发重连(disconnect→connect)', () async { final api = _FakeAccountApi(); final bridge = _CountingBridge(); final c = _makeContainer(api, bridge); addTearDown(c.dispose); await _waitLoggedIn(c); await c.read(routingProfileProvider.future); // 先连上。 c.read(connectionProvider.notifier).toggle(); await _until(() => c.read(connectionProvider).phase == VpnPhase.on); expect(bridge.startCount, 1, reason: '首次连接应调用一次 start()'); // 保存档案(连接态 on)→ 应触发一次重连(再调 start())。 await c.read(routingProfileProvider.notifier).save(); await _until(() => bridge.startCount >= 2); expect(bridge.startCount, 2, reason: '保存成功且 on 态应重连一次(再次 start())'); // 重连期间应给出瞬态提示,并最终重新回到 on。 await _until(() => c.read(connectionProvider).phase == VpnPhase.on); expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '重连后应回到 on'); }); test('连接态 off 时保存档案 → 不触发重连', () async { final api = _FakeAccountApi(); final bridge = _CountingBridge(); final c = _makeContainer(api, bridge); addTearDown(c.dispose); await _waitLoggedIn(c); await c.read(routingProfileProvider.future); expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '未连接'); await c.read(routingProfileProvider.notifier).save(); // 给可能的(不应发生的)异步重连留出时间窗口。 await Future.delayed(const Duration(milliseconds: 100)); expect(bridge.startCount, 0, reason: 'off 态不应触发重连'); expect(c.read(connectionProvider).phase, VpnPhase.off); }); test('addRule 触发的乐观保存(on 态)同样重连', () async { final api = _FakeAccountApi(); final bridge = _CountingBridge(); final c = _makeContainer(api, bridge); addTearDown(c.dispose); await _waitLoggedIn(c); await c.read(routingProfileProvider.future); c.read(connectionProvider.notifier).toggle(); await _until(() => c.read(connectionProvider).phase == VpnPhase.on); expect(bridge.startCount, 1); const rule = RoutingRule(type: 'domain_suffix', value: 'x.com', action: 'direct'); await c.read(routingProfileProvider.notifier).addRule(rule); await _until(() => bridge.startCount >= 2); expect(bridge.startCount, 2, reason: 'addRule 内部落盘同样应触发重连'); }); }