Merge remote-tracking branch 'origin/main' into codex/fix-bt-file-selection

# Conflicts:
#	ui/flutter/test/features/tasks/domain/task_record_test.dart
This commit is contained in:
Levi committed 2026-09-17 00:03:36 +08:00
commit a83e3a2b84
272 files changed
+17212 -1065

No files matched your search

@@ -0,0 +1,139 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/app/application/app_notification_controller.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/core/common/task_event.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
final calls = <MethodCall>[];
var permissionGranted = true;
late StreamController<TaskEvent> events;
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
MacOSFlutterLocalNotificationsPlugin.registerWith();
calls.clear();
permissionGranted = true;
events = StreamController<TaskEvent>.broadcast();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return permissionGranted;
});
});
tearDown(() {
unawaited(events.close());
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
});
Future<ProviderContainer> start({required bool enabled}) async {
final container = ProviderContainer(
overrides: [
appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)),
appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)),
],
);
addTearDown(container.dispose);
await container.read(appRuntimeControllerProvider.future);
await container.read(appNotificationControllerProvider.future);
return container;
}
Map<dynamic, dynamic> initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map;
test('macOS requests alerts and sound when download notifications are enabled at startup', () async {
await start(enabled: true);
expect(initialization()['requestAlertPermission'], isTrue);
expect(initialization()['requestSoundPermission'], isTrue);
expect(initialization()['requestBadgePermission'], isFalse);
});
test('disabled notifications defer authorization until the setting is enabled', () async {
final container = await start(enabled: false);
expect(initialization()['requestAlertPermission'], isFalse);
expect(initialization()['requestSoundPermission'], isFalse);
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
runtime.setNotificationsEnabled(true);
await container.pump();
await container.read(appNotificationControllerProvider.future);
expect(initialization()['requestAlertPermission'], isTrue);
expect(initialization()['requestSoundPermission'], isTrue);
runtime.setNotificationsEnabled(false);
await container.pump();
await container.read(appNotificationControllerProvider.future);
expect(initialization()['requestAlertPermission'], isFalse);
expect(initialization()['requestSoundPermission'], isFalse);
});
test('denied macOS authorization does not fail notification controller startup', () async {
permissionGranted = false;
final container = await start(enabled: true);
expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue);
});
test('terminal task events notify once after enabling and stop after disabling', () async {
final container = await start(enabled: false);
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip');
events.add(done);
await Future<void>.delayed(Duration.zero);
expect(calls.where((call) => call.method == 'show'), isEmpty);
runtime.setNotificationsEnabled(true);
await container.pump();
await container.read(appNotificationControllerProvider.future);
events.add(done);
events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip'));
await Future<void>.delayed(Duration.zero);
final notifications = calls.where((call) => call.method == 'show').toList();
expect(notifications, hasLength(2));
expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']);
runtime.setNotificationsEnabled(false);
await container.pump();
await container.read(appNotificationControllerProvider.future);
events.add(done);
await Future<void>.delayed(Duration.zero);
expect(calls.where((call) => call.method == 'show'), hasLength(2));
});
}
class _TestNotificationController extends AppNotificationController {
_TestNotificationController(this.taskEvents);
@override
final Stream<TaskEvent> taskEvents;
}
class _TestRuntimeController extends AppRuntimeController {
_TestRuntimeController(this.enabled);
final bool enabled;
@override
Future<AppRuntimeState> build() async => _runtime(enabled);
void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled));
AppRuntimeState _runtime(bool enabled) => AppRuntimeState(
startConfig: StartConfig(),
apiServerState: ApiServerState.fromJson({}),
downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled,
);
}
@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/app/application/app_platform_controller.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/util/updater.dart';
void main() {
test('runtime updates do not rebuild the platform controller', () async {
final container = ProviderContainer(
overrides: [
appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
appPlatformControllerProvider.overrideWith(_TestPlatformController.new),
],
);
addTearDown(container.dispose);
final subscription = container.listen(appPlatformControllerProvider, (_, _) {}, fireImmediately: true);
addTearDown(subscription.close);
await container.read(appPlatformControllerProvider.future);
final platformController = container.read(appPlatformControllerProvider.notifier) as _TestPlatformController;
final runtimeController = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
expect(platformController.buildCount, 1);
runtimeController.completeRuntime();
await container.read(appRuntimeControllerProvider.future);
await Future<void>.delayed(Duration.zero);
expect(platformController.updateCheckCount, 1);
final config = DownloaderConfig.fromJson(runtimeController.state.requireValue.downloaderConfig.toJson());
config.extra.locale = 'zh';
runtimeController.replaceDownloaderConfig(config);
await container.pump();
expect(platformController.buildCount, 1);
});
}
class _TestPlatformController extends AppPlatformController {
int buildCount = 0;
int updateCheckCount = 0;
@override
bool get isDesktop => false;
@override
Future<AppPlatformState> build() {
buildCount++;
return super.build();
}
@override
Future<VersionInfo?> checkForUpdate() async {
updateCheckCount++;
return null;
}
}
class _TestRuntimeController extends AppRuntimeController {
final _pendingRuntime = Completer<AppRuntimeState>();
@override
Future<AppRuntimeState> build() => _pendingRuntime.future;
void completeRuntime() {
_pendingRuntime.complete(
AppRuntimeState(
startConfig: StartConfig(),
apiServerState: const ApiServerState(
enabled: false,
mcpEnabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
),
downloaderConfig: DownloaderConfig(),
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/app/router/app_router.dart';
import 'package:gopeed/features/auth/application/web_auth_controller.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final platform in TargetPlatform.values) {
test('$platform preserves the expected section route stacks', () {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final auth = WebAuthController();
final router = AppRouter.build(auth);
addTearDown(router.dispose);
addTearDown(auth.dispose);
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
for (final entry in {
'/': ['/'],
'/extensions': isMobile ? ['/', 'extensions'] : ['/extensions'],
'/extensions/example': isMobile ? ['/', 'extensions', ':id'] : ['/extensions', ':id'],
'/settings': isMobile ? ['/', 'settings'] : ['/settings'],
'/settings/general': isMobile ? ['/', 'settings', ':section'] : ['/settings', ':section'],
'/tasks/example': ['/', 'tasks/:id'],
'/create': ['/', 'create'],
}.entries) {
final match = router.configuration.findMatch(Uri.parse(entry.key));
expect(match.isError, isFalse);
final shell = match.matches.single as ShellRouteMatch;
expect(shell.matches.map((match) => (match.route as GoRoute).path), entry.value);
}
});
}
}
@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/app/router/app_router.dart';
import 'package:gopeed/features/auth/application/web_auth_controller.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_toast.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
class _StartingRuntime extends AppRuntimeController {
final pending = Completer<AppRuntimeState>();
@override
Future<AppRuntimeState> build() => pending.future;
}
void main() {
testWidgets(
'cold startup registers Android back and confirms exit before runtime is ready',
(tester) async {
final platformCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
platformCalls.add(call);
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
final runtime = _StartingRuntime();
final auth = WebAuthController();
final router = AppRouter.build(auth);
addTearDown(router.dispose);
addTearDown(auth.dispose);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(
ProviderScope(
overrides: [appRuntimeControllerProvider.overrideWith(() => runtime)],
child: shad.ShadcnApp.router(
theme: AppTheme.dark(),
materialTheme: AppTheme.materialDark(),
routerConfig: router,
),
),
);
await tester.pump();
await tester.pump();
expect(find.byType(shad.CircularProgressIndicator), findsOneWidget);
expect(
platformCalls.where((call) => call.method == 'SystemNavigator.setFrameworkHandlesBack').last.arguments,
isTrue,
);
await tester.binding.handlePopRoute();
await tester.pump();
expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pump();
expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), hasLength(1));
await tester.pumpWidget(const SizedBox());
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
}
@@ -0,0 +1,161 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/app/router/mobile_exit_guard.dart';
import 'package:gopeed/shared/navigation/app_exit_confirmation_controller.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_toast.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
late List<MethodCall> exits;
late GoRouter router;
setUp(() {
exits = [];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'SystemNavigator.pop') exits.add(call);
return null;
},
);
router = GoRouter(
routes: [
ShellRoute(
builder: (_, _, child) => MobileExitGuard(child: child),
routes: [
GoRoute(
path: '/',
builder: (_, _) => const SizedBox.expand(),
routes: [
GoRoute(path: 'details', builder: (_, _) => const SizedBox.expand()),
for (final path in ['extensions', 'settings'])
GoRoute(path: path, builder: (_, _) => const SizedBox.expand()),
],
),
],
),
],
);
});
tearDown(() {
router.dispose();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
Future<void> mount(WidgetTester tester) async {
await tester.pumpWidget(
shad.ShadcnApp.router(theme: AppTheme.dark(), materialTheme: AppTheme.materialDark(), routerConfig: router),
);
await tester.pumpAndSettle();
}
testWidgets('root back shows bottom prompt, second back exits', (tester) async {
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
expect(tester.getCenter(find.byType(AppToastContent)).dx, 400);
expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, 576);
await tester.pump(const Duration(milliseconds: 1900));
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, hasLength(1));
expect(find.byType(AppToastContent), findsNothing);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('expired prompt requires a fresh pair of back actions', (tester) async {
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
await tester.pump(AppExitConfirmationController.confirmationWindow);
expect(find.byType(AppToastContent), findsNothing);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
await tester.pumpWidget(const SizedBox());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('nested page pops without prompting or exiting', (tester) async {
await mount(tester);
router.push('/details');
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(router.routeInformationProvider.value.uri.path, '/');
expect(find.byType(AppToastContent), findsNothing);
expect(exits, isEmpty);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('backgrounding clears the exit confirmation', (tester) async {
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(find.byType(AppToastContent), findsNothing);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
await tester.pumpWidget(const SizedBox());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
for (final path in ['/extensions', '/settings']) {
testWidgets('$path returns to tasks before confirming exit', (tester) async {
await mount(tester);
router.go(path);
await tester.pumpAndSettle();
expect(router.canPop(), isTrue);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(router.routeInformationProvider.value.uri.path, '/');
expect(find.byType(AppToastContent), findsNothing);
expect(exits, isEmpty);
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byType(AppToastContent), findsOneWidget);
expect(exits, isEmpty);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, hasLength(1));
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
}
testWidgets(
'dialog back dismisses the dialog before exit confirmation',
(tester) async {
await mount(tester);
final context = router.routerDelegate.navigatorKey.currentContext!;
showGeneralDialog<void>(
context: context,
pageBuilder: (_, _, _) => const Center(child: Text('Dialog content')),
);
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Dialog content'), findsNothing);
expect(find.byType(AppToastContent), findsNothing);
expect(exits, isEmpty);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets('bottom prompt stays above the Android safe area', (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 48);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetDevicePixelRatio);
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, tester.view.physicalSize.height - 48 - 24);
await tester.pumpWidget(const SizedBox());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
}
@@ -1,8 +1,58 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
import 'package:app_links_platform_interface/app_links_platform_interface.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/app/application/app_deep_link_controller.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/core/common/start_config.dart';
import 'package:share_handler/share_handler.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('receives initial and subsequent scheme extension URLs with UTF-8 params', () async {
final originalPlatform = AppLinksPlatform.instance;
final initialUrl = 'https://github.com/author/扩展';
Uri link(String url) => Uri.parse('gopeed:///extension').replace(
queryParameters: {
'params': base64Encode(utf8.encode(jsonEncode({'url': url}))),
},
);
final platform = _FakeAppLinksPlatform(initialLink: link(initialUrl));
AppLinksPlatform.instance = platform;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('window_manager'),
(_) async => false,
);
final container = ProviderContainer(
overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)],
);
try {
await container.read(appDeepLinkControllerProvider.future);
expect(container.read(pendingExtensionInstallProvider)?.url, initialUrl);
container.read(pendingExtensionInstallProvider.notifier).clear();
platform.emit(link('https://github.com/author/second'));
await Future<void>.delayed(Duration.zero);
expect(container.read(pendingExtensionInstallProvider)?.url, 'https://github.com/author/second');
} finally {
container.dispose();
await Future<void>.delayed(Duration.zero);
AppLinksPlatform.instance = originalPlatform;
await platform.close();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('window_manager'),
null,
);
}
});
test('keeps the established path-style Gopeed deep-link routes', () {
expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///create')), '/create');
expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///extension')), '/extension');
@@ -27,4 +77,75 @@ void main() {
expect(sharedMediaUri(media), Uri.parse('magnet:?xt=urn:btih:example'));
});
test('keeps listening for browser takeover links after runtime settings change', () async {
final originalPlatform = AppLinksPlatform.instance;
final appLinksPlatform = _FakeAppLinksPlatform();
AppLinksPlatform.instance = appLinksPlatform;
final container = ProviderContainer(
overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)],
);
try {
await container.read(appDeepLinkControllerProvider.future);
expect(appLinksPlatform.hasListener, isTrue);
container.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(DownloaderConfig());
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(appLinksPlatform.hasListener, isTrue);
} finally {
container.dispose();
await Future<void>.delayed(Duration.zero);
AppLinksPlatform.instance = originalPlatform;
await appLinksPlatform.close();
}
});
}
class _FakeAppLinksPlatform extends AppLinksPlatform {
_FakeAppLinksPlatform({this.initialLink});
final Uri? initialLink;
void emit(Uri uri) => _links.add(uri);
final _links = StreamController<Uri>.broadcast();
bool get hasListener => _links.hasListener;
@override
Future<Uri?> getInitialLink() async => initialLink;
@override
Stream<Uri> get uriLinkStream => _links.stream;
Future<void> close() => _links.close();
}
class _FakeRuntimeController extends AppRuntimeController {
@override
Future<AppRuntimeState> build() async {
final config = StartConfig()
..network = 'tcp'
..address = '127.0.0.1:9999'
..apiEnable = true
..apiToken = ''
..storage = 'bolt'
..storageDir = ''
..refreshInterval = 0;
return AppRuntimeState(
startConfig: config,
apiServerState: const ApiServerState(
enabled: true,
mcpEnabled: false,
running: true,
network: 'tcp',
address: '127.0.0.1:9999',
runningPort: 9999,
pendingApply: false,
lastError: '',
),
downloaderConfig: DownloaderConfig(),
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/core/common/libgopeed_channel.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('gopeed.com/libgopeed');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
test('mobile invokes carry unique request IDs for async callbacks', () async {
final requestIDs = <int>[];
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'invoke');
final arguments = (call.arguments as Map<Object?, Object?>).cast<String, Object?>();
requestIDs.add(arguments['requestID']! as int);
return '{"code":0}';
});
final bridge = LibgopeedChannel();
await Future.wait([bridge.invoke('GET', '/api/v1/info'), bridge.invoke('GET', '/api/v1/config')]);
expect(requestIDs, [0, 1]);
});
}
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/core/utils/compact_count_formatter.dart';
void main() {
test('keeps small counts exact and compacts larger counts to a bounded label', () {
expect(CompactCountFormatter.format(-1), '0');
expect(CompactCountFormatter.format(0), '0');
expect(CompactCountFormatter.format(9999), '9999');
expect(CompactCountFormatter.format(10000), '1w');
expect(CompactCountFormatter.format(12345), '1.2w');
expect(CompactCountFormatter.format(99999), '9.9w');
expect(CompactCountFormatter.format(123456), '12w');
expect(CompactCountFormatter.format(1000000), '1m');
expect(CompactCountFormatter.format(1234567), '1.2m');
expect(CompactCountFormatter.format(10000000), '10m');
expect(CompactCountFormatter.format(12345678), '12m');
expect(CompactCountFormatter.format(100000000), '100m');
expect(CompactCountFormatter.format(123456789), '123m');
expect(CompactCountFormatter.format(1000000000), '1b');
expect(CompactCountFormatter.format(1234567890), '1.2b');
expect(CompactCountFormatter.format(12345678901), '12b');
expect(CompactCountFormatter.format(1234567890123), '1.2t');
expect(CompactCountFormatter.format(1234567890123456), '1.2q');
expect(CompactCountFormatter.format(9223372036854775807), '9.2qi');
});
}
@@ -9,9 +9,9 @@ void main() {
test('create-task child window has a localized native title', () {
expect(
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
'Create Task - Gopeed',
'Gopeed-Create Task',
);
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed');
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务');
});
test('create-task payload only carries initial task data', () {
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:gopeed/api/model/extension.dart';
import 'package:gopeed/features/extensions/presentation/widgets/extension_setting_field.dart';
import 'package:gopeed/shared/widgets/app_text_field.dart';
void main() {
Future<void> pumpField(WidgetTester tester, Setting setting, TextEditingController controller) async {
await tester.pumpWidget(
ShadcnApp(
home: Scaffold(
child: Center(
child: SizedBox(
width: 280,
child: ExtensionSettingField(setting: setting, controller: controller),
),
),
),
),
);
await tester.pumpAndSettle();
}
Setting setting(SettingType type) =>
Setting(name: 'test', title: 'Test setting', description: 'Description', required: false, type: type);
testWidgets('boolean switch loads and updates the saved value', (tester) async {
final controller = TextEditingController(text: 'true');
addTearDown(controller.dispose);
await pumpField(tester, setting(SettingType.boolean), controller);
expect(find.byType(AppTextField), findsNothing);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(controller.text, 'false');
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
});
for (final type in [SettingType.string, SettingType.number]) {
testWidgets(
'$type options show labels and save selected values',
(tester) async {
final field = setting(type)..options = [Option(label: 'First', value: 1), Option(label: 'Second', value: 2)];
final controller = TextEditingController(text: '1');
addTearDown(controller.dispose);
await pumpField(tester, field, controller);
expect(find.byType(AppTextField), findsNothing);
expect(find.text('First'), findsOneWidget);
await tester.tap(find.byType(Select<String>));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.text('Second'));
await tester.pumpAndSettle();
expect(controller.text, '2');
expect(find.text('Second'), findsOneWidget);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}),
);
}
testWidgets('number field accepts decimals and rejects nonnumeric input', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await pumpField(tester, setting(SettingType.number), controller);
await tester.enterText(find.byType(AppTextField), '-12.5');
expect(controller.text, '-12.5');
await tester.enterText(find.byType(AppTextField), 'invalid');
expect(controller.text, '-12.5');
});
testWidgets('string with empty options remains a text field', (tester) async {
final controller = TextEditingController(text: 'original');
addTearDown(controller.dispose);
await pumpField(tester, setting(SettingType.string)..options = [], controller);
await tester.enterText(find.byType(AppTextField), 'updated');
expect(controller.text, 'updated');
});
}
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
void main() {
@@ -41,4 +42,19 @@ void main() {
expect(record.files.length, 3);
expect(record.totalBytes, 60);
});
test('file names preserve hash and question mark characters for type detection', () {
expect(
taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),
GopeedIcons.fileVideo,
);
expect(taskFileTypeIcon('What is this? #2.MP4'), GopeedIcons.fileVideo);
expect(taskFileTypeIcon('Episode #2.mp3'), GopeedIcons.fileAudio);
});
test('URL fallback ignores query and fragment when detecting file type', () {
expect(taskFileTypeIcon('https://example.com/video.mp4?token=abc#part'), GopeedIcons.fileVideo);
expect(taskFileTypeIcon('https://example.com/download?name=video.mp4'), GopeedIcons.file);
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
});
}
@@ -1,5 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/widgets.dart' as flutter show Row;
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -8,19 +11,197 @@ import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/capabilities/storage_capability.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
import 'package:gopeed/shared/services/download_directory_picker.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_design_tokens.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final navigationFails in [false, true]) {
testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async {
tester.view.physicalSize = const Size(1024, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final events = <String>[];
const channel = MethodChannel('window_manager');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
events.add(call.method);
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.createTask, (_) {
events.add('created');
return 'task-id';
})
..bind(NavigationMethods.showDownloadingTasks, (_) {
events.add('downloads');
if (navigationFails) throw StateError('Navigation unavailable');
return const RpcUnit();
});
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: CreateTaskWindowPage(
windowController: WindowController.fromWindowId('child'),
initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
// The mocked native close deliberately leaves the widget mounted.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(events, ['created', 'downloads', 'close']);
await tester.pumpWidget(const SizedBox());
});
}
testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'created-id';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final pending = container.read(pendingCreateTaskProvider.notifier);
pending.set(
CreateTask(
req: Request(
url: 'https://example.com/first.zip',
rawUrl: 'https://example.com/first',
labels: {'source': 'first'},
extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(),
proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'),
skipVerifyCert: true,
),
),
);
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip');
expect(container.read(pendingCreateTaskProvider), isNull);
final pageState = tester.state(find.byType(CreateTaskWindowPage));
for (final url in [
'https://example.com/second.zip',
'https://example.com/third.zip',
'https://example.com/third.zip',
]) {
pending.set(CreateTask(req: Request(url: url)));
router.go('/create');
await tester.pumpAndSettle();
expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState));
expect(_fieldText(tester, 'create-task-url-input'), url);
expect(container.read(pendingCreateTaskProvider), isNull);
}
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.req?.url, 'https://example.com/third.zip');
expect(submitted?.req?.rawUrl, isNull);
expect(submitted?.req?.labels, isNull);
expect(submitted?.req?.extra, isNull);
expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow);
expect(submitted?.req?.skipVerifyCert, isFalse);
expect(tester.takeException(), isNull);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('mobile create task page stays within the system safe areas', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
tester.view.physicalSize = const Size(700, 760);
tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding(top: 32, bottom: 24);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPadding);
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
try {
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
final contentPadding = tester.widget<Padding>(find.byKey(const ValueKey('create-task-safe-content')));
expect((contentPadding.padding as EdgeInsets).top, 32);
expect(
tester.getBottomRight(find.byKey(const ValueKey('create-task-confirm-button'))).dy,
lessThanOrEqualTo(tester.view.physicalSize.height / tester.view.devicePixelRatio - 24),
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('browser extension request parameters populate the form and survive submission', (
WidgetTester tester,
) async {
@@ -121,7 +302,10 @@ void main() {
final connectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
expect(connectionsInput, findsOneWidget);
expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth);
expect(
tester.getSize(connectionsInput).width,
tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width,
);
final connectionsField = tester.widget<shad.TextField>(connectionsInput);
expect(connectionsField.controller!.text, '12');
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
@@ -280,6 +464,58 @@ void main() {
expect(submitted?.opts?.asDefaultPath, isTrue);
});
testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: 'resolved-id',
res: Resource(
name: 'server-generated-name',
files: [FileInfo(name: 'video.mp4', size: 1024)],
),
),
)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(_fieldText(tester, 'create-task-rename-input'), isEmpty);
final createButton = tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button')));
expect(createButton.child, isA<SizedBox>());
expect((createButton.child as SizedBox).width, 68);
expect(
find.descendant(of: find.byKey(const ValueKey('resolve-create-button')), matching: find.text('Create')),
findsOneWidget,
);
expect(
tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))),
tester.getSize(find.byKey(const ValueKey('resolve-create-button'))),
);
expect(tester.takeException(), isNull);
});
testWidgets('create history supports filtering, deleting one entry, and clearing all entries', (
WidgetTester tester,
) async {
@@ -324,6 +560,7 @@ void main() {
await tester.tap(find.byIcon(Icons.history));
await tester.pumpAndSettle();
expect(find.text('History Links'), findsOneWidget);
expect(
tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width,
tester.getSize(find.byKey(const ValueKey('create-history-content'))).width,
@@ -0,0 +1,59 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/history_search_field.dart';
import 'package:gopeed/l10n/l10n.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) {
for (final scale in [1.0, 1.5, 2.0]) {
testWidgets('history hint and input line align on $platform at scale $scale', (tester) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final controller = TextEditingController();
addTearDown(controller.dispose);
String? query;
for (final width in [280.0, 360.0, 600.0]) {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AppComponentThemes(
child: Builder(
builder: (context) => Localizations.override(
context: context,
locale: const Locale('zh'),
child: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
child: Center(
child: SizedBox(
width: width,
child: HistorySearchField(controller: controller, onChanged: (value) => query = value),
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
final editable = tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
final hint = tester.renderObject<RenderBox>(find.text('搜索历史记录…'));
expect(editable.preferredLineHeight, lessThanOrEqualTo(editable.size.height));
expect(hint.size.height, closeTo(editable.preferredLineHeight, 0.1));
expect(hint.localToGlobal(Offset.zero).dy, closeTo(editable.localToGlobal(Offset.zero).dy, 0.1));
expect(tester.takeException(), isNull);
}
await tester.enterText(find.byType(EditableText), 'example');
expect(query, 'example');
debugDefaultTargetPlatformOverride = null;
});
}
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/create_task_action_buttons.dart';
import 'package:gopeed/l10n/l10n.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 1024.0]) {
for (final language in ['en', 'zh', 'de']) {
testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async {
tester.view.physicalSize = Size(width, 760);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var submitting = false;
var cancelled = false;
await tester.pumpWidget(
shad.ShadcnApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => Localizations.override(
context: context,
locale: Locale(language),
delegates: const [AppLocalizations.delegate],
child: AppComponentThemes(
child: Center(
child: StatefulBuilder(
builder: (context, setState) => shad.AlertDialog(
padding: const EdgeInsets.all(18),
content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100),
actions: [
Flexible(
child: CreateTaskActionButtons(
submitting: submitting,
onCancel: () => cancelled = true,
onSubmit: () => setState(() => submitting = true),
cancelLabel: context.l10n.cancel,
submitLabel: context.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
final cancel = find.byKey(const ValueKey('resolve-cancel-button'));
final create = find.byKey(const ValueKey('resolve-create-button'));
final cancelRect = tester.getRect(cancel);
final createRect = tester.getRect(create);
expect(cancelRect.size, createRect.size);
expect(cancelRect.center.dy, createRect.center.dy);
expect(createRect.left - cancelRect.right, 12);
expect((tester.widget<shad.SecondaryButton>(cancel).child as SizedBox).width, 68);
expect((tester.widget<AppLoadingButton>(create).child as SizedBox).width, 68);
expect(cancelRect.left, greaterThanOrEqualTo(0));
expect(createRect.right, lessThanOrEqualTo(width));
expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget);
await tester.tap(cancel);
expect(cancelled, isTrue);
await tester.tap(create);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(tester.widget<AppLoadingButton>(create).loading, isTrue);
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
expect(tester.getRect(cancel), cancelRect);
expect(tester.getRect(create), createRect);
expect(tester.takeException(), isNull);
});
}
}
}
@@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('provides the standard text field appearance', (tester) async {
late shad.TextFieldTheme textFieldTheme;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Builder(
builder: (context) {
textFieldTheme = shad.ComponentTheme.of<shad.TextFieldTheme>(context);
return const SizedBox();
},
),
),
),
);
expect(textFieldTheme.filled, isTrue);
expect(textFieldTheme.border, Border.all(color: AppPalette.light.border));
expect(textFieldTheme.borderRadius, BorderRadius.circular(AppDesignTokens.controlRadius));
expect(textFieldTheme.padding, AppDesignTokens.textFieldPadding);
});
testWidgets('provides the standard checkbox metrics', (tester) async {
late shad.CheckboxTheme checkboxTheme;
@@ -134,7 +134,10 @@ void main() {
of: find.byKey(const ValueKey('text-only-loading-button')),
matching: find.byType(shad.CircularProgressIndicator),
);
expect(find.text('Login'), findsNothing);
final hiddenLabel = find.text('Login');
expect(hiddenLabel, findsOneWidget);
expect(tester.widget<Opacity>(find.ancestor(of: hiddenLabel, matching: find.byType(Opacity))).opacity, 0);
expect(find.ancestor(of: hiddenLabel, matching: find.byType(ExcludeSemantics)), findsOneWidget);
expect(loader, findsOneWidget);
expect(tester.getRect(loader).center, bounds.center);
});
@@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_path_picker_field.dart';
import 'package:gopeed/shared/widgets/app_text_field.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('file paths remain editable and Web hides native file selection', (tester) async {
final controller = TextEditingController(text: '/scripts/old.sh');
addTearDown(controller.dispose);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Center(
child: AppPathPickerField.file(
controller: controller,
desktopWidth: 320,
pickerKey: const ValueKey('file-picker'),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('file-picker')), kIsWeb ? findsNothing : findsOneWidget);
expect(tester.widget<AppTextField>(find.byType(AppTextField)).readOnly, isFalse);
await tester.enterText(find.byType(EditableText), '/scripts/custom.sh');
await tester.pump();
expect(controller.text, '/scripts/custom.sh');
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,110 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar;
import 'package:flutter/services.dart' show BrowserContextMenu;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_text_field.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('Android long press uses Flutter adaptive text selection toolbar', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final controller = TextEditingController(text: 'https://example.com/download');
addTearDown(controller.dispose);
try {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Center(
child: SizedBox(width: 320, child: AppTextField(controller: controller)),
),
),
),
);
await tester.longPress(find.byType(AppTextField));
await tester.pumpAndSettle();
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = null;
}
}, skip: kIsWeb);
testWidgets('desktop right click uses Flutter adaptive text selection toolbar', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
final controller = TextEditingController(text: 'https://example.com/download');
addTearDown(controller.dispose);
try {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Center(
child: SizedBox(width: 320, child: AppTextField(controller: controller)),
),
),
),
);
final field = find.byType(AppTextField);
final gesture = await tester.startGesture(
tester.getCenter(field),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = null;
}
}, skip: kIsWeb);
testWidgets('Web right click keeps the browser menu and creates no Flutter overlay', (tester) async {
final controller = TextEditingController(text: 'https://example.com/download');
addTearDown(controller.dispose);
await tester.pumpWidget(
shad.ShadcnApp(
disableBrowserContextMenu: false,
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Center(
child: SizedBox(width: 320, child: AppTextField(controller: controller)),
),
),
),
);
await tester.pumpAndSettle();
expect(BrowserContextMenu.enabled, isTrue);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(AppTextField)),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing);
expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing);
}, skip: !kIsWeb);
test('AppTextField defaults to the shared context menu policy', () {
expect(const AppTextField().contextMenuBuilder, same(appTextFieldContextMenuBuilder));
});
}
+39
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart';
import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/util/arch/arch.dart';
import 'package:gopeed/util/github_mirror.dart';
@@ -193,10 +195,47 @@ void main() {
expect(find.text('下载更快'), findsNothing);
expect(find.text('Later'), findsOneWidget);
expect(find.text('Update Now'), findsOneWidget);
expect(find.text("What's New"), findsNothing);
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
expect(versionText.style?.decoration, TextDecoration.none);
expect(versionText.style?.inherit, isFalse);
expect(find.byIcon(Icons.system_update_alt_outlined), findsNothing);
final dialog = tester.getRect(find.byKey(const ValueKey('app-update-dialog')));
expect(dialog.width, lessThan(320));
expect(tester.getCenter(find.byKey(const ValueKey('app-update-heading'))).dx, closeTo(dialog.center.dx, 0.01));
expect(tester.getCenter(find.byKey(const ValueKey('app-update-actions'))).dx, closeTo(dialog.center.dx, 0.01));
expect(tester.takeException(), isNull);
});
testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.dark(),
materialTheme: AppTheme.materialDark(),
home: Center(
child: SizedBox(
width: 280,
child: AppUpdateNotesViewport(
child: Column(children: [for (var i = 0; i < 30; i++) Text('Release note $i')]),
),
),
),
),
);
await tester.pumpAndSettle();
final scrollbar = tester.widget<shad.Scrollbar>(find.byType(shad.Scrollbar));
expect(scrollbar.thumbVisibility, isTrue);
final controller = scrollbar.controller!;
expect(controller.position.maxScrollExtent, greaterThan(0));
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pumpAndSettle();
final viewport = tester.getRect(find.byType(AppUpdateNotesViewport));
final lastLine = tester.getRect(find.text('Release note 29'));
expect(lastLine.bottom, lessThanOrEqualTo(viewport.bottom - 24));
expect(lastLine.top, greaterThan(viewport.top));
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
File diff suppressed because it is too large. Load diff