Fix macos performance issue
Support custom global-ua Update core Optimize some details
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user