feat(profiles): add a custom profile editor
Changelog: Custom profiles
This commit is contained in:
@@ -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
@@ -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')),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+103
-71
@@ -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));
|
||||
});
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+100
-28
@@ -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));
|
||||
});
|
||||
}
|
||||
+118
-39
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+71
-1
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
() {
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user