Files
FlClash/test/views/clash_providers_view_test.dart
T
2026-10-16 22:54:53 +08:00

544 lines
15 KiB
Dart

import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/views/config/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:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/app_paths.dart';
import '../helpers/context_menu.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _TestClashProviders extends ClashProviders {
_TestClashProviders(this.initial);
final List<ClashProvider> initial;
@override
Stream<List<ClashProvider>> build() => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
}
class _RecordingClashProvidersAction extends ClashProvidersAction {
final List<Profile> usedBy;
final Exception? putFailure;
final put = <ClashProvider>[];
final refreshed = <ClashProvider>[];
final deleted = <ClashProvider>[];
_RecordingClashProvidersAction({this.usedBy = const [], this.putFailure});
@override
Future<ClashProvider> putProvider(
ClashProvider provider, {
ClashProvider? previous,
List<int>? content,
bool refresh = false,
}) async {
if (putFailure case final failure?) {
throw failure;
}
put.add(provider);
if (refresh) {
refreshed.add(provider);
}
return provider;
}
@override
void delProvider(ClashProvider provider) => deleted.add(provider);
@override
Future<List<Profile>> profilesUsing(ClashProvider provider) async => usedBy;
}
ProviderContainer _containerFor(
WidgetTester tester, {
List<Override> overrides = const [],
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(
overrides: [
profilesProvider.overrideWith(() => TestProfiles(profiles)),
...overrides,
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).update((_) => size);
return container;
}
void main() {
late Directory home;
setUpAll(() async {
home = Directory.systemTemp.createTempSync('flclash-rule-sets-view-');
await useAppPaths(home);
});
tearDownAll(() {
if (home.existsSync()) home.deleteSync(recursive: true);
});
testWidgets('a rule set shows its behavior beside its name and when its file '
'last changed at the end', (tester) async {
const cached = ClashProvider(
id: 31,
label: 'Cached',
url: 'https://example.com/cached.yaml',
);
final modified = DateTime.now().subtract(const Duration(hours: 3));
await tester.runAsync(() async {
final file = File(await appPath.getProviderCachePath(cached.fileName));
await file.create(recursive: true);
await file.writeAsString('payload: []');
await file.setLastModified(modified);
});
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders(const [cached]),
),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
for (var step = 0; step < 5; step++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
await tester.pump();
}
final updated = modified.getLastUpdateTimeDesc(
tester.element(find.text('Cached')),
compact: true,
);
expect(find.text(updated), findsOneWidget);
expect(find.widgetWithText(TonalChip, 'classical'), findsOneWidget);
});
const appRules = ClashProvider(
id: 2,
label: 'Ad block',
url: 'https://example.com/ads.yaml',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.mrs,
);
testWidgets('a url import rejects a name another rule set has', (
tester,
) async {
final action = _RecordingClashProvidersAction();
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders([appRules]),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Name'),
'Ad block',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/more.yaml',
);
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(action.put, isEmpty);
await tester.enterText(
find.widgetWithText(TextFormField, 'Name'),
'Extra rules',
);
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(action.put.single.label, 'Extra rules');
expect(action.put.single.url, 'https://example.com/more.yaml');
});
testWidgets('a url import without a name takes it from the url and asks '
'nothing more', (tester) async {
final action = _RecordingClashProvidersAction();
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders(const []),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/ads.list',
);
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(find.text('Behavior'), findsNothing);
expect(action.put.single.label, 'ads');
expect(action.put.single.url, 'https://example.com/ads.list');
});
testWidgets('a batch url import skips known urls and names each set apart', (
tester,
) async {
final action = _RecordingClashProvidersAction();
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders([appRules]),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Batch import'));
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField),
'https://example.com/ads.yaml\n'
'https://a.example/cn.list\n'
'https://b.example/cn.list',
);
await tester.pump();
expect(find.text('2 to add, 1 skipped as existing'), findsOneWidget);
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(action.put.map((provider) => (provider.label, provider.url)), [
('cn', 'https://a.example/cn.list'),
('cn(1)', 'https://b.example/cn.list'),
]);
});
testWidgets('a rule set the Core cannot read says why and is not saved', (
tester,
) async {
final action = _RecordingClashProvidersAction(
putFailure: const CoreMethodException(
code: 'rule_set_mixed',
message: 'rule set mixes domains and IP ranges',
),
);
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders(const []),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/mixed.list',
);
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(
find.text(
'The rule set mixes domains and IP ranges, so its type cannot be told',
),
findsOneWidget,
);
expect(action.put, isEmpty);
});
testWidgets('a local provider offers the editor and keeps the url out of '
'its options', (tester) async {
const local = ClashProvider(
id: 3,
label: 'Local list',
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.yaml,
);
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders(const [local]),
),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
expect(find.text('classical'), findsOneWidget);
expect(find.text('Local'), findsOneWidget);
await rightClick(tester, find.byType(DecorationListItem));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Sync'), findsNothing);
await tester.tap(find.text('Options'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextFormField, 'Name'), findsOneWidget);
expect(find.widgetWithText(TextFormField, 'URL'), findsNothing);
expect(find.text('Behavior'), findsNothing);
expect(find.text('Format'), findsNothing);
});
testWidgets('a remote rule set syncs through the action', (tester) async {
final action = _RecordingClashProvidersAction();
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders([appRules]),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
expect(find.text('domain'), findsOneWidget);
expect(find.text('Remote'), findsOneWidget);
expect(find.textContaining(appRules.url), findsNothing);
await rightClick(tester, find.byType(DecorationListItem));
await tester.pumpAndSettle();
await tester.tap(find.text('Sync'));
await tester.pumpAndSettle();
expect(action.refreshed, [appRules]);
});
testWidgets('a remote rule set previews read-only on a tap', (tester) async {
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders([appRules]),
),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await tester.tap(find.text('Ad block'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.widget<EditorPage>(find.byType(EditorPage)).onSave, isNull);
});
testWidgets('the options of a remote rule set edit only its name and url', (
tester,
) async {
final action = _RecordingClashProvidersAction();
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders([appRules]),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await rightClick(tester, find.byType(DecorationListItem));
await tester.pumpAndSettle();
await tester.tap(find.text('Options'));
await tester.pumpAndSettle();
expect(find.text('Behavior'), findsNothing);
expect(find.text('Format'), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/ads.list',
);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(
action.put.single,
appRules.copyWith(url: 'https://example.com/ads.list'),
);
});
testWidgets('deleting a provider goes through the action after confirming', (
tester,
) async {
final action = _RecordingClashProvidersAction();
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders([appRules]),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await rightClick(tester, find.byType(DecorationListItem));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(action.deleted, [appRules]);
});
testWidgets(
'a provider a custom profile still uses is deleted once confirmed',
(tester) async {
final action = _RecordingClashProvidersAction(
usedBy: [Profile.normal(label: 'Work')],
);
final container = _containerFor(
tester,
overrides: [
clashProvidersProvider.overrideWith(
() => _TestClashProviders([appRules]),
),
clashProvidersActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ClashProvidersView()),
),
);
await tester.pump();
await rightClick(tester, find.byType(DecorationListItem));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.textContaining('used by Work'), findsOneWidget);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(action.deleted, [appRules]);
},
);
}