feat(profiles): add a custom profile editor

Changelog: Custom profiles
This commit is contained in:
chen08209
2026-10-08 17:58:43 +08:00
parent 8c43053951
commit 3abda78af4
89 changed files with 16417 additions and 6608 deletions
+12 -9
View File
@@ -30,14 +30,6 @@ void main() {
});
});
group('UsedProxyExtension', () {
test('returns enum names as proxy values', () {
expect(UsedProxy.GLOBAL.value, 'GLOBAL');
expect(UsedProxy.DIRECT.value, 'DIRECT');
expect(UsedProxy.REJECT.value, 'REJECT');
});
});
group('KeyboardModifier', () {
test('keeps platform key variants for paired modifiers', () {
expect(KeyboardModifier.alt.physicalKeys, [
@@ -68,7 +60,18 @@ void main() {
group('RuleTarget', () {
test('contains built-in target names', () {
expect(RuleTarget.baseTargets, {'DIRECT', 'REJECT', 'REJECT-DROP'});
expect(RuleTarget.baseTargets, {
'DIRECT',
'REJECT',
'REJECT-DROP',
'PASS',
'PASS-RULE',
'COMPATIBLE',
});
});
test('lets a rule name the GLOBAL the core adds', () {
expect(RuleTarget.ruleTargets, {...RuleTarget.baseTargets, 'GLOBAL'});
});
});
File diff suppressed because it is too large Load Diff
+695
View File
@@ -0,0 +1,695 @@
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/enum/enum.dart';
import 'package:fl_clash/features/form/form.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/views/profiles/custom/custom_proxies.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 'package:mocktail/mocktail.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _RecordingRules extends ProfileRules {
final List<Rule> initial;
final replaced = <List<Rule>>[];
_RecordingRules(this.initial);
@override
Stream<List<Rule>> build(int profileId) => Stream.value(initial);
@override
void setAll(List<Rule> rules) => replaced.add(rules);
}
class _RecordingCustomProxies extends CustomProxies {
final List<CustomProxy> initial;
final saved = <CustomProxy>[];
final replaced = <List<CustomProxy>>[];
_RecordingCustomProxies(this.initial);
@override
Stream<List<CustomProxy>> build() => Stream.value(initial);
@override
void put(CustomProxy proxy) => saved.add(proxy);
@override
void setAll(List<CustomProxy> proxies) => replaced.add(proxies);
}
class _MockCore extends Mock implements CoreHandlerInterface {}
class _IdleClashProvidersAction extends ClashProvidersAction {
@override
Future<void> applyIfReferenced(
ProviderKind kind,
Set<String> labels, {
bool force = false,
}) async {}
}
void _setViewport(WidgetTester tester) {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
/// The editor's loading indicator schedules its next morph past unmount.
Future<void> _unmount(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 1));
}
Future<({EditorPage editor, BuildContext context})> _openEditor(
WidgetTester tester,
Finder button,
) async {
await tester.tap(button);
await tester.pumpAndSettle();
return (
editor: tester.widget<EditorPage>(find.byType(EditorPage)),
context: tester.element(find.byType(EditorPage)),
);
}
Finder _menuItem(String label) => find.descendant(
of: find.byType(CommonPopupMenu),
matching: find.text(label),
);
Future<({EditorPage editor, BuildContext context})> _openListEditor(
WidgetTester tester,
) async {
await tester.tap(find.byTooltip(currentAppLocalizations.add));
await tester.pumpAndSettle();
return _openEditor(tester, _menuItem(currentAppLocalizations.quickEdit));
}
void main() {
setUpAll(() {
registerFallbackValue(<Map<String, dynamic>>[]);
registerFallbackValue(<String>[]);
});
group('rule list', () {
Future<_RecordingRules> pumpRules(WidgetTester tester) async {
_setViewport(tester);
final profile = Profile.custom();
final notifier = _RecordingRules([
Rule.parse('DOMAIN,a.com,DIRECT', id: 1).copyWith(order: '1'),
Rule.parse('MATCH,DIRECT', id: 2).copyWith(order: '2'),
]);
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;
}
testWidgets('opens every rule as a YAML list and applies it on exit', (
tester,
) async {
final notifier = await pumpRules(tester);
final (:editor, :context) = await _openListEditor(tester);
expect(editor.content, '- DOMAIN,a.com,DIRECT\n- MATCH,DIRECT\n');
final popped = await editor.onPop!(
context,
editor.title,
'- DOMAIN,b.com,DIRECT\n${editor.content}',
);
expect(popped, isTrue);
final rules = notifier.replaced.single;
expect(rules.map((rule) => rule.rawValue), [
'DOMAIN,b.com,DIRECT',
'DOMAIN,a.com,DIRECT',
'MATCH,DIRECT',
]);
expect(rules.skip(1).map((rule) => rule.id), [1, 2]);
});
testWidgets('names the line of a rule the form would refuse', (
tester,
) async {
final notifier = await pumpRules(tester);
final (:editor, :context) = await _openListEditor(tester);
final popped = editor.onPop!(
context,
editor.title,
'- MATCH,DIRECT\n- DST-PORT,http,DIRECT\n',
);
await tester.pump();
expect(
find.textContaining(
currentAppLocalizations.lineIssueTip(
2,
currentAppLocalizations.invalidRangeContent,
),
),
findsOneWidget,
);
await tester.tap(find.text(currentAppLocalizations.cancel));
await tester.pump();
expect(await popped, isFalse);
expect(notifier.replaced, isEmpty);
});
testWidgets('a single rule has no quick edit of its own', (tester) async {
await pumpRules(tester);
await tester.tap(find.byType(RuleItem).first);
await tester.pumpAndSettle();
final form = find.byType(NestedFormSheet<Rule>);
expect(
find.descendant(
of: form,
matching: find.byTooltip(currentAppLocalizations.save),
),
findsOneWidget,
);
expect(
find.descendant(
of: form,
matching: find.byTooltip(currentAppLocalizations.quickEdit),
),
findsNothing,
);
});
});
group('custom proxies', () {
late _MockCore core;
setUp(() {
core = _MockCore();
when(() => core.validateProxies(any())).thenAnswer(
(invocation) async => [
for (final _ in invocation.positionalArguments.first as List) '',
],
);
});
void decodesTo(Map<String, List<Map<String, dynamic>>> proxiesByLink) {
when(() => core.decodeShareLinks(any())).thenAnswer(
(invocation) async => [
for (final link in invocation.positionalArguments.first as List)
proxiesByLink[link] ?? const [],
],
);
}
Future<_RecordingCustomProxies> pumpProxies(
WidgetTester tester,
List<CustomProxy> initial,
) async {
_setViewport(tester);
final proxies = _RecordingCustomProxies(initial);
final container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const <String>{}),
),
customProxiesProvider.overrideWith(() => proxies),
clashProvidersActionProvider.overrideWith(
_IdleClashProvidersAction.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();
return proxies;
}
CustomProxy readForm(WidgetTester tester) {
return ProviderScope.containerOf(
tester.element(
find.byType(NestedFormSheet<CustomProxy>, skipOffstage: false),
),
).read(customProxyProvider);
}
Finder formQuickEdit() => find.descendant(
of: find.byType(NestedFormSheet<CustomProxy>),
matching: find.byTooltip(currentAppLocalizations.quickEdit),
);
Finder linkField() => find.descendant(
of: find.byType(CommonDialog),
matching: find.byType(TextField),
);
Future<void> confirm(WidgetTester tester) async {
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
}
testWidgets('the list edits every proxy as share links in the editor and '
'keeps the ones whose link is unchanged', (tester) async {
const home = CustomProxy(
id: 1,
definition: {'name': 'Home', 'type': 'ss', 'server': 'a.example'},
);
const wireGuard = CustomProxy(
id: 2,
definition: {'name': 'WG', 'type': 'wireguard'},
);
when(
() => core.encodeShareLinks(any()),
).thenAnswer((_) async => ['ss://home#Home', '']);
decodesTo({
'vless://new#New': [
{'name': 'New', 'type': 'vless', 'server': 'n.example', 'port': 1},
],
});
final proxies = await pumpProxies(tester, const [home, wireGuard]);
final (:editor, :context) = await _openListEditor(tester);
expect(editor.content, 'ss://home#Home');
final popped = await editor.onPop!(
context,
editor.title,
'ss://home#Home\nvless://new#New',
);
expect(popped, isTrue);
final next = proxies.replaced.single;
expect(next.map((proxy) => proxy.name), ['Home', 'WG', 'New']);
expect(next.take(2), [home, wireGuard]);
verify(() => core.decodeShareLinks(['vless://new#New'])).called(1);
await _unmount(tester);
});
testWidgets('a renamed line asks before it drops the proxy it replaces', (
tester,
) async {
const home = CustomProxy(
id: 1,
definition: {'name': 'Home', 'type': 'ss', 'server': 'a.example'},
);
when(
() => core.encodeShareLinks(any()),
).thenAnswer((_) async => ['ss://home#Home']);
decodesTo({
'ss://home#Away': [
{'name': 'Away', 'type': 'ss', 'server': 'a.example'},
],
});
final proxies = await pumpProxies(tester, const [home]);
final (:editor, :context) = await _openListEditor(tester);
final prompt = currentAppLocalizations.proxiesReplacedTip('Home', 'Away');
var popped = editor.onPop!(context, editor.title, 'ss://home#Away');
await tester.pumpAndSettle();
expect(find.text(prompt), findsOneWidget);
await tester.tap(find.text(currentAppLocalizations.cancel));
await tester.pumpAndSettle();
expect(await popped, isFalse);
expect(proxies.replaced, isEmpty);
popped = editor.onPop!(context, editor.title, 'ss://home#Away');
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
expect(await popped, isTrue);
expect(proxies.replaced.single.map((proxy) => proxy.name), ['Away']);
await _unmount(tester);
});
testWidgets('a line the core cannot read is named and nothing is stored', (
tester,
) async {
when(() => core.encodeShareLinks(any())).thenAnswer((_) async => []);
decodesTo({});
final proxies = await pumpProxies(tester, const []);
final (:editor, :context) = await _openListEditor(tester);
final popped = editor.onPop!(context, editor.title, '\nhello');
await tester.pump();
await tester.pump();
expect(
find.textContaining(
currentAppLocalizations.lineIssueTip(
2,
currentAppLocalizations.shareLinksInvalid,
),
),
findsOneWidget,
);
await tester.tap(find.text(currentAppLocalizations.cancel));
await tester.pump();
expect(await popped, isFalse);
expect(proxies.replaced, isEmpty);
await _unmount(tester);
});
testWidgets('a new proxy fills its form from a link in quick edit', (
tester,
) async {
when(() => core.encodeShareLinks(any())).thenAnswer((_) async => ['']);
decodesTo({
'vless://u-1@b.example:443': [
{
'name': '',
'type': 'vless',
'server': 'b.example',
'port': 443,
'uuid': 'u-1',
},
],
'c3M6Ly9h': [
{'name': 'a', 'type': 'ss'},
{'name': 'b', 'type': 'ss'},
],
});
await pumpProxies(tester, const []);
await tester.tap(find.byTooltip(currentAppLocalizations.add));
await tester.pumpAndSettle();
await tester.tap(_menuItem(currentAppLocalizations.add));
await tester.pumpAndSettle();
await tester.tap(formQuickEdit());
await tester.pumpAndSettle();
expect(find.byType(EditorPage), findsNothing);
expect(tester.widget<TextField>(linkField()).controller!.text, isEmpty);
await tester.enterText(linkField(), 'c3M6Ly9h');
await confirm(tester);
expect(
find.text(currentAppLocalizations.singleShareLinkOnly),
findsOneWidget,
);
await tester.enterText(linkField(), ' vless://u-1@b.example:443\n');
await confirm(tester);
expect(find.byType(CommonDialog), findsNothing);
expect(readForm(tester).id, -1);
expect(readForm(tester).definition, {
'name': 'b.example',
'type': 'vless',
'server': 'b.example',
'port': 443,
'uuid': 'u-1',
});
expect(find.text('u-1'), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('an existing proxy opens its own link, keeps itself when the '
'link is left alone, and keeps its name for a link without one', (
tester,
) async {
const mine = CustomProxy(
id: 1,
definition: {
'name': 'Mine',
'type': 'socks5',
'server': 'a.example',
'port': 1080,
'dialer-proxy': 'Relay',
},
);
when(
() => core.encodeShareLinks(any()),
).thenAnswer((_) async => ['socks5://a.example:1080#Mine']);
decodesTo({
'trojan://pw@c.example:443': [
{
'name': '',
'type': 'trojan',
'server': 'c.example',
'port': 443,
'password': 'pw',
},
],
});
await pumpProxies(tester, const [mine]);
await tester.tap(find.text('Mine'));
await tester.pumpAndSettle();
await tester.tap(formQuickEdit());
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(linkField()).controller!.text,
'socks5://a.example:1080#Mine',
);
await confirm(tester);
expect(find.byType(CommonDialog), findsNothing);
expect(readForm(tester), mine);
verifyNever(() => core.decodeShareLinks(any()));
await tester.tap(formQuickEdit());
await tester.pumpAndSettle();
await tester.enterText(linkField(), 'trojan://pw@c.example:443');
await confirm(tester);
expect(readForm(tester).id, 1);
expect(readForm(tester).definition, {
'name': 'Mine',
'type': 'trojan',
'server': 'c.example',
'port': 443,
'password': 'pw',
});
await tester.pump(const Duration(seconds: 1));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('the form asks for the credentials its type reads', (
tester,
) async {
final proxies = await pumpProxies(tester, const [
CustomProxy(
id: 1,
definition: {
'name': 'Sock',
'type': 'socks5',
'server': 'a.example',
'port': 1080,
'username': 'me',
},
),
]);
await tester.tap(find.text('Sock'));
await tester.pumpAndSettle();
final password = find.descendant(
of: find.widgetWithText(FormRow, currentAppLocalizations.password),
matching: find.byType(EditableText),
);
expect(find.text('me'), findsOneWidget);
expect(find.text('UUID'), findsNothing);
expect(tester.widget<EditableText>(password).obscureText, isTrue);
await tester.ensureVisible(
find.byTooltip(currentAppLocalizations.showPassword),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(currentAppLocalizations.showPassword));
await tester.pump();
expect(tester.widget<EditableText>(password).obscureText, isFalse);
await tester.enterText(password, 'secret');
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(
proxies.saved.single.definition,
allOf(
containsPair('username', 'me'),
containsPair('password', 'secret'),
),
);
});
testWidgets('a vmess proxy asks for a UUID and no password', (
tester,
) async {
await pumpProxies(tester, const [
CustomProxy(id: 1, definition: {'name': 'Vm', 'type': 'vmess'}),
]);
await tester.tap(find.text('Vm'));
await tester.pumpAndSettle();
expect(find.text('UUID'), findsOneWidget);
expect(find.text(currentAppLocalizations.password), findsNothing);
expect(find.text(currentAppLocalizations.username), findsNothing);
});
testWidgets('the form picks the values a type requires and adds the '
'options it reads one at a time', (tester) async {
final proxies = await pumpProxies(tester, const [
CustomProxy(
id: 1,
definition: {
'name': 'Ss',
'type': 'ss',
'server': 'a.example',
'port': 8388,
'password': 'pw',
},
),
]);
tester.view.physicalSize = const Size(1400, 2600);
final l10n = currentAppLocalizations;
Finder row(String title) =>
find.ancestor(of: find.text(title), matching: find.byType(FormRow));
Finder offered(String title) => find.descendant(
of: find.byWidgetPredicate((widget) => widget is SelectionSheet),
matching: find.text(title),
);
Future<void> tapRow(String title) async {
await tester.ensureVisible(row(title).first);
await tester.tap(row(title).first);
await tester.pumpAndSettle();
}
Future<void> add(String title) async {
await tapRow(l10n.addSettingEntry);
await tester.tap(offered(title));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
}
await tester.tap(find.text('Ss'));
await tester.pumpAndSettle();
expect(
find.descendant(
of: row(l10n.cipher),
matching: find.text(l10n.required),
),
findsOneWidget,
);
await tapRow(l10n.cipher);
await tester.tap(find.text('aes-256-gcm'));
await tester.pumpAndSettle();
expect(readForm(tester).definition['cipher'], 'aes-256-gcm');
await tapRow(l10n.addSettingEntry);
expect(offered('UDP over TCP'), findsOneWidget);
expect(offered(l10n.network), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
await add('UDP over TCP');
expect(readForm(tester).definition['udp-over-tcp'], isTrue);
await tapRow(l10n.proxyType);
await tester.tap(find.text('vless'));
await tester.pumpAndSettle();
expect(row(l10n.cipher), findsNothing);
expect(row('UDP over TCP'), findsNothing);
expect(
readForm(tester).definition.keys,
isNot(anyOf(contains('cipher'), contains('udp-over-tcp'))),
);
await add(l10n.network);
expect(readForm(tester).definition['network'], 'tcp');
await tapRow(l10n.addSettingEntry);
expect(offered('WebSocket Path'), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
await tapRow(l10n.network);
await tester.tap(find.text('ws'));
await tester.pumpAndSettle();
expect(row('WebSocket Path'), findsOneWidget);
await tester.enterText(
find.descendant(
of: row('WebSocket Host'),
matching: find.byType(EditableText),
),
'cdn.example',
);
expect(readForm(tester).definition['ws-opts'], {
'headers': {'Host': 'cdn.example'},
});
await tapRow(l10n.network);
await tester.tap(find.text('grpc'));
await tester.pumpAndSettle();
expect(row('WebSocket Host'), findsNothing);
expect(readForm(tester).definition.containsKey('ws-opts'), isFalse);
await tester.tap(
find.descendant(
of: row('gRPC Service Name'),
matching: find.byTooltip(l10n.remove),
),
);
await tester.pumpAndSettle();
expect(row('gRPC Service Name'), findsNothing);
await tester.tap(find.byTooltip(l10n.save));
await tester.pumpAndSettle();
expect(
proxies.saved.single.definition,
allOf(
containsPair('type', 'vless'),
containsPair('network', 'grpc'),
isNot(contains('ws-opts')),
),
);
});
});
}
@@ -1,6 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
@@ -8,7 +8,7 @@ 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/overwrite/custom/rules.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';
@@ -18,8 +18,8 @@ import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _RecordingProfileCustomRules extends ProfileCustomRules {
_RecordingProfileCustomRules(this.initial);
class _RecordingProfileRules extends ProfileRules {
_RecordingProfileRules(this.initial);
final List<Rule> initial;
final List<Rule> puts = [];
@@ -31,6 +31,9 @@ class _RecordingProfileCustomRules extends ProfileCustomRules {
@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);
@@ -38,22 +41,15 @@ class _RecordingProfileCustomRules extends ProfileCustomRules {
void order(int oldIndex, int newIndex) {}
}
class _TestOverwriteData extends Notifier<CustomOverwriteDate> {
class _TestCustomData extends Notifier<CustomProfileData> {
@override
CustomOverwriteDate build() {
return const CustomOverwriteDate(
loaded: true,
ruleTargets: {'DIRECT'},
proxyNames: ['DIRECT'],
proxyTypes: {'DIRECT': 'Direct'},
);
CustomProfileData build() {
return const CustomProfileData(ruleTargets: {'DIRECT'});
}
}
final _testOverwriteDataProvider =
NotifierProvider<_TestOverwriteData, CustomOverwriteDate>(
_TestOverwriteData.new,
);
final _testCustomDataProvider =
NotifierProvider<_TestCustomData, CustomProfileData>(_TestCustomData.new);
class _TestClashProviders extends ClashProviders {
_TestClashProviders(this.ruleLabels);
@@ -61,28 +57,25 @@ class _TestClashProviders extends ClashProviders {
final List<String> ruleLabels;
@override
Stream<List<ClashProvider>> build(ProviderKind kind) => Stream.value([
if (kind == ProviderKind.rule)
for (final (index, label) in ruleLabels.indexed)
ClashProvider(
id: index,
kind: kind,
label: label,
url: 'https://example.com/$label.yaml',
),
Stream<List<ClashProvider>> build() => Stream.value([
for (final (index, label) in ruleLabels.indexed)
ClashProvider(
id: index,
label: label,
url: 'https://example.com/$label.yaml',
),
]);
}
class _Harness {
late final ProviderContainer container;
late final _RecordingProfileCustomRules rules;
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>? ruleProviders,
List<String> appRuleProviders = const [],
}) async {
tester.view.physicalSize = size;
@@ -90,24 +83,19 @@ class _Harness {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
profile = Profile.normal(label: 'profile');
rules = _RecordingProfileCustomRules(initialRules);
profile = Profile.custom(label: 'profile');
rules = _RecordingProfileRules(initialRules);
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileCustomRulesProvider.overrideWith2((_) => rules),
customOverwriteDateProvider(
profileRulesProvider.overrideWith2((_) => rules),
customProfileDataProvider(
profile.id,
).overrideWith((ref) => ref.watch(_testOverwriteDataProvider)),
if (ruleProviders != null) ...[
clashConfigProvider(profile.id).overrideWithValue(
AsyncData(ClashConfig(ruleProviders: ruleProviders)),
),
clashProvidersProvider.overrideWith2(
(_) => _TestClashProviders(appRuleProviders),
),
],
).overrideWith((ref) => ref.watch(_testCustomDataProvider)),
clashProvidersProvider.overrideWith(
() => _TestClashProviders(appRuleProviders),
),
],
);
addTearDown(container.dispose);
@@ -123,8 +111,18 @@ class _Harness {
await tester.pumpAndSettle();
}
Future<void> openAddSheet(WidgetTester tester) async {
await tester.tap(find.text(currentAppLocalizations.add));
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();
}
@@ -177,44 +175,29 @@ void main() {
expect(harness.rules.puts, isEmpty);
});
// Rule.init() seeds ruleTarget with DIRECT, so the empty-target guard is only
// reachable through SUB_RULE, where realTarget reads the unset subRule.
testWidgets('a SUB_RULE without a sub rule is rejected', (tester) async {
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();
// SUB_RULE sits far down a lazily built list, so scroll it into existence.
await tester.scrollUntilVisible(
find.text(RuleAction.SUB_RULE.name),
find.text(RuleAction.MATCH.name),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
await tester.tap(find.text(RuleAction.SUB_RULE.name).last);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), 'example.com');
await tester.pumpAndSettle();
await harness.save(tester);
expect(find.text(currentAppLocalizations.subRuleNotEmpty), findsOne);
expect(harness.rules.puts, isEmpty);
expect(find.text(RuleAction.NOT.name), findsOne);
expect(find.text(RuleAction.SUB_RULE.name), findsNothing);
});
testWidgets('the rule set sheet lists the subscription first in one list', (
testWidgets('the rule set sheet lists the app rule sets in one list', (
tester,
) async {
feature = const Feature(customProviders: true);
addTearDown(() => feature = const Feature());
final harness = _Harness();
await harness.pump(
tester,
ruleProviders: const ['own', 'shared'],
appRuleProviders: const ['shared', 'app-only'],
);
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));
@@ -222,12 +205,11 @@ void main() {
double top(String text) => tester.getTopLeft(find.text(text)).dy;
expect(find.text('shared'), findsOne);
expect(top('own'), lessThan(top('shared')));
expect(top('shared'), lessThan(top('app-only')));
expect(
find.descendant(
of: find.byType(OverwriteSelectionSheet<String>),
matching: find.byType(InfoHeader),
of: find.byType(SelectionSheet<String>),
matching: find.byType(ListHeader),
),
findsNothing,
);
@@ -271,21 +253,21 @@ void main() {
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text(RuleAction.SUB_RULE.name),
find.text(RuleAction.NOT.name),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
await tester.tap(find.text(RuleAction.SUB_RULE.name).last);
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(OverwriteSelectionSheet<RuleAction>);
final sheet = find.byType(SelectionSheet<RuleAction>);
final item = find.descendant(
of: sheet,
matching: find.text(RuleAction.SUB_RULE.name),
matching: find.text(RuleAction.NOT.name),
);
expect(item, findsOne);
final viewport = tester.getRect(
@@ -318,6 +300,56 @@ void main() {
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 {
@@ -418,7 +450,7 @@ void main() {
await tester.enterText(find.byType(TextFormField), 'x' * 400);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final sheet = tester.getRect(find.byType(OverwriteFormRow).first);
final sheet = tester.getRect(find.byType(FormRow).first);
final field = tester.getRect(find.byType(EditableText));
expect(field.right, lessThanOrEqualTo(sheet.right));
});
+17 -1
View File
@@ -101,6 +101,16 @@ void main() {
_labels(_complete('proxies:\n - name: a\n type: ss\n ci|')),
contains('cipher'),
);
const direct = 'proxies:\n - name: a\n type: direct\n ser|';
expect(_labels(_complete(direct)), isNot(contains('server')));
});
test('offers the fields of the listener type', () {
List<String> fields(String type) => _labels(
_complete('listeners:\n - name: a\n type: $type\n ws|'),
);
expect(fields('vmess'), contains('ws-path'));
expect(fields('shadowsocks'), isNot(contains('ws-path')));
});
test('completes the keys of an item the caret line opens', () {
@@ -135,6 +145,8 @@ void main() {
expect(_labels(policy).first, 'Proxy');
expect(policy!.suggestions.first.detail, 'select');
expect(_labels(_complete('$rules - MATCH,DI|')), ['DIRECT']);
expect(_labels(_complete('$rules - MATCH,GL|')), ['GLOBAL']);
expect(_labels(_complete('$rules - MATCH,COM|')), ['COMPATIBLE']);
expect(_labels(_complete('$rules - GEOIP,CN,DIRECT,no|')), [
'no-resolve',
]);
@@ -213,6 +225,10 @@ void main() {
'proxy-groups:\n - name: G\n type: select\n'
' proxies: [DIRECT, REJ|';
expect(_labels(_complete(document)), ['REJECT', 'REJECT-DROP']);
expect(
_labels(_complete(document.replaceFirst('REJ|', 'GL|'))),
isNot(contains('GLOBAL')),
);
});
test('completes against the part of the config being edited', () {
@@ -272,7 +288,7 @@ void main() {
expect(_labels(script("({ type: 'vm|")), ['vmess']);
expect(
_labels(script("proxy.type === 'hyst|")),
unorderedEquals(['hysteria2', 'hysteria']),
unorderedEquals(['hysteria2', 'hysteria', 'hysteria2-realm']),
);
});
+21
View File
@@ -0,0 +1,21 @@
import 'package:fl_clash/features/form/filter.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('filter parts', () {
const asia = Filter(label: 'Asia', regex: 'hk`jp');
test('a preset is applied only once every part is there', () {
expect(asia.isAppliedTo('mine`hk`jp'), isTrue);
expect(asia.isAppliedTo('jp`kr'), isFalse);
expect(asia.isAppliedTo(null), isFalse);
});
test('drops the empty parts mihomo would match everything with', () {
expect(filterParts('hk``jp`'), ['hk', 'jp']);
expect(joinFilterParts(['hk', 'jp']), 'hk`jp');
expect(joinFilterParts(const []), isNull);
});
});
}
@@ -1,7 +1,9 @@
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -12,47 +14,46 @@ class _EditorHarness extends StatelessWidget {
final ValueNotifier<List<String>?> items;
final void Function(Set<String> ids)? onDelete;
final bool searchable;
final bool inBottomSheet;
const _EditorHarness({
required this.items,
this.onDelete,
this.searchable = false,
this.inBottomSheet = false,
});
@override
Widget build(BuildContext context) {
final page = ListEditorPage<String, String>(
title: 'Editor',
itemsOf: (_) => items.value,
itemBuilder:
(context, ref, item, index, isEditing, isSelected, onToggleSelected) {
return ListTile(
title: Text(item),
onTap: onToggleSelected,
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
);
},
onReorder: (oldIndex, newIndex) {},
onAdd: () {},
emptyLabel: 'Empty',
selectionEnabled: true,
idOf: (item) => item,
onDelete: onDelete,
searchFieldsOf: searchable ? (item) => [item] : null,
);
return TestApp(
wrapInProviderScope: true,
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 800)),
],
child: OverwriteEditorPage<String, String>(
title: 'Editor',
itemsOf: (_) => items.value,
itemBuilder:
(
context,
ref,
item,
index,
isEditing,
isSelected,
onToggleSelected,
) {
return ListTile(
title: Text(item),
onTap: onToggleSelected,
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
);
},
onReorder: (oldIndex, newIndex) {},
onAdd: () {},
emptyLabel: 'Empty',
selectionEnabled: true,
idOf: (item) => item,
onDelete: onDelete,
searchFieldsOf: searchable ? (item) => [item] : null,
),
child: inBottomSheet
? Material(
child: SheetProvider(type: SheetType.bottomSheet, child: page),
)
: page,
);
}
}
@@ -164,6 +165,29 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('the last row scrolls clear of a docked search field', (
tester,
) async {
final items = ValueNotifier<List<String>>([
for (var index = 0; index < 40; index++) 'item $index',
]);
addTearDown(items.dispose);
await tester.pumpWidget(
_EditorHarness(items: items, searchable: true, inBottomSheet: true),
);
await tester.pumpAndSettle();
await tester.drag(find.text('item 0'), const Offset(0, -5000));
await tester.pumpAndSettle();
expect(
tester.getBottomLeft(find.widgetWithText(ListTile, 'item 39')).dy,
lessThanOrEqualTo(tester.getTopLeft(find.byType(SearchField)).dy),
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a search lists only the matches and cannot reorder them', (
tester,
) async {
@@ -390,4 +414,52 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('builds rows past the viewport once its route settles and '
'keeps the rows it arrived with', (tester) async {
final items = [for (var i = 0; i < 200; i++) 'item $i'];
final builds = <int, int>{};
final page = ListEditorPage<String, String>(
title: 'Editor',
itemsOf: (_) => items,
itemBuilder:
(context, ref, item, index, isEditing, isSelected, onToggleSelected) {
builds[index] = (builds[index] ?? 0) + 1;
return ListTile(title: Text(item));
},
itemExtent: 50,
onReorder: (oldIndex, newIndex) {},
onAdd: () {},
emptyLabel: 'Empty',
idOf: (item) => item,
);
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(400, 600)),
],
child: Builder(
builder: (context) => TextButton(
onPressed: () => BaseNavigator.push(context, page),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final arrived = {...builds.keys};
expect(arrived, isNotEmpty);
expect(
tester.getTopLeft(find.text('item ${arrived.length - 1}')).dy,
lessThan(600),
);
await tester.pumpAndSettle();
expect(builds.length, greaterThan(arrived.length));
expect([for (final index in arrived) builds[index]], everyElement(1));
});
}
@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -13,11 +16,24 @@ import '../helpers/test_app.dart';
const _group = ProxyGroup(id: 1, name: 'g', type: GroupType.Selector);
class _ProbeForm extends ConsumerWidget {
const _ProbeForm();
class _ProbeForm extends ConsumerStatefulWidget {
final void Function(BuildContext context) onSave;
const _ProbeForm({required this.onSave});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<_ProbeForm> createState() => _ProbeFormState();
}
class _ProbeFormState extends ConsumerState<_ProbeForm> {
@override
void initState() {
super.initState();
NestedFormSheet.bindSave(context, () => widget.onSave(context));
}
@override
Widget build(BuildContext context) {
final isBottomSheet =
SheetProvider.of(context)?.type == SheetType.bottomSheet;
final name = ref.watch(proxyGroupProvider.select((state) => state.name));
@@ -62,16 +78,20 @@ class _ProbeForm extends ConsumerWidget {
class _NestedSheetHarness {
int saveCalls = 0;
bool saves = true;
late final ProviderContainer container;
Future<void> pump(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 1000);
Future<void> pump(
WidgetTester tester, {
Size size = const Size(1400, 1000),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
container = ProviderContainer(
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1400, 1000)),
viewSizeProvider.overrideWithBuild((_, _) => size),
proxyGroupProvider.overrideWithBuild((_, _) => _group),
],
);
@@ -87,18 +107,17 @@ class _NestedSheetHarness {
onPressed: () {
showSheet(
context: context,
props: const SheetProps(
isScrollControlled: true,
backgroundColor: Colors.transparent,
maxWidth: double.maxFinite,
),
builder: (_) => OverwriteNestedSheet<ProxyGroup>(
props: nestedPagedSheetProps,
builder: (_) => NestedFormSheet<ProxyGroup>(
currentOf: (ref) => ref.read(proxyGroupProvider),
save: (context, ref) {
saveCalls++;
return true;
},
formBuilder: (_) => const _ProbeForm(),
formBuilder: (_) => _ProbeForm(
onSave: (context) {
saveCalls++;
if (saves) {
context.safeNestedPop();
}
},
),
),
);
},
@@ -117,6 +136,12 @@ class _NestedSheetHarness {
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
}
Future<void> mutate(WidgetTester tester) async {
await tester.ensureVisible(find.text('mutate'));
await tester.tap(find.text('mutate'));
await tester.pump();
}
}
void main() {
@@ -124,12 +149,12 @@ void main() {
final harness = _NestedSheetHarness();
await harness.pump(tester);
expect(find.byType(OverwriteNestedSheet<ProxyGroup>), findsOneWidget);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsOneWidget);
await harness.closeBySystemBack(tester);
expect(harness.saveCalls, 0);
expect(find.byType(OverwriteNestedSheet<ProxyGroup>), findsNothing);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsNothing);
});
testWidgets('system back pops a nested page before closing the sheet', (
@@ -147,43 +172,97 @@ void main() {
await harness.closeBySystemBack(tester);
expect(find.text('nested page'), findsNothing);
expect(find.byType(OverwriteNestedSheet<ProxyGroup>), findsOneWidget);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsOneWidget);
expect(harness.saveCalls, 0);
});
testWidgets('closing with changes saves after confirmation', (tester) async {
testWidgets('closing with changes asks before saving them', (tester) async {
final harness = _NestedSheetHarness();
await harness.pump(tester);
final l = AppLocalizations.current;
await tester.ensureVisible(find.text('mutate'));
await tester.tap(find.text('mutate'));
await tester.pump();
await harness.mutate(tester);
await harness.closeBySystemBack(tester);
expect(find.text(AppLocalizations.current.dataChangedSave), findsOneWidget);
await tester.tap(find.text(AppLocalizations.current.confirm));
expect(find.text(l.saveChanges), findsOneWidget);
expect(harness.saveCalls, 0);
await tester.tap(find.text(l.confirm));
await tester.pumpAndSettle();
expect(harness.saveCalls, 1);
expect(find.byType(OverwriteNestedSheet<ProxyGroup>), findsNothing);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsNothing);
});
testWidgets('closing with changes discards when the user cancels', (
tester,
) async {
testWidgets('declining the save closes without saving', (tester) async {
final harness = _NestedSheetHarness();
await harness.pump(tester);
final l = AppLocalizations.current;
await tester.ensureVisible(find.text('mutate'));
await tester.tap(find.text('mutate'));
await tester.pump();
await harness.closeBySystemBack(tester);
expect(find.text(AppLocalizations.current.dataChangedSave), findsOneWidget);
await tester.tap(find.text(AppLocalizations.current.cancel));
await harness.mutate(tester);
await tester.tap(find.byTooltip(l.close));
await tester.pumpAndSettle();
await tester.tap(find.text(l.cancel));
await tester.pumpAndSettle();
expect(harness.saveCalls, 0);
expect(find.byType(OverwriteNestedSheet<ProxyGroup>), findsNothing);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsNothing);
});
testWidgets('dismissing the save prompt keeps the form open', (tester) async {
final harness = _NestedSheetHarness();
await harness.pump(tester);
final l = AppLocalizations.current;
await harness.mutate(tester);
await harness.closeBySystemBack(tester);
expect(find.text(l.saveChanges), findsOneWidget);
await harness.closeBySystemBack(tester);
expect(find.byType(CommonDialog), findsNothing);
expect(harness.saveCalls, 0);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsOneWidget);
});
testWidgets('a tap outside a nested page with changes asks only to save', (
tester,
) async {
final harness = _NestedSheetHarness();
await harness.pump(tester, size: const Size(400, 800));
final l = AppLocalizations.current;
harness.container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(name: 'changed'));
unawaited(
Navigator.of(
tester.element(find.text('row 0')),
).push(PagedSheetRoute(builder: (_) => const Text('nested page'))),
);
await tester.pumpAndSettle();
expect(find.text('nested page'), findsOneWidget);
await tester.tapAt(const Offset(200, 40));
await tester.pumpAndSettle();
expect(find.text(l.confirmExitWindow), findsNothing);
expect(find.text(l.saveChanges), findsOneWidget);
});
testWidgets('a save that fails keeps the form open without more prompts', (
tester,
) async {
final harness = _NestedSheetHarness()..saves = false;
await harness.pump(tester);
final l = AppLocalizations.current;
await harness.mutate(tester);
await harness.closeBySystemBack(tester);
await tester.tap(find.text(l.confirm));
await tester.pumpAndSettle();
expect(harness.saveCalls, 1);
expect(find.byType(CommonDialog), findsNothing);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsOneWidget);
});
}
@@ -1,465 +0,0 @@
import 'dart:async';
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/overwrite/overwrite.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/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/views/profiles/overwrite/custom/custom_proxies.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/groups.dart';
import 'package:fl_clash/views/profiles/overwrite/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 'package:mocktail/mocktail.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _TestProfileCustomRules extends ProfileCustomRules {
final List<Rule> initial;
_TestProfileCustomRules(this.initial);
@override
Stream<List<Rule>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
}
class _TestProxyGroups extends ProxyGroups {
final List<ProxyGroup> initial;
final List<Set<int>> deleted = [];
_TestProxyGroups(this.initial);
@override
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
@override
void delAll(Iterable<int> proxyGroupIds) {
deleted.add(proxyGroupIds.toSet());
}
}
class _RecordingCustomProxies extends CustomProxies {
final saved = <CustomProxy>[];
@override
Stream<List<CustomProxy>> build(int profileId) => Stream.value(const []);
@override
void put(CustomProxy proxy) => saved.add(proxy);
}
class _MockCore extends Mock implements CoreHandlerInterface {}
class _TestOverwriteData extends Notifier<CustomOverwriteDate> {
@override
CustomOverwriteDate build() {
return const CustomOverwriteDate(
loaded: true,
ruleTargets: {'DIRECT'},
proxyNames: ['DIRECT'],
proxyTypes: {'DIRECT': 'Direct'},
);
}
void setRuleTargets(Set<String> ruleTargets) {
state = state.copyWith(ruleTargets: ruleTargets);
}
}
final _testOverwriteDataProvider =
NotifierProvider<_TestOverwriteData, CustomOverwriteDate>(
_TestOverwriteData.new,
);
void _setViewport(WidgetTester tester) {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
Future<_TestProxyGroups> _pumpProxyGroups(WidgetTester tester) async {
_setViewport(tester);
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.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),
customOverwriteDateProvider(profile.id).overrideWithValue(
CustomOverwriteDate(
loaded: true,
proxyNames: const ['DIRECT'],
proxyTypes: const {'DIRECT': 'Direct'},
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: CustomProxyGroupsView(profile.id)),
),
);
await tester.pump();
return notifier;
}
void main() {
testWidgets('groups list rounds only the first and last rows', (
tester,
) async {
await _pumpProxyGroups(tester);
final rows = find.byType(DecorationListItem);
expect(rows, findsNWidgets(3));
expect(
find.descendant(of: rows.first, matching: find.byType(Divider)),
findsOneWidget,
);
expect(
find.descendant(of: rows.last, matching: find.byType(Divider)),
findsNothing,
);
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.confirm));
await tester.pumpAndSettle();
expect(notifier.deleted.single, {100, 102});
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.normal().copyWith(
overwriteType: OverwriteType.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 container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(proxyGroups)),
customOverwriteDateProvider(profile.id).overrideWithValue(
CustomOverwriteDate(
loaded: true,
proxyNames: const ['DIRECT'],
proxyTypes: const {'DIRECT': 'Direct'},
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: CustomProxyGroupsView(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(OverwriteFormRow), findsWidgets);
await tester.enterText(find.byType(TextFormField).first, 'y' * 400);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('rule invalid state refreshes when rule targets change', (
tester,
) async {
_setViewport(tester);
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.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),
profileCustomRulesProvider.overrideWith2(
(_) => _TestProfileCustomRules(rules),
),
customOverwriteDateProvider(profile.id).overrideWith((ref) {
return ref.watch(_testOverwriteDataProvider);
}),
],
);
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(_testOverwriteDataProvider.notifier).setRuleTargets({
...container.read(_testOverwriteDataProvider).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.normal().copyWith(
overwriteType: OverwriteType.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),
profileCustomRulesProvider.overrideWith2(
(_) => _TestProfileCustomRules(rules),
),
customOverwriteDateProvider(profile.id).overrideWithValue(
const CustomOverwriteDate(
loaded: true,
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());
},
);
testWidgets('a custom proxy saved before the config loads is checked on it', (
tester,
) async {
_setViewport(tester);
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.custom,
);
final config = Completer<ClashConfig>();
final proxies = _RecordingCustomProxies();
final core = _MockCore();
registerFallbackValue(<Map<String, dynamic>>[]);
when(() => core.validateProxies(any())).thenAnswer((_) async => ['']);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customProxiesProvider.overrideWith2((_) => proxies),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(const [])),
clashConfigProvider(profile.id).overrideWith((_) => config.future),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: CustomProxiesView(profile.id)),
),
);
await tester.pump();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).first, 'Auto');
await tester.tap(find.byTooltip('Save'));
await tester.pump();
config.complete(
const ClashConfig(
proxyGroups: [
ProxyGroup(
id: 1,
name: 'Auto',
type: GroupType.URLTest,
proxies: ['DIRECT'],
),
],
),
);
await tester.pumpAndSettle();
expect(proxies.saved, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a second save while the core validates stores nothing more', (
tester,
) async {
_setViewport(tester);
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.custom,
);
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: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
customProxiesProvider.overrideWith2((_) => proxies),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(const [])),
clashConfigProvider(
profile.id,
).overrideWith((_) async => const ClashConfig()),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(500, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: CustomProxiesView(profile.id)),
),
);
await tester.pump();
await tester.tap(find.text('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());
});
}
@@ -1,92 +0,0 @@
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
final _proxyTypes = {
for (var i = 1; i <= sheetSearchMinItemCount; i++)
'Node $i': i.isEven ? 'Vmess' : 'Trojan',
};
Future<List<String>> _pumpSheet(
WidgetTester tester, {
required List<String> proxies,
}) async {
final picked = <String>[];
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 800)),
],
child: SheetProvider(
type: SheetType.bottomSheet,
child: OverwriteSelectionSheet<String>(
title: 'Target',
sections: [
const OverwriteSelectionSection(
label: 'Basic',
items: ['DIRECT', 'REJECT'],
),
OverwriteSelectionSection(
label: 'Proxies',
items: proxies,
subtitleBuilder: (_, name) => _proxyTypes[name],
),
],
labelBuilder: (item) => item,
selectedOf: (_) => null,
onSelected: picked.add,
),
),
),
);
await tester.pump();
return picked;
}
void main() {
testWidgets('a short list has no search field', (tester) async {
await _pumpSheet(tester, proxies: ['Node 1']);
expect(find.byType(SearchField), findsNothing);
expect(find.text('Node 1'), findsOneWidget);
});
testWidgets('narrows the sections by label and subtitle', (tester) async {
final picked = await _pumpSheet(tester, proxies: _proxyTypes.keys.toList());
expect(find.byType(SearchField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'vmess 4');
await tester.pump();
expect(find.text('Basic'), findsNothing);
expect(find.text('Node 4'), findsOneWidget);
expect(find.text('Node 2'), findsNothing);
await tester.tap(find.text('Node 4'));
expect(picked, ['Node 4']);
await tester.enterText(find.byType(TextField), 'reject');
await tester.pump();
expect(find.text('REJECT'), findsOneWidget);
expect(find.text('Proxies'), findsNothing);
});
testWidgets('keeps the field above the no-results state', (tester) async {
await _pumpSheet(tester, proxies: _proxyTypes.keys.toList());
await tester.enterText(find.byType(TextField), 'missing');
await tester.pumpAndSettle();
expect(find.text(AppLocalizations.current.noSearchResults), findsOneWidget);
expect(find.byType(SearchField), findsOneWidget);
await tester.tap(find.byTooltip(AppLocalizations.current.clearSearch));
await tester.pumpAndSettle();
expect(find.text('Node 1'), findsOneWidget);
});
}
@@ -1,181 +0,0 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.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';
const _group = ProxyGroup(id: 1, name: 'g', type: GroupType.Selector);
int _applyCalls = 0;
class _StageFlowProbe extends ConsumerStatefulWidget {
const _StageFlowProbe();
@override
ConsumerState<_StageFlowProbe> createState() => _StageFlowProbeState();
}
class _StageFlowProbeState extends ConsumerState<_StageFlowProbe>
with OverwriteStageFlowMixin<_StageFlowProbe> {
@override
String get key => 'stage_probe';
@override
void initState() {
super.initState();
listenForStageChanges(
tag: 'apply',
apply: (state, staged) {
_applyCalls++;
return state.copyWith(proxies: [...state.proxies ?? [], ...staged]);
},
);
listenForStageChanges(
tag: 'apply_scene',
scene: 'scene',
apply: (state, staged) {
return state.copyWith(proxies: [...state.proxies ?? [], ...staged]);
},
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => handleStage('A'),
child: const Text('stage'),
),
FilledButton(
onPressed: () => handleStage('B', 'scene'),
child: const Text('stage scene'),
),
FilledButton(
onPressed: () {
handleStage('C');
handleRealStage(
tag: 'apply',
apply: (state, staged) {
return state.copyWith(
proxies: [...state.proxies ?? [], ...staged],
);
},
);
},
child: const Text('real'),
),
],
);
}
}
void main() {
Future<ProviderContainer> pumpProbe(WidgetTester tester) async {
_applyCalls = 0;
final container = ProviderContainer(
overrides: [proxyGroupProvider.overrideWithBuild((_, _) => _group)],
);
addTearDown(container.dispose);
container.listen(proxyGroupProvider, (_, _) {});
container.listen(itemsProvider('stage_probe_scene'), (_, _) {});
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: _StageFlowProbe()),
),
);
await tester.pump();
return container;
}
testWidgets(
'handleStage applies the staged items after the debounce and clears the stage',
(tester) async {
final container = await pumpProbe(tester);
await tester.tap(find.text('stage'));
await tester.pump();
expect(container.read(itemsProvider('stage_probe')), {'A'});
expect(container.read(proxyGroupProvider).proxies, isNull);
await tester.pump(const Duration(milliseconds: 500));
expect(container.read(proxyGroupProvider).proxies, ['A']);
expect(container.read(itemsProvider('stage_probe')), isEmpty);
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpWidget(const SizedBox.shrink());
},
);
testWidgets('scene-scoped staging uses a separate stage key', (tester) async {
final container = await pumpProbe(tester);
await tester.tap(find.text('stage scene'));
await tester.pump();
expect(container.read(itemsProvider('stage_probe_scene')), {'B'});
expect(container.read(itemsProvider('stage_probe')), isEmpty);
await tester.pump(const Duration(milliseconds: 500));
expect(container.read(proxyGroupProvider).proxies, ['B']);
expect(container.read(itemsProvider('stage_probe_scene')), isEmpty);
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('handleRealStage applies the staged items and clears the stage', (
tester,
) async {
final container = await pumpProbe(tester);
await tester.tap(find.text('real'));
await tester.pump();
expect(container.read(itemsProvider('stage_probe')), {'C'});
await tester.pump(const Duration(milliseconds: 500));
expect(container.read(proxyGroupProvider).proxies, ['C']);
expect(container.read(itemsProvider('stage_probe')), isEmpty);
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('clearing the stage does not schedule a second apply', (
tester,
) async {
await pumpProbe(tester);
await tester.tap(find.text('stage'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(_applyCalls, 1);
await tester.pump(const Duration(milliseconds: 500));
expect(_applyCalls, 1);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('staged items are settled when the widget leaves the tree', (
tester,
) async {
final container = await pumpProbe(tester);
await tester.tap(find.text('stage'));
await tester.pump();
expect(container.read(proxyGroupProvider).proxies, isNull);
await tester.pumpWidget(const SizedBox.shrink());
expect(container.read(proxyGroupProvider).proxies, ['A']);
await tester.pump(const Duration(milliseconds: 500));
expect(_applyCalls, 1);
});
}
-106
View File
@@ -1,106 +0,0 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/overwrite/overwrite.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 _TestProfileCustomRules extends ProfileCustomRules {
final List<Rule> initial;
_TestProfileCustomRules(this.initial);
@override
Stream<List<Rule>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
}
class _TestProxyGroups extends ProxyGroups {
final List<ProxyGroup> initial;
_TestProxyGroups(this.initial);
@override
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
}
class _RecordingSetupAction extends SetupAction {
int autoApplyCalls = 0;
@override
void autoApplyProfile() {
autoApplyCalls++;
}
}
void main() {
testWidgets(
'unmounting OverwriteView applies the profile without using ref',
(tester) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.custom,
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileCustomRulesProvider.overrideWith2(
(_) => _TestProfileCustomRules([]),
),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups([])),
clashConfigProvider(profile.id).overrideWithValue(
const AsyncData(
ClashConfig(
proxies: [Proxy(name: 'DIRECT', type: 'Direct')],
proxyProviders: ['provider'],
),
),
),
customOverwriteDateProvider(profile.id).overrideWithValue(
const CustomOverwriteDate(ruleTargets: {'DIRECT'}),
),
setupActionProvider.overrideWith(_RecordingSetupAction.new),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: OverwriteView(profileId: profile.id)),
),
);
await tester.pump();
expect(find.byType(OverwriteView), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(tester.takeException(), isNull);
expect(
(container.read(setupActionProvider.notifier) as _RecordingSetupAction)
.autoApplyCalls,
1,
);
},
);
}
+678
View File
@@ -0,0 +1,678 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/config/tun.dart';
import 'package:fl_clash/views/profiles/add.dart';
import 'package:fl_clash/views/profiles/custom/custom.dart';
import 'package:fl_clash/views/profiles/custom/providers.dart';
import 'package:fl_clash/views/profiles/extend/extend.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 _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) {}
}
class _TestProxyGroups extends ProxyGroups {
final List<ProxyGroup> initial;
_TestProxyGroups(this.initial);
@override
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
}
class _TestCustomProxies extends CustomProxies {
final List<CustomProxy> initial;
_TestCustomProxies(this.initial);
@override
Stream<List<CustomProxy>> build() => Stream.value(initial);
}
class _RecordingProfilesAction extends ProfilesAction {
@override
Future<void> deleteProfile(int id) =>
ref.read(profilesProvider.notifier).del(id);
}
class _RecordingSetupAction extends SetupAction {
int autoApplyCalls = 0;
@override
void autoApplyProfile() {
autoApplyCalls++;
}
}
void main() {
void setViewport(WidgetTester tester) {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
Future<void> expectAppliesOnUnmount(
WidgetTester tester,
ProviderContainer container,
Widget view, {
void Function()? edit,
}) async {
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: view),
),
);
await tester.pump();
expect(find.byWidget(view), findsOneWidget);
if (edit != null) {
await tester.pump();
edit();
await tester.pump();
}
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(tester.takeException(), isNull);
expect(
(container.read(setupActionProvider.notifier) as _RecordingSetupAction)
.autoApplyCalls,
1,
);
}
testWidgets('unmounting ExtendView applies the profile without using ref', (
tester,
) async {
setViewport(tester);
final profile = Profile.normal();
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileRulesProvider.overrideWith2((_) => _TestProfileRules([])),
clashConfigProvider(profile.id).overrideWithValue(
const AsyncData(
ClashConfig(
proxies: [Proxy(name: 'DIRECT', type: 'Direct')],
),
),
),
setupActionProvider.overrideWith(_RecordingSetupAction.new),
],
);
await expectAppliesOnUnmount(
tester,
container,
ExtendView(profileId: profile.id),
);
});
testWidgets(
'unmounting an edited CustomProfileView stamps and applies the profile '
'without using ref',
(tester) async {
setViewport(tester);
final edited = DateTime(2020);
final profile = Profile.custom(
label: 'mine',
).copyWith(lastUpdateDate: edited);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileRulesProvider.overrideWith2((_) => _TestProfileRules([])),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups([])),
customProfileDataProvider(
profile.id,
).overrideWithValue(const CustomProfileData(ruleTargets: {'DIRECT'})),
setupActionProvider.overrideWith(_RecordingSetupAction.new),
],
);
await expectAppliesOnUnmount(
tester,
container,
CustomProfileView(profileId: profile.id),
edit: () => container
.read(profilesProvider.notifier)
.updateProfile(
profile.id,
(profile) => profile.copyWith(label: 'renamed'),
),
);
expect(
container
.read(profileProvider(profile.id))!
.lastUpdateDate!
.isAfter(edited),
isTrue,
);
},
);
group('CustomProfileView', () {
Future<void> pumpCustom(
WidgetTester tester, {
List<ProxyGroup> groups = const [],
}) async {
setViewport(tester);
final profile = Profile.custom(label: 'mine').copyWith(
overrides: const ProfileOverrides(
dns: Dns(enable: false),
dnsOverrideKeys: {DnsOverrideKey.enable},
ntp: Ntp(enable: true),
ntpOverrideKeys: {NtpOverrideKey.enable},
sniffer: Sniffer(enable: true),
),
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profileRulesProvider.overrideWith2((_) => _TestProfileRules([])),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(groups)),
customProfileDataProvider(profile.id).overrideWithValue(
CustomProfileData(
proxyGroups: groups,
proxyProviders: const {'Sub'},
ruleTargets: {'DIRECT', for (final group in groups) group.name},
),
),
setupActionProvider.overrideWith(_RecordingSetupAction.new),
customProxiesProvider.overrideWith(
() => _TestCustomProxies(const []),
),
customProxyCoreErrorsProvider.overrideWith((_) async => const {}),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: CustomProfileView(profileId: profile.id)),
),
);
await tester.pumpAndSettle();
}
Finder tile(String label) =>
find.ancestor(of: find.text(label), matching: find.byType(CommonCard));
String? statusOf(WidgetTester tester, String label) {
final l = currentAppLocalizations;
return tester
.widget<Text>(
find.descendant(
of: tile(label),
matching: find.byWidgetPredicate(
(widget) =>
widget is Text &&
(widget.data == l.enabled || widget.data == l.disabled),
),
),
)
.data;
}
testWidgets('leads its proxy groups with a tile per section', (
tester,
) async {
await pumpCustom(
tester,
groups: const [
ProxyGroup(id: 1, name: 'Proxy', type: GroupType.Selector),
],
);
final l = currentAppLocalizations;
expect(find.text('mine'), findsOneWidget);
expect(find.text(l.preview), findsNothing);
expect(tile(l.rule), findsOneWidget);
expect(statusOf(tester, 'DNS'), l.disabled);
expect(
find.descendant(
of: tile(l.more),
matching: find.text(l.sectionsInEffect(1)),
),
findsOneWidget,
);
final header = find.widgetWithText(ListHeader, l.proxyGroup);
expect(
tester.getTopLeft(header).dy,
lessThan(tester.getTopLeft(find.text('Proxy')).dy),
);
});
testWidgets('counts the providers its groups name on a tile', (
tester,
) async {
await pumpCustom(
tester,
groups: const [
ProxyGroup(
id: 1,
name: 'Proxy',
type: GroupType.Selector,
use: ['Sub'],
),
],
);
final l = currentAppLocalizations;
expect(tile(l.nodes), findsNothing);
expect(
find.descendant(of: tile(l.providers), matching: find.text('1')),
findsOneWidget,
);
await tester.tap(tile(l.providers));
await tester.pumpAndSettle();
expect(find.byType(CustomProvidersView), findsOneWidget);
expect(find.text('Sub'), findsOneWidget);
});
testWidgets('offers importing a config from a link or a file', (
tester,
) async {
await pumpCustom(tester);
final l = currentAppLocalizations;
await tester.tap(find.widgetWithText(FilledButton, l.import));
await tester.pumpAndSettle();
expect(find.text(l.importUrl), findsOneWidget);
expect(find.text(l.importFile), findsOneWidget);
});
testWidgets('keeps its tiles above an empty group list', (tester) async {
await pumpCustom(tester);
final l = currentAppLocalizations;
expect(find.text(l.proxyGroupEmpty), findsOneWidget);
expect(tile('DNS'), findsOneWidget);
expect(
tester.getTopLeft(tile('DNS')).dy,
lessThan(tester.getTopLeft(find.text(l.proxyGroupEmpty)).dy),
);
});
testWidgets('gathers the rarely changed sections on a more page', (
tester,
) async {
await pumpCustom(tester);
final l = currentAppLocalizations;
Finder row(String title) =>
find.ancestor(of: find.text(title), matching: find.byType(ListTile));
Finder state(String title, String text) =>
find.descendant(of: row(title), matching: find.text(text));
expect(tile(l.dialerProxy), findsNothing);
await tester.tap(tile(l.more));
await tester.pumpAndSettle();
expect(state(l.dialerProxy, l.none), findsOneWidget);
expect(state('NTP', l.enabled), findsOneWidget);
expect(state(l.sniffer, l.disabled), findsOneWidget);
expect(state(l.tun, l.defaultText), findsOneWidget);
final container = globalState.container;
container
.read(profilesProvider.notifier)
.updateProfile(
container.read(currentProfileIdProvider)!,
(profile) => profile.copyWith.overrides(
tunOverrideKeys: {TunOverrideKey.excludeInterface},
),
);
await tester.pump();
expect(state(l.tun, l.settingsCount(1)), findsOneWidget);
await tester.tap(row(l.tun));
await tester.pumpAndSettle();
expect(find.byType(TunView), findsOneWidget);
});
});
group('a new custom profile', () {
ProviderContainer buildContainer(
List<Profile> profiles, {
List<ProxyGroup> groups = const [],
}) {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles(profiles)),
profileRulesProvider.overrideWith2((_) => _TestProfileRules([])),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(groups)),
customProfileDataProvider.overrideWith(
(_, _) => CustomProfileData(
proxyGroups: groups,
ruleTargets: {'DIRECT', for (final group in groups) group.name},
),
),
setupActionProvider.overrideWith(_RecordingSetupAction.new),
profilesActionProvider.overrideWith(_RecordingProfilesAction.new),
customProxiesProvider.overrideWith(
() => _TestCustomProxies(const []),
),
customProxyCoreErrorsProvider.overrideWith((_) async => const {}),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
return container;
}
Future<void> pumpOver(
WidgetTester tester,
ProviderContainer container,
Widget editor,
) async {
setViewport(tester);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Builder(
builder: (context) => FilledButton(
onPressed: () => BaseNavigator.push(context, editor),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
final editor = find.byType(CustomProfileView);
Future<void> leave(WidgetTester tester) async {
await globalState.navigatorKey.currentState!.maybePop();
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 1));
}
testWidgets('replaces the add page and opens under a default name', (
tester,
) async {
setViewport(tester);
final container = buildContainer(const []);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showAddProfilePage(context),
child: const Text('home'),
),
),
),
),
);
await tester.tap(find.text('home'));
await tester.pumpAndSettle();
final l = currentAppLocalizations;
await tester.tap(find.text(l.customProfile));
await tester.pumpAndSettle();
expect(find.byType(TextFormField), findsNothing);
expect(find.byType(AddProfileView, skipOffstage: false), findsNothing);
expect(editor, findsOneWidget);
expect(container.read(profilesProvider).single.label, l.unnamed);
await leave(tester);
expect(find.text('home'), findsOneWidget);
expect(container.read(profilesProvider), isEmpty);
});
testWidgets('opens in the tab the add sheet came from on desktop', (
tester,
) async {
setViewport(tester);
final container = buildContainer(const []);
final tabNavigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Navigator(
key: tabNavigator,
pages: [
MaterialPage(
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showAddProfilePage(context),
child: const Text('home'),
),
),
),
],
onDidRemovePage: (_) {},
),
),
),
);
await tester.tap(find.text('home'));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.customProfile));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(AddProfileView), findsOneWidget);
expect(editor, findsNothing);
expect(container.read(profilesProvider), isEmpty);
await tester.pumpAndSettle();
expect(find.byType(AddProfileView, skipOffstage: false), findsNothing);
expect(editor, findsNothing);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.byType(AddProfileView, skipOffstage: false), findsNothing);
expect(
find.descendant(of: find.byKey(tabNavigator), matching: editor),
findsOneWidget,
);
await tabNavigator.currentState!.maybePop();
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 1));
expect(find.text('home'), findsOneWidget);
expect(container.read(profilesProvider), isEmpty);
});
testWidgets('is dropped on leaving it as it was created', (tester) async {
final profile = Profile.custom(label: 'Unnamed');
final container = buildContainer([profile]);
await pumpOver(
tester,
container,
CustomProfileView(profileId: profile.id, isNew: true),
);
unawaited(globalState.navigatorKey.currentState!.maybePop());
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(container.read(profilesProvider), isEmpty);
expect(editor, findsOneWidget);
expect(
find.descendant(of: editor, matching: find.text('Unnamed')),
findsOneWidget,
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 1));
expect(editor, findsNothing);
});
const proxyGroup = ProxyGroup(
id: 1,
name: 'Proxy',
type: GroupType.Selector,
);
testWidgets('is kept once it has a group, under its name if not given', (
tester,
) async {
final profile = Profile.custom(label: 'Unnamed');
final container = buildContainer([profile], groups: const [proxyGroup]);
await pumpOver(
tester,
container,
CustomProfileView(profileId: profile.id, isNew: true),
);
await leave(tester);
expect(find.byType(InputDialog), findsOneWidget);
await tester.tap(find.text(currentAppLocalizations.cancel));
await tester.pumpAndSettle();
expect(editor, findsNothing);
expect(container.read(profilesProvider), [profile]);
});
testWidgets('asks for a name on leaving with its name selected', (
tester,
) async {
final profile = Profile.custom(label: 'Unnamed');
final container = buildContainer([profile], groups: const [proxyGroup]);
await pumpOver(
tester,
container,
CustomProfileView(profileId: profile.id, isNew: true),
);
await leave(tester);
final field = find.descendant(
of: find.byType(InputDialog),
matching: find.byType(EditableText),
);
final editable = tester.widget<EditableText>(field);
expect(editable.focusNode.hasFocus, isTrue);
expect(
editable.controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
await tester.enterText(field, 'Mine');
await tester.tap(find.text(currentAppLocalizations.submit));
await tester.pumpAndSettle();
expect(editor, findsNothing);
expect(container.read(profilesProvider).single.label, 'Mine');
});
testWidgets('is kept once renamed while its page is open', (tester) async {
final profile = Profile.custom(label: 'Unnamed');
final container = buildContainer([profile]);
await pumpOver(
tester,
container,
CustomProfileView(profileId: profile.id, isNew: true),
);
expect(find.byTooltip(currentAppLocalizations.rename), findsNothing);
container
.read(profilesProvider.notifier)
.updateProfile(
profile.id,
(profile) => profile.copyWith(label: 'Mine'),
);
await tester.pump();
expect(find.text('Mine'), findsOneWidget);
await leave(tester);
expect(container.read(profilesProvider).single.label, 'Mine');
expect(find.byType(InputDialog), findsNothing);
});
testWidgets('an existing profile is kept when left untouched', (
tester,
) async {
final profile = Profile.custom(label: 'mine');
final container = buildContainer([profile]);
await pumpOver(
tester,
container,
CustomProfileView(profileId: profile.id),
);
await leave(tester);
expect(container.read(profilesProvider), [profile]);
final setupAction =
container.read(setupActionProvider.notifier) as _RecordingSetupAction;
expect(setupAction.autoApplyCalls, 0);
});
testWidgets('an existing profile applies on leaving once edited', (
tester,
) async {
final profile = Profile.custom(label: 'mine');
final container = buildContainer([profile]);
await pumpOver(
tester,
container,
CustomProfileView(profileId: profile.id),
);
container
.read(profilesProvider.notifier)
.updateProfile(
profile.id,
(profile) => profile.copyWith(label: 'renamed'),
);
await tester.pump();
await leave(tester);
final setupAction =
container.read(setupActionProvider.notifier) as _RecordingSetupAction;
expect(setupAction.autoApplyCalls, 1);
});
});
}
+192
View File
@@ -0,0 +1,192 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/editor/clash_schema.dart';
import 'package:fl_clash/features/editor/relaxed_yaml.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
void main() {
Object? relaxed(String content, [EditorSchema schema = EditorSchema.dns]) =>
loadYaml(relaxYaml(content, schema)!);
group('relaxYaml', () {
test('drops the section header a pasted fragment still carries', () {
for (final (content, schema, expected) in [
(
'dns:\n enable: true\n ipv6: false\n',
EditorSchema.dns,
'enable: true\nipv6: false\n',
),
(
'DNS:\nenable: true\nipv6: false\n',
EditorSchema.dns,
'enable: true\nipv6: false\n',
),
(
'ntp:\n enable: true\n port: 123\n',
EditorSchema.ntp,
'enable: true\nport: 123\n',
),
]) {
expect(relaxYaml(content, schema), expected, reason: content);
}
});
test('nests each key the schema knows whatever its indentation', () {
expect(
relaxed('''
enable: true
ipv6: false
nameserver:
- 1.1.1.1
ipv6-timeout: 300
fallback-filter:
geoip: true
\tgeoip-code: CN
respect-rules: true
'''),
{
'enable': true,
'ipv6': false,
'nameserver': ['1.1.1.1'],
'ipv6-timeout': 300,
'fallback-filter': {'geoip': true, 'geoip-code': 'CN'},
'respect-rules': true,
},
);
});
test('nests a key the schema does not know by its indentation', () {
expect(
relaxed('''
nameserver:
- 1.1.1.1
cache-max-size: 4096
nameserver-policy:
localhost: 127.0.0.1
'''),
{
'nameserver': ['1.1.1.1'],
'cache-max-size': 4096,
'nameserver-policy': {
'localhost': ['127.0.0.1'],
},
},
);
});
test('reads a key with no space or a full-width colon after it', () {
expect(relaxed('Enable:true\nipv6:false\nlisten: 0.0.0.0:53\n'), {
'enable': true,
'ipv6': false,
'listen': '0.0.0.0:53',
});
});
test('turns a list written on one line into entries', () {
expect(
relaxed('''
nameserver: 223.5.5.5, tls://1.1.1.1:853 # cn first
fake-ip-filter:
*.lan,+.local
-localhost
nameserver-policy:
'geosite:cn': 223.5.5.5, 119.29.29.29
geosite:private:
- 192.168.1.1
proxy-server-nameserver: [8.8.8.8]
'''),
{
'nameserver': ['223.5.5.5', 'tls://1.1.1.1:853'],
'fake-ip-filter': ['*.lan', '+.local', 'localhost'],
'nameserver-policy': {
'geosite:cn': ['223.5.5.5', '119.29.29.29'],
'geosite:private': ['192.168.1.1'],
},
'proxy-server-nameserver': ['8.8.8.8'],
},
);
});
test('keeps a sniffer protocol apart from the keys around it', () {
expect(
relaxed('''
sniffer:
sniff:
http:
ports: 80, 8080-8880
override-destination: true
TLS:
ports: [443]
override-destination: false
''', EditorSchema.sniffer),
{
'sniff': {
'HTTP': {
'ports': [80, '8080-8880'],
'override-destination': true,
},
'TLS': {
'ports': [443],
'override-destination': false,
},
},
},
);
});
test('takes the one group of a pasted proxy-groups list', () {
expect(
relaxed('''
proxy-groups:
- name: Auto
type: url-test
proxies: HK 01, JP
''', EditorSchema.proxyGroup),
{
'name': 'Auto',
'type': 'url-test',
'proxies': ['HK 01', 'JP'],
},
);
});
test('leaves a document that is no map schema alone', () {
expect(relaxYaml('- MATCH,DIRECT', EditorSchema.rules), isNull);
});
});
group('readRelaxed', () {
const dns = Dns();
test('reads what parses as written without rewriting it', () {
final contents = <String>[];
readRelaxed('enable: true', EditorSchema.dns, contents.add);
expect(contents, ['enable: true']);
});
test('a DNS quick edit takes a header and loose indentation', () {
final result = readRelaxed(
'dns:\nenable: true\n nameserver: 1.1.1.1, 8.8.8.8\n',
EditorSchema.dns,
dns.applyOverrideYaml,
);
expect(result.dns.enable, isTrue);
expect(result.dns.nameserver, ['1.1.1.1', '8.8.8.8']);
expect(result.keys, {DnsOverrideKey.enable, DnsOverrideKey.nameserver});
});
test('reports the error of the text as written', () {
expect(
() => readRelaxed(
'bogus-key: 1\nenable:true',
EditorSchema.dns,
dns.applyOverrideYaml,
),
throwsA(isA<YamlException>()),
);
});
});
}
@@ -1,8 +1,9 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/rule.dart';
import 'package:fl_clash/features/form/rule.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -77,6 +78,73 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a rule without content centers its action', (tester) async {
await tester.pumpWidget(
TestApp(
child: Material(
child: Center(
child: SizedBox(
width: 600,
height: 64,
child: RuleItem(
isSelected: false,
rule: const Rule(
ruleAction: RuleAction.MATCH,
ruleTarget: 'DIRECT',
),
onSelected: () {},
onEdit: (_) {},
),
),
),
),
),
);
expect(
tester.getCenter(find.text(RuleAction.MATCH.name)).dy,
moreOrLessEquals(tester.getCenter(find.byType(RuleItem)).dy, epsilon: 1),
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a long target yields width to the action on one line', (
tester,
) async {
await tester.pumpWidget(
TestApp(
child: Material(
child: Center(
child: SizedBox(
width: 480,
height: 64,
child: RuleItem(
isSelected: false,
rule: const Rule(
ruleAction: RuleAction.DOMAIN_KEYWORD,
content: 'example',
ruleTarget: 'Singapore-Auto-Select',
),
onSelected: () {},
onEdit: (_) {},
),
),
),
),
),
);
final action = tester.renderObject<RenderParagraph>(
find.text(RuleAction.DOMAIN_KEYWORD.name),
);
expect(action.didExceedMaxLines, isFalse);
expect(action.size.height, action.getMinIntrinsicHeight(double.infinity));
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('selecting MATCH target shows the label but stores MATCH', (
tester,
) async {
@@ -109,6 +177,8 @@ void main() {
await tester.enterText(find.byType(TextFormField), 'example.com');
await tester.tap(find.byType(DropdownMenu));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text(l10n.matchTarget).last);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.matchTarget).last);
await tester.pumpAndSettle();
expect(
+1 -1
View File
@@ -1,4 +1,4 @@
import 'package:fl_clash/features/overwrite/rule_preset.dart';
import 'package:fl_clash/features/form/rule_preset.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/clash_config.dart';
import 'package:fl_clash/providers/app.dart';
+194
View File
@@ -0,0 +1,194 @@
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
final _proxyTypes = {
for (var i = 1; i <= sheetSearchMinItemCount; i++)
'Node $i': i.isEven ? 'Vmess' : 'Trojan',
};
Future<List<String>> _pumpSheet(
WidgetTester tester, {
required List<String> proxies,
}) async {
final picked = <String>[];
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 800)),
],
child: SheetProvider(
type: SheetType.bottomSheet,
child: SelectionSheet<String>(
title: 'Target',
sections: [
const SelectionSection(label: 'Basic', items: ['DIRECT', 'REJECT']),
SelectionSection(
label: 'Proxies',
items: proxies,
subtitleBuilder: (_, name) => _proxyTypes[name],
),
],
labelBuilder: (item) => item,
selectedOf: (_) => null,
onSelected: picked.add,
),
),
),
);
await tester.pump();
return picked;
}
void main() {
testWidgets('a short list has no search field', (tester) async {
await _pumpSheet(tester, proxies: ['Node 1']);
expect(find.byType(SearchField), findsNothing);
expect(find.text('Node 1'), findsOneWidget);
});
testWidgets('leaves out a section with nothing in it', (tester) async {
await _pumpSheet(tester, proxies: const []);
expect(find.text('Basic'), findsOneWidget);
expect(find.text('Proxies'), findsNothing);
});
testWidgets('narrows the sections by label and subtitle', (tester) async {
final picked = await _pumpSheet(tester, proxies: _proxyTypes.keys.toList());
expect(find.byType(SearchField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'vmess 4');
await tester.pump();
expect(find.text('Basic'), findsNothing);
expect(find.text('Node 4'), findsOneWidget);
expect(find.text('Node 2'), findsNothing);
await tester.tap(find.text('Node 4'));
expect(picked, ['Node 4']);
await tester.enterText(find.byType(TextField), 'reject');
await tester.pump();
expect(find.text('REJECT'), findsOneWidget);
expect(find.text('Proxies'), findsNothing);
});
testWidgets('a sheet that removes picks stays open until the last one', (
tester,
) async {
final picked = <String>[];
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 800)),
],
child: const SizedBox.shrink(),
),
);
globalState.navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => SheetProvider(
type: SheetType.bottomSheet,
child: SelectionSheet<String>(
title: 'Add',
sections: const [
SelectionSection(label: 'Basic', items: ['DIRECT', 'REJECT']),
SelectionSection(label: 'Proxies', items: ['Node 1']),
],
labelBuilder: (item) => item,
selectedOf: (_) => null,
removeOnSelect: true,
onSelected: picked.add,
),
),
),
);
await tester.pumpAndSettle();
final sheet = find.byType(SelectionSheet<String>);
await tester.tap(find.text('Node 1'));
await tester.pump();
await tester.tap(find.text('Node 1'), warnIfMissed: false);
expect(picked, ['Node 1']);
expect(find.text('Proxies'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('Node 1'), findsNothing);
expect(find.text('Proxies'), findsNothing);
await tester.tap(find.text('DIRECT'));
await tester.pumpAndSettle();
expect(sheet, findsOneWidget);
await tester.tap(find.text('REJECT'));
await tester.pumpAndSettle();
expect(picked, ['Node 1', 'DIRECT', 'REJECT']);
expect(sheet, findsNothing);
});
testWidgets('keeps the field above the no-results state', (tester) async {
await _pumpSheet(tester, proxies: _proxyTypes.keys.toList());
await tester.enterText(find.byType(TextField), 'missing');
await tester.pumpAndSettle();
expect(find.text(AppLocalizations.current.noSearchResults), findsOneWidget);
expect(find.byType(SearchField), findsOneWidget);
await tester.tap(find.byTooltip(AppLocalizations.current.clearSearch));
await tester.pumpAndSettle();
expect(find.text('Node 1'), findsOneWidget);
});
testWidgets('a drag on the list opens a bottom sheet to the tall detent', (
tester,
) async {
const view = Size(400, 900);
tester.view.physicalSize = view;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
TestApp(
overrides: [viewSizeProvider.overrideWithBuild((_, _) => view)],
child: Builder(
builder: (context) => TextButton(
onPressed: () => showSheet<void>(
context: context,
builder: (_) => SelectionSheet<String>(
title: 'Add',
sections: [
SelectionSection(
items: [for (var i = 0; i < 60; i++) 'Item $i'],
),
],
labelBuilder: (item) => item,
selectedOf: (_) => null,
onSelected: (_) {},
),
),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
double sheetTop() => tester.getTopLeft(find.byType(SheetDragHandle)).dy;
final list = find.byType(CustomScrollView);
expect(sheetTop(), closeTo(view.height * (1 - snapSheetDetents.first), 1));
await tester.fling(list, const Offset(0, -200), 2000);
await tester.pumpAndSettle();
expect(sheetTop(), closeTo(view.height * (1 - snapSheetDetents.last), 1));
expect(tester.widget<CustomScrollView>(list).controller!.offset, 0);
});
}
-10
View File
@@ -265,14 +265,4 @@ void main() {
expect(event.data, 'test log');
});
});
group('InvokeMessage', () {
test('fromJson', () {
final msg = InvokeMessage.fromJson({
'type': 'protect',
'data': {'method': 'test'},
});
expect(msg.type, InvokeMessageType.protect);
});
});
}
+98 -5
View File
@@ -26,16 +26,28 @@ class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
CustomImportApp _app({
Map<String, String> profiles = const {},
Map<ProviderSource, String> keyedProfiles = const {},
Set<String> profileLabels = const {},
Map<String, String> ruleProviders = const {},
Set<String> ruleProviderLabels = const {},
List<Map<String, dynamic>> proxies = const [],
Set<String> groupNames = const {},
}) => (
profileLabels: {...profileLabels, ...profiles.values},
profileUrls: profiles,
profileLabels: {
...profileLabels,
...profiles.values,
...keyedProfiles.values,
},
profileSources: {
for (final MapEntry(:key, :value) in profiles.entries)
(url: key, ageSecretKey: ''): value,
...keyedProfiles,
},
ruleProviderLabels: {...ruleProviderLabels, ...ruleProviders.values},
ruleProviderUrls: ruleProviders,
ruleProviderSources: {
for (final MapEntry(:key, :value) in ruleProviders.entries)
(url: key, ageSecretKey: ''): value,
},
proxies: proxies,
groupNames: groupNames,
);
@@ -77,7 +89,12 @@ proxy-groups:
''', _app(profileLabels: {'sub'}));
expect(config.proxyProviders, [
(label: 'sub(1)', url: 'https://example.com/new', content: ''),
(
label: 'sub(1)',
url: 'https://example.com/new',
ageSecretKey: '',
content: '',
),
]);
expect(config.proxyGroups.single.use, ['sub(1)', 'other']);
expect(config.overrides.proxyProviders, {
@@ -104,6 +121,7 @@ proxy-providers:
expect(config.proxyProviders.first, (
label: 'local',
url: '',
ageSecretKey: '',
content: '',
));
final inline = config.proxyProviders.last;
@@ -116,6 +134,61 @@ proxy-providers:
});
});
test('matches a provider without a key by its url alone', () {
final config = _read(
'''
proxy-providers:
sub: {type: http, url: https://example.com/sub}
proxy-groups:
- {name: Proxy, type: select, use: [sub]}
''',
_app(
keyedProfiles: {
(url: 'https://example.com/sub', ageSecretKey: 'AGE-SECRET-KEY-1A'):
'Keyed',
},
),
);
expect(config.proxyProviders, isEmpty);
expect(config.proxyGroups.single.use, ['Keyed']);
});
test('matches a provider with a key by its url and key', () {
final config = _read(
'''
proxy-providers:
same:
type: http
url: https://example.com/sub
age-secret-key: AGE-SECRET-KEY-1A
other:
type: http
url: https://example.com/sub
age-secret-key: AGE-SECRET-KEY-1B
proxy-groups:
- {name: Proxy, type: select, use: [same, other]}
''',
_app(
profiles: {'https://example.com/sub': 'Plain'},
keyedProfiles: {
(url: 'https://example.com/sub', ageSecretKey: 'AGE-SECRET-KEY-1A'):
'Keyed',
},
),
);
expect(config.proxyProviders, [
(
label: 'other',
url: 'https://example.com/sub',
ageSecretKey: 'AGE-SECRET-KEY-1B',
content: '',
),
]);
expect(config.proxyGroups.single.use, ['Keyed', 'other']);
});
test('adds one provider for two names sharing a url', () {
final config = _read('''
proxy-providers:
@@ -170,7 +243,12 @@ sniffer:
);
expect(config.ruleProviders, [
(label: 'ads(1)', url: 'https://example.com/ads.yaml', content: ''),
(
label: 'ads(1)',
url: 'https://example.com/ads.yaml',
ageSecretKey: '',
content: '',
),
]);
expect(
[for (final rule in config.rules) rule.rawValue],
@@ -401,6 +479,21 @@ rules:
expect(config.rules.single.ruleTarget, 'G');
});
test('moves a proxy off a name the Core keeps, but not a group', () {
final config = _read('''
proxies:
- {name: GLOBAL, type: ss, server: a.example.com}
proxy-groups:
- {name: GLOBAL, type: select, proxies: [DIRECT]}
''');
expect(
[for (final proxy in config.proxies) proxy['name']],
['GLOBAL-01'],
);
expect(config.proxyGroups.single.name, 'GLOBAL');
});
test(
'drops include-all-providers and keeps the proxies of include-all',
() {
+316 -22
View File
@@ -750,6 +750,46 @@ void main() {
]);
});
test('a proxy cannot take GLOBAL, which a group may', () {
const proxy = CustomProxy(id: 1, definition: {'name': 'GLOBAL'});
expect(
customProxyIssues(proxy, proxies: const [proxy], groupNames: const {}),
[const CustomIssue.reservedName('GLOBAL')],
);
const group = ProxyGroup(
id: 3,
name: 'GLOBAL',
type: GroupType.Selector,
proxies: ['DIRECT'],
);
expect(proxyGroupIssues(group, profileData), isEmpty);
});
test('a rule may target the GLOBAL the core adds, which a group may not '
'hold', () {
expect(
customRuleIssues(Rule.parse('MATCH,GLOBAL', id: 1), profileData),
isEmpty,
);
const group = ProxyGroup(
id: 3,
name: 'G',
type: GroupType.Selector,
proxies: ['GLOBAL'],
);
expect(proxyGroupIssues(group, profileData), [
const CustomIssue.missingProxies(['GLOBAL']),
]);
});
test('a rule cannot target a name holding a comma', () {
final data = profileData.copyWith(ruleTargets: {'DIRECT', 'A,B'});
const rule = Rule(id: 1, ruleAction: RuleAction.MATCH, ruleTarget: 'A,B');
expect(customRuleIssues(rule, data), [
const CustomIssue.missingTarget('A,B'),
]);
});
test('custom proxies report names and core errors together', () {
const first = CustomProxy(id: 1, definition: {'name': 'X'});
const second = CustomProxy(id: 2, definition: {'name': 'X'});
@@ -779,7 +819,7 @@ void main() {
]);
});
test('credentials match the keys each option struct of the core reads', () {
group('proxy fields follow the option structs of the core', () {
const root = 'core/Clash.Meta/adapter';
final structs = {
for (final file in Directory(
@@ -787,32 +827,283 @@ void main() {
).listSync().whereType<File>())
if (!file.path.endsWith('_test.go'))
for (final match in RegExp(
r'^type (\w+Option) struct \{\n([\s\S]*?)\n\}',
r'^type (\w+) struct \{\n([\s\S]*?)\n\}',
multiLine: true,
).allMatches(file.readAsStringSync()))
match.group(1)!: match.group(2)!,
};
final cases = RegExp(
r'^\tcase "([^"]+)":\n\t\t\w+ := &outbound\.(\w+)\{',
final keyPattern = RegExp(r'proxy:"([^",]+)(,omitempty)?"');
final typePattern = RegExp(
r'^\t\w+\s+(\S+)\s+`proxy:"([^",]+)',
multiLine: true,
).allMatches(File('$root/parser.go').readAsStringSync()).toList();
final keyPattern = RegExp(r'proxy:"(username|password|uuid)[,"]');
);
final embeddedPattern = RegExp(r'^\t(\w+Option)$', multiLine: true);
final bodies = {
for (final match in RegExp(
r'^\tcase "([^"]+)":\n\t\t\w+ := &outbound\.(\w+)\{',
multiLine: true,
).allMatches(File('$root/parser.go').readAsStringSync()))
match.group(1)!: [
structs[match.group(2)]!,
for (final embedded in embeddedPattern.allMatches(
structs[match.group(2)]!,
))
if (embedded.group(1) != 'BasicOption') structs[embedded.group(1)]!,
structs['BasicOption']!,
].join('\n'),
};
Set<String> keysIn(String body, {bool required = false}) => {
for (final match in keyPattern.allMatches(body))
if (match.group(1) != '-' && (!required || match.group(2) == null))
match.group(1)!,
};
Set<String> keysOf(String type, {bool required = false}) =>
keysIn(bodies[type]!, required: required);
expect(cases, hasLength(customProxyTypes.length));
for (final match in cases) {
final type = match.group(1)!;
expect(
CustomProxy(
id: 0,
definition: {'type': type},
).credentials.map((credential) => credential.name).toSet(),
keyPattern
.allMatches(structs[match.group(2)]!)
.map((key) => key.group(1))
.toSet(),
reason: type,
);
}
test('every type the core parses has its struct', () {
expect(bodies.keys, customProxyTypes);
});
test('a plugin offers only the plugin-opts keys it reads', () {
List<ProxyField> optionsOf(String plugin) => CustomProxy(
id: 0,
definition: {'type': 'ss', 'plugin': plugin},
).options;
expect(optionsOf('kcptun'), contains(ProxyField.pluginMode));
expect(optionsOf('kcptun'), isNot(contains(ProxyField.pluginHost)));
expect(optionsOf('shadow-tls'), contains(ProxyField.pluginHost));
expect(optionsOf('shadow-tls'), isNot(contains(ProxyField.pluginMode)));
});
test('the form asks for the credentials each struct reads', () {
const credentials = {'username', 'password', 'uuid'};
for (final type in customProxyTypes) {
expect(
{
for (final field in CustomProxy(
id: 0,
definition: {'type': type},
).credentials)
field.path.single,
},
keysOf(type).intersection(credentials),
reason: type,
);
}
});
test('the required keys are the ones each struct requires', () {
for (final type in customProxyTypes) {
expect(
CustomProxy(id: 0, definition: {'type': type}).requiredKeys,
keysOf(type, required: true).difference({'name'}),
reason: type,
);
}
});
test('the form always shows every key a struct requires', () {
// A PEM block does not fit a form row, and the form has no row for a
// zerotier network yet.
const notInForm = {
'openvpn': {'ca'},
'zerotier': {'network'},
};
for (final type in customProxyTypes) {
final proxy = CustomProxy(id: 0, definition: {'type': type});
expect(
{for (final field in proxy.fields) field.path.single},
containsAll(
proxy.requiredKeys.difference({
'server',
'port',
...?notInForm[type],
}),
),
reason: type,
);
}
});
test('every field and option is a key its struct reads', () {
for (final type in customProxyTypes) {
final definitions = [
{'type': type, 'plugin': 'obfs', 'obfs': 'salamander'},
for (final network in CustomProxy(
id: 0,
definition: {'type': type},
).choicesOf(ProxyField.network))
{'type': type, 'network': network},
];
for (final definition in definitions) {
final proxy = CustomProxy(id: 0, definition: definition);
for (final field in [...proxy.fields, ...proxy.options]) {
var body = bodies[type]!;
for (final key in field.path) {
expect(keysIn(body), contains(key), reason: '$type $field');
final nested =
structs[[
for (final match in typePattern.allMatches(body))
if (match.group(2) == key) match.group(1)!,
].first];
if (nested == null) {
break;
}
body = nested;
}
if (field.kind == ProxyFieldKind.choice) {
expect(
proxy.choicesOf(field),
isNotEmpty,
reason: '$type $field',
);
}
}
}
}
});
test('every choice the form offers is a value the core names', () {
final sources = [
for (final directory in [
'adapter',
'component',
'constant',
'transport',
])
for (final file in Directory(
'core/Clash.Meta/$directory',
).listSync(recursive: true).whereType<File>())
if (file.path.endsWith('.go') && !file.path.endsWith('_test.go'))
file.readAsStringSync().toLowerCase(),
].join('\n');
// These fields take their values from modules outside the submodule.
const elsewhere = {
'ss': {ProxyField.cipher},
'vmess': {ProxyField.cipher},
'hysteria2': {ProxyField.obfs},
};
for (final type in customProxyTypes) {
final proxy = CustomProxy(id: 0, definition: {'type': type});
for (final field in ProxyField.values) {
if (elsewhere[type]?.contains(field) ?? false) {
continue;
}
for (final choice in proxy.choicesOf(field)) {
expect(
sources,
contains('"${choice.toLowerCase()}"'),
reason: '$type $field $choice',
);
}
}
}
});
});
test('a nested field adds and drops the maps that hold it, in any case the '
'core reads', () {
const proxy = CustomProxy(
id: 0,
definition: {'type': 'vless', 'network': 'ws'},
);
final withHost = proxy.withField(ProxyField.wsHost, 'cdn.example');
expect(withHost.definition['ws-opts'], {
'headers': {'Host': 'cdn.example'},
});
expect(withHost.valueOf(ProxyField.wsHost), 'cdn.example');
expect(
withHost.withField(ProxyField.wsHost, null).definition,
proxy.definition,
);
const lower = CustomProxy(
id: 0,
definition: {
'type': 'vless',
'network': 'ws',
'ws-opts': {
'headers': {'host': 'a.example'},
},
},
);
expect(lower.valueOf(ProxyField.wsHost), 'a.example');
expect(
lower.withField(ProxyField.wsHost, 'b.example').definition['ws-opts'],
{
'headers': {'host': 'b.example'},
},
);
});
test('a changed value drops the options only the old one made available', () {
const proxy = CustomProxy(
id: 0,
definition: {
'type': 'vless',
'network': 'ws',
'ws-opts': {
'path': '/a',
'headers': {'Host': 'a.example'},
'max-early-data': 2048,
},
},
);
expect(proxy.withField(ProxyField.network, 'grpc').definition, {
'type': 'vless',
'network': 'grpc',
'ws-opts': {'max-early-data': 2048},
});
expect(
const CustomProxy(
id: 0,
definition: {
'type': 'hysteria2',
'obfs': 'salamander',
'obfs-password': 'pw',
},
).withField(ProxyField.obfs, null).definition,
{'type': 'hysteria2'},
);
});
test('a new type keeps what it reads alike and starts from the values '
'nearly every server takes', () {
const proxy = CustomProxy(
id: 0,
definition: {
'name': 'a',
'type': 'ss',
'cipher': 'aes-256-gcm',
'password': 'pw',
'udp-over-tcp': true,
},
);
final vmess = proxy.withType('vmess');
expect(vmess.definition, {
'name': 'a',
'type': 'vmess',
'cipher': 'auto',
'alterId': 0,
});
expect(vmess.definition.keys.first, 'name');
expect(vmess.withType('ss').definition, {'name': 'a', 'type': 'ss'});
expect(
proxy
.withField(ProxyField.cipher, 'aes-128-gcm')
.withType('vmess')
.definition['cipher'],
'aes-128-gcm',
);
expect(proxy.withType('trojan').definition, {
'name': 'a',
'type': 'trojan',
'password': 'pw',
});
expect(proxy.withType('ss'), same(proxy));
});
test('imported links take names no proxy holds yet', () {
@@ -822,6 +1113,7 @@ void main() {
{'name': '', 'type': 'vless', 'server': 'b.example'},
{'name': '-01', 'type': 'vless', 'server': 'b.example'},
{'name': 'DIRECT', 'type': 'trojan', 'server': 'c.example'},
{'name': 'GLOBAL', 'type': 'trojan', 'server': 'd.example'},
],
existing: const [
CustomProxy(id: 1, definition: {'name': 'Home', 'type': 'ss'}),
@@ -834,14 +1126,16 @@ void main() {
'b.example-01',
'b.example-02',
'DIRECT-01',
'GLOBAL-01',
]);
expect(proxies.map((proxy) => proxy.server), [
'a.example',
'b.example',
'b.example',
'c.example',
'd.example',
]);
expect(proxies.map((proxy) => proxy.id).toSet(), hasLength(4));
expect(proxies.map((proxy) => proxy.id).toSet(), hasLength(5));
});
group('share link edit', () {
+153 -46
View File
@@ -1,5 +1,8 @@
import 'package:fl_clash/common/common.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/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/add.dart';
import 'package:material_ui/material_ui.dart';
@@ -7,15 +10,46 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
ProviderContainer _containerFor(WidgetTester tester) {
class _RecordingProfilesAction extends ProfilesAction {
final batches = <List<String>>[];
final singles = <String>[];
final ageSecretKeys = <String>[];
@override
Future<void> addProfileFromLink(
String link, {
String? label,
String ageSecretKey = '',
}) async {
singles.add(link);
ageSecretKeys.add(ageSecretKey);
}
@override
Future<void> addProfilesFromLinks(List<String> links) async =>
batches.add(links);
}
ProviderContainer _containerFor(
WidgetTester tester, {
ProfilesAction? profilesAction,
List<Profile> profiles = const [],
}) {
const size = Size(1400, 1000);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer();
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles(profiles)),
if (profilesAction != null)
profilesActionProvider.overrideWith(() => profilesAction),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).update((_) => size);
@@ -23,7 +57,7 @@ ProviderContainer _containerFor(WidgetTester tester) {
}
void main() {
testWidgets('lists the QR code, file, and URL import entries', (
testWidgets('lists the QR code, file, link, and custom entries', (
tester,
) async {
final container = _containerFor(tester);
@@ -34,7 +68,8 @@ void main() {
child: TestApp(
child: Scaffold(
body: Builder(
builder: (context) => AddProfileView(context: context),
builder: (context) =>
AddProfileView(context: context, origin: context),
),
),
),
@@ -45,15 +80,20 @@ void main() {
final l10n = currentAppLocalizations;
expect(find.text(l10n.qrcode), findsOne);
expect(find.text(l10n.file), findsOne);
expect(find.text(l10n.url), findsOne);
expect(find.text(l10n.link), findsOne);
expect(find.text(l10n.customProfile), findsOne);
expect(tester.takeException(), null);
});
testWidgets('URL import dialog rejects an empty value and keeps the sheet', (
testWidgets('a batch link import skips existing profiles and adds the rest', (
tester,
) async {
final container = _containerFor(tester);
String? popped;
final action = _RecordingProfilesAction();
final container = _containerFor(
tester,
profilesAction: action,
profiles: [Profile.normal(url: 'https://example.com/old')],
);
await tester.pumpWidget(
UncontrolledProviderScope(
@@ -61,15 +101,8 @@ void main() {
child: TestApp(
child: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () async {
popped = await showDialog<String>(
context: context,
builder: (_) => const URLFormDialog(),
);
},
child: const Text('open'),
),
builder: (context) =>
AddProfileView(context: context, origin: context),
),
),
),
@@ -77,25 +110,28 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
final l10n = currentAppLocalizations;
await tester.tap(find.text(l10n.link));
await tester.pumpAndSettle();
expect(find.byType(URLFormDialog), findsOne);
await tester.tap(find.text(currentAppLocalizations.submit));
await tester.tap(find.byTooltip(l10n.batchImport));
await tester.pumpAndSettle();
expect(
find.byType(URLFormDialog),
findsOne,
reason: 'an empty URL must not close the dialog',
await tester.enterText(
find.byType(TextField),
'https://example.com/a\nhttps://example.com/old\nhttps://example.com/b',
);
expect(popped, isNull);
await tester.pump();
await tester.tap(find.text(l10n.submit));
await tester.pumpAndSettle();
expect(action.batches, [
['https://example.com/a', 'https://example.com/b'],
]);
expect(tester.takeException(), null);
});
testWidgets('URL import dialog returns the entered value', (tester) async {
final container = _containerFor(tester);
String? popped;
testWidgets('the link import takes a proxy share link', (tester) async {
final action = _RecordingProfilesAction();
final container = _containerFor(tester, profilesAction: action);
await tester.pumpWidget(
UncontrolledProviderScope(
@@ -103,15 +139,8 @@ void main() {
child: TestApp(
child: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () async {
popped = await showDialog<String>(
context: context,
builder: (_) => const URLFormDialog(),
);
},
child: const Text('open'),
),
builder: (context) =>
AddProfileView(context: context, origin: context),
),
),
),
@@ -119,18 +148,96 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
final l10n = currentAppLocalizations;
await tester.tap(find.text(l10n.link));
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField),
'https://example.com/profile',
find.widgetWithText(TextFormField, l10n.link),
'trojan://pass@example.com:443#HK',
);
await tester.tap(find.text(currentAppLocalizations.submit));
await tester.pump();
await tester.tap(find.text(l10n.submit));
await tester.pumpAndSettle();
expect(find.byType(URLFormDialog), findsNothing);
expect(popped, 'https://example.com/profile');
expect(action.singles, ['trojan://pass@example.com:443#HK']);
expect(tester.takeException(), null);
});
testWidgets('the link import passes the age secret key along', (
tester,
) async {
final action = _RecordingProfilesAction();
final container = _containerFor(tester, profilesAction: action);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Scaffold(
body: Builder(
builder: (context) =>
AddProfileView(context: context, origin: context),
),
),
),
),
);
await tester.pumpAndSettle();
final l10n = currentAppLocalizations;
await tester.tap(find.text(l10n.link));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, l10n.link),
'https://example.com/sub',
);
await tester.enterText(
find.widgetWithText(TextFormField, l10n.ageSecretKey),
' AGE-SECRET-KEY-1A ',
);
await tester.pump();
await tester.tap(find.text(l10n.submit));
await tester.pumpAndSettle();
expect(action.singles, ['https://example.com/sub']);
expect(action.ageSecretKeys, ['AGE-SECRET-KEY-1A']);
expect(tester.takeException(), null);
});
testWidgets('the link import rejects what is neither kind of link', (
tester,
) async {
final action = _RecordingProfilesAction();
final container = _containerFor(tester, profilesAction: action);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Scaffold(
body: Builder(
builder: (context) =>
AddProfileView(context: context, origin: context),
),
),
),
),
);
await tester.pumpAndSettle();
final l10n = currentAppLocalizations;
await tester.tap(find.text(l10n.link));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, l10n.link),
'wireguard://key@example.com:51820',
);
await tester.pump();
await tester.tap(find.text(l10n.submit));
await tester.pumpAndSettle();
expect(find.text(l10n.invalidLinkTip), findsOne);
expect(action.singles, isEmpty);
expect(tester.takeException(), null);
});
}
-432
View File
@@ -1,432 +0,0 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/groups.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/proxies.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/proxy_providers.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:fl_clash/widgets/super_reorderable_list.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';
const _profileId = 1;
const _appProvider = ClashProvider(
id: 9,
kind: ProviderKind.proxy,
label: 'Shared nodes',
url: 'https://example.com/nodes.yaml',
);
class _TestClashProviders extends ClashProviders {
@override
Stream<List<ClashProvider>> build(ProviderKind kind) => Stream.value(
kind == ProviderKind.proxy ? const [_appProvider] : const [],
);
}
ProxyGroup _group({
List<String>? proxies,
List<String>? use,
bool? includeAllProxies,
bool? includeAllProviders,
}) {
return ProxyGroup(
id: 100,
profileId: _profileId,
name: 'Group',
type: GroupType.Selector,
proxies: proxies,
use: use,
includeAllProxies: includeAllProxies,
includeAllProviders: includeAllProviders,
);
}
Finder _rowOf(String title) => find
.ancestor(of: find.text(title), matching: find.byType(OverwriteFormRow))
.first;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(
() => feature = const Feature(customProviders: true, customProxies: true),
);
tearDown(() => feature = const Feature());
late ProviderContainer container;
setUpAll(() async {
await AppLocalizations.load(const Locale('en'));
});
ProviderContainer buildContainer(
ProxyGroup group, {
List<String> proxyProviders = const ['provider-a'],
}) {
final profile = Profile.normal(
label: 'p',
).copyWith(id: _profileId, overwriteType: OverwriteType.custom);
final built = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
clashProvidersProvider.overrideWith2((_) => _TestClashProviders()),
currentProfileIdProvider.overrideWithBuild((_, _) => _profileId),
proxyGroupProvider.overrideWithBuild((_, _) => group),
clashConfigProvider(_profileId).overrideWithValue(
AsyncData(
ClashConfig(
proxies: const [Proxy(name: 'DIRECT', type: 'Direct')],
proxyProviders: proxyProviders,
),
),
),
],
);
addTearDown(built.dispose);
globalState.container = built;
built.read(viewSizeProvider.notifier).update((_) => const Size(1400, 1000));
return built;
}
Future<void> pumpEditView(WidgetTester tester, Widget view) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: SheetProvider(
type: SheetType.page,
child: ProfileIdProvider(profileId: _profileId, child: view),
),
),
),
);
await tester.pump();
}
group('EditProxiesView', () {
testWidgets('lists the selected proxies', (tester) async {
container = buildContainer(_group(proxies: ['DIRECT', 'REJECT']));
await pumpEditView(tester, const EditProxiesView());
expect(find.text('DIRECT'), findsOneWidget);
expect(find.text('REJECT'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('shows the proxies empty state when none are selected', (
tester,
) async {
container = buildContainer(_group(proxies: const []));
await pumpEditView(tester, const EditProxiesView());
expect(find.text(currentAppLocalizations.proxiesEmpty), findsOneWidget);
});
testWidgets('the include toggle reflects includeAllProxies', (
tester,
) async {
container = buildContainer(
_group(proxies: const [], includeAllProxies: true),
);
await pumpEditView(tester, const EditProxiesView());
expect(
find.text(currentAppLocalizations.includeAllProxies),
findsOneWidget,
);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
});
testWidgets('toggling the switch flips includeAllProxies only', (
tester,
) async {
container = buildContainer(
_group(proxies: const [], includeAllProxies: false),
);
await pumpEditView(tester, const EditProxiesView());
await tester.tap(find.byType(Switch));
await tester.pump();
final group = container.read(proxyGroupProvider);
expect(group.includeAllProxies, isTrue);
expect(group.includeAllProviders, isNull);
});
testWidgets('reordering rewrites the proxies list', (tester) async {
container = buildContainer(_group(proxies: ['DIRECT', 'REJECT']));
await pumpEditView(tester, const EditProxiesView());
tester
.widget<SliverReorderableList>(
find.byType(SuperSliverReorderableList),
)
.onReorderItem!(0, 1);
await tester.pump();
expect(container.read(proxyGroupProvider).proxies, ['REJECT', 'DIRECT']);
});
});
group('EditProxyProvidersView', () {
testWidgets('lists the selected providers', (tester) async {
container = buildContainer(_group(use: ['provider-a']));
await pumpEditView(tester, const EditProxyProvidersView());
expect(find.text('provider-a'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('shows the providers empty state when none are selected', (
tester,
) async {
container = buildContainer(_group(use: const []));
await pumpEditView(tester, const EditProxyProvidersView());
expect(
find.text(currentAppLocalizations.proxyProvidersEmpty),
findsOneWidget,
);
});
testWidgets('the include toggle reflects includeAllProviders', (
tester,
) async {
container = buildContainer(
_group(use: const [], includeAllProviders: true),
);
await pumpEditView(tester, const EditProxyProvidersView());
expect(
find.text(currentAppLocalizations.includeAllProxyProviders),
findsOneWidget,
);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
});
testWidgets('toggling the switch flips includeAllProviders only', (
tester,
) async {
container = buildContainer(
_group(use: const [], includeAllProviders: false),
);
await pumpEditView(tester, const EditProxyProvidersView());
await tester.tap(find.byType(Switch));
await tester.pump();
final group = container.read(proxyGroupProvider);
expect(group.includeAllProviders, isTrue);
expect(group.includeAllProxies, isNull);
});
testWidgets('the add picker lists the subscription first in one list', (
tester,
) async {
container = buildContainer(_group(use: const []));
await pumpEditView(tester, const AddProxyProvidersView());
double top(String text) => tester.getTopLeft(find.text(text)).dy;
expect(find.text(currentAppLocalizations.proxyProviders), findsOneWidget);
expect(top('provider-a'), lessThan(top('p')));
expect(top('provider-a'), lessThan(top('Shared nodes')));
expect(find.text(currentAppLocalizations.profile), findsNothing);
});
testWidgets('the add picker shows a name the subscription has once', (
tester,
) async {
container = buildContainer(
_group(use: const []),
proxyProviders: const ['provider-a', 'Shared nodes'],
);
await pumpEditView(tester, const AddProxyProvidersView());
double top(String text) => tester.getTopLeft(find.text(text)).dy;
expect(find.text('Shared nodes'), findsOneWidget);
expect(top('Shared nodes'), lessThan(top('p')));
});
testWidgets('the selected providers say where each one resolves', (
tester,
) async {
container = buildContainer(
_group(use: ['provider-a', 'Shared nodes', 'p']),
);
await pumpEditView(tester, const EditProxyProvidersView());
for (final source in [
currentAppLocalizations.providerSourceSubscription,
currentAppLocalizations.app,
currentAppLocalizations.profile,
]) {
expect(find.text(source), findsOneWidget, reason: source);
}
});
testWidgets('with custom providers off the selected show no source', (
tester,
) async {
feature = const Feature();
container = buildContainer(_group(use: ['provider-a']));
await pumpEditView(tester, const EditProxyProvidersView());
expect(find.text('provider-a'), findsOneWidget);
expect(
find.text(currentAppLocalizations.providerSourceSubscription),
findsNothing,
);
});
testWidgets('with custom providers off the add picker lists only the '
'subscription', (tester) async {
feature = const Feature();
container = buildContainer(
_group(use: const []),
proxyProviders: const ['provider-a', 'Shared nodes'],
);
await pumpEditView(tester, const AddProxyProvidersView());
expect(find.text('provider-a'), findsOneWidget);
expect(find.text('Shared nodes'), findsOneWidget);
expect(find.text('p'), findsNothing);
});
testWidgets('reordering rewrites the use list', (tester) async {
container = buildContainer(_group(use: ['provider-a', 'provider-b']));
await pumpEditView(tester, const EditProxyProvidersView());
tester
.widget<SliverReorderableList>(
find.byType(SuperSliverReorderableList),
)
.onReorderItem!(0, 1);
await tester.pump();
expect(container.read(proxyGroupProvider).use, [
'provider-b',
'provider-a',
]);
});
});
group('EditProxyGroupView', () {
testWidgets('shows lazy on, as the core defaults it', (tester) async {
container = buildContainer(_group());
await pumpEditView(tester, const EditProxyGroupView());
final lazy = tester.widget<Switch>(
find.descendant(
of: _rowOf(currentAppLocalizations.testWhenUsed),
matching: find.byType(Switch),
),
);
expect(lazy.value, isTrue);
});
testWidgets('does not offer the relay type the core removed', (
tester,
) async {
container = buildContainer(_group());
await pumpEditView(tester, const EditProxyGroupView());
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
expect(find.text(GroupType.Selector.name), findsWidgets);
expect(find.text(GroupType.LoadBalance.name), findsOneWidget);
expect(find.text(GroupType.Relay.name), findsNothing);
});
testWidgets('offers tolerance only to a url-test group', (tester) async {
container = buildContainer(_group());
await pumpEditView(tester, const EditProxyGroupView());
expect(find.text(currentAppLocalizations.tolerance), findsNothing);
container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(type: GroupType.URLTest));
await tester.pump();
expect(find.text(currentAppLocalizations.tolerance), findsOneWidget);
expect(find.text(currentAppLocalizations.strategy), findsNothing);
});
testWidgets('offers strategy only to a load-balance group', (tester) async {
container = buildContainer(_group());
await pumpEditView(tester, const EditProxyGroupView());
container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(type: GroupType.LoadBalance));
await tester.pump();
expect(find.text(currentAppLocalizations.strategy), findsOneWidget);
expect(
find.text(LoadBalanceStrategy.consistentHashing.value),
findsOneWidget,
);
await tester.ensureVisible(find.text(currentAppLocalizations.strategy));
await tester.pump();
await tester.tap(find.text(currentAppLocalizations.strategy));
await tester.pumpAndSettle();
await tester.tap(find.text(LoadBalanceStrategy.roundRobin.value));
await tester.pumpAndSettle();
expect(
container.read(proxyGroupProvider).strategy,
LoadBalanceStrategy.roundRobin,
);
});
testWidgets('writes the timeout the core reads in milliseconds', (
tester,
) async {
container = buildContainer(_group());
await pumpEditView(tester, const EditProxyGroupView());
await tester.ensureVisible(find.text(currentAppLocalizations.timeout));
await tester.pump();
await tester.enterText(
find.descendant(
of: _rowOf(currentAppLocalizations.timeout),
matching: find.byType(TextFormField),
),
'2500',
);
await tester.pump();
expect(container.read(proxyGroupProvider).timeout, 2500);
expect(find.text('ms'), findsWidgets);
});
});
}
+866
View File
@@ -0,0 +1,866 @@
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/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';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/custom/groups.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 'package:mocktail/mocktail.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart' show Override;
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
const _profileId = 1;
class _MockCore extends Mock implements CoreHandlerInterface {}
class _TestClashProviders extends ClashProviders {
@override
Stream<List<ClashProvider>> build() => Stream.value(const []);
}
ProxyGroup _group({List<String>? proxies, List<String>? use, String? filter}) {
return ProxyGroup(
id: 100,
profileId: _profileId,
name: 'Group',
type: GroupType.Selector,
proxies: proxies,
use: use,
filter: filter,
);
}
const _home = CustomProxy(
id: 1,
definition: {
'name': 'Home',
'type': 'socks5',
'server': '1.1.1.1',
'port': 1080,
},
);
class _TestCustomProxies extends CustomProxies {
_TestCustomProxies(this.proxies);
final List<CustomProxy> proxies;
@override
Stream<List<CustomProxy>> build() => Stream.value(proxies);
}
class _TestProxyGroups extends ProxyGroups {
@override
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(const []);
}
class _TestProxyDialers extends ProxyDialers {
@override
Stream<Map<int, String>> build(int profileId) => Stream.value(const {});
}
class _PendingProfileData extends Notifier<CustomProfileData?> {
@override
CustomProfileData? build() => null;
void load(CustomProfileData data) => state = data;
}
final _pendingProfileDataProvider =
NotifierProvider<_PendingProfileData, CustomProfileData?>(
_PendingProfileData.new,
);
final _hongKong = defaultFilters.first;
final _remainingTraffic = defaultFilters.firstWhere(
(filter) => filter.label == 'Remaining Traffic',
);
Finder _rowOf(String title) =>
find.ancestor(of: find.text(title), matching: find.byType(FormRow)).first;
final _optionSheet = find.byWidgetPredicate(
(widget) => widget is SelectionSheet,
);
Future<void> _addOption(WidgetTester tester, String label) async {
await _openOptions(tester);
final option = find.descendant(of: _optionSheet, matching: find.text(label));
await tester.tap(option);
await tester.pumpAndSettle();
expect(option, findsNothing);
expect(_optionSheet, findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(_optionSheet, findsNothing);
}
Future<void> _openOptions(WidgetTester tester) async {
final add = find.widgetWithText(
FormRow,
currentAppLocalizations.addSettingEntry,
);
await tester.ensureVisible(add);
await tester.pump();
await tester.tap(add);
await tester.pumpAndSettle();
expect(_optionSheet, findsOneWidget);
}
Finder _bannerText(String text) => find.descendant(
of: find.byType(ErrorBanner),
matching: find.textContaining(text),
);
Future<void> _dismissSaveBlocked(WidgetTester tester, String reason) async {
final dialog = find.byType(CommonDialog);
expect(
find.descendant(
of: dialog,
matching: find.text(currentAppLocalizations.cannotSave),
),
findsOneWidget,
);
expect(
find.descendant(of: dialog, matching: find.textContaining(reason)),
findsOneWidget,
);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
late _MockCore core;
setUpAll(() async {
await AppLocalizations.load(const Locale('en'));
registerFallbackValue(<String>[]);
});
ProviderContainer buildContainer(
ProxyGroup group, {
List<CustomProxy> customProxies = const [],
List<Override> overrides = const [],
}) {
final profile = Profile.custom(label: 'mine').copyWith(id: _profileId);
final subscription = Profile.normal(
label: 'p',
url: 'https://example.com/p',
).copyWith(id: 2);
core = _MockCore();
when(() => core.validateFilters(any())).thenAnswer(
(invocation) async => [
for (final filter
in invocation.positionalArguments.single as List<String>)
filter.contains('(') ? 'missing closing )' : '',
],
);
final built = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
profilesProvider.overrideWith(
() => TestProfiles([profile, subscription]),
),
clashProvidersProvider.overrideWith(_TestClashProviders.new),
customProxiesProvider.overrideWith(
() => _TestCustomProxies(customProxies),
),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups()),
proxyDialersProvider.overrideWith2((_) => _TestProxyDialers()),
currentProfileIdProvider.overrideWithBuild((_, _) => _profileId),
proxyGroupProvider.overrideWithBuild((_, _) => group),
...overrides,
],
);
addTearDown(built.dispose);
globalState.container = built;
built.read(viewSizeProvider.notifier).update((_) => const Size(1400, 1000));
return built;
}
Future<void> pumpEditView(WidgetTester tester, Widget view) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: SheetProvider(
type: SheetType.page,
child: ProfileIdProvider(profileId: _profileId, child: view),
),
),
),
);
await tester.pump();
}
/// The option sheet pages over the form, as it does in the app.
Future<void> pumpGroupSheet(WidgetTester tester) async {
await pumpEditView(
tester,
Builder(
builder: (context) => FilledButton(
onPressed: () => showNestedFormSheet<ProxyGroup>(
context: context,
profileId: _profileId,
overrides: const [],
currentOf: (ref) => ref.read(proxyGroupProvider),
formBuilder: (_) => const EditProxyGroupView(),
),
child: const Text('open'),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
group('group members', () {
Finder rowOf(String title) => find.ancestor(
of: find.text(title),
matching: find.byType(DecorationListItem),
);
testWidgets('every member sits in one list that ends in an add row', (
tester,
) async {
final l = currentAppLocalizations;
container = buildContainer(
_group(
proxies: ['DIRECT', 'Home'],
use: ['p'],
filter: '${_hongKong.regex}`mine',
).copyWith(excludeFilter: _remainingTraffic.regex),
customProxies: const [_home],
);
await pumpEditView(tester, const EditProxyGroupView());
await tester.pump();
double top(String text) => tester.getTopLeft(find.text(text)).dy;
final order = [
'DIRECT',
'Home',
'p',
_hongKong.label,
'mine',
_remainingTraffic.label,
l.addNodes,
];
for (final (index, name) in order.indexed.skip(1)) {
expect(top(name), greaterThan(top(order[index - 1])), reason: name);
}
expect(find.text(l.basicStrategy), findsOneWidget);
expect(find.text('socks5'), findsOneWidget);
expect(
find.descendant(of: rowOf('p'), matching: find.text(l.proxyProviders)),
findsOneWidget,
);
expect(find.text(l.filters), findsNWidgets(2));
expect(
find.descendant(
of: rowOf(_remainingTraffic.label),
matching: find.text(l.excludeFilter),
),
findsOneWidget,
);
expect(find.text(_hongKong.regex), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('removing the last exclude filter clears it', (tester) async {
container = buildContainer(
_group(proxies: ['DIRECT']).copyWith(excludeFilter: 'x'),
);
await pumpEditView(tester, const EditProxyGroupView());
await tester.tap(
find.descendant(
of: rowOf('x'),
matching: find.byTooltip(currentAppLocalizations.remove),
),
);
await tester.pump();
expect(container.read(proxyGroupProvider).excludeFilter, isNull);
expect(container.read(proxyGroupProvider).proxies, ['DIRECT']);
});
testWidgets('each kind of member reorders in a list of its own', (
tester,
) async {
container = buildContainer(
_group(proxies: ['DIRECT', 'REJECT'], use: ['a', 'b'], filter: 'x`y'),
);
await pumpEditView(tester, const EditProxyGroupView());
final lists = find.byType(SuperSliverReorderableList);
Future<void> move(int list, int from, int to) async {
tester.widget<SliverReorderableList>(lists.at(list)).onReorderItem!(
from,
to,
);
await tester.pump();
}
expect(lists, findsNWidgets(3));
await move(0, 0, 1);
await move(1, 0, 1);
await move(2, 0, 1);
final group = container.read(proxyGroupProvider);
expect(group.proxies, ['REJECT', 'DIRECT']);
expect(group.use, ['b', 'a']);
expect(group.filter, 'y`x');
});
testWidgets('removing the last filter clears it', (tester) async {
container = buildContainer(_group(proxies: ['DIRECT'], filter: 'x'));
await pumpEditView(tester, const EditProxyGroupView());
await tester.tap(
find.descendant(
of: rowOf('x'),
matching: find.byTooltip(currentAppLocalizations.remove),
),
);
await tester.pump();
expect(container.read(proxyGroupProvider).filter, isNull);
expect(container.read(proxyGroupProvider).proxies, ['DIRECT']);
});
testWidgets('a removed member collapses out, and a move skips it', (
tester,
) async {
container = buildContainer(_group(proxies: ['DIRECT', 'REJECT', 'PASS']));
await pumpEditView(tester, const EditProxyGroupView());
await tester.tap(
find.descendant(
of: rowOf('DIRECT'),
matching: find.byTooltip(currentAppLocalizations.remove),
),
);
await tester.pump();
expect(container.read(proxyGroupProvider).proxies, ['REJECT', 'PASS']);
expect(find.text('DIRECT'), findsOneWidget);
tester
.widget<SliverReorderableList>(
find.byType(SuperSliverReorderableList),
)
.onReorderItem!(2, 1);
await tester.pump();
expect(container.read(proxyGroupProvider).proxies, ['PASS', 'REJECT']);
await tester.pumpAndSettle();
expect(find.text('DIRECT'), findsNothing);
});
testWidgets('the picker offers what the group lacks and adds each pick', (
tester,
) async {
final l = currentAppLocalizations;
container = buildContainer(
_group(proxies: ['DIRECT'], filter: 'mine'),
customProxies: const [_home],
);
final picker = find.byWidgetPredicate(
(widget) =>
widget.runtimeType.toString() == '_MemberPicker<ProxyGroup>',
);
Finder offered(String name) =>
find.descendant(of: picker, matching: find.text(name));
await pumpGroupSheet(tester);
await tester.ensureVisible(find.text(l.addNodes));
await tester.pumpAndSettle();
await tester.tap(find.text(l.addNodes));
await tester.pumpAndSettle();
expect(offered('REJECT'), findsNothing);
for (final header in [
'${l.basicStrategy} (5)',
'${l.localProxies} (1)',
'${l.proxyProviders} (1)',
]) {
await tester.tap(offered(header));
await tester.pumpAndSettle();
}
await tester.tap(
find.descendant(
of: picker,
matching: find.textContaining('${l.filters} ('),
),
);
await tester.pumpAndSettle();
expect(offered('DIRECT'), findsNothing);
expect(offered('mine'), findsNothing);
expect(offered(l.url), findsOneWidget);
expect(offered('https://example.com/p'), findsNothing);
await tester.tap(offered('REJECT'));
await tester.pump();
expect(container.read(proxyGroupProvider).proxies, ['DIRECT', 'REJECT']);
expect(offered('REJECT'), findsOneWidget);
await tester.tap(offered('REJECT'));
await tester.pump();
expect(container.read(proxyGroupProvider).proxies, ['DIRECT']);
await tester.tap(offered('REJECT'));
await tester.tap(offered('Home'));
await tester.tap(offered('p'));
await tester.scrollUntilVisible(
offered(_hongKong.label),
200,
scrollable: find
.descendant(of: picker, matching: find.byType(Scrollable))
.first,
);
await tester.tap(offered(_hongKong.label));
await tester.pump();
expect(find.byTooltip(l.confirm), findsNothing);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(picker, findsNothing);
final group = container.read(proxyGroupProvider);
expect(group.proxies, ['DIRECT', 'REJECT', 'Home']);
expect(group.use, ['p']);
expect(group.filter, 'mine`${_hongKong.regex}');
});
testWidgets('the picker adds exclude filters as members of their own', (
tester,
) async {
final l = currentAppLocalizations;
container = buildContainer(_group(proxies: ['DIRECT']));
final picker = find.byWidgetPredicate(
(widget) =>
widget.runtimeType.toString() == '_MemberPicker<ProxyGroup>',
);
await pumpGroupSheet(tester);
await tester.ensureVisible(find.text(l.addNodes));
await tester.pumpAndSettle();
await tester.tap(find.text(l.addNodes));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: picker,
matching: find.textContaining('${l.excludeFilter} ('),
),
);
await tester.pumpAndSettle();
final info = find.descendant(
of: picker,
matching: find.text(_remainingTraffic.label),
);
await tester.scrollUntilVisible(
info,
200,
scrollable: find
.descendant(of: picker, matching: find.byType(Scrollable))
.first,
);
await tester.tap(info);
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
final group = container.read(proxyGroupProvider);
expect(group.excludeFilter, _remainingTraffic.regex);
expect(group.filter, isNull);
expect(
find.descendant(
of: find.byType(SuperSliverReorderableList),
matching: find.text(_remainingTraffic.label),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byType(SuperSliverReorderableList),
matching: find.text(l.excludeFilter),
),
findsOneWidget,
);
});
});
group('EditProxyGroupView', () {
testWidgets('a new group holds its unfilled fields back until a save', (
tester,
) async {
container = buildContainer(_group().copyWith(id: -1, name: ''));
await pumpEditView(tester, const EditProxyGroupView());
expect(
_bannerText(currentAppLocalizations.customIssueEmptyName),
findsNothing,
);
expect(
_bannerText(currentAppLocalizations.customIssueNoProxySource),
findsNothing,
);
container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(name: 'DIRECT'));
await tester.pumpAndSettle();
expect(
_bannerText(currentAppLocalizations.customIssueReservedName('DIRECT')),
findsOneWidget,
);
expect(
_bannerText(currentAppLocalizations.customIssueNoProxySource),
findsNothing,
);
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(
_bannerText(currentAppLocalizations.customIssueNoProxySource),
findsOneWidget,
);
});
testWidgets('saving on the way out shows what an unfinished group lacks', (
tester,
) async {
final l = currentAppLocalizations;
container = buildContainer(_group().copyWith(id: -1, name: ''));
await pumpGroupSheet(tester);
container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(name: 'g2'));
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text(l.saveChanges), findsOneWidget);
await tester.tap(find.text(l.confirm));
await tester.pumpAndSettle();
await _dismissSaveBlocked(tester, l.customIssueNoProxySource);
expect(find.byType(CommonDialog), findsNothing);
expect(find.byType(EditProxyGroupView), findsOneWidget);
expect(_bannerText(l.customIssueNoProxySource), findsOneWidget);
});
testWidgets('a save before the profile data loads does nothing', (
tester,
) async {
container = buildContainer(
_group().copyWith(id: -1, name: ''),
overrides: [
customProfileDataProvider(
_profileId,
).overrideWith((ref) => ref.watch(_pendingProfileDataProvider)),
],
);
final emptyName = _bannerText(
currentAppLocalizations.customIssueEmptyName,
);
await pumpEditView(tester, const EditProxyGroupView());
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(find.byType(EditProxyGroupView), findsOneWidget);
container
.read(_pendingProfileDataProvider.notifier)
.load(const CustomProfileData(ruleTargets: {'DIRECT'}));
await tester.pumpAndSettle();
expect(emptyName, findsNothing);
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(emptyName, findsOneWidget);
});
testWidgets('refuses an edit that makes an app proxy dial through itself', (
tester,
) async {
final group = _group(proxies: ['DIRECT']);
container = buildContainer(
group,
overrides: [
customProfileDataProvider(_profileId).overrideWithValue(
CustomProfileData(
proxyGroups: [
const ProxyGroup(
id: 1,
name: 'Landing',
type: GroupType.Selector,
proxies: ['Home'],
),
group,
],
ruleTargets: const {'DIRECT', 'Landing', 'Group'},
proxies: const {'Home'},
dialers: const {'Home': 'Group'},
),
),
],
);
final message = currentAppLocalizations.customIssueDialerLoop(
'Home',
'Group',
);
await pumpEditView(tester, const EditProxyGroupView());
expect(_bannerText(message), findsNothing);
container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(proxies: ['DIRECT', 'Landing']));
await tester.pumpAndSettle();
expect(_bannerText(message), findsOneWidget);
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
await _dismissSaveBlocked(tester, message);
expect(find.byType(EditProxyGroupView), findsOneWidget);
expect(_bannerText(message), findsOneWidget);
});
testWidgets('the issues banner stays on top as the form scrolls', (
tester,
) async {
final l = currentAppLocalizations;
container = buildContainer(
_group().copyWith(
url: 'https://example.com',
interval: 600,
timeout: 2000,
maxFailedTimes: 3,
lazy: false,
hidden: true,
disableUDP: true,
),
);
await pumpEditView(tester, const EditProxyGroupView());
tester.view.physicalSize = const Size(1400, 600);
await tester.pumpAndSettle();
final banner = _bannerText(l.customIssueNoProxySource);
final top = tester.getTopLeft(banner).dy;
final general = tester.getTopLeft(find.text(l.general)).dy;
await tester.drag(find.text(l.nodes), const Offset(0, -200));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text(l.general)).dy, lessThan(general));
expect(tester.getTopLeft(banner).dy, top);
});
testWidgets('an existing group shows its issues on opening', (
tester,
) async {
container = buildContainer(_group());
await pumpEditView(tester, const EditProxyGroupView());
await tester.pumpAndSettle();
expect(
_bannerText(currentAppLocalizations.customIssueNoProxySource),
findsOneWidget,
);
});
testWidgets('a new group opens on the basics alone', (tester) async {
final l = currentAppLocalizations;
container = buildContainer(_group().copyWith(id: -1, name: ''));
await pumpEditView(tester, const EditProxyGroupView());
expect(find.text(l.name), findsOneWidget);
expect(find.text(l.nodes), findsOneWidget);
expect(find.text(l.addSettingEntry), findsOneWidget);
for (final title in [l.hideFromList, l.filter, l.testWhenUsed]) {
expect(find.text(title), findsNothing);
}
});
testWidgets('an option is added at the core default', (tester) async {
container = buildContainer(_group());
await pumpGroupSheet(tester);
await _addOption(tester, currentAppLocalizations.testWhenUsed);
final lazy = tester.widget<Switch>(
find.descendant(
of: _rowOf(currentAppLocalizations.testWhenUsed),
matching: find.byType(Switch),
),
);
expect(lazy.value, isTrue);
expect(container.read(proxyGroupProvider).lazy, isTrue);
});
testWidgets('an option set on the group shows, and removing it clears it', (
tester,
) async {
container = buildContainer(_group().copyWith(interval: 600));
await pumpGroupSheet(tester);
expect(find.text('600'), findsOneWidget);
expect(find.text(currentAppLocalizations.timeout), findsNothing);
await tester.tap(
find.descendant(
of: _rowOf(currentAppLocalizations.testInterval),
matching: find.byTooltip(currentAppLocalizations.remove),
),
);
await tester.pumpAndSettle();
expect(container.read(proxyGroupProvider).interval, isNull);
expect(find.text(currentAppLocalizations.testInterval), findsNothing);
});
testWidgets('does not offer the relay type the core removed', (
tester,
) async {
container = buildContainer(_group());
await pumpGroupSheet(tester);
await tester.tap(find.text(currentAppLocalizations.proxyType));
await tester.pumpAndSettle();
expect(find.text(GroupType.Selector.name), findsWidgets);
expect(find.text(GroupType.LoadBalance.name), findsOneWidget);
expect(find.text(GroupType.Relay.name), findsNothing);
});
testWidgets('offers tolerance only to a url-test group', (tester) async {
container = buildContainer(_group());
await pumpGroupSheet(tester);
await _openOptions(tester);
expect(find.text(currentAppLocalizations.tolerance), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(type: GroupType.URLTest));
await tester.pump();
await _openOptions(tester);
expect(find.text(currentAppLocalizations.tolerance), findsOneWidget);
expect(find.text(currentAppLocalizations.strategy), findsNothing);
});
testWidgets('offers strategy only to a load-balance group', (tester) async {
container = buildContainer(_group());
await pumpGroupSheet(tester);
container
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(type: GroupType.LoadBalance));
await tester.pump();
await _addOption(tester, currentAppLocalizations.strategy);
expect(find.text(currentAppLocalizations.strategy), findsOneWidget);
expect(
find.text(LoadBalanceStrategy.consistentHashing.value),
findsOneWidget,
);
await tester.ensureVisible(find.text(currentAppLocalizations.strategy));
await tester.pump();
await tester.tap(find.text(currentAppLocalizations.strategy));
await tester.pumpAndSettle();
await tester.tap(find.text(LoadBalanceStrategy.roundRobin.value));
await tester.pumpAndSettle();
expect(
container.read(proxyGroupProvider).strategy,
LoadBalanceStrategy.roundRobin,
);
});
testWidgets('writes the timeout the core reads in milliseconds', (
tester,
) async {
container = buildContainer(_group());
await pumpGroupSheet(tester);
await _addOption(tester, currentAppLocalizations.timeout);
expect(container.read(proxyGroupProvider).timeout, 5000);
await tester.ensureVisible(find.text(currentAppLocalizations.timeout));
await tester.pump();
await tester.enterText(
find.descendant(
of: _rowOf(currentAppLocalizations.timeout),
matching: find.byType(TextFormField),
),
'2500',
);
await tester.pump();
expect(container.read(proxyGroupProvider).timeout, 2500);
expect(find.text('ms'), findsWidgets);
});
testWidgets('a filter the core rejects is shown and blocks the save', (
tester,
) async {
container = buildContainer(
_group(proxies: ['DIRECT']).copyWith(filter: 'hk`(jp'),
);
final message = currentAppLocalizations.customIssueInvalidFilter(
currentAppLocalizations.filter,
'missing closing )',
);
await pumpEditView(tester, const EditProxyGroupView());
expect(_bannerText(message), findsNothing);
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(_bannerText(message), findsOneWidget);
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
await _dismissSaveBlocked(tester, message);
expect(find.byType(EditProxyGroupView), findsOneWidget);
expect(_bannerText(message), findsOneWidget);
});
testWidgets('an exclude filter the core rejects is named in the banner', (
tester,
) async {
final l = currentAppLocalizations;
container = buildContainer(
_group(proxies: ['DIRECT']).copyWith(excludeFilter: 'hk`(jp'),
);
final message = l.customIssueInvalidFilter(
l.excludeFilter,
'missing closing )',
);
await pumpEditView(tester, const EditProxyGroupView());
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(_bannerText(message), findsOneWidget);
});
});
}
+429
View File
@@ -0,0 +1,429 @@
import 'dart:io';
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/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/custom/providers.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 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
const _profileId = 1;
class _FakePathProvider extends PathProviderPlatform {
final String root;
_FakePathProvider(this.root);
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
class _TestProxyGroups extends ProxyGroups {
_TestProxyGroups(this.groups);
final List<ProxyGroup> groups;
@override
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(groups);
}
class _TestProfileRules extends ProfileRules {
_TestProfileRules(this.rules);
final List<Rule> rules;
@override
Stream<List<Rule>> build(int profileId) => Stream.value(rules);
}
class _TestClashProviders extends ClashProviders {
@override
Stream<List<ClashProvider>> build() => Stream.value(const [
ClashProvider(
id: 1,
label: 'cn',
url: 'https://example.com/cn.yaml',
behavior: RuleProviderBehavior.domain,
),
]);
}
class _TestCustomProxies extends CustomProxies {
@override
Stream<List<CustomProxy>> build() => Stream.value(const []);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
setUpAll(() {
PathProviderPlatform.instance = _FakePathProvider(
Directory.systemTemp.createTempSync('custom_providers').path,
);
});
Future<void> pumpView(
WidgetTester tester, {
List<ProxyGroup> groups = const [],
List<Rule> rules = const [],
ProfileOverrides overrides = const ProfileOverrides(),
bool loaded = true,
}) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([
Profile(
id: _profileId,
label: 'mine',
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
overrides: overrides,
),
Profile.normal(
label: 'sub',
url: 'https://example.com/sub',
).copyWith(id: 2, lastUpdateDate: DateTime.now()),
]),
),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(groups)),
profileRulesProvider.overrideWith2((_) => _TestProfileRules(rules)),
clashProvidersProvider.overrideWith(_TestClashProviders.new),
customProxiesProvider.overrideWith(_TestCustomProxies.new),
customProfileDataProvider(_profileId).overrideWithValue(
loaded
? const CustomProfileData(
proxyProviders: {'sub'},
ruleProviders: {'cn'},
)
: null,
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1200, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: CustomProvidersView(_profileId)),
),
);
await tester.pump();
await tester.pump();
}
ProfileOverrides overrides() =>
container.read(profilesProvider).getProfile(_profileId)!.overrides;
testWidgets('lists the providers its groups and rules name', (tester) async {
await pumpView(
tester,
groups: const [
ProxyGroup(
id: 1,
name: 'g',
type: GroupType.Selector,
use: ['sub', 'gone'],
),
],
rules: [
Rule.parse('RULE-SET,cn,DIRECT', id: 2),
Rule.parse('RULE-SET,missing,DIRECT', id: 3),
],
);
final l = currentAppLocalizations;
for (final name in ['sub', 'gone', 'cn', 'missing']) {
expect(find.text(name), findsOneWidget, reason: name);
}
expect(find.text(l.proxies), findsOneWidget);
expect(find.text(l.rules), findsOneWidget);
expect(find.byType(LastUpdateTimeText), findsOneWidget);
expect(find.text('https://example.com/sub'), findsNothing);
expect(find.byType(InfoMessageButton), findsNWidgets(2));
});
testWidgets('marks no provider missing until the profile data loads', (
tester,
) async {
await pumpView(
tester,
groups: const [
ProxyGroup(id: 1, name: 'g', type: GroupType.Selector, use: ['gone']),
],
rules: [Rule.parse('RULE-SET,missing,DIRECT', id: 2)],
loaded: false,
);
expect(find.text('gone'), findsOneWidget);
expect(find.text('missing'), findsOneWidget);
expect(find.byType(InfoMessageButton), findsNothing);
});
testWidgets('a provider sets only the options added in its sheet', (
tester,
) async {
await pumpView(
tester,
groups: const [
ProxyGroup(id: 1, name: 'g', type: GroupType.Selector, use: ['sub']),
],
);
final l = currentAppLocalizations;
await tester.tap(find.byTooltip(l.edit));
await tester.pumpAndSettle();
expect(find.byType(TextFormField), findsNothing);
expect(find.byTooltip(l.quickEdit), findsNothing);
await tester.tap(find.text(l.addSettingEntry));
await tester.pumpAndSettle();
await tester.tap(find.text(l.testInterval));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), '600');
await tester.tap(find.byTooltip(l.save));
await tester.pumpAndSettle();
expect(
overrides().proxyProviders['sub'],
const ProxyProviderOptions(
healthCheck: ProviderHealthCheck(interval: 600),
),
);
});
testWidgets('an override starts at the value it is added to set', (
tester,
) async {
await pumpView(
tester,
groups: const [
ProxyGroup(id: 1, name: 'g', type: GroupType.Selector, use: ['sub']),
],
);
final l = currentAppLocalizations;
await tester.tap(find.byTooltip(l.edit));
await tester.pumpAndSettle();
await tester.tap(find.text(l.addSettingEntry));
await tester.pumpAndSettle();
await tester.tap(find.text('UDP'));
await tester.pumpAndSettle();
await tester.tap(find.text(l.ipVersion));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(l.save));
await tester.pumpAndSettle();
expect(
overrides().proxyProviders['sub'],
const ProxyProviderOptions(
proxyOverride: ProviderOverride(udp: true, ipVersion: IpVersion.dual),
),
);
});
testWidgets('text options left empty stay after reopening', (tester) async {
await pumpView(
tester,
groups: const [
ProxyGroup(id: 1, name: 'g', type: GroupType.Selector, use: ['sub']),
],
);
final l = currentAppLocalizations;
final titles = [l.expectedStatus, l.additionalPrefix, l.additionalSuffix];
await tester.tap(find.byTooltip(l.edit));
await tester.pumpAndSettle();
await tester.tap(find.text(l.addSettingEntry));
await tester.pumpAndSettle();
for (final title in titles) {
await tester.tap(find.text(title));
await tester.pumpAndSettle();
}
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(l.save));
await tester.pumpAndSettle();
expect(
overrides().proxyProviders['sub'],
const ProxyProviderOptions(
healthCheck: ProviderHealthCheck(expectedStatus: ''),
proxyOverride: ProviderOverride(
additionalPrefix: '',
additionalSuffix: '',
),
),
);
await tester.tap(find.byTooltip(l.edit));
await tester.pumpAndSettle();
for (final title in titles) {
expect(
find.widgetWithText(FormRow, title),
findsOneWidget,
reason: title,
);
}
});
testWidgets('options emptied after holding a value are dropped', (
tester,
) async {
await pumpView(
tester,
groups: const [
ProxyGroup(id: 1, name: 'g', type: GroupType.Selector, use: ['sub']),
],
overrides: const ProfileOverrides(
proxyProviders: {
'sub': ProxyProviderOptions(
healthCheck: ProviderHealthCheck(
url: 'https://cp.example',
interval: 300,
expectedStatus: '',
),
),
},
),
);
final l = currentAppLocalizations;
Finder fieldIn(String title) => find.descendant(
of: find.widgetWithText(FormRow, title),
matching: find.byType(TextFormField),
);
await tester.tap(find.byTooltip(l.edit));
await tester.pumpAndSettle();
await tester.enterText(fieldIn(l.testUrl), '');
await tester.enterText(fieldIn(l.testInterval), '');
await tester.tap(find.byTooltip(l.save));
await tester.pumpAndSettle();
expect(
overrides().proxyProviders['sub'],
const ProxyProviderOptions(
healthCheck: ProviderHealthCheck(expectedStatus: ''),
),
);
});
testWidgets('filters are picked from the presets as member rows', (
tester,
) async {
final hongKong = defaultFilters.first;
await pumpView(
tester,
groups: const [
ProxyGroup(id: 1, name: 'g', type: GroupType.Selector, use: ['sub']),
],
overrides: const ProfileOverrides(
proxyProviders: {'sub': ProxyProviderOptions(excludeFilter: 'x')},
),
);
final l = currentAppLocalizations;
final picker = find.byWidgetPredicate(
(widget) =>
widget.runtimeType.toString() ==
'_MemberPicker<ProxyProviderOptions>',
);
await tester.tap(find.byTooltip(l.edit));
await tester.pumpAndSettle();
expect(find.text('x'), findsOneWidget);
await tester.tap(find.text(l.addFilters));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: picker,
matching: find.textContaining('${l.filters} ('),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(of: picker, matching: find.text(hongKong.label)),
);
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text(hongKong.label), findsOneWidget);
final excluded = find.ancestor(
of: find.text('x'),
matching: find.byType(DecorationListItem),
);
await tester.tap(
find.descendant(of: excluded, matching: find.byTooltip(l.remove)),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(l.save));
await tester.pumpAndSettle();
expect(
overrides().proxyProviders['sub'],
ProxyProviderOptions(filter: hongKong.regex),
);
});
testWidgets('tapping a provider opens the same sheet', (tester) async {
await pumpView(
tester,
groups: const [
ProxyGroup(id: 1, name: 'g', type: GroupType.Selector, use: ['sub']),
],
);
final l = currentAppLocalizations;
await tester.tap(find.text('sub'));
await tester.pumpAndSettle();
expect(find.text(l.addSettingEntry), findsOneWidget);
});
testWidgets('a rule set is listed without opening its options', (
tester,
) async {
await pumpView(tester, rules: [Rule.parse('RULE-SET,cn,DIRECT', id: 2)]);
await tester.tap(find.text('cn'));
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsNothing);
expect(find.byType(TextFormField), findsNothing);
});
testWidgets('an empty page says it has no providers', (tester) async {
await pumpView(tester);
final l = currentAppLocalizations;
expect(find.text(l.nullTip(l.providers)), findsOneWidget);
});
}
-118
View File
@@ -1,118 +0,0 @@
import 'package:drift/native.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/icon.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';
const _recordUrl = 'https://example.com/icon.png';
ProviderContainer _containerFor(WidgetTester tester) {
const size = Size(1400, 1000);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer();
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).update((_) => size);
return container;
}
class _PopResult {
String? value;
}
Future<_PopResult> _pumpIconEditView(
WidgetTester tester,
ProviderContainer container, {
String? value,
}) async {
final result = _PopResult();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Builder(
builder: (context) => TextButton(
onPressed: () async {
result.value = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => IconEditView(value)),
);
},
child: const Text('open'),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return result;
}
void main() {
late Database testDatabase;
setUp(() {
testDatabase = Database(NativeDatabase.memory());
// The view reads the process-wide handle; swap in an in-memory executor.
database = testDatabase;
});
tearDown(() async {
await testDatabase.close();
});
testWidgets('shows the empty state when no icon record matches', (
tester,
) async {
final container = _containerFor(tester);
await _pumpIconEditView(tester, container);
expect(find.byType(NullStatus), findsOne);
expect(tester.takeException(), null);
});
testWidgets('lists stored icon records for the current input', (
tester,
) async {
await testDatabase.iconRecordsDao.put(_recordUrl);
final container = _containerFor(tester);
await _pumpIconEditView(tester, container, value: 'example.com');
expect(find.text(_recordUrl), findsOne);
expect(find.byType(NullStatus), findsNothing);
expect(tester.takeException(), null);
});
testWidgets('picking a record closes the view and returns its url', (
tester,
) async {
await testDatabase.iconRecordsDao.put(_recordUrl);
final container = _containerFor(tester);
final result = await _pumpIconEditView(
tester,
container,
value: 'example.com',
);
expect(result.value, isNull, reason: 'still open');
await tester.tap(find.text(_recordUrl));
await tester.pumpAndSettle();
expect(find.byType(IconEditView), findsNothing);
expect(result.value, _recordUrl);
expect(tester.takeException(), null);
});
}
+391
View File
@@ -0,0 +1,391 @@
import 'package:drift/native.dart';
import 'package:fl_clash/database/database.dart' hide IconSets;
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/custom/icon.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/glyph_finders.dart';
import '../helpers/test_app.dart';
class _TestIconSets extends IconSets {
_TestIconSets(this.initial);
final List<IconSet> initial;
@override
Stream<List<IconSet>> build() => Stream.value(initial);
}
const _hongKong = IconSetIcon(
name: 'Hong_Kong.png',
url: 'https://example.com/hk.png',
);
const _japan = IconSetIcon(
name: 'Japan.png',
url: 'https://example.com/jp.png',
);
const _youTube = IconSetIcon(
name: 'YouTube.png',
url: 'https://example.com/yt.png',
);
const _netflix = IconSetIcon(
name: 'Netflix.png',
url: 'https://example.com/nf.png',
);
const _regions = IconSet(id: 1, name: 'Regions', icons: [_hongKong, _japan]);
const _services = IconSet(id: 2, name: 'Services', icons: [_youTube, _netflix]);
class _PopResult {
String? value;
bool popped = false;
}
Future<_PopResult> _pumpPicker(
WidgetTester tester, {
List<IconSet> iconSets = const [],
String? value,
String groupName = '',
}) async {
const size = Size(1400, 1000);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [iconSetsProvider.overrideWith(() => _TestIconSets(iconSets))],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).update((_) => size);
final result = _PopResult();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Builder(
builder: (context) => TextButton(
onPressed: () async {
result.value = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) =>
IconPickerView(value: value, groupName: groupName),
),
);
result.popped = true;
},
child: const Text('open'),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return result;
}
void main() {
late Database testDatabase;
setUp(() {
testDatabase = Database(NativeDatabase.memory());
database = testDatabase;
});
tearDown(() async {
await testDatabase.close();
});
testWidgets('without icon sets or records it says where to add them', (
tester,
) async {
await _pumpPicker(tester);
expect(
find.text(
'No icon sets yet. Add them in Advanced configuration → Icon sets',
),
findsOne,
);
expect(find.byTooltip('Icon URL'), findsOne);
expect(tester.takeException(), null);
});
testWidgets('suggests icons for the group name and returns the one tapped', (
tester,
) async {
final result = await _pumpPicker(
tester,
iconSets: [_regions, _services],
groupName: 'YouTube Premium',
);
expect(find.text('Suggested'), findsOne);
expect(find.text('YouTube'), findsOne);
expect(find.text('Japan'), findsNothing);
await tester.tap(find.text('YouTube'));
await tester.pumpAndSettle();
expect(result.value, _youTube.url);
});
testWidgets('switching the source shows the icons of that set', (
tester,
) async {
await _pumpPicker(tester, iconSets: [_regions, _services]);
expect(find.text('Suggested'), findsNothing);
expect(find.text('Hong Kong'), findsOne);
await tester.tap(find.text('Services'));
await tester.pumpAndSettle();
expect(find.text('Hong Kong'), findsNothing);
expect(find.text('Netflix'), findsOne);
});
testWidgets('opens on the set holding the current icon, which can be '
'removed', (tester) async {
final result = await _pumpPicker(
tester,
iconSets: [_regions, _services],
value: _japan.url,
groupName: 'YouTube',
);
expect(find.text('Hong Kong'), findsOne);
expect(find.text('Japan'), findsNWidgets(2));
await tester.tap(find.byTooltip('Remove'));
await tester.pumpAndSettle();
expect(result.value, '');
});
testWidgets('every icon sits on the same plate, only the chosen one is '
'outlined, and names stay unframed below', (tester) async {
await _pumpPicker(tester, iconSets: [_regions], value: _japan.url);
Finder plateOf(String label) => find.descendant(
of: find.byTooltip(label).last,
matching: find.byType(Material),
);
BorderSide sideOf(String label) =>
(tester.widget<Material>(plateOf(label)).shape! as OutlinedBorder).side;
Color? colorOf(String label) =>
tester.widget<Material>(plateOf(label)).color;
expect(colorOf('Hong Kong'), isNot(Colors.transparent));
expect(colorOf('Hong Kong'), colorOf('Japan'));
expect(sideOf('Japan').style, BorderStyle.solid);
expect(sideOf('Hong Kong'), BorderSide.none);
expect(
tester.getTopLeft(find.text('Hong Kong')).dy,
greaterThanOrEqualTo(tester.getBottomLeft(plateOf('Hong Kong')).dy),
);
});
testWidgets('leaving without a choice keeps the icon', (tester) async {
final result = await _pumpPicker(
tester,
iconSets: [_regions],
value: _japan.url,
);
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
expect(result.popped, isTrue);
expect(result.value, isNull);
});
testWidgets('a search looks through every set by icon name', (tester) async {
await _pumpPicker(tester, iconSets: [_regions, _services]);
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'net');
await tester.pumpAndSettle();
expect(find.text('Netflix'), findsOne);
expect(find.text('Services'), findsOne);
expect(find.text('Hong Kong'), findsNothing);
expect(find.text('Regions'), findsNothing);
await tester.enterText(find.byType(TextField), 'nothing like it');
await tester.pumpAndSettle();
expect(find.byType(NullStatus), findsOne);
});
testWidgets('a search shows an icon once, under the first source with it', (
tester,
) async {
await _pumpPicker(
tester,
iconSets: [_regions, _services],
groupName: 'YouTube',
);
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'you');
await tester.pumpAndSettle();
expect(find.text('YouTube'), findsOne);
expect(find.text('Suggested'), findsOne);
expect(find.text('Services'), findsNothing);
});
testWidgets('suggested leads with the matches and then the recent icons', (
tester,
) async {
await testDatabase.iconRecordsDao.put('https://example.com/Proxy.png');
await _pumpPicker(
tester,
iconSets: [_regions, _services],
groupName: 'YouTube',
);
expect(find.text('Suggested'), findsOne);
expect(find.text('Recent'), findsNothing);
final youTube = tester.getTopLeft(find.text('YouTube'));
final proxy = tester.getTopLeft(find.text('Proxy'));
expect(
youTube.dy < proxy.dy || youTube.dy == proxy.dy && youTube.dx < proxy.dx,
isTrue,
);
await tester.longPress(find.text('YouTube'));
await tester.pumpAndSettle();
expect(find.text('Confirm'), findsNothing);
});
testWidgets('a large set is matched off the UI isolate without an empty '
'state first', (tester) async {
final many = IconSet(
id: 3,
name: 'Many',
icons: [
for (var index = 0; index < 1000; index++)
IconSetIcon(
name: 'Icon_$index.png',
url: 'https://example.com/$index.png',
),
_youTube,
],
);
await _pumpPicker(tester, iconSets: [many], groupName: 'YouTube');
expect(find.text('Suggested'), findsNothing);
expect(find.byType(NullStatus), findsNothing);
for (var attempt = 0; attempt < 40; attempt++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pumpAndSettle();
if (find.text('Suggested').evaluate().isNotEmpty) {
break;
}
}
expect(find.text('Suggested'), findsOne);
expect(find.text('YouTube'), findsOne);
expect(find.text('Icon 0'), findsNothing);
});
testWidgets('a recent icon can be forgotten with a long press', (
tester,
) async {
await testDatabase.iconRecordsDao.put('https://example.com/Proxy.png');
await _pumpPicker(tester);
expect(find.text('Suggested'), findsOne);
await tester.longPress(find.text('Proxy'));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Proxy'), findsNothing);
expect(await testDatabase.iconRecordsDao.query(''), isEmpty);
});
testWidgets('a link is accepted only once it is a url', (tester) async {
final result = await _pumpPicker(tester, iconSets: [_regions]);
await tester.tap(find.byTooltip('Icon URL'));
await tester.pumpAndSettle();
final field = find.descendant(
of: find.byType(InputDialog),
matching: find.byType(TextFormField),
);
await tester.enterText(field, 'not a link');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(find.byType(InputDialog), findsOne);
expect(result.popped, isFalse);
await tester.enterText(field, ' https://example.com/custom.png ');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(result.value, 'https://example.com/custom.png');
});
testWidgets('the grid builds the rows past the viewport only once the page '
'has arrived', (tester) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var lastBuilt = -1;
await tester.pumpWidget(
TestApp(
child: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => IconGridScrollView(
slivers: [
SliverFixedExtentList.builder(
itemExtent: 100,
itemCount: 50,
itemBuilder: (_, index) {
lastBuilt = index > lastBuilt ? index : lastBuilt;
return Text('row $index');
},
),
],
),
),
),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(lastBuilt, lessThan(7));
await tester.pumpAndSettle();
expect(lastBuilt, greaterThan(7));
});
}
-318
View File
@@ -1,318 +0,0 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/name_add_picker.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/search_field.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
ProxyGroup _group({List<String>? proxies}) {
return ProxyGroup(
id: 100,
profileId: 1,
name: 'Group',
type: GroupType.Selector,
proxies: proxies,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
setUpAll(() async {
await AppLocalizations.load(const Locale('en'));
});
ProviderContainer buildContainer(ProxyGroup group) {
final built = ProviderContainer(
overrides: [proxyGroupProvider.overrideWithBuild((_, _) => group)],
);
addTearDown(built.dispose);
// `proxyGroupProvider` is auto-dispose and nothing in the picker watches it,
// so without a listener each `read` below would rebuild it from the override
// and drop what the staging flow wrote.
built.listen(proxyGroupProvider, (_, _) {});
globalState.container = built;
built.read(viewSizeProvider.notifier).update((_) => const Size(1400, 1000));
return built;
}
Future<void> pumpPicker(
WidgetTester tester, {
required List<NameAddSection> sections,
List<String?> scenes = const [null],
}) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: SheetProvider(
type: SheetType.bottomSheet,
child: NameAddPicker(
title: 'Add',
stageTagPrefix: 'NameAddPickerTest',
scenes: scenes,
apply: (state, staged) =>
state.copyWith(proxies: [...state.proxies ?? [], ...staged]),
sectionsBuilder: (_, _) => sections,
),
),
),
),
);
await tester.pump();
}
/// The picker keys its staging sets off a `UniqueKeyStateMixin` key that only
/// exists once the state is built. Both `itemsProvider` and `proxyGroupProvider`
/// are auto-dispose, so the container has to hold them or a `read` after the
/// flow runs would rebuild them and lose the write.
String holdStageKey(
WidgetTester tester, {
List<String?> scenes = const [null],
}) {
final key =
(tester.state(find.byType(NameAddPicker)) as dynamic).key as String;
for (final scene in scenes) {
container.listen(
itemsProvider(scene == null ? key : '${key}_$scene'),
(_, _) {},
);
}
return key;
}
/// Clearing the stage re-triggers the listener, which arms one more debounce.
/// It has to be drained or the binding reports a pending timer.
Future<void> drainStaging(WidgetTester tester) async {
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpWidget(const SizedBox.shrink());
}
testWidgets('renders each section label with its entries', (tester) async {
container = buildContainer(_group());
await pumpPicker(
tester,
scenes: const ['a', 'b'],
sections: const [
NameAddSection(
label: 'Section A',
scene: 'a',
entries: [NameAddEntry(title: 'alpha', subtitle: 'first')],
),
NameAddSection(
label: 'Section B',
scene: 'b',
entries: [NameAddEntry(title: 'beta')],
),
],
);
expect(find.text('Section A'), findsOneWidget);
expect(find.text('Section B'), findsOneWidget);
expect(find.text('alpha'), findsOneWidget);
expect(find.text('first'), findsOneWidget);
expect(find.text('beta'), findsOneWidget);
});
testWidgets('drops sections that have no entries', (tester) async {
container = buildContainer(_group());
await pumpPicker(
tester,
scenes: const ['a', 'b'],
sections: const [
NameAddSection(label: 'Empty', scene: 'a', entries: []),
NameAddSection(
label: 'Filled',
scene: 'b',
entries: [NameAddEntry(title: 'beta')],
),
],
);
expect(find.text('Empty'), findsNothing);
expect(find.text('Filled'), findsOneWidget);
});
testWidgets('shows the no-data label only when every section is empty', (
tester,
) async {
container = buildContainer(_group());
await pumpPicker(
tester,
scenes: const ['a'],
sections: const [NameAddSection(label: 'Empty', scene: 'a', entries: [])],
);
expect(find.text(currentAppLocalizations.noData), findsOneWidget);
});
testWidgets(
'a section still holding entries keeps the list even when its siblings '
'are empty',
(tester) async {
container = buildContainer(_group());
await pumpPicker(
tester,
scenes: const ['targets', 'proxies'],
sections: const [
NameAddSection(
label: 'Targets',
scene: 'targets',
entries: [NameAddEntry(title: 'DIRECT')],
),
NameAddSection(label: 'Proxies', scene: 'proxies', entries: []),
],
);
expect(find.text(currentAppLocalizations.noData), findsNothing);
expect(find.text('DIRECT'), findsOneWidget);
},
);
testWidgets('adding an entry stages it onto the proxy group', (tester) async {
container = buildContainer(_group(proxies: ['existing']));
await pumpPicker(
tester,
sections: const [
NameAddSection(
label: 'Section',
entries: [NameAddEntry(title: 'alpha')],
),
],
);
holdStageKey(tester);
await tester.tap(find.byGlyph(AppGlyphs.add));
await tester.pump();
// The staging flow debounces before it writes through.
await tester.pump(const Duration(milliseconds: 400));
expect(container.read(proxyGroupProvider).proxies, ['existing', 'alpha']);
await drainStaging(tester);
});
testWidgets('each scene stages independently', (tester) async {
container = buildContainer(_group());
await pumpPicker(
tester,
scenes: const ['a', 'b'],
sections: const [
NameAddSection(
label: 'A',
scene: 'a',
entries: [NameAddEntry(title: 'alpha')],
),
NameAddSection(
label: 'B',
scene: 'b',
entries: [NameAddEntry(title: 'beta')],
),
],
);
holdStageKey(tester, scenes: const ['a', 'b']);
await tester.tap(find.byGlyph(AppGlyphs.add).first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(container.read(proxyGroupProvider).proxies, ['alpha']);
await tester.tap(find.byGlyph(AppGlyphs.add).last);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(container.read(proxyGroupProvider).proxies, ['alpha', 'beta']);
await drainStaging(tester);
});
group('search', () {
List<NameAddSection> proxySections(int count) => [
const NameAddSection(
label: 'Targets',
scene: 'targets',
entries: [NameAddEntry(title: 'DIRECT', subtitle: 'direct')],
),
NameAddSection(
label: 'Proxies',
scene: 'proxies',
entries: [
for (var i = 1; i < count; i++)
NameAddEntry(
title: 'Node $i',
subtitle: i.isEven ? 'Vmess' : 'Trojan',
),
],
),
];
testWidgets('offers no search field for a short list', (tester) async {
container = buildContainer(_group());
await pumpPicker(
tester,
scenes: const ['targets', 'proxies'],
sections: proxySections(sheetSearchMinItemCount - 1),
);
expect(find.byType(SearchField), findsNothing);
});
testWidgets('narrows every section by title and subtitle', (tester) async {
container = buildContainer(_group());
await pumpPicker(
tester,
scenes: const ['targets', 'proxies'],
sections: proxySections(sheetSearchMinItemCount),
);
expect(find.byType(SearchField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'trojan');
await tester.pump();
expect(find.text('Targets'), findsNothing);
expect(find.text('Node 1'), findsOneWidget);
expect(find.text('Node 2'), findsNothing);
await tester.enterText(find.byType(TextField), 'node 2');
await tester.pump();
expect(find.text('Node 2'), findsOneWidget);
expect(find.text('Node 1'), findsNothing);
await tester.enterText(find.byType(TextField), 'missing');
await tester.pumpAndSettle();
expect(
find.text(currentAppLocalizations.noSearchResults),
findsOneWidget,
);
expect(find.byType(SearchField), findsOneWidget);
});
});
}
+42 -21
View File
@@ -7,6 +7,7 @@ import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/edit.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -36,19 +37,11 @@ class _NoopSetupAction extends SetupAction {
void autoApplyProfile() {}
}
class _RenameConflictProfilesAction extends ProfilesAction {
class _RecordingProfilesAction extends ProfilesAction {
final put = <Profile>[];
@override
Future<({List<Profile> renameIn, List<Profile> conflicts})> providerRename(
Profile previous,
Profile next,
) async =>
(renameIn: const <Profile>[], conflicts: [Profile.normal(label: 'Work')]);
@override
void putProfile(Profile profile, {Iterable<int> renameIn = const []}) =>
put.add(profile);
void putProfile(Profile profile) => put.add(profile);
}
Profile _urlProfile() =>
@@ -148,9 +141,11 @@ bool _isTextFieldFocused() {
void main() {
late Directory tempDir;
setUpAll(() {
setUpAll(() async {
tempDir = Directory.systemTemp.createTempSync('edit_profile_focus_test');
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
// A path resolved inside one test's fake zone never wakes a later test.
await appPath.dataDir.future;
});
tearDownAll(() {
@@ -177,26 +172,52 @@ void main() {
expect(find.byTooltip('Save'), findsOneWidget);
});
testWidgets('a name a user subscription already has is not saved', (
tester,
) async {
final action = _RenameConflictProfilesAction();
testWidgets('a custom profile edits only its name', (tester) async {
final action = _RecordingProfilesAction();
final profile = Profile.custom(label: 'Mine');
await pumpEditProfile(
tester,
profile: profile,
overrides: [profilesActionProvider.overrideWith(() => action)],
);
expect(find.byType(TextFormField), findsOneWidget);
expect(find.text(currentAppLocalizations.autoUpdate), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, currentAppLocalizations.name),
'Taken',
'Renamed',
);
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(
find.textContaining('subscriptions of Work already have Taken'),
findsOneWidget,
);
expect(action.put, isEmpty);
expect(action.put.single, profile.copyWith(label: 'Renamed'));
});
testWidgets(
'the profile file row shows its size and update time without chips',
(tester) async {
await pumpEditProfile(tester);
final row = find.widgetWithText(
DecorationListItem,
currentAppLocalizations.profile,
);
for (var i = 0; i < 20 && row.evaluate().isEmpty; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
await tester.pump(const Duration(milliseconds: 400));
}
expect(row, findsOneWidget);
expect(
find.descendant(of: row, matching: find.byType(LastUpdateTimeText)),
findsOneWidget,
);
expect(
find.descendant(of: row, matching: find.text(0.traffic.show)),
findsOneWidget,
);
expect(find.byType(MetaChip), findsNothing);
},
);
}
+30
View File
@@ -363,6 +363,36 @@ void main() {
expect(result, 'default');
});
testWidgets('SecretTextFormField hides the text until revealed', (
tester,
) async {
final controller = TextEditingController(text: 'AGE-SECRET-KEY-1A');
addTearDown(controller.dispose);
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Material(
child: SecretTextFormField(controller: controller, maxLines: 5),
),
),
);
EditableText editable() => tester.widget(find.byType(EditableText));
expect(editable().obscureText, isTrue);
expect(editable().maxLines, 1);
await tester.tap(find.byTooltip('Show password'));
await tester.pump();
expect(editable().obscureText, isFalse);
expect(editable().maxLines, 5);
await tester.tap(find.byTooltip('Hide password'));
await tester.pump();
expect(editable().obscureText, isTrue);
expect(controller.text, 'AGE-SECRET-KEY-1A');
});
testWidgets(
'NamedUrlDialog puts the optional name first and focuses the url',
(tester) async {
+185 -34
View File
@@ -1,6 +1,5 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -9,11 +8,12 @@ import 'package:fl_clash/views/profiles/profiles.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/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/context_menu.dart';
import '../helpers/test_profiles.dart';
Profile urlProfile(String label) =>
@@ -42,6 +42,8 @@ class _RecordingProfilesAction extends ProfilesAction {
Future<ProviderContainer> pumpProfiles(
WidgetTester tester, {
required List<Profile> profiles,
int? currentProfileId,
ValueListenable<bool>? isActive,
List<Override> overrides = const [],
}) async {
tester.view.physicalSize = const Size(900, 800);
@@ -52,7 +54,9 @@ Future<ProviderContainer> pumpProfiles(
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles(profiles)),
currentProfileIdProvider.overrideWithBuild((_, _) => profiles.first.id),
currentProfileIdProvider.overrideWithBuild(
(_, _) => currentProfileId ?? profiles.first.id,
),
...overrides,
],
);
@@ -75,7 +79,14 @@ Future<ProviderContainer> pumpProfiles(
globalState.theme = CommonTheme.of(context, 1);
return child!;
},
home: const ProfilesView(),
home: isActive == null
? const ProfilesView()
: ValueListenableBuilder(
valueListenable: isActive,
builder: (_, isActive, child) =>
PageActivityScope(isActive: isActive, child: child!),
child: const ProfilesView(),
),
),
),
);
@@ -84,9 +95,8 @@ Future<ProviderContainer> pumpProfiles(
}
void main() {
testWidgets('arrow right from a profile card focuses its more button', (
tester,
) async {
testWidgets('a focused profile card opens its menu and reaches its '
'subscription info', (tester) async {
final profiles = [
urlProfile('url 1'),
Profile.normal(label: 'file 1'),
@@ -119,11 +129,32 @@ void main() {
for (var profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
await focusCard(profileIndex);
await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(CommonPopupMenu),
matching: find.text(currentAppLocalizations.delete),
),
findsOneWidget,
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(CommonPopupMenu), findsNothing);
if (profiles[profileIndex].subscriptionInfo == null) {
continue;
}
await focusCard(profileIndex);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final context = FocusManager.instance.primaryFocus?.context;
expect(context?.findAncestorWidgetOfExactType<IconButton>(), isNotNull);
expect(
context?.findAncestorWidgetOfExactType<IconButton>()?.tooltip,
currentAppLocalizations.subscriptionInfo,
);
expect(
context?.findAncestorWidgetOfExactType<ListItem>()?.key,
Key(profiles[profileIndex].id.toString()),
@@ -131,21 +162,39 @@ void main() {
}
});
testWidgets('subscription menu item opens the usage dialog', (tester) async {
testWidgets('a card shows its traffic under the title and its update time '
'at the end, without an edit button', (tester) async {
final file = Profile.normal(label: 'file');
final url = urlProfile('url').copyWith(
lastUpdateDate: DateTime.now().subtract(const Duration(minutes: 5)),
);
final container = await pumpProfiles(tester, profiles: [url, file]);
expect(find.byTooltip(currentAppLocalizations.edit), findsNothing);
expect(find.text('3KB / 4KB'), findsOneWidget);
expect(find.text(currentAppLocalizations.minutesAgo(5)), findsOneWidget);
container.read(updatingKeysProvider.notifier).start(url.updatingKey);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text(currentAppLocalizations.minutesAgo(5)), findsNothing);
expect(
find.descendant(
of: find.ancestor(
of: find.text('url'),
matching: find.byType(ProfileItem),
),
matching: find.byType(CommonCircleLoading),
),
findsOneWidget,
);
});
testWidgets('the usage icon opens the subscription dialog', (tester) async {
await pumpProfiles(tester, profiles: [urlProfile('url')]);
final profileItem = find.ancestor(
of: find.text('url'),
matching: find.byType(ListItem),
);
await tester.tap(
find.descendant(of: profileItem, matching: find.byGlyph(AppGlyphs.more)),
);
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.more).last);
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.subscriptionInfo));
await tester.tap(find.byTooltip(currentAppLocalizations.subscriptionInfo));
await tester.pumpAndSettle();
expect(find.byType(CommonDialog), findsOneWidget);
@@ -153,33 +202,135 @@ void main() {
expect(find.text(currentAppLocalizations.subscriptionInfo), findsOneWidget);
});
testWidgets('a profile another one uses as a provider is not deleted', (
Future<void> openProfileMenu(WidgetTester tester, String label) async {
await rightClick(tester, find.text(label));
await tester.pumpAndSettle();
}
testWidgets('an external profile lists extend and keeps its final config '
'under more', (tester) async {
final l = currentAppLocalizations;
await pumpProfiles(tester, profiles: [urlProfile('url')]);
await openProfileMenu(tester, 'url');
expect(find.text(l.extend), findsOneWidget);
expect(find.text(l.finalConfig), findsNothing);
expect(find.text(l.rename), findsNothing);
await tester.tap(find.text(l.more).last);
await tester.pumpAndSettle();
expect(find.text(l.finalConfig), findsOneWidget);
expect(find.text(l.subscriptionInfo), findsNothing);
});
testWidgets('a custom profile lists its final config and no extend', (
tester,
) async {
final l = currentAppLocalizations;
await pumpProfiles(tester, profiles: [Profile.custom(label: 'mine')]);
await openProfileMenu(tester, 'mine');
expect(find.text(l.finalConfig), findsOneWidget);
expect(find.text(l.extend), findsNothing);
expect(find.text(l.more), findsNothing);
});
testWidgets('a custom profile renames from its menu', (tester) async {
final l = currentAppLocalizations;
await pumpProfiles(tester, profiles: [Profile.custom(label: 'mine')]);
await openProfileMenu(tester, 'mine');
await tester.tap(find.text(l.rename));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'ours');
await tester.tap(find.text(l.submit));
await tester.pumpAndSettle();
expect(find.text('ours'), findsOneWidget);
expect(find.text('mine'), findsNothing);
});
testWidgets('a profile another one uses as a provider is deleted once '
'confirmed', (tester) async {
final action = _RecordingProfilesAction([Profile.normal(label: 'Work')]);
final home = urlProfile('Home');
await pumpProfiles(
tester,
profiles: [urlProfile('Home')],
profiles: [home],
overrides: [profilesActionProvider.overrideWith(() => action)],
);
final profileItem = find.ancestor(
of: find.text('Home'),
matching: find.byType(ListItem),
);
await tester.tap(
find.descendant(of: profileItem, matching: find.byGlyph(AppGlyphs.more)),
);
await tester.pumpAndSettle();
await openProfileMenu(tester, 'Home');
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(find.textContaining('Home is still used'), findsOneWidget);
expect(find.textContaining('of Work'), findsOneWidget);
expect(find.text(currentAppLocalizations.cancel), findsNothing);
expect(find.textContaining('used by Work'), findsOneWidget);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
expect(action.deleted, isEmpty);
expect(action.deleted, [home.id]);
});
group('the selected profile is revealed', () {
final profiles = [
for (var index = 0; index < 40; index++) urlProfile('profile $index'),
];
final selected = profiles[36];
Rect selectedRect(WidgetTester tester) => tester.getRect(
find.ancestor(
of: find.text(selected.label),
matching: find.byType(ProfileItem),
),
);
void expectRevealed(WidgetTester tester) {
final viewport = tester.getRect(find.byKey(profilesStoreKey));
final rect = selectedRect(tester);
expect(rect.top, greaterThanOrEqualTo(viewport.top));
expect(rect.bottom, lessThanOrEqualTo(viewport.bottom));
}
testWidgets('on first build', (tester) async {
await pumpProfiles(
tester,
profiles: profiles,
currentProfileId: selected.id,
);
await tester.pumpAndSettle();
expectRevealed(tester);
});
testWidgets('not when the kept page becomes active again', (tester) async {
final isActive = ValueNotifier(true);
addTearDown(isActive.dispose);
await pumpProfiles(
tester,
profiles: profiles,
currentProfileId: selected.id,
isActive: isActive,
);
await tester.pumpAndSettle();
isActive.value = false;
await tester.pump();
final scrollable = tester.state<ScrollableState>(
find.descendant(
of: find.byKey(profilesStoreKey),
matching: find.byType(Scrollable),
),
);
scrollable.position.jumpTo(0);
await tester.pumpAndSettle();
expect(find.text(selected.label), findsNothing);
isActive.value = true;
await tester.pumpAndSettle();
expect(scrollable.position.pixels, 0);
expect(find.text(selected.label), findsNothing);
});
});
}