import 'dart:convert'; import 'dart:io'; import 'package:fl_clash/common/app_localizations.dart'; import 'package:fl_clash/common/app_ports.dart'; import 'package:fl_clash/common/constant.dart'; import 'package:fl_clash/common/l10n_labels.dart'; import 'package:fl_clash/common/tray.dart'; import 'package:fl_clash/enum/enum.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/state.dart'; import 'package:fl_clash/state.dart'; import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; import 'package:riverpod/riverpod.dart'; import 'package:tray/tray.dart'; import '../helpers/app_paths.dart'; const _channel = MethodChannel('tray'); const _proxyGroup = Group( name: 'Proxy', type: GroupType.Selector, all: [ Proxy(name: 'A', type: 'Direct'), Proxy(name: 'B', type: 'Direct'), Proxy(name: 'C', type: 'Direct'), ], ); HotKeyAction _bind(HotAction action, PhysicalKeyboardKey key) { return HotKeyAction( action: action, key: key.usbHidUsage, modifiers: const {KeyboardModifier.control, KeyboardModifier.shift}, ); } TrayState _trayState({ bool isStart = false, bool tunEnable = false, bool systemProxy = false, bool autoLaunch = false, bool showTrayTitle = false, bool safeMode = false, Mode mode = Mode.rule, List groups = const [], Map hotKeys = const {}, }) { return TrayState( mode: mode, port: 7890, autoLaunch: autoLaunch, systemProxy: systemProxy, tunEnable: tunEnable, isStart: isStart, groups: groups, selectedMap: const {}, showTrayTitle: showTrayTitle, safeMode: safeMode, hotKeys: hotKeys, ); } List> _items(MethodCall? call) { final menu = (call?.arguments as Map?)?['menu'] as List?; return menu?.cast>() ?? const []; } Map _item(MethodCall? call, String label) { return _items(call).firstWhere((item) => item['label'] == label); } List _labels(MethodCall? call) { return _items(call).map((item) => item['label']).whereType().toList(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late List calls; late ProviderContainer container; late AppTray tray; late Directory root; setUpAll(() async { root = Directory.systemTemp.createTempSync('tray_menu_test'); await useAppPaths(root); await AppLocalizations.load(const Locale('en')); }); tearDownAll(() { // The shared system temp dir is not exclusively ours; another suite running // alongside this one can take the tree out from under the teardown, either // before the check or between the check and the delete. try { if (root.existsSync()) { root.deleteSync(recursive: true); } } on FileSystemException { return; } }); setUp(() { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; calls = []; tray = AppTray.forPlatform(isMacOS: true, isWindows: false); container = ProviderContainer(); Tray.instance.resetForTesting(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_channel, (call) async { calls.add(call); return true; }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_channel, null); container.dispose(); debugDefaultTargetPlatformOverride = null; }); MethodCall? showCall() { for (final call in calls.reversed) { if (call.method == 'show') { return call; } } return null; } Future update(TrayState trayState, {AppTray? on}) { return (on ?? tray).update( trayState: trayState, traffic: const Traffic(), read: container.read, ); } test( 'SystemAction.updateTray builds the menu without reading itself', () async { globalState.container = container; trayPort = tray; addTearDown(() => trayPort = null); await container.read(systemActionProvider.notifier).updateTray(); expect(showCall(), isNotNull); expect(_labels(showCall()), contains(currentAppLocalizations.exit)); }, ); test('builds the stopped menu without the running-only toggles', () async { await update(_trayState()); final labels = _labels(showCall()); final l10n = currentAppLocalizations; expect(labels, contains(l10n.show)); expect(labels, contains(l10n.start)); expect(labels, contains(l10n.autoLaunch)); expect(labels, contains(l10n.copyEnvVar)); expect(labels, contains(l10n.exit)); expect(labels, isNot(contains(l10n.tun))); expect(labels, isNot(contains(l10n.systemProxy))); }); test('adds TUN and system proxy toggles once the core is running', () async { await update(_trayState(isStart: true)); final labels = _labels(showCall()); final l10n = currentAppLocalizations; expect(labels, contains(l10n.stop), reason: 'start flips to stop'); expect(labels, contains(l10n.tun)); expect(labels, contains(l10n.systemProxy)); }); group('safe mode', () { test('keeps the full menu', () async { await update(_trayState(isStart: true, safeMode: true)); final labels = _labels(showCall()); final l10n = currentAppLocalizations; expect(labels, contains(l10n.stop)); expect(labels, contains(l10n.tun)); expect(labels, contains(l10n.systemProxy)); expect(labels, contains(l10n.copyEnvVar)); }); test('names the mode in the tooltip and ships the safe icon', () async { await update(_trayState(isStart: true, safeMode: true)); final arguments = showCall()!.arguments as Map; final l10n = currentAppLocalizations; expect(arguments['toolTip'], '$appName (${l10n.safeMode})'); final reps = ((arguments['icon'] as Map)['reps'] as List).cast(); final base = reps.firstWhere((rep) => rep['scale'] == 1.0); expect( base['bytes'], base64Encode( File('assets/images/tray/macos/status_4.png').readAsBytesSync(), ), ); }); }); test('offers every outbound mode as a menu entry', () async { await update(_trayState(mode: Mode.global)); final checkedModes = _items(showCall()) .where((item) => item['checked'] == true) .map((item) => item['label']) .toList(); expect(checkedModes, contains(Intl.message(Mode.global.name))); }); test('sends icon, tooltip and menu in a single show call', () async { await update(_trayState(isStart: true, tunEnable: true)); final showCalls = calls.where((call) => call.method == 'show').toList(); expect(showCalls, hasLength(1)); final arguments = showCalls.single.arguments as Map; expect(arguments['toolTip'], appName); expect(arguments['menu'], isNotEmpty); final reps = ((arguments['icon'] as Map)['reps'] as List).cast(); expect(reps.map((rep) => rep['scale']), containsAll([1.0, 2.0, 3.0, 4.0])); expect(reps.map((rep) => rep['bytes']), everyElement(isNotEmpty)); expect((arguments['icon'] as Map)['isTemplate'], isTrue); }); test('skips the platform call when the tray state is unchanged', () async { await update(_trayState(isStart: true)); await update(_trayState(isStart: true)); expect(calls.where((call) => call.method == 'show'), hasLength(1)); }); test('menu item ids are stable across identical rebuilds', () async { await update(_trayState()); final first = _items(showCall()).map((item) => item['id']).toList(); Tray.instance.resetForTesting(); calls.clear(); await update(_trayState()); final second = _items(showCall()).map((item) => item['id']).toList(); expect(second, first); expect(first.first, 1024); }); test('group submenus carry their proxies as nested items', () async { await update( _trayState( groups: [ const Group( name: 'Proxy', type: GroupType.Selector, all: [Proxy(name: 'A', type: 'Direct')], ), ], ), ); final submenu = _items( showCall(), ).firstWhere((item) => item['type'] == 'submenu'); expect(submenu['label'], 'Proxy'); final children = (submenu['items'] as List).cast>(); expect(children.map((item) => item['label']), contains('A')); }); test('shows each bound hotkey dimmed beside its entry', () async { await update( _trayState( isStart: true, hotKeys: { HotAction.view: _bind(HotAction.view, PhysicalKeyboardKey.keyV), HotAction.globalMode: _bind( HotAction.globalMode, PhysicalKeyboardKey.keyG, ), HotAction.tun: _bind(HotAction.tun, PhysicalKeyboardKey.keyT), }, ), ); final l10n = currentAppLocalizations; final call = showCall(); expect(_item(call, l10n.show)['detail'], '⌃⇧V'); expect(_item(call, Mode.global.label)['detail'], '⌃⇧G'); expect(_item(call, l10n.tun)['detail'], '⌃⇧T'); expect(_item(call, l10n.exit), isNot(contains('detail'))); expect(_item(call, Mode.rule.label), isNot(contains('detail'))); }); test( 'group submenus show the delay of the selection and of each proxy', () async { container.dispose(); container = ProviderContainer( overrides: [ selectedProxyNameProvider('Proxy').overrideWithValue('A'), trayDelaysProvider.overrideWithValue({ 'Proxy': {'A': 120, 'B': -1, 'C': -2}, }), ], ); const group = Group( name: 'Proxy', type: GroupType.Selector, all: [ Proxy(name: 'A', type: 'Direct'), Proxy(name: 'B', type: 'Direct'), Proxy(name: 'C', type: 'Direct'), Proxy(name: 'D', type: 'Direct'), ], ); await update(_trayState(groups: [group])); final submenu = _item(showCall(), 'Proxy'); expect(submenu['detail'], '120'); final children = (submenu['items'] as List).cast>(); expect(children.map((item) => item['detail']), [ '120', currentAppLocalizations.timeout, currentAppLocalizations.delayFailed, null, ]); }, ); test('a delay test entry follows the groups', () async { await update( _trayState( groups: [_proxyGroup], hotKeys: { HotAction.delayTest: _bind( HotAction.delayTest, PhysicalKeyboardKey.keyD, ), }, ), ); final items = _items(showCall()); final index = items.indexWhere((item) => item['type'] == 'submenu'); expect(items[index + 1]['label'], HotAction.delayTest.label); expect(items[index + 1]['detail'], '⌃⇧D'); }); group('a platform that is not macOS', () { late AppTray windows; setUp(() { windows = AppTray.forPlatform(isMacOS: false, isWindows: true); }); test('gets a plain icon, no group submenus and no speed toggle', () async { await update( _trayState( isStart: true, groups: [ const Group( name: 'Proxy', type: GroupType.Selector, all: [Proxy(name: 'A', type: 'Direct')], ), ], ), on: windows, ); final arguments = showCall()!.arguments as Map; expect((arguments['icon'] as Map)['isTemplate'], isFalse); expect( _items(showCall()).where((item) => item['type'] == 'submenu'), isEmpty, ); expect( _labels(showCall()), isNot(contains(currentAppLocalizations.speedStatistics)), ); expect(_labels(showCall()), isNot(contains(HotAction.delayTest.label))); }); test('joins modifiers with + in the hotkey hints', () async { await update( _trayState( hotKeys: { HotAction.view: _bind(HotAction.view, PhysicalKeyboardKey.keyV), }, ), on: windows, ); expect( _item(showCall(), currentAppLocalizations.show)['detail'], 'Ctrl+Shift+V', ); }); test('never pushes a tray title', () async { await windows.updateTitle(showTrayTitle: true, traffic: const Traffic()); expect(calls.where((call) => call.method == 'setTitle'), isEmpty); }); }); }