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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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});
|
||||
});
|
||||
}
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user