Fix macos performance issue

Support custom global-ua

Update core

Optimize some details
This commit is contained in:
chen08209
2026-05-27 15:16:51 +08:00
parent 5179d55dfb
commit 7e7f1f89f3
192 changed files with 4904 additions and 2910 deletions
+50
View File
@@ -0,0 +1,50 @@
import 'dart:typed_data';
import 'package:fl_clash/common/converter.dart';
import 'package:test/test.dart';
void main() {
group('Uint8ListToListIntConverter', () {
test('converts bytes to a growable int list copy', () {
final source = Uint8List.fromList([1, 2, 255]);
final result = uint8ListToListIntConverter.convert(source);
source[0] = 9;
result.add(3);
expect(result, [1, 2, 255, 3]);
});
test('forwards chunked conversions and closes target sink', () {
final sink = _CollectingSink();
final converterSink = uint8ListToListIntConverter.startChunkedConversion(
sink,
);
converterSink.add(Uint8List.fromList([1, 2]));
converterSink.add(Uint8List.fromList([3]));
converterSink.close();
expect(sink.values, [
[1, 2],
[3],
]);
expect(sink.isClosed, isTrue);
});
});
}
class _CollectingSink implements Sink<List<int>> {
final values = <List<int>>[];
var isClosed = false;
@override
void add(List<int> data) {
values.add(data);
}
@override
void close() {
isClosed = true;
}
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:fl_clash/common/datetime.dart';
import 'package:test/test.dart';
void main() {
group('DateTimeExtension', () {
test('detects dates before now', () {
expect(
DateTime.now().subtract(const Duration(milliseconds: 1)).isBeforeNow,
isTrue,
);
});
test('isBeforeSecure keeps null as false and non-null as true', () {
final dateTime = DateTime(2026, 5, 27);
expect(dateTime.isBeforeSecure(null), isFalse);
expect(dateTime.isBeforeSecure(DateTime(2026, 5, 26)), isTrue);
expect(dateTime.isBeforeSecure(DateTime(2026, 5, 28)), isTrue);
});
test('formats date, full datetime, and time slices', () {
final dateTime = DateTime(2026, 5, 27, 14, 3, 9);
expect(dateTime.show, '2026-05-27');
expect(dateTime.showFull, '2026-05-27 14:03:09');
expect(dateTime.showTime, ' 14:03:09');
});
});
}
+16 -12
View File
@@ -45,19 +45,23 @@ void main() {
expect(b.compareTo(a), greaterThan(0));
});
test('group=true sorts before group=false within same priority and delay',
() {
const a = DelayState(delay: 100, group: true);
const b = DelayState(delay: 100, group: false);
expect(a.compareTo(b), lessThan(0));
});
test(
'group=true sorts before group=false within same priority and delay',
() {
const a = DelayState(delay: 100, group: true);
const b = DelayState(delay: 100, group: false);
expect(a.compareTo(b), lessThan(0));
},
);
test('group=false sorts after group=true within same priority and delay',
() {
const a = DelayState(delay: 100, group: false);
const b = DelayState(delay: 100, group: true);
expect(a.compareTo(b), greaterThan(0));
});
test(
'group=false sorts after group=true within same priority and delay',
() {
const a = DelayState(delay: 100, group: false);
const b = DelayState(delay: 100, group: true);
expect(a.compareTo(b), greaterThan(0));
},
);
test('returns 0 for equal states', () {
const a = DelayState(delay: 100, group: true);
+22
View File
@@ -134,6 +134,28 @@ void main() {
});
});
group('ListExt.copyAndReorder', () {
test('uses final insertion index when moving an item forward', () {
final result = ['a', 'b', 'c', 'd'].copyAndReorder(1, 3);
expect(result, ['a', 'c', 'd', 'b']);
});
test('uses final insertion index when moving an item backward', () {
final result = ['a', 'b', 'c', 'd'].copyAndReorder(3, 1);
expect(result, ['a', 'd', 'b', 'c']);
});
test('does not mutate original list', () {
final list = ['a', 'b', 'c'];
final result = list.copyAndReorder(0, 2);
expect(list, ['a', 'b', 'c']);
expect(result, ['b', 'c', 'a']);
});
});
group('ListExt.safeSublist', () {
test('returns full list when start <= 0', () {
final result = [1, 2, 3].safeSublist(0);
+29
View File
@@ -0,0 +1,29 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:fl_clash/common/picker.dart';
import 'package:test/test.dart';
void main() {
group('PlatformFileExt.readBytes', () {
test('loads bytes from the picked file path', () async {
final directory = await Directory.systemTemp.createTemp(
'fl_clash_picker_test_',
);
addTearDown(() => directory.delete(recursive: true));
final file = File('${directory.path}/profile.yaml');
await file.writeAsString('mixed-port: 7890');
final platformFile = PlatformFile(
name: 'profile.yaml',
path: file.path,
size: await file.length(),
);
final bytes = await platformFile.readBytes();
expect(String.fromCharCodes(bytes), 'mixed-port: 7890');
});
});
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:fl_clash/common/protocol.dart';
import 'package:test/test.dart';
void main() {
group('ProtocolRegistrationPlan', () {
test('builds registry writes for URL protocol registration', () {
const plan = ProtocolRegistrationPlan(
scheme: 'flclash',
executable: r'C:\Program Files\FlClash\FlClash.exe',
);
expect(plan.protocolKey, r'Software\Classes\flclash');
expect(plan.commandKey, r'shell\open\command');
expect(plan.protocolValueName, 'URL Protocol');
expect(plan.protocolValue, '');
expect(plan.command, r'"C:\Program Files\FlClash\FlClash.exe" "%1"');
});
});
}
+2 -3
View File
@@ -246,9 +246,8 @@ void main() {
});
test('updateGeoData delegates', () async {
const params = UpdateGeoDataParams(geoType: 'mmdb', geoName: 'Country');
when(() => mock.updateGeoData(params)).thenAnswer((_) async => 'ok');
final result = await controller.updateGeoData(params);
when(() => mock.updateGeoData('MMDB')).thenAnswer((_) async => 'ok');
final result = await controller.updateGeoData('MMDB');
expect(result, 'ok');
});
+114
View File
@@ -0,0 +1,114 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
void main() {
group('GroupType', () {
test('parses clash group type aliases case-insensitively', () {
expect(GroupType.parse('url-test'), GroupType.URLTest);
expect(GroupType.parse('URLTEST'), GroupType.URLTest);
expect(GroupType.parse('selector'), GroupType.Selector);
expect(GroupType.parse('loadbalance'), GroupType.LoadBalance);
});
test('throws for unsupported group type', () {
expect(() => GroupType.parse('unknown'), throwsUnimplementedError);
});
});
group('GroupTypeExtension', () {
test('maps display names back to enum values', () {
expect(GroupTypeExtension.getGroupType('Selector'), GroupType.Selector);
expect(GroupTypeExtension.getGroupType('URLTest'), GroupType.URLTest);
expect(GroupTypeExtension.getGroupType('missing'), isNull);
});
test('marks only computed selection types', () {
expect(GroupType.URLTest.isComputedSelected, isTrue);
expect(GroupType.Fallback.isComputedSelected, isTrue);
expect(GroupType.Selector.isComputedSelected, isFalse);
});
});
group('UsedProxyExtension', () {
test('returns enum names as proxy values', () {
expect(UsedProxy.GLOBAL.value, 'GLOBAL');
expect(UsedProxy.DIRECT.value, 'DIRECT');
expect(UsedProxy.REJECT.value, 'REJECT');
});
});
group('KeyboardModifierExt', () {
test('maps keyboard modifiers to hotkey modifiers', () {
expect(KeyboardModifier.alt.toHotKeyModifier(), HotKeyModifier.alt);
expect(
KeyboardModifier.control.toHotKeyModifier(),
HotKeyModifier.control,
);
expect(KeyboardModifier.shift.toHotKeyModifier(), HotKeyModifier.shift);
});
test('keeps platform key variants for paired modifiers', () {
expect(KeyboardModifier.alt.physicalKeys, [
PhysicalKeyboardKey.altLeft,
PhysicalKeyboardKey.altRight,
]);
expect(KeyboardModifier.meta.physicalKeys, [
PhysicalKeyboardKey.metaLeft,
PhysicalKeyboardKey.metaRight,
]);
});
});
group('RuleAction', () {
test('excludes actions that cannot be manually added', () {
expect(RuleAction.addedRuleActions, isNot(contains(RuleAction.MATCH)));
expect(RuleAction.addedRuleActions, isNot(contains(RuleAction.RULE_SET)));
expect(RuleAction.addedRuleActions, isNot(contains(RuleAction.SUB_RULE)));
expect(RuleAction.addedRuleActions, contains(RuleAction.DOMAIN));
});
test('identifies actions with extra params', () {
expect(RuleAction.GEOIP.hasParams, isTrue);
expect(RuleAction.IP_CIDR.hasParams, isTrue);
expect(RuleAction.DOMAIN.hasParams, isFalse);
});
});
group('RuleTarget', () {
test('contains built-in target names', () {
expect(RuleTarget.baseTargets, {'DIRECT', 'REJECT'});
});
});
group('ItemPosition', () {
test('calculates simple list positions', () {
expect(ItemPosition.get(0, 1), ItemPosition.startAndEnd);
expect(ItemPosition.get(0, 3), ItemPosition.start);
expect(ItemPosition.get(1, 3), ItemPosition.middle);
expect(ItemPosition.get(2, 3), ItemPosition.end);
});
test('calculates visual positions after deleted items are skipped', () {
final items = ['a', 'b', 'c', 'd'];
final deletedItems = {'a', 'c'};
expect(
ItemPosition.calculateVisualPosition(1, items, deletedItems),
ItemPosition.start,
);
expect(
ItemPosition.calculateVisualPosition(3, items, deletedItems),
ItemPosition.end,
);
});
test('deleted current item has no visual position', () {
expect(
ItemPosition.calculateVisualPosition(1, ['a', 'b'], {'b'}),
ItemPosition.middle,
);
});
});
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:test/test.dart';
void main() {
group('PackagesExt', () {
const packages = [
Package(
packageName: 'system.app',
label: 'System',
system: true,
internet: true,
lastUpdateTime: 1,
),
Package(
packageName: 'user.old',
label: 'Alpha',
system: false,
internet: false,
lastUpdateTime: 2,
),
Package(
packageName: 'user.new',
label: 'Beta',
system: false,
internet: true,
lastUpdateTime: 3,
),
];
test('filters system and non-internet apps', () {
final result = packages.getViewList(
pinedList: [],
sortType: AccessSortType.none,
isFilterSystemApp: true,
isFilterNonInternetApp: true,
);
expect(result.map((item) => item.packageName), ['user.new']);
});
test('pins selected packages before sorted packages', () {
final result = packages.getViewList(
pinedList: ['user.old'],
sortType: AccessSortType.name,
isFilterSystemApp: false,
isFilterNonInternetApp: false,
);
expect(result.map((item) => item.packageName), [
'user.old',
'user.new',
'system.app',
]);
});
});
group('TrackerInfoExt', () {
test('builds destination description and process text', () {
final trackerInfo = TrackerInfo(
id: '1',
start: DateTime(2026),
metadata: const Metadata(
network: 'tcp',
host: 'example.com',
destinationIP: '1.1.1.1',
destinationPort: '443',
process: 'Browser',
uid: 501,
),
chains: const ['Proxy'],
rule: 'MATCH',
rulePayload: '',
);
expect(trackerInfo.desc, 'tcp://example.com/1.1.1.1:443');
expect(trackerInfo.progressText, 'Browser(501)');
});
});
group('TrafficExt', () {
test('formats speed, description, tray title, and total speed', () {
const traffic = Traffic(up: 1024, down: 2048);
expect(traffic.speedText, '↑ 1KB/s ↓ 2KB/s');
expect(traffic.desc, '1KB ↑ 2KB ↓');
expect(traffic.trayTitle, '1 KB/s\n2 KB/s');
expect(traffic.speed, 3072);
});
});
group('GroupsExt', () {
test('finds group by name and resolves current selection', () {
const groups = [
Group(name: 'Auto', type: GroupType.URLTest, now: 'Proxy A'),
Group(name: 'Manual', type: GroupType.Selector, now: 'Proxy B'),
];
expect(
groups.getGroup('Auto')?.getCurrentSelectedName('Proxy C'),
'Proxy A',
);
expect(
groups.getGroup('Manual')?.getCurrentSelectedName('Proxy C'),
'Proxy C',
);
expect(groups.getGroup('Missing'), isNull);
});
});
group('IpInfo parsers', () {
test('parse supported response shapes', () {
expect(
IpInfo.fromIpInfoIoJson({'ip': '1.1.1.1', 'country': 'US'}),
const IpInfo(ip: '1.1.1.1', countryCode: 'US'),
);
expect(
IpInfo.fromMyIpJson({'ip': '2.2.2.2', 'cc': 'JP'}),
const IpInfo(ip: '2.2.2.2', countryCode: 'JP'),
);
expect(
IpInfo.fromIpAPIJson({'query': '3.3.3.3', 'countryCode': 'CN'}),
const IpInfo(ip: '3.3.3.3', countryCode: 'CN'),
);
});
test('throw FormatException for unsupported response shapes', () {
expect(
() => IpInfo.fromIpInfoIoJson({'ip': '1.1.1.1'}),
throwsFormatException,
);
expect(
() => IpInfo.fromIpApiCoJson({'ip': '1.1.1.1'}),
throwsFormatException,
);
});
});
group('ResultExt', () {
test('identifies success and error results', () {
final success = Result.success('ok');
final error = Result<Object>.error('failed');
expect(success.isSuccess, isTrue);
expect(success.isError, isFalse);
expect(error.isSuccess, isFalse);
expect(error.isError, isTrue);
});
});
}
+111
View File
@@ -17,6 +17,79 @@ T roundTrip<T>(
}
void main() {
group('GeoResource JSON', () {
test('exposes lowercase GeoResource values', () {
expect(GeoResource.MMDB.value, 'mmdb');
expect(GeoResource.ASN.value, 'asn');
expect(GeoResource.GEOIP.value, 'geo-ip');
expect(GeoResource.GEOSITE.value, 'geo-site');
});
test('parses current lowercase GeoResource keys from config JSON', () {
final config = PatchClashConfig.fromJson({
'geox-url': {
'mmdb': 'https://example.com/mmdb',
'asn': 'https://example.com/asn.mmdb',
'geo-ip': 'https://example.com/geoip.dat',
'geo-site': 'https://example.com/geosite.dat',
},
});
expect(config.geoXUrl, {
GeoResource.MMDB: 'https://example.com/mmdb',
GeoResource.ASN: 'https://example.com/asn.mmdb',
GeoResource.GEOIP: 'https://example.com/geoip.dat',
GeoResource.GEOSITE: 'https://example.com/geosite.dat',
});
});
test('parses legacy GeoResource keys from config JSON', () {
final config = PatchClashConfig.fromJson({
'geox-url': {
'geoip': 'https://example.com/legacy-geoip.dat',
'geosite': 'https://example.com/legacy-geosite.dat',
},
});
expect(config.geoXUrl, {
GeoResource.GEOIP: 'https://example.com/legacy-geoip.dat',
GeoResource.GEOSITE: 'https://example.com/legacy-geosite.dat',
});
});
test('GeoXUrl defaults use GeoResource keys', () {
expect(defaultGeoXUrl.keys, GeoResource.values);
});
test('PatchClashConfig serializes geoXUrl map with lowercase keys', () {
final json = const PatchClashConfig(
geoXUrl: {GeoResource.GEOIP: 'https://example.com/geoip.dat'},
).toJson();
expect(json['geox-url'], {'geo-ip': 'https://example.com/geoip.dat'});
});
test('converts geoXUrl map to raw config map', () {
const geoXUrl = {
GeoResource.MMDB: 'https://example.com/mmdb',
GeoResource.GEOSITE: 'https://example.com/geosite.dat',
};
expect(geoXUrl.raw, {
'mmdb': 'https://example.com/mmdb',
'geo-site': 'https://example.com/geosite.dat',
});
});
test('PatchClashConfig parses geoXUrl map with GeoResource keys', () {
final config = PatchClashConfig.fromJson({
'geox-url': {'mmdb': 'https://example.com/mmdb'},
});
expect(config.geoXUrl, {GeoResource.MMDB: 'https://example.com/mmdb'});
});
});
group('AppSettingProps JSON round-trip', () {
test('default values survive round-trip', () {
const props = AppSettingProps();
@@ -35,6 +108,7 @@ void main() {
expect(restored.showLabel, false);
expect(restored.minimizeOnExit, true);
expect(restored.restoreStrategy, RestoreStrategy.compatible);
expect(restored.customUserAgent, '');
expect(restored.testUrl, defaultTestUrl);
});
@@ -45,6 +119,7 @@ void main() {
autoLaunch: true,
closeConnections: false,
testUrl: 'https://custom.test',
customUserAgent: 'CustomUA/1.0',
);
final restored = roundTrip(
() => props.toJson(),
@@ -55,6 +130,7 @@ void main() {
expect(restored.autoLaunch, true);
expect(restored.closeConnections, false);
expect(restored.testUrl, 'https://custom.test');
expect(restored.customUserAgent, 'CustomUA/1.0');
});
test('safeFromJson returns default on null', () {
@@ -162,6 +238,41 @@ void main() {
});
});
group('PatchClashConfig JSON round-trip', () {
test('defaults match Clash patch defaults', () {
const config = PatchClashConfig();
expect(config.mixedPort, defaultMixedPort);
expect(config.allowLan, false);
expect(config.mode, Mode.rule);
expect(config.externalController, ExternalControllerStatus.close);
expect(config.geodataLoader, GeodataLoader.memconservative);
});
test('custom values survive round-trip', () {
const config = PatchClashConfig(
mixedPort: 7890,
allowLan: true,
mode: Mode.rule,
logLevel: LogLevel.debug,
externalController: ExternalControllerStatus.open,
geodataLoader: GeodataLoader.memconservative,
);
final restored = roundTrip(
() => config.toJson(),
PatchClashConfig.fromJson,
);
expect(restored.mixedPort, 7890);
expect(restored.allowLan, true);
expect(restored.mode, Mode.rule);
expect(restored.logLevel, LogLevel.debug);
expect(restored.externalController, ExternalControllerStatus.open);
expect(restored.geodataLoader, GeodataLoader.memconservative);
});
});
group('ProxiesStyleProps JSON round-trip', () {
test('default values', () {
const props = ProxiesStyleProps();
+107
View File
@@ -0,0 +1,107 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:test/test.dart';
void main() {
group('SubscriptionInfo', () {
test('parses subscription-userinfo header values', () {
final info = SubscriptionInfo.formHString(
'upload=10; download=20; total=100; expire=200',
);
expect(info.upload, 10);
expect(info.download, 20);
expect(info.total, 100);
expect(info.expire, 200);
});
test('falls back to zero for null and invalid values', () {
expect(SubscriptionInfo.formHString(null), const SubscriptionInfo());
final info = SubscriptionInfo.formHString(
'upload=bad; download=20; total=; expire=abc',
);
expect(info.upload, 0);
expect(info.download, 20);
expect(info.total, 0);
expect(info.expire, 0);
});
});
group('ProfileExtension', () {
test('derives type, label, filename, and updating key', () {
const fileProfile = Profile(
id: 7,
autoUpdateDuration: defaultUpdateDuration,
);
const urlProfile = Profile(
id: 8,
label: 'Remote',
url: 'https://example.com/profile.yaml',
autoUpdate: true,
autoUpdateDuration: defaultUpdateDuration,
);
expect(fileProfile.type, ProfileType.file);
expect(fileProfile.realAutoUpdate, false);
expect(fileProfile.realLabel, '7');
expect(fileProfile.fileName, '7.yaml');
expect(fileProfile.updatingKey, 'profile_7');
expect(urlProfile.type, ProfileType.url);
expect(urlProfile.realAutoUpdate, true);
expect(urlProfile.realLabel, 'Remote');
});
});
group('ProfilesExt', () {
test('gets profile by id', () {
const profiles = [
Profile(id: 1, label: 'A', autoUpdateDuration: defaultUpdateDuration),
Profile(id: 2, label: 'B', autoUpdateDuration: defaultUpdateDuration),
];
expect(profiles.getProfile(2)?.label, 'B');
expect(profiles.getProfile(3), isNull);
expect(profiles.getProfile(null), isNull);
});
test('optimizes duplicate labels with incremented suffix', () {
const profiles = [
Profile(
id: 1,
label: 'Work',
autoUpdateDuration: defaultUpdateDuration,
),
Profile(
id: 2,
label: 'Work(1)',
autoUpdateDuration: defaultUpdateDuration,
),
];
const newProfile = Profile(
id: 3,
label: 'Work',
autoUpdateDuration: defaultUpdateDuration,
);
expect(profiles.optimizeLabel(newProfile).label, 'Work(2)');
});
});
group('ProfileRuleLinkExt', () {
test('builds stable key from non-null parts', () {
const link = ProfileRuleLink(
profileId: 1,
ruleId: 2,
scene: RuleScene.added,
);
const globalLink = ProfileRuleLink(ruleId: 3);
expect(link.key, '1_2_added');
expect(globalLink.key, '3');
});
});
}
+25
View File
@@ -1,5 +1,7 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -36,6 +38,29 @@ void main() {
expect(profile?.url, edited.url);
});
});
group('GeoResourceAction', () {
test('GeoResource has correct updatingKey', () {
expect(GeoResource.MMDB.updatingKey, 'geo_resource_MMDB');
expect(GeoResource.ASN.updatingKey, 'geo_resource_ASN');
expect(GeoResource.GEOIP.updatingKey, 'geo_resource_GEOIP');
expect(GeoResource.GEOSITE.updatingKey, 'geo_resource_GEOSITE');
});
test('IsUpdating provider works with geo resource key', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final key = GeoResource.MMDB.updatingKey;
expect(container.read(isUpdatingProvider(key)), false);
container.read(isUpdatingProvider(key).notifier).value = true;
expect(container.read(isUpdatingProvider(key)), true);
container.read(isUpdatingProvider(key).notifier).value = false;
expect(container.read(isUpdatingProvider(key)), false);
});
});
}
class _TestProfiles extends Profiles {
+107 -5
View File
@@ -52,6 +52,42 @@ void main() {
});
});
group('Providers provider', () {
test('setProvider replaces provider with matching name', () {
final oldProvider = ExternalProvider(
name: 'Proxy',
type: 'Proxy',
count: 1,
vehicleType: 'HTTP',
updateAt: DateTime(2026),
);
final newProvider = oldProvider.copyWith(count: 2);
container.read(providersProvider.notifier).update((_) => [oldProvider]);
container.read(providersProvider.notifier).setProvider(newProvider);
expect(container.read(providersProvider).single.count, 2);
});
test('setProvider ignores null and missing provider names', () {
final provider = ExternalProvider(
name: 'Proxy',
type: 'Proxy',
count: 1,
vehicleType: 'HTTP',
updateAt: DateTime(2026),
);
container.read(providersProvider.notifier).update((_) => [provider]);
container.read(providersProvider.notifier).setProvider(null);
container
.read(providersProvider.notifier)
.setProvider(provider.copyWith(name: 'Other', count: 9));
expect(container.read(providersProvider).single, provider);
});
});
group('SystemBrightness provider', () {
test('default is dark', () {
expect(container.read(systemBrightnessProvider), Brightness.dark);
@@ -176,9 +212,12 @@ void main() {
expect(container.read(backBlockProvider), false);
});
test('can update', () {
container.read(backBlockProvider.notifier).update((_) => true);
test('can block and unblock back navigation', () {
container.read(backBlockProvider.notifier).backBlock();
expect(container.read(backBlockProvider), true);
container.read(backBlockProvider.notifier).unBackBlock();
expect(container.read(backBlockProvider), false);
});
});
@@ -221,9 +260,72 @@ void main() {
expect(container.read(checkIpNumProvider), 0);
});
test('increment works', () {
container.read(checkIpNumProvider.notifier).update((_) => 3);
expect(container.read(checkIpNumProvider), 3);
test('increment returns previous value and updates state', () {
final value = container.read(checkIpNumProvider.notifier).add();
expect(value, 0);
expect(container.read(checkIpNumProvider), 1);
});
});
group('SortNum provider', () {
test('increment returns previous value and updates state', () {
final value = container.read(sortNumProvider.notifier).add();
expect(value, 0);
expect(container.read(sortNumProvider), 1);
});
});
group('DelayDataSource provider', () {
test('sets delay by url and proxy name', () {
container
.read(delayDataSourceProvider.notifier)
.setDelay(
const Delay(name: 'Proxy', url: 'https://test.example', value: 120),
);
expect(container.read(delayDataSourceProvider), {
'https://test.example': {'Proxy': 120},
});
});
test('keeps same state instance when delay value is unchanged', () {
final notifier = container.read(delayDataSourceProvider.notifier);
const delay = Delay(name: 'Proxy', url: 'https://test.example', value: 1);
notifier.setDelay(delay);
final state = container.read(delayDataSourceProvider);
notifier.setDelay(delay);
expect(identical(container.read(delayDataSourceProvider), state), isTrue);
});
});
group('Loading provider', () {
test('stop without start sets loading false immediately', () async {
final notifier = container.read(
loadingProvider(LoadingTag.profiles).notifier,
);
await notifier.stop();
expect(container.read(loadingProvider(LoadingTag.profiles)), false);
});
test('stop keeps loading visible for minimum duration', () async {
final notifier = container.read(
loadingProvider(LoadingTag.profiles).notifier,
);
notifier.start();
await notifier.stop();
expect(container.read(loadingProvider(LoadingTag.profiles)), true);
await Future.delayed(const Duration(milliseconds: 1100));
expect(container.read(loadingProvider(LoadingTag.profiles)), false);
});
});
+80 -20
View File
@@ -1,3 +1,4 @@
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/config.dart';
@@ -25,9 +26,9 @@ void main() {
});
test('can update state', () {
container.read(appSettingProvider.notifier).update(
(_) => const AppSettingProps(autoLaunch: true),
);
container
.read(appSettingProvider.notifier)
.update((_) => const AppSettingProps(autoLaunch: true));
final value = container.read(appSettingProvider);
expect(value.autoLaunch, true);
});
@@ -41,9 +42,9 @@ void main() {
});
test('can update state', () {
container.read(windowSettingProvider.notifier).update(
(_) => const WindowProps(width: 1024, height: 768),
);
container
.read(windowSettingProvider.notifier)
.update((_) => const WindowProps(width: 1024, height: 768));
final value = container.read(windowSettingProvider);
expect(value.width, 1024);
expect(value.height, 768);
@@ -58,9 +59,9 @@ void main() {
});
test('can update state', () {
container.read(vpnSettingProvider.notifier).update(
(_) => const VpnProps(enable: false),
);
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: false));
expect(container.read(vpnSettingProvider).enable, false);
});
});
@@ -73,9 +74,9 @@ void main() {
});
test('can update state', () {
container.read(networkSettingProvider.notifier).update(
(_) => const NetworkProps(systemProxy: false),
);
container
.read(networkSettingProvider.notifier)
.update((_) => const NetworkProps(systemProxy: false));
expect(container.read(networkSettingProvider).systemProxy, false);
});
});
@@ -87,13 +88,10 @@ void main() {
});
test('can update state', () {
container.read(themeSettingProvider.notifier).update(
(_) => const ThemeProps(primaryColor: 0xFF123456),
);
expect(
container.read(themeSettingProvider).primaryColor,
0xFF123456,
);
container
.read(themeSettingProvider.notifier)
.update((_) => const ThemeProps(primaryColor: 0xFF123456));
expect(container.read(themeSettingProvider).primaryColor, 0xFF123456);
});
});
@@ -112,6 +110,18 @@ void main() {
test('default is null', () {
expect(container.read(davSettingProvider), null);
});
test('can update WebDAV settings', () {
const davProps = DAVProps(
uri: 'https://dav.example.com',
user: 'user',
password: 'password',
);
container.read(davSettingProvider.notifier).update((_) => davProps);
expect(container.read(davSettingProvider), davProps);
});
});
group('OverrideDns provider', () {
@@ -125,10 +135,43 @@ void main() {
});
});
group('ExcludeSSIDs provider', () {
test('reorders with final insertion index semantics', () {
container
.read(excludeSSIDsProvider.notifier)
.update((_) => ['Home', 'Office', 'Cafe', 'Hotel']);
container.read(excludeSSIDsProvider.notifier).update((value) {
return value.copyAndReorder(1, 3);
});
expect(container.read(excludeSSIDsProvider), [
'Home',
'Cafe',
'Hotel',
'Office',
]);
});
});
group('HotKeyActions provider', () {
test('default is empty list', () {
expect(container.read(hotKeyActionsProvider), isEmpty);
});
test('can update hotkey actions', () {
const actions = [
HotKeyAction(
action: HotAction.start,
key: 1,
modifiers: {KeyboardModifier.control},
),
];
container.read(hotKeyActionsProvider.notifier).update((_) => actions);
expect(container.read(hotKeyActionsProvider), actions);
});
});
group('ProxiesStyleSetting provider', () {
@@ -138,7 +181,9 @@ void main() {
});
test('can update state', () {
container.read(proxiesStyleSettingProvider.notifier).update(
container
.read(proxiesStyleSettingProvider.notifier)
.update(
(_) => const ProxiesStyleProps(sortType: ProxiesSortType.delay),
);
expect(
@@ -158,15 +203,25 @@ void main() {
expect(config.currentProfileId, null);
expect(config.overrideDns, false);
expect(config.hotKeyActions, isEmpty);
expect(config.patchClashConfig, const PatchClashConfig());
expect(config.excludeSSIDs, isEmpty);
});
test('reflects updated sub-provider values', () {
container.read(currentProfileIdProvider.notifier).update((_) => 99);
container.read(overrideDnsProvider.notifier).update((_) => true);
container
.read(patchClashConfigProvider.notifier)
.update((_) => const PatchClashConfig(mixedPort: 7890));
container
.read(excludeSSIDsProvider.notifier)
.update((_) => ['Office Wi-Fi']);
final config = container.read(configProvider);
expect(config.currentProfileId, 99);
expect(config.overrideDns, true);
expect(config.patchClashConfig.mixedPort, 7890);
expect(config.excludeSSIDs, ['Office Wi-Fi']);
});
});
@@ -185,6 +240,11 @@ void main() {
expect(overrideContainer.read(currentProfileIdProvider), 7);
expect(overrideContainer.read(overrideDnsProvider), true);
expect(
overrideContainer.read(patchClashConfigProvider),
config.patchClashConfig,
);
expect(overrideContainer.read(excludeSSIDsProvider), config.excludeSSIDs);
expect(
overrideContainer.read(appSettingProvider).onlyStatisticsProxy,
false,
+111
View File
@@ -0,0 +1,111 @@
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('ListItem.input limits dialog text by maxLength', (tester) async {
String? changedValue;
await tester.pumpWidget(
ProviderScope(
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 1000)),
],
child: _TestApp(
child: Scaffold(
body: ListItem.input(
title: const Text('Port'),
delegate: InputDelegate(
title: 'Port',
value: '',
maxLength: 5,
onChanged: (value) {
changedValue = value;
},
),
),
),
),
),
);
await tester.tap(find.text('Port'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), '123456789');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(changedValue, '12345');
});
testWidgets('ListInputPage reorders using final insertion index', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
const ProviderScope(
child: _TestApp(
child: ListInputPage(
title: 'Items',
items: ['a', 'b', 'c'],
titleBuilder: _textBuilder,
),
),
),
);
final listView = tester.widget<ReorderableListView>(
find.byType(ReorderableListView),
);
listView.onReorderItem!(0, 2);
await tester.pump();
expect(_top(tester, 'b'), lessThan(_top(tester, 'c')));
expect(_top(tester, 'c'), lessThan(_top(tester, 'a')));
});
}
Widget _textBuilder(String value) {
return Text(value);
}
double _top(WidgetTester tester, String text) {
return tester.getTopLeft(find.text(text)).dy;
}
class _TestApp extends StatelessWidget {
final Widget child;
const _TestApp({required this.child});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (context, child) {
globalState.theme = CommonTheme.of(context, 1);
return child!;
},
home: child,
);
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:fl_clash/widgets/activate_box.dart';
import 'package:fl_clash/widgets/builder.dart';
import 'package:fl_clash/widgets/disabled_mask.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('ActivateBox blocks pointer events when inactive', (
tester,
) async {
var taps = 0;
await tester.pumpWidget(
MaterialApp(
home: ActivateBox(
active: false,
child: TextButton(onPressed: () => taps++, child: const Text('tap')),
),
),
);
await tester.tap(find.text('tap'), warnIfMissed: false);
expect(taps, 0);
});
testWidgets('ActivateBox allows pointer events when active', (tester) async {
var taps = 0;
await tester.pumpWidget(
MaterialApp(
home: ActivateBox(
active: true,
child: TextButton(onPressed: () => taps++, child: const Text('tap')),
),
),
);
await tester.tap(find.text('tap'));
expect(taps, 1);
});
testWidgets('DisabledMask applies filter only when status is true', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: DisabledMask(status: true, child: Text('disabled')),
),
);
expect(find.byType(ColorFiltered), findsOneWidget);
await tester.pumpWidget(
const MaterialApp(
home: DisabledMask(status: false, child: Text('enabled')),
),
);
expect(find.byType(ColorFiltered), findsNothing);
});
testWidgets('FloatingActionButtonExtendedBuilder defaults to extended', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: FloatingActionButtonExtendedBuilder(
builder: (isExtended) => Text('extended: $isExtended'),
),
),
);
expect(find.text('extended: true'), findsOneWidget);
});
testWidgets('FloatingActionButtonExtendedBuilder reads inherited state', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: CommonScaffoldFabExtendedProvider(
isExtended: false,
child: FloatingActionButtonExtendedBuilder(
builder: (isExtended) => Text('extended: $isExtended'),
),
),
),
);
expect(find.text('extended: false'), findsOneWidget);
});
}