Files
FlClash/test/features/custom_rule_form_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

490 lines
15 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.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_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _RecordingProfileRules extends ProfileRules {
_RecordingProfileRules(this.initial);
final List<Rule> initial;
final List<Rule> puts = [];
final List<int> deletes = [];
@override
Stream<List<Rule>> build(int profileId) => Stream.value(initial);
@override
void put(Rule rule) => puts.add(rule);
@override
void putAll(List<Rule> rules) => puts.addAll(rules);
@override
void delAll(Iterable<int> ruleIds) => deletes.addAll(ruleIds);
@override
void order(int oldIndex, int newIndex) {}
}
class _TestCustomData extends Notifier<CustomProfileData> {
@override
CustomProfileData build() {
return const CustomProfileData(ruleTargets: {'DIRECT'});
}
}
final _testCustomDataProvider =
NotifierProvider<_TestCustomData, CustomProfileData>(_TestCustomData.new);
class _TestClashProviders extends ClashProviders {
_TestClashProviders(this.ruleLabels);
final List<String> ruleLabels;
@override
Stream<List<ClashProvider>> build() => Stream.value([
for (final (index, label) in ruleLabels.indexed)
ClashProvider(
id: index,
label: label,
url: 'https://example.com/$label.yaml',
),
]);
}
/// The list of the open selection sheet, not the search field docked below it.
final _selectionList = find
.descendant(
of: find.byWidgetPredicate((widget) => widget is SelectionSheet),
matching: find.byType(Scrollable),
)
.first;
class _Harness {
late final ProviderContainer container;
late final _RecordingProfileRules rules;
late final Profile profile;
Future<void> pump(
WidgetTester tester, {
List<Rule> initialRules = const [],
Size size = const Size(1400, 1000),
List<String> appRuleProviders = const [],
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
profile = Profile.custom(label: 'profile');
rules = _RecordingProfileRules(initialRules);
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileRulesProvider.overrideWith2((_) => rules),
customProfileDataProvider(
profile.id,
).overrideWith((ref) => ref.watch(_testCustomDataProvider)),
clashProvidersProvider.overrideWith(
() => _TestClashProviders(appRuleProviders),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).update((_) => size);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: CustomRulesView(profile.id)),
),
);
await tester.pumpAndSettle();
}
Future<void> openAddSheet(WidgetTester tester) =>
openAddMenuItem(tester, currentAppLocalizations.add);
Future<void> openAddMenuItem(WidgetTester tester, String label) async {
await tester.tap(find.byTooltip(currentAppLocalizations.add));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(CommonPopupMenu),
matching: find.text(label),
),
);
await tester.pumpAndSettle();
}
Future<void> save(WidgetTester tester) async {
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await tester.pumpAndSettle();
}
Future<void> selectType(WidgetTester tester, RuleAction action) async {
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text(action.name),
300,
scrollable: _selectionList,
);
// That leaves the row at the viewport's top, under the floating bar.
await Scrollable.ensureVisible(
tester.element(find.text(action.name).last),
alignment: 0.5,
);
await tester.pumpAndSettle();
await tester.tap(find.text(action.name).last);
await tester.pumpAndSettle();
}
}
void main() {
testWidgets('the add sheet opens with the basic info form', (tester) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
final l10n = currentAppLocalizations;
expect(find.text(l10n.basicInfo), findsOne);
expect(find.text(l10n.proxyType), findsOne);
expect(find.text(l10n.content), findsOne);
expect(tester.takeException(), null);
});
testWidgets('saving without content is rejected and nothing is stored', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
await harness.save(tester);
expect(find.text(currentAppLocalizations.contentNotEmpty), findsOne);
expect(harness.rules.puts, isEmpty);
});
testWidgets('the type sheet leaves out SUB_RULE', (tester) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text(RuleAction.MATCH.name),
300,
scrollable: _selectionList,
);
await tester.pumpAndSettle();
expect(find.text(RuleAction.NOT.name), findsOne);
expect(find.text(RuleAction.SUB_RULE.name), findsNothing);
});
testWidgets('a SUB-RULE kept from before cannot be saved as one', (
tester,
) async {
final harness = _Harness();
await harness.pump(
tester,
initialRules: [Rule.parse('SUB-RULE,(NETWORK,tcp),streaming')],
);
await tester.tap(find.text(RuleAction.SUB_RULE.name));
await tester.pumpAndSettle();
await harness.save(tester);
expect(
find.text(currentAppLocalizations.invalidSubRule('streaming')),
findsWidgets,
);
expect(harness.rules.puts, isEmpty);
});
testWidgets('the rule set sheet lists the app rule sets in one list', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester, appRuleProviders: const ['shared', 'app-only']);
await harness.openAddSheet(tester);
await harness.selectType(tester, RuleAction.RULE_SET);
await tester.tap(find.text(currentAppLocalizations.ruleSet));
await tester.pumpAndSettle();
double top(String text) => tester.getTopLeft(find.text(text)).dy;
expect(find.text('shared'), findsOne);
expect(top('shared'), lessThan(top('app-only')));
expect(
find.descendant(
of: find.byType(SelectionSheet<String>),
matching: find.byType(ListHeader),
),
findsNothing,
);
});
testWidgets('a MATCH rule saves without a content field', (tester) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text(RuleAction.MATCH.name),
300,
scrollable: _selectionList,
);
await tester.pumpAndSettle();
await tester.tap(find.text(RuleAction.MATCH.name).last);
await tester.pumpAndSettle();
expect(find.text(currentAppLocalizations.content), findsNothing);
await harness.save(tester);
expect(find.text(currentAppLocalizations.contentNotEmpty), findsNothing);
expect(harness.rules.puts, hasLength(1));
final stored = harness.rules.puts.single;
expect(stored.ruleAction, RuleAction.MATCH);
expect(stored.ruleTarget, 'DIRECT');
expect(stored.rawValue, 'MATCH,DIRECT');
});
testWidgets('the type sheet opens scrolled to the selected action', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text(RuleAction.NOT.name),
300,
scrollable: _selectionList,
);
await tester.pumpAndSettle();
await tester.tap(find.text(RuleAction.NOT.name).last);
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
final sheet = find.byType(SelectionSheet<RuleAction>);
final item = find.descendant(
of: sheet,
matching: find.text(RuleAction.NOT.name),
);
expect(item, findsOne);
final viewport = tester.getRect(
find.descendant(of: sheet, matching: find.byType(CustomScrollView)),
);
final itemRect = tester.getRect(item);
expect(itemRect.top, greaterThanOrEqualTo(viewport.top));
expect(itemRect.bottom, lessThanOrEqualTo(viewport.bottom));
});
testWidgets('a complete rule is stored with a generated id', (tester) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
await tester.enterText(find.byType(TextFormField), 'example.com');
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.splitStrategy));
await tester.pumpAndSettle();
await tester.tap(find.text('DIRECT').last);
await tester.pumpAndSettle();
await harness.save(tester);
expect(harness.rules.puts, hasLength(1));
final stored = harness.rules.puts.single;
expect(stored.content, 'example.com');
expect(stored.ruleTarget, 'DIRECT');
expect(stored.id, isNot(-1), reason: 'a new rule gets a snowflake id');
});
testWidgets('quick actions store the confirmed presets', (tester) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddMenuItem(tester, currentAppLocalizations.quickActions);
await tester.tap(find.text(currentAppLocalizations.rulePresetLanDirect));
await tester.pump();
await tester.tap(find.byTooltip(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
expect(
harness.rules.puts.map((rule) => rule.rawValue),
RulePreset.lanDirect.rawRules,
);
});
testWidgets('the bar groups search with sort and adds from one menu', (
tester,
) async {
final l10n = currentAppLocalizations;
final harness = _Harness();
await harness.pump(
tester,
initialRules: [
Rule.parse('DOMAIN-SUFFIX,a.com,DIRECT'),
Rule.parse('DOMAIN-SUFFIX,b.com,DIRECT'),
],
);
Finder inBar(Finder finder) =>
find.descendant(of: find.byType(AppBar), matching: finder);
final group = find.byType(TonalButtonGroup);
expect(inBar(group), findsOne);
for (final tooltip in [l10n.search, l10n.sort]) {
expect(
find.descendant(of: group, matching: find.byTooltip(tooltip)),
findsOne,
);
}
expect(inBar(find.byTooltip(l10n.quickActions)), findsNothing);
expect(inBar(find.byTooltip(l10n.more)), findsNothing);
await tester.tap(find.byTooltip(l10n.add));
await tester.pumpAndSettle();
final menu = find.byType(CommonPopupMenu);
for (final label in [l10n.add, l10n.quickActions, l10n.quickEdit]) {
expect(find.descendant(of: menu, matching: find.text(label)), findsOne);
}
});
testWidgets('the content field keeps focus while its payload turns invalid', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
await harness.selectType(tester, RuleAction.DST_PORT);
await tester.enterText(find.byType(TextFormField), '80');
await tester.pump();
final editable = tester.state<EditableTextState>(find.byType(EditableText));
expect(editable.widget.focusNode.hasFocus, isTrue);
await tester.enterText(find.byType(TextFormField), '80x');
await tester.pump();
expect(find.byType(InfoMessageButton), findsOne);
expect(
tester.state<EditableTextState>(find.byType(EditableText)),
same(editable),
);
expect(editable.widget.focusNode.hasFocus, isTrue);
});
testWidgets('the additional parameter switches toggle and are stored', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
await harness.openAddSheet(tester);
await harness.selectType(tester, RuleAction.IP_CIDR);
final l10n = currentAppLocalizations;
expect(find.text(l10n.additionalParameters), findsOne);
final switches = find.byType(Switch);
expect(switches, findsNWidgets(2));
expect(tester.widgetList<Switch>(switches).map((s) => s.value), [
false,
false,
]);
await tester.tap(switches.at(0));
await tester.pumpAndSettle();
await tester.tap(switches.at(1));
await tester.pumpAndSettle();
expect(tester.widgetList<Switch>(switches).map((s) => s.value), [
true,
true,
]);
await tester.enterText(find.byType(TextFormField), '1.1.1.1/32');
await tester.pumpAndSettle();
await harness.save(tester);
final stored = harness.rules.puts.single;
expect(stored.noResolve, isTrue);
expect(stored.src, isTrue);
expect(stored.rawValue, 'IP-CIDR,1.1.1.1/32,DIRECT,src,no-resolve');
});
testWidgets('deleting from the edit sheet removes the rule', (tester) async {
final harness = _Harness();
final rule = Rule.parse('DOMAIN-SUFFIX,example.com,DIRECT');
await harness.pump(tester, initialRules: [rule]);
await tester.tap(find.text('example.com'));
await tester.pumpAndSettle();
final l10n = currentAppLocalizations;
expect(find.text(l10n.editRule), findsOne);
await tester.tap(find.text(l10n.delete));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(CommonDialog),
matching: find.text(l10n.delete),
),
);
await tester.pumpAndSettle();
expect(harness.rules.deletes, [rule.id]);
expect(find.text(l10n.editRule), findsNothing);
});
testWidgets('long targets and contents stay inside the rows', (tester) async {
final harness = _Harness();
final rules = [
Rule.parse(
'SUB-RULE,(DOMAIN,example.com),a-very-long-sub-rule-name-that-goes-on',
),
Rule.parse(
r'PROCESS-NAME-REGEX,^very-long-process-name-[0-9]{1,3}\.exe$,DIRECT',
),
];
await harness.pump(tester, initialRules: rules, size: const Size(360, 800));
expect(tester.takeException(), isNull);
await tester.tap(find.text(RuleAction.PROCESS_NAME_REGEX.name));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.enterText(find.byType(TextFormField), 'x' * 400);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final sheet = tester.getRect(find.byType(FormRow).first);
final field = tester.getRect(find.byType(EditableText));
expect(field.right, lessThanOrEqualTo(sheet.right));
});
}