import 'dart:async'; import 'dart:io'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/core/controller.dart'; import 'package:fl_clash/core/interface.dart'; import 'package:fl_clash/core/method.dart'; import 'package:fl_clash/core/spill.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/action.dart'; import 'package:fl_clash/providers/app.dart'; import 'package:fl_clash/providers/config.dart'; import 'package:fl_clash/providers/core.dart'; import 'package:fl_clash/providers/database.dart'; import 'package:fl_clash/providers/state.dart'; import 'package:fl_clash/state.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart'; import 'package:riverpod/riverpod.dart'; import 'package:yaml/yaml.dart'; import '../helpers/app_paths.dart'; import '../helpers/test_profiles.dart'; class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {} // checkAndUpdateAndCopy checks the file system before it refreshes, so its // failure tests need appPath to resolve to a real, writable directory. class _ListenerHandoffFailureSetupAction extends SetupAction { final List coreRunningCalls = []; @override Future setCoreRunning(bool running) async { coreRunningCalls.add(running); if (running) { throw StateError('listener handoff failed'); } return true; } } class _MessageFailureSetupAction extends SetupAction { final List coreRunningCalls = []; @override Future setCoreRunning(bool running) async { coreRunningCalls.add(running); return true; } } class TestCommonAction extends CommonAction { int trafficUpdates = 0; @override Future updateTraffic() async { trafficUpdates++; } } class TestSetupAction extends SetupAction { final List coreRunningCalls = []; final List> pendingCoreCalls = []; int trafficResets = 0; int applyProfileCalls = 0; final List appliedForce = []; bool blockCoreCalls = false; Error? coreRunningError; final Set unansweredCoreCalls = {}; int authorizeCalls = 0; AuthorizeCode authorizeResult = AuthorizeCode.none; @override Future authorizeCore() async { authorizeCalls++; return authorizeResult; } @override Future setCoreRunning(bool running) async { coreRunningCalls.add(running); if (blockCoreCalls) { final gate = Completer(); pendingCoreCalls.add(gate); await gate.future; } final error = coreRunningError; if (error != null) { throw error; } return !unansweredCoreCalls.contains(running); } @override void resetCoreTraffic() => trafficResets++; @override Future applyProfile({ bool silence = false, bool force = false, Future Function()? preloadInvoke, }) async { applyProfileCalls++; appliedForce.add(force); await preloadInvoke?.call(); return true; } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() { registerFallbackValue(const SetupParams(selectedMap: {}, testUrl: '')); registerFallbackValue(const SetupBase(path: '', overlay: '')); registerFallbackValue( const UpdateParams( tun: Tun(), mixedPort: 7890, allowLan: false, findProcessMode: FindProcessMode.off, mode: Mode.rule, logLevel: LogLevel.info, ipv6: false, tcpConcurrent: false, externalController: ExternalControllerStatus.close, unifiedDelay: false, ), ); }); late TestSetupAction action; late ProviderContainer container; setUp(() { action = TestSetupAction(); container = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => action), commonActionProvider.overrideWith(TestCommonAction.new), ], ); globalState.container = container; globalState.needInitStatus = true; container.read(setupActionProvider.notifier); }); tearDown(() async { action.blockCoreCalls = false; action.coreRunningError = null; action.unansweredCoreCalls.clear(); for (final gate in action.pendingCoreCalls) { if (!gate.isCompleted) { gate.complete(); } } await container.read(setupActionProvider.notifier).setRunning(false); container.dispose(); globalState.needInitStatus = true; }); void markInitialized() { container.read(initProvider.notifier).value = true; } group('setRunning gating', () { test('ignores a start request before initialization completes', () async { await container.read(setupActionProvider.notifier).setRunning(true); expect(action.coreRunningCalls, isEmpty); expect(container.read(runTimeProvider), isNull); }); test('an initialize request bypasses the init gate', () async { await container .read(setupActionProvider.notifier) .setRunning(true, initialize: true); expect(action.coreRunningCalls, [true]); expect(action.applyProfileCalls, 1); expect(globalState.needInitStatus, isFalse); }); test('safe mode runs the profile but never touches the listener', () async { final scopedAction = TestSetupAction(); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => scopedAction), commonActionProvider.overrideWith(TestCommonAction.new), safeModeProvider.overrideWithValue(true), ], ); addTearDown(scoped.dispose); globalState.container = scoped; scoped.read(setupActionProvider.notifier); await scoped .read(setupActionProvider.notifier) .setRunning(true, initialize: true); expect(scopedAction.coreRunningCalls, isEmpty); expect(scopedAction.applyProfileCalls, 1); expect(scoped.read(isStartProvider), isTrue); await scoped.read(setupActionProvider.notifier).setRunning(false); expect(scopedAction.coreRunningCalls, isEmpty); expect(scoped.read(isStartProvider), isFalse); }); test('starts the core once initialization is done', () async { markInitialized(); await container.read(setupActionProvider.notifier).setRunning(true); expect(action.coreRunningCalls, [true]); expect(container.read(runTimeProvider), isNotNull); }); test('a stop request is never gated on initialization', () async { await container.read(setupActionProvider.notifier).setRunning(false); expect(action.coreRunningCalls, [false]); }); }); group('run failures', () { test('a start the core rejects stops reporting a run time', () async { markInitialized(); action.coreRunningError = StateError('start failed'); await expectLater( container.read(setupActionProvider.notifier).setRunning(true), throwsStateError, ); expect(container.read(runTimeProvider), isNull); }); test( 'a stop the core rejects keeps the run time it started with', () async { markInitialized(); await container.read(setupActionProvider.notifier).setRunning(true); action.coreRunningError = StateError('stop failed'); await expectLater( container.read(setupActionProvider.notifier).setRunning(false), throwsStateError, ); expect(container.read(runTimeProvider), isNotNull); }, ); final unanswered = isA().having( (error) => error.code, 'code', 'no_response', ); test('a start the core leaves unanswered fails and is undone', () async { markInitialized(); action.unansweredCoreCalls.add(true); await expectLater( container.read(setupActionProvider.notifier).setRunning(true), throwsA(unanswered), ); expect(container.read(isStartProvider), isFalse); expect(container.read(runTimeProvider), isNull); expect(container.read(runningVpnOptionsProvider), isNull); }); test('a stop the core leaves unanswered fails but stays stopped', () async { markInitialized(); final setup = container.read(setupActionProvider.notifier); await setup.setRunning(true); action.unansweredCoreCalls.add(false); await expectLater(setup.setRunning(false), throwsA(unanswered)); expect(container.read(isStartProvider), isFalse); expect(container.read(runTimeProvider), isNull); expect(container.read(runningVpnOptionsProvider), isNull); expect(action.trafficResets, 1); }); test( 'an initial start the core leaves unanswered is stopped again', () async { action.unansweredCoreCalls.add(true); final applied = await container .read(setupActionProvider.notifier) .setRunning(true, initialize: true); expect(applied, isFalse); expect(action.coreRunningCalls, [true, false]); expect(container.read(isStartProvider), isFalse); expect(container.read(runningVpnOptionsProvider), isNull); }, ); }); group('stop cleanup', () { test('resets traffic counters', () async { markInitialized(); await container.read(setupActionProvider.notifier).setRunning(true); container.read(totalTrafficProvider.notifier).value = const Traffic( up: 10, down: 20, ); await container.read(setupActionProvider.notifier).setRunning(false); expect(action.trafficResets, 1); expect(container.read(trafficsProvider).list, isEmpty); expect(container.read(totalTrafficProvider), const Traffic()); expect(container.read(runTimeProvider), isNull); }); }); group('run time ticker', () { late ProviderContainer scoped; // Built inside each test, on its fake clock: futures made there never // settle in the shared tearDown. SetupAction buildScoped() { scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(TestSetupAction.new), commonActionProvider.overrideWith(TestCommonAction.new), // Bypassing private addresses computes the routes in Isolate.run, which // the fake clock never lets finish. networkSettingProvider.overrideWithBuild( (_, _) => const NetworkProps(bypassPrivateRoute: false), ), ], ); addTearDown(scoped.dispose); globalState.container = scoped; scoped .read(appSettingProvider.notifier) .update((state) => state.copyWith(showTrayTitle: false)); scoped.read(initProvider.notifier).value = true; return scoped.read(setupActionProvider.notifier); } int trafficUpdates() => (scoped.read(commonActionProvider.notifier) as TestCommonAction) .trafficUpdates; void setVisible(bool visible) => scoped.read(appVisibleProvider.notifier).value = visible; testWidgets('reads traffic only while the app is visible', (tester) async { final setup = buildScoped(); await setup.setRunning(true); expect(trafficUpdates(), 1); await tester.pump(const Duration(seconds: 2)); expect(trafficUpdates(), 3); setVisible(false); await tester.pump(const Duration(seconds: 5)); expect(trafficUpdates(), 3); expect(scoped.read(isStartProvider), isTrue); setVisible(true); await tester.pump(); expect(trafficUpdates(), 4); await tester.pump(const Duration(seconds: 1)); expect(trafficUpdates(), 5); await setup.setRunning(false); }); testWidgets('a start while hidden waits for the app to be shown', ( tester, ) async { final setup = buildScoped(); setVisible(false); await setup.setRunning(true); await tester.pump(const Duration(seconds: 3)); expect(scoped.read(runTimeProvider), isNotNull); expect(trafficUpdates(), 0); setVisible(true); await tester.pump(); expect(trafficUpdates(), 1); await setup.setRunning(false); }); testWidgets('the macOS menu bar title keeps traffic coming while hidden', ( tester, ) async { final setup = buildScoped(); setVisible(false); await setup.setRunning(true); await tester.pump(const Duration(seconds: 2)); expect(trafficUpdates(), 0); scoped .read(appSettingProvider.notifier) .update((state) => state.copyWith(showTrayTitle: true)); await tester.pump(const Duration(seconds: 2)); expect(trafficUpdates(), system.isMacOS ? 3 : 0); await setup.setRunning(false); }); }); group('suspend', () { test('skips starting the core on an excluded SSID', () async { container.dispose(); action = TestSetupAction(); container = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => action), commonActionProvider.overrideWith(TestCommonAction.new), excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']), ], ); globalState.container = container; container.read(initProvider.notifier).value = true; container.read(currentSSIDProvider.notifier).value = 'Office Wi-Fi'; await container.read(setupActionProvider.notifier).setRunning(true); expect(action.coreRunningCalls, isEmpty); expect(container.read(runningVpnOptionsProvider), isNull); }); test('an excluded SSID parks a run and leaving it resumes', () async { container.dispose(); action = TestSetupAction(); container = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => action), commonActionProvider.overrideWith(TestCommonAction.new), excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']), ], ); globalState.container = container; container.read(initProvider.notifier).value = true; final setup = container.read(setupActionProvider.notifier); await setup.setRunning(true); action.coreRunningCalls.clear(); container.read(currentSSIDProvider.notifier).value = 'Office Wi-Fi'; setup.syncSuspend(); await Future.delayed(const Duration(milliseconds: 700)); expect(action.coreRunningCalls, [false]); expect(container.read(isStartProvider), isTrue); expect(container.read(runningVpnOptionsProvider), isNull); container.read(currentSSIDProvider.notifier).value = 'Home'; setup.syncSuspend(); await Future.delayed(const Duration(milliseconds: 700)); expect(action.coreRunningCalls, [false, true]); expect( container.read(runningVpnOptionsProvider), await container.read(vpnOptionsProvider.future), ); }); test('an SSID change leaves a stopped run alone', () async { markInitialized(); container.read(setupActionProvider.notifier).syncSuspend(); await Future.delayed(const Duration(milliseconds: 700)); expect(action.coreRunningCalls, isEmpty); }); test('still stops the core on an excluded SSID', () async { container.dispose(); action = TestSetupAction(); container = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => action), commonActionProvider.overrideWith(TestCommonAction.new), excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']), ], ); globalState.container = container; container.read(currentSSIDProvider.notifier).value = 'Office Wi-Fi'; await container.read(setupActionProvider.notifier).setRunning(false); expect(action.coreRunningCalls, [false]); }); }); group('running vpn options', () { test('a start keeps what the service starts with until a stop', () async { markInitialized(); final setup = container.read(setupActionProvider.notifier); final started = await container.read(vpnOptionsProvider.future); await setup.setRunning(true); container .read(vpnSettingProvider.notifier) .update((state) => state.copyWith(ipv6: !state.ipv6)); expect(container.read(runningVpnOptionsProvider), started); await setup.setRunning(false); expect(container.read(runningVpnOptionsProvider), isNull); }); test('a start the core rejects keeps nothing', () async { markInitialized(); action.coreRunningError = StateError('listener refused'); await expectLater( container.read(setupActionProvider.notifier).setRunning(true), throwsStateError, ); expect(container.read(runningVpnOptionsProvider), isNull); }); test('a restart stops and starts with the current options', () async { markInitialized(); final setup = container.read(setupActionProvider.notifier); await setup.setRunning(true); container .read(vpnSettingProvider.notifier) .update((state) => state.copyWith(ipv6: !state.ipv6)); await setup.restartVpn(); expect(action.coreRunningCalls, [true, false, true]); expect(container.read(isStartProvider), isTrue); expect( container.read(runningVpnOptionsProvider), await container.read(vpnOptionsProvider.future), ); }); test('a restart never starts a stopped run', () async { markInitialized(); await container.read(setupActionProvider.notifier).restartVpn(); expect(action.coreRunningCalls, isEmpty); expect(container.read(isStartProvider), isFalse); }); }); group('latest-intent arbitration', () { test('a superseded stop does not run the post-stop cleanup', () async { markInitialized(); final notifier = container.read(setupActionProvider.notifier); action.blockCoreCalls = true; final stopping = notifier.setRunning(false); await Future.delayed(Duration.zero); final starting = notifier.setRunning(true); await Future.delayed(Duration.zero); action.blockCoreCalls = false; for (final gate in action.pendingCoreCalls) { if (!gate.isCompleted) { gate.complete(); } } await Future.wait([stopping, starting]); expect(action.coreRunningCalls, [false, true]); expect(action.trafficResets, 0); expect(container.read(runTimeProvider), isNotNull); }); test( 'a superseded start the core leaves unanswered does not fail', () async { markInitialized(); final notifier = container.read(setupActionProvider.notifier); action.blockCoreCalls = true; action.unansweredCoreCalls.add(true); final starting = notifier.setRunning(true); await Future.delayed(Duration.zero); final stopping = notifier.setRunning(false); await Future.delayed(Duration.zero); action.blockCoreCalls = false; for (final gate in action.pendingCoreCalls) { if (!gate.isCompleted) { gate.complete(); } } expect(await Future.wait([starting, stopping]), [true, true]); expect(action.coreRunningCalls, [true, false]); expect(container.read(isStartProvider), isFalse); }, ); test('the newest request wins the local running state', () async { markInitialized(); final notifier = container.read(setupActionProvider.notifier); await notifier.setRunning(true); expect(container.read(runTimeProvider), isNotNull); await notifier.setRunning(false); expect(container.read(runTimeProvider), isNull); await notifier.setRunning(true); expect(container.read(runTimeProvider), isNotNull); expect(action.coreRunningCalls, [true, false, true]); }); }); group('initStatus', () { test('is a no-op once the status has already been initialized', () async { globalState.needInitStatus = false; await container.read(setupActionProvider.notifier).initStatus(); expect(action.coreRunningCalls, isEmpty); expect(action.applyProfileCalls, 0); }); test('starts the core when autoRun is enabled', () async { container.read(appSettingProvider.notifier).value = const AppSettingProps( autoRun: true, ); await container.read(setupActionProvider.notifier).initStatus(); expect(action.coreRunningCalls, [true]); expect(globalState.needInitStatus, isFalse); }); test('only applies the profile when autoRun is disabled', () async { container.read(appSettingProvider.notifier).value = const AppSettingProps( autoRun: false, ); await container.read(setupActionProvider.notifier).initStatus(); expect(action.coreRunningCalls, isEmpty); expect(action.applyProfileCalls, 1); }); }); group('requestAdmin', () { test('never asks for authorization while tun is disabled', () async { expect(await action.requestAdmin(false), isTrue); expect(action.authorizeCalls, 0); expect( container.read(authorizedTunEnableProvider), TunAuthorizationState.none, ); }); test('safe mode never asks, whatever the tun setting says', () async { final scopedAction = TestSetupAction(); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => scopedAction), safeModeProvider.overrideWithValue(true), ], ); addTearDown(scoped.dispose); scoped.read(setupActionProvider.notifier); expect(await scopedAction.requestAdmin(true), isTrue); expect(scopedAction.authorizeCalls, 0); expect( scoped.read(authorizedTunEnableProvider), TunAuthorizationState.none, ); }); test('does not ask again once the state left none', () async { container.read(authorizedTunEnableProvider.notifier).value = TunAuthorizationState.authorized; expect(await action.requestAdmin(true), isTrue); expect(action.authorizeCalls, 0); }); test( 'a successful authorization hands off instead of continuing', () async { action.authorizeResult = AuthorizeCode.success; expect(await action.requestAdmin(true), isFalse); expect(action.authorizeCalls, 1); expect( container.read(authorizedTunEnableProvider), TunAuthorizationState.authorized, ); }, ); test('a platform without an authorization step continues inline', () async { action.authorizeResult = AuthorizeCode.none; expect(await action.requestAdmin(true), isTrue); expect( container.read(authorizedTunEnableProvider), TunAuthorizationState.authorized, ); }); test('a failed authorization turns tun off and asks again on the next ' 'enable', () async { await AppLocalizations.load(const Locale('en')); container .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith.tun(enable: true)); action.authorizeResult = AuthorizeCode.error; expect(await action.requestAdmin(true), isTrue); expect(container.read(patchClashConfigProvider).tun.enable, isFalse); expect( container.read(authorizedTunEnableProvider), TunAuthorizationState.none, ); action.authorizeResult = AuthorizeCode.success; expect(await action.requestAdmin(true), isFalse); expect(action.authorizeCalls, 2); }); }); group('updateConfig', () { test('safe mode pushes tun disabled even once authorized', () async { final core = _MockCoreHandlerInterface(); when(() => core.updateConfig(any())).thenAnswer((_) async => ''); final scopedAction = TestSetupAction(); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => scopedAction), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), safeModeProvider.overrideWithValue(true), ], ); addTearDown(scoped.dispose); scoped.read(setupActionProvider.notifier); scoped .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith(tun: state.tun.copyWith(enable: true)), ); scoped.read(authorizedTunEnableProvider.notifier).value = TunAuthorizationState.authorized; await scopedAction.updateConfig(); final params = verify(() => core.updateConfig(captureAny())).captured.single as UpdateParams; expect(params.tun.enable, isFalse); expect(scopedAction.authorizeCalls, 0); }); test('safe mode closes the external controller', () async { final core = _MockCoreHandlerInterface(); when(() => core.updateConfig(any())).thenAnswer((_) async => ''); final scopedAction = TestSetupAction(); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => scopedAction), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), safeModeProvider.overrideWithValue(true), ], ); addTearDown(scoped.dispose); scoped.read(setupActionProvider.notifier); scoped .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( externalController: ExternalControllerStatus.open, ), ); await scopedAction.updateConfig(); final params = verify(() => core.updateConfig(captureAny())).captured.single as UpdateParams; expect(params.externalController, ExternalControllerStatus.close); expect( scoped.read(patchClashConfigProvider).externalController, ExternalControllerStatus.open, ); }); Future updateWith(PatchClashConfig patchConfig) async { final core = _MockCoreHandlerInterface(); when(() => core.updateConfig(any())).thenAnswer((_) async => ''); final scopedAction = TestSetupAction(); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(TestProfiles.new), setupActionProvider.overrideWith(() => scopedAction), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), ], ); addTearDown(scoped.dispose); scoped.read(setupActionProvider.notifier); scoped.read(patchClashConfigProvider.notifier).value = patchConfig; await scopedAction.updateConfig(); return verify(() => core.updateConfig(captureAny())).captured.single as UpdateParams; } test('hands the Core the controller secret with the address', () async { final params = await updateWith( const PatchClashConfig( externalController: ExternalControllerStatus.open, secret: 'abc', ), ); expect(params.externalController, ExternalControllerStatus.open); expect(params.secret, 'abc'); }); test('never opens the controller without a secret', () async { final params = await updateWith( const PatchClashConfig( externalController: ExternalControllerStatus.open, ), ); expect(params.externalController, ExternalControllerStatus.close); }); }); group('recoverMissingProfile', () { ProviderContainer buildScoped(List profiles, int? profileId) { final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles(profiles)), currentProfileIdProvider.overrideWithBuild((_, _) => profileId), setupActionProvider.overrideWith(TestSetupAction.new), ], ); addTearDown(scoped.dispose); return scoped; } test('a dangling profile id falls back to the first profile', () { final profile = Profile.normal(label: 'p').copyWith(id: 1); final scoped = buildScoped([profile], 404); final recovered = scoped .read(setupActionProvider.notifier) .recoverMissingProfile(); expect(recovered?.id, profile.id); expect(scoped.read(currentProfileIdProvider), profile.id); }); test('no profiles keeps the stored id untouched', () { final scoped = buildScoped(const [], 404); expect( scoped.read(setupActionProvider.notifier).recoverMissingProfile(), isNull, ); expect(scoped.read(currentProfileIdProvider), 404); }); test('an unset id is the first-run state, not a dangling one', () { final profile = Profile.normal(label: 'p').copyWith(id: 1); final scoped = buildScoped([profile], null); expect( scoped.read(setupActionProvider.notifier).recoverMissingProfile(), isNull, ); expect(scoped.read(currentProfileIdProvider), isNull); }); }); group('changeMode', () { test('records the requested mode', () { container.read(setupActionProvider.notifier).changeMode(Mode.direct); expect(container.read(patchClashConfigProvider).mode, Mode.direct); }); test('leaving global alone keeps the selected group', () { final profile = Profile.normal( label: 'p', ).copyWith(id: 1, currentGroupName: 'Manual'); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupActionProvider.overrideWith(TestSetupAction.new), ], ); addTearDown(scoped.dispose); scoped.read(setupActionProvider.notifier).changeMode(Mode.rule); expect(scoped.read(currentProfileProvider)?.currentGroupName, 'Manual'); }); test('global mode also selects the global group', () { final profile = Profile.normal( label: 'p', ).copyWith(id: 1, currentGroupName: 'Manual'); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupActionProvider.overrideWith(TestSetupAction.new), ], ); addTearDown(scoped.dispose); scoped.read(setupActionProvider.notifier).changeMode(Mode.global); expect(scoped.read(patchClashConfigProvider).mode, Mode.global); expect( scoped.read(currentProfileProvider)?.currentGroupName, GroupName.GLOBAL.name, ); }); }); group('applyProfileDebounce', () { test('collapses a burst into a single apply', () async { final notifier = container.read(setupActionProvider.notifier); notifier.applyProfileDebounce(force: true); notifier.applyProfileDebounce(force: true); notifier.applyProfileDebounce(force: true); expect(action.applyProfileCalls, 0); await Future.delayed(const Duration(milliseconds: 800)); expect(action.applyProfileCalls, 1); }); test('a forced apply stays forced when a plain one follows', () async { final notifier = container.read(setupActionProvider.notifier); notifier.applyProfileDebounce(force: true); notifier.applyProfileDebounce(); await Future.delayed(const Duration(milliseconds: 800)); notifier.applyProfileDebounce(); await Future.delayed(const Duration(milliseconds: 800)); expect(action.appliedForce, [true, false]); }); test('a stop cancels a pending apply', () async { markInitialized(); final notifier = container.read(setupActionProvider.notifier); notifier.applyProfileDebounce(force: true); await notifier.setRunning(false); await Future.delayed(const Duration(milliseconds: 800)); expect(action.applyProfileCalls, 0); notifier.applyProfileDebounce(); await Future.delayed(const Duration(milliseconds: 800)); expect(action.appliedForce, [false]); }); }); group('_setupConfig via the real SetupAction', () { late Directory tempDir; late String? originalLastConfigMd5; setUpAll(() async { tempDir = Directory.systemTemp.createTempSync('setup_action_test'); await useAppPaths(tempDir); await AppLocalizations.load(const Locale('en')); originalLastConfigMd5 = globalState.lastConfigMd5; globalState.packageInfo = PackageInfo( appName: 'FlClash', packageName: 'com.follow.clash', version: '0.0.0', buildNumber: '0', ); }); tearDownAll(() { try { tempDir.deleteSync(recursive: true); } catch (_) {} }); tearDown(() { globalState.lastConfigMd5 = originalLastConfigMd5; }); // profileId: null routes getProfile/setupState around the database and // Core.getConfig, isolating the behavior under test. const nullProfileSetupState = SetupState( profileId: null, profileLastUpdateDate: null, profileType: ProfileType.file, extendType: ExtendType.standard, rules: [], proxyGroups: [], addedRules: [], script: null, overrides: ProfileOverrides(), ); test( 'a refresh failure still runs core.setupConfig and preloadInvoke', () async { final profile = Profile.normal(label: 'p', url: 'http://127.0.0.1:9/'); final core = _MockCoreHandlerInterface(); when(() => core.setupConfig(any())).thenAnswer((_) async => ''); var preloadRan = false; final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupStateProvider.overrideWith((_, _) => nullProfileSetupState), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); await scoped .read(setupActionProvider.notifier) .applyProfile( force: true, preloadInvoke: () async { preloadRan = true; }, ); expect(preloadRan, isTrue); verify(() => core.setupConfig(any())).called(1); }, ); test( 'a profile that fails to build still pushes the empty config to core', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); when( () => core.getProfileBase(any()), ).thenThrow(Exception('broken yaml')); String? pushedConfig; when(() => core.setupConfig(any())).thenAnswer((_) async { pushedConfig = await File( await appPath.configFilePath, ).readAsString(); return ''; }); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupStateProvider.overrideWith( (_, profileId) => nullProfileSetupState.copyWith(profileId: profileId), ), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final succeeded = await scoped .read(setupActionProvider.notifier) .applyProfile(force: true); expect(succeeded, isFalse); expect(pushedConfig, isEmpty); expect(scoped.read(currentProfileIdProvider), profile.id); }, ); test( 'a config write failure reports setup as failed without calling core', () async { final configPath = await appPath.configFilePath; final configFile = File(configPath); if (await configFile.exists()) { await configFile.delete(); } final configAsDirectory = Directory(configPath); await configAsDirectory.create(recursive: true); final core = _MockCoreHandlerInterface(); when(() => core.setupConfig(any())).thenAnswer((_) async => ''); final scoped = ProviderContainer( overrides: [ currentProfileProvider.overrideWithValue(null), setupStateProvider.overrideWith((_, _) => nullProfileSetupState), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); try { final succeeded = await scoped .read(setupActionProvider.notifier) .applyProfile(force: true); expect(succeeded, isFalse); verifyNever(() => core.setupConfig(any())); } finally { await configAsDirectory.delete(recursive: true); } }, ); test( 'safe mode writes a closed external controller into the profile', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); when( () => core.getProfileBase(any()), ).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd')); String? pushedConfig; when(() => core.setupConfig(any())).thenAnswer((invocation) async { final params = invocation.positionalArguments.single as SetupParams; pushedConfig = params.base?.overlay; return ''; }); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupStateProvider.overrideWith( (_, profileId) => nullProfileSetupState.copyWith(profileId: profileId), ), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), safeModeProvider.overrideWithValue(true), ], ); addTearDown(scoped.dispose); scoped .read(patchClashConfigProvider.notifier) .update( (state) => state.copyWith( externalController: ExternalControllerStatus.open, ), ); final succeeded = await scoped .read(setupActionProvider.notifier) .applyProfile(force: true); expect(succeeded, isTrue); expect(pushedConfig, contains('external-controller: ""')); expect(pushedConfig, isNot(contains('9090'))); }, ); test('leaving an editor rebuilds the profile only once its inputs ' 'differ from the applied config', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); when( () => core.getProfileBase(any()), ).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd')); when(() => core.setupConfig(any())).thenAnswer((_) async => ''); var setupState = nullProfileSetupState.copyWith(profileId: profile.id); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupStateProvider.overrideWith((_, _) => setupState), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final setupAction = scoped.read(setupActionProvider.notifier); Future leaveEditor() async { setupAction.autoApplyProfile(); SchedulerBinding.instance ..handleBeginFrame(null) ..handleDrawFrame(); await pumpEventQueue(); } await setupAction.applyProfile(force: true); verify(() => core.getProfileBase(any())).called(1); await leaveEditor(); verifyNever(() => core.getProfileBase(any())); setupState = setupState.copyWith(matchTarget: 'DIRECT'); scoped.invalidate(setupStateProvider); await leaveEditor(); verify(() => core.getProfileBase(any())).called(1); // Queued behind the rebuild: one that outlasts the pump would compare // against the md5 tearDown resets and push into a disposed container. await setupAction.applyProfile(); }); test('a config the Core rejected is pushed again once reverted, and the ' 'groups follow the Core through both', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); Map global(List all) => { 'name': 'GLOBAL', 'type': 'Selector', 'hidden': true, 'all': all, }; final profileProxies = ProxiesData( all: const ['Proxy', 'GLOBAL'], proxies: { 'Proxy': { 'name': 'Proxy', 'type': 'Selector', 'hidden': false, 'all': ['HK-01'], }, 'HK-01': {'name': 'HK-01', 'type': 'ss'}, 'GLOBAL': global(['Proxy']), }, ); final fallbackProxies = ProxiesData( all: const ['GLOBAL'], proxies: { 'DIRECT': {'name': 'DIRECT', 'type': 'Direct'}, 'GLOBAL': global(['DIRECT']), }, ); var rejecting = false; var coreProxies = profileProxies; when( () => core.getProfileBase(any()), ).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd')); when(() => core.getExternalProviders()).thenAnswer((_) async => []); when(() => core.getProxies()).thenAnswer((_) async => coreProxies); when(() => core.setupConfig(any())).thenAnswer((_) async { if (rejecting) { coreProxies = fallbackProxies; return 'invalid hosts'; } coreProxies = profileProxies; return ''; }); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupStateProvider.overrideWith( (_, profileId) => nullProfileSetupState.copyWith(profileId: profileId), ), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final setupAction = scoped.read(setupActionProvider.notifier); List shownGroups() => [ for (final group in scoped.read(currentGroupsStateProvider).value) group.name, ]; void setHosts(Map hosts) { scoped .read(patchClashConfigProvider.notifier) .update((state) => state.copyWith(hosts: hosts)); } final originalHosts = scoped.read(patchClashConfigProvider).hosts; expect(await setupAction.applyProfile(force: true), isTrue); expect(shownGroups(), ['Proxy']); rejecting = true; setHosts({'example.com': 'not-an-address'}); expect(await setupAction.applyProfile(), isFalse); expect(shownGroups(), isEmpty); rejecting = false; setHosts(originalHosts); expect(await setupAction.applyProfile(), isTrue); verify(() => core.setupConfig(any())).called(3); expect(shownGroups(), ['Proxy']); }); test('a profile file leaves its bulk sections in the Core and records ' 'what the Core was handed', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); var digest = 'first'; when(() => core.getProfileBase(any())).thenAnswer( (_) async => ProfileBase( config: const {'mixed-port': 1}, digest: digest, matchRules: const ['MATCH,DIRECT', 'MATCH,Proxy'], proxies: 3, proxyGroups: 2, rules: 5, ), ); final pushed = []; when(() => core.setupConfig(any())).thenAnswer((invocation) async { pushed.add(invocation.positionalArguments.single as SetupParams); return ''; }); final configFile = File(await appPath.configFilePath); await configFile.writeAsString('previous'); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupStateProvider.overrideWith( (_, profileId) => nullProfileSetupState.copyWith( profileId: profileId, addedRules: [Rule.parse('DOMAIN,added.test,MATCH')], ), ), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final setupAction = scoped.read(setupActionProvider.notifier); addTearDown(() => globalState.lastConfigCounts = null); expect(await setupAction.applyProfile(force: true), isTrue); final base = pushed.single.base!; expect(base.path, await appPath.getProfilePath(profile.id.toString())); final overlay = loadYaml(base.overlay) as YamlMap; expect(overlay['rules'], ['DOMAIN,added.test,Proxy']); expect(overlay['mixed-port'], isNot(1)); expect(await configFile.readAsString(), 'previous'); expect(globalState.lastConfigCounts, (groups: 2, proxies: 3, rules: 6)); await setupAction.applyProfile(); expect(pushed, hasLength(1)); digest = 'second'; await setupAction.applyProfile(); expect(pushed, hasLength(2)); when(() => core.setupConfig(any())).thenAnswer((_) async => 'rejected'); digest = 'third'; expect(await setupAction.applyProfile(), isFalse); expect(globalState.lastConfigCounts, (groups: 0, proxies: 0, rules: 0)); }); test('the preview shows the config the Core merges', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); when( () => core.getProfileBase(any()), ).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd')); final built = File( join(await appPath.homeDirPath, coreSpillDirectoryName, 'p.yaml'), ); when(() => core.buildProfile(any())).thenAnswer((_) async { await built.create(recursive: true); await built.writeAsString('proxies: []\n'); return built.path; }); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), setupStateProvider.overrideWith( (_, profileId) => nullProfileSetupState.copyWith(profileId: profileId), ), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final yaml = await scoped .read(setupActionProvider.notifier) .getProfileWithId(profile.id); verifyNever(() => core.getConfig(any())); expect(yaml, 'proxies: []\n'); expect(built.existsSync(), isFalse); }); test( 'the extend page reads only the outbounds of a profile file', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); when(() => core.getProfileOutbounds(any())).thenAnswer( (_) async => { 'proxies': [ {'name': 'a', 'type': 'ss'}, ], 'proxy-groups': [ {'name': 'g', 'type': 'select'}, ], }, ); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), ], ); addTearDown(scoped.dispose); final config = await scoped.read( clashConfigProvider(profile.id).future, ); verifyNever(() => core.getConfig(any())); expect( { for (final proxy in config.proxies) proxy.name: proxy.type, for (final group in config.proxyGroups) group.name: group.type, }, {'a': 'ss', 'g': GroupType.Selector}, ); }, ); test( 'a start applies the profile again only once its inputs changed', () async { final profile = Profile.normal(label: 'p'); final core = _MockCoreHandlerInterface(); when( () => core.getProfileBase(any()), ).thenAnswer((_) async => const ProfileBase(config: {}, digest: 'd')); when(() => core.setupConfig(any())).thenAnswer((_) async => ''); when(() => core.startListener()).thenAnswer((_) async => true); when(() => core.stopListener()).thenAnswer((_) async => true); final scoped = ProviderContainer( overrides: [ profilesProvider.overrideWith(() => TestProfiles([profile])), currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), setupStateProvider.overrideWith( (_, profileId) => nullProfileSetupState.copyWith(profileId: profileId), ), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), commonActionProvider.overrideWith(TestCommonAction.new), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); scoped.read(initProvider.notifier).value = true; final setupAction = scoped.read(setupActionProvider.notifier); await setupAction.applyProfile(force: true); verify(() => core.setupConfig(any())).called(1); await setupAction.setRunning(true); await Future.delayed(const Duration(milliseconds: 800)); verifyNever(() => core.setupConfig(any())); await setupAction.setRunning(false); scoped.read(appSettingProvider.notifier).value = scoped .read(appSettingProvider) .copyWith(testUrl: 'https://example.com/204'); await setupAction.setRunning(true); await Future.delayed(const Duration(milliseconds: 800)); verify(() => core.setupConfig(any())).called(1); await setupAction.setRunning(false); }, ); test('a custom profile declares only the proxies and providers it names, ' 'without a profile file', () async { final profile = Profile.custom(label: 'p'); final core = _MockCoreHandlerInterface(); const appProxy = CustomProxy( id: 11, definition: {'name': 'mine', 'type': 'socks5', 'server': '::1'}, ); const unusedProxy = CustomProxy( id: 15, definition: {'name': 'spare', 'type': 'socks5', 'server': '::1'}, ); const appRule = ClashProvider( id: 12, label: 'appRules', url: 'https://example.com/r.yaml', behavior: RuleProviderBehavior.domain, format: RuleProviderFormat.yaml, ); final compiled = File(await appRule.compiledPath) ..createSync(recursive: true); addTearDown(compiled.deleteSync); const unusedRule = ClashProvider( id: 13, label: 'unused', url: 'https://example.com/u.yaml', ); const localRule = ClashProvider( id: 14, label: 'localRules', behavior: RuleProviderBehavior.classical, format: RuleProviderFormat.yaml, ); const nestedRule = ClashProvider( id: 16, label: 'nestedRules', behavior: RuleProviderBehavior.classical, format: RuleProviderFormat.yaml, ); final setupState = nullProfileSetupState.copyWith( profileId: profile.id, profileType: ProfileType.custom, proxyGroups: [ const ProxyGroup( id: 1, name: 'g', type: GroupType.Selector, proxies: ['mine'], use: ['sub'], ), ], rules: [ Rule.parse('RULE-SET,appRules,DIRECT', id: 2), Rule.parse('RULE-SET,localRules,DIRECT', id: 3), Rule.parse( 'AND,((RULE-SET,nestedRules),(NETWORK,UDP)),REJECT', id: 4, ), ], clashProviders: const [appRule, unusedRule, localRule, nestedRule], profileProviders: const {'sub': 42}, appProxies: const [appProxy, unusedProxy], ); final scoped = ProviderContainer( overrides: [ coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final res = await scoped .read(setupActionProvider.notifier) .getProfile( setupState: setupState, patchConfig: const PatchClashConfig(), ); final config = loadYaml(res.yaml) as YamlMap; final proxyProviders = config['proxy-providers'] as YamlMap; verifyNever(() => core.getConfig(any())); expect(proxyProviders.keys, {'sub'}); expect((config['proxies'] as YamlList).single['name'], 'mine'); expect(proxyProviders['sub']['type'], 'file'); expect(proxyProviders['sub']['path'], await appPath.getProfilePath('42')); expect((config['proxy-groups'] as YamlList).single['name'], 'g'); expect(config['rules'], [ 'RULE-SET,appRules,DIRECT', 'RULE-SET,localRules,DIRECT', 'AND,((RULE-SET,nestedRules),(NETWORK,UDP)),REJECT', ]); expect(config['rule-providers'].containsKey('nestedRules'), isTrue); final remote = config['rule-providers']['appRules'] as YamlMap; expect(remote['type'], 'file'); expect(remote.containsKey('url'), isFalse); expect(remote['behavior'], 'domain'); expect(remote['format'], 'mrs'); expect(remote['path'], compiled.path); expect(config['rule-providers'].containsKey('unused'), isFalse); final local = config['rule-providers']['localRules'] as YamlMap; expect(local['type'], 'file'); expect(local.containsKey('url'), isFalse); expect( local['path'], await appPath.getProviderCachePath(localRule.fileName), ); }); test('a provider a custom profile names carries its options', () async { final profile = Profile.custom(label: 'p'); final core = _MockCoreHandlerInterface(); final setupState = nullProfileSetupState.copyWith( profileId: profile.id, profileType: ProfileType.custom, proxyGroups: [ const ProxyGroup( id: 1, name: 'g', type: GroupType.Selector, use: ['sub', 'plain'], ), ], profileProviders: const {'sub': 42, 'plain': 43}, overrides: const ProfileOverrides( proxyProviders: { 'sub': ProxyProviderOptions( healthCheck: ProviderHealthCheck(interval: 600), filter: 'HK', excludeFilter: 'expire', ), }, ), ); final scoped = ProviderContainer( overrides: [ coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final res = await scoped .read(setupActionProvider.notifier) .getProfile( setupState: setupState, patchConfig: const PatchClashConfig(), ); final providers = (loadYaml(res.yaml) as YamlMap)['proxy-providers']; expect(providers['sub']['filter'], 'HK'); expect(providers['sub']['exclude-filter'], 'expire'); expect(providers['sub']['health-check'], { 'enable': true, 'url': defaultTestUrl, 'interval': 600, }); expect(providers['sub']['type'], 'file'); expect((providers['plain'] as YamlMap).keys, {'type', 'path'}); }); test('a custom profile group taking in every proxy or provider declares ' 'none the profile does not name', () async { final profile = Profile.custom(label: 'p'); final core = _MockCoreHandlerInterface(); final setupState = nullProfileSetupState.copyWith( profileId: profile.id, profileType: ProfileType.custom, proxyGroups: [ const ProxyGroup( id: 1, name: 'g', type: GroupType.Selector, filter: 'HK', includeAllProxies: true, includeAllProviders: true, ), const ProxyGroup( id: 2, name: 'h', type: GroupType.Selector, proxies: ['mine'], use: ['sub'], ), ], profileProviders: const {'sub': 42, 'plain': 43}, appProxies: const [ CustomProxy(id: 11, definition: {'name': 'mine', 'type': 'direct'}), CustomProxy(id: 15, definition: {'name': 'spare', 'type': 'direct'}), ], ); final scoped = ProviderContainer( overrides: [ coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final res = await scoped .read(setupActionProvider.notifier) .getProfile( setupState: setupState, patchConfig: const PatchClashConfig(), ); final config = loadYaml(res.yaml) as YamlMap; expect((config['proxy-providers'] as YamlMap).keys, {'sub'}); expect([for (final proxy in config['proxies']) proxy['name']], ['mine']); final group = (config['proxy-groups'] as YamlList).first as YamlMap; expect(group['include-all-proxies'], isTrue); expect(group['include-all-providers'], isTrue); }); test('only a subscription profile runs its script extension', () async { final engine = scriptEvaluator; addTearDown(() => scriptEvaluator = engine); final evaluated = []; scriptEvaluator = ({required String script, required String config}) async { evaluated.add(script); return config; }; final script = await Script( id: 77, label: 'script', lastUpdateTime: DateTime(2026), ).save('const main = (config) => config;'); final core = _MockCoreHandlerInterface(); when(() => core.getConfig(any())).thenAnswer((_) async => {}); final scoped = ProviderContainer( overrides: [ coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); Future build(ProfileType type) => scoped .read(setupActionProvider.notifier) .getProfile( setupState: nullProfileSetupState.copyWith( profileId: 5, profileType: type, extendType: ExtendType.script, script: script, ), patchConfig: const PatchClashConfig(), ); await build(ProfileType.custom); expect(evaluated, isEmpty); verifyNever(() => core.getConfig(any())); await build(ProfileType.file); expect(evaluated, hasLength(1)); verify(() => core.getConfig(any())).called(1); }); test( 'a rejected setupConfig without a handoff reports failure, not success', () async { final core = _MockCoreHandlerInterface(); when(() => core.setupConfig(any())).thenThrow(StateError('rejected')); final scoped = ProviderContainer( overrides: [ currentProfileProvider.overrideWithValue(null), setupStateProvider.overrideWith((_, _) => nullProfileSetupState), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(SetupAction.new), ], ); addTearDown(scoped.dispose); final succeeded = await scoped .read(setupActionProvider.notifier) .applyProfile(force: true); expect(succeeded, isFalse); verify(() => core.setupConfig(any())).called(1); }, ); test( 'a listener handoff failure during initialize rolls back running', () async { final core = _MockCoreHandlerInterface(); when(() => core.setupConfig(any())).thenAnswer((_) async => ''); final scoped = ProviderContainer( overrides: [ currentProfileProvider.overrideWithValue(null), setupStateProvider.overrideWith((_, _) => nullProfileSetupState), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith( _ListenerHandoffFailureSetupAction.new, ), ], ); addTearDown(scoped.dispose); final handoffAction = scoped.read(setupActionProvider.notifier) as _ListenerHandoffFailureSetupAction; await handoffAction.setRunning(true, initialize: true); expect(handoffAction.coreRunningCalls, [true, false]); expect(scoped.read(runTimeProvider), isNull); verify(() => core.setupConfig(any())).called(1); }, ); test( 'a non-empty setupConfig message during initialize rolls back running', () async { final core = _MockCoreHandlerInterface(); when( () => core.setupConfig(any()), ).thenAnswer((_) async => 'config rejected'); final scoped = ProviderContainer( overrides: [ currentProfileProvider.overrideWithValue(null), setupStateProvider.overrideWith((_, _) => nullProfileSetupState), coreHandlerProvider.overrideWithValue(CoreController.scoped(core)), setupActionProvider.overrideWith(_MessageFailureSetupAction.new), ], ); addTearDown(scoped.dispose); final messageAction = scoped.read(setupActionProvider.notifier) as _MessageFailureSetupAction; await messageAction.setRunning(true, initialize: true); expect(messageAction.coreRunningCalls, [true, false]); expect(scoped.read(runTimeProvider), isNull); verify(() => core.setupConfig(any())).called(1); }, ); }); }