Files
FlClash/test/providers/proxies_action_test.dart
T
chen08209 8378c7081c feat(views): rework the proxies and scan pages, lay out records by their parts and unify search
A queued delay test shows a static clock until the task pool runs it, so
only the running tests animate while the list scrolls.

Changelog: Hide timed-out nodes and search every list
2026-10-02 20:32:42 +08:00

928 lines
31 KiB
Dart

import 'dart:async';
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/enum/enum.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/l10n/l10n.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod/riverpod.dart';
import '../helpers/test_profiles.dart';
class MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
const _testUrl = 'http://delay.test';
Group _group(String name, List<Proxy> all) =>
Group(type: GroupType.Selector, name: name, all: all);
const _proxy = Proxy(name: 'HK-01', type: 'ss');
Profile _selectedProfile(String proxyName) => Profile(
id: 1,
autoUpdateDuration: Duration.zero,
selectedMap: {'Proxy': proxyName},
);
final _delayKey = delayTestKey(_testUrl, 'HK-01');
ProviderContainer _delayContainer(ProviderContainer Function() build) {
final container = build();
container.read(appSettingProvider.notifier).value = const AppSettingProps(
testUrl: _testUrl,
);
return container;
}
ExternalProvider _provider(String name, {int count = 1}) => ExternalProvider(
name: name,
type: 'Proxy',
count: count,
vehicleType: 'HTTP',
updateAt: DateTime.utc(2026),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MockCoreHandlerInterface core;
setUpAll(() async {
registerFallbackValue(
const ChangeProxyParams(groupName: 'G', proxyName: 'P'),
);
core = MockCoreHandlerInterface();
await AppLocalizations.load(const Locale('en'));
});
setUp(() => reset(core));
ProviderContainer buildContainer({Profile? profile}) {
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
profilesProvider.overrideWith(() => TestProfiles([?profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile?.id),
],
);
addTearDown(container.dispose);
return container;
}
ProxiesAction actionOf(ProviderContainer container) =>
container.read(proxiesActionProvider.notifier);
ProviderContainer runningContainer() {
final container = buildContainer();
container.read(initProvider.notifier).value = true;
container.read(runTimeProvider.notifier).value = 1;
return container;
}
group('updateGroups', () {
test('publishes the groups derived from core proxy data', () async {
when(core.getProxies).thenAnswer(
(_) async => ProxiesData(
all: const ['Proxy', 'Direct'],
proxies: Map<String, dynamic>.from({
'Proxy': Map<String, dynamic>.from({
'name': 'Proxy',
'type': 'Selector',
'now': 'HK-01',
'all': ['HK-01'],
}),
'Direct': Map<String, dynamic>.from({
'name': 'Direct',
'type': 'Direct',
}),
'HK-01': Map<String, dynamic>.from({'name': 'HK-01', 'type': 'ss'}),
}),
),
);
final container = buildContainer();
await actionOf(container).updateGroups();
final groups = container.read(groupsProvider);
expect(groups.map((group) => group.name), ['Proxy']);
expect(groups.single.all.map((proxy) => proxy.name), ['HK-01']);
});
test(
'publishes the groups once a retry succeeds after core throws',
() async {
var attempt = 0;
when(core.getProxies).thenAnswer((_) async {
attempt++;
if (attempt == 1) {
throw StateError('core down');
}
return ProxiesData(
all: const ['Proxy', 'Direct'],
proxies: Map<String, dynamic>.from({
'Proxy': Map<String, dynamic>.from({
'name': 'Proxy',
'type': 'Selector',
'now': 'HK-01',
'all': ['HK-01'],
}),
'Direct': Map<String, dynamic>.from({
'name': 'Direct',
'type': 'Direct',
}),
'HK-01': Map<String, dynamic>.from({
'name': 'HK-01',
'type': 'ss',
}),
}),
);
});
final container = buildContainer();
await actionOf(container).updateGroups();
final groups = container.read(groupsProvider);
expect(groups.map((group) => group.name), ['Proxy']);
verify(core.getProxies).called(2);
},
);
test(
'clears the groups once retry is exhausted after core throws',
() async {
when(core.getProxies).thenThrow(StateError('core down'));
final container = buildContainer();
container.read(groupsProvider.notifier).value = [
_group('Stale', const []),
];
await actionOf(container).updateGroups();
expect(container.read(groupsProvider), isEmpty);
verify(core.getProxies).called(3);
},
);
test('a core status change alone does not clear the groups', () {
final container = buildContainer();
actionOf(container);
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
container.read(groupsProvider.notifier).value = [
_group('Stale', const []),
];
container.read(coreStatusProvider.notifier).value =
CoreStatus.disconnected;
expect(container.read(groupsProvider).map((group) => group.name), [
'Stale',
]);
});
});
group('changeProxy', () {
setUp(() {
when(
() => core.changeProxy(any()),
).thenAnswer((_) async => const ChangeProxyResult(changed: true));
when(core.closeConnections).thenAnswer((_) async => true);
when(core.resetConnections).thenAnswer((_) async => true);
});
test('closes connections when enabled', () async {
final container = runningContainer();
container.read(appSettingProvider.notifier).value = const AppSettingProps(
closeConnections: true,
);
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verify(
() => core.changeProxy(
const ChangeProxyParams(groupName: 'Proxy', proxyName: 'HK-01'),
),
).called(1);
verify(core.closeConnections).called(1);
verifyNever(core.resetConnections);
});
test('resets connections instead when the setting is off', () async {
final container = buildContainer();
container.read(appSettingProvider.notifier).value = const AppSettingProps(
closeConnections: false,
);
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verify(core.resetConnections).called(1);
verifyNever(core.closeConnections);
});
test('a failing connection reset does not fail the switch', () async {
when(core.closeConnections).thenThrow(
const CoreMethodException(
code: 'transport_disconnected',
message: 'Core RPC client is closed',
),
);
final container = buildContainer(profile: _selectedProfile('HK-00'));
container.read(appSettingProvider.notifier).value = const AppSettingProps(
closeConnections: true,
);
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verify(core.closeConnections).called(1);
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-01',
});
});
test('skips the connection reset when the switch itself fails', () async {
when(() => core.changeProxy(any())).thenThrow(StateError('core down'));
final container = runningContainer();
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verifyNever(core.closeConnections);
verifyNever(core.resetConnections);
});
test(
'skips the connection reset when the Core reports no change',
() async {
when(
() => core.changeProxy(any()),
).thenAnswer((_) async => const ChangeProxyResult(changed: false));
final container = runningContainer();
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verifyNever(core.closeConnections);
verifyNever(core.resetConnections);
},
);
test('rolls the selection back when the Core answers a message', () async {
when(() => core.changeProxy(any())).thenAnswer(
(_) async => const ChangeProxyResult(message: 'proxy not exist'),
);
final container = buildContainer(profile: _selectedProfile('HK-00'));
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verifyNever(core.closeConnections);
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-00',
});
});
test('commits the selection the Core accepted', () async {
final container = buildContainer(profile: _selectedProfile('HK-00'));
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-01',
});
});
test('rolls the selection back when the switch fails', () async {
when(() => core.changeProxy(any())).thenThrow(StateError('core down'));
final container = buildContainer(profile: _selectedProfile('HK-00'));
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-00',
});
});
test(
'rolls back to the last selection the Core applied, not the last tap',
() async {
when(() => core.changeProxy(any())).thenThrow(StateError('core down'));
final container = buildContainer(profile: _selectedProfile('HK-00'));
final action = actionOf(container);
action.changeProxyDebounce('Proxy', 'HK-01');
action.changeProxyDebounce('Proxy', 'HK-02');
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-02',
});
await action.changeProxy(groupName: 'Proxy', proxyName: 'HK-02');
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-00',
});
debouncer.cancel((FunctionTag.changeProxy, 'Proxy'));
},
);
});
group('proxyDelayTest', () {
test('marks the node pending while it runs, then records it', () async {
late ProviderContainer container;
final observed = <bool>[];
when(() => core.asyncTestDelay(_testUrl, 'HK-01')).thenAnswer((_) async {
observed.add(
container.read(pendingDelayTestsProvider).containsKey(_delayKey),
);
return const Delay(name: 'HK-01', url: _testUrl, value: 128);
});
container = _delayContainer(buildContainer);
await actionOf(container).proxyDelayTest(_proxy);
expect(observed, [true]);
expect(container.read(delayDataSourceProvider)[_testUrl]?['HK-01'], 128);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('keeps the last measurement when the Core does not answer', () async {
when(
() => core.asyncTestDelay(_testUrl, 'HK-01'),
).thenAnswer((_) async => null);
final container = _delayContainer(buildContainer);
container
.read(delayDataSourceProvider.notifier)
.setDelay(const Delay(name: 'HK-01', url: _testUrl, value: 42));
await actionOf(container).proxyDelayTest(_proxy);
expect(container.read(delayDataSourceProvider)[_testUrl]?['HK-01'], 42);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('falls back to untested when a throwing call had no value', () async {
when(
() => core.asyncTestDelay(_testUrl, 'HK-01'),
).thenThrow(StateError('channel is gone'));
final container = _delayContainer(buildContainer);
await actionOf(container).proxyDelayTest(_proxy);
expect(container.read(delayDataSourceProvider)[_testUrl]?['HK-01'], null);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('does nothing when the resolved proxy name is empty', () async {
final container = buildContainer();
await actionOf(container).proxyDelayTest(const Proxy(name: '', type: ''));
expect(container.read(delayDataSourceProvider), isEmpty);
expect(container.read(pendingDelayTestsProvider), isEmpty);
verifyNever(() => core.asyncTestDelay(any(), any()));
});
});
group('delayTest', () {
test('measures every proxy and bumps the sort counter', () async {
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) async => Delay(
name: invocation.positionalArguments[1] as String,
url: _testUrl,
value: 10,
),
);
final container = _delayContainer(buildContainer);
final before = container.read(sortNumProvider);
await actionOf(
container,
).delayTest(const [_proxy, Proxy(name: 'HK-02', type: 'ss')]);
final delays = container.read(delayDataSourceProvider)[_testUrl];
expect(delays?['HK-01'], 10);
expect(delays?['HK-02'], 10);
expect(container.read(sortNumProvider), before + 1);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('lands a run of quick results in batches, not one per node', () async {
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) async => Delay(
name: invocation.positionalArguments[1] as String,
url: _testUrl,
value: 10,
),
);
final container = _delayContainer(buildContainer);
var delayChanges = 0;
var pendingChanges = 0;
container.listen(delayDataSourceProvider, (_, _) => delayChanges++);
container.listen(pendingDelayTestsProvider, (_, _) => pendingChanges++);
final proxies = List.generate(
1000,
(index) => Proxy(name: 'HK-$index', type: 'ss'),
);
await actionOf(container).delayTest(proxies);
expect(
container.read(delayDataSourceProvider)[_testUrl],
hasLength(1000),
);
expect(container.read(pendingDelayTestsProvider), isEmpty);
expect(delayChanges, 1);
expect(pendingChanges, lessThanOrEqualTo(2));
});
test(
'marks only nodes the pool runs as running, the rest queued',
() async {
final answers = <String, Completer<Delay?>>{};
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) => answers
.putIfAbsent(
invocation.positionalArguments[1] as String,
Completer<Delay?>.new,
)
.future,
);
final container = _delayContainer(buildContainer);
final proxies = List.generate(
maxConcurrentDelayTests + 1,
(index) => Proxy(name: 'HK-$index', type: 'ss'),
);
DelayTestPhase? phaseOf(int index) => container.read(
pendingDelayTestsProvider,
)[delayTestKey(_testUrl, 'HK-$index')];
final run = actionOf(container).delayTest(proxies);
expect(phaseOf(0), DelayTestPhase.running);
expect(phaseOf(maxConcurrentDelayTests - 1), DelayTestPhase.running);
expect(phaseOf(maxConcurrentDelayTests), DelayTestPhase.queued);
answers['HK-0']!.complete(null);
await Future<void>.delayed(renderThrottleDuration * 2);
expect(phaseOf(0), isNull);
expect(phaseOf(maxConcurrentDelayTests), DelayTestPhase.running);
for (final answer in answers.values) {
if (!answer.isCompleted) {
answer.complete(null);
}
}
await run;
expect(container.read(pendingDelayTestsProvider), isEmpty);
},
);
test('probes a node that appears twice only once', () async {
when(() => core.asyncTestDelay(_testUrl, 'HK-01')).thenAnswer(
(_) async => const Delay(name: 'HK-01', url: _testUrl, value: 10),
);
final container = _delayContainer(buildContainer);
await actionOf(container).delayTest(const [_proxy, _proxy]);
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
});
test('testing groups probes a shared node once per test URL', () async {
const otherUrl = 'http://other.test';
when(() => core.asyncTestDelay(any(), any())).thenAnswer(
(invocation) async => Delay(
url: invocation.positionalArguments[0] as String,
name: invocation.positionalArguments[1] as String,
value: 10,
),
);
final container = _delayContainer(buildContainer);
final before = container.read(sortNumProvider);
await actionOf(container).delayTestGroups([
_group('A', const [_proxy]),
_group('B', const [_proxy, Proxy(name: 'HK-02', type: 'ss')]),
const Group(
type: GroupType.URLTest,
name: 'C',
testUrl: otherUrl,
all: [_proxy],
),
]);
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
verify(() => core.asyncTestDelay(_testUrl, 'HK-02')).called(1);
verify(() => core.asyncTestDelay(otherUrl, 'HK-01')).called(1);
expect(container.read(sortNumProvider), before + 1);
});
test(
'testing a page group probes the nodes the timeout filter hides',
() async {
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) async => Delay(
name: invocation.positionalArguments[1] as String,
url: _testUrl,
value: 10,
),
);
final container = _delayContainer(buildContainer);
container
.read(groupsProvider.notifier)
.update(
(_) => [
_group('Proxy', const [
_proxy,
Proxy(name: 'HK-02', type: 'ss'),
Proxy(name: 'JP-01', type: 'ss'),
]),
],
);
container
.read(delayDataSourceProvider.notifier)
.setDelay(const Delay(url: _testUrl, name: 'HK-02', value: -1));
container
.read(proxiesStyleSettingProvider.notifier)
.update((state) => state.copyWith(hideTimeoutProxies: true));
container.read(queryProvider(QueryTag.proxies).notifier).value = 'HK';
await actionOf(container).delayTestPageGroup('Proxy');
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
verify(() => core.asyncTestDelay(_testUrl, 'HK-02')).called(1);
verifyNever(() => core.asyncTestDelay(_testUrl, 'JP-01'));
},
);
test('a page group holds its testing mark until the run settles', () async {
final release = Completer<Delay?>();
when(
() => core.asyncTestDelay(_testUrl, 'HK-01'),
).thenAnswer((_) => release.future);
final container = _delayContainer(buildContainer);
container
.read(groupsProvider.notifier)
.update(
(_) => [
_group('Proxy', const [_proxy]),
],
);
final run = actionOf(container).delayTestPageGroup('Proxy');
await actionOf(container).delayTestPageGroup('Proxy');
expect(container.read(delayTestingGroupsProvider), {'Proxy'});
release.completeError(
const CoreMethodException(code: 'internal_error', message: 'boom'),
);
await run;
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
expect(container.read(delayTestingGroupsProvider), isEmpty);
});
test('stops the run once the transport is gone', () async {
var calls = 0;
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer((_) async {
calls++;
throw const CoreMethodException(
code: 'transport_disconnected',
message: 'the Core is gone',
);
});
final container = _delayContainer(buildContainer);
final proxies = List.generate(
maxConcurrentDelayTests * 3,
(index) => Proxy(name: 'HK-$index', type: 'ss'),
);
await actionOf(container).delayTest(proxies);
expect(calls, lessThanOrEqualTo(maxConcurrentDelayTests));
expect(calls, lessThan(proxies.length));
expect(container.read(delayDataSourceProvider), isEmpty);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('a proxy that answers nothing leaves the rest of the run', () async {
var calls = 0;
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer((
invocation,
) async {
calls++;
final name = invocation.positionalArguments[1] as String;
if (name == 'HK-1') {
return null;
}
return Delay(name: name, url: _testUrl, value: 10);
});
final container = _delayContainer(buildContainer);
final proxies = List.generate(
8,
(index) => Proxy(name: 'HK-$index', type: 'ss'),
);
await actionOf(container).delayTest(proxies);
expect(calls, proxies.length);
final delays = container.read(delayDataSourceProvider)[_testUrl];
expect(delays?.length, proxies.length - 1);
expect(delays?['HK-1'], isNull);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('a proxy that fails leaves the rest of the run', () async {
var calls = 0;
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer((
invocation,
) async {
calls++;
final name = invocation.positionalArguments[1] as String;
if (name == 'HK-1') {
throw const CoreMethodException(
code: 'internal_error',
message: 'internal panic',
);
}
return Delay(name: name, url: _testUrl, value: 10);
});
final container = _delayContainer(buildContainer);
final proxies = List.generate(
8,
(index) => Proxy(name: 'HK-$index', type: 'ss'),
);
await actionOf(container).delayTest(proxies);
expect(calls, proxies.length);
final delays = container.read(delayDataSourceProvider)[_testUrl];
expect(delays?.length, proxies.length - 1);
expect(delays?['HK-1'], isNull);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('drops every spinner when the Core goes away mid-run', () async {
final release = Completer<Delay?>();
when(
() => core.asyncTestDelay(_testUrl, any()),
).thenAnswer((_) => release.future);
final container = _delayContainer(buildContainer);
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
final run = actionOf(
container,
).delayTest(const [_proxy, Proxy(name: 'HK-02', type: 'ss')]);
await Future<void>.delayed(Duration.zero);
expect(container.read(pendingDelayTestsProvider), isNotEmpty);
container.read(coreStatusProvider.notifier).value =
CoreStatus.disconnected;
expect(container.read(pendingDelayTestsProvider), isEmpty);
release.complete(const Delay(name: 'HK-01', url: _testUrl, value: 10));
await run;
expect(container.read(delayDataSourceProvider), isEmpty);
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
});
group('updateProvider', () {
test(
'stores the refreshed provider and clears the updating flag',
() async {
final refreshed = _provider('geo', count: 5);
final release = Completer<String>();
when(
() => core.updateExternalProvider('geo'),
).thenAnswer((_) => release.future);
when(
() => core.getExternalProvider('geo'),
).thenAnswer((_) async => refreshed);
final container = buildContainer();
container.read(providersProvider.notifier).value = [_provider('geo')];
final update = actionOf(
container,
).updateProvider(_provider('geo'), showLoading: true);
expect(container.read(isUpdatingProvider('provider_geo')), isTrue);
release.complete('');
final message = await update;
expect(message, isEmpty);
expect(container.read(providersProvider), [refreshed]);
expect(container.read(isUpdatingProvider('provider_geo')), isFalse);
},
);
testWidgets('the stale sweep does not interrupt the provider update', (
tester,
) async {
final refreshed = _provider('geo', count: 8);
final release = Completer<String>();
when(
() => core.updateExternalProvider('geo'),
).thenAnswer((_) => release.future);
when(
() => core.getExternalProvider('geo'),
).thenAnswer((_) async => refreshed);
final container = buildContainer();
container.read(updatingActionProvider.notifier);
container.read(providersProvider.notifier).value = [_provider('geo')];
final update = actionOf(
container,
).updateProvider(_provider('geo'), showLoading: true);
await tester.pump(updatingStaleTimeout + updatingSweepInterval);
expect(container.read(isUpdatingProvider('provider_geo')), isFalse);
release.complete('');
expect(await update, isEmpty);
expect(container.read(providersProvider), [refreshed]);
await tester.pump(Duration.zero);
});
testWidgets('a core disconnect clears the provider updating state', (
tester,
) async {
final release = Completer<String>();
when(
() => core.updateExternalProvider('geo'),
).thenAnswer((_) => release.future);
when(
() => core.getExternalProvider('geo'),
).thenAnswer((_) async => _provider('geo'));
final container = buildContainer();
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
final update = actionOf(
container,
).updateProvider(_provider('geo'), showLoading: true);
expect(container.read(isUpdatingProvider('provider_geo')), isTrue);
container.read(coreStatusProvider.notifier).value =
CoreStatus.disconnected;
await tester.pump();
expect(container.read(isUpdatingProvider('provider_geo')), isFalse);
release.complete('');
await update;
await tester.pump(Duration.zero);
});
test('returns the core message without storing a provider', () async {
when(
() => core.updateExternalProvider('geo'),
).thenAnswer((_) async => 'update failed');
final container = buildContainer();
final message = await actionOf(
container,
).updateProvider(_provider('geo'), showLoading: true);
expect(message, 'update failed');
expect(container.read(providersProvider), isEmpty);
expect(container.read(isUpdatingProvider('provider_geo')), isFalse);
verifyNever(() => core.getExternalProvider(any()));
});
test('clears the updating flag when core throws', () async {
when(
() => core.updateExternalProvider('geo'),
).thenThrow(StateError('boom'));
final container = buildContainer();
await expectLater(
actionOf(container).updateProvider(_provider('geo'), showLoading: true),
throwsStateError,
);
expect(container.read(isUpdatingProvider('provider_geo')), isFalse);
});
});
group('sideLoadExternalProvider', () {
test('stores the provider after a successful side load', () async {
final refreshed = _provider('rules', count: 3);
final release = Completer<String>();
when(
() => core.sideLoadExternalProvider(
providerName: 'rules',
data: 'payload',
),
).thenAnswer((_) => release.future);
when(
() => core.getExternalProvider('rules'),
).thenAnswer((_) async => refreshed);
final container = buildContainer();
container.read(providersProvider.notifier).value = [_provider('rules')];
final sideLoad = actionOf(container).sideLoadExternalProvider(
_provider('rules'),
'payload',
showLoading: true,
);
expect(container.read(isUpdatingProvider('provider_rules')), isTrue);
release.complete('');
final message = await sideLoad;
expect(message, isEmpty);
expect(container.read(providersProvider), [refreshed]);
expect(container.read(isUpdatingProvider('provider_rules')), isFalse);
});
test('surfaces the core message and skips the refresh', () async {
when(
() => core.sideLoadExternalProvider(providerName: 'rules', data: 'bad'),
).thenAnswer((_) async => 'invalid payload');
final container = buildContainer();
final message = await actionOf(
container,
).sideLoadExternalProvider(_provider('rules'), 'bad');
expect(message, 'invalid payload');
verifyNever(() => core.getExternalProvider(any()));
});
});
group('current profile mutations', () {
test('updateCurrentGroupName writes the new group onto the profile', () {
final profile = Profile.normal(label: 'p');
final container = buildContainer(profile: profile);
actionOf(container).updateCurrentGroupName('Proxy');
expect(container.read(profilesProvider).single.currentGroupName, 'Proxy');
});
test('updateCurrentGroupName is a no-op for the same group', () {
final profile = Profile.normal(
label: 'p',
).copyWith(currentGroupName: 'Proxy');
final container = buildContainer(profile: profile);
actionOf(container).updateCurrentGroupName('Proxy');
expect(container.read(profilesProvider).single, same(profile));
});
test('updateCurrentUnfoldSet is a no-op without a current profile', () {
final container = buildContainer();
expect(
() => actionOf(container).updateCurrentUnfoldSet({'Proxy'}),
returnsNormally,
);
expect(container.read(profilesProvider), isEmpty);
});
test('updateCurrentUnfoldSet stores the set on the current profile', () {
final profile = Profile.normal(label: 'p');
final container = buildContainer(profile: profile);
actionOf(container).updateCurrentUnfoldSet({'Proxy', 'Auto'});
expect(container.read(profilesProvider).single.unfoldSet, {
'Proxy',
'Auto',
});
});
});
}