Files
FlClash/test/features/custom_profile_views_test.dart
T
chen08209 0bac50029a feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-17 17:21:43 +08:00

1846 lines
59 KiB
Dart

import 'dart:async';
import 'package:fl_clash/common/app_localizations.dart';
import 'package:fl_clash/common/indexing.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/pages/editor.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:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/profiles/custom/custom_proxies.dart';
import 'package:fl_clash/views/profiles/custom/dialers.dart';
import 'package:fl_clash/views/profiles/custom/groups.dart';
import 'package:fl_clash/views/profiles/custom/rules.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _TestProfileRules extends ProfileRules {
final List<Rule> initial;
_TestProfileRules(this.initial);
@override
Stream<List<Rule>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
@override
void optimistic(List<Rule> next, FutureOr<void> Function() action) {
value = next;
}
}
class _TestProxyGroups extends ProxyGroups {
final List<ProxyGroup> initial;
final List<Set<int>> deleted = [];
final List<(int, int)> orders = [];
_TestProxyGroups(this.initial);
@override
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) => orders.add((oldIndex, newIndex));
@override
void delAll(Iterable<int> proxyGroupIds) {
deleted.add(proxyGroupIds.toSet());
}
@override
void optimistic(List<ProxyGroup> next, FutureOr<void> Function() action) {
value = next;
}
}
class _RecordingCustomProxies extends CustomProxies {
final List<CustomProxy> initial;
final saved = <CustomProxy>[];
_RecordingCustomProxies([this.initial = const []]);
@override
Stream<List<CustomProxy>> build() => Stream.value(initial);
@override
void put(CustomProxy proxy) {
saved.add(proxy);
value = [...value, proxy];
}
}
class _RecordingProxyDialers extends ProxyDialers {
final calls = <(int, String?)>[];
@override
Stream<Map<int, String>> build(int profileId) => Stream.value(const {});
@override
void set(int proxyId, String? target) => calls.add((proxyId, target));
}
class _MockCore extends Mock implements CoreHandlerInterface {}
class _RecordingClashProvidersAction extends ClashProvidersAction {
final applied = <Set<String>>[];
@override
Future<void> applyIfProxiesNamed(Set<String> names) async {
applied.add(names);
}
}
class _TestCustomData extends Notifier<CustomProfileData> {
@override
CustomProfileData build() {
return const CustomProfileData(ruleTargets: {'DIRECT'});
}
void setRuleTargets(Set<String> ruleTargets) {
state = state.copyWith(ruleTargets: ruleTargets);
}
}
final _testCustomDataProvider =
NotifierProvider<_TestCustomData, CustomProfileData>(_TestCustomData.new);
Future<void> _dismissSaveBlocked(WidgetTester tester, String reason) async {
final dialog = find.byType(CommonDialog);
expect(
find.descendant(
of: dialog,
matching: find.text(AppLocalizations.current.cannotSave),
),
findsOneWidget,
);
expect(
find.descendant(of: dialog, matching: find.textContaining(reason)),
findsOneWidget,
);
await tester.tap(find.text(AppLocalizations.current.confirm));
await tester.pumpAndSettle();
}
Future<void> _expectClosesWithoutIssues(WidgetTester tester) async {
final form = find.byType(ErrorBanner, skipOffstage: false);
final message = find.descendant(
of: form,
matching: find.byType(Text),
skipOffstage: false,
);
for (var frame = 0; frame < 120 && form.evaluate().isNotEmpty; frame++) {
await tester.pump(const Duration(milliseconds: 16));
expect(message, findsNothing);
}
expect(form, findsNothing);
}
Finder _menuItem(String label) => find.descendant(
of: find.byType(CommonPopupMenu),
matching: find.text(label),
);
Widget _proxyGroupsPage(int profileId) => CommonScaffold(
title: 'Groups',
body: CustomScrollView(slivers: [CustomProxyGroupsSection(profileId)]),
);
void _setViewport(WidgetTester tester) {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
class _GroupsInUse extends ProfilesAction {
final Set<String> names;
_GroupsInUse(this.names);
@override
Future<Set<String>> groupsInUse(int profileId, Set<int> groupIds) async =>
names;
}
Future<_TestProxyGroups> _pumpProxyGroups(
WidgetTester tester, {
bool pushed = false,
Set<String> inUse = const {},
}) async {
_setViewport(tester);
final profile = Profile.custom();
final proxyGroups = List.generate(
3,
(index) => ProxyGroup(
id: 100 + index,
profileId: profile.id,
name: 'Group $index',
type: GroupType.Selector,
proxies: const ['DIRECT'],
),
);
final notifier = _TestProxyGroups(proxyGroups);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
proxyGroupsProvider.overrideWith2((_) => notifier),
profilesActionProvider.overrideWith(() => _GroupsInUse(inUse)),
customProfileDataProvider(profile.id).overrideWithValue(
CustomProfileData(
proxyGroups: proxyGroups,
proxyProviders: const {'provider'},
ruleTargets: {
...RuleTarget.baseTargets,
...proxyGroups.map((group) => group.name),
},
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: pushed ? const SizedBox() : _proxyGroupsPage(profile.id),
),
),
);
if (pushed) {
unawaited(
tester
.state<NavigatorState>(find.byType(Navigator).first)
.push(
MaterialPageRoute<void>(
builder: (_) => _proxyGroupsPage(profile.id),
),
),
);
await tester.pumpAndSettle();
}
await tester.pump();
return notifier;
}
Future<_TestProfileRules> _pumpRules(WidgetTester tester) async {
_setViewport(tester);
final profile = Profile.custom();
final orders = indexing.generateNKeys(3);
final notifier = _TestProfileRules([
for (final (index, domain) in ['a.com', 'b.com', 'c.com'].indexed)
Rule.parse(
'DOMAIN,$domain,DIRECT',
id: index + 1,
).copyWith(order: orders[index]),
]);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileRulesProvider.overrideWith2((_) => notifier),
customProfileDataProvider(
profile.id,
).overrideWithValue(const CustomProfileData(ruleTargets: {'DIRECT'})),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: CustomRulesView(profile.id)),
),
);
await tester.pump();
return notifier;
}
void main() {
group('rules list menu', () {
final menu = find.byType(CommonPopupMenu);
Finder menuItem(String label) =>
find.descendant(of: menu, matching: find.text(label));
testWidgets('a right click copies the rule right below it', (tester) async {
final notifier = await _pumpRules(tester);
await tester.tap(
find.text('a.com'),
buttons: kSecondaryButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
await tester.tap(menuItem(AppLocalizations.current.copy));
await tester.pumpAndSettle();
expect(notifier.value.map((rule) => rule.content), [
'a.com',
'a.com',
'b.com',
'c.com',
]);
expect(notifier.value[1].id, isNot(1));
expect(find.text('a.com'), findsNWidgets(2));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a right click deletes the rule once confirmed', (
tester,
) async {
final notifier = await _pumpRules(tester);
await tester.tap(
find.text('b.com'),
buttons: kSecondaryButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
await tester.tap(menuItem(AppLocalizations.current.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.value.map((rule) => rule.id), [1, 3]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a right click opens it among search results too', (
tester,
) async {
await _pumpRules(tester);
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'c.com');
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(ContextMenuRegion),
matching: find.text('c.com'),
),
buttons: kSecondaryButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(menuItem(AppLocalizations.current.copy), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
});
testWidgets('a rules selection offers delete and select all but no copy', (
tester,
) async {
await _pumpRules(tester);
await tester.tap(find.byType(CommonCheckBox).at(0));
await tester.pump();
expect(find.byTooltip(AppLocalizations.current.copy), findsNothing);
expect(
find.descendant(
of: find.byType(TonalButtonGroup),
matching: find.byType(IconButton),
),
findsNWidgets(2),
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('groups list rounds only the first and last rows', (
tester,
) async {
await _pumpProxyGroups(tester);
final rows = find.byType(DecorationListItem);
expect(rows, findsNWidgets(3));
final color = Theme.of(
tester.element(rows.first),
).colorScheme.outlineVariant;
final separator = paints
..something(
(method, arguments) =>
method == #drawRect &&
(arguments[1] as Paint).color.toARGB32() == color.toARGB32(),
);
expect(tester.renderObject(rows.first), separator);
expect(tester.renderObject(rows.last), isNot(separator));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('groups list deletes the checked groups', (tester) async {
final notifier = await _pumpProxyGroups(tester);
await tester.tap(find.byType(CommonCheckBox).at(0));
await tester.pump();
await tester.tap(find.text('Group 2'));
await tester.pump();
await tester.tap(find.byTooltip(AppLocalizations.current.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.deleted.single, {100, 102});
await tester.pumpWidget(const SizedBox.shrink());
});
group('groups list menu', () {
final menu = find.byType(CommonPopupMenu);
Finder menuItem(String label) =>
find.descendant(of: menu, matching: find.text(label));
Future<void> rightClick(WidgetTester tester, String name) async {
await tester.tap(
find.text(name),
buttons: kSecondaryButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
}
testWidgets('a right click opens it, and copy numbers the copies on', (
tester,
) async {
final notifier = await _pumpProxyGroups(tester);
await rightClick(tester, 'Group 0');
expect(menuItem(AppLocalizations.current.delete), findsOneWidget);
await tester.tap(menuItem(AppLocalizations.current.copy));
await tester.pumpAndSettle();
expect(menu, findsNothing);
final copy = notifier.value[1];
expect(copy.name, 'Group 0-01');
expect(copy.id, isNot(100));
expect(copy.proxies, ['DIRECT']);
expect(find.text('Group 0-01'), findsOneWidget);
await rightClick(tester, 'Group 0-01');
await tester.tap(menuItem(AppLocalizations.current.copy));
await tester.pumpAndSettle();
expect(notifier.value.map((group) => group.name), [
'Group 0',
'Group 0-01',
'Group 0-02',
'Group 1',
'Group 2',
]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a touch long press opens it without moving the row', (
tester,
) async {
final notifier = await _pumpProxyGroups(tester);
await tester.longPress(find.text('Group 0'));
await tester.pumpAndSettle();
expect(menuItem(AppLocalizations.current.copy), findsOneWidget);
expect(notifier.orders, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a right click opens it, and delete asks first', (
tester,
) async {
final notifier = await _pumpProxyGroups(tester);
await tester.tap(
find.text('Group 1'),
buttons: kSecondaryButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
await tester.tap(menuItem(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(
find.text(AppLocalizations.current.confirmDeleteProxyGroup),
findsOneWidget,
);
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.deleted.single, {101});
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('delete says when the profile still names the group', (
tester,
) async {
final notifier = await _pumpProxyGroups(tester, inUse: {'Group 1'});
await tester.tap(
find.text('Group 1'),
buttons: kSecondaryButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
await tester.tap(menuItem(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(
find.text(AppLocalizations.current.proxyGroupInUse('Group 1')),
findsOneWidget,
);
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.deleted.single, {101});
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('the edit page warns when the profile still names the group', (
tester,
) async {
final notifier = await _pumpProxyGroups(tester, inUse: {'Group 1'});
await tester.tap(find.text('Group 1'));
await tester.pumpAndSettle();
final delete = find.descendant(
of: find.byType(EditProxyGroupView),
matching: find.text(AppLocalizations.current.delete),
);
await tester.ensureVisible(delete);
await tester.pumpAndSettle();
await tester.tap(delete);
await tester.pumpAndSettle();
expect(
find.text(AppLocalizations.current.proxyGroupInUse('Group 1')),
findsOneWidget,
);
await tester.tap(
find.descendant(
of: find.byType(CommonDialog),
matching: find.text(AppLocalizations.current.delete),
),
);
await tester.pumpAndSettle();
expect(notifier.deleted.single, {101});
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a row only drags by its handle in sort mode', (tester) async {
final notifier = await _pumpProxyGroups(tester);
final rowHeight = tester.getSize(find.byType(DecorationListItem).first);
final pressed = await tester.startGesture(
tester.getCenter(find.text('Group 0')),
kind: PointerDeviceKind.mouse,
);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50));
await pressed.moveBy(Offset(0, rowHeight.height * 2));
await tester.pump();
await pressed.up();
await tester.pumpAndSettle();
expect(notifier.orders, isEmpty);
await tester.tap(find.byGlyph(AppGlyphs.sort));
await tester.pumpAndSettle();
expect(find.byType(CommonCheckBox).hitTestable(), findsNothing);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(SortHandle).first),
);
await tester.pump(kPressTimeout);
await gesture.moveBy(const Offset(0, 20));
await tester.pump();
await gesture.moveBy(Offset(0, rowHeight.height * 2));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(menu, findsNothing);
expect(notifier.orders.single.$1, 0);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('sort mode stays in the section and keeps adding', (
tester,
) async {
await _pumpProxyGroups(tester, pushed: true);
final l10n = AppLocalizations.current;
await tester.tap(find.byTooltip(l10n.sort));
await tester.pumpAndSettle();
expect(find.byType(CommonCheckBox).hitTestable(), findsNothing);
expect(find.byType(BackButton).hitTestable(), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(CommonCheckBox).hitTestable(), findsNWidgets(3));
await tester.tap(find.byTooltip(l10n.sort));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(l10n.add));
await tester.pumpAndSettle();
expect(find.byType(EditProxyGroupView), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('stays closed while groups are being checked', (tester) async {
await _pumpProxyGroups(tester);
await tester.tap(find.byType(CommonCheckBox).at(0));
await tester.pump();
await rightClick(tester, 'Group 1');
expect(menu, findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
});
});
testWidgets('group editor sets the options mihomo reads per type', (
tester,
) async {
await _pumpProxyGroups(tester);
tester.view.physicalSize = const Size(1400, 2600);
final l10n = AppLocalizations.current;
Finder row(String title) =>
find.ancestor(of: find.text(title), matching: find.byType(FormRow));
Finder valueIn(String title, String value) =>
find.descendant(of: row(title), matching: find.text(value));
Future<void> tapRow(String title) async {
await Scrollable.ensureVisible(
tester.element(row(title)),
alignment: 0.5,
);
await tester.pumpAndSettle();
await tester.tap(row(title));
await tester.pumpAndSettle();
}
final addOption = find.widgetWithText(FormRow, l10n.addSettingEntry);
Finder offered(String title) => find.descendant(
of: find.byWidgetPredicate((widget) => widget is SelectionSheet),
matching: find.text(title),
);
Future<void> openOptions() async {
await tester.ensureVisible(addOption);
await tester.tap(addOption);
await tester.pumpAndSettle();
}
Future<void> add(String title) async {
await openOptions();
await tester.tap(offered(title));
await tester.pumpAndSettle();
expect(offered(title), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
}
await tester.tap(find.text('Group 0'));
await tester.pumpAndSettle();
expect(row(l10n.emptyFallback), findsNothing);
expect(row(l10n.defaultSelected), findsNothing);
await add(l10n.defaultSelected);
expect(valueIn(l10n.defaultSelected, l10n.defaultText), findsOneWidget);
await tapRow(l10n.defaultSelected);
await tester.tap(find.text('DIRECT'));
await tester.pumpAndSettle();
expect(valueIn(l10n.defaultSelected, 'DIRECT'), findsOneWidget);
await add(l10n.emptyFallback);
expect(valueIn(l10n.emptyFallback, 'COMPATIBLE'), findsOneWidget);
await tapRow(l10n.emptyFallback);
await tester.tap(find.text('REJECT'));
await tester.pumpAndSettle();
expect(valueIn(l10n.emptyFallback, 'REJECT'), findsOneWidget);
await tapRow(l10n.proxyType);
await tester.tap(find.text(GroupType.LoadBalance.name));
await tester.pumpAndSettle();
expect(row(l10n.defaultSelected), findsNothing);
await add(l10n.strategy);
await tapRow(l10n.strategy);
await tester.tap(find.text(LoadBalanceStrategy.roundRobin.value));
await tester.pumpAndSettle();
await openOptions();
expect(offered(l10n.hashByInUser), findsNothing);
expect(offered(l10n.defaultSelected), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
await tapRow(l10n.proxyType);
await tester.tap(find.text(GroupType.Selector.name).last);
await tester.pumpAndSettle();
expect(valueIn(l10n.defaultSelected, 'DIRECT'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('group options left at their defaults stay after reopening', (
tester,
) async {
await _pumpProxyGroups(tester);
tester.view.physicalSize = const Size(1400, 2600);
final l10n = AppLocalizations.current;
Finder row(String title) =>
find.ancestor(of: find.text(title), matching: find.byType(FormRow));
final addOption = find.widgetWithText(FormRow, l10n.addSettingEntry);
Future<void> add(String title) async {
await tester.ensureVisible(addOption);
await tester.tap(addOption);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byWidgetPredicate((widget) => widget is SelectionSheet),
matching: find.text(title),
),
);
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
}
Future<void> setType(GroupType type) async {
await tester.ensureVisible(row(l10n.proxyType));
await tester.tap(row(l10n.proxyType));
await tester.pumpAndSettle();
await tester.tap(find.text(type.name).last);
await tester.pumpAndSettle();
}
final loadBalanceOptions = [
l10n.emptyFallback,
l10n.expectedStatus,
l10n.hashByInUser,
];
await tester.tap(find.text('Group 0'));
await tester.pumpAndSettle();
await add(l10n.defaultSelected);
await setType(GroupType.LoadBalance);
for (final title in loadBalanceOptions) {
await add(title);
}
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await _expectClosesWithoutIssues(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('Group 0'));
await tester.pumpAndSettle();
for (final title in loadBalanceOptions) {
expect(row(title), findsOneWidget, reason: title);
}
ProviderScope.containerOf(tester.element(find.byType(EditProxyGroupView)))
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(emptyFallback: ''));
await tester.pumpAndSettle();
expect(
find.descendant(
of: row(l10n.emptyFallback),
matching: find.text('COMPATIBLE'),
),
findsOneWidget,
);
await setType(GroupType.Selector);
expect(row(l10n.defaultSelected), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('group options emptied after holding a value are gone after '
'reopening', (tester) async {
await _pumpProxyGroups(tester);
tester.view.physicalSize = const Size(1400, 2600);
final l10n = AppLocalizations.current;
final titles = [l10n.testUrl, l10n.timeout, l10n.expectedStatus];
Finder row(String title) =>
find.ancestor(of: find.text(title), matching: find.byType(FormRow));
Finder fieldIn(String title) =>
find.descendant(of: row(title), matching: find.byType(EditableText));
final addOption = find.widgetWithText(FormRow, l10n.addSettingEntry);
await tester.tap(find.text('Group 0'));
await tester.pumpAndSettle();
for (final title in titles) {
await tester.ensureVisible(addOption);
await tester.tap(addOption);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byWidgetPredicate((widget) => widget is SelectionSheet),
matching: find.text(title),
),
);
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
}
await tester.enterText(fieldIn(l10n.expectedStatus), '200');
for (final title in titles) {
await tester.enterText(fieldIn(title), '');
}
await tester.pump();
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await _expectClosesWithoutIssues(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('Group 0'));
await tester.pumpAndSettle();
for (final title in titles) {
expect(row(title), findsNothing, reason: title);
}
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a saved new group closes without flagging its own copy', (
tester,
) async {
_setViewport(tester);
final profile = Profile.custom();
final proxyGroups = _TestProxyGroups(const []);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
proxyGroupsProvider.overrideWith2((_) => proxyGroups),
customProfileDataProvider(profile.id).overrideWith((ref) {
final groups =
ref.watch(proxyGroupsProvider(profile.id)).value ??
const <ProxyGroup>[];
return CustomProfileData(
proxyGroups: groups,
ruleTargets: {
...RuleTarget.baseTargets,
...groups.map((group) => group.name),
},
);
}),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: _proxyGroupsPage(profile.id)),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
ProviderScope.containerOf(tester.element(find.byType(EditProxyGroupView)))
.read(proxyGroupProvider.notifier)
.update(
(state) => state.copyWith(name: 'Mine', proxies: const ['DIRECT']),
);
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await _expectClosesWithoutIssues(tester);
expect(proxyGroups.value.map((group) => group.name), ['Mine']);
await tester.pumpWidget(const SizedBox.shrink());
});
group('saving a group', () {
late _TestProxyGroups proxyGroups;
late _MockCore core;
late ProviderContainer container;
Future<ProxyGroupProvider> openNewGroup(WidgetTester tester) async {
_setViewport(tester);
final profile = Profile.custom();
proxyGroups = _TestProxyGroups(const []);
core = _MockCore();
registerFallbackValue(<String>[]);
container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
proxyGroupsProvider.overrideWith2((_) => proxyGroups),
customProfileDataProvider(profile.id).overrideWith((ref) {
final groups =
ref.watch(proxyGroupsProvider(profile.id)).value ??
const <ProxyGroup>[];
return CustomProfileData(
proxyGroups: groups,
ruleTargets: {
...RuleTarget.baseTargets,
...groups.map((group) => group.name),
},
);
}),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: _proxyGroupsPage(profile.id)),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
return ProviderScope.containerOf(
tester.element(find.byType(EditProxyGroupView)),
).read(proxyGroupProvider.notifier);
}
testWidgets('an empty fallback the core refuses keeps the group unsaved', (
tester,
) async {
final form = await openNewGroup(tester);
form.update(
(state) => state.copyWith(
name: 'Mine',
proxies: const ['DIRECT'],
emptyFallback: 'Gone',
),
);
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await tester.pumpAndSettle();
expect(proxyGroups.value, isEmpty);
expect(find.byType(EditProxyGroupView), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('edits made while the core checks the filters are saved', (
tester,
) async {
final form = await openNewGroup(tester);
final checked = Completer<List<String>>();
when(() => core.validateFilters(any())).thenAnswer((_) => checked.future);
form.update(
(state) => state.copyWith(
name: 'Mine',
proxies: const ['DIRECT'],
filter: 'HK',
),
);
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await tester.pump();
form.update((state) => state.copyWith(name: 'Later'));
when(() => core.validateFilters(any())).thenAnswer((_) async => ['', '']);
checked.complete(['', '']);
await _expectClosesWithoutIssues(tester);
expect(proxyGroups.value.map((group) => group.name), ['Later']);
await tester.pumpWidget(const SizedBox.shrink());
});
});
testWidgets('group editor keeps long values inside its rows', (tester) async {
tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final profile = Profile.custom();
final proxyGroups = [
ProxyGroup(
id: 100,
profileId: profile.id,
name: 'a-very-long-proxy-group-name-that-goes-on-and-on',
type: GroupType.URLTest,
icon: 'https://example.com/a/very/long/path/to/an/icon/file/name.png',
filter: '(?i)hk|hong ?kong|an extremely long filter expression here',
url: 'https://www.gstatic.com/generate_204/a/very/long/url/path',
),
];
final core = _MockCore();
registerFallbackValue(<String>[]);
when(() => core.validateFilters(any())).thenAnswer((_) async => ['', '']);
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(proxyGroups)),
customProfileDataProvider(profile.id).overrideWithValue(
CustomProfileData(
proxyGroups: proxyGroups,
ruleTargets: RuleTarget.baseTargets,
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(360, 800));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: _proxyGroupsPage(profile.id)),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.tap(find.text(proxyGroups.single.name));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byType(FormRow), findsWidgets);
await tester.enterText(find.byType(TextFormField).first, 'y' * 400);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a provider dragged above the proxies is listed first', (
tester,
) async {
await _pumpProxyGroups(tester);
tester.view.physicalSize = const Size(1400, 2600);
await tester.tap(find.text('Group 0'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(EditProxyGroupView)),
);
container
.read(proxyGroupProvider.notifier)
.update(
(state) => state.copyWith(
proxies: const ['DIRECT', 'REJECT'],
use: const ['provider'],
),
);
await tester.pumpAndSettle();
Finder row(String name) => find.ancestor(
of: find.text(name),
matching: find.byType(DecorationListItem),
);
final handle = find.descendant(
of: row('provider'),
matching: find.byType(SortHandle),
);
await tester.ensureVisible(handle);
await tester.pumpAndSettle();
final directTop = tester.getTopLeft(row('DIRECT')).dy;
final rise = tester.getCenter(handle).dy - directTop;
final gesture = await tester.startGesture(tester.getCenter(handle));
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50));
for (var step = 0; step < 4; step++) {
await gesture.moveBy(Offset(0, -rise / 4 - 10));
await tester.pump(const Duration(milliseconds: 100));
}
await tester.pump(const Duration(milliseconds: 500));
expect(tester.getTopLeft(row('DIRECT')).dy, greaterThan(directTop));
await gesture.up();
await tester.pumpAndSettle();
final group = container.read(proxyGroupProvider);
expect(group.proxies, ['DIRECT', 'REJECT']);
expect(group.use, ['provider']);
expect(group.usePosition, {'provider': 0});
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('rule invalid state refreshes when rule targets change', (
tester,
) async {
_setViewport(tester);
final profile = Profile.custom();
final rules = [
const Rule(
id: 1,
content: 'example.com',
ruleTarget: 'missing',
order: '1',
),
];
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileRulesProvider.overrideWith2((_) => _TestProfileRules(rules)),
customProfileDataProvider(profile.id).overrideWith((ref) {
return ref.watch(_testCustomDataProvider);
}),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: CustomRulesView(profile.id)),
),
);
await tester.pump();
expect(find.byGlyph(AppGlyphs.info), findsOneWidget);
container.read(_testCustomDataProvider.notifier).setRuleTargets({
...container.read(_testCustomDataProvider).ruleTargets,
'missing',
});
await tester.pump();
expect(find.byGlyph(AppGlyphs.info), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets(
'a rule the core would reject is flagged despite a DIRECT target',
(tester) async {
_setViewport(tester);
final profile = Profile.custom();
final rules = [
Rule.parse('NETWORK,tcp,DIRECT', id: 1).copyWith(order: '1'),
Rule.parse('NETWORK,icmp,DIRECT', id: 2).copyWith(order: '2'),
Rule.parse('RULE-SET,known,DIRECT', id: 3).copyWith(order: '3'),
Rule.parse('RULE-SET,gone,DIRECT', id: 4).copyWith(order: '4'),
];
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileRulesProvider.overrideWith2((_) => _TestProfileRules(rules)),
customProfileDataProvider(profile.id).overrideWithValue(
const CustomProfileData(
ruleTargets: {'DIRECT'},
ruleProviders: {'known'},
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: CustomRulesView(profile.id)),
),
);
await tester.pump();
expect(find.byGlyph(AppGlyphs.info), findsNWidgets(2));
await tester.pumpWidget(const SizedBox.shrink());
},
);
Future<void> pumpNodes(WidgetTester tester, List<CustomProxy> nodes) async {
_setViewport(tester);
final container = ProviderContainer(
overrides: [
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(
() => _RecordingCustomProxies(nodes),
),
customProxyCoreErrorsProvider.overrideWith((_) async => const {}),
clashProvidersActionProvider.overrideWith(
_RecordingClashProvidersAction.new,
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pump();
await tester.pump();
}
const nodes = [
CustomProxy(
id: 1,
definition: {
'name': 'HK',
'type': 'ss',
'server': 'hk.example.com',
'port': 8388,
'udp': true,
},
),
CustomProxy(
id: 2,
definition: {
'name': 'JP',
'type': 'vless',
'server': 'jp.example.com',
'port': 443,
'network': 'ws',
'tls': true,
},
),
];
testWidgets('each node shows only its name and type', (tester) async {
await pumpNodes(tester, nodes);
final l = currentAppLocalizations;
expect(find.text(l.proxies), findsOneWidget);
expect(find.text('ss'), findsOneWidget);
expect(find.text('vless'), findsOneWidget);
expect(find.text('hk.example.com:8388'), findsNothing);
expect(find.byType(MetaChip), findsNothing);
expect(find.byType(SliverReorderableList), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('an empty nodes page offers to add one', (tester) async {
await pumpNodes(tester, const []);
final l = currentAppLocalizations;
expect(find.text(l.nullTip(l.proxies)), findsOneWidget);
expect(find.widgetWithText(FilledButton, l.add), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a second save while the core validates stores nothing more', (
tester,
) async {
_setViewport(tester);
final validated = Completer<List<String>>();
final proxies = _RecordingCustomProxies();
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(() => core.validateProxies(any())).thenAnswer((_) => validated.future);
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(() => proxies),
clashProvidersActionProvider.overrideWith(
_RecordingClashProvidersAction.new,
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(_menuItem('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).first, 'Mine');
await tester.tap(find.byTooltip('Save'));
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pump();
validated.complete(['']);
await tester.pumpAndSettle();
expect(proxies.saved.map((proxy) => proxy.name), ['Mine']);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a saved new proxy closes without flagging its own copy or an '
'outdated core verdict', (tester) async {
_setViewport(tester);
final proxies = _RecordingCustomProxies();
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(() => core.validateProxies(any())).thenAnswer((invocation) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
return [
for (final definition
in invocation.positionalArguments.single as List<Object?>)
(definition! as Map)['server'] == null ? 'missing server' : '',
];
});
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(() => proxies),
clashProvidersActionProvider.overrideWith(
_RecordingClashProvidersAction.new,
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(_menuItem('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).first, 'Mine');
await tester.pump(const Duration(seconds: 1));
await tester.enterText(find.byType(TextFormField).at(1), 'example.com');
await tester.tap(find.byTooltip('Save'));
await _expectClosesWithoutIssues(tester);
await tester.pumpAndSettle();
expect(proxies.saved.map((proxy) => proxy.name), ['Mine']);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a new proxy shows what the core rejects only after a save', (
tester,
) async {
_setViewport(tester);
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(
() => core.validateProxies(any()),
).thenAnswer((_) async => ['missing server']);
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(_RecordingCustomProxies.new),
clashProvidersActionProvider.overrideWith(
_RecordingClashProvidersAction.new,
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
final rejected = find.descendant(
of: find.byType(ErrorBanner),
matching: find.textContaining('missing server'),
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(_menuItem('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).first, 'Mine');
await tester.pump(const Duration(seconds: 1));
expect(rejected, findsNothing);
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
await _dismissSaveBlocked(tester, 'missing server');
expect(rejected, findsOneWidget);
expect(find.textContaining('missing server'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a save the core cannot check is refused in the banner', (
tester,
) async {
_setViewport(tester);
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(
() => core.validateProxies(any()),
).thenAnswer((_) async => throw Exception('core down'));
final proxies = _RecordingCustomProxies();
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(() => proxies),
clashProvidersActionProvider.overrideWith(
_RecordingClashProvidersAction.new,
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
final rejected = find.descendant(
of: find.byType(ErrorBanner),
matching: find.textContaining('core down'),
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(_menuItem('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).first, 'Mine');
await tester.pump(const Duration(seconds: 1));
expect(rejected, findsNothing);
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
await _dismissSaveBlocked(tester, 'core down');
expect(rejected, findsOneWidget);
expect(find.textContaining('core down'), findsOneWidget);
expect(proxies.saved, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('an easytier proxy edits its network in place of a server', (
tester,
) async {
_setViewport(tester);
final proxies = _RecordingCustomProxies(const [
CustomProxy(id: 1, definition: {'name': 'Mesh', 'type': 'easytier'}),
]);
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(() => core.validateProxies(any())).thenAnswer((_) async => ['']);
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(() => proxies),
clashProvidersActionProvider.overrideWith(
_RecordingClashProvidersAction.new,
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
Finder row(String title) => find.widgetWithText(FormRow, title);
Finder fieldIn(String title) =>
find.descendant(of: row(title), matching: find.byType(TextFormField));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pump();
final l10n = AppLocalizations.current;
await tester.tap(find.text('Mesh'));
await tester.pumpAndSettle();
expect(row(l10n.server), findsNothing);
expect(row(l10n.port), findsNothing);
expect(
find.descendant(of: row(l10n.peers), matching: find.text(l10n.none)),
findsOneWidget,
);
await tester.enterText(fieldIn(l10n.networkName), 'home');
await tester.enterText(fieldIn(l10n.networkSecret), 'secret');
await tester.tap(row(l10n.peers));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(ListEditorPage<String, String>),
matching: find.byTooltip(l10n.add),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(EntryDialog<String>),
matching: find.byType(TextFormField),
),
'tcp://peer.example.com:11010',
);
await tester.tap(find.text(l10n.confirm));
await tester.pumpAndSettle();
expect(find.text('tcp://peer.example.com:11010'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(
find.descendant(
of: row(l10n.peers),
matching: find.text(l10n.itemsCount(1)),
),
findsOneWidget,
);
await tester.tap(find.byTooltip(l10n.save));
await tester.pumpAndSettle();
expect(proxies.saved.single.definition, {
'name': 'Mesh',
'type': 'easytier',
'network-name': 'home',
'network-secret': 'secret',
'peers': ['tcp://peer.example.com:11010'],
});
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('an app-level proxy saves outside a profile and reapplies on '
'leaving', (tester) async {
_setViewport(tester);
final proxies = _RecordingCustomProxies();
final action = _RecordingClashProvidersAction();
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(() => core.validateProxies(any())).thenAnswer((_) async => ['']);
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(() => proxies),
clashProvidersActionProvider.overrideWith(() => action),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(_menuItem('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).first, 'Home');
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
expect(proxies.saved.map((proxy) => proxy.name), ['Home']);
expect(action.applied, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
expect(action.applied, [
{'Home'},
]);
});
testWidgets('app-level proxies left as they were, or changed only in the '
'options they list empty, do not reapply', (tester) async {
_setViewport(tester);
final action = _RecordingClashProvidersAction();
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(() => core.validateProxies(any())).thenAnswer((_) async => ['']);
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(
() => _RecordingCustomProxies(const [
CustomProxy(id: 1, definition: {'name': 'Home', 'type': 'ss'}),
]),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxiesView()),
),
);
await tester.pumpAndSettle();
container.read(customProxiesProvider.notifier).value = const [
CustomProxy(
id: 1,
definition: {'name': 'Home', 'type': 'ss'},
emptyOptions: {ProxyField.tfo},
),
];
await tester.pump();
await tester.pumpWidget(const SizedBox.shrink());
expect(action.applied, isEmpty);
});
testWidgets('dialers list the declared proxies and offer what does not '
'loop back', (tester) async {
_setViewport(tester);
const profileId = 1;
final dialers = _RecordingProxyDialers();
final container = ProviderContainer(
overrides: [
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(
() => _RecordingCustomProxies(const [
CustomProxy(id: 10, definition: {'name': 'Home', 'type': 'ss'}),
CustomProxy(id: 11, definition: {'name': 'Work', 'type': 'ss'}),
CustomProxy(id: 12, definition: {'name': 'Spare', 'type': 'ss'}),
]),
),
proxyDialersProvider.overrideWith2((_) => dialers),
customProfileDataProvider(profileId).overrideWithValue(
const CustomProfileData(
proxyGroups: [
ProxyGroup(
id: 1,
name: 'Landing',
type: GroupType.Selector,
proxies: ['Work', 'Home'],
),
ProxyGroup(
id: 2,
name: 'Relay',
type: GroupType.Selector,
proxies: ['DIRECT'],
),
],
ruleTargets: {'DIRECT', 'Landing', 'Relay'},
proxies: {'Home', 'Work', 'Spare'},
dialers: {'Home': 'Work'},
),
),
customProfileIssuesProvider(profileId).overrideWithValue(
const CustomProfileIssues(
dialers: {
11: [CustomIssue.missingDialer('Gone')],
},
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProxyDialersView(profileId)),
),
);
await tester.pump();
expect(find.text(currentAppLocalizations.none), findsNothing);
expect(find.text('Spare'), findsNothing);
expect(find.byGlyph(AppGlyphs.info), findsOneWidget);
Finder offered(String name) => find.descendant(
of: find.byType(SelectionSheet<String>),
matching: find.text(name),
);
await tester.tap(find.text('Home'));
await tester.pumpAndSettle();
expect(offered(currentAppLocalizations.localProxies), findsOneWidget);
expect(offered('Work'), findsOneWidget);
expect(offered('Home'), findsNothing);
expect(offered('Relay'), findsOneWidget);
expect(offered('Landing'), findsNothing);
await tester.tap(offered('Work'));
await tester.pumpAndSettle();
await tester.tap(find.text('Work'));
await tester.pumpAndSettle();
expect(offered('Home'), findsNothing);
expect(offered('Work'), findsNothing);
expect(offered('Landing'), findsNothing);
await tester.tap(offered('Relay'));
await tester.pumpAndSettle();
expect(dialers.calls, [(10, 'Work'), (11, 'Relay')]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('quick edit opens over the sheet but beside the navigation', (
tester,
) async {
_setViewport(tester);
final profile = Profile.custom();
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(const [])),
customProfileDataProvider(
profile.id,
).overrideWithValue(const CustomProfileData()),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
final pageNavigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Navigator(
key: pageNavigator,
pages: [MaterialPage(child: _proxyGroupsPage(profile.id))],
onDidRemovePage: (_) {},
),
),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(currentAppLocalizations.quickEdit));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final editorNavigator = tester.state<NavigatorState>(
find
.ancestor(
of: find.byType(EditorPage),
matching: find.byType(Navigator),
)
.first,
);
expect(editorNavigator, same(pageNavigator.currentState));
await tester.pumpWidget(const SizedBox.shrink());
});
}