feat(config): log DNS queries, override only picked keys and rework settings

TUN now defaults to the mips stack.

Changelog: DNS query log and per-key DNS/NTP overrides
This commit is contained in:
chen08209
2026-09-25 03:56:59 +08:00
parent bee8f24269
commit a390a42032
68 changed files with 10702 additions and 2996 deletions
+144
View File
@@ -0,0 +1,144 @@
import 'package:fl_clash/common/input_entries.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('parseListInput', () {
test('splits on newlines and both comma widths', () {
final parsed = parseListInput('a, b,c\r\nd\n\n e ');
expect(parsed.entries, ['a', 'b', 'c', 'd', 'e']);
expect(parsed.isValid, isTrue);
expect(parsed.skippedExisting, 0);
});
test('keeps spaces inside an item', () {
final parsed = parseListInput('https://dns.google/dns-query#Proxy A');
expect(parsed.entries, ['https://dns.google/dns-query#Proxy A']);
});
test('strips YAML sequence markers and quotes', () {
final parsed = parseListInput("- 'geosite:cn'\n- \"+.lan\",\n- plain");
expect(parsed.entries, ['geosite:cn', '+.lan', 'plain']);
});
test('keeps a rule line whole', () {
final parsed = parseListInput(
"DOMAIN-SUFFIX,lan,real-ip\n- 'rule-set,cn,fake-ip'\nMATCH,fake-ip\n"
'a.com, b.com',
);
expect(parsed.entries, [
'DOMAIN-SUFFIX,lan,real-ip',
'rule-set,cn,fake-ip',
'MATCH,fake-ip',
'a.com',
'b.com',
]);
});
test('merges duplicates and skips existing values', () {
final parsed = parseListInput('a, b, a, c', existing: {'b', 'c'});
expect(parsed.entries, ['a']);
expect(parsed.skippedExisting, 2);
});
test('reports over-long items with their line', () {
final parsed = parseListInput('ok\nfar-too-long\nfine', maxLength: 4);
expect(parsed.entries, ['ok', 'fine']);
expect(parsed.issues, [
const InputIssue(
line: 2,
raw: 'far-too-long',
kind: InputIssueKind.valueTooLong,
),
]);
expect(parsed.isValid, isFalse);
});
});
group('parseMapInput', () {
test('splits on the first whitespace and keeps colons', () {
final parsed = parseMapInput(
'geosite:cn tls://1.1.1.1:853\n'
'example.com 2001:4860:4860::8888\n'
'local ::1',
);
expect(parsed.entries.map((e) => e.key), [
'geosite:cn',
'example.com',
'local',
]);
expect(parsed.entries.map((e) => e.value), [
'tls://1.1.1.1:853',
'2001:4860:4860::8888',
'::1',
]);
});
test('accepts YAML mapping lines and key=value', () {
final parsed = parseMapInput(
"'geosite:cn': https://dns.google/dns-query\n"
'"+.lan": 127.0.0.1,\n'
'a = b\n'
'c=d\n'
'e : ::1',
);
expect(Map.fromEntries(parsed.entries), {
'geosite:cn': 'https://dns.google/dns-query',
'+.lan': '127.0.0.1',
'a': 'b',
'c': 'd',
'e': '::1',
});
});
test('reports lines without a value', () {
final parsed = parseMapInput('ok 1\nlonely\nempty=\nfine 2');
expect(parsed.entries.map((e) => e.key), ['ok', 'fine']);
expect(parsed.issues, [
const InputIssue(
line: 2,
raw: 'lonely',
kind: InputIssueKind.missingValue,
),
const InputIssue(
line: 3,
raw: 'empty=',
kind: InputIssueKind.missingValue,
),
]);
});
test('reports over-long keys and values separately', () {
final parsed = parseMapInput(
'toolongkey v\nk toolongvalue',
keyMaxLength: 4,
valueMaxLength: 4,
);
expect(parsed.entries, isEmpty);
expect(parsed.issues.map((issue) => issue.kind), [
InputIssueKind.keyTooLong,
InputIssueKind.valueTooLong,
]);
expect(parsed.issues.map((issue) => issue.raw), [
'toolongkey',
'toolongvalue',
]);
});
test('keeps the first of duplicate keys and skips existing keys', () {
final parsed = parseMapInput('a 1\na 2\nb 3\nc 4', existingKeys: {'b'});
expect(Map.fromEntries(parsed.entries), {'a': '1', 'c': '4'});
expect(parsed.skippedExisting, 1);
});
});
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:fl_clash/common/keyboard.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
const _mac = ShortcutLabels(isMacOS: true, isWindows: false);
const _windows = ShortcutLabels(isMacOS: false, isWindows: true);
const _linux = ShortcutLabels(isMacOS: false, isWindows: false);
const _everyModifier = {
KeyboardModifier.meta,
KeyboardModifier.shift,
KeyboardModifier.alt,
KeyboardModifier.control,
};
void main() {
group('ShortcutLabels', () {
final keyS = PhysicalKeyboardKey.keyS.usbHidUsage;
test('macOS writes symbols in menu order without separators', () {
expect(_mac.text(_everyModifier, keyS), '⌃⌥⇧⌘S');
expect(_mac.parts(_everyModifier, keyS), ['⌃', '⌥', '⇧', '⌘', 'S']);
});
test('Windows and Linux spell modifiers out and join them with +', () {
expect(_windows.text(_everyModifier, keyS), 'Ctrl+Alt+Shift+Win+S');
expect(_linux.text(_everyModifier, keyS), 'Ctrl+Alt+Shift+Super+S');
});
test('names keys by their position rather than their debug name', () {
final cases = {
PhysicalKeyboardKey.keyA: 'A',
PhysicalKeyboardKey.keyZ: 'Z',
PhysicalKeyboardKey.digit1: '1',
PhysicalKeyboardKey.digit9: '9',
PhysicalKeyboardKey.digit0: '0',
PhysicalKeyboardKey.f1: 'F1',
PhysicalKeyboardKey.f12: 'F12',
PhysicalKeyboardKey.f13: 'F13',
PhysicalKeyboardKey.f24: 'F24',
PhysicalKeyboardKey.numpad1: 'Num 1',
PhysicalKeyboardKey.numpad0: 'Num 0',
PhysicalKeyboardKey.arrowUp: '↑',
PhysicalKeyboardKey.quote: "'",
PhysicalKeyboardKey.space: 'Space',
};
for (final MapEntry(:key, :value) in cases.entries) {
expect(_windows.key(key.usbHidUsage), value, reason: key.debugName);
}
});
test('macOS uses the menu glyphs for editing keys', () {
expect(_mac.key(PhysicalKeyboardKey.enter.usbHidUsage), '↩');
expect(_mac.key(PhysicalKeyboardKey.escape.usbHidUsage), '⎋');
expect(_mac.key(PhysicalKeyboardKey.backspace.usbHidUsage), '⌫');
expect(_windows.key(PhysicalKeyboardKey.enter.usbHidUsage), 'Enter');
expect(_windows.key(PhysicalKeyboardKey.escape.usbHidUsage), 'Esc');
});
});
group('isValidHotKey', () {
final keyS = PhysicalKeyboardKey.keyS.usbHidUsage;
test('needs a key and at least one primary modifier', () {
expect(isValidHotKey({KeyboardModifier.control}, keyS), isTrue);
expect(isValidHotKey({KeyboardModifier.meta}, keyS), isTrue);
expect(isValidHotKey({KeyboardModifier.control}, null), isFalse);
expect(isValidHotKey(const {}, keyS), isFalse);
});
test('lock-style modifiers alone are not enough', () {
expect(isValidHotKey({KeyboardModifier.capsLock}, keyS), isFalse);
expect(isValidHotKey({KeyboardModifier.fn}, keyS), isFalse);
});
});
test('isModifierKey recognizes both sides of every modifier', () {
expect(isModifierKey(PhysicalKeyboardKey.controlRight), isTrue);
expect(isModifierKey(PhysicalKeyboardKey.metaLeft), isTrue);
expect(isModifierKey(PhysicalKeyboardKey.keyA), isFalse);
});
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
const _profileDns = <String, dynamic>{
'enable': true,
'listen': '0.0.0.0:53',
'nameserver': ['9.9.9.9'],
'fallback-filter': {
'geoip': false,
'domain': ['keep.me'],
},
};
const _patchConfig = PatchClashConfig(
dns: Dns(
listen: ':1053',
ipv6: true,
nameserver: ['1.1.1.1'],
fallbackFilter: FallbackFilter(geoip: true),
),
dnsOverrideKeys: {
DnsOverrideKey.nameserver,
DnsOverrideKey.fallbackFilterGeoip,
DnsOverrideKey.ipv6,
},
);
Future<YamlMap> _dnsOf({
required Map<String, dynamic> rawConfig,
required bool overrideDns,
}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 1,
rawConfig: rawConfig,
realPatchConfig: _patchConfig,
overrideDns: overrideDns,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
return config['dns'] as YamlMap;
}
void main() {
test('overrides only the selected DNS keys of an enabled profile', () async {
final dns = await _dnsOf(
rawConfig: {'dns': _profileDns},
overrideDns: true,
);
expect(dns['nameserver'], ['1.1.1.1']);
expect(dns['ipv6'], true);
expect(dns['listen'], '0.0.0.0:53');
expect(dns['fallback-filter'], {
'geoip': true,
'domain': ['keep.me'],
});
});
test('leaves an enabled profile alone while the override is off', () async {
final dns = await _dnsOf(
rawConfig: {'dns': _profileDns},
overrideDns: false,
);
expect(dns['nameserver'], ['9.9.9.9']);
expect(dns.containsKey('ipv6'), isFalse);
expect(dns['fallback-filter'], {
'geoip': false,
'domain': ['keep.me'],
});
});
test(
'fills the minimal defaults plus overrides for a profile without DNS',
() async {
final dns = await _dnsOf(rawConfig: {}, overrideDns: false);
expect(dns, {
'enable': true,
'enhanced-mode': 'fake-ip',
'nameserver': ['1.1.1.1'],
'ipv6': true,
'fallback-filter': {'geoip': true},
});
},
);
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
const _profileNtp = <String, dynamic>{
'enable': true,
'server': 'ntp.aliyun.com',
'port': 123,
'write-to-system': true,
};
const _patchConfig = PatchClashConfig(
ntp: Ntp(server: 'time.cloudflare.com', interval: 60),
ntpOverrideKeys: {NtpOverrideKey.server, NtpOverrideKey.interval},
);
Future<YamlMap?> _ntpOf({
required Map<String, dynamic> rawConfig,
required bool overrideNtp,
}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 1,
rawConfig: rawConfig,
realPatchConfig: _patchConfig,
overrideDns: false,
overrideNtp: overrideNtp,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
return config['ntp'] as YamlMap?;
}
void main() {
test('overrides only the selected NTP keys of a profile', () async {
final ntp = await _ntpOf(
rawConfig: {'ntp': _profileNtp},
overrideNtp: true,
);
expect(ntp!['server'], 'time.cloudflare.com');
expect(ntp['interval'], 60);
expect(ntp['enable'], true);
expect(ntp['port'], 123);
expect(ntp['write-to-system'], true);
});
test('leaves the profile alone while the override is off', () async {
final ntp = await _ntpOf(
rawConfig: {'ntp': _profileNtp},
overrideNtp: false,
);
expect(ntp!['server'], 'ntp.aliyun.com');
expect(ntp.containsKey('interval'), isFalse);
});
test('writes nothing for a profile without NTP', () async {
expect(await _ntpOf(rawConfig: {}, overrideNtp: false), isNull);
});
test('a profile without NTP gets only the selected keys', () async {
final ntp = await _ntpOf(rawConfig: {}, overrideNtp: true);
expect(ntp, {'server': 'time.cloudflare.com', 'interval': 60});
});
}
+60
View File
@@ -192,6 +192,7 @@ void main() {
hosts: {'router.local': '192.168.1.1,192.168.1.2'},
),
overrideDns: false,
overrideNtp: false,
appendSystemDns: true,
proxyGroups: const [],
rules: const [],
@@ -249,6 +250,7 @@ void main() {
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -302,6 +304,7 @@ void main() {
geoUpdateInterval: 48,
),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -330,6 +333,7 @@ void main() {
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -359,7 +363,14 @@ void main() {
rawConfig: {},
realPatchConfig: PatchClashConfig(),
overrideDns: true,
overrideNtp: false,
appendSystemDns: false,
proxies: [
CustomProxy(
id: 2,
definition: {'name': 'Node', 'type': 'socks5', 'port': 1080},
),
],
proxyGroups: [
ProxyGroup(
id: 1,
@@ -383,7 +394,12 @@ void main() {
expect(config['dns']['enable'], true);
expect(config['dns']['nameserver'], isNot(contains('system://')));
expect(config['proxies'], [
{'name': 'Node', 'type': 'socks5', 'port': 1080},
]);
expect(config['proxy-groups'], hasLength(1));
expect(config['proxy-groups'][0].keys, ['name', 'type', 'proxies']);
expect(config['proxy-groups'][0]['type'], 'select');
expect(config['rules'], ['DOMAIN,custom.example,DIRECT']);
});
@@ -412,6 +428,7 @@ void main() {
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -432,6 +449,47 @@ void main() {
);
});
test('makeRealProfileTask keeps safe mode off the host', () async {
Future<YamlMap> build({required bool safeMode}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: {
'dns': {'enable': true, 'listen': '0.0.0.0:53'},
'ntp': {'enable': true, 'write-to-system': true},
'external-controller-tls': '127.0.0.1:9443',
'external-controller-unix': 'mihomo.sock',
'external-controller-pipe': r'\\.\pipe\mihomo',
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
safeMode: safeMode,
),
);
return loadYaml(result.yaml) as YamlMap;
}
final normal = await build(safeMode: false);
expect(normal['dns']['listen'], '0.0.0.0:53');
expect(normal['external-controller-tls'], '127.0.0.1:9443');
expect(normal['ntp']['write-to-system'], true);
final safe = await build(safeMode: true);
expect(safe['dns']['listen'], '');
expect(safe['external-controller-tls'], '');
expect(safe['external-controller-unix'], '');
expect(safe['external-controller-pipe'], '');
expect(safe['ntp']['write-to-system'], false);
expect(safe['ntp']['enable'], true);
});
group('makeRealProfileTask interface-name mode', () {
Future<YamlMap> runWith(PatchClashConfig realPatchConfig) async {
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
@@ -445,6 +503,7 @@ void main() {
rawConfig: rawConfig,
realPatchConfig: realPatchConfig,
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -519,6 +578,7 @@ void main() {
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
+105
View File
@@ -1,7 +1,11 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/manager/hotkey_manager.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/models/common.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rust_api/rust_api.dart';
@@ -62,4 +66,105 @@ void main() {
);
});
});
group('HotKeyManager', () {
final action = HotKeyAction(
action: HotAction.view,
key: PhysicalKeyboardKey.keyV.usbHidUsage,
modifiers: const {KeyboardModifier.control},
);
Future<(ProviderContainer, List<List<HotKeySpec>>)> pump(
WidgetTester tester, {
bool safeMode = false,
Future<List<HotKeyFailure>> Function()? respond,
}) async {
final registrations = <List<HotKeySpec>>[];
final container = ProviderContainer(
overrides: [
safeModeProvider.overrideWithValue(safeMode),
hotKeyActionsProvider.overrideWithValue([action]),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: HotKeyManager(
registerHotKeys: ({required specs}) async {
registrations.add(specs);
return respond?.call() ?? const [];
},
hotKeyEventSource: () => const Stream<int>.empty(),
child: const SizedBox.shrink(),
),
),
);
await tester.pumpAndSettle();
return (container, registrations);
}
testWidgets('registers the configured shortcuts with the OS', (
tester,
) async {
final (container, registrations) = await pump(tester);
expect(registrations, hasLength(1));
expect(registrations.single.single.id, HotAction.view.index);
expect(container.read(hotKeyFailuresProvider), isEmpty);
});
testWidgets('a safe mode build registers nothing', (tester) async {
final (_, registrations) = await pump(tester, safeMode: true);
expect(registrations, isEmpty);
});
testWidgets('publishes the shortcuts the OS refused', (tester) async {
final (container, _) = await pump(
tester,
respond: () async => [
HotKeyFailure(id: HotAction.view.index, reason: 'taken'),
],
);
expect(container.read(hotKeyFailuresProvider), {HotAction.view: 'taken'});
});
testWidgets('marks every shortcut failed when registration throws', (
tester,
) async {
final (container, _) = await pump(
tester,
respond: () => Future.error(StateError('no backend')),
);
expect(container.read(hotKeyFailuresProvider).keys, [HotAction.view]);
});
testWidgets('releases every shortcut while one is being recorded', (
tester,
) async {
final (container, registrations) = await pump(
tester,
respond: () async => [
HotKeyFailure(id: HotAction.view.index, reason: 'taken'),
],
);
container.read(hotKeyRecordingProvider.notifier).value = true;
await tester.pumpAndSettle();
expect(registrations.last, isEmpty);
expect(container.read(hotKeyFailuresProvider), {
HotAction.view: 'taken',
}, reason: 'an empty registration says nothing about the bindings');
container.read(hotKeyRecordingProvider.notifier).value = false;
await tester.pumpAndSettle();
expect(registrations, hasLength(3));
expect(registrations.last.single.id, HotAction.view.index);
});
});
}
+179 -4
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -105,13 +106,37 @@ void main() {
expect(restored.closeConnections, true);
expect(restored.isAnimateToPage, true);
expect(restored.autoCheckUpdate, true);
expect(restored.showLabel, false);
expect(restored.sidebarExpanded, true);
expect(restored.minimizeOnExit, true);
expect(restored.restoreStrategy, RestoreStrategy.compatible);
expect(restored.customUserAgent, '');
expect(restored.userAgents, defaultUserAgents);
expect(restored.testUrl, defaultTestUrl);
});
test('a saved outboundModeV2 card folds into outboundMode', () {
final restored = AppSettingProps.fromJson({
'dashboardWidgets': ['networkSpeed', 'outboundModeV2', 'outboundMode'],
});
expect(restored.dashboardWidgets, [
DashboardWidget.networkSpeed,
DashboardWidget.outboundMode,
]);
final alone = AppSettingProps.fromJson({
'dashboardWidgets': ['outboundModeV2', 'trafficUsage'],
});
expect(alone.dashboardWidgets, [
DashboardWidget.outboundMode,
DashboardWidget.trafficUsage,
]);
});
test('every dashboard widget survives round-trip', () {
const props = AppSettingProps(dashboardWidgets: DashboardWidget.values);
final restored = roundTrip(props.toJson, AppSettingProps.fromJson);
expect(restored.dashboardWidgets, DashboardWidget.values);
});
test('custom values survive round-trip', () {
const props = AppSettingProps(
locale: 'zh_CN',
@@ -119,7 +144,7 @@ void main() {
autoLaunch: true,
closeConnections: false,
testUrl: 'https://custom.test',
customUserAgent: 'CustomUA/1.0',
userAgents: ['CustomUA/1.0'],
);
final restored = roundTrip(
() => props.toJson(),
@@ -130,7 +155,42 @@ void main() {
expect(restored.autoLaunch, true);
expect(restored.closeConnections, false);
expect(restored.testUrl, 'https://custom.test');
expect(restored.customUserAgent, 'CustomUA/1.0');
expect(restored.userAgents, ['CustomUA/1.0']);
});
test('a legacy custom user agent joins the presets', () {
expect(
AppSettingProps.fromJson({
'customUserAgent': ' CustomUA/1.0 ',
}).userAgents,
[...defaultUserAgents, 'CustomUA/1.0'],
);
expect(
AppSettingProps.fromJson({'customUserAgent': ''}).userAgents,
defaultUserAgents,
);
expect(
AppSettingProps.fromJson({
'customUserAgent': 'CustomUA/1.0',
'userAgents': <String>[],
}).userAgents,
isEmpty,
);
});
test('a legacy showLabel sets whether the sidebar is expanded', () {
expect(
AppSettingProps.fromJson({'showLabel': false}).sidebarExpanded,
false,
);
expect(
AppSettingProps.fromJson({
'showLabel': false,
'sidebarExpanded': true,
}).sidebarExpanded,
true,
);
expect(AppSettingProps.fromJson({}).sidebarExpanded, true);
});
test('safeFromJson returns default on null', () {
@@ -307,6 +367,47 @@ void main() {
expect(restored.type, ProxiesType.list);
expect(restored.sortType, ProxiesSortType.delay);
});
test('reads the icon styles saved under their former names', () {
expect(
ProxiesStyleProps.fromJson({'iconStyle': 'standard'}).iconStyle,
ProxiesIconStyle.filled,
);
expect(
ProxiesStyleProps.fromJson({'iconStyle': 'icon'}).iconStyle,
ProxiesIconStyle.plain,
);
expect(
ProxiesStyleProps.fromJson({'iconStyle': 'none'}).iconStyle,
ProxiesIconStyle.hidden,
);
});
test('falls back to the default icon style', () {
expect(ProxiesStyleProps.fromJson({}).iconStyle, ProxiesIconStyle.filled);
expect(
ProxiesStyleProps.fromJson({'iconStyle': 'nonsense'}).iconStyle,
ProxiesIconStyle.filled,
);
});
test('round-trips every icon style under its own name', () {
for (final style in ProxiesIconStyle.values) {
final restored = roundTrip(
() => ProxiesStyleProps(iconStyle: style).toJson(),
ProxiesStyleProps.fromJson,
);
expect(restored.iconStyle, style);
}
});
test('round-trip keeps the timed-out node filter', () {
final restored = roundTrip(
() => const ProxiesStyleProps(hideTimeoutProxies: true).toJson(),
ProxiesStyleProps.fromJson,
);
expect(restored.hideTimeoutProxies, true);
});
});
group('ThemeProps JSON round-trip', () {
@@ -417,6 +518,34 @@ void main() {
expect(result.appSettingProps.onlyStatisticsProxy, false);
});
test('a fresh config ships valid, distinct default hotkeys', () {
final defaults = Config.realFromJson(null).hotKeyActions;
expect(
defaults.map((action) => action.action),
Platform.isWindows
? isEmpty
: [
HotAction.view,
HotAction.start,
HotAction.mode,
HotAction.proxy,
HotAction.delayTest,
],
);
for (final action in defaults) {
expect(
isValidHotKey(action.modifiers, action.key),
isTrue,
reason: action.action.name,
);
}
expect(
defaults.map((action) => action.key).toSet(),
hasLength(defaults.length),
);
});
test('full config round-trip', () {
const config = Config(
currentProfileId: 42,
@@ -442,4 +571,50 @@ void main() {
expect(restored.windowProps.height, 720);
});
});
group('ProxyGroup definition', () {
const group = ProxyGroup(
profileId: 7,
id: 99,
name: 'Auto',
type: GroupType.URLTest,
proxies: ['A'],
use: [],
url: 'https://cp.cloudflare.com/generate_204',
interval: 300,
timeout: 5000,
lazy: false,
filter: '',
tolerance: 50,
strategy: LoadBalanceStrategy.roundRobin,
order: 'a0',
);
test('emits only what the core reads', () {
final definition = group.definition;
expect(definition['name'], 'Auto');
expect(definition['type'], 'url-test');
expect(definition['proxies'], ['A']);
expect(definition['interval'], 300);
expect(definition['timeout'], 5000);
expect(definition['lazy'], false);
expect(definition.containsKey('profileId'), isFalse);
expect(definition.containsKey('id'), isFalse);
expect(definition.containsKey('order'), isFalse);
expect(definition.containsKey('use'), isFalse);
expect(definition.containsKey('filter'), isFalse);
expect(definition.containsKey('max-failed-times'), isFalse);
});
test('scopes tolerance and strategy to the types that read them', () {
expect(group.definition['tolerance'], 50);
expect(group.definition.containsKey('strategy'), isFalse);
final balanced = group.copyWith(type: GroupType.LoadBalance).definition;
expect(balanced['strategy'], 'round-robin');
expect(balanced.containsKey('tolerance'), isFalse);
});
});
}
+262
View File
@@ -0,0 +1,262 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:test/test.dart';
PatchClashConfig _decode(Map<String, Object?> json) {
return PatchClashConfig.fromJson(
jsonDecode(jsonEncode(json)) as Map<String, Object?>,
);
}
void main() {
test('DnsOverrideKey names every key of the core DNS section', () {
final source = File('core/Clash.Meta/config/config.go').readAsStringSync();
Iterable<String> yamlKeysOf(String struct) {
final body = RegExp(
'type $struct struct {([^}]*)}',
).firstMatch(source)!.group(1)!;
return RegExp(
r'yaml:"([^",]+)',
).allMatches(body).map((match) => match.group(1)!);
}
expect(DnsOverrideKey.values.map((key) => key.path).toSet(), {
for (final key in yamlKeysOf('RawDNS'))
if (key != DnsOverrideKey.fallbackFilterSection) key,
for (final key in yamlKeysOf('RawFallbackFilter'))
'${DnsOverrideKey.fallbackFilterSection}.$key',
});
});
group('Dns.overrideJson', () {
test('emits only the selected keys in model order', () {
const dns = Dns(listen: ':53', nameserver: ['1.1.1.1']);
final json = dns.overrideJson({
DnsOverrideKey.nameserver,
DnsOverrideKey.listen,
});
expect(json.keys.toList(), ['listen', 'nameserver']);
expect(json['nameserver'], ['1.1.1.1']);
expect(json['listen'], ':53');
});
test('nests fallback filter keys and splits policy servers', () {
const dns = Dns(
nameserverPolicy: {'geosite:cn': 'a, b', 'x.com': '1.1.1.1'},
fallbackFilter: FallbackFilter(geoipCode: 'US'),
);
final json = dns.overrideJson({
DnsOverrideKey.nameserverPolicy,
DnsOverrideKey.fallbackFilterGeoipCode,
});
expect(json['fallback-filter'], {'geoip-code': 'US'});
expect(json['nameserver-policy'], {
'geosite:cn': ['a', 'b'],
'x.com': '1.1.1.1',
});
});
test('is empty without keys', () {
expect(const Dns().overrideJson({}), isEmpty);
});
});
group('mergeDnsOverride', () {
test('keeps the fallback filter keys the override does not name', () {
final merged = mergeDnsOverride(
{
'enable': true,
'fallback-filter': {
'geoip': false,
'domain': ['keep.me'],
},
},
{
'fallback-filter': {'geoip': true},
},
);
expect(merged['enable'], true);
expect(merged['fallback-filter'], {
'geoip': true,
'domain': ['keep.me'],
});
});
test('replaces policy maps whole', () {
final merged = mergeDnsOverride(
{
'nameserver-policy': {'a.com': '9.9.9.9', 'b.com': '8.8.8.8'},
},
{
'nameserver-policy': {'a.com': '1.1.1.1'},
},
);
expect(merged['nameserver-policy'], {'a.com': '1.1.1.1'});
});
test('replaces lists whole', () {
final merged = mergeDnsOverride(
{
'nameserver': ['9.9.9.9', '8.8.8.8'],
},
{
'nameserver': ['1.1.1.1'],
},
);
expect(merged['nameserver'], ['1.1.1.1']);
});
});
group('PatchClashConfig.dnsOverrideKeys', () {
test('round-trips as key paths', () {
const config = PatchClashConfig(
dnsOverrideKeys: {
DnsOverrideKey.fallbackFilterGeoip,
DnsOverrideKey.nameserver,
},
);
final json = config.toJson();
expect(json['dns-override-keys'], [
'fallback-filter.geoip',
'nameserver',
]);
expect(_decode(json).dnsOverrideKeys, config.dnsOverrideKeys);
});
test('a fresh config overrides nothing', () {
expect(const PatchClashConfig().dnsOverrideKeys, isEmpty);
});
test('the baseline a profile without DNS is given stays minimal', () {
expect(defaultDns.overrideJson(baselineDnsOverrideKeys), {
'enable': true,
'enhanced-mode': 'fake-ip',
'nameserver': defaultDns.nameserver,
});
});
test('a config saved before the key set keeps the keys it overrode', () {
final json = const PatchClashConfig().toJson()
..remove('dns-override-keys');
final keys = _decode(json).dnsOverrideKeys;
expect(keys, hasLength(20));
expect(keys, contains(DnsOverrideKey.fallbackFilterDomain));
expect(keys, isNot(contains(DnsOverrideKey.directNameserver)));
});
test('a saved empty key set wins over edited values', () {
final json = const PatchClashConfig(dns: Dns(ipv6: true)).toJson();
json['dns-override-keys'] = <String>[];
expect(_decode(json).dnsOverrideKeys, isEmpty);
});
test('a key this build does not know is dropped, not the config', () {
final json = const PatchClashConfig(mixedPort: 7899).toJson();
json['dns-override-keys'] = ['nameserver', 'key-from-a-newer-build'];
final config = _decode(json);
expect(config.dnsOverrideKeys, {DnsOverrideKey.nameserver});
expect(config.mixedPort, 7899);
});
});
group('Dns.applyOverrideYaml', () {
const dns = Dns(
ipv6: true,
nameserverPolicy: {'geosite:cn': 'a, b'},
fallbackFilter: FallbackFilter(geoipCode: 'US'),
);
const keys = {
DnsOverrideKey.ipv6,
DnsOverrideKey.nameserverPolicy,
DnsOverrideKey.fallbackFilterGeoipCode,
};
test('round-trips the raw document', () {
final result = dns.applyOverrideYaml(dns.overrideYaml(keys));
expect(result.keys, keys);
expect(result.dns, dns);
});
test('names the keys the document lists and keeps other values', () {
final result = dns.applyOverrideYaml('''
nameserver:
- 1.1.1.1
fallback-filter:
geoip: false
''');
expect(result.keys, {
DnsOverrideKey.nameserver,
DnsOverrideKey.fallbackFilterGeoip,
});
expect(result.dns.nameserver, ['1.1.1.1']);
expect(result.dns.fallbackFilter.geoip, isFalse);
expect(result.dns.fallbackFilter.geoipCode, 'US');
expect(result.dns.ipv6, isTrue);
});
test('an empty document clears the key set', () {
final result = dns.applyOverrideYaml('');
expect(result.keys, isEmpty);
expect(result.dns, dns);
});
test('reads the keys the core added and their enums', () {
final result = dns.applyOverrideYaml('''
ipv6-timeout: 300
cache-algorithm: arc
fake-ip-filter-mode: whitelist
direct-nameserver-follow-policy: true
proxy-server-nameserver-policy:
node.example: [1.1.1.1, 8.8.8.8]
''');
expect(result.dns.ipv6Timeout, 300);
expect(result.dns.cacheAlgorithm, DnsCacheAlgorithm.arc);
expect(result.dns.fakeIpFilterMode, FakeIpFilterMode.whitelist);
expect(result.dns.directNameserverFollowPolicy, isTrue);
expect(result.dns.proxyServerNameserverPolicy, {
'node.example': '1.1.1.1, 8.8.8.8',
});
expect(
result.dns.overrideJson({DnsOverrideKey.proxyServerNameserverPolicy}),
{
'proxy-server-nameserver-policy': {
'node.example': ['1.1.1.1', '8.8.8.8'],
},
},
);
});
test('rejects unknown keys and values the model cannot hold', () {
expect(
() => dns.applyOverrideYaml('bogus-key: 1'),
throwsFormatException,
);
expect(
() => dns.applyOverrideYaml('fake-ip-filter-mode: sometimes'),
throwsA(anything),
);
expect(
() => dns.applyOverrideYaml('fallback-filter:\n geo: true'),
throwsFormatException,
);
expect(() => dns.applyOverrideYaml('ipv6: maybe'), throwsA(anything));
expect(() => dns.applyOverrideYaml('- ipv6'), throwsFormatException);
});
});
}
+142
View File
@@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:test/test.dart';
PatchClashConfig _decode(Map<String, Object?> json) {
return PatchClashConfig.fromJson(
jsonDecode(jsonEncode(json)) as Map<String, Object?>,
);
}
void main() {
test('NtpOverrideKey names every key of the core NTP section', () {
final source = File('core/Clash.Meta/config/config.go').readAsStringSync();
final body = RegExp(
'type RawNTP struct {([^}]*)}',
).firstMatch(source)!.group(1)!;
expect(NtpOverrideKey.values.map((key) => key.path).toSet(), {
for (final match in RegExp(r'yaml:"([^",]+)').allMatches(body))
match.group(1)!,
});
});
test('the model defaults match the ones the core parses', () {
final source = File('core/Clash.Meta/config/config.go').readAsStringSync();
final body = RegExp(
r'NTP: RawNTP\{([^}]*)\}',
).firstMatch(source)!.group(1)!;
String valueOf(String field) =>
RegExp('$field:\\s*(.+),').firstMatch(body)!.group(1)!.trim();
expect(valueOf('Server'), '"${defaultNtp.server}"');
expect(valueOf('Port'), '${defaultNtp.port}');
expect(valueOf('Interval'), '${defaultNtp.interval}');
expect(valueOf('Enable'), '${defaultNtp.enable}');
expect(valueOf('WriteToSystem'), '${defaultNtp.writeToSystem}');
});
group('Ntp.overrideJson', () {
test('emits only the selected keys in model order', () {
const ntp = Ntp(server: 'time.cloudflare.com', port: 1230);
final json = ntp.overrideJson({
NtpOverrideKey.port,
NtpOverrideKey.server,
});
expect(json.keys.toList(), ['server', 'port']);
expect(json['server'], 'time.cloudflare.com');
expect(json['port'], 1230);
});
test('is empty without keys', () {
expect(const Ntp().overrideJson({}), isEmpty);
});
});
group('PatchClashConfig.ntpOverrideKeys', () {
test('round-trips as key paths', () {
const config = PatchClashConfig(
ntpOverrideKeys: {
NtpOverrideKey.writeToSystem,
NtpOverrideKey.dialerProxy,
},
);
final json = config.toJson();
expect(json['ntp-override-keys'], ['write-to-system', 'dialer-proxy']);
expect(_decode(json).ntpOverrideKeys, config.ntpOverrideKeys);
});
test('a fresh config overrides nothing', () {
expect(const PatchClashConfig().ntpOverrideKeys, isEmpty);
});
test('a config saved before the section keeps the empty set', () {
final json = const PatchClashConfig().toJson()
..remove('ntp')
..remove('ntp-override-keys');
final config = _decode(json);
expect(config.ntpOverrideKeys, isEmpty);
expect(config.ntp, defaultNtp);
});
test('a key this build does not know is dropped, not the config', () {
final json = const PatchClashConfig(mixedPort: 7899).toJson();
json['ntp-override-keys'] = ['server', 'key-from-a-newer-build'];
final config = _decode(json);
expect(config.ntpOverrideKeys, {NtpOverrideKey.server});
expect(config.mixedPort, 7899);
});
});
group('Ntp.applyOverrideYaml', () {
const ntp = Ntp(enable: true, server: 'ntp.aliyun.com', interval: 60);
const keys = {
NtpOverrideKey.enable,
NtpOverrideKey.server,
NtpOverrideKey.interval,
};
test('round-trips the raw document', () {
final result = ntp.applyOverrideYaml(ntp.overrideYaml(keys));
expect(result.keys, keys);
expect(result.ntp, ntp);
});
test('names the keys the document lists and keeps other values', () {
final result = ntp.applyOverrideYaml('''
port: 1230
write-to-system: true
''');
expect(result.keys, {NtpOverrideKey.port, NtpOverrideKey.writeToSystem});
expect(result.ntp.port, 1230);
expect(result.ntp.writeToSystem, isTrue);
expect(result.ntp.server, 'ntp.aliyun.com');
expect(result.ntp.interval, 60);
});
test('an empty document clears the key set', () {
final result = ntp.applyOverrideYaml('');
expect(result.keys, isEmpty);
expect(result.ntp, ntp);
});
test('rejects unknown keys and values the model cannot hold', () {
expect(
() => ntp.applyOverrideYaml('bogus-key: 1'),
throwsFormatException,
);
expect(() => ntp.applyOverrideYaml('port: many'), throwsA(anything));
expect(() => ntp.applyOverrideYaml('- enable'), throwsFormatException);
});
});
}
+4 -2
View File
@@ -22,7 +22,7 @@ void main() {
expect(value.onlyStatisticsProxy, false);
expect(value.autoLaunch, false);
expect(value.closeConnections, true);
expect(value.isAnimateToPage, true);
expect(value.tabAnimation, TabAnimation.slide);
});
test('can update state', () {
@@ -232,15 +232,17 @@ void main() {
themeProps: ThemeProps(),
currentProfileId: 7,
overrideDns: true,
overrideNtp: true,
);
final overrides = buildConfigOverrides(config);
expect(overrides.length, 12);
expect(overrides.length, 13);
final overrideContainer = ProviderContainer(overrides: overrides);
addTearDown(overrideContainer.dispose);
expect(overrideContainer.read(currentProfileIdProvider), 7);
expect(overrideContainer.read(overrideDnsProvider), true);
expect(overrideContainer.read(overrideNtpProvider), true);
expect(
overrideContainer.read(patchClashConfigProvider),
config.patchClashConfig,
+8
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui' show Brightness, Color;
import 'package:fl_clash/common/app_ports.dart';
import 'package:fl_clash/common/preferences.dart';
@@ -66,6 +67,13 @@ class _GeometryWindowPort implements WindowPort {
return geometry;
}
@override
Future<bool> setBlur({
required bool enabled,
required Brightness brightness,
required Color tint,
}) async => false;
@override
Future<void> close() async {}
+86 -12
View File
@@ -1,4 +1,6 @@
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/action.dart';
import 'package:fl_clash/providers/app.dart';
@@ -11,6 +13,7 @@ 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';
import '../helpers/test_profiles.dart';
@@ -65,6 +68,16 @@ class _TestSystemAction extends SystemAction {
}
}
Future<void> openBarMenu(WidgetTester tester) async {
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byGlyph(AppGlyphs.more),
),
);
await tester.pumpAndSettle();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -192,6 +205,42 @@ void main() {
await teardownView(tester);
});
testWidgets('ignores case and needs every term to match', (tester) async {
seedAccessControl(const AccessControlProps(enable: true));
await pumpAccessView(tester);
container.read(queryProvider(QueryTag.access).notifier).value = 'CHAT';
await tester.pump();
expect(find.text('Chat'), findsOneWidget);
expect(find.text('Browser'), findsNothing);
container.read(queryProvider(QueryTag.access).notifier).value =
'EXAMPLE browser';
await tester.pump();
expect(find.text('Browser'), findsOneWidget);
expect(find.text('Chat'), findsNothing);
await teardownView(tester);
});
testWidgets('the app bar search field filters the list', (tester) async {
seedAccessControl(const AccessControlProps(enable: true));
await pumpAccessView(tester);
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'nothing matches');
await tester.pumpAndSettle();
expect(find.text('Browser'), findsNothing);
expect(
find.text(AppLocalizations.current.noSearchResults),
findsOneWidget,
);
await teardownView(tester);
});
});
group('selection', () {
@@ -214,22 +263,47 @@ void main() {
await teardownView(tester);
});
testWidgets('the action button selects then clears every visible app', (
testWidgets('the bar menu selects then clears every visible app', (
tester,
) async {
seedAccessControl(const AccessControlProps(enable: true));
await pumpAccessView(tester);
await tester.tap(find.byType(FloatingActionButton).first);
await tester.pump();
await openBarMenu(tester);
await tester.tap(find.text('Select all'));
await tester.pumpAndSettle();
expect(
[...container.read(accessControlStateProvider).currentList]..sort(),
['com.example.browser', 'com.example.chat'],
);
await tester.pump(const Duration(milliseconds: 400));
await tester.tap(find.byType(FloatingActionButton).first);
await tester.pump();
await openBarMenu(tester);
await tester.tap(find.text('Deselect all'));
await tester.pumpAndSettle();
expect(container.read(accessControlStateProvider).currentList, isEmpty);
await teardownView(tester);
});
testWidgets('searching selects then clears only the matching apps', (
tester,
) async {
seedAccessControl(const AccessControlProps(enable: true));
await pumpAccessView(tester);
await tester.tap(find.byTooltip('Search'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'chat');
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Select all'));
await tester.pumpAndSettle();
expect(container.read(accessControlStateProvider).currentList, [
'com.example.chat',
]);
await tester.tap(find.byTooltip('Deselect all'));
await tester.pumpAndSettle();
expect(container.read(accessControlStateProvider).currentList, isEmpty);
await teardownView(tester);
@@ -287,7 +361,8 @@ void main() {
expect(find.text('App list permission required'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Authorize'), findsOneWidget);
expect(find.text('No data'), findsNothing);
expect(find.byType(FloatingActionButton), findsNothing);
await openBarMenu(tester);
expect(find.text('Select all'), findsNothing);
await teardownView(tester);
});
@@ -354,8 +429,9 @@ void main() {
seedAccessControl(const AccessControlProps(enable: true));
await pumpAccessView(tester);
await openBarMenu(tester);
expect(find.widgetWithText(FilledButton, 'Save'), findsNothing);
expect(find.text('Save'), findsNothing);
await teardownView(tester);
});
@@ -376,10 +452,8 @@ void main() {
await tester.tap(find.text('Browser'));
await tester.pump();
final saveButton = find.widgetWithText(FilledButton, 'Save');
expect(saveButton, findsOneWidget);
await tester.tap(saveButton);
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
final saved = container
.read(vpnSettingProvider)
@@ -0,0 +1,406 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.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/dashboard/widgets/dns_queries.dart';
import 'package:fl_clash/views/dns_queries.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/gestures.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';
import '../../helpers/test_profiles.dart';
const _mobileSize = Size(400, 800);
final _shortTop = _mobileSize.height * (1 - snapSheetDetents.first);
final _tallTop = _mobileSize.height * (1 - snapSheetDetents.last);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
setUp(() {
container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 1000);
});
tearDown(() => container.dispose());
void seedDnsQueries(int count) {
final notifier = container.read(dnsQueriesProvider.notifier);
notifier.value = FixedList<DnsQuery>(500);
for (var index = 0; index < count; index++) {
notifier.addQuery(
DnsQuery(
domain: 'host-$index.example',
type: 'A',
answers: const ['1.1.1.1'],
rcode: 'NOERROR',
delay: 12,
time: DateTime.utc(2026, 9, 18, 4, 30),
),
);
}
}
Future<void> pumpCard(
WidgetTester tester, {
Size size = const Size(1200, 1000),
}) async {
container.read(viewSizeProvider.notifier).value = size;
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Scaffold(body: ListView(children: const [DnsQueriesCard()])),
),
),
);
await tester.pump();
}
double sheetTop(WidgetTester tester) {
return tester.getTopLeft(find.byType(SheetDragHandle)).dy;
}
/// Drags the sheet by its title, above anything the content can scroll.
Future<void> flingSheet(WidgetTester tester, double dy) {
return tester.flingFrom(
Offset(_mobileSize.width / 2, sheetTop(tester) + sheetAppBarHeight / 2),
Offset(0, dy),
900,
);
}
testWidgets('shows the query count and follows it', (tester) async {
await pumpCard(tester);
expect(find.text('DNS queries'), findsOneWidget);
expect(find.text('0'), findsOneWidget);
container.read(dnsQueryCountProvider.notifier).value = 1234;
await tester.pump();
// The count repaints on the throttled cadence, not per query.
expect(find.text('0'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 301));
expect(find.text('1234'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('tapping the card opens the DNS list beside it', (tester) async {
await pumpCard(tester);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
expect(find.byType(DnsQueriesView), findsOneWidget);
// A sheet this wide has nothing to drag between.
expect(find.byType(SheetDragHandle), findsNothing);
await tester.tap(find.byGlyph(AppGlyphs.close));
await tester.pumpAndSettle();
expect(find.byType(DnsQueriesView), findsNothing);
expect(find.text('DNS queries'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets(
'the sheet opens at the short detent and flings to the tall one',
(tester) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
expect(find.byType(DnsQueriesView), findsOneWidget);
expect(sheetTop(tester), closeTo(_shortTop, 1));
await flingSheet(tester, -120);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
},
);
testWidgets('swiping the list up opens the tall detent first', (
tester,
) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
await tester.flingFrom(const Offset(200, 600), const Offset(0, -150), 900);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('flinging the sheet down stops at the short detent, then '
'closes it', (tester) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
await flingSheet(tester, -120);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
await flingSheet(tester, 200);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_shortTop, 1));
await flingSheet(tester, 200);
await tester.pumpAndSettle();
expect(find.byType(DnsQueriesView), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
/// Moves a finger frame by frame and returns where the sheet sits just
/// before it lets go.
Future<double> slowDrag(
WidgetTester tester,
Offset from,
double dy, {
VoidCallback? midway,
}) async {
const steps = 20;
final gesture = await tester.startGesture(from);
for (var step = 0; step < steps; step++) {
await gesture.moveBy(Offset(0, dy / steps));
await tester.pump(const Duration(milliseconds: 16));
if (step == steps ~/ 2) {
midway?.call();
}
}
await tester.pump(const Duration(milliseconds: 200));
final top = sheetTop(tester);
await gesture.up();
await tester.pumpAndSettle();
return top;
}
Future<void> openTall(WidgetTester tester) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
await flingSheet(tester, -120);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
}
testWidgets('a slow drag down the list from its top collapses the tall '
'sheet', (tester) async {
await openTall(tester);
final held = await slowDrag(tester, const Offset(200, 400), 200);
expect(held, closeTo(_tallTop + 200 - kTouchSlop, 2));
expect(sheetTop(tester), closeTo(_shortTop, 1));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('a slow drag up the list expands the short sheet', (
tester,
) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
await slowDrag(tester, const Offset(200, 650), -250);
expect(sheetTop(tester), closeTo(_tallTop, 1));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('a query landing mid drag leaves the sheet under the finger', (
tester,
) async {
await openTall(tester);
final held = await slowDrag(
tester,
const Offset(200, 400),
200,
midway: () => container
.read(dnsQueriesProvider.notifier)
.addQuery(
DnsQuery(
domain: 'late.example',
type: 'A',
answers: const ['1.1.1.1'],
rcode: 'NOERROR',
delay: 12,
time: DateTime.utc(2026, 9, 18, 4, 31),
),
),
);
expect(held, closeTo(_tallTop + 200 - kTouchSlop, 2));
expect(sheetTop(tester), closeTo(_shortTop, 1));
expect(find.text('late.example'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('a query landing while the sheet opens waits for it to open', (
tester,
) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
final position = tester
.state<ScrollableState>(
find.descendant(
of: find.byType(DnsQueriesView),
matching: find.byWidgetPredicate(
(widget) =>
widget is Scrollable &&
widget.axisDirection == AxisDirection.up,
),
),
)
.position;
await flingSheet(tester, -120);
await tester.pump(const Duration(milliseconds: 50));
container
.read(dnsQueriesProvider.notifier)
.addQuery(
DnsQuery(
domain: 'late.example',
type: 'A',
answers: const ['1.1.1.1'],
rcode: 'NOERROR',
delay: 12,
time: DateTime.utc(2026, 9, 18, 4, 31),
),
);
await tester.pump(const Duration(milliseconds: 150));
await tester.pump(const Duration(milliseconds: 150));
expect(sheetTop(tester), greaterThan(_tallTop + 1));
expect(position.pixels, lessThan(position.maxScrollExtent));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
expect(position.pixels, position.maxScrollExtent);
expect(find.text('late.example'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('a throw that runs the list out leaves the sheet alone', (
tester,
) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
await flingSheet(tester, -120);
await tester.pumpAndSettle();
await tester.drag(find.byType(DnsQueriesView), const Offset(0, -200));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
await tester.fling(find.byType(DnsQueriesView), const Offset(0, 100), 2500);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('a short drag below the short detent follows the finger and '
'springs back', (tester) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
final floor = sheetTop(tester);
final gesture = await tester.startGesture(
Offset(_mobileSize.width / 2, floor + sheetAppBarHeight / 2),
);
await gesture.moveBy(const Offset(0, 140));
await tester.pump();
final pulled = sheetTop(tester);
expect(pulled, greaterThan(floor + 100));
await gesture.up();
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(floor, 1));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('the sheet closes from its header button', (tester) async {
seedDnsQueries(40);
await pumpCard(tester, size: _mobileSize);
await tester.tap(find.byType(DnsQueriesCard));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.byType(DnsQueriesView), findsNothing);
expect(find.text('DNS queries'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
}
+258
View File
@@ -0,0 +1,258 @@
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/dns_queries.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';
DnsQuery _dnsQuery(
String domain, {
String type = 'A',
DnsQueryInitiator? initiator,
String upstream = '',
bool cached = false,
List<String> answers = const ['1.1.1.1'],
String rcode = 'NOERROR',
String error = '',
}) {
return DnsQuery(
domain: domain,
type: type,
initiator: initiator,
upstream: upstream,
cached: cached,
answers: answers,
rcode: rcode,
error: error,
delay: 12,
time: DateTime.utc(2026, 9, 18, 4, 30),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
setUp(() {
container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
});
tearDown(() => container.dispose());
void seedDnsQueries(List<DnsQuery> dnsQueries) {
final notifier = container.read(dnsQueriesProvider.notifier);
notifier.value = FixedList<DnsQuery>(500);
for (final dnsQuery in dnsQueries) {
notifier.addQuery(dnsQuery);
}
}
Future<void> pumpDnsQueries(WidgetTester tester) 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: const TestApp(child: DnsQueriesView()),
),
);
await tester.pump();
await tester.pump();
}
Future<void> teardownView(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
}
testWidgets('shows the empty state without any query', (tester) async {
await pumpDnsQueries(tester);
expect(find.byType(NullStatus), findsOneWidget);
expect(find.text('No DNS queries yet'), findsOneWidget);
await teardownView(tester);
});
testWidgets('lists each query with its answers and chips', (tester) async {
seedDnsQueries([
_dnsQuery(
'alpha.test',
initiator: DnsQueryInitiator.app,
answers: ['93.184.216.34'],
),
_dnsQuery(
'cached.test',
initiator: DnsQueryInitiator.direct,
cached: true,
),
_dnsQuery(
'missing.test',
type: 'AAAA',
answers: const [],
rcode: 'NXDOMAIN',
),
_dnsQuery(
'timeout.test',
answers: const [],
rcode: '',
error: 'i/o timeout',
),
]);
await pumpDnsQueries(tester);
expect(find.text('alpha.test'), findsOneWidget);
expect(find.text('93.184.216.34'), findsOneWidget);
expect(find.text('App'), findsOneWidget);
expect(find.text('Direct'), findsOneWidget);
expect(find.text('Cache'), findsOneWidget);
expect(find.text('NXDOMAIN'), findsOneWidget);
expect(
find.text(DateTime.utc(2026, 9, 18, 4, 30).showFull),
findsNWidgets(4),
);
expect(find.text('12 ms'), findsNWidgets(4));
expect(
tester.getCenter(find.text('App')).dy,
greaterThan(tester.getCenter(find.text('alpha.test')).dy),
);
final error = tester.widget<Text>(find.text('i/o timeout'));
expect(
error.style?.color,
Theme.of(tester.element(find.text('i/o timeout'))).colorScheme.error,
);
await teardownView(tester);
});
testWidgets('a query arriving after mount reaches the list', (tester) async {
seedDnsQueries([_dnsQuery('alpha.test')]);
await pumpDnsQueries(tester);
expect(find.text('gamma.test'), findsNothing);
container
.read(dnsQueriesProvider.notifier)
.addQuery(_dnsQuery('gamma.test'));
await tester.pump(const Duration(seconds: 1));
await tester.pump();
await tester.pump();
expect(find.text('gamma.test'), findsOneWidget);
await teardownView(tester);
});
testWidgets('tapping a chip keeps only the queries carrying it', (
tester,
) async {
seedDnsQueries([
_dnsQuery('alpha.test'),
_dnsQuery('beta.test', type: 'AAAA', answers: ['::1']),
]);
await pumpDnsQueries(tester);
expect(find.text('alpha.test'), findsOneWidget);
await tester.tap(find.widgetWithText(RecordLabel, 'AAAA'));
await tester.pumpAndSettle();
expect(find.text('alpha.test'), findsNothing);
expect(find.text('beta.test'), findsOneWidget);
await teardownView(tester);
});
testWidgets('tapping a query opens its details', (tester) async {
seedDnsQueries([
_dnsQuery(
'alpha.test',
initiator: DnsQueryInitiator.rule,
upstream: 'tls://1.1.1.1:853',
cached: true,
answers: ['edge.alpha.net', '93.184.216.34'],
),
]);
await pumpDnsQueries(tester);
expect(find.text('tls://1.1.1.1:853'), findsOneWidget);
expect(find.text('12 ms'), findsOneWidget);
await tester.tap(find.text('alpha.test'));
await tester.pumpAndSettle();
expect(find.text('DNS details'), findsOneWidget);
expect(find.text('Record type'), findsOneWidget);
expect(find.text('Initiator'), findsOneWidget);
expect(find.text('Source'), findsOneWidget);
Finder detailText(String text) => find.descendant(
of: find.byType(DnsQueryDetailView),
matching: find.text(text),
);
expect(detailText('tls://1.1.1.1:853'), findsOneWidget);
expect(detailText('Cache'), findsOneWidget);
expect(detailText('Yes'), findsOneWidget);
expect(detailText('12 ms'), findsOneWidget);
expect(find.text('Answers'), findsOneWidget);
expect(find.text('edge.alpha.net'), findsOneWidget);
expect(find.text('93.184.216.34'), findsOneWidget);
await teardownView(tester);
});
testWidgets('a failed query shows its error in its own section', (
tester,
) async {
seedDnsQueries([
_dnsQuery(
'timeout.test',
answers: const [],
rcode: '',
error: 'i/o timeout',
),
]);
await pumpDnsQueries(tester);
await tester.tap(find.text('timeout.test'));
await tester.pumpAndSettle();
final errorHeader = find.descendant(
of: find.byType(DnsQueryDetailView),
matching: find.widgetWithText(ListHeader, 'Error'),
);
expect(errorHeader, findsOneWidget);
final errorText = find.descendant(
of: find.byType(DnsQueryDetailView),
matching: find.text('i/o timeout'),
);
expect(
tester.widget<Text>(errorText).style?.color,
Theme.of(tester.element(errorText)).colorScheme.error,
);
expect(
tester.getTopLeft(errorText).dy,
greaterThan(tester.getBottomLeft(errorHeader).dy),
);
await teardownView(tester);
});
}
+239
View File
@@ -0,0 +1,239 @@
import 'dart:ui' show Tristate;
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/pages/editor.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/config/dns.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';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
Future<void> pumpView(WidgetTester tester) 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.new)],
);
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: DnsView()),
),
);
await tester.pump();
}
PatchClashConfig config() => container.read(patchClashConfigProvider);
void setKeys(Set<DnsOverrideKey> keys) {
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(dnsOverrideKeys: keys));
}
String emptyLabel() =>
currentAppLocalizations.nullTip(currentAppLocalizations.overrideEntries);
Future<void> openMenu(WidgetTester tester) async {
await tester.tap(find.byTooltip(currentAppLocalizations.more));
await tester.pumpAndSettle();
}
testWidgets('lists a key picked from the sheet', (tester) async {
await pumpView(tester);
setKeys({});
await tester.pump();
expect(find.text(emptyLabel()), findsOneWidget);
await openMenu(tester);
await tester.tap(find.text(currentAppLocalizations.add));
await tester.pumpAndSettle();
expect(find.text(currentAppLocalizations.addOverrideEntry), findsOneWidget);
await tester.tap(find.text(currentAppLocalizations.listen));
await tester.pumpAndSettle();
expect(config().dnsOverrideKeys, {DnsOverrideKey.listen});
expect(find.text(emptyLabel()), findsNothing);
expect(find.text(currentAppLocalizations.listen), findsOneWidget);
expect(tester.takeException(), null);
});
testWidgets('a toggle entry writes its value and can be removed', (
tester,
) async {
await pumpView(tester);
setKeys({DnsOverrideKey.ipv6});
await tester.pump();
expect(find.text('IPv6'), findsOneWidget);
await tester.tap(find.byType(Switch).last);
await tester.pump();
expect(config().dns.ipv6, isTrue);
await tester.tap(find.byTooltip(currentAppLocalizations.remove));
await tester.pumpAndSettle();
expect(config().dnsOverrideKeys, isEmpty);
expect(find.text('IPv6'), findsNothing);
expect(find.text(emptyLabel()), findsOneWidget);
});
testWidgets('groups fallback filter entries under their own header', (
tester,
) async {
await pumpView(tester);
setKeys({DnsOverrideKey.fallbackFilterDomain, DnsOverrideKey.enable});
await tester.pump();
final l = currentAppLocalizations;
final status = tester.getTopLeft(find.text(l.status));
final header = tester.getTopLeft(find.text(l.fallbackFilter));
final domain = tester.getTopLeft(find.text(l.domain));
expect(find.text(l.options), findsOneWidget);
expect(status.dy, lessThan(header.dy));
expect(header.dy, lessThan(domain.dy));
});
testWidgets('a list entry without a description summarises its items', (
tester,
) async {
await pumpView(tester);
setKeys({
DnsOverrideKey.fallbackFilterDomain,
DnsOverrideKey.fallbackFilterGeosite,
});
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith.dns.fallbackFilter(
domain: ['+.a.com', '+.b.com'],
geosite: [],
),
);
await tester.pump();
expect(find.text('+.a.com, +.b.com'), findsOneWidget);
expect(find.text(currentAppLocalizations.none), findsOneWidget);
});
testWidgets('a number entry writes the integer it is given', (tester) async {
await pumpView(tester);
setKeys({DnsOverrideKey.ipv6Timeout});
await tester.pump();
await tester.tap(find.text(currentAppLocalizations.ipv6Timeout));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '300');
await tester.tap(find.text(currentAppLocalizations.submit));
await tester.pumpAndSettle();
expect(config().dns.ipv6Timeout, 300);
expect(find.text('300'), findsOneWidget);
});
testWidgets('the DNS mode options leave out hosts', (tester) async {
await pumpView(tester);
setKeys({DnsOverrideKey.enhancedMode});
await tester.pump();
await tester.tap(find.text(currentAppLocalizations.dnsMode));
await tester.pumpAndSettle();
expect(find.text(DnsMode.redirHost.name), findsOneWidget);
expect(find.text(DnsMode.hosts.name), findsNothing);
});
testWidgets('the add item disables while every key is listed', (
tester,
) async {
await pumpView(tester);
setKeys(DnsOverrideKey.values.toSet());
await tester.pump();
await openMenu(tester);
final semantics = tester.getSemantics(
find.text(currentAppLocalizations.add),
);
expect(semantics.flagsCollection.isEnabled, Tristate.isFalse);
});
testWidgets('the override switch writes the override provider', (
tester,
) async {
await pumpView(tester);
expect(container.read(overrideDnsProvider), isFalse);
await tester.tap(find.text(currentAppLocalizations.overrideDns));
await tester.pump();
expect(container.read(overrideDnsProvider), isTrue);
});
Future<EditorPage> openQuickEdit(WidgetTester tester) async {
await openMenu(tester);
await tester.tap(find.text(currentAppLocalizations.quickEdit));
await tester.pumpAndSettle();
return tester.widget<EditorPage>(find.byType(EditorPage));
}
testWidgets('quick edit opens the override document and applies it on exit', (
tester,
) async {
await pumpView(tester);
setKeys({DnsOverrideKey.ipv6});
await tester.pump();
final editor = await openQuickEdit(tester);
expect(editor.content, contains('ipv6: false'));
expect(editor.readOnly, isFalse);
expect(editor.onSave, isNull);
final context = tester.element(find.byType(EditorPage));
final popped = await editor.onPop!(context, 'DNS', 'listen: :53');
expect(popped, isTrue);
expect(config().dnsOverrideKeys, {DnsOverrideKey.listen});
expect(config().dns.listen, ':53');
});
testWidgets('a rejected quick edit can only be discarded or kept open', (
tester,
) async {
await pumpView(tester);
setKeys({});
await tester.pump();
final editor = await openQuickEdit(tester);
final context = tester.element(find.byType(EditorPage));
final popped = editor.onPop!(context, 'DNS', 'bogus: 1');
await tester.pump();
expect(
find.textContaining(currentAppLocalizations.discardChanges),
findsOneWidget,
);
await tester.tap(find.text(currentAppLocalizations.cancel));
await tester.pump();
expect(await popped, isFalse);
expect(config().dnsOverrideKeys, isEmpty);
});
}
+237 -121
View File
@@ -12,11 +12,16 @@ import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
const _labels = ShortcutLabels(isMacOS: false, isWindows: true);
final _keyA = PhysicalKeyboardKey.keyA.usbHidUsage;
final _keyB = PhysicalKeyboardKey.keyB.usbHidUsage;
ProviderContainer _containerFor(
WidgetTester tester, {
List<HotKeyAction> hotKeyActions = const [],
}) {
const size = Size(1400, 1000);
const size = Size(1400, 1400);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -38,6 +43,16 @@ ProviderContainer _containerFor(
return container;
}
Future<void> _pumpView(WidgetTester tester, ProviderContainer container) async {
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: HotKeyView()),
),
);
await tester.pumpAndSettle();
}
Future<void> _pumpRecorder(
WidgetTester tester,
ProviderContainer container,
@@ -53,8 +68,9 @@ Future<void> _pumpRecorder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
Scaffold(body: HotKeyRecorder(hotKeyAction: action)),
builder: (_) => Scaffold(
body: HotKeyRecorder(hotKeyAction: action, labels: _labels),
),
),
),
child: const Text('open'),
@@ -68,74 +84,135 @@ Future<void> _pumpRecorder(
await tester.pumpAndSettle();
}
Future<void> _pressWithControl(
Future<void> _press(
WidgetTester tester,
PhysicalKeyboardKey key,
LogicalKeyboardKey logicalKey,
) async {
await simulateKeyDownEvent(
LogicalKeyboardKey.controlLeft,
physicalKey: PhysicalKeyboardKey.controlLeft,
);
LogicalKeyboardKey logicalKey, {
List<(PhysicalKeyboardKey, LogicalKeyboardKey)> holding = const [],
}) async {
for (final (physical, logical) in holding) {
await simulateKeyDownEvent(logical, physicalKey: physical);
}
await simulateKeyDownEvent(logicalKey, physicalKey: key);
await tester.pumpAndSettle();
await simulateKeyUpEvent(logicalKey, physicalKey: key);
await simulateKeyUpEvent(
LogicalKeyboardKey.controlLeft,
physicalKey: PhysicalKeyboardKey.controlLeft,
for (final (physical, logical) in holding.reversed) {
await simulateKeyUpEvent(logical, physicalKey: physical);
}
await tester.pumpAndSettle();
}
const _control = (
PhysicalKeyboardKey.controlLeft,
LogicalKeyboardKey.controlLeft,
);
Future<void> _pressControlA(WidgetTester tester) {
return _press(
tester,
PhysicalKeyboardKey.keyA,
LogicalKeyboardKey.keyA,
holding: const [_control],
);
}
void main() {
group('HotKeyView.getSubtitle', () {
testWidgets('reports the empty state when no key is bound', (tester) async {
final container = _containerFor(tester);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: HotKeyView()),
),
);
await tester.pumpAndSettle();
TextButton _button(WidgetTester tester, String label) {
return tester.widget<TextButton>(find.widgetWithText(TextButton, label));
}
const view = HotKeyView();
final context = tester.element(find.byType(HotKeyView));
void main() {
group('HotKeyView', () {
testWidgets('lists every action, bound or not', (tester) async {
final container = _containerFor(
tester,
hotKeyActions: [
HotKeyAction(
action: HotAction.view,
key: _keyA,
modifiers: const {KeyboardModifier.control},
),
],
);
await _pumpView(tester, container);
for (final action in HotAction.values) {
await tester.scrollUntilVisible(find.text(action.label), 100);
expect(find.text(action.label), findsOne, reason: action.name);
}
expect(
view.getSubtitle(context, const HotKeyAction(action: HotAction.mode)),
currentAppLocalizations.noHotKey,
find.text(currentAppLocalizations.hotkeyNotSet),
findsAtLeastNWidgets(1),
);
expect(tester.takeException(), null);
});
testWidgets('joins modifiers and the key into one label', (tester) async {
final container = _containerFor(tester);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: HotKeyView()),
),
testWidgets('shows a binding as key caps and clears it in place', (
tester,
) async {
final container = _containerFor(
tester,
hotKeyActions: [
HotKeyAction(
action: HotAction.view,
key: _keyA,
modifiers: const {KeyboardModifier.shift},
),
],
);
await _pumpView(tester, container);
final labels = ShortcutLabels.host();
expect(find.text(labels.modifier(KeyboardModifier.shift)), findsOne);
expect(find.text(labels.key(_keyA)), findsOne);
await tester.tap(find.byTooltip(currentAppLocalizations.remove));
await tester.pumpAndSettle();
const view = HotKeyView();
final context = tester.element(find.byType(HotKeyView));
final label = view.getSubtitle(
context,
HotKeyAction(
action: HotAction.mode,
key: PhysicalKeyboardKey.keyA.usbHidUsage,
modifiers: const {KeyboardModifier.control},
),
);
expect(container.read(hotKeyActionsProvider), isEmpty);
expect(find.text(labels.key(_keyA)), findsNothing);
});
expect(label, contains('+'));
expect(label, endsWith(PhysicalKeyboardKey.keyA.label));
expect(tester.takeException(), null);
testWidgets('flags a binding the OS refused', (tester) async {
final container = _containerFor(
tester,
hotKeyActions: [
HotKeyAction(
action: HotAction.view,
key: _keyA,
modifiers: const {KeyboardModifier.control},
),
],
);
container.read(hotKeyFailuresProvider.notifier).value = {
HotAction.view: 'already registered',
};
await _pumpView(tester, container);
expect(find.text(currentAppLocalizations.hotkeyUnavailable), findsOne);
expect(find.byTooltip('already registered'), findsOne);
});
});
group('HotKeyRecorder', () {
testWidgets('prompts for input until a key is captured', (tester) async {
testWidgets('pauses global hotkeys while it is open', (tester) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
container,
const HotKeyAction(action: HotAction.mode),
);
expect(container.read(hotKeyRecordingProvider), isTrue);
await tester.tap(find.text(currentAppLocalizations.cancel));
await tester.pumpAndSettle();
expect(container.read(hotKeyRecordingProvider), isFalse);
});
testWidgets('previews held modifiers until a key completes it', (
tester,
) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
@@ -145,73 +222,81 @@ void main() {
expect(find.text(currentAppLocalizations.pressKeyboard), findsOne);
await _pressWithControl(
tester,
PhysicalKeyboardKey.keyA,
LogicalKeyboardKey.keyA,
await simulateKeyDownEvent(
LogicalKeyboardKey.controlLeft,
physicalKey: PhysicalKeyboardKey.controlLeft,
);
expect(find.text(currentAppLocalizations.pressKeyboard), findsNothing);
expect(find.byType(KeyboardKeyBox), findsNWidgets(2));
expect(tester.takeException(), null);
});
testWidgets('confirm stores a modifier plus key combination', (
tester,
) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
container,
const HotKeyAction(action: HotAction.mode),
);
await _pressWithControl(
tester,
PhysicalKeyboardKey.keyA,
LogicalKeyboardKey.keyA,
);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
final stored = container.read(hotKeyActionsProvider);
expect(stored, hasLength(1));
expect(stored.single.action, HotAction.mode);
expect(stored.single.key, PhysicalKeyboardKey.keyA.usbHidUsage);
expect(stored.single.modifiers, {KeyboardModifier.control});
});
testWidgets('confirm rejects a bare key with no modifier', (tester) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
container,
const HotKeyAction(action: HotAction.mode),
);
expect(find.text('Ctrl'), findsOne);
expect(find.text('…'), findsOne);
expect(_button(tester, currentAppLocalizations.save).onPressed, isNull);
await simulateKeyDownEvent(
LogicalKeyboardKey.keyA,
physicalKey: PhysicalKeyboardKey.keyA,
);
await tester.pumpAndSettle();
await simulateKeyUpEvent(
LogicalKeyboardKey.keyA,
physicalKey: PhysicalKeyboardKey.keyA,
);
await tester.tap(find.text(currentAppLocalizations.confirm));
await simulateKeyUpEvent(
LogicalKeyboardKey.controlLeft,
physicalKey: PhysicalKeyboardKey.controlLeft,
);
await tester.pumpAndSettle();
expect(container.read(hotKeyActionsProvider), isEmpty);
expect(find.text(currentAppLocalizations.inputCorrectHotkey), findsOne);
expect(find.text('…'), findsNothing);
expect(find.text('Ctrl'), findsOne);
expect(find.text('A'), findsOne);
expect(tester.takeException(), null);
});
testWidgets('confirm rejects a combination already bound elsewhere', (
testWidgets('save stores a modifier plus key combination', (tester) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
container,
const HotKeyAction(action: HotAction.mode),
);
await _pressControlA(tester);
await tester.tap(find.text(currentAppLocalizations.save));
await tester.pumpAndSettle();
final stored = container.read(hotKeyActionsProvider);
expect(stored, hasLength(1));
expect(stored.single.action, HotAction.mode);
expect(stored.single.key, _keyA);
expect(stored.single.modifiers, {KeyboardModifier.control});
});
testWidgets('a bare key cannot be saved and says why', (tester) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
container,
const HotKeyAction(action: HotAction.mode),
);
await _press(tester, PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA);
expect(_button(tester, currentAppLocalizations.save).onPressed, isNull);
expect(
find.text(
currentAppLocalizations.hotkeyNeedsModifier('Ctrl, Alt, Shift, Win'),
),
findsOne,
);
expect(container.read(hotKeyActionsProvider), isEmpty);
});
testWidgets('saving a taken combination moves it from the other action', (
tester,
) async {
final taken = HotKeyAction(
action: HotAction.start,
key: PhysicalKeyboardKey.keyA.usbHidUsage,
key: _keyA,
modifiers: const {KeyboardModifier.control},
);
final container = _containerFor(tester, hotKeyActions: [taken]);
@@ -221,60 +306,91 @@ void main() {
const HotKeyAction(action: HotAction.mode),
);
await _pressWithControl(
tester,
PhysicalKeyboardKey.keyA,
LogicalKeyboardKey.keyA,
await _pressControlA(tester);
expect(
find.text(
currentAppLocalizations.hotkeyConflictWith(HotAction.start.label),
),
findsOne,
);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(container.read(hotKeyActionsProvider), [taken]);
expect(find.text(currentAppLocalizations.hotkeyConflict), findsOne);
final stored = container.read(hotKeyActionsProvider);
expect(stored, hasLength(1));
expect(stored.single.action, HotAction.mode);
expect(stored.single.key, _keyA);
});
testWidgets('confirm replaces the binding for the same action', (
testWidgets('save replaces the binding for the same action', (
tester,
) async {
final existing = HotKeyAction(
action: HotAction.mode,
key: PhysicalKeyboardKey.keyB.usbHidUsage,
key: _keyB,
modifiers: const {KeyboardModifier.control},
);
final container = _containerFor(tester, hotKeyActions: [existing]);
await _pumpRecorder(tester, container, existing);
await _pressWithControl(
tester,
PhysicalKeyboardKey.keyA,
LogicalKeyboardKey.keyA,
);
await tester.tap(find.text(currentAppLocalizations.confirm));
await _pressControlA(tester);
await tester.tap(find.text(currentAppLocalizations.save));
await tester.pumpAndSettle();
final stored = container.read(hotKeyActionsProvider);
expect(stored, hasLength(1), reason: 'replaces rather than appends');
expect(stored.single.key, PhysicalKeyboardKey.keyA.usbHidUsage);
expect(stored.single.key, _keyA);
});
testWidgets('remove clears the key and modifiers for the action', (
tester,
) async {
testWidgets('remove clears the binding for the action', (tester) async {
final existing = HotKeyAction(
action: HotAction.mode,
key: PhysicalKeyboardKey.keyB.usbHidUsage,
key: _keyB,
modifiers: const {KeyboardModifier.control},
);
final container = _containerFor(tester, hotKeyActions: [existing]);
final other = HotKeyAction(
action: HotAction.tun,
key: _keyA,
modifiers: const {KeyboardModifier.control},
);
final container = _containerFor(tester, hotKeyActions: [existing, other]);
await _pumpRecorder(tester, container, existing);
await tester.tap(find.text(currentAppLocalizations.remove));
await tester.pumpAndSettle();
final stored = container.read(hotKeyActionsProvider);
expect(stored, hasLength(1));
expect(stored.single.key, isNull);
expect(stored.single.modifiers, isEmpty);
expect(container.read(hotKeyActionsProvider), [other]);
});
testWidgets('remove is offered only for a bound action', (tester) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
container,
const HotKeyAction(action: HotAction.mode),
);
expect(find.text(currentAppLocalizations.remove), findsNothing);
});
testWidgets('a bare Escape closes without saving', (tester) async {
final container = _containerFor(tester);
await _pumpRecorder(
tester,
container,
const HotKeyAction(action: HotAction.mode),
);
await _press(
tester,
PhysicalKeyboardKey.escape,
LogicalKeyboardKey.escape,
);
expect(find.byType(HotKeyRecorder), findsNothing);
expect(container.read(hotKeyActionsProvider), isEmpty);
});
});
}
+26 -56
View File
@@ -164,73 +164,43 @@ void main() {
});
});
group('interface name visibility', () {
testWidgets('is hidden unless the mode is custom', (tester) async {
container
.read(patchClashConfigProvider.notifier)
.update(
(state) =>
state.copyWith(interfaceNameMode: InterfaceNameMode.follow),
);
await pumpItem(tester, const InterfaceNameItem());
expect(find.byType(ListTile), findsNothing);
});
testWidgets('is shown when the mode is custom', (tester) async {
container
.read(patchClashConfigProvider.notifier)
.update(
(state) =>
state.copyWith(interfaceNameMode: InterfaceNameMode.custom),
);
await pumpItem(tester, const InterfaceNameItem());
expect(find.byType(ListTile), findsOneWidget);
});
});
group('network options items', () {
test('interface name rows appear only on desktop', () {
final desktopTypes = networkOptionsItems(
isDesktop: true,
List<Type> types({
required bool isDesktop,
bool isCustomInterfaceName = true,
bool isBypassPrivateRoute = false,
}) {
return networkOptionsItems(
isDesktop: isDesktop,
isMacOS: false,
).map((item) => item.runtimeType);
isCustomInterfaceName: isCustomInterfaceName,
isBypassPrivateRoute: isBypassPrivateRoute,
).map((item) => item.runtimeType).toList();
}
test('interface name rows appear only on desktop', () {
final desktopTypes = types(isDesktop: true);
expect(desktopTypes, contains(InterfaceNameModeItem));
expect(desktopTypes, contains(InterfaceNameItem));
final androidTypes = networkOptionsItems(
isDesktop: false,
isMacOS: false,
).map((item) => item.runtimeType);
final androidTypes = types(isDesktop: false);
expect(androidTypes, isNot(contains(InterfaceNameModeItem)));
expect(androidTypes, isNot(contains(InterfaceNameItem)));
});
});
group('route address visibility', () {
testWidgets('is hidden while bypassing private addresses', (tester) async {
container
.read(networkSettingProvider.notifier)
.update(
(state) => state.copyWith(routeMode: RouteMode.bypassPrivate),
);
await pumpItem(tester, const RouteAddressItem());
expect(find.byType(ListTile), findsNothing);
test('the interface name row is listed only for the custom mode', () {
expect(
types(isDesktop: true, isCustomInterfaceName: false),
isNot(contains(InterfaceNameItem)),
);
});
testWidgets('is shown for every other route mode', (tester) async {
container
.read(networkSettingProvider.notifier)
.update((state) => state.copyWith(routeMode: RouteMode.config));
await pumpItem(tester, const RouteAddressItem());
expect(find.byType(ListTile), findsOneWidget);
test('the route address row is listed unless private routes bypass', () {
expect(types(isDesktop: false), contains(RouteAddressItem));
expect(
types(isDesktop: false, isBypassPrivateRoute: true),
isNot(contains(RouteAddressItem)),
);
});
});
}
+188
View File
@@ -0,0 +1,188 @@
import 'dart:ui' show Tristate;
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/pages/editor.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/config/ntp.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';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
Future<void> pumpView(WidgetTester tester) 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.new)],
);
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: NtpView()),
),
);
await tester.pump();
}
PatchClashConfig config() => container.read(patchClashConfigProvider);
void setKeys(Set<NtpOverrideKey> keys) {
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(ntpOverrideKeys: keys));
}
String emptyLabel() =>
currentAppLocalizations.nullTip(currentAppLocalizations.overrideEntries);
Future<void> openMenu(WidgetTester tester) async {
await tester.tap(find.byTooltip(currentAppLocalizations.more));
await tester.pumpAndSettle();
}
testWidgets('a fresh config lists no entry', (tester) async {
await pumpView(tester);
expect(config().ntpOverrideKeys, isEmpty);
expect(find.text(emptyLabel()), findsOneWidget);
});
testWidgets('lists a key picked from the sheet', (tester) async {
await pumpView(tester);
await openMenu(tester);
await tester.tap(find.text(currentAppLocalizations.add));
await tester.pumpAndSettle();
expect(find.text(currentAppLocalizations.addOverrideEntry), findsOneWidget);
await tester.tap(find.text(currentAppLocalizations.server));
await tester.pumpAndSettle();
expect(config().ntpOverrideKeys, {NtpOverrideKey.server});
expect(find.text(emptyLabel()), findsNothing);
expect(find.text(defaultNtp.server), findsOneWidget);
});
testWidgets('a toggle entry writes its value and can be removed', (
tester,
) async {
await pumpView(tester);
setKeys({NtpOverrideKey.enable});
await tester.pump();
expect(find.text(currentAppLocalizations.status), findsOneWidget);
await tester.tap(find.byType(Switch).last);
await tester.pump();
expect(config().ntp.enable, isTrue);
await tester.tap(find.byTooltip(currentAppLocalizations.remove));
await tester.pumpAndSettle();
expect(config().ntpOverrideKeys, isEmpty);
expect(find.text(emptyLabel()), findsOneWidget);
});
testWidgets('a number entry writes the integer it is given', (tester) async {
await pumpView(tester);
setKeys({NtpOverrideKey.interval});
await tester.pump();
await tester.tap(find.text(currentAppLocalizations.ntpInterval));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '120');
await tester.tap(find.text(currentAppLocalizations.submit));
await tester.pumpAndSettle();
expect(config().ntp.interval, 120);
expect(find.text('120'), findsOneWidget);
});
testWidgets('the add item disables while every key is listed', (
tester,
) async {
await pumpView(tester);
setKeys(NtpOverrideKey.values.toSet());
await tester.pump();
await openMenu(tester);
final semantics = tester.getSemantics(
find.text(currentAppLocalizations.add),
);
expect(semantics.flagsCollection.isEnabled, Tristate.isFalse);
});
testWidgets('the override switch writes the override provider', (
tester,
) async {
await pumpView(tester);
expect(container.read(overrideNtpProvider), isFalse);
await tester.tap(find.text(currentAppLocalizations.overrideNtp));
await tester.pump();
expect(container.read(overrideNtpProvider), isTrue);
});
Future<EditorPage> openQuickEdit(WidgetTester tester) async {
await openMenu(tester);
await tester.tap(find.text(currentAppLocalizations.quickEdit));
await tester.pumpAndSettle();
return tester.widget<EditorPage>(find.byType(EditorPage));
}
testWidgets('quick edit opens the override document and applies it on exit', (
tester,
) async {
await pumpView(tester);
setKeys({NtpOverrideKey.enable});
await tester.pump();
final editor = await openQuickEdit(tester);
expect(editor.content, contains('enable: false'));
expect(editor.readOnly, isFalse);
final context = tester.element(find.byType(EditorPage));
final popped = await editor.onPop!(context, 'NTP', 'port: 1230');
expect(popped, isTrue);
expect(config().ntpOverrideKeys, {NtpOverrideKey.port});
expect(config().ntp.port, 1230);
});
testWidgets('a rejected quick edit can only be discarded or kept open', (
tester,
) async {
await pumpView(tester);
final editor = await openQuickEdit(tester);
final context = tester.element(find.byType(EditorPage));
final popped = editor.onPop!(context, 'NTP', 'bogus: 1');
await tester.pump();
expect(
find.textContaining(currentAppLocalizations.discardChanges),
findsOneWidget,
);
await tester.tap(find.text(currentAppLocalizations.cancel));
await tester.pump();
expect(await popped, isFalse);
expect(config().ntpOverrideKeys, isEmpty);
});
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:fl_clash/common/common.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/user_agents.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';
void main() {
late ProviderContainer container;
String? selected() => container.read(patchClashConfigProvider).globalUa;
List<String> saved() => container.read(appSettingProvider).userAgents;
Finder menuOf(String userAgent) => find.descendant(
of: find.ancestor(
of: find.text(userAgent),
matching: find.byType(ListTile),
),
matching: find.byTooltip(currentAppLocalizations.more),
);
Future<void> pumpView(WidgetTester tester, {String? globalUa}) async {
tester.view.physicalSize = const Size(1000, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1000, 1600));
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(globalUa: globalUa));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: UserAgentsView()),
),
);
await tester.pumpAndSettle();
}
Future<void> submitInput(WidgetTester tester, String text) async {
await tester.enterText(find.byType(TextFormField), text);
await tester.tap(find.text(currentAppLocalizations.submit));
await tester.pumpAndSettle();
}
testWidgets('lists the default and the presets, default without a menu', (
tester,
) async {
await pumpView(tester);
expect(find.text(currentAppLocalizations.defaultText), findsOneWidget);
for (final userAgent in defaultUserAgents) {
expect(find.text(userAgent), findsOneWidget);
}
expect(
find.byTooltip(currentAppLocalizations.more),
findsNWidgets(defaultUserAgents.length),
);
});
testWidgets('tapping a row selects it and the default clears it', (
tester,
) async {
await pumpView(tester);
await tester.tap(find.text(defaultUserAgents.last));
await tester.pumpAndSettle();
expect(selected(), defaultUserAgents.last);
await tester.tap(find.text(currentAppLocalizations.defaultText));
await tester.pumpAndSettle();
expect(selected(), isNull);
});
testWidgets('adding appends without changing the selection', (tester) async {
await pumpView(tester);
await tester.tap(find.text(currentAppLocalizations.add));
await tester.pumpAndSettle();
await submitInput(tester, ' CustomUA/1.0 ');
expect(saved(), [...defaultUserAgents, 'CustomUA/1.0']);
expect(selected(), isNull);
expect(find.text('CustomUA/1.0'), findsOneWidget);
});
testWidgets('adding rejects an empty or duplicate value', (tester) async {
await pumpView(tester);
await tester.tap(find.text(currentAppLocalizations.add));
await tester.pumpAndSettle();
await submitInput(tester, ' ');
expect(
find.text(
currentAppLocalizations.emptyTip(currentAppLocalizations.userAgent),
),
findsOneWidget,
);
await submitInput(tester, defaultUserAgents.first);
expect(
find.text(
currentAppLocalizations.existsTip(currentAppLocalizations.userAgent),
),
findsOneWidget,
);
expect(saved(), defaultUserAgents);
});
testWidgets('editing the selected value keeps it selected', (tester) async {
await pumpView(tester, globalUa: defaultUserAgents.first);
await tester.tap(menuOf(defaultUserAgents.first));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.edit));
await tester.pumpAndSettle();
await submitInput(tester, 'clash-verge/v3.0.0');
expect(saved(), ['clash-verge/v3.0.0', defaultUserAgents.last]);
expect(selected(), 'clash-verge/v3.0.0');
});
testWidgets('deleting the selected value falls back to the default', (
tester,
) async {
await pumpView(tester, globalUa: defaultUserAgents.last);
await tester.tap(menuOf(defaultUserAgents.last));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
expect(saved(), [defaultUserAgents.first]);
expect(selected(), isNull);
expect(find.text(defaultUserAgents.last), findsNothing);
});
testWidgets('shows a selected value that is missing from the list', (
tester,
) async {
await pumpView(tester, globalUa: 'Legacy/1.0');
expect(find.text('Legacy/1.0'), findsOneWidget);
expect(saved(), defaultUserAgents);
await tester.tap(menuOf(defaultUserAgents.first));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
expect(saved(), [defaultUserAgents.last, 'Legacy/1.0']);
expect(selected(), 'Legacy/1.0');
});
}
+342 -30
View File
@@ -1,5 +1,7 @@
import 'dart:ui' as ui;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -7,6 +9,7 @@ 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';
final _viewSizeOverride = viewSizeProvider.overrideWithBuild(
@@ -57,6 +60,54 @@ void main() {
await tester.pump();
});
testWidgets('ListItem takes the grouped card inside a V3 section', (
tester,
) async {
bool? changedValue;
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Scaffold(
body: generateSectionV3(
items: [
ListItem.toggle(
title: const Text('Grouped'),
value: false,
onChanged: (value) {
changedValue = value;
},
),
ListItem.open(
title: const Text('Opens'),
widget: const SizedBox(),
),
],
),
),
),
);
ItemPosition positionOf(String text) => tester
.widget<ItemPositionProvider>(
find
.ancestor(
of: find.text(text),
matching: find.byType(ItemPositionProvider),
)
.first,
)
.position;
expect(find.byType(DecorationListItem), findsNWidgets(2));
expect(positionOf('Grouped'), ItemPosition.start);
expect(positionOf('Opens'), ItemPosition.end);
await tester.tap(find.text('Grouped'));
expect(changedValue, isTrue);
});
testWidgets('ListItem.checkbox toggles when tapping the row', (tester) async {
bool? changedValue;
@@ -118,7 +169,7 @@ void main() {
expect(changedValue, '12345');
});
testWidgets('ListInputPage reorders using final insertion index', (
testWidgets('ListEditView reorders using final insertion index', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 1000);
@@ -134,7 +185,7 @@ void main() {
(_, _) => const Size(1200, 1000),
),
],
child: const ListInputPage(
child: const ListEditView(
title: 'Items',
items: ['a', 'b', 'c'],
titleBuilder: _textBuilder,
@@ -154,6 +205,43 @@ void main() {
expect(_top(tester, 'c'), lessThan(_top(tester, 'a')));
});
testWidgets('ListItem.options keeps a null option apart from dismissal', (
tester,
) async {
final changes = <String?>[];
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Scaffold(
body: ListItem<String?>.options(
title: const Text('Language'),
dialogTitle: 'Language',
options: const [null, 'en'],
value: 'en',
textBuilder: (value) => value ?? 'Default',
onChanged: changes.add,
),
),
),
);
await tester.tap(find.text('Language'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(find.text('Default'), findsNothing);
expect(changes, isEmpty);
await tester.tap(find.text('Language'));
await tester.pumpAndSettle();
await tester.tap(find.text('Default'));
await tester.pumpAndSettle();
expect(changes, [null]);
});
testWidgets('OptionsDialog returns the tapped option', (tester) async {
String? selected;
@@ -189,6 +277,42 @@ void main() {
expect(selected, 'Two');
});
testWidgets('OptionsDialog cancel closes without a value', (tester) async {
String? selected = 'unset';
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Builder(
builder: (context) {
return FilledButton(
onPressed: () async {
selected = await showDialog<String>(
context: context,
builder: (_) => const OptionsDialog<String>(
title: 'Options',
options: ['One', 'Two'],
value: 'One',
textBuilder: _optionText,
),
);
},
child: const Text('Open'),
);
},
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(OptionsDialog<String>), findsNothing);
expect(selected, isNull);
});
testWidgets('InputDialog validates, submits, and resets values', (
tester,
) async {
@@ -239,7 +363,82 @@ void main() {
expect(result, 'default');
});
testWidgets('AddDialog returns scalar and map values', (tester) async {
testWidgets(
'NamedUrlDialog puts the optional name first and focuses the url',
(tester) async {
({String label, String url})? result;
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Builder(
builder: (context) {
return FilledButton(
onPressed: () async {
result = await showDialog<({String label, String url})>(
context: context,
builder: (_) => const NamedUrlDialog(title: 'Import'),
);
},
child: const Text('Open'),
);
},
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final nameField = find.widgetWithText(TextFormField, 'Name');
final urlField = find.widgetWithText(TextFormField, 'URL');
expect(
tester.getTopLeft(nameField).dy,
lessThan(tester.getTopLeft(urlField).dy),
);
expect(find.text('Optional'), findsOneWidget);
expect(
tester
.widget<EditableText>(
find.descendant(
of: urlField,
matching: find.byType(EditableText),
),
)
.focusNode
.hasFocus,
isTrue,
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(find.text('URL cannot be empty'), findsOneWidget);
expect(result, isNull);
await tester.enterText(nameField, 'Home');
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(
tester
.widget<EditableText>(
find.descendant(
of: urlField,
matching: find.byType(EditableText),
),
)
.focusNode
.hasFocus,
isTrue,
);
await tester.enterText(urlField, 'https://example.com/sub');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(result, (label: 'Home', url: 'https://example.com/sub'));
},
);
testWidgets('EntryDialog returns scalar and map values', (tester) async {
Object? result;
await tester.pumpWidget(
@@ -251,12 +450,13 @@ void main() {
children: [
FilledButton(
onPressed: () async {
result = await showDialog<String>(
result = await showDialog<List<String>>(
context: context,
builder: (_) => const AddDialog(
builder: (_) => const EntryDialog<String>(
title: 'Scalar',
valueField: Field(label: 'Value', value: ''),
valueMaxLength: 4,
toEntry: _scalarEntry,
),
);
},
@@ -264,15 +464,17 @@ void main() {
),
FilledButton(
onPressed: () async {
result = await showDialog<MapEntry<String, String>>(
result = await showDialog<List<MapEntry<String, String>>>(
context: context,
builder: (_) => const AddDialog(
title: 'Pair',
keyField: Field(label: 'Key', value: ''),
valueField: Field(label: 'Value', value: ''),
keyMaxLength: 3,
valueMaxLength: 4,
),
builder: (_) =>
const EntryDialog<MapEntry<String, String>>(
title: 'Pair',
keyField: Field(label: 'Key', value: ''),
valueField: Field(label: 'Value', value: ''),
keyMaxLength: 3,
valueMaxLength: 4,
toEntry: _pairEntry,
),
);
},
child: const Text('Pair'),
@@ -288,11 +490,11 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.pump();
expect(find.byType(AddDialog), findsOneWidget);
expect(find.byType(EntryDialog<String>), findsOneWidget);
await tester.enterText(find.byType(TextFormField), 'value');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(result, 'valu');
expect(result, ['valu']);
await tester.tap(find.text('Pair'));
await tester.pumpAndSettle();
@@ -301,11 +503,12 @@ void main() {
await tester.enterText(fields.last, 'value');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect((result! as MapEntry<String, String>).key, 'key');
expect((result! as MapEntry<String, String>).value, 'valu');
final pair = (result! as List<MapEntry<String, String>>).single;
expect(pair.key, 'key');
expect(pair.value, 'valu');
});
testWidgets('ListInputPage adds, edits, selects, and deletes items', (
testWidgets('ListEditView adds, edits, selects, and deletes items', (
tester,
) async {
await tester.pumpWidget(
@@ -316,7 +519,7 @@ void main() {
(_, _) => const Size(1200, 1000),
),
],
child: const ListInputPage(
child: const ListEditView(
title: 'Items',
items: ['a', 'b'],
titleBuilder: _textBuilder,
@@ -337,21 +540,20 @@ void main() {
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), 'x, y,x , toolong');
await tester.enterText(find.byType(TextFormField), 'x, y');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Value must be at most 4 characters'), findsOneWidget);
expect(find.text('Value must be a single item'), findsOneWidget);
await tester.enterText(find.byType(TextFormField), 'x, y,x , a');
await tester.enterText(find.byType(TextFormField), 'a');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Value already exists'), findsOneWidget);
await tester.enterText(find.byType(TextFormField), 'x, y,x ,');
await tester.enterText(find.byType(TextFormField), ' x ');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('x'), findsNWidgets(3));
expect(find.text('y'), findsNWidgets(3));
await tester.tap(find.text('c').first);
await tester.pumpAndSettle();
@@ -362,15 +564,15 @@ void main() {
await tester.tap(find.byType(Checkbox).first);
await tester.pump();
expect(find.byIcon(Icons.delete), findsOneWidget);
expect(find.byGlyph(AppGlyphs.delete), findsOneWidget);
await tester.tap(find.text('Select all'));
await tester.pump();
await tester.tap(find.byIcon(Icons.delete));
await tester.tap(find.byGlyph(AppGlyphs.delete));
await tester.pump();
expect(find.text('No data'), findsOneWidget);
});
testWidgets('MapInputPage adds, reorders, selects, and deletes entries', (
testWidgets('MapEditView adds, reorders, selects, and deletes entries', (
tester,
) async {
await tester.pumpWidget(
@@ -381,9 +583,9 @@ void main() {
(_, _) => const Size(1200, 1000),
),
],
child: const MapInputPage(
child: const MapEditView(
title: 'Map',
map: {'a': '1', 'b': '2'},
entries: {'a': '1', 'b': '2'},
titleBuilder: _entryTitle,
subtitleBuilder: _entrySubtitle,
leadingBuilder: _entryTitle,
@@ -417,11 +619,110 @@ void main() {
await tester.pump();
await tester.tap(find.text('Select all'));
await tester.pump();
await tester.tap(find.byIcon(Icons.delete));
await tester.tap(find.byGlyph(AppGlyphs.delete));
await tester.pump();
expect(find.text('No data'), findsOneWidget);
});
testWidgets(
'ListEditView batch add previews, blocks issues, skips existing',
(tester) async {
await tester.pumpWidget(
ProviderScope(
child: TestApp(
overrides: [
viewSizeProvider.overrideWithBuild(
(_, _) => const Size(1200, 1000),
),
],
child: const ListEditView(
title: 'Items',
items: ['a'],
titleBuilder: _textBuilder,
itemMaxLength: 4,
),
),
),
);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Batch add'));
await tester.pumpAndSettle();
expect(find.byTooltip('Single add'), findsOneWidget);
expect(_confirmButton(tester).onPressed, isNull);
expect(
find.text('One item per line, or separated by commas'),
findsOneWidget,
);
await tester.enterText(find.byType(TextField), 'toolong\nx');
await tester.pump();
expect(
find.text('Line 1: Value must be at most 4 characters'),
findsOneWidget,
);
expect(_confirmButton(tester).onPressed, isNull);
await tester.enterText(find.byType(TextField), 'a, x\ny');
await tester.pump();
expect(find.text('2 to add, 1 skipped as existing'), findsOneWidget);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.byType(EntryDialog<String>), findsNothing);
expect(find.text('a'), findsOneWidget);
expect(find.text('x'), findsOneWidget);
expect(find.text('y'), findsOneWidget);
},
);
testWidgets('MapEditView batch add parses lines and skips existing keys', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
child: TestApp(
overrides: [
viewSizeProvider.overrideWithBuild(
(_, _) => const Size(1200, 1000),
),
],
child: const MapEditView(
title: 'Map',
entries: {'a': '1'},
titleBuilder: _entryTitle,
subtitleBuilder: _entrySubtitle,
),
),
),
);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).first, 'c');
await tester.enterText(find.byType(TextFormField).last, '3');
await tester.tap(find.byTooltip('Batch add'));
await tester.pumpAndSettle();
expect(find.text('1 to add, 0 skipped as existing'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'c 3\na 9\nd');
await tester.pump();
expect(find.text('Line 3: Value cannot be empty'), findsOneWidget);
expect(_confirmButton(tester).onPressed, isNull);
await tester.enterText(
find.byType(TextField),
'geosite:cn tls://1.1.1.1:853\na 9',
);
await tester.pump();
expect(find.text('1 to add, 1 skipped as existing'), findsOneWidget);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('geosite:cn'), findsOneWidget);
expect(find.text('tls://1.1.1.1:853'), findsOneWidget);
expect(find.text('1'), findsOneWidget);
expect(find.text('9'), findsNothing);
});
test('NoInputBorder implements border geometry and interior painting', () {
const border = NoInputBorder();
const rect = Rect.fromLTWH(1, 2, 30, 40);
@@ -444,6 +745,11 @@ void main() {
String _optionText(String value) => value;
String _scalarEntry(String? key, String value) => value;
MapEntry<String, String> _pairEntry(String? key, String value) =>
MapEntry(key!, value);
Widget _textBuilder(String value) {
return Text(value);
}
@@ -452,6 +758,12 @@ Widget _entryTitle(MapEntry<String, String> value) => Text(value.key);
Widget _entrySubtitle(MapEntry<String, String> value) => Text(value.value);
TextButton _confirmButton(WidgetTester tester) {
return tester.widget<TextButton>(
find.ancestor(of: find.text('Confirm'), matching: find.byType(TextButton)),
);
}
double _top(WidgetTester tester, String text) {
return tester.getTopLeft(find.text(text)).dy;
}
+80 -4
View File
@@ -1,14 +1,20 @@
import 'dart:async';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/config/on_demand.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
class _TestExcludeSSIDs extends ExcludeSSIDs {
@@ -38,6 +44,7 @@ void main() {
WifiSsidPermission permission = WifiSsidPermission.denied,
bool isAndroid = false,
bool isMacOS = false,
bool isStart = false,
Locale? locale,
Size size = const Size(1400, 1000),
}) async {
@@ -52,6 +59,7 @@ void main() {
locationPermissionsProvider.overrideWith(
() => _TestLocationPermissions(permission),
),
isStartProvider.overrideWithValue(isStart),
],
);
addTearDown(container.dispose);
@@ -77,7 +85,7 @@ void main() {
expect(find.text('SSIDs are empty'), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
expect(find.text('Select all'), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
expect(find.byGlyph(AppGlyphs.delete), findsNothing);
});
testWidgets('every excluded SSID is rendered', (tester) async {
@@ -92,12 +100,12 @@ void main() {
tester,
) async {
await pumpView(tester, ssids: ['Home', 'Office']);
expect(find.byIcon(Icons.delete), findsNothing);
expect(find.byGlyph(AppGlyphs.delete), findsNothing);
await tester.tap(find.byType(CommonCheckBox).first);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete), findsOneWidget);
expect(find.byGlyph(AppGlyphs.delete), findsOneWidget);
expect(find.text('Select all'), findsOneWidget);
expect(find.text('Add'), findsNothing);
});
@@ -125,7 +133,7 @@ void main() {
await tester.tap(find.byType(CommonCheckBox).first);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete));
await tester.tap(find.byGlyph(AppGlyphs.delete));
await tester.pumpAndSettle();
expect(container.read(excludeSSIDsProvider), ['Office']);
@@ -159,6 +167,54 @@ void main() {
expect(find.bySemanticsLabel('Tap to authorize'), findsNothing);
});
testWidgets('a refused location request leaves the permission as it was', (
tester,
) async {
const channel = MethodChannel('wifi_ssid');
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
channel,
(_) async => throw PlatformException(code: 'IN_PROGRESS'),
);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
await pumpView(tester, isMacOS: true);
await tester.tap(find.text('Tap to authorize'));
await tester.pumpAndSettle();
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.denied,
);
expect(find.bySemanticsLabel('Tap to authorize'), findsOneWidget);
});
testWidgets('a second tap while the location request waits asks only once', (
tester,
) async {
const channel = MethodChannel('wifi_ssid');
final messenger = tester.binding.defaultBinaryMessenger;
final answer = Completer<Object?>();
var requests = 0;
messenger.setMockMethodCallHandler(channel, (_) {
requests++;
return answer.future;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
await pumpView(tester, isMacOS: true);
container.read(viewSizeProvider.notifier).value = const Size(1400, 1000);
await tester.tap(find.text('Tap to authorize'));
await tester.pump();
await tester.tap(find.text('Tap to authorize'));
await tester.pump();
answer.complete(WifiSsidPermission.permanentlyDenied.index);
await tester.pumpAndSettle();
expect(requests, 1);
expect(find.text('Location permission required'), findsOneWidget);
});
testWidgets('Android also asks to be left out of battery optimization', (
tester,
) async {
@@ -168,6 +224,26 @@ void main() {
expect(find.text('Location permission'), findsOneWidget);
});
testWidgets('while running the battery item explains instead of asking', (
tester,
) async {
await pumpView(tester, isAndroid: true, isStart: true);
container.read(viewSizeProvider.notifier).value = const Size(1400, 1000);
final appLocalizations = AppLocalizations.of(
tester.element(find.byType(OnDemandView)),
);
expect(find.bySemanticsLabel(appLocalizations.tapToAuthorize), findsOne);
await tester.tap(find.byTooltip(appLocalizations.tip));
await tester.pumpAndSettle();
expect(
find.text(appLocalizations.batteryOptimizationStatusTip),
findsOneWidget,
);
});
testWidgets('the authorize action sits on its own line under the text', (
tester,
) async {