import 'package:fl_clash/common/app_ports.dart'; import 'package:fl_clash/common/cidr.dart'; import 'package:fl_clash/common/constant.dart'; import 'package:fl_clash/common/system.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/app.dart'; import 'package:fl_clash/providers/config.dart'; import 'package:fl_clash/providers/database.dart'; import 'package:fl_clash/providers/state.dart'; import 'package:fl_clash/ui/color.dart'; import 'package:fl_clash/views/navigation.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:riverpod/riverpod.dart'; import '../helpers/test_profiles.dart'; void main() { late ProviderContainer container; setUp(() { navigationPort = navigation; addTearDown(() => navigationPort = null); container = ProviderContainer( overrides: [profilesProvider.overrideWith(TestProfiles.new)], ); }); tearDown(() { container.dispose(); }); test('group derivation sanitizes runtime state and respects clash mode', () { final groups = [ const Group( name: 'Visible', type: GroupType.Selector, now: 'Selected', hidden: false, all: [Proxy(name: 'Selected', type: 'Direct', now: 'runtime')], ), const Group(name: 'Hidden', type: GroupType.Selector, hidden: true), Group( name: GroupName.GLOBAL.name, type: GroupType.Selector, hidden: true, ), ]; container.read(groupsProvider.notifier).update((_) => groups); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); final ruleGroups = container.read(currentGroupsStateProvider).value; expect(ruleGroups.map((group) => group.name), ['Visible']); expect(ruleGroups.single.now, isEmpty); expect(ruleGroups.single.all.single.now, isEmpty); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.global)); expect(container.read(currentGroupsStateProvider).value, hasLength(3)); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.direct)); expect(container.read(currentGroupsStateProvider).value, isEmpty); }); test('a GLOBAL group of the profile\'s own shows in rule mode', () { container .read(groupsProvider.notifier) .update( (_) => [ Group( name: GroupName.GLOBAL.name, type: GroupType.Selector, hidden: false, ), ], ); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); expect( container .read(currentGroupsStateProvider) .value .map((group) => group.name), [GroupName.GLOBAL.name], ); }); test('group derivation ignores a change of selection only', () { Group group(String now, String nestedNow) => Group( name: 'Visible', type: GroupType.Selector, now: now, hidden: false, all: [ const Proxy(name: 'Node', type: 'Direct'), Proxy(name: 'Auto', type: 'URLTest', now: nestedNow), ], ); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); container.read(groupsProvider.notifier).update((_) => [group('Node', 'a')]); var notified = 0; container.listen(currentGroupsStateProvider, (_, _) => notified++); container.read(groupsProvider.notifier).update((_) => [group('Auto', 'b')]); container.read(currentGroupsStateProvider); expect(notified, 0); }); test('hiding timed-out nodes leaves the rest of the group alone', () { const testUrl = 'https://default.test'; container .read(groupsProvider.notifier) .update( (_) => [ const Group( name: 'Visible', type: GroupType.Selector, hidden: false, all: [ Proxy(name: 'fast', type: 'ss'), Proxy(name: 'slow', type: 'ss'), ], ), ], ); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); container .read(appSettingProvider.notifier) .update((state) => state.copyWith(testUrl: testUrl)); container .read(delayDataSourceProvider.notifier) .setDelay(const Delay(url: testUrl, name: 'slow', value: -1)); expect( container.read(visibleGroupsStateProvider).value.single.all, hasLength(2), ); container .read(proxiesStyleSettingProvider.notifier) .update((state) => state.copyWith(hideTimeoutProxies: true)); expect( container .read(visibleGroupsStateProvider) .value .single .all .map((proxy) => proxy.name), ['fast'], ); }); test('hidden timed-out nodes follow test batches and cleared delays', () { const testUrl = 'https://default.test'; container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); container .read(appSettingProvider.notifier) .update((state) => state.copyWith(testUrl: testUrl)); container .read(proxiesStyleSettingProvider.notifier) .update((state) => state.copyWith(hideTimeoutProxies: true)); void setGroup(String now) { container .read(groupsProvider.notifier) .update( (_) => [ Group( name: 'Visible', type: GroupType.Selector, now: now, hidden: false, all: const [ Proxy(name: 'a', type: 'ss'), Proxy(name: 'b', type: 'ss'), Proxy(name: 'c', type: 'ss'), ], ), ], ); } Iterable visibleNames() => container .read(visibleGroupsStateProvider) .value .single .all .map((proxy) => proxy.name); setGroup('a'); expect(visibleNames(), ['a', 'b', 'c']); container .read(delayDataSourceProvider.notifier) .setDelay(const Delay(url: testUrl, name: 'b', value: -1)); setGroup('c'); expect(visibleNames(), ['a', 'b', 'c']); container.read(sortNumProvider.notifier).add(); expect(visibleNames(), ['a', 'c']); container .read(delayDataSourceProvider.notifier) .setDelay(const Delay(url: testUrl, name: 'c', value: -1)); setGroup('a'); expect(visibleNames(), ['a', 'c']); container.read(delayDataSourceProvider.notifier).value = {}; expect(visibleNames(), ['a', 'b', 'c']); }); test('hiding timed-out nodes keeps the core pick, never empties a group', () { const testUrl = 'https://default.test'; const nodes = [Proxy(name: 'a', type: 'ss'), Proxy(name: 'b', type: 'ss')]; container .read(groupsProvider.notifier) .update( (_) => [ const Group( name: 'Auto', type: GroupType.URLTest, now: 'b', hidden: false, all: nodes, ), const Group( name: 'Balance', type: GroupType.LoadBalance, hidden: false, all: nodes, ), ], ); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); container .read(appSettingProvider.notifier) .update((state) => state.copyWith(testUrl: testUrl)); for (final node in nodes) { container .read(delayDataSourceProvider.notifier) .setDelay(Delay(url: testUrl, name: node.name, value: -1)); } container .read(proxiesStyleSettingProvider.notifier) .update((state) => state.copyWith(hideTimeoutProxies: true)); final groups = container.read(visibleGroupsStateProvider).value; expect(groups.first.all.map((proxy) => proxy.name), ['b']); expect(groups.last.all.map((proxy) => proxy.name), ['a', 'b']); }); test('hiding timed-out nodes keeps a group listed in another', () { const testUrl = 'https://default.test'; container .read(groupsProvider.notifier) .update( (_) => const [ Group( name: 'Proxy', type: GroupType.Selector, now: 'a', hidden: false, all: [ Proxy(name: 'a', type: 'ss'), Proxy(name: 'HK', type: 'Selector'), ], ), Group( name: 'HK', type: GroupType.Selector, now: 'b', hidden: true, all: [Proxy(name: 'b', type: 'ss')], ), ], ); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); container .read(appSettingProvider.notifier) .update((state) => state.copyWith(testUrl: testUrl)); container .read(delayDataSourceProvider.notifier) .setDelay(const Delay(url: testUrl, name: 'b', value: -1)); container .read(proxiesStyleSettingProvider.notifier) .update((state) => state.copyWith(hideTimeoutProxies: true)); expect( container .read(visibleGroupsStateProvider) .value .single .all .map((proxy) => proxy.name), ['a', 'HK'], ); }); test('a search whose matches all timed out keeps them and their group', () { const testUrl = 'https://default.test'; container .read(groupsProvider.notifier) .update( (_) => const [ Group( name: 'Proxy', type: GroupType.Selector, now: 'JP', hidden: false, all: [ Proxy(name: 'HK 01', type: 'ss'), Proxy(name: 'HK 02', type: 'ss'), Proxy(name: 'JP', type: 'ss'), ], ), ], ); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); container .read(appSettingProvider.notifier) .update((state) => state.copyWith(testUrl: testUrl)); container .read(proxiesStyleSettingProvider.notifier) .update((state) => state.copyWith(hideTimeoutProxies: true)); final delays = container.read(delayDataSourceProvider.notifier); delays.setDelay(const Delay(url: testUrl, name: 'HK 01', value: -1)); delays.setDelay(const Delay(url: testUrl, name: 'HK 02', value: -1)); List names(String query) => [ for (final proxy in container.read(filterGroupsStateProvider(query)).value.single.all) proxy.name, ]; expect(names(''), ['JP']); expect(names('hk'), ['HK 01', 'HK 02']); delays.setDelay(const Delay(url: testUrl, name: 'HK 02', value: 80)); container.read(sortNumProvider.notifier).add(); expect(names('hk'), ['HK 02']); }); test('navigation providers select items for width and current page', () { container .read(viewSizeProvider.notifier) .update((_) => Size(maxMobileWidth.toDouble(), 800)); final mobile = container.read(currentNavigationItemsStateProvider).value; expect( mobile.map((item) => item.label), containsAll([PageLabel.dashboard, PageLabel.profiles, PageLabel.tools]), ); expect( mobile.map((item) => item.label), isNot(contains(PageLabel.connections)), ); container .read(viewSizeProvider.notifier) .update((_) => const Size(1200, 800)); container .read(currentPageLabelProvider.notifier) .toPage(PageLabel.connections); final desktop = container.read(navigationStateProvider); expect(desktop.viewMode, ViewMode.desktop); expect(desktop.currentIndex, greaterThan(0)); expect( desktop.navigationItems[desktop.currentIndex].label, PageLabel.connections, ); container .read(currentPageLabelProvider.notifier) .toPage(PageLabel.resources); expect(container.read(navigationStateProvider).currentIndex, 0); }); test('layout and page state providers compose their dependencies', () { final profile = Profile.normal(label: 'Primary'); _profiles(container).replace([profile]); container.read(currentProfileIdProvider.notifier).update((_) => profile.id); container .read(viewSizeProvider.notifier) .update((_) => const Size(1000, 800)); container.read(sideWidthProvider.notifier).update((_) => 200); final profiles = container.read(profilesStateProvider); expect(profiles.profiles.single.label, 'Primary'); expect(profiles.currentProfileId, profile.id); final dashboard = container.read(dashboardStateProvider); expect(dashboard.dashboardWidgets, isNotEmpty); }); test( 'proxy list and tab providers filter groups and preserve selections', () { final profile = Profile.normal().copyWith( currentGroupName: 'Group B', unfoldSet: {'Group A'}, ); final groups = [ const Group( name: 'Group A', type: GroupType.Selector, hidden: false, all: [ Proxy(name: 'Alpha', type: 'Direct'), Proxy(name: 'Beta', type: 'Direct'), ], ), const Group( name: 'Group B', type: GroupType.URLTest, hidden: false, testUrl: 'https://group.test', all: [Proxy(name: 'Gamma', type: 'Direct')], ), ]; _profiles(container).replace([profile]); container .read(currentProfileIdProvider.notifier) .update((_) => profile.id); container.read(groupsProvider.notifier).update((_) => groups); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); container .read(viewSizeProvider.notifier) .update((_) => const Size(900, 800)); expect(container.read(filterGroupsStateProvider('')).value, hasLength(2)); final filtered = container.read(filterGroupsStateProvider('ALP')).value; expect(filtered, hasLength(1)); expect(filtered.single.all.single.name, 'Alpha'); container .read(queryProvider(QueryTag.proxies).notifier) .update((_) => 'ga'); final list = container.read(proxiesListStateProvider); expect(list.groups.single.name, 'Group B'); expect(list.currentUnfoldSet, {'Group A'}); final tab = container.read(proxiesTabStateProvider); expect(tab.currentGroupName, 'Group B'); expect(tab.groups.single.all.single.name, 'Gamma'); final controller = container.read(proxiesTabControllerStateProvider); expect(controller.groupNames, ['Group B']); expect(controller.currentGroupName, 'Group B'); }, ); test('proxy search matches the proxy type and needs every term', () { container .read(groupsProvider.notifier) .update( (_) => const [ Group( name: 'Group A', type: GroupType.Selector, hidden: false, all: [ Proxy(name: 'HK 01', type: 'Vmess'), Proxy(name: 'HK 02', type: 'Trojan'), Proxy(name: 'JP 01', type: 'Trojan'), ], ), ], ); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(mode: Mode.rule)); List names(String query) => [ for (final proxy in container.read(filterGroupsStateProvider(query)).value.single.all) proxy.name, ]; expect(names('trojan'), ['HK 02', 'JP 01']); expect(names('hk TROJAN'), ['HK 02']); expect(container.read(filterGroupsStateProvider('hk jp')).value, isEmpty); }); test('runtime, VPN, tray, and DNS states follow live state', () async { container .read(runTimeProvider.notifier) .update((_) => DateTime(2026).millisecondsSinceEpoch); container .read(networkSettingProvider.notifier) .update( (state) => state.copyWith( systemProxy: false, bypassDomain: const ['localhost'], autoSetSystemDns: true, ), ); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( mixedPort: 8899, mode: Mode.global, tun: state.tun.copyWith(enable: true), ), ); expect(container.read(shouldPatchSystemDnsProvider), isFalse); container .read(authorizedTunEnableProvider.notifier) .update((_) => TunAuthorizationState.authorized); final proxy = container.read(proxyStateProvider); expect(proxy.isStart, isTrue); expect(proxy.systemProxy, isFalse); expect(proxy.bassDomain, ['localhost']); expect(proxy.port, 8899); expect(container.read(isStartProvider), isTrue); final tray = container.read(trayStateProvider); expect(tray.mode, Mode.global); expect(tray.port, 8899); expect(tray.tunEnable, isTrue); expect(tray.isStart, isTrue); final vpn = await container.read(vpnOptionsProvider.future); expect(vpn.stack, container.read(patchClashConfigProvider).tun.stack.name); expect(vpn.port, 8899); expect(vpn.bypassDomain, ['localhost']); expect(container.read(shouldPatchSystemDnsProvider), isTrue); container .read(networkSettingProvider.notifier) .update((state) => state.copyWith(autoSetSystemDns: false)); expect(container.read(shouldPatchSystemDnsProvider), isFalse); container .read(networkSettingProvider.notifier) .update((state) => state.copyWith(autoSetSystemDns: true)); container .read(authorizedTunEnableProvider.notifier) .update((_) => TunAuthorizationState.unauthorized); expect(container.read(shouldPatchSystemDnsProvider), isFalse); container.read(excludeSSIDsProvider.notifier).update((_) => ['Office']); container.read(currentSSIDProvider.notifier).update((_) => 'Office'); expect(container.read(suspendProvider), isTrue); expect(container.read(proxyStateProvider).isStart, isFalse); }); test('safe mode keeps every host integration off', () async { await AppLocalizations.load(const Locale('en')); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), safeModeProvider.overrideWithValue(true), ], ); addTearDown(scoped.dispose); scoped.listen(sharedStateProvider, (_, _) {}); scoped.read(runTimeProvider.notifier).update((_) => 1); scoped .read(networkSettingProvider.notifier) .update( (state) => state.copyWith(systemProxy: true, autoSetSystemDns: true), ); scoped .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith(tun: state.tun.copyWith(enable: true)), ); scoped .read(authorizedTunEnableProvider.notifier) .update((_) => TunAuthorizationState.authorized); scoped .read(vpnSettingProvider.notifier) .update((state) => state.copyWith(enable: true, systemProxy: true)); scoped .read(appSettingProvider.notifier) .update((state) => state.copyWith(showTrayTitle: true)); scoped.listen(trayStateProvider, (_, _) {}); scoped .read(hotKeyActionsProvider.notifier) .update( (_) => const [ HotKeyAction( action: HotAction.view, key: 1, modifiers: {KeyboardModifier.control}, ), ], ); final tray = scoped.read(trayStateProvider); expect(tray.safeMode, isTrue); expect(tray.showTrayTitle, isTrue); expect(tray.hotKeys.keys, [HotAction.view]); expect(scoped.read(trayTitleStateProvider).showTrayTitle, isTrue); expect(scoped.read(shouldPatchSystemDnsProvider), isFalse); await scoped.read(vpnOptionsProvider.future); final vpnOptions = scoped.read(sharedStateProvider).vpnOptions!; expect(vpnOptions.enable, isFalse); expect(vpnOptions.systemProxy, isFalse); }); test('selection and delay providers resolve groups and profile state', () { final profile = Profile.normal().copyWith( selectedMap: {'Selector': 'Leaf'}, unfoldSet: {'Selector'}, ); const groups = [ Group( name: 'Selector', type: GroupType.Selector, all: [Proxy(name: 'Leaf', type: 'Direct')], ), ]; _profiles(container).replace([profile]); container.read(currentProfileIdProvider.notifier).update((_) => profile.id); container.read(groupsProvider.notifier).update((_) => groups); container .read(delayDataSourceProvider.notifier) .setDelay( const Delay( name: 'Leaf', url: 'https://www.gstatic.com/generate_204', value: 42, ), ); expect(container.read(selectedMapProvider), {'Selector': 'Leaf'}); expect(container.read(unfoldSetProvider), {'Selector'}); expect(container.read(proxyNameProvider('Selector')), 'Leaf'); expect(container.read(selectedProxyNameProvider('Selector')), 'Leaf'); expect( container.read(realSelectedProxyStateProvider('Selector')).proxyName, 'Leaf', ); expect(container.read(delayProvider(proxyName: 'Selector')), 42); }); test('tray delays follow the listed groups and their test URLs', () { const probeUrl = 'https://probe.example/204'; container .read(groupsProvider.notifier) .update( (_) => const [ Group( name: 'Selector', type: GroupType.Selector, hidden: false, all: [ Proxy(name: 'Leaf', type: 'Direct'), Proxy(name: 'Idle', type: 'Direct'), ], ), Group( name: 'Probe', type: GroupType.URLTest, hidden: false, testUrl: probeUrl, all: [Proxy(name: 'Leaf', type: 'Direct')], ), ], ); container.read(delayDataSourceProvider.notifier) ..setDelay(const Delay(name: 'Leaf', url: defaultTestUrl, value: 42)) ..setDelay(const Delay(name: 'Leaf', url: probeUrl, value: -1)); expect(container.read(trayDelaysProvider), { 'Selector': {'Leaf': 42}, 'Probe': {'Leaf': -1}, }); }); test('tray hotkeys leave out bindings that cannot fire', () { container.listen(trayStateProvider, (_, _) {}); container .read(hotKeyActionsProvider.notifier) .update( (_) => const [ HotKeyAction( action: HotAction.view, key: 1, modifiers: {KeyboardModifier.control}, ), HotKeyAction( action: HotAction.start, key: 2, modifiers: {KeyboardModifier.capsLock}, ), HotKeyAction( action: HotAction.tun, key: 3, modifiers: {KeyboardModifier.control}, ), ], ); container.read(hotKeyFailuresProvider.notifier).value = { HotAction.tun: 'taken', }; expect(container.read(trayStateProvider).hotKeys.keys, [HotAction.view]); }); test('theme and simple derived providers cover fallback branches', () { expect(container.read(currentBrightnessProvider), Brightness.dark); container .read(systemBrightnessProvider.notifier) .update((_) => Brightness.light); container .read(themeSettingProvider.notifier) .update((state) => state.copyWith(themeMode: ThemeMode.system)); expect(container.read(currentBrightnessProvider), Brightness.light); final fallback = container.read( genColorSchemeProvider(Brightness.light, ignoreConfig: true), ); expect(fallback.brightness, Brightness.light); final explicit = container.read( genColorSchemeProvider(Brightness.dark, color: Colors.purple), ); expect(explicit.brightness, Brightness.dark); expect( container.read(realTestUrlProvider('https://custom.test')), 'https://custom.test', ); }); test('a color scheme outlives the provider that generated it', () { final provider = genColorSchemeProvider( Brightness.dark, color: Colors.teal, ); final first = container.read(provider); container.invalidate(provider); expect(container.read(provider), same(first)); container .read(themeSettingProvider.notifier) .update((state) => state.copyWith(colorStyle: ColorStyle.soft)); expect(container.read(provider), isNot(same(first))); }); test('hotkey, profile, and extend providers expose defaults', () { const action = HotKeyAction( action: HotAction.start, key: 1, modifiers: {KeyboardModifier.control}, ); container.read(hotKeyActionsProvider.notifier).update((_) => [action]); expect(container.read(getHotKeyActionProvider(HotAction.start)), action); expect( container.read(getHotKeyActionProvider(HotAction.tun)).action, HotAction.tun, ); final profile = Profile.normal().copyWith(extendType: ExtendType.script); _profiles(container).replace([profile]); expect(container.read(profileProvider(profile.id)), profile); expect(container.read(extendTypeProvider(profile.id)), ExtendType.script); expect(container.read(extendTypeProvider(-1)), ExtendType.standard); expect( container.read(accessControlStateProvider), const AccessControlProps(), ); }); test( 'a custom profile is no proxy provider and has no config file', () async { final subscription = Profile.normal(label: 'Sub'); final file = Profile.normal(label: 'File'); final custom = Profile.custom(label: 'Mine'); _profiles(container).replace([subscription, file, custom]); expect(container.read(profileProvidersProvider), { 'Sub': subscription.id, 'File': file.id, }); expect( await container.read(clashConfigProvider(custom.id).future), const ClashConfig(), ); }, ); test('shared state hands the VPN service the resolved route list', () async { await AppLocalizations.load(const Locale('en')); container.listen(sharedStateProvider, (_, _) {}); container .read(networkSettingProvider.notifier) .update((state) => state.copyWith(bypassPrivateRoute: false)); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( tun: state.tun.copyWith(routeAddress: const ['10.0.0.0/8']), ), ); await container.read(vpnOptionsProvider.future); expect(container.read(sharedStateProvider).vpnOptions?.routeAddress, [ '10.0.0.0/8', '::/0', ]); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith(tun: state.tun.copyWith(routeAddress: [])), ); container .read(networkSettingProvider.notifier) .update((state) => state.copyWith(bypassPrivateRoute: true)); await container.read(vpnOptionsProvider.future); expect( container.read(sharedStateProvider).vpnOptions?.routeAddress, subtractCidrs(const [], privateRouteAddress, wholeWhenEmpty: true), ); }); test('only the route lists recompute the VPN routes', () async { final computed = ?>[]; container.listen( vpnRouteAddressProvider, (_, next) => computed.add(next.value), fireImmediately: true, ); await container.read(vpnRouteAddressProvider.future); computed.clear(); container .read(vpnSettingProvider.notifier) .update((state) => state.copyWith(ipv6: !state.ipv6)); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( mixedPort: state.mixedPort + 1, tun: state.tun.copyWith(mtu: state.tun.mtu - 1), ), ); await container.read(vpnOptionsProvider.future); expect(computed, isEmpty); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( tun: state.tun.copyWith(routeExcludeAddress: ['10.0.0.0/8']), ), ); await container.read(vpnRouteAddressProvider.future); expect(computed.last, isNot(contains('0.0.0.0/0'))); }); test('shared state keeps the last routes while new ones compute', () async { await AppLocalizations.load(const Locale('en')); container.listen(sharedStateProvider, (_, _) {}); container .read(networkSettingProvider.notifier) .update((state) => state.copyWith(bypassPrivateRoute: false)); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( tun: state.tun.copyWith(routeAddress: const ['10.0.0.0/8']), ), ); await container.read(vpnOptionsProvider.future); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( tun: state.tun.copyWith(routeAddress: const ['172.16.0.0/12']), ), ); expect(container.read(sharedStateProvider).vpnOptions?.routeAddress, [ '10.0.0.0/8', '::/0', ]); await container.read(vpnOptionsProvider.future); expect(container.read(sharedStateProvider).vpnOptions?.routeAddress, [ '172.16.0.0/12', '::/0', ]); }); // VpnService.setHttpProxy cannot carry credentials. test('local authentication withholds the VPN system proxy', () async { await AppLocalizations.load(const Locale('en')); container.listen(sharedStateProvider, (_, _) {}); await container.read(vpnOptionsProvider.future); expect(container.read(sharedStateProvider).vpnOptions?.systemProxy, true); container .read(networkSettingProvider.notifier) .update( (state) => state.copyWith( authentication: const AuthenticationProps( enable: true, username: 'user', password: 'pass', ), ), ); await container.read(vpnOptionsProvider.future); expect(container.read(sharedStateProvider).vpnOptions?.systemProxy, false); expect(container.read(updateParamsProvider).authentication, ['user:pass']); container .read(networkSettingProvider.notifier) .update( (state) => state.copyWith( authentication: const AuthenticationProps(enable: false), ), ); await container.read(vpnOptionsProvider.future); expect(container.read(sharedStateProvider).vpnOptions?.systemProxy, true); expect(container.read(updateParamsProvider).authentication, isEmpty); }); test('the setup patch changes only with what a re-apply carries', () { container.listen(setupPatchProvider, (_, _) {}); final initial = container.read(setupPatchProvider); container .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( mode: Mode.global, mixedPort: 8899, tun: state.tun.copyWith(enable: true), ), ); expect(container.read(setupPatchProvider), initial); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(hosts: const {'a.test': '1.1.1.1'})); final withHosts = container.read(setupPatchProvider); expect(withHosts, isNot(initial)); container .read(networkSettingProvider.notifier) .update( (state) => state.copyWith(appendSystemDns: !state.appendSystemDns), ); final withSystemDns = container.read(setupPatchProvider); expect(withSystemDns, isNot(withHosts)); container .read(networkSettingProvider.notifier) .update( (state) => state.copyWith(bypassPrivateRoute: !state.bypassPrivateRoute), ); expect( container.read(setupPatchProvider), system.isDesktop ? isNot(withSystemDns) : withSystemDns, ); }); test('shared state carries the notification stop action switch', () async { await AppLocalizations.load(const Locale('en')); container.listen(sharedStateProvider, (_, _) {}); expect(container.read(sharedStateProvider).showStopAction, true); container .read(appSettingProvider.notifier) .update((state) => state.copyWith(showNotificationStopAction: false)); expect(container.read(sharedStateProvider).showStopAction, false); }); test('shared state follows the locale whose messages are loaded', () async { container.listen(sharedStateProvider, (_, _) {}); await AppLocalizations.load(const Locale('en')); container.read(loadedLocaleProvider.notifier).value = const Locale('en'); final en = container.read(sharedStateProvider); await AppLocalizations.load(const Locale('zh', 'CN')); addTearDown(() => AppLocalizations.load(const Locale('en'))); expect(container.read(sharedStateProvider).stopText, en.stopText); container.read(loadedLocaleProvider.notifier).value = const Locale( 'zh', 'CN', ); final zh = container.read(sharedStateProvider); expect(zh.stopText, isNot(en.stopText)); expect(zh.stopTip, isNot(en.stopTip)); expect(zh.startTip, isNot(en.startTip)); }); } TestProfiles _profiles(ProviderContainer container) { return container.read(profilesProvider.notifier) as TestProfiles; }