Files
gopeed/ui/flutter/test/widget_test.dart
T
Levi 4b231f52c5 feat(task): record and display task completion time
Add DoneAt to the download task model: set when a task finishes and
cleared when it is downloaded again. The value flows through the API
model and task record, and is shown as "Completed at" in the task
drawer info tab next to the creation time.
2026-09-20 08:56:54 +08:00

6222 lines
256 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, debugDefaultTargetPlatformOverride, kIsWeb;
import 'package:flutter/material.dart' show Icons, Scrollbar, SelectionArea;
import 'package:flutter/gestures.dart' show PointerDeviceKind, kDoubleTapMinTime, kSecondaryMouseButton;
import 'package:flutter/rendering.dart' show RenderParagraph;
import 'package:flutter/services.dart' show MethodChannel, SystemChannels;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:window_manager/window_manager.dart';
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
import 'package:gopeed/app/app.dart';
import 'package:gopeed/app/router/app_router.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/api/model/resolve_result.dart';
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
import 'package:gopeed/features/tasks/application/task_list_navigation.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
import 'package:gopeed/app/application/app_appearance_controller.dart';
import 'package:gopeed/app/application/app_deep_link_controller.dart';
import 'package:gopeed/app/application/app_notification_controller.dart';
import 'package:gopeed/app/application/app_platform_controller.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/install_extension.dart';
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/extension.dart' as api_extension;
import 'package:gopeed/api/model/meta.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/store_extension.dart';
import 'package:gopeed/api/model/task.dart' as api_task;
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
import 'package:gopeed/core/window/app_window_chrome.dart';
import 'package:gopeed/core/window/app_window_frame.dart';
import 'package:gopeed/features/home/presentation/widgets/tasks_top_bar.dart';
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.dart';
import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart';
import 'package:gopeed/features/home/presentation/widgets/primary_rail.dart';
import 'package:gopeed/features/tasks/application/pending_update_task.dart';
import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart';
import 'package:gopeed/features/tasks/application/task_runtime_status_provider.dart';
import 'package:gopeed/features/tasks/application/task_stats_provider.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
import 'package:gopeed/features/settings/application/settings_controller.dart';
import 'package:gopeed/features/settings/presentation/pages/settings_page.dart';
import 'package:gopeed/features/settings/presentation/widgets/download_categories_setting.dart';
import 'package:gopeed/features/settings/presentation/widgets/mcp_agent_setup.dart';
import 'package:gopeed/features/settings/presentation/widgets/settings_item.dart';
import 'package:gopeed/features/settings/presentation/widgets/settings_language_select.dart';
import 'package:gopeed/features/settings/presentation/widgets/settings_list_editor.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
import 'package:gopeed/features/tasks/presentation/widgets/speed_monitor_card.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_batch_selection_builder.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_tree.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_progress_bar.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_context_menu.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_delete_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_drawer.dart';
import 'package:gopeed/features/tasks/presentation/widgets/pending_update_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_statistics/piece_map.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_statistics/peer_table.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_statistics/task_statistics_tab.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_update_url_dialog.dart';
import 'package:gopeed/api/model/task_stats.dart';
import 'package:gopeed/l10n/l10n.dart';
import 'package:gopeed/shared/theme/app_palette.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/theme/app_theme_color.dart';
import 'package:gopeed/shared/services/download_directory_picker.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_choice_segmented_control.dart';
import 'package:gopeed/shared/widgets/app_copy_icon_button.dart';
import 'package:gopeed/shared/widgets/app_http_headers_editor.dart';
import 'package:gopeed/shared/widgets/app_path_picker_field.dart';
import 'package:gopeed/shared/widgets/app_primary_button.dart';
import 'package:gopeed/shared/widgets/app_text_field.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:gopeed/shared/widgets/app_toast.dart';
import 'package:gopeed/shared/widgets/app_swipe_tabs.dart';
import 'package:gopeed/shared/widgets/gopeed_app_mark.dart';
import 'package:gopeed/shared/widgets/responsive_menu_layout.dart';
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
import 'package:gopeed/shared/widgets/window/desktop_window_header.dart';
import 'package:gopeed/util/updater.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en');
for (final direct in [true, false]) {
testWidgets(
'mobile creation selects downloading after success (direct: $direct)',
(tester) async {
await _setTestSize(tester, const Size(700, 900));
var created = 0;
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = direct;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: 'resolved',
res: Resource(
name: 'new.zip',
files: [FileInfo(name: 'new.zip', size: 10)],
),
),
)
..bind(GopeedMethods.createTask, (_) {
created++;
return 'new-task';
});
final container = ProviderContainer(
overrides: [
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
appDeepLinkControllerProvider.overrideWith(FakeDeepLinkController.new),
appNotificationControllerProvider.overrideWith(FakeNotificationController.new),
tasksControllerProvider.overrideWith(CompletedTasksController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: container, child: const GopeedApp()));
await tester.pumpAndSettle();
await tester.tap(find.text('Completed 1'));
await tester.pumpAndSettle();
expect(find.text('done.zip'), findsOneWidget);
final router = GoRouter.of(AppRouter.rootNavigatorKey.currentContext!);
void openCreate() {
container
.read(pendingCreateTaskProvider.notifier)
.set(CreateTask(req: Request(url: 'https://example.com/new.zip')));
router.go('/create');
}
openCreate();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-cancel-button')));
await tester.pumpAndSettle();
expect(find.text('done.zip'), findsOneWidget);
expect(container.read(taskListNavigationProvider), 0);
expect(created, 0);
openCreate();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
if (!direct) {
expect(created, 0);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
}
await tester.pumpAndSettle();
expect(created, 1);
expect(router.routeInformationProvider.value.uri.path, '/');
expect(container.read(taskListNavigationProvider), 1);
expect(find.text('done.zip'), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
},
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}),
);
}
testWidgets('child success returns to downloading from completed and another page', (tester) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(CompletedTasksController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const GopeedApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Completed'));
await tester.pumpAndSettle();
expect(find.text('done.zip'), findsOneWidget);
// Exercise the same serialized operation dispatched by a child window.
await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {});
await tester.pumpAndSettle();
expect(find.text('done.zip'), findsNothing);
expect(find.text('Create Task'), findsOneWidget);
GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).go('/settings');
await tester.pumpAndSettle();
await LocalAppCapabilities.instance.capabilities.navigation.showDownloadingTasks();
await tester.pumpAndSettle();
expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/');
expect(find.text('done.zip'), findsNothing);
expect(find.text('Create Task'), findsOneWidget);
});
test('Web MCP endpoint excludes page path, query, and hash route', () {
expect(
webMcpEndpoint(Uri.parse('https://gopeed.example:8443/app?source=desktop#/settings/advanced')),
'https://gopeed.example:8443/mcp',
);
expect(webMcpEndpoint(Uri.parse('http://127.0.0.1:9999/#/settings?tab=advanced')), 'http://127.0.0.1:9999/mcp');
});
test('Web API display address normalizes localhost', () {
expect(webApiDisplayAddress(Uri.parse('http://localhost:9999/#/settings/advanced')), '127.0.0.1:9999');
expect(webApiDisplayAddress(Uri.parse('https://example.com/settings')), 'example.com');
expect(webApiDisplayAddress(Uri.parse('http://[::1]:9999/settings')), '[::1]:9999');
});
test('Flutter Web debug API defaults to port 9999', () {
final pageUri = Uri.parse('http://localhost:52143/app?build=debug#/settings/advanced');
expect(webApiBaseUrl(pageUri, debugMode: true), 'http://127.0.0.1:9999/');
expect(webApiBaseUrl(pageUri, debugMode: false), 'http://localhost:52143/');
expect(webTransportBaseUrl('http://127.0.0.1:9999/', pageUri, debugMode: true), 'http://127.0.0.1:9999/');
expect(webTransportBaseUrl('http://127.0.0.1:9999/', pageUri, debugMode: false), '');
});
test('macOS reserves its native overlay header without custom chrome', () {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
try {
expect(AppWindowChrome.isDesktopWindow, isTrue);
expect(AppWindowChrome.usesCustomChrome, isFalse);
expect(AppWindowChrome.reservesHeaderInset, isTrue);
expect(AppWindowChrome.clipsRoundedCorners, isFalse);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
test('Web reserves the same top content inset as desktop platforms', () {
expect(AppWindowChrome.reservesHeaderInsetFor(web: true, platform: TargetPlatform.android), isTrue);
expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.windows), isTrue);
expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.macOS), isTrue);
expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.linux), isTrue);
expect(AppWindowChrome.reservesHeaderInsetFor(web: false, platform: TargetPlatform.android), isFalse);
});
testWidgets('desktop frame overlays global caption controls without insetting content', (WidgetTester tester) async {
const windowChannel = MethodChannel('window_manager');
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
windowChannel,
(call) async => call.method == 'isMaximized' ? false : null,
);
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(windowChannel, null);
});
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const AppWindowFrame(child: SizedBox.expand(key: ValueKey('window-content'))),
),
);
await tester.pump();
expect(find.byType(DesktopWindowHeader), findsOneWidget);
expect(find.byType(DragToResizeArea), findsOneWidget);
expect(
find.descendant(of: find.byType(DesktopWindowHeader), matching: find.byType(WindowCaptionControls)),
findsOneWidget,
);
expect(tester.getSize(find.byType(DesktopWindowHeader)).height, AppDesignTokens.windowHeaderHeight);
expect(tester.getTopLeft(find.byKey(const ValueKey('window-content'))), Offset.zero);
debugDefaultTargetPlatformOverride = null;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(windowChannel, null);
});
testWidgets('tasks page renders key content', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1024, 768);
tester.view.devicePixelRatio = 1;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(FakeTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pumpAndSettle();
expect(find.text('Downloading'), findsOneWidget);
expect(find.text('Completed'), findsOneWidget);
expect(find.text('Failed'), findsOneWidget);
expect(find.text('Create Task'), findsOneWidget);
expect(find.text('No tasks in this list'), findsOneWidget);
expect(find.text('Add Task'), findsNothing);
expect(find.byKey(const ValueKey('task-empty-illustration')), findsOneWidget);
expect(find.text('Create a task or refresh the list.'), findsNothing);
expect(find.text('Refresh'), findsNothing);
expect(find.textContaining('ACTIVE CONNECTIONS'), findsNothing);
expect(find.ancestor(of: find.text('Create Task'), matching: find.byType(AppPrimaryButton)), findsOneWidget);
final taskActions = find.byKey(const ValueKey('tasks-top-action-buttons'));
final taskSearch = find.byKey(const ValueKey('tasks-search-field-container'));
final createButton = find.byKey(const ValueKey('tasks-create-button-container'));
expect(find.descendant(of: taskActions, matching: find.byType(shad.IconButton)), findsNWidgets(4));
expect(tester.getSize(taskSearch).width, 240);
expect(
tester.getRect(taskActions).right,
closeTo(tester.getRect(find.byType(TasksTopBar)).right - AppDesignTokens.space32, 0.01),
);
expect(
tester.widget<Container>(find.byKey(const ValueKey('primary-rail-active-indicator'))).color,
AppPalette.light.textPrimary,
);
expect(
tester
.widget<GestureDetector>(
find.descendant(
of: find.byKey(const ValueKey('primary-rail-tasks-item')),
matching: find.byType(GestureDetector),
),
)
.behavior,
HitTestBehavior.opaque,
);
expect(find.byType(GopeedAppMark), findsOneWidget);
expect(find.byKey(const ValueKey('primary-rail-app-mark')), findsOneWidget);
expect(tester.getSize(find.byKey(const ValueKey('primary-rail-app-mark'))), const Size.square(24));
expect(find.descendant(of: find.byType(GopeedAppMark), matching: find.byType(CustomPaint)), findsOneWidget);
expect(find.descendant(of: find.byType(GopeedAppMark), matching: find.byType(Image)), findsNothing);
expect(find.text('M'), findsNothing);
expect(find.text('V1.0'), findsNothing);
expect(
tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width,
AppDesignTokens.filterSidebarWidth,
);
final primaryNavigationTop = tester.getTopLeft(find.byKey(const ValueKey('primary-rail-tasks-item'))).dy;
expect(
tester.getTopLeft(find.byKey(const ValueKey('secondary-navigation-item-0'))).dy,
closeTo(primaryNavigationTop, 0.01),
);
expect(
tester.getTopLeft(find.byKey(const ValueKey('tasks-content-start'))).dy,
closeTo(primaryNavigationTop, 0.01),
);
expect(tester.getSize(find.byKey(const ValueKey('speed-monitor-gauge'))), const Size(60, 32));
final gaugeRect = tester.getRect(find.byKey(const ValueKey('speed-monitor-gauge')));
final downloadLineRect = tester.getRect(find.byKey(const ValueKey('speed-monitor-download-line')));
final valuesRect = tester.getRect(find.byKey(const ValueKey('speed-monitor-values')));
expect(gaugeRect.right, lessThan(downloadLineRect.left));
expect(gaugeRect.center.dy, closeTo(valuesRect.center.dy, 0.01));
expect(find.text('B/s'), findsNWidgets(2));
expect(
find.descendant(of: find.byKey(const ValueKey('speed-monitor-values')), matching: find.byType(Icon)),
findsNWidgets(2),
);
tester.view.physicalSize = const Size(800, 768);
await tester.pumpAndSettle();
final compactSearchWidth = tester.getSize(taskSearch).width;
expect(tester.getSize(find.byType(PrimaryRail)).width, AppDesignTokens.railWidth);
expect(compactSearchWidth, lessThan(240));
expect(tester.getRect(createButton).left - tester.getRect(taskSearch).right, greaterThanOrEqualTo(16));
expect(
tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width,
AppDesignTokens.filterSidebarWidth,
);
expect(find.byKey(const ValueKey('speed-monitor-gauge')), findsOneWidget);
expect(
tester.getRect(taskActions).right,
closeTo(tester.getRect(find.byType(TasksTopBar)).right - AppDesignTokens.space32, 0.01),
);
expect(tester.takeException(), isNull);
final primaryRailGesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await primaryRailGesture.addPointer(
location: tester.getCenter(find.byKey(const ValueKey('primary-rail-tasks-item'))),
);
await tester.pump(const Duration(seconds: 1));
expect(find.text('Tasks'), findsOneWidget);
await primaryRailGesture.removePointer();
await tester.pumpAndSettle();
tester.view.physicalSize = const Size(720, 768);
await tester.pumpAndSettle();
expect(find.byType(PrimaryRail), findsOneWidget);
expect(tester.getSize(find.byType(PrimaryRail)).width, AppDesignTokens.railWidth);
expect(tester.getSize(taskSearch).width, lessThan(compactSearchWidth));
expect(tester.getRect(createButton).left - tester.getRect(taskSearch).right, greaterThanOrEqualTo(16));
expect(
tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width,
AppDesignTokens.filterSidebarWidth,
);
expect(
tester.getRect(taskActions).right,
closeTo(tester.getRect(find.byType(TasksTopBar)).right - AppDesignTokens.space32, 0.01),
);
expect(tester.takeException(), isNull);
tester.view.physicalSize = const Size(719, 768);
await tester.pumpAndSettle();
expect(find.byType(PrimaryRail), findsNothing);
expect(find.byType(PrimaryBottomNavigation), findsOneWidget);
});
testWidgets('task loading failures use the empty state without retry controls', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(FailingTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pumpAndSettle();
expect(find.text('No tasks in this list'), findsOneWidget);
expect(find.byKey(const ValueKey('task-empty-illustration')), findsOneWidget);
expect(find.text('Retry'), findsNothing);
expect(find.text('Unable to load tasks'), findsNothing);
});
testWidgets('android root navigation requires a second back action to exit', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
var systemPopCalls = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'SystemNavigator.pop') systemPopCalls++;
return null;
});
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(FakeTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.text('Press back again to exit'), findsOneWidget);
expect(systemPopCalls, 0);
await tester.binding.handlePopRoute();
await tester.pump();
expect(systemPopCalls, 1);
await tester.pump(const Duration(seconds: 6));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('task search uses a specific empty result message', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(RecordingTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.enterText(find.byType(AppTextField), 'not-a-task');
await tester.pump();
expect(find.text('No matching tasks found'), findsOneWidget);
expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsNothing);
expect(find.text('No tasks in this list'), findsNothing);
});
testWidgets('tasks page mobile header places actions above text tabs', (WidgetTester tester) async {
tester.view.physicalSize = const Size(390, 760);
tester.view.devicePixelRatio = 1;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(FakeTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pumpAndSettle();
expect(find.text('Tasks'), findsNWidgets(2));
expect(find.text('Downloading 0'), findsOneWidget);
expect(find.text('Completed 0'), findsOneWidget);
expect(find.text('Failed 0'), findsOneWidget);
expect(find.text('Create Task'), findsOneWidget);
final headerCreate = find.byKey(const ValueKey('tasks-mobile-create-button'));
expect(tester.getSize(headerCreate).height, 34);
final batchButton = find.byKey(const ValueKey('tasks-mobile-batch-button'));
expect(tester.getRect(headerCreate).height, tester.getRect(batchButton).height);
expect(tester.getRect(headerCreate).top, tester.getRect(batchButton).top);
expect(tester.getRect(headerCreate).bottom, tester.getRect(batchButton).bottom);
expect(tester.getBottomLeft(headerCreate).dy, lessThan(tester.getTopLeft(find.text('Downloading 0')).dy));
for (final size in [const Size(320, 568), const Size(768, 1024)]) {
tester.view.physicalSize = size;
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsOneWidget);
}
tester.view.physicalSize = const Size(390, 760);
await tester.pumpAndSettle();
final content = find.byWidgetPredicate((widget) => widget is AppSwipeTabs);
void expectActive(String label) {
expect(tester.widget<Text>(find.text(label)).style!.fontWeight, FontWeight.w700);
}
expectActive('Downloading 0');
await tester.drag(content, const Offset(160, 0));
await tester.pumpAndSettle();
expectActive('Downloading 0');
await tester.drag(content, const Offset(-160, 0));
await tester.pumpAndSettle();
expectActive('Completed 0');
await tester.drag(content, const Offset(-160, 0));
await tester.pumpAndSettle();
expectActive('Failed 0');
await tester.drag(content, const Offset(-160, 0));
await tester.pumpAndSettle();
expectActive('Failed 0');
await tester.drag(content, const Offset(160, 0));
await tester.pumpAndSettle();
expectActive('Completed 0');
await tester.tap(find.text('Downloading 0'));
await tester.pumpAndSettle();
await tester.drag(content, const Offset(0, -160));
await tester.pumpAndSettle();
expectActive('Downloading 0');
await tester.drag(content, const Offset(-160, 0));
await tester.pumpAndSettle();
expectActive('Completed 0');
expect(tester.takeException(), isNull);
});
testWidgets('extensions grid adds columns from a minimum card width and keeps desktop toolbar aligned', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1100, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const AppComponentThemes(child: ExtensionsPage()),
),
),
);
await tester.pumpAndSettle();
int gridColumns() {
final grid = tester.widget<SliverGrid>(find.byType(SliverGrid));
return (grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount).crossAxisCount;
}
expect(gridColumns(), 3);
final searchRect = tester.getRect(find.byKey(const ValueKey('extension-search-input')));
final sortRect = tester.getRect(find.byKey(const ValueKey('extension-sort-control')));
final refreshRect = tester.getRect(find.byKey(const ValueKey('refresh-extensions-button')));
final developRect = tester.getRect(find.byKey(const ValueKey('develop-extension-button')));
final installRect = tester.getRect(find.byKey(const ValueKey('install-extension-button')));
final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0')));
final rightmostCardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-2')));
final appMarkRect = tester.getRect(find.byKey(const ValueKey('primary-rail-app-mark')));
expect(searchRect.height, closeTo(sortRect.height, 0.01));
expect(searchRect.width, 240);
expect(searchRect.width, lessThan(cardRect.width));
expect(sortRect.left - searchRect.right, closeTo(10, 0.01));
expect(sortRect.center.dy, closeTo(searchRect.center.dy, 0.01));
expect(sortRect.height, closeTo(refreshRect.height, 0.01));
expect(searchRect.center.dy, closeTo(appMarkRect.center.dy, 0.01));
expect(installRect.center.dy, closeTo(searchRect.center.dy, 0.01));
expect(developRect.right, lessThan(installRect.left));
expect(installRect.left, greaterThan(sortRect.right));
expect(installRect.right, closeTo(rightmostCardRect.right, 0.01));
tester.view.physicalSize = const Size(1100, 600);
await tester.pumpAndSettle();
expect(
tester.getRect(find.byKey(const ValueKey('install-extension-button'))).right,
closeTo(tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))).right, 0.01),
);
tester.view.physicalSize = const Size(1024, 768);
await tester.pumpAndSettle();
expect(gridColumns(), 3);
expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(290));
tester.view.physicalSize = const Size(1028, 608);
await tester.pumpAndSettle();
expect(gridColumns(), 3);
expect(tester.getSize(find.byKey(const ValueKey('extension-search-field-container'))).width, 240);
expect(
tester.getRect(find.byKey(const ValueKey('install-extension-button'))).right,
closeTo(tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))).right, 0.01),
);
tester.view.physicalSize = const Size(720, 608);
await tester.pumpAndSettle();
expect(find.byType(PrimaryRail), findsOneWidget);
final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container')));
expect(compactSearchRect.width, lessThan(240));
expect(compactSearchRect.height, closeTo(sortRect.height, 0.01));
expect(
tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).center.dy,
closeTo(compactSearchRect.center.dy, 0.01),
);
expect(tester.takeException(), isNull);
tester.view.physicalSize = const Size(1400, 900);
await tester.pumpAndSettle();
expect(gridColumns(), 4);
expect(
tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).left -
tester.getRect(find.byKey(const ValueKey('extension-search-input'))).right,
closeTo(10, 0.01),
);
expect(tester.takeException(), isNull);
});
testWidgets('extension cards compact large counts without crowding their actions', (WidgetTester tester) async {
await _setTestSize(tester, const Size(722, 608));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const AppComponentThemes(child: ExtensionsPage()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('1.2w'), findsOneWidget);
expect(find.text('1.2b'), findsOneWidget);
final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-crowded-extension')));
final statsRect = tester.getRect(find.byKey(const ValueKey('extension-card-stats-crowded-extension')));
final actionsRect = tester.getRect(find.byKey(const ValueKey('extension-card-actions-crowded-extension')));
expect(cardRect.width, closeTo(290, 0.01));
expect(statsRect.right, lessThanOrEqualTo(actionsRect.left - 8));
expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14));
expect(tester.takeException(), isNull);
});
testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
final controller = FakeExtensionsController();
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(() => controller)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('extension-card-extension-1')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('extension-details-drawer')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-content')), findsOneWidget);
expect(find.text('Extension 1 README'), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-close')), findsNothing);
expect(
find.descendant(of: find.byKey(const ValueKey('extension-details-drawer')), matching: find.byIcon(Icons.close)),
findsOneWidget,
);
expect(find.byKey(const ValueKey('app-detail-drawer-close-button')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-hero')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-metadata')), findsNothing);
final stats = find.byKey(const ValueKey('extension-details-stats'));
expect(stats, findsOneWidget);
expect(find.descendant(of: stats, matching: find.byIcon(Icons.star_rounded)), findsOneWidget);
expect(find.descendant(of: stats, matching: find.byIcon(Icons.download_outlined)), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-readme')), findsOneWidget);
expect(find.text('#download'), findsNothing);
for (final key in ['extension-details-homepage', 'extension-details-github']) {
expect(tester.widget(find.byKey(ValueKey(key))), isA<shad.SecondaryButton>());
}
final drawerRect = tester.getRect(find.byKey(const ValueKey('extension-details-drawer')));
final filterBarRect = tester.getRect(find.byKey(const ValueKey('extensions-filter-bar')));
final listRect = tester.getRect(find.byKey(const ValueKey('extensions-list-scroll-view')));
expect(drawerRect.top, filterBarRect.top);
expect(drawerRect.top, lessThan(listRect.top));
expect(drawerRect.bottom, listRect.bottom);
expect(drawerRect.top, greaterThan(AppDesignTokens.windowHeaderHeight));
await tester.tap(find.byKey(const ValueKey('extension-details-install')));
await tester.pump(const Duration(milliseconds: 300));
expect(controller.installCalls, 1);
expect(find.byKey(const ValueKey('app-toast-content')), findsNothing);
await tester.tap(find.byKey(const ValueKey('app-detail-drawer-close-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('extension-details-content')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('extension details open as a standalone route on mobile', (WidgetTester tester) async {
await _setTestSize(tester, const Size(320, 700));
final router = GoRouter(
initialLocation: '/extensions',
routes: [
GoRoute(
path: '/extensions',
builder: (context, state) => const ExtensionsPage(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => ExtensionDetailsPage(
extensionId: state.pathParameters['id'] ?? '',
initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null,
),
),
],
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
),
),
);
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('open-extension-details-extension-0'));
expect(MediaQuery.sizeOf(tester.element(card)).width, 320);
tester.widget<GestureDetector>(card).onTap!();
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('extension-details-content')), findsOneWidget);
expect(find.text('Extension 0 README'), findsOneWidget);
expect(find.byType(PrimaryBottomNavigation), findsNothing);
expect(find.byKey(const ValueKey('extensions-list-scroll-view')), findsNothing);
expect(find.byKey(const ValueKey('app-detail-back-button')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-close')), findsNothing);
expect(
GoRouterState.of(tester.element(find.byKey(const ValueKey('extension-details-content')))).uri.path,
'/extensions/extension-0',
);
expect(tester.takeException(), isNull);
});
for (final (width, details) in [(390.0, false), (1100.0, false), (1100.0, true)]) {
testWidgets('extension update confirms, shows progress and retries at $width details=$details', (tester) async {
await _setTestSize(tester, Size(width, 900));
final controller = UpdateableExtensionsController();
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(() => controller)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
if (details) {
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
await tester.pumpAndSettle();
}
final entry = find.byKey(ValueKey(details ? 'extension-details-update' : 'update-extension-extension-0'));
final dialog = find.byKey(const ValueKey('extension-update-dialog'));
final confirm = find.byKey(const ValueKey('confirm-update-extension-button'));
final cancel = find.byKey(const ValueKey('cancel-update-extension-button'));
await tester.tap(entry);
await tester.pumpAndSettle();
expect(dialog, findsOneWidget);
expect(find.text('Update “Extension 0” to the latest version?'), findsOneWidget);
expect(controller.updateCalls, 0);
await tester.tap(cancel);
await tester.pumpAndSettle();
expect(dialog, findsNothing);
expect(controller.updateCalls, 0);
await tester.tap(entry);
await tester.pumpAndSettle();
controller.failUpdate = true;
await tester.tap(confirm);
await tester.pump();
expect(controller.updateCalls, 1);
expect(tester.widget<AppLoadingButton>(confirm).loading, isTrue);
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
await tester.tap(confirm);
await tester.tapAt(const Offset(5, 5));
await tester.binding.handlePopRoute();
await tester.pump();
expect(controller.updateCalls, 1);
expect(dialog, findsOneWidget);
controller.updateGate.complete();
await tester.pumpAndSettle();
expect(dialog, findsOneWidget);
expect(find.byKey(const ValueKey('extension-update-error')), findsOneWidget);
expect(tester.widget<AppLoadingButton>(confirm).loading, isFalse);
controller.failUpdate = false;
controller.updateGate = Completer<void>();
await tester.tap(confirm);
await tester.pump();
expect(controller.updateCalls, 2);
controller.updateGate.complete();
await tester.pumpAndSettle();
expect(dialog, findsNothing);
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
testWidgets('installed extension details use a disabled installed button without a hero status', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1100, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
await tester.pumpAndSettle();
final hero = find.byKey(const ValueKey('extension-details-hero'));
final installedButton = find.byKey(const ValueKey('extension-details-installed'));
final primaryButton = find.descendant(of: installedButton, matching: find.byType(shad.PrimaryButton));
expect(find.descendant(of: hero, matching: find.text('Installed')), findsNothing);
expect(installedButton, findsOneWidget);
expect(find.descendant(of: installedButton, matching: find.text('Installed')), findsOneWidget);
expect(tester.widget<shad.PrimaryButton>(primaryButton).onPressed, isNull);
expect(tester.takeException(), isNull);
});
testWidgets('installed extension details prefer the local README', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
late Directory extensionDirectory;
await tester.runAsync(() async {
extensionDirectory = await Directory.systemTemp.createTemp('gopeed-extension-readme-');
await File(
'${extensionDirectory.path}${Platform.pathSeparator}README.md',
).writeAsString('# Local extension README\n\nLoaded from the installed extension.');
});
addTearDown(() => extensionDirectory.delete(recursive: true));
await tester.pumpWidget(
ProviderScope(
overrides: [
extensionsControllerProvider.overrideWith(() => DevReadmeExtensionsController(extensionDirectory.path)),
],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
await tester.pump(const Duration(milliseconds: 300));
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pump();
expect(find.text('Local extension README'), findsOneWidget);
expect(find.text('Extension 0 README'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('extension initial loading uses card skeletons without a progress spinner', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(LoadingExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pump();
expect(find.byKey(const ValueKey('extensions-initial-skeleton')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-skeleton-card-0')), findsOneWidget);
expect(find.byType(shad.CircularProgressIndicator), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('extension pagination keeps the bottom progress spinner', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(LoadingMoreExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byKey(const ValueKey('extensions-initial-skeleton')), findsNothing);
expect(find.byKey(const ValueKey('extension-card-extension-0')), findsOneWidget);
expect(find.byType(shad.CircularProgressIndicator), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('extensions mobile toolbar gives search its own row and keeps actions split below', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(390, 760));
tester.view.padding = const FakeViewPadding(top: 32, bottom: 24);
addTearDown(tester.view.resetPadding);
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const AppComponentThemes(child: ExtensionsPage()),
),
),
);
await tester.pumpAndSettle();
final searchRect = tester.getRect(find.byKey(const ValueKey('extension-search-input')));
final sortRect = tester.getRect(find.byKey(const ValueKey('extension-sort-control')));
final developRect = tester.getRect(find.byKey(const ValueKey('develop-extension-button')));
final installRect = tester.getRect(find.byKey(const ValueKey('install-extension-button')));
final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0')));
expect(searchRect.top, greaterThanOrEqualTo(32));
expect(searchRect.width, closeTo(358, 0.01));
expect(searchRect.width, closeTo(cardRect.width, 0.01));
expect(sortRect.top, greaterThan(searchRect.bottom));
expect(sortRect.left, closeTo(searchRect.left, 0.01));
expect(installRect.right, closeTo(searchRect.right, 0.01));
expect(installRect.center.dy, closeTo(sortRect.center.dy, 0.01));
expect(developRect.right, lessThan(installRect.left));
final list = find.byKey(const ValueKey('extensions-list-scroll-view'));
final scrollable = find.descendant(of: list, matching: find.byType(Scrollable));
await tester.drag(list, const Offset(0, -300));
await tester.pumpAndSettle();
expect(tester.getRect(find.byKey(const ValueKey('extension-search-input'))).top, closeTo(searchRect.top, 0.01));
expect(tester.state<ScrollableState>(scrollable).position.pixels, greaterThan(0));
expect(tester.takeException(), isNull);
final container = ProviderScope.containerOf(tester.element(find.byType(ExtensionsPage)));
ExtensionListFilter filter() => container.read(extensionsControllerProvider).requireValue.listFilter;
expect(filter(), ExtensionListFilter.market);
await tester.drag(list, const Offset(-160, 0));
await tester.pumpAndSettle();
expect(filter(), ExtensionListFilter.installed);
await tester.drag(list, const Offset(-160, 0));
await tester.pumpAndSettle();
expect(filter(), ExtensionListFilter.installed);
await tester.drag(list, const Offset(160, 0));
await tester.pumpAndSettle();
expect(filter(), ExtensionListFilter.market);
await tester.drag(list, const Offset(-20, 0));
await tester.pumpAndSettle();
expect(filter(), ExtensionListFilter.market);
tester.view.physicalSize = const Size(1024, 760);
await tester.pumpAndSettle();
await tester.drag(list, const Offset(-160, 0));
await tester.pumpAndSettle();
expect(filter(), ExtensionListFilter.market);
expect(tester.takeException(), isNull);
});
testWidgets('removing an extension requires explicit confirmation', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
late FakeExtensionsController controller;
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(() => controller = FakeExtensionsController())],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
final removeButton = find.byKey(const ValueKey('remove-extension-extension-0'));
await tester.tap(removeButton);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('remove-extension-dialog')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('cancel-remove-extension-button')));
await tester.pumpAndSettle();
expect(controller.removeCalls, 0);
expect(find.byKey(const ValueKey('remove-extension-dialog')), findsNothing);
await tester.tap(removeButton);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('confirm-remove-extension-button')));
await tester.pumpAndSettle();
expect(controller.removeCalls, 1);
expect(find.byKey(const ValueKey('remove-extension-dialog')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('extension market automatically loads the next page near the list bottom', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
late PaginatedExtensionsController controller;
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(() => controller = PaginatedExtensionsController())],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Load More'), findsNothing);
expect(controller.loadMoreCalls, 0);
await tester.fling(find.byKey(const ValueKey('extensions-list-scroll-view')), const Offset(0, -1400), 3000);
await tester.pumpAndSettle();
expect(controller.loadMoreCalls, 1);
expect(find.text('Load More'), findsNothing);
expect(find.byKey(const ValueKey('extensions-no-more-indicator')), findsOneWidget);
expect(find.text('No more extensions'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets(
'URL installation leaves toolbar icons unchanged while store installation shows progress',
(WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(BusyInstallExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final manualInstall = find.byKey(const ValueKey('install-extension-button'));
final storeInstall = find.byKey(const ValueKey('install-store-extension-extension-1'));
expect(find.descendant(of: manualInstall, matching: find.byType(shad.CircularProgressIndicator)), findsNothing);
expect(find.descendant(of: manualInstall, matching: find.byIcon(Icons.add_link)), findsOneWidget);
final localInstall = find.byKey(const ValueKey('load-local-extension-button'));
expect(localInstall, findsOneWidget);
expect(find.descendant(of: localInstall, matching: find.byType(shad.CircularProgressIndicator)), findsNothing);
expect(find.descendant(of: localInstall, matching: find.byIcon(Icons.folder_open_outlined)), findsOneWidget);
expect(find.descendant(of: storeInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget);
expect(find.descendant(of: storeInstall, matching: find.byIcon(Icons.download)), findsNothing);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
);
for (final (width, platform) in [
(320.0, TargetPlatform.android),
(390.0, TargetPlatform.iOS),
(768.0, TargetPlatform.android),
(1100.0, TargetPlatform.macOS),
]) {
testWidgets(
'scheme install automatically runs before and after mounting at width $width',
(tester) async {
await _setTestSize(tester, Size(width, 900));
final controller = FakeExtensionsController()..installGate = Completer<void>();
final container = ProviderContainer(overrides: [extensionsControllerProvider.overrideWith(() => controller)]);
addTearDown(container.dispose);
const firstUrl = 'https://github.com/author/扩展';
container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final input = find.byKey(const ValueKey('extension-install-url-input'));
expect(tester.widget<AppTextField>(input).controller!.text, firstUrl);
expect(container.read(pendingExtensionInstallProvider), isNull);
expect(controller.installCalls, 1);
expect(controller.lastInstallUrl, firstUrl);
final popoverRect = tester.getRect(find.byKey(const ValueKey('extension-install-popover')));
expect(popoverRect.left, greaterThanOrEqualTo(0));
expect(popoverRect.right, lessThanOrEqualTo(width));
expect(find.descendant(of: input, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget);
// Rebuilds and manual submission must not duplicate an active install.
await tester.tap(find.descendant(of: input, matching: find.byType(shad.IconButton)));
await tester.pump();
expect(controller.installCalls, 1);
controller.installGate!.complete();
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('extension-install-popover')), findsNothing);
// A warm link automatically installs from an already-open form too.
await tester.tap(find.byKey(const ValueKey('install-extension-button')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
const secondUrl = 'https://github.com/author/second';
controller.installGate = Completer<void>();
container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: secondUrl));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<AppTextField>(input).controller!.text, secondUrl);
expect(controller.installCalls, 2);
expect(controller.lastInstallUrl, secondUrl);
// A request arriving during installation waits until the active form closes.
container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl));
await tester.pump();
expect(controller.installCalls, 2);
controller.installGate!.complete();
controller.installGate = Completer<void>();
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
expect(controller.installCalls, 3);
expect(controller.lastInstallUrl, firstUrl);
controller.installGate!.complete();
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
await tester.pump(const Duration(seconds: 1));
},
variant: TargetPlatformVariant.only(platform),
);
}
testWidgets(
'install URL keeps focus after native menu paste',
(tester) async {
await _setTestSize(tester, const Size(390, 900));
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.hasStrings') return {'value': true};
if (call.method == 'Clipboard.getData') return {'text': 'https://github.com/author/repo'};
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
final search = find.byKey(const ValueKey('extension-search-input'));
await tester.tap(search);
await tester.tap(find.byKey(const ValueKey('install-extension-button')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final input = find.byKey(const ValueKey('extension-install-url-input'));
final editableFinder = find.descendant(of: input, matching: find.byType(EditableText));
final editable = tester.widget<EditableText>(editableFinder);
expect(editable.focusNode.hasFocus, isTrue);
if (defaultTargetPlatform == TargetPlatform.macOS) {
final gesture = await tester.startGesture(
tester.getCenter(input),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await gesture.up();
} else {
await tester.longPress(input);
}
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.text('Paste'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(input, findsOneWidget);
expect(editable.controller.text, 'https://github.com/author/repo');
expect(editable.focusNode.hasFocus, isTrue);
expect(
tester
.widget<EditableText>(find.descendant(of: search, matching: find.byType(EditableText)))
.focusNode
.hasFocus,
isFalse,
);
await tester.tapAt(const Offset(10, 500));
await tester.pumpAndSettle();
expect(input, findsNothing);
},
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS}),
);
testWidgets(
'mobile does not unlock local extension installation after five taps',
(tester) async {
// A tablet-sized mobile screen must still use mobile platform policy.
await _setTestSize(tester, const Size(1100, 900));
final controller = FakeExtensionsController();
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(() => controller)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
for (var i = 0; i < 5; i++) {
await tester.tap(find.byKey(const ValueKey('install-extension-button')));
await tester.pump(const Duration(milliseconds: 100));
}
expect(controller.devModeEnabled, isFalse);
expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing);
// Even a pre-existing dev-mode state must not expose the native folder action.
controller.enableDevModeForTest();
await tester.pump();
expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing);
},
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}),
);
testWidgets(
'extension install popover unlocks local loading and installed cards use switches',
(WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(shad.Switch), findsOneWidget);
expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing);
final installButton = find.byKey(const ValueKey('install-extension-button'));
expect(find.descendant(of: installButton, matching: find.byType(shad.IconButton)), findsOneWidget);
for (var index = 0; index < 5; index++) {
await tester.tap(installButton);
await tester.pump(const Duration(milliseconds: 100));
}
expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-install-url-input')), findsOneWidget);
final localButton = find.byKey(const ValueKey('load-local-extension-button'));
expect(localButton, findsOneWidget);
expect(find.descendant(of: localButton, matching: find.byType(shad.IconButton)), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant({TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux}),
);
testWidgets('responsive menu uses sidebar on desktop and two-level navigation on mobile', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(const _ResponsiveMenuHarness());
await tester.pumpAndSettle();
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.text('General content'), findsOneWidget);
await _setTestSize(tester, const Size(390, 760));
await tester.pumpWidget(const _ResponsiveMenuHarness());
await tester.pumpAndSettle();
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.text('General content'), findsNothing);
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.text('Advanced content'), findsOneWidget);
expect(find.text('SETTINGS'), findsNothing);
});
testWidgets('responsive menu keeps the full desktop sidebar from 720 pixels', (WidgetTester tester) async {
await _setTestSize(tester, const Size(800, 760));
await tester.pumpWidget(const _ResponsiveMenuHarness());
await tester.pumpAndSettle();
final pane = find.byKey(const ValueKey('secondary-navigation-pane'));
expect(tester.getSize(pane).width, AppDesignTokens.filterSidebarWidth);
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.text('General'), findsOneWidget);
expect(find.text('General content'), findsOneWidget);
});
testWidgets('responsive mobile menu reserves the safe area and uses comfortable entry sizes', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(390, 760));
tester.view.padding = const FakeViewPadding(top: 32, bottom: 24);
addTearDown(tester.view.resetPadding);
await tester.pumpWidget(const _ResponsiveMenuHarness());
await tester.pumpAndSettle();
final pane = find.byKey(const ValueKey('secondary-navigation-pane'));
final firstEntry = find.byKey(const ValueKey('secondary-navigation-item-0'));
expect(tester.getTopLeft(pane).dy, 32);
expect(tester.getSize(firstEntry).height, greaterThanOrEqualTo(52));
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
final mobileHeader = find.byKey(const ValueKey('mobile-content-header'));
expect(tester.getTopLeft(mobileHeader).dy, 32);
expect(tester.getSize(mobileHeader).height, 56);
});
testWidgets('settings use three top-level entries and ordered groups', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
expect(find.text('Basic'), findsWidgets);
expect(find.text('Downloads'), findsOneWidget);
expect(find.text('Advanced'), findsOneWidget);
expect(find.text('网络设置'), findsNothing);
expect(
tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width,
AppDesignTokens.filterSidebarWidth,
);
final primaryNavigationTop = tester.getTopLeft(find.byKey(const ValueKey('primary-rail-tasks-item'))).dy;
expect(
tester.getTopLeft(find.byKey(const ValueKey('secondary-navigation-item-0'))).dy,
closeTo(primaryNavigationTop, 0.01),
);
expect(
tester.getTopLeft(find.byKey(const ValueKey('settings-body-content'))).dy,
closeTo(primaryNavigationTop, 0.01),
);
expect(find.byKey(const ValueKey('theme-mode-system')), findsOneWidget);
expect(find.text('Light'), findsOneWidget);
expect(find.text('Dark'), findsOneWidget);
expect(find.text('Accent Color'), findsOneWidget);
expect(find.text('强调色'), findsNothing);
expect(find.byKey(const ValueKey('theme-color-green')), findsOneWidget);
expect(find.byKey(const ValueKey('theme-color-purple')), findsOneWidget);
expect(find.byKey(const ValueKey('settings-language-select')), findsOneWidget);
expect(find.text('Browser Extension'), findsOneWidget);
expect(find.byKey(const ValueKey('browser-extension-chrome')), findsOneWidget);
expect(find.byKey(const ValueKey('browser-extension-edge')), findsOneWidget);
expect(find.byKey(const ValueKey('browser-extension-firefox')), findsOneWidget);
expect(find.text('Version'), findsOneWidget);
expect(find.text('Current version -'), findsOneWidget);
expect(find.byKey(const ValueKey('check-app-update-button')), findsOneWidget);
expect(find.text('Check for Updates'), findsOneWidget);
expect(find.text('Usage Analytics'), findsOneWidget);
expect(find.text('Contributors'), findsOneWidget);
expect(find.byKey(const ValueKey('gopeed-homepage')), findsOneWidget);
expect(find.byKey(const ValueKey('gopeed-github')), findsOneWidget);
expect(find.byKey(const ValueKey('gopeed-contributors')), findsOneWidget);
final aboutOrder = [
'Homepage',
'GitHub',
'Contributors',
'Usage Analytics',
'Notify for updates',
'Version',
].map((label) => tester.getTopLeft(find.text(label)).dy).toList();
expect(aboutOrder, orderedEquals([...aboutOrder]..sort()));
for (final (key, label) in [
('gopeed-homepage', 'gopeed.com'),
('gopeed-github', 'github.com/GopeedLab/gopeed'),
('gopeed-contributors', 'View contributors'),
]) {
final link = find.byKey(ValueKey(key));
expect(find.descendant(of: link, matching: find.byType(shad.SecondaryButton)), findsNothing);
expect(find.descendant(of: link, matching: find.byIcon(Icons.open_in_new)), findsOneWidget);
expect(
tester.widget<Text>(find.descendant(of: link, matching: find.text(label))).style?.decoration,
TextDecoration.underline,
);
}
final swatchCenters = AppThemeColor.values
.map((color) => tester.getCenter(find.byKey(ValueKey('theme-color-${color.key}'))).dy)
.toList();
expect(swatchCenters.reduce((a, b) => a < b ? a : b), closeTo(swatchCenters.first, 0.01));
expect(swatchCenters.reduce((a, b) => a > b ? a : b), closeTo(swatchCenters.first, 0.01));
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('download-categories-editor')), findsOneWidget);
expect(find.text('Archives'), findsNothing);
final httpSettings = find.byKey(const ValueKey('settings-http-block'));
expect(find.descendant(of: httpSettings, matching: find.text('Extract Archives Automatically')), findsOneWidget);
expect(find.descendant(of: httpSettings, matching: find.text('Delete Archives After Extraction')), findsOneWidget);
expect(find.text('Delete .torrent file after BT task creation'), findsNothing);
expect(find.text('HTTP'), findsOneWidget);
expect(find.text('BitTorrent'), findsOneWidget);
expect(find.text('ED2K'), findsOneWidget);
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.text('Network'), findsOneWidget);
expect(find.text('API'), findsOneWidget);
expect(find.text('Developer'), findsOneWidget);
expect(tester.getTopLeft(find.text('API')).dy, lessThan(tester.getTopLeft(find.text('Network')).dy));
expect(tester.takeException(), isNull);
});
testWidgets('settings keep the desktop shell at the 720 pixel boundary', (WidgetTester tester) async {
await _setTestSize(tester, const Size(720, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
expect(find.byType(PrimaryRail), findsOneWidget);
expect(find.byType(PrimaryBottomNavigation), findsNothing);
expect(
tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width,
AppDesignTokens.filterSidebarWidth,
);
expect(tester.takeException(), isNull);
});
testWidgets('dependent settings follow the legacy visibility rules', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
Finder settingSwitch(String label) => find.descendant(
of: find.ancestor(of: find.text(label), matching: find.byType(SettingsItem)),
matching: find.byType(shad.Switch),
);
await tester.tap(settingSwitch('Extract Archives Automatically'));
await tester.pumpAndSettle();
expect(find.text('Delete Archives After Extraction'), findsNothing);
await tester.tap(settingSwitch('Auto create BT tasks from .torrent files'));
await tester.pumpAndSettle();
expect(find.text('Delete .torrent file after BT task creation'), findsOneWidget);
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.text('Protocol'), findsNothing);
expect(find.text('Unix Socket'), findsNothing);
expect(find.text('Username'), findsNothing);
await tester.tap(find.text('Custom Proxy'));
await tester.pumpAndSettle();
expect(find.text('Protocol'), findsOneWidget);
expect(find.text('Username'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
});
testWidgets('numeric settings use compact bounded spinner inputs', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final settingsController = FakeSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final maxRunning = find.byKey(const ValueKey('max-running-input'));
expect(maxRunning, findsOneWidget);
expect(find.text('Maximum number of tasks that may download at the same time'), findsOneWidget);
expect(tester.getSize(maxRunning).width, AppDesignTokens.settingsNumberControlWidth);
expect(tester.widget<shad.TextField>(maxRunning).features.single, isA<shad.InputSpinnerFeature>());
await tester.enterText(maxRunning, '999');
await tester.pump(const Duration(milliseconds: 700));
expect(tester.widget<shad.TextField>(maxRunning).controller!.text, '256');
expect(settingsController.state.requireValue.config.maxRunning, 256);
final seedRatio = find.byKey(const ValueKey('bt-seed-ratio-input'));
await tester.ensureVisible(seedRatio);
await tester.pumpAndSettle();
await tester.enterText(seedRatio, '1.1');
final seedRatioStepperButtons = find.descendant(of: seedRatio, matching: find.byType(shad.IconButton));
expect(seedRatioStepperButtons, findsNWidgets(2));
await tester.tap(seedRatioStepperButtons.first);
await tester.pump();
expect(tester.widget<shad.TextField>(seedRatio).controller!.text, '1.2');
expect(tester.takeException(), isNull);
});
testWidgets('download directory keeps long text clear of its aligned picker action', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(CategorySettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final directoryInput = find.byKey(const ValueKey('download-directory-input'));
final directoryPicker = find.byKey(const ValueKey('download-directory-picker'));
final standardInput = find.byKey(const ValueKey('http-user-agent-input'));
expect(find.text('Automatically save last task directory'), findsNothing);
expect(tester.getSize(directoryInput).height, tester.getSize(standardInput).height);
final directoryField = tester.widget<shad.TextField>(directoryInput);
final standardField = tester.widget<shad.TextField>(standardInput);
expect(directoryField.filled, standardField.filled);
expect(directoryField.border, standardField.border);
expect(directoryField.borderRadius, standardField.borderRadius);
expect(directoryField.padding, standardField.padding);
const longDirectory =
'/storage/emulated/0/Download/a-very-long-directory-name-that-must-not-run-under-the-picker-icon';
await tester.enterText(directoryInput, longDirectory);
await tester.pump();
final editableText = find.descendant(of: directoryInput, matching: find.byType(EditableText));
expect(tester.getRect(editableText).right, lessThan(tester.getRect(directoryPicker).left));
expect(find.descendant(of: directoryInput, matching: directoryPicker), findsOneWidget);
expect(tester.getRect(directoryPicker).right, closeTo(tester.getRect(directoryInput).right, 0.01));
expect(tester.widget<shad.IconButton>(directoryPicker).variance, same(shad.ButtonVariance.outline));
final pathTooltip = find.ancestor(of: directoryInput, matching: find.byType(AppTooltip));
expect(pathTooltip, findsOneWidget);
expect(tester.widget<AppTooltip>(pathTooltip).message, longDirectory);
final categoryActions = find.descendant(
of: find.byKey(const ValueKey('download-categories-editor')),
matching: find.byType(shad.GhostButton),
);
expect(categoryActions, findsWidgets);
expect(tester.getSize(directoryPicker).height, tester.getSize(directoryInput).height);
expect(tester.getTopRight(directoryPicker).dx, closeTo(tester.getTopRight(categoryActions.last).dx, 0.01));
expect(tester.takeException(), isNull);
});
testWidgets('android download directory picker only switches to verified raw paths', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android;
addTearDown(() => DownloadDirectoryPicker.debugPlatformOverride = null);
final calls = <String>[];
DownloadDirectoryPicker.debugAndroidLocationsLoader = () async {
calls.add('getLocations');
return <String, String>{
'application': '/storage/emulated/0/Android/data/com.gopeed.gopeed/files',
'downloads': '/storage/emulated/0/Download/Gopeed',
};
};
DownloadDirectoryPicker.debugDownloadsPreparer = (path) async {
calls.add('prepareDownloads');
return path;
};
addTearDown(() {
DownloadDirectoryPicker.debugAndroidLocationsLoader = null;
DownloadDirectoryPicker.debugDownloadsPreparer = null;
});
final controller = TextEditingController(text: '/storage/emulated/0/Android/data/com.gopeed.gopeed/files');
addTearDown(controller.dispose);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(20),
child: AppPathPickerField.downloadDirectory(
controller: controller,
fieldKey: const ValueKey('android-directory-input'),
pickerKey: const ValueKey('android-directory-picker'),
),
),
),
);
expect(tester.widget<shad.TextField>(find.byKey(const ValueKey('android-directory-input'))).readOnly, isTrue);
await tester.tap(find.byKey(const ValueKey('android-directory-picker')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('android-download-directory-dialog')), findsOneWidget);
expect(find.text('App storage'), findsOneWidget);
expect(find.text('Download/Gopeed'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option')));
await tester.pumpAndSettle();
expect(controller.text, '/storage/emulated/0/Download/Gopeed');
expect(calls, ['getLocations', 'prepareDownloads']);
expect(find.byKey(const ValueKey('android-download-directory-dialog')), findsNothing);
expect(tester.takeException(), isNull);
DownloadDirectoryPicker.debugPlatformOverride = null;
});
testWidgets('ios download directory is fixed and has no picker action', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.iOS;
addTearDown(() => DownloadDirectoryPicker.debugPlatformOverride = null);
final controller = TextEditingController(text: '/app/Documents');
addTearDown(controller.dispose);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(20),
child: AppPathPickerField.downloadDirectory(
controller: controller,
fieldKey: const ValueKey('ios-directory-input'),
pickerKey: const ValueKey('ios-directory-picker'),
),
),
),
);
expect(tester.widget<shad.TextField>(find.byKey(const ValueKey('ios-directory-input'))).readOnly, isTrue);
expect(find.byKey(const ValueKey('ios-directory-picker')), findsNothing);
expect(tester.takeException(), isNull);
DownloadDirectoryPicker.debugPlatformOverride = null;
});
testWidgets('web download directory supports manual input without a picker action', (WidgetTester tester) async {
await _setTestSize(tester, const Size(700, 300));
DownloadDirectoryPicker.debugWebOverride = true;
addTearDown(() => DownloadDirectoryPicker.debugWebOverride = null);
final controller = TextEditingController(text: '/downloads');
addTearDown(controller.dispose);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(20),
child: AppPathPickerField.downloadDirectory(
controller: controller,
fieldKey: const ValueKey('web-directory-input'),
pickerKey: const ValueKey('web-directory-picker'),
),
),
),
);
expect(tester.widget<shad.TextField>(find.byKey(const ValueKey('web-directory-input'))).readOnly, isFalse);
expect(find.byKey(const ValueKey('web-directory-picker')), findsNothing);
await tester.enterText(find.byKey(const ValueKey('web-directory-input')), '/downloads/manual');
expect(controller.text, '/downloads/manual');
});
testWidgets('ED2K list settings use multiline text and persist comma-separated values', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final settingsController = Ed2kSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final servers = find.byKey(const ValueKey('ed2k-server-address-input'));
final serverMet = find.byKey(const ValueKey('ed2k-server-met-input'));
final nodesDat = find.byKey(const ValueKey('ed2k-nodes-dat-input'));
expect(tester.widget<shad.TextField>(servers).controller!.text, 'server-a:4661\nserver-b:4661');
expect(tester.widget<shad.TextField>(servers).maxLines, 5);
expect(tester.widget<shad.TextField>(serverMet).maxLines, 4);
expect(tester.widget<shad.TextField>(nodesDat).maxLines, 4);
await tester.ensureVisible(servers);
await tester.pumpAndSettle();
await tester.enterText(servers, 'one.example:4661\ntwo.example:4661');
await tester.pump(const Duration(milliseconds: 700));
expect(
settingsController.state.requireValue.config.protocolConfig.ed2k.serverAddr,
'one.example:4661,two.example:4661',
);
expect(tester.takeException(), isNull);
});
testWidgets('proxy and API TCP addresses use separate host and port inputs', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final settingsController = ProxySettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
String inputText(String key) => tester.widget<shad.TextField>(find.byKey(ValueKey(key))).controller!.text;
expect(inputText('proxy-host-input'), 'proxy.example.com');
expect(inputText('proxy-port-input'), '1080');
expect(inputText('api-host-input'), '127.0.0.1');
expect(inputText('api-port-input'), '9999');
expect(
tester.widget<shad.TextField>(find.byKey(const ValueKey('proxy-port-input'))).features.single,
isA<shad.InputSpinnerFeature>(),
);
await tester.enterText(find.byKey(const ValueKey('proxy-port-input')), '3128');
await tester.pump(const Duration(milliseconds: 700));
expect(settingsController.state.requireValue.config.proxy.host, 'proxy.example.com:3128');
expect(tester.takeException(), isNull);
});
testWidgets('API settings keep a validated draft until the save button is pressed', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final runtimeController = RecordingStartConfigRuntimeController();
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') {
clipboardText = (call.arguments as Map<Object?, Object?>)['text'] as String?;
}
return null;
});
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(() => runtimeController),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(dark: true, wrapComponentThemes: true),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.text('Runtime status'), findsOneWidget);
expect(find.text('http://192.168.1.20:4321'), findsOneWidget);
expect(find.byKey(const ValueKey('api-server-status-dot')), findsOneWidget);
final host = find.byKey(const ValueKey('api-host-input'));
final port = find.byKey(const ValueKey('api-port-input'));
final saveButton = find.byKey(const ValueKey('save-api-config-button'));
expect(tester.widget<AppLoadingButton>(saveButton).variant, AppLoadingButtonVariant.primary);
expect(tester.widget<shad.TextField>(host).controller!.text, '192.168.1.20');
expect(tester.widget<shad.TextField>(port).controller!.text, '4321');
expect(tester.widget<AppLoadingButton>(saveButton).onPressed, isNull);
expect(find.byKey(const ValueKey('mcp-endpoint-switch')), findsOneWidget);
expect(find.byType(McpSettingsItem), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-ai-icon')), findsOneWidget);
expect(find.text('Collaborate with AI agents and explore limitless possibilities'), findsOneWidget);
expect(find.text('Setup guide'), findsOneWidget);
final agentSetupLink = find.byKey(const ValueKey('open-mcp-agent-setup'));
expect(
tester.getRect(agentSetupLink).left,
lessThan(tester.getRect(find.byKey(const ValueKey('mcp-endpoint-switch'))).left),
);
await tester.ensureVisible(agentSetupLink);
await tester.tap(agentSetupLink);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('mcp-agent-setup-dialog')), findsOneWidget);
expect(find.byTooltip('Cursor'), findsOneWidget);
expect(find.text('Codex · MCP'), findsOneWidget);
expect(find.textContaining('http://192.168.1.20:4321/mcp'), findsOneWidget);
expect(find.textContaining("GOPEED_API_TOKEN='••••••••'"), findsOneWidget);
expect(find.textContaining('bearer-token-env-var GOPEED_API_TOKEN'), findsOneWidget);
expect(find.textContaining("GOPEED_API_TOKEN='token'"), findsNothing);
expect(find.textContaining('token placeholder'), findsNothing);
final copySnippet = find.byKey(const ValueKey('copy-mcp-agent-snippet'));
expect(tester.widget(copySnippet), isA<AppCopyIconButton>());
expect(find.descendant(of: copySnippet, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget);
expect(
tester.widget<AppTooltip>(find.descendant(of: copySnippet, matching: find.byType(AppTooltip))).message,
'Copy',
);
await tester.tap(copySnippet);
await tester.pump();
expect(clipboardText, contains("export GOPEED_API_TOKEN='token'"));
expect(clipboardText, contains('--bearer-token-env-var GOPEED_API_TOKEN'));
expect(clipboardText, isNot(contains('••••••••')));
expect(find.descendant(of: copySnippet, matching: find.byIcon(Icons.check)), findsOneWidget);
expect(
tester.widget<AppTooltip>(find.descendant(of: copySnippet, matching: find.byType(AppTooltip))).message,
'Copied',
);
await tester.tap(find.byKey(const ValueKey('mcp-agent-cursor')));
await tester.pumpAndSettle();
expect(find.textContaining('Authorization'), findsOneWidget);
expect(find.textContaining('Bearer ••••••••'), findsOneWidget);
expect(find.textContaining('Bearer token'), findsNothing);
expect(find.descendant(of: copySnippet, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget);
expect(
tester.widget<AppTooltip>(find.descendant(of: copySnippet, matching: find.byType(AppTooltip))).message,
'Copy',
);
await tester.tap(copySnippet);
await tester.pump();
expect(clipboardText, contains('Bearer token'));
await tester.tap(find.byKey(const ValueKey('mcp-agent-claude-code')));
await tester.pumpAndSettle();
expect(find.textContaining('claude mcp add --transport http'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup')));
await tester.pumpAndSettle();
final mcpSwitch = find.byKey(const ValueKey('mcp-endpoint-switch'));
await Scrollable.ensureVisible(tester.element(mcpSwitch), alignment: 0.5);
await tester.pumpAndSettle();
await tester.tap(mcpSwitch);
await tester.pump();
expect(tester.widget<AppLoadingButton>(saveButton).onPressed, isNotNull);
await tester.enterText(host, '');
await tester.enterText(port, '0');
await tester.pump(const Duration(seconds: 1));
expect(runtimeController.savedStartConfig, isNull);
expect(find.text('Unsaved API changes'), findsNothing);
expect(tester.widget<AppLoadingButton>(saveButton).onPressed, isNotNull);
await tester.ensureVisible(saveButton);
await tester.pumpAndSettle();
await tester.tap(saveButton);
await tester.pumpAndSettle();
expect(runtimeController.savedStartConfig, isNull);
await tester.enterText(host, '127.0.0.1');
await tester.tap(saveButton);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('restart-api-server-dialog')), findsOneWidget);
expect(runtimeController.savedStartConfig, isNull);
await tester.tap(find.byKey(const ValueKey('confirm-restart-api-server-button')));
await tester.pumpAndSettle();
expect(runtimeController.savedStartConfig?.network, 'tcp');
expect(runtimeController.savedStartConfig?.address, '127.0.0.1:0');
expect(runtimeController.savedStartConfig?.mcpEnable, isTrue);
expect(tester.widget<AppLoadingButton>(saveButton).onPressed, isNull);
expect(find.text('Restart required'), findsNothing);
expect(find.text('Auto saved'), findsNothing);
tester.view.physicalSize = const Size(390, 900);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.text('Unix Socket'), findsNothing);
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('read-only MCP settings keep the Agent setup link available', (WidgetTester tester) async {
var changeCount = 0;
var openCount = 0;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(
child: McpSettingsItem(
enabled: true,
readOnly: true,
onChanged: (_) => changeCount += 1,
onOpenAgentSetup: () => openCount += 1,
),
),
),
);
await tester.pump();
final switchFinder = find.byKey(const ValueKey('mcp-endpoint-switch'));
expect(tester.widget<shad.Switch>(switchFinder).enabled, isFalse);
await tester.tap(switchFinder);
await tester.pump();
expect(changeCount, 0);
await tester.tap(find.byKey(const ValueKey('open-mcp-agent-setup')));
await tester.pump();
expect(openCount, 1);
});
testWidgets('Web MCP Agent setup switches once to an API token template and resets on reopen', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(800, 700));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => Center(
child: shad.PrimaryButton(
onPressed: () => unawaited(
showMcpAgentSetupDialog(
context,
endpoint: 'https://example.com/mcp',
apiToken: 'stale-web-token',
mcpRunning: true,
allowManualApiTokenTemplate: true,
),
),
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final tokenHelp = find.byKey(const ValueKey('mcp-api-token-template-help'));
expect(tokenHelp, findsOneWidget);
expect(find.descendant(of: tokenHelp, matching: find.byIcon(Icons.help_outline)), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-api-token-template-enabled')), findsNothing);
expect(find.textContaining('<API_TOKEN>'), findsNothing);
expect(find.textContaining('stale-web-token'), findsNothing);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
await mouse.moveTo(tester.getCenter(tokenHelp));
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('mcp-api-token-template-hint')), findsOneWidget);
expect(
find.text('If you configured an API token, authentication information is required when connecting the agent'),
findsOneWidget,
);
final useTokenTemplate = find.byKey(const ValueKey('use-mcp-api-token-template'));
expect(useTokenTemplate, findsOneWidget);
expect(find.text('Switch to authenticated configuration'), findsOneWidget);
await tester.tap(useTokenTemplate);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('mcp-api-token-template-help')), findsOneWidget);
expect(find.descendant(of: tokenHelp, matching: find.byIcon(Icons.help_outline)), findsOneWidget);
expect(find.textContaining("GOPEED_API_TOKEN='<API_TOKEN>'"), findsOneWidget);
expect(find.textContaining('--bearer-token-env-var GOPEED_API_TOKEN'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup')));
await tester.pumpAndSettle();
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('mcp-api-token-template-help')), findsOneWidget);
expect(find.textContaining('<API_TOKEN>'), findsNothing);
});
testWidgets('MCP Agent setup dialog fits a compact mobile viewport', (WidgetTester tester) async {
await _setTestSize(tester, const Size(320, 568));
var mcpRunning = false;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => Center(
child: shad.PrimaryButton(
onPressed: () => unawaited(
showMcpAgentSetupDialog(
context,
endpoint: 'http://127.0.0.1:9999/mcp',
apiToken: mcpRunning ? '' : 'mobile-secret-token',
mcpRunning: mcpRunning,
),
),
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('mcp-agent-setup-dialog')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-codex')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-claude-code')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-cursor')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-github-copilot')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-windsurf')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-gemini-cli')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-cline')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-opencode')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-trae')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-qoder')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-codebuddy')), findsNothing);
expect(find.byKey(const ValueKey('mcp-agent-workbuddy')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-zcode')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-deepseek-harness')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-agent-pi')), findsOneWidget);
expect(find.byTooltip('DeepSeek Harness'), findsOneWidget);
expect(find.text('Codex · MCP'), findsOneWidget);
expect(find.text('Cursor'), findsNothing);
expect(find.textContaining('mobi••••••••oken'), findsOneWidget);
expect(find.textContaining('mobile-secret-token'), findsNothing);
final codexPosition = tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-codex')));
final claudePosition = tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-claude-code')));
final cursorPosition = tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-cursor')));
expect(codexPosition.dy, claudePosition.dy);
expect(claudePosition.dy, cursorPosition.dy);
expect(codexPosition.dx, lessThan(claudePosition.dx));
expect(claudePosition.dx, lessThan(cursorPosition.dx));
expect(
tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-cursor'))).dy,
lessThan(tester.getTopLeft(find.byKey(const ValueKey('mcp-agent-pi'))).dy),
);
expect(find.byKey(const ValueKey('mcp-not-running-hint')), findsOneWidget);
expect(find.text('The MCP service is not enabled. Please enable it'), findsOneWidget);
expect(find.text('Generic'), findsNothing);
expect(tester.getSize(find.byKey(const ValueKey('mcp-agent-snippet'))).height, 176);
expect(find.byKey(const ValueKey('mcp-snippet-horizontal-scrollbar')), findsOneWidget);
expect(find.byKey(const ValueKey('mcp-snippet-vertical-scrollbar')), findsOneWidget);
expect(tester.getRect(find.byKey(const ValueKey('mcp-agent-snippet'))).right, lessThanOrEqualTo(320));
expect(tester.getRect(find.byKey(const ValueKey('close-mcp-agent-setup'))).right, lessThanOrEqualTo(320));
expect(find.byKey(const ValueKey('finish-mcp-agent-setup')), findsNothing);
expect(tester.takeException(), isNull);
final deepSeek = find.byKey(const ValueKey('mcp-agent-deepseek-harness'));
await tester.ensureVisible(deepSeek);
await tester.tap(deepSeek);
await tester.pumpAndSettle();
expect(find.text('DeepSeek Harness · MCP'), findsOneWidget);
expect(find.textContaining('@deepseek-ai/dsh-mcp-client'), findsOneWidget);
expect(tester.getSize(find.byKey(const ValueKey('mcp-agent-snippet'))).height, 176);
final pi = find.byKey(const ValueKey('mcp-agent-pi'));
await tester.ensureVisible(pi);
await tester.tap(pi);
await tester.pumpAndSettle();
expect(find.textContaining('pi install npm:pi-codemcp'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup')));
await tester.pumpAndSettle();
mcpRunning = true;
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('mcp-not-running-hint')), findsNothing);
expect(find.textContaining('codex mcp add gopeed --url'), findsOneWidget);
});
testWidgets('disabling the API server requires confirmation and keeps listener fields visible', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1024, 900));
final runtimeController = RecordingStartConfigRuntimeController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(() => runtimeController),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
final hostInput = find.byKey(const ValueKey('api-host-input'));
await tester.enterText(hostInput, 'unsaved.example.com');
final toggleButton = find.byKey(const ValueKey('toggle-api-server-button'));
await tester.ensureVisible(toggleButton);
await tester.pumpAndSettle();
await tester.tap(toggleButton);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('disable-api-server-dialog')), findsOneWidget);
expect(find.byKey(const ValueKey('api-host-input')), findsOneWidget);
expect(find.byKey(const ValueKey('api-port-input')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('cancel-disable-api-server-button')));
await tester.pumpAndSettle();
expect(runtimeController.savedStartConfig, isNull);
await tester.tap(toggleButton);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('confirm-disable-api-server-button')));
await tester.pumpAndSettle();
expect(runtimeController.savedStartConfig?.apiEnable, isFalse);
expect(runtimeController.savedStartConfig?.address, '192.168.1.20:4321');
expect(tester.widget<shad.TextField>(hostInput).controller!.text, 'unsaved.example.com');
final statusDot = tester.widget<AnimatedContainer>(find.byKey(const ValueKey('api-server-status-dot')));
expect((statusDot.decoration! as BoxDecoration).color, AppPalette.light.textMuted.withValues(alpha: 0.72));
expect(find.text('http://192.168.1.20:4321'), findsNothing);
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('shared toast appears at top center with content-driven width', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 700));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Builder(
builder: (context) => Center(
child: shad.PrimaryButton(
onPressed: () => showAppToast(context, '保存成功', type: AppToastType.success),
child: const Text('Show toast'),
),
),
),
),
),
);
await tester.tap(find.text('Show toast'));
await tester.pump(const Duration(milliseconds: 600));
final toast = find.byKey(const ValueKey('app-toast-content'));
expect(toast, findsOneWidget);
expect(tester.getSize(toast).width, lessThan(320));
final toastRect = tester.getRect(toast);
expect(toastRect.center.dx, closeTo(512, 0.01));
expect(toastRect.top, lessThan(80));
final decoration = tester.widget<Container>(toast).decoration! as BoxDecoration;
expect((decoration.border! as Border).top.color, AppPalette.light.border);
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('tracker subscriptions support selecting and clearing all entries', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
tester.platformDispatcher.textScaleFactorTestValue = 1.2;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final settingsController = FakeSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final selectAll = find.byKey(const ValueKey('tracker-select-all'));
expect(selectAll, findsOneWidget);
expect(find.descendant(of: selectAll, matching: find.text('Select All')), findsOneWidget);
await tester.ensureVisible(selectAll);
await tester.pumpAndSettle();
final horizontalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
expect(horizontalScrollbar.thumbVisibility, isTrue);
expect(horizontalScrollbar.controller?.hasClients, isTrue);
expect(horizontalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
final verticalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-vertical-scrollbar')));
expect(verticalScrollbar.controller?.hasClients, isTrue);
expect(verticalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
final scrollbarRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
final scrollViewRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scroll-view')));
expect(scrollbarRect.bottom - scrollViewRect.bottom, AppDesignTokens.space12);
expect(find.text(allTrackerSubscribeUrls.first), findsOneWidget);
horizontalScrollbar.controller!.jumpTo(horizontalScrollbar.controller!.position.maxScrollExtent);
await tester.pump();
expect(horizontalScrollbar.controller!.offset, greaterThan(0));
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, allTrackerSubscribeUrls);
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, isEmpty);
expect(tester.takeException(), isNull);
});
testWidgets('tracker update button keeps its loading indicator circular', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final runtimeController = PendingTrackerRuntimeController();
final settingsController = StaticSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(() => runtimeController),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final updateButton = find.byKey(const ValueKey('tracker-update-button'));
await tester.ensureVisible(updateButton);
await tester.pumpAndSettle();
final sectionScrollView = find.byKey(const ValueKey('settings-section-scroll-view'));
final sectionScrollable = find.descendant(of: sectionScrollView, matching: find.byType(Scrollable)).first;
final offsetBeforeUpdate = tester.state<ScrollableState>(sectionScrollable).position.pixels;
expect(offsetBeforeUpdate, greaterThan(0));
await tester.tap(updateButton);
await tester.pump();
final progress = find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator));
expect(progress, findsOneWidget);
expect(tester.getSize(progress), const Size.square(14));
expect(find.descendant(of: updateButton, matching: find.text('Update Now')), findsOneWidget);
runtimeController.completeTrackerUpdate();
await tester.pumpAndSettle();
expect(settingsController.reloadCalls, 1);
expect(settingsController.lastReloadShowLoading, isFalse);
expect(tester.state<ScrollableState>(sectionScrollable).position.pixels, closeTo(offsetBeforeUpdate, 0.01));
expect(tester.takeException(), isNull);
});
testWidgets('loading button preserves its content dimensions', (WidgetTester tester) async {
var loading = false;
late StateSetter setButtonState;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(
child: StatefulBuilder(
builder: (context, update) {
setButtonState = update;
return AppLoadingButton(
key: const ValueKey('dimension-stable-loading-button'),
onPressed: () {},
loading: loading,
variant: AppLoadingButtonVariant.primary,
child: const Text('Confirm'),
);
},
),
),
),
);
await tester.pump();
final button = find.byKey(const ValueKey('dimension-stable-loading-button'));
final idleSize = tester.getSize(button);
setButtonState(() => loading = true);
await tester.pump();
expect(tester.getSize(button), idleSize);
expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget);
});
testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final runtimeController = FailingTrackerRuntimeController();
final settingsController = StaticSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(() => runtimeController),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final updateButton = find.byKey(const ValueKey('tracker-update-button'));
await tester.ensureVisible(updateButton);
await tester.pumpAndSettle();
final sectionScrollView = find.byKey(const ValueKey('settings-section-scroll-view'));
final sectionScrollable = find.descendant(of: sectionScrollView, matching: find.byType(Scrollable)).first;
final offsetBeforeUpdate = tester.state<ScrollableState>(sectionScrollable).position.pixels;
await tester.tap(updateButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(settingsController.reloadCalls, 0);
expect(tester.state<ScrollableState>(sectionScrollable).position.pixels, closeTo(offsetBeforeUpdate, 0.01));
expect(find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator)), findsNothing);
expect(find.byKey(const ValueKey('app-toast-content')), findsOneWidget);
expect(find.textContaining('tracker update failed'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
});
testWidgets('settings list add buttons align responsively', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
for (final key in const [ValueKey('add-webhook-button'), ValueKey('add-script-button')]) {
final button = find.byKey(key);
expect(button, findsOneWidget);
final editor = find.ancestor(of: button, matching: find.byType(SettingsListEditor));
expect(tester.getRect(button).right, closeTo(tester.getRect(editor).right, 0.01));
}
tester.view.physicalSize = const Size(390, 900);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
for (final key in const [ValueKey('add-webhook-button'), ValueKey('add-script-button')]) {
final button = find.byKey(key);
final editor = find.ancestor(of: button, matching: find.byType(SettingsListEditor));
expect(tester.getRect(button).left, closeTo(tester.getRect(editor).left, 0.01));
}
expect(tester.takeException(), isNull);
});
testWidgets('download categories use structured add and list controls', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(CategorySettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
expect(find.text('资料'), findsOneWidget);
expect(find.text('/tmp/data'), findsOneWidget);
final categoryEditor = find.byKey(const ValueKey('download-categories-editor'));
final addCategoryButton = find.byKey(const ValueKey('add-download-category'));
expect(addCategoryButton, findsOneWidget);
expect(tester.getRect(addCategoryButton).right, closeTo(tester.getRect(categoryEditor).right, 0.01));
expect(tester.getRect(addCategoryButton).bottom, closeTo(tester.getRect(categoryEditor).bottom, 0.01));
await tester.tap(addCategoryButton);
await tester.pumpAndSettle();
expect(find.byType(DownloadCategoriesControl), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('download-category-name')), '归档');
await tester.enterText(find.byKey(const ValueKey('download-category-path')), '/tmp/archive');
await tester.tap(find.text('Insert Placeholder'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('%date% — Full date (YYYY-MM-DD)'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('path-placeholder-%date%')));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('归档'), findsOneWidget);
expect(find.text('/tmp/archive%date%'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('edit-download-category-资料')));
await tester.pumpAndSettle();
expect(find.text('Edit Category'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Edit Category'), findsNothing);
expect(find.text('资料'), findsOneWidget);
tester.view.physicalSize = const Size(390, 900);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
expect(tester.getRect(addCategoryButton).left, closeTo(tester.getRect(categoryEditor).left, 0.01));
expect(tester.takeException(), isNull);
});
testWidgets('settings content stretches responsively and stays centered', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1800, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
Rect contentRect() => tester.getRect(find.byKey(const ValueKey('settings-body-content')));
Rect sidebarRect() => tester.getRect(find.byKey(const ValueKey('secondary-navigation-pane')));
final wideContent = contentRect();
final wideSidebar = sidebarRect();
expect(wideContent.width, AppDesignTokens.settingsContentMaxWidth);
expect(wideContent.left - wideSidebar.right, closeTo(1800 - wideContent.right, 0.01));
expect(wideContent.left - wideSidebar.right, greaterThan(AppDesignTokens.space24));
tester.view.physicalSize = const Size(1280, 900);
await tester.pumpAndSettle();
final standardDesktopContent = contentRect();
final standardDesktopSidebar = sidebarRect();
expect(standardDesktopContent.width, AppDesignTokens.settingsContentMaxWidth);
expect(
standardDesktopContent.left - standardDesktopSidebar.right,
closeTo(1280 - standardDesktopContent.right, 0.01),
);
expect(standardDesktopContent.left - standardDesktopSidebar.right, greaterThan(AppDesignTokens.space24));
tester.view.physicalSize = const Size(910, 900);
await tester.pumpAndSettle();
final compactContent = contentRect();
final compactSidebar = sidebarRect();
expect(compactContent.left - compactSidebar.right, AppDesignTokens.space24);
expect(910 - compactContent.right, AppDesignTokens.space24);
expect(tester.takeException(), isNull);
});
testWidgets('settings items keep controls wider while labels grow responsively', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1000, 700));
const subtitle = '这是一段足够长的设置说明,用于验证窗口变宽以后左侧说明区域会随可用空间一起增长';
Future<({Size label, Size control})> layoutAt(double width) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(
child: SizedBox(
width: width,
child: const SettingsItem(
title: '设置项',
subtitle: subtitle,
child: SizedBox(key: ValueKey('responsive-setting-control'), width: 220, height: 32),
),
),
),
),
);
await tester.pumpAndSettle();
return (
label: tester.getSize(find.text(subtitle)),
control: tester.getSize(find.byKey(const ValueKey('responsive-setting-control'))),
);
}
final compact = await layoutAt(600);
final wide = await layoutAt(800);
expect(wide.label.width, greaterThan(compact.label.width));
expect(wide.label.height, lessThan(compact.label.height));
expect(compact.control.width, 220);
expect(wide.control.width, 220);
});
testWidgets('available app update uses a theme-colored icon and text button', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(AvailableUpdatePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
expect(find.text('Current version -'), findsOneWidget);
expect(find.textContaining('9.9.9'), findsNothing);
expect(find.byKey(const ValueKey('app-update-available-indicator')), findsNothing);
expect(find.text('Update'), findsOneWidget);
final button = find.byKey(const ValueKey('check-app-update-button'));
expect(find.descendant(of: button, matching: find.byIcon(Icons.system_update_alt_outlined)), findsOneWidget);
expect(find.ancestor(of: find.text('Update'), matching: find.byType(shad.SecondaryButton)), findsOneWidget);
});
testWidgets('checking for updates replaces only the icon with a neutral loader', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(CheckingUpdatePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(wrapComponentThemes: true),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final button = find.byKey(const ValueKey('check-app-update-button'));
final progress = find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator));
expect(progress, findsOneWidget);
expect(tester.widget<shad.CircularProgressIndicator>(progress).color, AppPalette.light.textMuted);
expect(tester.widget<shad.CircularProgressIndicator>(progress).backgroundColor, AppPalette.light.border);
expect(find.descendant(of: button, matching: find.text('Check for Updates')), findsOneWidget);
expect(find.text('正在检查'), findsNothing);
});
testWidgets('settings language uses a dropdown and saves the selected locale', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
final settingsController = FakeSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
await tester.pumpAndSettle();
expect(find.text('English'), findsOneWidget);
expect(SettingsLanguageSelect.supportedValues, hasLength(22));
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
expect(supportedLocaleFromConfig('zh_CN'), isNull);
expect(supportedLocaleFromConfig('pt_BR'), isNull);
expect(supportedLocaleFromConfig('zh'), const Locale('zh'));
expect(supportedLocaleFromConfig('zh_TW'), const Locale('zh', 'TW'));
expect(lookupAppLocalizations(const Locale('zh')).label, '简体中文');
expect(lookupAppLocalizations(const Locale('zh', 'TW')).label, '繁體中文');
await tester.tap(find.text('English'));
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.extra.locale, 'en');
expect(find.text('English'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('theme settings adapt to mobile content layout', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Basic'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('theme-mode-system')), findsOneWidget);
expect(find.byKey(const ValueKey('theme-color-green')), findsOneWidget);
final languageSelect = find.byKey(const ValueKey('settings-language-select'));
expect(tester.getSize(languageSelect).height, 44);
await tester.ensureVisible(languageSelect);
await tester.tap(languageSelect);
await tester.pumpAndSettle();
final languagePopup = find.byType(shad.SelectPopup<String>);
expect(languagePopup, findsOneWidget);
expect(tester.getSize(languagePopup).height, greaterThan(240));
final englishOption = find.ancestor(of: find.text('English'), matching: find.byType(shad.Button));
expect(tester.getSize(englishOption).height, greaterThanOrEqualTo(44));
expect(tester.takeException(), isNull);
});
testWidgets('settings mobile back returns through home before confirming exit', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
var systemPopCalls = 0;
bool? frameworkHandlesBack;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'SystemNavigator.pop') systemPopCalls++;
if (call.method == 'SystemNavigator.setFrameworkHandlesBack') frameworkHandlesBack = call.arguments as bool;
return null;
});
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
tasksControllerProvider.overrideWith(FakeTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pumpAndSettle();
expect(frameworkHandlesBack, isTrue);
await tester.tap(find.text('Settings'));
await tester.pumpAndSettle();
await tester.tap(find.text('Basic'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('theme-mode-system')), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.byKey(const ValueKey('theme-mode-system')), findsNothing);
expect(systemPopCalls, 0);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(HomePage), findsOneWidget);
expect(find.text('Press back again to exit'), findsNothing);
expect(systemPopCalls, 0);
expect(frameworkHandlesBack, isTrue);
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.text('Press back again to exit'), findsOneWidget);
expect(systemPopCalls, 0);
await tester.binding.handlePopRoute();
await tester.pump();
expect(systemPopCalls, 1);
await tester.pump(const Duration(seconds: 6));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('light task card hover uses a distinct semantic surface', (WidgetTester tester) async {
await _setTestSize(tester, const Size(760, 220));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: _taskRecord(id: 'hover', name: 'hover.zip'),
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(location: Offset.zero);
await gesture.moveTo(tester.getCenter(find.byType(TaskCard)));
await tester.pump();
final hoverSurface = tester.widget<ColoredBox>(find.byKey(const ValueKey('task-card-hover-surface')));
expect(hoverSurface.color, AppPalette.light.taskCardHoverBg);
expect(hoverSurface.color, isNot(AppPalette.light.bg));
expect(hoverSurface.color, isNot(AppPalette.light.cardBg));
});
test('appearance controller initializes legacy configuration safely', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container
.read(appAppearanceControllerProvider.notifier)
.initialize(ExtraConfig(themeMode: 'dark', themeColor: 'purple'));
final appearance = container.read(appAppearanceControllerProvider);
expect(appearance.themeMode, AppThemeMode.dark);
expect(appearance.themeColor, AppThemeColor.purple);
expect(AppThemeColor.fromKey('missing'), AppThemeColor.green);
});
test('theme colors map to shad primary and ring only', () {
final green = AppPalette.lightFor(AppThemeColor.green);
final purple = AppPalette.lightFor(AppThemeColor.purple);
final shadTheme = AppTheme.light(AppThemeColor.purple);
expect(purple.brand, AppThemeColor.purple.light);
expect(purple.brand, isNot(green.brand));
expect(purple.brandProgress, isNot(green.brandProgress));
expect(purple.primaryActionBg, green.primaryActionBg);
expect(purple.primaryActionForeground, green.primaryActionForeground);
expect(shadTheme.colorScheme.primary, AppThemeColor.purple.light);
expect(shadTheme.colorScheme.ring, AppThemeColor.purple.light);
expect(shadTheme.colorScheme.accent, AppPalette.light.inputBg);
});
testWidgets('create task advanced options scroll smoothly into view', (WidgetTester tester) async {
await _setTestSize(tester, const Size(800, 500));
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
await tester.pumpAndSettle();
final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input'));
final createRenameInput = find.byKey(const ValueKey('create-task-rename-input'));
final createConnectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height);
expect(tester.getSize(createConnectionsInput), tester.getSize(createRenameInput));
final createDirectoryField = tester.widget<shad.TextField>(createDirectoryInput);
final createRenameField = tester.widget<shad.TextField>(
find.descendant(of: createRenameInput, matching: find.byType(AppTextField)),
);
expect(createDirectoryField.filled, createRenameField.filled);
expect(createDirectoryField.border, createRenameField.border);
expect(createDirectoryField.borderRadius, createRenameField.borderRadius);
expect(createDirectoryField.padding, createRenameField.padding);
await tester.ensureVisible(find.text('Advanced'));
await tester.pumpAndSettle();
final scrollView = tester.widget<SingleChildScrollView>(find.byKey(const ValueKey('create-task-form-scroll')));
final controller = scrollView.controller!;
expect((scrollView.padding! as EdgeInsets).bottom, 20);
final initialOffset = controller.offset;
await tester.tap(find.text('Advanced'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 80));
final movingOffset = controller.offset;
await tester.pump(const Duration(milliseconds: 300));
final settledOffset = controller.offset;
expect(movingOffset, greaterThan(initialOffset));
expect(settledOffset, greaterThanOrEqualTo(movingOffset));
expect(settledOffset, lessThanOrEqualTo(controller.position.maxScrollExtent));
expect(find.text('Header'), findsOneWidget);
expect(find.text('HTTP headers'), findsNothing);
expect(find.text('Add'), findsNothing);
expect(find.text('Delete'), findsNothing);
expect(find.text('User-Agent'), findsOneWidget);
final proxyMode = find.byKey(const ValueKey('create-task-proxy-mode'));
expect(tester.widget(proxyMode), isA<AppChoiceSegmentedControl<RequestProxyMode>>());
expect(find.byKey(const ValueKey('create-task-proxy-mode-follow')), findsOneWidget);
expect(find.byKey(const ValueKey('create-task-proxy-mode-none')), findsOneWidget);
expect(
tester.getRect(find.byKey(const ValueKey('create-task-proxy-mode-follow'))).left,
closeTo(tester.getRect(createRenameInput).left, 0.01),
);
final customProxy = find.byKey(const ValueKey('create-task-proxy-mode-custom'));
expect(customProxy, findsOneWidget);
await tester.tap(customProxy);
await tester.pump();
expect(find.byKey(const ValueKey('create-task-proxy-scheme')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('create-task-proxy-mode-follow')));
await tester.pump();
expect(find.byKey(const ValueKey('create-task-proxy-scheme')), findsNothing);
expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget);
final bitTorrentTab = find.text('BitTorrent');
await tester.ensureVisible(bitTorrentTab);
await tester.pumpAndSettle();
await tester.tap(bitTorrentTab);
await tester.pump();
expect(find.byKey(const ValueKey('create-task-auto-extract')), findsNothing);
await tester.tap(find.text('HTTP'));
await tester.pump();
expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget);
final advancedContent = tester.widget<Padding>(find.byKey(const ValueKey('create-task-advanced-content')));
expect((advancedContent.padding as EdgeInsets).bottom, 4);
final headerName = find.byKey(const ValueKey('create-task-http-header-name-0'));
final headerValue = find.byKey(const ValueKey('create-task-http-header-value-0'));
expect(tester.getSize(headerValue).width / tester.getSize(headerName).width, closeTo(1.618, 0.01));
var addHeader = find.byKey(const ValueKey('create-task-http-header-add'));
var lastRemoveHeader = find.byKey(const ValueKey('create-task-http-header-remove-2'));
expect(tester.getCenter(addHeader).dy, closeTo(tester.getCenter(lastRemoveHeader).dy, 0.01));
expect(tester.getRect(addHeader).left, greaterThan(tester.getRect(lastRemoveHeader).right));
await tester.ensureVisible(addHeader);
await tester.pumpAndSettle();
await tester.tap(addHeader);
await tester.pump();
expect(find.byKey(const ValueKey('create-task-http-header-name-3')), findsOneWidget);
addHeader = find.byKey(const ValueKey('create-task-http-header-add'));
lastRemoveHeader = find.byKey(const ValueKey('create-task-http-header-remove-3'));
expect(tester.getCenter(addHeader).dy, closeTo(tester.getCenter(lastRemoveHeader).dy, 0.01));
for (var index = 3; index > 0; index--) {
final removeHeader = find.byKey(ValueKey('create-task-http-header-remove-$index'));
tester
.widget<shad.GhostButton>(find.descendant(of: removeHeader, matching: find.byType(shad.GhostButton)))
.onPressed!();
await tester.pump();
}
expect(find.byKey(const ValueKey('create-task-http-header-name-0')), findsOneWidget);
expect(find.byKey(const ValueKey('create-task-http-header-name-1')), findsNothing);
final onlyRemoveHeader = find.descendant(
of: find.byKey(const ValueKey('create-task-http-header-remove-0')),
matching: find.byType(shad.GhostButton),
);
expect(tester.widget<shad.GhostButton>(onlyRemoveHeader).onPressed, isNull);
expect(find.byKey(const ValueKey('create-task-http-header-name-0')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('create task rename stays user-controlled and resets when the URL changes', (WidgetTester tester) async {
await _setTestSize(tester, const Size(700, 500));
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
await tester.pump();
final urlInput = find.byKey(const ValueKey('create-task-url-input'));
final renameInput = find.byKey(const ValueKey('create-task-rename-input'));
await tester.enterText(urlInput, 'https://example.com/first.zip');
await tester.enterText(renameInput, 'custom-name.zip');
expect(find.text('custom-name.zip'), findsOneWidget);
await tester.enterText(urlInput, 'https://example.com/second.zip');
await tester.pump();
final renameField = tester.widget<AppTextField>(
find.descendant(of: renameInput, matching: find.byType(AppTextField)),
);
expect(renameField.controller!.text, isEmpty);
await tester.pump(const Duration(seconds: 1));
});
testWidgets('create task stacks narrow forms and preserves header edits across resizing', (tester) async {
await _setTestSize(tester, const Size(390, 844));
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
await tester.pumpAndSettle();
final rename = find.byKey(const ValueKey('create-task-rename-input'));
final directory = find.byKey(const ValueKey('create-task-directory-input'));
expect(tester.getRect(rename).top, greaterThan(tester.getRect(find.text('Rename')).bottom));
expect(tester.getSize(directory).width, closeTo(342, 0.01));
await tester.ensureVisible(find.text('Advanced'));
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
final proxy = find.byKey(const ValueKey('create-task-proxy-mode'));
expect(tester.getRect(proxy).top, greaterThan(tester.getRect(find.text('Proxy')).bottom));
final name = find.byKey(const ValueKey('create-task-http-header-name-0'));
final value = find.byKey(const ValueKey('create-task-http-header-value-0'));
expect(tester.getRect(value).top, closeTo(tester.getRect(name).top, 0.01));
expect(tester.getRect(value).left, greaterThan(tester.getRect(name).right));
expect(tester.getSize(value).width / tester.getSize(name).width, closeTo(1.618, 0.01));
await tester.ensureVisible(value);
await tester.pumpAndSettle();
await tester.enterText(find.descendant(of: value, matching: find.byType(EditableText)), 'mobile-agent');
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
final editor = tester.widget<AppHttpHeadersEditor>(find.byType(AppHttpHeadersEditor));
expect(editor.controller.toMap()['User-Agent'], 'mobile-agent');
final add = find.byKey(const ValueKey('create-task-http-header-add'));
final lastRemove = find.byKey(const ValueKey('create-task-http-header-remove-2'));
expect(tester.getCenter(add).dy, closeTo(tester.getCenter(lastRemove).dy, 0.01));
expect(tester.getRect(add).left, greaterThan(tester.getRect(lastRemove).right));
await tester.ensureVisible(add);
await tester.pumpAndSettle();
await tester.tap(add);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('create-task-http-header-name-3')), findsOneWidget);
final remove = find.byKey(const ValueKey('create-task-http-header-remove-1'));
await tester.ensureVisible(remove);
await tester.pumpAndSettle();
await tester.tap(remove);
await tester.pumpAndSettle();
expect(editor.controller.toMap()['User-Agent'], 'mobile-agent');
expect(editor.controller.toMap().containsKey('Cookie'), isFalse);
for (final width in [720.0, 1024.0, 390.0]) {
tester.view.physicalSize = Size(width, 844);
await tester.pumpAndSettle();
expect(tester.getRect(name).top, closeTo(tester.getRect(value).top, 0.01));
expect(tester.getRect(value).left, greaterThan(tester.getRect(name).right));
expect(editor.controller.toMap()['User-Agent'], 'mobile-agent');
expect(tester.takeException(), isNull);
}
final custom = find.byKey(const ValueKey('create-task-proxy-mode-custom'));
await tester.ensureVisible(custom);
await tester.pumpAndSettle();
await tester.tap(custom);
await tester.pumpAndSettle();
final server = find.byKey(const ValueKey('create-task-proxy-server'));
final port = find.byKey(const ValueKey('create-task-proxy-port'));
final user = find.byKey(const ValueKey('create-task-proxy-username'));
final password = find.byKey(const ValueKey('create-task-proxy-password'));
expect(tester.getRect(port).top, greaterThan(tester.getRect(server).bottom));
expect(tester.getRect(password).top, greaterThan(tester.getRect(user).bottom));
expect(tester.takeException(), isNull);
});
testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async {
await _setTestSize(tester, const Size(700, 500));
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
await tester.pump();
expect(find.text('Mode'), findsNothing);
expect(find.text('Direct Download'), findsOneWidget);
expect(find.text('Skip resolving and create tasks immediately'), findsOneWidget);
final directDownloadCheckbox = find.descendant(
of: find.byKey(const ValueKey('create-task-direct-download-toggle')),
matching: find.byType(shad.Checkbox),
);
expect(
tester.getRect(directDownloadCheckbox).top,
greaterThan(tester.getRect(find.text('Direct Download')).bottom),
);
await tester.pump(const Duration(seconds: 1));
});
testWidgets('create task directory attaches an outline picker to the input', (WidgetTester tester) async {
await _setTestSize(tester, const Size(700, 500));
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
await tester.pump();
final directoryInput = find.byKey(const ValueKey('create-task-directory-input'));
final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker'));
expect(directoryInput, findsOneWidget);
expect(directoryPicker, findsOneWidget);
await tester.enterText(
directoryInput,
'/storage/emulated/0/Download/a-very-long-create-task-directory-that-must-not-run-under-the-picker-icon',
);
await tester.pump();
final editableText = find.descendant(of: directoryInput, matching: find.byType(EditableText));
expect(tester.getRect(editableText).right, lessThan(tester.getRect(directoryPicker).left));
expect(find.descendant(of: directoryInput, matching: directoryPicker), findsOneWidget);
expect(tester.getRect(directoryPicker).right, closeTo(tester.getRect(directoryInput).right, 0.01));
expect(tester.widget<shad.IconButton>(directoryPicker).variance, same(shad.ButtonVariance.outline));
expect(tester.takeException(), isNull);
await tester.pump(const Duration(seconds: 1));
});
testWidgets('app focus outlines use the compact global border', (WidgetTester tester) async {
late shad.FocusOutlineTheme focusTheme;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(AppThemeColor.purple),
materialTheme: AppTheme.materialLight(AppThemeColor.purple),
home: AppComponentThemes(
child: Builder(
builder: (context) {
focusTheme = shad.ComponentTheme.of<shad.FocusOutlineTheme>(context);
return const SizedBox();
},
),
),
),
);
expect(focusTheme.align, 1);
expect(focusTheme.border?.top.width, 1);
});
testWidgets('task toolbar actions use native outline icon buttons', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 120));
final searchController = TextEditingController();
addTearDown(searchController.dispose);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(AppThemeColor.purple),
materialTheme: AppTheme.materialLight(AppThemeColor.purple),
home: TasksTopBar(
searchController: searchController,
onAddTask: () {},
batchMode: true,
selectedBatchCount: 2,
canPauseSelected: true,
canResumeSelected: true,
onToggleBatchMode: () {},
onPauseSelected: () {},
onResumeSelected: () {},
onDeleteSelected: () {},
),
),
);
await tester.pump();
final actions = tester
.widgetList<shad.IconButton>(
find.descendant(
of: find.byKey(const ValueKey('tasks-top-action-buttons')),
matching: find.byType(shad.IconButton),
),
)
.toList(growable: false);
expect(actions, hasLength(4));
for (final action in actions) {
expect(action.variance, shad.ButtonVariance.outline);
}
expect(
find.descendant(
of: find.byKey(const ValueKey('tasks-top-action-buttons')),
matching: find.byType(shad.ButtonStyleOverride),
),
findsNothing,
);
});
testWidgets('create task button fits Chinese and caps longer translations', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 120));
final searchController = TextEditingController();
addTearDown(searchController.dispose);
Widget appFor(Locale locale) {
return shad.ShadcnApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => Localizations.override(
context: context,
locale: locale,
delegates: const [AppLocalizations.delegate],
child: TasksTopBar(
searchController: searchController,
onAddTask: () {},
batchMode: false,
selectedBatchCount: 0,
canPauseSelected: false,
canResumeSelected: false,
onToggleBatchMode: () {},
onPauseSelected: () {},
onResumeSelected: () {},
onDeleteSelected: () {},
),
),
),
);
}
await tester.pumpWidget(appFor(const Locale('zh')));
await tester.pumpAndSettle();
final chineseText = find.text('创建任务');
expect(chineseText, findsOneWidget);
expect(tester.renderObject<RenderParagraph>(chineseText).didExceedMaxLines, isFalse);
final chineseWidth = tester.getSize(find.byKey(const ValueKey('tasks-create-button-container'))).width;
expect(chineseWidth, greaterThanOrEqualTo(120));
await tester.pumpWidget(appFor(const Locale('hu')));
await tester.pumpAndSettle();
final hungarianText = find.text('Feladat létrehozása');
expect(hungarianText, findsOneWidget);
final hungarianWidth = tester.getSize(find.byKey(const ValueKey('tasks-create-button-container'))).width;
expect(hungarianWidth, greaterThan(chineseWidth));
expect(hungarianWidth, lessThanOrEqualTo(190));
});
testWidgets('resolved file selector initially expands only first-level directories', (tester) async {
await _setTestSize(tester, const Size(760, 620));
var selected = <int>[];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(
files: [
FileInfo(path: '', name: 'root.txt', size: 1),
FileInfo(path: 'folder', name: 'first.txt', size: 2),
FileInfo(path: 'folder/nested', name: 'second.txt', size: 3),
FileInfo(path: 'folder/nested/deeper', name: 'third.txt', size: 4),
],
initialSelection: const [0, 1, 2, 3],
onSelectionChanged: (values) => selected = values,
),
),
);
await tester.pumpAndSettle();
expect(find.text('root.txt'), findsOneWidget);
expect(find.text('first.txt'), findsOneWidget);
expect(find.text('nested'), findsOneWidget);
expect(find.text('second.txt'), findsNothing);
expect(find.text('third.txt'), findsNothing);
expect(selected, [0, 1, 2, 3]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:folder/nested')));
await tester.pumpAndSettle();
expect(find.text('second.txt'), findsOneWidget);
expect(find.text('deeper'), findsOneWidget);
expect(find.text('third.txt'), findsNothing);
expect(selected, [0, 1, 2, 3]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pumpAndSettle();
expect(find.text('third.txt'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pumpAndSettle();
expect(find.text('first.txt'), findsNothing);
expect(find.text('root.txt'), findsOneWidget);
expect(selected, [0, 1, 2, 3]);
expect(tester.takeException(), isNull);
});
testWidgets('resolved file selector uses aligned tree header and sortable columns', (WidgetTester tester) async {
await _setTestSize(tester, const Size(760, 620));
var selected = <int>[];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
files: [
FileInfo(path: 'videos', name: 'clip.mp4', size: 0),
FileInfo(path: 'docs', name: 'notes.txt', size: 0),
FileInfo(path: 'docs', name: 'manual.pdf', size: 0),
],
initialSelection: const [0, 1, 2],
onSelectionChanged: (values) => selected = values,
),
),
),
);
await tester.pumpAndSettle();
final treeFinder = find.byWidgetPredicate((widget) => widget is VirtualTreeView);
expect(treeFinder, findsOneWidget);
final tree = tester.widget<VirtualTreeView<dynamic>>(treeFinder);
expect(tree.branchLine, same(shad.BranchLine.path));
expect(tree.rowHeight, 36);
expect(
tester.widgetList<shad.Checkbox>(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null),
isTrue,
);
final checkboxes = find.byType(shad.Checkbox);
expect(tester.getTopLeft(checkboxes.at(0)).dx, closeTo(tester.getTopLeft(checkboxes.at(1)).dx, 0.5));
final headerCenterY = tester.getRect(checkboxes.at(0)).center.dy;
expect(
tester.getRect(find.byKey(const ValueKey('resolve-tree-expand-toggle'))).center.dy,
closeTo(headerCenterY, 0.01),
);
expect(tester.getRect(find.text('Name')).center.dy, closeTo(headerCenterY, 0.01));
expect(tester.getRect(find.text('Size')).center.dy, closeTo(headerCenterY, 0.01));
final expandTop = tester.getRect(find.byKey(const ValueKey('resolve-tree-expand-top')));
final expandBottom = tester.getRect(find.byKey(const ValueKey('resolve-tree-expand-bottom')));
expect((expandTop.top + expandBottom.bottom) / 2, closeTo(headerCenterY, 0.01));
expect(find.text('Invert visible'), findsNothing);
expect(find.text('Clear'), findsNothing);
expect(find.byType(shad.TextField), findsNothing);
expect(find.byKey(const ValueKey('resolve-tree-expand-toggle')), findsOneWidget);
expect(find.byType(shad.OutlinedContainer), findsOneWidget);
expect(find.text('clip.mp4'), findsOneWidget);
expect(find.text('Unknown size'), findsOneWidget);
expect(tester.widget<Text>(find.text('clip.mp4')).style?.color, AppPalette.light.textPrimary);
final baselines = tester
.widgetList<Baseline>(
find.descendant(
of: find.byKey(const ValueKey('resolve-tree-selection-stats')),
matching: find.byType(Baseline),
),
)
.toList();
expect(baselines, hasLength(2));
expect(baselines.first.baseline, baselines.last.baseline);
expect(baselines.every((widget) => widget.baselineType == TextBaseline.alphabetic), isTrue);
final filtersRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-filters')));
final statsRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selection-stats')));
final selectedCountRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-count')));
final selectedSizeRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-size')));
expect(statsRect.center.dy, closeTo(filtersRect.center.dy, 0.01));
expect(selectedCountRect.center.dy, closeTo(selectedSizeRect.center.dy, 0.01));
expect(selectedSizeRect.left, greaterThan(selectedCountRect.right));
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pumpAndSettle();
expect(find.text('clip.mp4').hitTestable(), findsNothing);
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pumpAndSettle();
expect(find.text('clip.mp4'), findsOneWidget);
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:videos'));
expect(videosExpand, findsOneWidget);
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:0')), findsOneWidget);
await tester.tap(videosExpand);
await tester.pumpAndSettle();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect(find.text('clip.mp4').hitTestable(), findsNothing);
expect(find.text('videos').hitTestable(), findsOneWidget);
expect(selected, [0, 1, 2]);
await tester.tap(find.text('videos'));
await tester.pump();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect(selected, [1, 2]);
await tester.tap(find.text('videos'));
await tester.pump();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect(selected, [0, 1, 2]);
await tester.tap(videosExpand);
await tester.pumpAndSettle();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isTrue);
expect(find.text('clip.mp4'), findsOneWidget);
BoxDecoration sortDecoration(String key) {
final decoration = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(DecoratedBox));
return tester.widget<DecoratedBox>(decoration).decoration as BoxDecoration;
}
expect(sortDecoration('resolve-tree-sort-name-asc').color, isNull);
expect(sortDecoration('resolve-tree-sort-name-desc').color, isNull);
final nameCenterY = tester.getRect(find.text('Name')).center.dy;
final ascendingRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-sort-name-asc')));
final descendingRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-sort-name-desc')));
expect((ascendingRect.top + descendingRect.bottom) / 2, closeTo(nameCenterY, 0.01));
await tester.tap(find.text('Name'));
await tester.pumpAndSettle();
expect(sortDecoration('resolve-tree-sort-name-asc').color, isNotNull);
expect(sortDecoration('resolve-tree-sort-name-desc').color, isNull);
await tester.tap(find.text('Name'));
await tester.pumpAndSettle();
expect(sortDecoration('resolve-tree-sort-name-asc').color, isNull);
expect(sortDecoration('resolve-tree-sort-name-desc').color, isNotNull);
expect(find.text('notes.txt'), findsOneWidget);
expect(find.text('clip.mp4'), findsOneWidget);
expect(selected, [0, 1, 2]);
tester.view.physicalSize = const Size(280, 620);
await tester.pumpAndSettle();
expect(
tester.getRect(find.byKey(const ValueKey('resolve-tree-selection-stats'))).top,
greaterThanOrEqualTo(tester.getRect(find.byKey(const ValueKey('resolve-tree-filters'))).bottom),
);
expect(
tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-size'))).top,
greaterThanOrEqualTo(tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-count'))).bottom),
);
expect(tester.takeException(), isNull);
});
testWidgets('file type filters support multiple selection and toggling off', (WidgetTester tester) async {
await _setTestSize(tester, const Size(760, 620));
var selected = <int>[];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
files: [
FileInfo(path: '', name: 'clip.mp4', size: 100),
FileInfo(path: '', name: 'song.mp3', size: 200),
FileInfo(path: '', name: 'cover.png', size: 300),
],
initialSelection: const [],
onSelectionChanged: (values) => selected = values,
),
),
),
);
await tester.pumpAndSettle();
Color? filterBackground(String key) {
final decoration = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(DecoratedBox));
return (tester.widget<DecoratedBox>(decoration.first).decoration as BoxDecoration).color;
}
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(selected, [0]);
expect(filterBackground('resolve-tree-filter-video'), AppPalette.light.filterActiveBg);
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-audio')));
await tester.pump();
expect(selected, [0, 1]);
expect(filterBackground('resolve-tree-filter-audio'), AppPalette.light.filterActiveBg);
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(selected, [1]);
expect(filterBackground('resolve-tree-filter-video'), isNull);
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-audio')));
await tester.pump();
expect(selected, isEmpty);
});
testWidgets('file name area scrolls horizontally while the trailing column stays fixed', (WidgetTester tester) async {
await _setTestSize(tester, const Size(420, 420));
const longName =
'this-is-a-very-long-file-name-that-needs-horizontal-scrolling-to-be-read-completely.archive.tar.gz';
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
files: [FileInfo(name: longName, size: 1024)],
initialSelection: const [0],
onSelectionChanged: (_) {},
),
),
),
);
await tester.pumpAndSettle();
final horizontalScroll = find.byKey(const ValueKey('resolve-tree-horizontal-scroll'));
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:0'));
expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsOneWidget);
expect(horizontalScroll, findsOneWidget);
expect(trailing, findsOneWidget);
final scrollable = find.descendant(of: horizontalScroll, matching: find.byType(Scrollable));
final position = tester.state<ScrollableState>(scrollable).position;
expect(position.maxScrollExtent, greaterThan(0));
final nameLeftBefore = tester.getRect(find.text(longName)).left;
final trailingLeftBefore = tester.getRect(trailing).left;
await tester.drag(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), const Offset(-120, 0));
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(0));
expect(tester.getRect(find.text(longName)).left, lessThan(nameLeftBefore));
expect(tester.getRect(trailing).left, closeTo(trailingLeftBefore, 0.01));
position.jumpTo(0);
await tester.pumpAndSettle();
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:0'));
await tester.drag(nameArea, const Offset(-100, 0));
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(0));
expect(tester.getRect(find.text(longName)).left, lessThan(nameLeftBefore));
expect(tester.getRect(trailing).left, closeTo(trailingLeftBefore, 0.01));
final previousOffset = position.pixels;
await tester.drag(nameArea, const Offset(80, 0));
await tester.pumpAndSettle();
expect(position.pixels, lessThan(previousOffset));
expect(tester.takeException(), isNull);
});
testWidgets('file name scrollbar stays hidden when measured labels fit', (WidgetTester tester) async {
await _setTestSize(tester, const Size(420, 420));
const shortName = 'short.zip';
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
files: [FileInfo(name: shortName, size: 1024)],
initialSelection: const [0],
onSelectionChanged: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text(shortName), findsOneWidget);
expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsNothing);
expect(find.byKey(const ValueKey('resolve-tree-horizontal-scroll')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('virtual tree lazily builds large file lists', (WidgetTester tester) async {
await _setTestSize(tester, const Size(760, 620));
final files = List.generate(
1200,
(index) => FileInfo(path: 'folder-${index ~/ 100}', name: 'file-$index.bin', size: index),
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
),
),
);
await tester.pumpAndSettle();
expect(find.byWidgetPredicate((widget) => widget is VirtualTreeView), findsOneWidget);
expect(find.text('file-1199.bin'), findsNothing);
expect(find.byType(shad.Checkbox).evaluate().length, lessThan(100));
final listView = tester.widget<ListView>(find.byType(ListView));
expect(listView.childrenDelegate, isA<SliverChildBuilderDelegate>());
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
await tester.pump();
expect(find.text('file-1199.bin'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pump(const Duration(milliseconds: 75));
expect(find.byType(shad.Checkbox).evaluate().length, lessThan(100));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
test('task runtime status parses selected file progress', () {
final status = api_task.TaskRuntimeStatus.fromJson({
'status': 'running',
'used': 10,
'speed': 20,
'downloaded': 50,
'total': 100,
'uploadSpeed': 0,
'uploaded': 0,
'extractStatus': '',
'extractProgress': 0,
'files': [
{'index': 3, 'size': 100, 'downloaded': 50},
],
});
expect(status.status, api_task.Status.running);
expect(status.files.single.index, 3);
expect(status.files.single.size, 100);
expect(status.files.single.downloaded, 50);
});
test('task transfer speeds include running downloads and active seeds', () {
final speeds = aggregateTaskTransferSpeeds([
_apiTransferTask(
id: 'running-seed',
status: api_task.Status.running,
uploading: true,
speed: 100,
uploadSpeed: 10,
),
_apiTransferTask(
id: 'completed-seed',
status: api_task.Status.done,
uploading: true,
speed: 999,
uploadSpeed: 20,
),
_apiTransferTask(id: 'paused-stale', status: api_task.Status.pause, uploading: true, speed: 999, uploadSpeed: 30),
_apiTransferTask(
id: 'running-download',
status: api_task.Status.running,
uploading: false,
speed: 50,
uploadSpeed: 40,
),
]);
expect(speeds.downloadBytesPerSecond, 150);
expect(speeds.uploadBytesPerSecond, 30);
});
test('task records keep a missing resource size unknown', () {
final task = _apiTransferTask(
id: 'unknown-size',
status: api_task.Status.running,
uploading: false,
speed: 0,
uploadSpeed: 0,
);
task.progress.used = 1704000000000000000;
final record = TaskRecord.fromApi(task);
expect(record.total, isNull);
expect(record.progress, isNull);
expect(record.isIndeterminate, isTrue);
});
test('known total size preserves zero download speed independently of task status', () {
for (final status in api_task.Status.values) {
for (final total in [0, 1024]) {
for (final speed in [0, 512]) {
final task = _apiTransferTask(id: 'speed', status: status, uploading: false, speed: speed, uploadSpeed: 0);
task.meta.res = Resource(size: total, files: []);
expect(TaskRecord.fromApi(task).speed, total > 0 || speed > 0 ? '$speed B/s' : isNull);
}
}
}
});
for (final width in [390.0, 1024.0]) {
testWidgets('known-size task cards retain zero download speed at width $width', (tester) async {
await _setTestSize(tester, Size(width, 220));
for (final status in api_task.Status.values) {
final task = _apiTransferTask(id: 'zero-speed', status: status, uploading: false, speed: 0, uploadSpeed: 0);
task.meta.res = Resource(size: 1024, files: []);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: TaskRecord.fromApi(task),
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
),
),
),
);
await tester.pump();
expect(find.text('0 B/s'), findsOneWidget);
expect(find.byIcon(Icons.south), findsOneWidget);
expect(tester.takeException(), isNull);
}
});
}
testWidgets('known-size task details retain zero download speed', (tester) async {
await _setTestSize(tester, const Size(700, 900));
final task = _apiTransferTask(
id: 'zero-details',
status: api_task.Status.running,
uploading: false,
speed: 0,
uploadSpeed: 0,
);
task.meta.res = Resource(size: 1024, files: []);
await tester.pumpWidget(
ProviderScope(
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskDetailsView(
task: TaskRecord.fromApi(task),
mobile: true,
onOpenStorage: () {},
onUpdateUrl: (_) async {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('0 B/s'), findsOneWidget);
expect(tester.takeException(), isNull);
});
test('task records preserve zero upload speed while uploading', () {
final record = TaskRecord.fromApi(
_apiTransferTask(id: 'seeding', status: api_task.Status.done, uploading: true, speed: 0, uploadSpeed: 0),
);
expect(record.uploading, isTrue);
expect(record.uploadSpeed, '0 B/s');
});
test('task records expose the Go download duration in nanoseconds', () {
final task = _apiTransferTask(
id: 'completed-duration',
status: api_task.Status.done,
uploading: false,
speed: 0,
uploadSpeed: 0,
);
task.progress.used = 3500000000;
final record = TaskRecord.fromApi(task);
expect(record.downloadDuration, const Duration(milliseconds: 3500));
expect(record.createdAt, DateTime.fromMillisecondsSinceEpoch(0));
});
test('task URL updates are limited to paused or failed HTTP tasks', () {
expect(_taskRecord(id: 'http-paused', name: 'paused.bin', status: TaskStatus.paused).canUpdateUrl, isTrue);
expect(_taskRecord(id: 'http-failed', name: 'failed.bin', status: TaskStatus.failed).canUpdateUrl, isTrue);
expect(_taskRecord(id: 'http-running', name: 'running.bin').canUpdateUrl, isFalse);
expect(
_taskRecord(
id: 'bt-paused',
name: 'paused.torrent',
status: TaskStatus.paused,
protocol: api_task.Protocol.bt,
).canUpdateUrl,
isFalse,
);
});
test('task icons follow task type and file extension instead of status', () {
final extensionCases = <(String, IconData)>[
('setup.exe', GopeedIcons.fileInstaller),
('mobile.apk', GopeedIcons.fileAndroid),
('mobile.ipa', GopeedIcons.fileIos),
('backup.iso', GopeedIcons.fileDiskImage),
('index.html', GopeedIcons.fileWeb),
('notes.md', GopeedIcons.fileText),
('manual.pdf', GopeedIcons.filePdf),
('report.docx', GopeedIcons.fileDocument),
('budget.xlsx', GopeedIcons.fileSpreadsheet),
('slides.pptx', GopeedIcons.filePresentation),
('source.tar.gz', GopeedIcons.fileArchive),
('photo.PNG', GopeedIcons.fileImage),
('track.flac', GopeedIcons.fileAudio),
('movie.MKV', GopeedIcons.fileVideo),
('main.dart', GopeedIcons.fileCode),
('book.epub', GopeedIcons.fileEbook),
('display.woff2', GopeedIcons.fileFont),
('cache.sqlite', GopeedIcons.fileDatabase),
('linux.torrent', GopeedIcons.protocolBt),
];
for (final (name, icon) in extensionCases) {
expect(
_taskRecord(id: name, name: name).icon,
icon,
reason: name,
);
}
expect(_taskRecord(id: 'folder', name: 'Downloads', isFolder: true).icon, GopeedIcons.folder);
expect(
_taskRecord(id: 'bt-folder', name: 'Linux collection', isFolder: true, protocol: api_task.Protocol.bt).icon,
GopeedIcons.folderBt,
);
expect(_taskRecord(id: 'file', name: 'unknown').icon, GopeedIcons.file);
expect(_taskRecord(id: 'bt', name: 'unknown', protocol: api_task.Protocol.bt).icon, GopeedIcons.protocolBt);
expect(_taskRecord(id: 'ed2k', name: 'unknown', protocol: api_task.Protocol.ed2k).icon, GopeedIcons.protocolEd2k);
final runningIcon = _taskRecord(id: 'running-video', name: 'movie.mp4').icon;
final failedIcon = _taskRecord(id: 'failed-video', name: 'movie.mp4', status: TaskStatus.failed).icon;
expect(failedIcon, runningIcon);
});
testWidgets('task cards label a missing total as unknown size', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 220));
final record = TaskRecord.fromApi(
_apiTransferTask(
id: 'unknown-size-card',
status: api_task.Status.running,
uploading: false,
speed: 0,
uploadSpeed: 0,
),
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: record,
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
),
),
),
);
await tester.pump();
expect(find.text('Unknown size'), findsOneWidget);
expect(find.textContaining('TB'), findsNothing);
});
testWidgets('task cards display zero upload speed while uploading', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 220));
for (final status in [api_task.Status.running, api_task.Status.done]) {
final record = TaskRecord.fromApi(
_apiTransferTask(id: 'uploading-${status.name}', status: status, uploading: true, speed: 0, uploadSpeed: 0),
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: record,
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
),
),
),
);
await tester.pump();
expect(find.text('0 B/s'), findsOneWidget);
expect(find.byIcon(Icons.north), findsOneWidget);
expect(tester.takeException(), isNull);
}
});
testWidgets('completed task cards omit progress and keep size left with speed and status right', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1024, 220));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: _taskRecord(id: 'completed-card', name: 'completed.zip', status: TaskStatus.completed),
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
),
),
),
);
await tester.pump();
expect(find.text('Completed'), findsOneWidget);
expect(find.text('COMPLETED'), findsNothing);
expect(find.text('100 B'), findsOneWidget);
expect(find.text('10 B/s'), findsOneWidget);
final sizeRect = tester.getRect(find.text('100 B'));
final speedRect = tester.getRect(find.text('10 B/s'));
final statusRect = tester.getRect(find.text('Completed'));
expect(sizeRect.left, lessThan(speedRect.left));
expect(speedRect.right, lessThan(statusRect.left));
expect(find.byType(TaskProgressBar), findsNothing);
expect(find.text('Downloaded'), findsNothing);
});
testWidgets('failed task cards show the shared failed label without an error reason', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 220));
const task = TaskRecord(
id: 'failed-card',
name: 'failed.zip',
status: TaskStatus.failed,
downloaded: '50 B',
total: '100 B',
speed: '10 B/s',
progress: 0.5,
url: 'https://example.com/failed.zip',
storagePath: '/downloads/failed.zip',
files: [],
uploading: false,
error: 'Connection refused',
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: task,
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
),
),
),
);
await tester.pump();
expect(find.text('Failed'), findsOneWidget);
expect(find.text('Connection refused'), findsNothing);
expect(find.text('10 B/s'), findsOneWidget);
expect(tester.getRect(find.text('50 B / 100 B')).left, lessThan(tester.getRect(find.text('Failed')).left));
});
testWidgets('speed monitor displays supplied task totals without simulated updates', (WidgetTester tester) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const SizedBox(
width: 192,
child: SpeedMonitorCard(downloadBytesPerSecond: 10 * 1024 * 1024, uploadBytesPerSecond: 2 * 1024 * 1024),
),
),
);
await tester.pump(const Duration(seconds: 2));
expect(find.text('10'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('MB/s'), findsNWidgets(2));
});
testWidgets('speed monitor adapts units for low transfer rates', (WidgetTester tester) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const SizedBox(
width: 192,
child: SpeedMonitorCard(downloadBytesPerSecond: 96 * 1024, uploadBytesPerSecond: 512),
),
),
);
expect(find.text('96'), findsOneWidget);
expect(find.text('KB/s'), findsOneWidget);
expect(find.text('512'), findsOneWidget);
expect(find.text('B/s'), findsOneWidget);
});
testWidgets('task file tree maps sparse file progress by resource index', (WidgetTester tester) async {
await _setTestSize(tester, const Size(520, 620));
const task = TaskRecord(
id: 'task-1',
name: 'album',
status: TaskStatus.downloading,
downloaded: '50 B',
total: '100 B',
progress: 0.5,
url: 'https://example.com/album.torrent',
storagePath: '/downloads/album',
files: [
TaskFileNode(path: 'disc', name: 'skipped.mp3', sizeBytes: 100),
TaskFileNode(path: 'disc', name: 'selected.mp3', sizeBytes: 100),
],
uploading: false,
);
final status = api_task.TaskRuntimeStatus(
status: api_task.Status.running,
used: 10,
speed: 20,
downloaded: 50,
total: 100,
uploadSpeed: 0,
uploaded: 0,
extractStatus: api_task.ExtractStatus.none,
extractProgress: 0,
files: [api_task.FileRuntimeStatus(index: 1, size: 100, downloaded: 50)],
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskFileTree(task: task, runtimeStatus: status),
),
),
);
await tester.pump();
expect(find.text('Progress'), findsNWidgets(2));
expect(find.text('Size'), findsOneWidget);
expect(find.text('skipped.mp3'), findsOneWidget);
expect(find.text('selected.mp3'), findsOneWidget);
expect(find.byIcon(GopeedIcons.folder), findsOneWidget);
expect(find.byIcon(GopeedIcons.fileAudio), findsNWidgets(2));
expect(tester.widget<Text>(find.text('selected.mp3')).style?.fontSize, 11);
final compactTree = tester.widget<VirtualTreeView<dynamic>>(
find.byWidgetPredicate((widget) => widget is VirtualTreeView),
);
expect(compactTree.rowHeight, 27);
expect(find.byKey(const ValueKey('task-file-progress-file:0')), findsNothing);
final selectedProgress = find.byKey(const ValueKey('task-file-progress-file:1'));
expect(selectedProgress, findsOneWidget);
final circularProgress = find.descendant(
of: selectedProgress,
matching: find.byType(shad.CircularProgressIndicator),
);
expect(circularProgress, findsOneWidget);
expect(tester.widget<shad.CircularProgressIndicator>(circularProgress).value, 0.5);
expect(find.descendant(of: selectedProgress, matching: find.text('100 B')), findsOneWidget);
expect(find.byType(shad.Checkbox), findsNothing);
});
testWidgets('task file tree keeps a progress ring while per-file status is unavailable', (WidgetTester tester) async {
await _setTestSize(tester, const Size(520, 420));
const task = TaskRecord(
id: 'task-fallback-progress',
name: 'pending.bin',
status: TaskStatus.downloading,
downloaded: '25 B',
total: '100 B',
progress: 0.25,
url: 'https://example.com/pending.bin',
storagePath: '/downloads/pending.bin',
files: [TaskFileNode(path: '', name: 'pending.bin', sizeBytes: 100)],
uploading: false,
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const Padding(
padding: EdgeInsets.all(24),
child: TaskFileTree(task: task),
),
),
);
await tester.pump();
final fileProgress = find.byKey(const ValueKey('task-file-progress-file:0'));
final ring = find.descendant(of: fileProgress, matching: find.byType(shad.CircularProgressIndicator));
expect(ring, findsOneWidget);
expect(tester.widget<shad.CircularProgressIndicator>(ring).value, 0.25);
});
testWidgets('completed task files use a completion icon instead of a progress ring', (WidgetTester tester) async {
await _setTestSize(tester, const Size(520, 420));
const task = TaskRecord(
id: 'task-completed',
name: 'completed.bin',
status: TaskStatus.completed,
downloaded: '100 B',
total: '100 B',
progress: 1,
url: 'https://example.com/completed.bin',
storagePath: '/downloads/completed.bin',
files: [TaskFileNode(path: '', name: 'completed.bin', sizeBytes: 100)],
uploading: false,
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const Padding(
padding: EdgeInsets.all(24),
child: TaskFileTree(task: task),
),
),
);
expect(find.byIcon(shad.BootstrapIcons.checkCircleFill), findsOneWidget);
expect(find.byType(shad.CircularProgressIndicator), findsNothing);
});
testWidgets('paused and failed task files use state icons instead of progress rings', (WidgetTester tester) async {
await _setTestSize(tester, const Size(520, 420));
const pausedTask = TaskRecord(
id: 'task-paused',
name: 'paused.bin',
status: TaskStatus.paused,
downloaded: '40 B',
total: '100 B',
progress: 0.4,
url: 'https://example.com/paused.bin',
storagePath: '/downloads/paused.bin',
files: [TaskFileNode(path: '', name: 'paused.bin', sizeBytes: 100)],
uploading: false,
);
const failedTask = TaskRecord(
id: 'task-failed',
name: 'failed.bin',
status: TaskStatus.failed,
downloaded: '40 B',
total: '100 B',
progress: 0.4,
url: 'https://example.com/failed.bin',
storagePath: '/downloads/failed.bin',
files: [TaskFileNode(path: '', name: 'failed.bin', sizeBytes: 100)],
uploading: false,
);
Future<void> pumpTask(TaskRecord task) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskFileTree(task: task),
),
),
);
await tester.pump();
}
await pumpTask(pausedTask);
expect(find.byIcon(Icons.pause_circle_filled), findsOneWidget);
expect(find.byType(shad.CircularProgressIndicator), findsNothing);
await pumpTask(failedTask);
expect(find.byIcon(Icons.error_rounded), findsOneWidget);
expect(find.byType(shad.CircularProgressIndicator), findsNothing);
});
testWidgets('task drawer aligns information labels beside their values', (WidgetTester tester) async {
await _setTestSize(tester, const Size(900, 700));
final task = _taskRecord(
id: 'details',
name: 'details.zip',
status: TaskStatus.completed,
remaining: '1 minute remaining',
downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3),
createdAt: DateTime(2026, 8, 27, 12, 34, 56),
doneAt: DateTime(2026, 8, 28, 1, 2, 3),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
taskRuntimeStatusProvider(task.id).overrideWith(
(ref) async => api_task.TaskRuntimeStatus(
status: api_task.Status.done,
used: 0,
speed: 0,
downloaded: 100,
total: 100,
uploadSpeed: 0,
uploaded: 0,
files: const [],
),
),
],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Stack(
children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})],
),
),
),
);
await tester.pumpAndSettle();
final labelTopLeft = tester.getTopLeft(find.text('Name'));
final valueTopLeft = tester.getTopLeft(find.text('details.zip'));
expect((labelTopLeft.dy - valueTopLeft.dy).abs(), lessThan(5));
expect(valueTopLeft.dx, greaterThan(labelTopLeft.dx));
final storageLabelTopLeft = tester.getTopLeft(find.text('Storage Path'));
final storageValueFinder = find.byKey(const ValueKey('task-details-storage-value'));
final storageValueTopLeft = tester.getTopLeft(storageValueFinder);
expect((storageLabelTopLeft.dy - storageValueTopLeft.dy).abs(), lessThan(5));
expect(storageValueTopLeft.dx, greaterThan(storageLabelTopLeft.dx));
final storageValue = tester.widget<Text>(storageValueFinder);
expect(storageValue.data, contains('\u200B'));
expect(storageValue.data!.replaceAll('\u200B', ''), '/downloads/details.zip');
expect(storageValue.semanticsLabel, '/downloads/details.zip');
final urlValue = tester.widget<Text>(find.byKey(const ValueKey('task-details-url-value')));
expect(urlValue.data, contains('\u200B'));
expect(urlValue.data!.replaceAll('\u200B', ''), task.url);
expect(urlValue.semanticsLabel, task.url);
final selectionArea = find.byKey(const ValueKey('task-details-selection-area'));
expect(selectionArea, findsOneWidget);
expect(tester.widget(selectionArea), isA<SelectionArea>());
expect(find.ancestor(of: find.text('details.zip'), matching: selectionArea), findsOneWidget);
expect(
find.ancestor(of: find.byKey(const ValueKey('task-details-url-value')), matching: selectionArea),
findsOneWidget,
);
expect(find.text('Task Details'), findsOneWidget);
expect(find.text('Task Name'), findsNothing);
final detailHeader = tester.widget<SizedBox>(find.byKey(const ValueKey('app-detail-drawer-header')));
final detailHeaderDecoration = (detailHeader.child! as DecoratedBox).decoration as BoxDecoration;
expect((detailHeaderDecoration.border! as Border).bottom.width, 1);
expect(find.text('Details'), findsOneWidget);
expect(find.text('Files'), findsOneWidget);
expect(find.text('Statistics'), findsOneWidget);
expect(find.text('Remaining'), findsNothing);
expect(find.text('Download duration'), findsOneWidget);
expect(find.text('2 hours'), findsOneWidget);
expect(find.text('100 B'), findsOneWidget);
expect(find.text('50 B / 100 B'), findsNothing);
expect(find.text('Created'), findsOneWidget);
expect(find.text('2026-08-27 12:34:56'), findsOneWidget);
expect(find.text('Completed at'), findsOneWidget);
expect(find.text('2026-08-28 01:02:03'), findsOneWidget);
expect(
tester.getBottomLeft(find.text('2026-08-27 12:34:56')).dy,
lessThan(tester.getTopLeft(find.text('Completed at')).dy),
);
final infoDivider = find.byKey(const ValueKey('task-details-info-divider'));
expect(tester.getBottomLeft(find.text('Completed at')).dy, lessThan(tester.getTopLeft(infoDivider).dy));
expect(tester.getBottomLeft(find.text('Created')).dy, lessThan(tester.getTopLeft(infoDivider).dy));
expect(tester.getBottomLeft(infoDivider).dy, lessThan(tester.getTopLeft(find.text('Download Link')).dy));
expect(find.text('1 minute remaining'), findsNothing);
expect(find.text('Completed'), findsOneWidget);
expect(
tester.getSize(find.byKey(const ValueKey('task-details-drawer'))).width,
AppDesignTokens.taskDetailsDrawerMinWidth,
);
tester.view.physicalSize = const Size(1600, 900);
await tester.pumpAndSettle();
expect(
tester.getSize(find.byKey(const ValueKey('task-details-drawer'))).width,
1600 * AppDesignTokens.taskDetailsDrawerViewportRatio,
);
await tester.tap(find.text('Files'));
await tester.pump();
expect(find.text('Progress'), findsWidgets);
expect(find.text('Task progress'), findsNothing);
final browseFilesAction = find.byKey(const ValueKey('task-files-browse'));
expect(browseFilesAction, findsOneWidget);
final totalProgress = find.byKey(const ValueKey('task-files-total-progress'));
expect(totalProgress, findsOneWidget);
expect(tester.getTopLeft(browseFilesAction).dx, greaterThan(tester.getTopRight(totalProgress).dx));
expect(
find.descendant(of: browseFilesAction, matching: find.byIcon(Icons.snippet_folder_outlined)),
findsOneWidget,
);
final browseFilesTooltipFinder = find.descendant(of: browseFilesAction, matching: find.byType(AppTooltip));
expect(tester.widget<AppTooltip>(browseFilesTooltipFinder).message, 'Browse Files');
await tester.tap(browseFilesAction);
await tester.pumpAndSettle();
final browserDialog = find.byKey(const ValueKey('task-file-browser-dialog'));
expect(browserDialog, findsOneWidget);
expect(find.descendant(of: browserDialog, matching: find.byType(TaskFileManagerView)), findsOneWidget);
expect(tester.getCenter(browserDialog).dx, closeTo(tester.view.physicalSize.width / 2, 1));
expect(tester.getCenter(browserDialog).dy, closeTo(tester.view.physicalSize.height / 2, 1));
});
testWidgets('paused task details show a dash for remaining time', (WidgetTester tester) async {
await _setTestSize(tester, const Size(900, 700));
final task = _taskRecord(id: 'paused-details', name: 'paused.zip', status: TaskStatus.paused);
await tester.pumpWidget(
ProviderScope(
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskDetailsView(task: task, mobile: false, onOpenStorage: () {}, onUpdateUrl: (_) async {}),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Pause'), findsOneWidget);
expect(find.text('Remaining'), findsOneWidget);
expect(find.text('—'), findsOneWidget);
});
testWidgets('failed task details omit the unavailable error reason', (WidgetTester tester) async {
await _setTestSize(tester, const Size(900, 700));
final task = TaskRecord(
id: 'failed-details',
name: 'failed.zip',
status: TaskStatus.failed,
downloaded: '50 B',
total: '100 B',
progress: 0.5,
url: 'https://example.com/failed.zip',
storagePath: '/downloads/failed.zip',
files: const [],
uploading: false,
error: 'Connection refused',
createdAt: DateTime(2026, 8, 27, 12, 34, 56),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
taskRuntimeStatusProvider(task.id).overrideWith(
(ref) async => api_task.TaskRuntimeStatus(
status: api_task.Status.error,
used: 0,
speed: 0,
downloaded: 50,
total: 100,
uploadSpeed: 0,
uploaded: 0,
files: const [],
),
),
],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Stack(
children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})],
),
),
),
);
await tester.pumpAndSettle();
final failedStatus = find.text('Failed');
expect(failedStatus, findsOneWidget);
expect(tester.widget<Text>(failedStatus).style?.color, AppPalette.of(tester.element(failedStatus)).error);
expect(find.text('Created'), findsOneWidget);
expect(find.text('Error'), findsNothing);
expect(find.text('Connection refused'), findsNothing);
});
testWidgets('task drawer actions use icon buttons with hover labels', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await _setTestSize(tester, const Size(900, 700));
final task = _taskRecord(id: 'editable-details', name: 'editable.zip', status: TaskStatus.paused);
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') {
clipboardText = (call.arguments as Map<Object?, Object?>)['text'] as String?;
}
return null;
});
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
await tester.pumpWidget(
ProviderScope(
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Stack(
children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})],
),
),
),
);
await tester.pumpAndSettle();
for (final key in ['task-details-edit-url', 'task-details-copy-url', 'task-details-open-storage']) {
final action = find.byKey(ValueKey(key));
expect(action, findsOneWidget);
expect(find.descendant(of: action, matching: find.byType(shad.IconButton)), findsOneWidget);
expect(find.descendant(of: action, matching: find.byType(AppTooltip)), findsOneWidget);
}
expect(find.byKey(const ValueKey('task-details-close')), findsNothing);
expect(
find.descendant(of: find.byKey(const ValueKey('task-details-drawer')), matching: find.byIcon(Icons.close)),
findsOneWidget,
);
expect(find.byKey(const ValueKey('app-detail-drawer-close-button')), findsOneWidget);
expect(find.text('Copy'), findsNothing);
expect(find.text('Edit'), findsNothing);
expect(find.text('Open'), findsNothing);
final copyAction = find.byKey(const ValueKey('task-details-copy-url'));
expect(tester.widget(copyAction), isA<AppCopyIconButton>());
expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget);
expect(
tester.widget<AppTooltip>(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message,
'Copy',
);
await tester.tap(copyAction);
await tester.pump();
expect(clipboardText, task.url);
expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.check)), findsOneWidget);
expect(
tester.widget<AppTooltip>(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message,
'Copied',
);
final editAction = find.byKey(const ValueKey('task-details-edit-url'));
final editTooltipFinder = find.descendant(of: editAction, matching: find.byType(AppTooltip));
expect(tester.widget<AppTooltip>(editTooltipFinder).message, 'Edit');
await tester.tap(editAction);
await tester.pumpAndSettle();
for (final key in ['task-details-cancel-url', 'task-details-save-url']) {
final action = find.byKey(ValueKey(key));
expect(action, findsOneWidget);
expect(find.descendant(of: action, matching: find.byType(shad.IconButton)), findsOneWidget);
expect(find.descendant(of: action, matching: find.byType(AppTooltip)), findsOneWidget);
}
expect(find.text('Cancel'), findsNothing);
expect(find.text('Save'), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('mobile task details copy the storage path instead of opening it', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await _setTestSize(tester, const Size(390, 700));
final task = _taskRecord(id: 'mobile-storage', name: 'mobile.zip', status: TaskStatus.completed);
var openedStorage = false;
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') {
clipboardText = (call.arguments as Map<Object?, Object?>)['text'] as String?;
}
return null;
});
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
await tester.pumpWidget(
ProviderScope(
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskDetailsView(
task: task,
mobile: true,
onOpenStorage: () => openedStorage = true,
onUpdateUrl: (_) async {},
),
),
),
);
await tester.pumpAndSettle();
final copyAction = find.byKey(const ValueKey('task-details-copy-storage'));
expect(copyAction, findsOneWidget);
expect(find.byKey(const ValueKey('task-details-open-storage')), findsNothing);
expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget);
expect(
tester.widget<AppTooltip>(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message,
'Copy',
);
await tester.ensureVisible(copyAction);
await tester.pumpAndSettle();
tester
.widget<shad.IconButton>(find.descendant(of: copyAction, matching: find.byType(shad.IconButton)))
.onPressed!();
await tester.pumpAndSettle();
expect(openedStorage, isFalse);
expect(clipboardText, task.storagePath);
expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.check)), findsOneWidget);
expect(
tester.widget<AppTooltip>(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message,
'Copied',
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('task drawer hides URL editing for non-HTTP tasks', (WidgetTester tester) async {
await _setTestSize(tester, const Size(900, 700));
final task = _taskRecord(
id: 'bt-details',
name: 'paused.torrent',
status: TaskStatus.paused,
protocol: api_task.Protocol.bt,
);
await tester.pumpWidget(
ProviderScope(
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Stack(
children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})],
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-details-edit-url')), findsNothing);
expect(find.byKey(const ValueKey('task-details-copy-url')), findsOneWidget);
});
testWidgets('mobile task details use a standalone route without bottom navigation', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 760));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(RecordingTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(PrimaryBottomNavigation), findsOneWidget);
await tester.tap(find.text('first.zip'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('Task Details'), findsOneWidget);
expect(find.text('Details'), findsOneWidget);
expect(find.text('Files'), findsOneWidget);
expect(find.text('Statistics'), findsOneWidget);
expect(find.byType(PrimaryBottomNavigation), findsNothing);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.text('Files'));
await tester.pump();
expect(find.byKey(const ValueKey('task-files-browse')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('task-files-browse')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.byType(TaskFileManagerView), findsOneWidget);
expect(find.text('first.zip'), findsWidgets);
});
testWidgets('task file manager uses flat directories and platform-specific file actions', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(390, 700));
final task = _taskRecord(
id: 'managed-files',
name: 'bundle',
status: TaskStatus.completed,
isFolder: true,
files: const [
TaskFileNode(path: '/', name: 'root.txt', sizeBytes: 128),
TaskFileNode(path: '/docs', name: 'guide.pdf', sizeBytes: 1024),
],
);
Future<void> pumpManager({required bool webActions, required bool desktopActions}) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskFileManagerView(task: task, webActions: webActions, desktopActions: desktopActions),
),
);
await tester.pump();
}
await pumpManager(webActions: true, desktopActions: false);
expect(find.text('root.txt'), findsOneWidget);
expect(find.text('docs'), findsOneWidget);
expect(find.text('guide.pdf'), findsNothing);
expect(find.byIcon(GopeedIcons.fileText), findsOneWidget);
expect(find.byIcon(GopeedIcons.folder), findsOneWidget);
expect(find.byIcon(Icons.open_in_new), findsOneWidget);
final openFileTooltip = find.ancestor(of: find.byIcon(Icons.open_in_new), matching: find.byType(AppTooltip));
expect(tester.widget<AppTooltip>(openFileTooltip).message, 'Open File');
expect(find.byIcon(Icons.download_outlined), findsOneWidget);
expect(find.byIcon(Icons.share_outlined), findsNothing);
await tester.tap(find.text('docs'));
await tester.pump();
expect(find.text('guide.pdf'), findsOneWidget);
expect(find.byIcon(GopeedIcons.filePdf), findsOneWidget);
expect(find.text('1 items'), findsNothing);
expect(find.byKey(const ValueKey('file-breadcrumb-/docs')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('file-breadcrumb-/')));
await tester.pump();
expect(find.text('1 items'), findsOneWidget);
await pumpManager(webActions: false, desktopActions: false);
expect(find.byIcon(Icons.download_outlined), findsNothing);
expect(find.byIcon(Icons.share_outlined), findsOneWidget);
await pumpManager(webActions: false, desktopActions: true);
expect(find.byIcon(Icons.download_outlined), findsNothing);
expect(find.byIcon(Icons.share_outlined), findsNothing);
expect(find.byIcon(Icons.open_in_new), findsOneWidget);
expect(find.byIcon(Icons.folder_open_outlined), findsNWidgets(2));
});
testWidgets('open desktop task details follow refreshed file metadata', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
late RefreshingTaskFilesController tasksController;
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(() => tasksController = RefreshingTaskFilesController()),
],
child: const GopeedApp(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.text('fresh.zip'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
await tester.tap(find.text('Files'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
expect(find.text('late-file.bin'), findsNothing);
tasksController.publishFiles();
await tester.pump();
expect(find.text('late-file.bin'), findsOneWidget);
});
testWidgets('mobile task cards use compact vertical spacing', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 240));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ListView(
children: [
TaskCard(
task: _taskRecord(id: 'mobile-compact', name: 'mobile.zip'),
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
),
],
),
),
);
expect(tester.getSize(find.byKey(const ValueKey('task-card-mobile-surface'))).height, lessThan(96));
});
testWidgets('piece map stays within narrow layouts and exposes a tapped range', (WidgetTester tester) async {
await _setTestSize(tester, const Size(280, 640));
final pieces = List<TaskPieceState>.generate(
5000,
(index) => index < 2100 ? TaskPieceState.completed : TaskPieceState.pending,
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(16),
child: PieceMap(pieceMap: TaskPieceMap.fromStates(pieces, pieceSize: 1024)),
),
),
);
await tester.pump(const Duration(milliseconds: 50));
expect(tester.takeException(), isNull);
final piecePaint = find.descendant(of: find.byType(PieceMap), matching: find.byType(CustomPaint));
expect(tester.getSize(piecePaint).height, lessThan(240));
await tester.tapAt(tester.getTopLeft(piecePaint) + const Offset(5, 5));
await tester.pump();
expect(find.textContaining('Pieces 1'), findsOneWidget);
});
testWidgets('large piece map fills complete rows at the default drawer width', (WidgetTester tester) async {
await _setTestSize(tester, const Size(440, 640));
final pieces = List<TaskPieceState>.filled(5000, TaskPieceState.pending);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: PieceMap(pieceMap: TaskPieceMap.fromStates(pieces, pieceSize: 1024)),
),
),
);
await tester.pump();
final piecePaint = find.descendant(of: find.byType(PieceMap), matching: find.byType(CustomPaint));
expect(tester.getSize(piecePaint).width, 392);
// The fixed 252-cell cap fills 9 rows at the default 28-column width.
expect(tester.getSize(piecePaint).height, closeTo(123.96, 0.1));
expect(tester.takeException(), isNull);
});
testWidgets('BT statistics show upload, share ratio, and share duration on the second row', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(440, 760));
final stats = BtTaskStats(
totalPeers: 24,
activePeers: 6,
connectedSeeders: 4,
connectedLeechers: 2,
seedBytes: 1536,
seedRatio: 1.25,
seedTime: 3723,
peers: const [],
pieceMap: TaskPieceMap.fromStates(const [TaskPieceState.completed, TaskPieceState.pending], pieceSize: 1024),
);
await tester.pumpWidget(
ProviderScope(
overrides: [taskStatsProvider.overrideWith((ref, request) async => stats)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskStatisticsTab(
task: _taskRecord(id: 'bt-statistics', name: 'archive.torrent', protocol: api_task.Protocol.bt),
mobile: false,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Downloaded'), findsNothing);
expect(find.text('Uploaded'), findsOneWidget);
expect(find.text('Share Ratio'), findsOneWidget);
expect(find.text('Share Duration'), findsOneWidget);
expect(find.text('2 hours'), findsOneWidget);
final uploadRect = tester.getRect(find.text('Uploaded'));
final ratioRect = tester.getRect(find.text('Share Ratio'));
final durationRect = tester.getRect(find.text('Share Duration'));
expect(uploadRect.top, closeTo(ratioRect.top, 0.01));
expect(ratioRect.top, closeTo(durationRect.top, 0.01));
expect(uploadRect.center.dx, lessThan(ratioRect.center.dx));
expect(ratioRect.center.dx, lessThan(durationRect.center.dx));
expect(tester.takeException(), isNull);
});
testWidgets('empty peer table keeps its sortable column headers', (WidgetTester tester) async {
await _setTestSize(tester, const Size(440, 500));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const Padding(
padding: EdgeInsets.all(16),
child: PeerTable(protocol: PeerTableProtocol.bt, peers: []),
),
),
);
await tester.pumpAndSettle();
for (final header in [
'Address',
'Client',
'Protocol',
'Speed',
'Upload speed',
'Pieces',
'Progress',
'File relevance',
'Source',
]) {
expect(find.text(header), findsOneWidget);
}
expect(find.text('No connections'), findsNothing);
expect(find.byType(Scrollbar), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('peer table displays the remote completion percentage', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 500));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const Padding(
padding: EdgeInsets.all(16),
child: PeerTable(
protocol: PeerTableProtocol.bt,
peers: [
TaskPeerStats(
address: '127.0.0.1:6881',
client: 'Gopeed',
downloadSpeed: 2048,
uploadSpeed: 1024,
pieceCount: 3,
completion: 0.375,
relevance: 0.125,
source: 'pex',
transport: 'utp',
),
TaskPeerStats(
address: '127.0.0.2:6881',
client: 'Gopeed',
downloadSpeed: 1024,
uploadSpeed: 512,
pieceCount: 8,
completion: 1,
relevance: 0,
source: 'dht',
transport: 'tcp',
),
],
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Progress'), findsOneWidget);
expect(find.text('File relevance'), findsOneWidget);
expect(find.text('37.5%'), findsOneWidget);
expect(find.text('12.5%'), findsOneWidget);
expect(find.text('uTP'), findsOneWidget);
expect(find.text('BT'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('ED2K peer table keeps protocol-specific columns', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 500));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const Padding(
padding: EdgeInsets.all(16),
child: PeerTable(
protocol: PeerTableProtocol.ed2k,
peers: [
TaskPeerStats(
address: '127.0.0.1:4662',
client: 'aMule',
downloadSpeed: 2048,
uploadSpeed: 1024,
pieceCount: 3,
completion: null,
relevance: null,
source: 'kad',
transport: 'tcp',
),
],
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('ED2K'), findsOneWidget);
expect(find.text('Progress'), findsNothing);
expect(find.text('File relevance'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('HTTP connection lanes keep metadata and progress on one compact row', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 640));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(16),
child: HttpConnectionLanes(
taskDownloading: true,
connections: const [
HttpConnectionStats(
downloaded: 32 * 1024 * 1024,
total: 64 * 1024 * 1024,
completed: false,
failed: false,
retryTimes: 0,
),
HttpConnectionStats(
downloaded: 12 * 1024 * 1024,
total: 64 * 1024 * 1024,
completed: false,
failed: false,
retryTimes: 2,
),
HttpConnectionStats(
downloaded: 48 * 1024 * 1024,
total: 64 * 1024 * 1024,
completed: true,
failed: false,
retryTimes: 0,
),
HttpConnectionStats(
downloaded: 8 * 1024 * 1024,
total: 64 * 1024 * 1024,
completed: false,
failed: true,
retryTimes: 3,
),
],
),
),
),
);
await tester.pump(const Duration(milliseconds: 50));
final firstLane = find.byKey(const ValueKey('http-connection-lane-0'));
final secondLane = find.byKey(const ValueKey('http-connection-lane-1'));
final thirdLane = find.byKey(const ValueKey('http-connection-lane-2'));
final fourthLane = find.byKey(const ValueKey('http-connection-lane-3'));
expect(tester.getSize(firstLane).height, 38);
expect(tester.getSize(secondLane).height, 38);
expect(tester.getSize(thirdLane).height, 38);
expect(tester.getSize(fourthLane).height, 38);
expect(find.text('#1'), findsOneWidget);
expect(find.text('Downloading'), findsNothing);
expect(find.text('Retrying'), findsNothing);
expect(find.text('重试 2 次'), findsNothing);
expect(find.text('Completed'), findsNothing);
final firstProgress = find.byKey(const ValueKey('http-connection-progress-0'));
final secondProgress = find.byKey(const ValueKey('http-connection-progress-1'));
final thirdProgress = find.byKey(const ValueKey('http-connection-progress-2'));
final fourthProgress = find.byKey(const ValueKey('http-connection-progress-3'));
expect(tester.getSize(firstProgress).width, tester.getSize(secondProgress).width);
expect(tester.getSize(secondProgress).width, tester.getSize(thirdProgress).width);
expect(tester.getSize(thirdProgress).width, tester.getSize(fourthProgress).width);
expect(tester.widget<TaskProgressBar>(firstProgress).shimmer, isTrue);
expect(tester.widget<TaskProgressBar>(firstProgress).indeterminate, isFalse);
expect(tester.widget<TaskProgressBar>(firstProgress).value, 0.5);
expect(tester.widget<TaskProgressBar>(secondProgress).shimmer, isFalse);
expect(tester.widget<TaskProgressBar>(secondProgress).indeterminate, isFalse);
expect(tester.widget<TaskProgressBar>(thirdProgress).shimmer, isFalse);
expect(tester.widget<TaskProgressBar>(thirdProgress).value, 0.75);
expect(tester.widget<TaskProgressBar>(fourthProgress).shimmer, isFalse);
expect(tester.widget<TaskProgressBar>(fourthProgress).value, 0.125);
final retryIcon = find.byKey(const ValueKey('http-connection-status-1'));
final retryTooltip = find.descendant(of: retryIcon, matching: find.byType(AppTooltip));
expect(tester.widget<AppTooltip>(retryTooltip).message, 'Retrying · 2 retries');
final failedIcon = find.byKey(const ValueKey('http-connection-status-3'));
expect(find.descendant(of: failedIcon, matching: find.byType(AppTooltip)), findsOneWidget);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
await mouse.moveTo(tester.getCenter(retryIcon));
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
expect(find.text('Retrying · 2 retries'), findsOneWidget);
await mouse.removePointer();
expect(
tester.getRect(firstProgress).left - tester.getRect(find.byKey(const ValueKey('http-connection-status-0'))).right,
closeTo(8, 0.01),
);
expect(tester.getRect(find.text('32 MB')).right, closeTo(tester.getRect(firstLane).right, 0.01));
expect(tester.getRect(find.text('48 MB')).right, closeTo(tester.getRect(thirdLane).right, 0.01));
expect(tester.takeException(), isNull);
});
testWidgets('HTTP connections use indeterminate progress only when total is unknown', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 320));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const Padding(
padding: EdgeInsets.all(16),
child: HttpConnectionLanes(
taskDownloading: true,
connections: [
HttpConnectionStats(downloaded: 1024, total: 0, completed: false, failed: false, retryTimes: 0),
HttpConnectionStats(downloaded: 4096, total: 2048, completed: false, failed: false, retryTimes: 0),
],
),
),
),
);
await tester.pump(const Duration(milliseconds: 50));
final unknown = tester.widget<TaskProgressBar>(find.byKey(const ValueKey('http-connection-progress-0')));
final overflow = tester.widget<TaskProgressBar>(find.byKey(const ValueKey('http-connection-progress-1')));
expect(unknown.indeterminate, isTrue);
expect(unknown.value, isNull);
expect(overflow.indeterminate, isFalse);
expect(overflow.value, 1);
});
testWidgets('HTTP connection progress stays static while its task is paused', (WidgetTester tester) async {
await _setTestSize(tester, const Size(390, 320));
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const Padding(
padding: EdgeInsets.all(16),
child: HttpConnectionLanes(
taskDownloading: false,
connections: [
HttpConnectionStats(downloaded: 1024, total: 2048, completed: false, failed: false, retryTimes: 0),
HttpConnectionStats(downloaded: 1024, total: 0, completed: false, failed: false, retryTimes: 0),
],
),
),
),
);
await tester.pump(const Duration(milliseconds: 50));
final known = tester.widget<TaskProgressBar>(find.byKey(const ValueKey('http-connection-progress-0')));
final unknown = tester.widget<TaskProgressBar>(find.byKey(const ValueKey('http-connection-progress-1')));
expect(known.shimmer, isFalse);
expect(known.indeterminate, isFalse);
expect(known.value, 0.5);
expect(unknown.shimmer, isFalse);
expect(unknown.indeterminate, isFalse);
expect(unknown.value, isNull);
});
testWidgets('task context menu exposes pause and selection operations', (WidgetTester tester) async {
await _setTestSize(tester, const Size(520, 620));
var paused = false;
var selected = false;
var detailsShown = false;
var directoryOpened = false;
var filesBrowsed = false;
final task = _taskRecord(id: 'context-task', name: 'context.zip');
final actions = TaskContextActions(
selected: false,
allSelected: false,
listeningForUpdate: false,
onShowDetails: () => detailsShown = true,
onOpenFile: () {},
onOpenDirectory: () => directoryOpened = true,
onBrowseFiles: () => filesBrowsed = true,
onToggleSelectAll: () {},
onToggleSelected: () => selected = true,
onPause: () => paused = true,
onResume: () {},
onDelete: () {},
onUpdateUrl: () {},
onToggleUpdateListener: () {},
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: shad.Scaffold(
child: Center(
child: TaskContextMenu(
task: task,
actions: actions,
child: const SizedBox(key: ValueKey('context-target'), width: 260, height: 80),
),
),
),
),
);
await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
expect(find.text('Details'), findsOneWidget);
expect(find.text('Open File'), findsNothing);
expect(find.text('Open Directory'), findsOneWidget);
expect(find.text('Browse Files'), findsOneWidget);
expect(find.text('Pause'), findsOneWidget);
expect(find.text('Select'), findsOneWidget);
expect(tester.getTopLeft(find.text('Details')).dy, greaterThan(tester.getTopLeft(find.text('Update URL')).dy));
expect(tester.getTopLeft(find.text('Open Directory')).dy, greaterThan(tester.getTopLeft(find.text('Details')).dy));
expect(
tester.getTopLeft(find.text('Browse Files')).dy,
greaterThan(tester.getTopLeft(find.text('Open Directory')).dy),
);
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(detailsShown, isTrue);
await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Directory'));
await tester.pumpAndSettle();
expect(directoryOpened, isTrue);
await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
await tester.tap(find.text('Browse Files'));
await tester.pumpAndSettle();
expect(filesBrowsed, isTrue);
await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
await tester.tap(find.text('Select'));
await tester.pumpAndSettle();
expect(selected, isTrue);
await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
await tester.tap(find.text('Pause'));
await tester.pumpAndSettle();
expect(paused, isTrue);
});
test('task context menu exposes system-open actions by platform and task type', () {
final completedFileTask = _taskRecord(id: 'completed-file-task', name: 'file.zip', status: TaskStatus.completed);
final downloadingFileTask = _taskRecord(id: 'downloading-file-task', name: 'file.zip');
final completedDirectoryTask = _taskRecord(
id: 'completed-directory-task',
name: 'directory',
status: TaskStatus.completed,
isFolder: true,
);
expect(shouldShowTaskOpenFileAction(completedFileTask, web: false), isTrue);
expect(shouldShowTaskOpenFileAction(downloadingFileTask, web: false), isFalse);
expect(shouldShowTaskOpenFileAction(completedDirectoryTask, web: false), isFalse);
expect(shouldShowTaskOpenFileAction(completedFileTask, web: true), isFalse);
expect(shouldShowTaskOpenDirectoryAction(web: false, desktop: true), isTrue);
expect(shouldShowTaskOpenDirectoryAction(web: false, desktop: false), isFalse);
expect(shouldShowTaskOpenDirectoryAction(web: true, desktop: true), isFalse);
});
testWidgets('task URL update reuses the shared HTTP header editor', (WidgetTester tester) async {
await _setTestSize(tester, const Size(900, 700));
TaskUrlUpdate? update;
final task = _taskRecord(
id: 'update-url',
name: 'archive.zip',
status: TaskStatus.paused,
requestHeaders: const {'Authorization': 'Bearer old'},
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async => update = await showTaskUpdateUrlDialog(context, task),
child: const Text('Open update dialog'),
),
),
),
),
);
await tester.tap(find.text('Open update dialog'));
await tester.pumpAndSettle();
expect(find.byType(AppHttpHeadersEditor), findsOneWidget);
final firstName = find.byKey(const ValueKey('update-task-http-header-name-0'));
final firstValue = find.byKey(const ValueKey('update-task-http-header-value-0'));
final firstNameField = find.descendant(of: firstName, matching: find.byType(AppTextField));
final firstValueField = find.descendant(of: firstValue, matching: find.byType(AppTextField));
expect(tester.widget<shad.TextField>(firstNameField).controller!.text, 'Authorization');
expect(tester.widget<shad.TextField>(firstValueField).controller!.text, 'Bearer old');
expect(tester.getSize(firstValue).width / tester.getSize(firstName).width, closeTo(1.618, 0.01));
await tester.tap(find.byKey(const ValueKey('update-task-http-header-add')));
await tester.pump();
final secondName = find.byKey(const ValueKey('update-task-http-header-name-1'));
final secondValue = find.byKey(const ValueKey('update-task-http-header-value-1'));
expect(secondName, findsOneWidget);
await tester.enterText(secondName, 'Referer');
await tester.enterText(secondValue, 'https://example.com/source');
final addHeader = find.byKey(const ValueKey('update-task-http-header-add'));
final removeHeader = find.byKey(const ValueKey('update-task-http-header-remove-1'));
expect(tester.getCenter(addHeader).dy, closeTo(tester.getCenter(removeHeader).dy, 0.01));
await tester.tap(find.text('Update and resume'));
await tester.pumpAndSettle();
expect(update?.headers, {'Authorization': 'Bearer old', 'Referer': 'https://example.com/source'});
});
testWidgets('task URL update can be cancelled without a result', (WidgetTester tester) async {
await _setTestSize(tester, const Size(900, 700));
TaskUrlUpdate? update;
var completed = false;
final task = _taskRecord(id: 'cancel-update-url', name: 'archive.zip', status: TaskStatus.paused);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
update = await showTaskUpdateUrlDialog(context, task);
completed = true;
},
child: const Text('Open update dialog'),
),
),
),
),
);
await tester.tap(find.text('Open update dialog'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(completed, isTrue);
expect(update, isNull);
expect(tester.takeException(), isNull);
});
testWidgets('delete dialog returns keep-files switch value', (WidgetTester tester) async {
await _setTestSize(tester, const Size(520, 620));
bool? keepFiles;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
keepFiles = await showTaskDeleteDialog(context, taskCount: 2, keepFiles: true);
},
child: const Text('Open delete dialog'),
),
),
),
);
await tester.tap(find.text('Open delete dialog'));
await tester.pumpAndSettle();
expect(find.text('Delete 2 tasks?'), findsOneWidget);
expect(find.text('Only remove the task record from Gopeed'), findsNothing);
expect(find.byKey(const ValueKey('task-delete-dialog-icon')), findsOneWidget);
expect(find.byKey(const ValueKey('task-delete-keep-files-option')), findsOneWidget);
final keepFilesSwitch = find.byKey(const ValueKey('task-delete-keep-files-switch'));
expect(tester.widget<shad.Switch>(keepFilesSwitch).value, isTrue);
await tester.tap(keepFilesSwitch);
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(keepFiles, isFalse);
});
testWidgets('pending URL update asks whether to update, create, or cancel', (WidgetTester tester) async {
await _setTestSize(tester, const Size(520, 620));
PendingUpdateDecision? decision;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
decision = await showPendingUpdateDialog(context, taskName: 'archive.zip');
},
child: const Text('Open pending update dialog'),
),
),
),
);
await tester.tap(find.text('Open pending update dialog'));
await tester.pumpAndSettle();
expect(find.text('Pending Update Task Found'), findsOneWidget);
expect(
find.text('Task "archive.zip" is waiting for URL update. Do you want to update it with the new URL?'),
findsOneWidget,
);
expect(find.text('Cancel'), findsOneWidget);
expect(find.text('Create New Task'), findsOneWidget);
expect(find.text('Update Task'), findsOneWidget);
await tester.tap(find.text('Create New Task'));
await tester.pumpAndSettle();
expect(decision, PendingUpdateDecision.createTask);
});
testWidgets('captured URL update preserves the full request and stops listening after confirmation', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1024, 768));
late RecordingPendingUpdateTasksController tasksController;
final container = ProviderContainer(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
appDeepLinkControllerProvider.overrideWith(FakeDeepLinkController.new),
appNotificationControllerProvider.overrideWith(FakeNotificationController.new),
tasksControllerProvider.overrideWith(() {
tasksController = RecordingPendingUpdateTasksController();
return tasksController;
}),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: container, child: const GopeedApp()));
await tester.pumpAndSettle();
const listeningTask = PendingUpdateTask(id: 'paused-http', name: 'archive.zip');
final replacementRequest = Request(
url: 'https://example.com/replacement.zip',
extra: const {
'header': {'Authorization': 'Bearer replacement'},
},
skipVerifyCert: true,
);
container.read(pendingUpdateTaskProvider.notifier).set(listeningTask);
container
.read(pendingUpdateRequestProvider.notifier)
.set(
PendingUpdateRequest(
task: listeningTask,
createTask: CreateTask(req: replacementRequest),
),
);
await tester.pumpAndSettle();
expect(find.text('Pending Update Task Found'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(tasksController.updatedTaskId, isNull);
expect(container.read(pendingUpdateTaskProvider), same(listeningTask));
expect(container.read(pendingUpdateRequestProvider), isNull);
container
.read(pendingUpdateRequestProvider.notifier)
.set(
PendingUpdateRequest(
task: listeningTask,
createTask: CreateTask(req: replacementRequest),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Update Task'));
await tester.pumpAndSettle();
expect(tasksController.updatedTaskId, 'paused-http');
expect(tasksController.updatedRequest, same(replacementRequest));
expect(tasksController.updatedRequest?.skipVerifyCert, isTrue);
expect(tasksController.updatedRequest?.extra, replacementRequest.extra);
expect(container.read(pendingUpdateTaskProvider), isNull);
expect(container.read(pendingUpdateRequestProvider), isNull);
});
testWidgets('desktop batch pause sends only selected task ids', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
late RecordingTasksController tasksController;
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(() => tasksController = RecordingTasksController()),
],
child: const GopeedApp(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.byIcon(Icons.checklist_rtl_outlined));
await tester.pump(const Duration(milliseconds: 250));
expect(
tester.widgetList<shad.Checkbox>(find.byType(shad.Checkbox)).every((item) => item.activeColor == null),
isTrue,
);
await tester.tap(find.text('first.zip'));
await tester.pump();
await tester.tap(find.byIcon(Icons.pause_outlined));
await tester.pump(const Duration(milliseconds: 250));
expect(tasksController.pausedIds, ['first']);
});
testWidgets('completed task batch selection only enables delete', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 768));
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
tasksControllerProvider.overrideWith(CompletedTasksController.new),
],
child: const GopeedApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Completed'));
await tester.pump();
await tester.tap(find.byIcon(Icons.checklist_rtl_outlined));
await tester.pump(const Duration(milliseconds: 250));
await tester.tap(find.text('done.zip'));
await tester.pump();
final actions = tester
.widgetList<shad.IconButton>(
find.descendant(
of: find.byKey(const ValueKey('tasks-top-action-buttons')),
matching: find.byType(shad.IconButton),
),
)
.toList(growable: false);
expect(actions, hasLength(4));
expect(actions[0].onPressed, isNull);
expect(actions[1].onPressed, isNull);
expect(actions[2].onPressed, isNotNull);
expect(actions[3].onPressed, isNotNull);
});
testWidgets('batch selection rebuilds only the affected task', (WidgetTester tester) async {
final controller = TaskBatchSelectionController();
addTearDown(controller.dispose);
const taskIds = ['first', 'second', 'third'];
final buildCounts = {for (final taskId in taskIds) taskId: 0};
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Row(
children: [
for (final taskId in taskIds)
TaskBatchSelectionBuilder(
controller: controller,
taskId: taskId,
visibleTaskIds: taskIds,
builder: (context, selected, allSelected) {
buildCounts[taskId] = buildCounts[taskId]! + 1;
return Text('$taskId:$selected:$allSelected');
},
),
],
),
),
);
buildCounts.updateAll((_, _) => 0);
controller.toggle('first');
await tester.pump();
expect(buildCounts, {'first': 1, 'second': 0, 'third': 0});
expect(find.text('first:true:false'), findsOneWidget);
});
testWidgets('completed task selection responds immediately in batch mode', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 220));
var selectedCount = 0;
var openedCount = 0;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: _taskRecord(id: 'completed', name: 'completed.zip', status: TaskStatus.completed),
selected: false,
batchMode: true,
selectedInBatch: false,
onPressed: () => selectedCount++,
onToggleBatch: () => selectedCount++,
onOpen: () => openedCount++,
),
),
),
);
await tester.tap(find.text('completed.zip'));
await tester.pump();
expect(selectedCount, 1);
expect(openedCount, 0);
});
for (final width in [390.0, 1024.0]) {
testWidgets(
'completed card actions respect platform at width $width',
(tester) async {
await _setTestSize(tester, Size(width, 220));
var deleted = 0;
var revealed = 0;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: _taskRecord(id: 'platform-actions', name: 'done.zip', status: TaskStatus.completed),
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
onDelete: () => deleted++,
onReveal: () => revealed++,
),
),
),
);
await tester.pumpAndSettle();
final desktop =
!kIsWeb &&
{TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux}.contains(defaultTargetPlatform);
expect(find.byIcon(Icons.folder_open_outlined), desktop ? findsOneWidget : findsNothing);
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
if (desktop) {
await tester.tap(find.byIcon(Icons.folder_open_outlined));
expect(revealed, 1);
}
await tester.tap(find.byIcon(Icons.delete_outline));
expect(deleted, 1);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant({
TargetPlatform.android,
TargetPlatform.iOS,
TargetPlatform.windows,
TargetPlatform.macOS,
TargetPlatform.linux,
}),
);
}
testWidgets(
'completed task double click opens while folder action reveals',
(WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 220));
var openedCount = 0;
var revealedCount = 0;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskCard(
task: _taskRecord(id: 'completed-actions', name: 'completed.zip', status: TaskStatus.completed),
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
onOpen: () => openedCount++,
onReveal: () => revealedCount++,
),
),
),
);
await tester.tap(find.text('completed.zip'));
await tester.pump(kDoubleTapMinTime);
await tester.tap(find.text('completed.zip'));
await tester.pumpAndSettle();
expect(openedCount, 1);
expect(revealedCount, 0);
await tester.tap(find.byIcon(Icons.folder_open_outlined));
await tester.pump();
expect(openedCount, 1);
expect(revealedCount, 1);
},
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
skip: kIsWeb,
);
}
Future<void> _setTestSize(WidgetTester tester, Size size) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
}
class _ResponsiveMenuHarness extends StatefulWidget {
const _ResponsiveMenuHarness();
@override
State<_ResponsiveMenuHarness> createState() => _ResponsiveMenuHarnessState();
}
class _ResponsiveMenuHarnessState extends State<_ResponsiveMenuHarness> {
var _selected = 'general';
var _showMobileContent = false;
@override
Widget build(BuildContext context) {
return shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResponsiveMenuLayout<String>(
title: 'Settings',
selectedValue: _selected,
onSelected: (value) => setState(() {
_selected = value;
_showMobileContent = true;
}),
items: const [
ResponsiveMenuItem(value: 'general', label: 'General', icon: Icons.settings_outlined),
ResponsiveMenuItem(value: 'advanced', label: 'Advanced', icon: Icons.tune_outlined),
],
mobileContentTitleBuilder: (value) => value == 'advanced' ? 'Advanced' : 'General',
mobileContentVisible: _showMobileContent,
onMobileBack: () => setState(() => _showMobileContent = false),
contentBuilder: (context, selected) {
return Text(selected == 'advanced' ? 'Advanced content' : 'General content');
},
),
);
}
}
class _SettingsTestApp extends StatefulWidget {
const _SettingsTestApp({this.dark = false, this.wrapComponentThemes = false});
final bool dark;
final bool wrapComponentThemes;
@override
State<_SettingsTestApp> createState() => _SettingsTestAppState();
}
class _SettingsTestAppState extends State<_SettingsTestApp> {
late final GoRouter _router;
@override
void initState() {
super.initState();
_router = GoRouter(
initialLocation: '/settings',
routes: [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
routes: [
GoRoute(
path: ':section',
builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']),
),
],
),
],
);
}
@override
void dispose() {
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return shad.ShadcnApp.router(
theme: widget.dark ? AppTheme.dark() : AppTheme.light(),
materialTheme: widget.dark ? AppTheme.materialDark() : AppTheme.materialLight(),
builder: (context, child) {
final content = widget.wrapComponentThemes
? AppComponentThemes(child: child ?? const SizedBox.shrink())
: child ?? const SizedBox.shrink();
final mediaQuery = MediaQuery.maybeOf(context);
if (mediaQuery == null) return content;
return MediaQuery(data: mediaQuery.copyWith(disableAnimations: true), child: content);
},
routerConfig: _router,
);
}
}
class _CreateTaskPageHarness extends StatelessWidget {
const _CreateTaskPageHarness();
@override
Widget build(BuildContext context) {
return shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const AppComponentThemes(child: CreateTaskWindowPage()),
);
}
}
class FakeTasksController extends TasksController {
@override
Future<TasksState> build() async => const TasksState(tasks: []);
}
class FailingTasksController extends TasksController {
@override
Future<TasksState> build() async => throw Exception('offline');
}
class FakeExtensionsController extends ExtensionsController {
bool get devModeEnabled => state.requireValue.devMode;
void enableDevModeForTest() {
state = AsyncValue.data(state.requireValue.copyWith(devMode: true));
}
int installCalls = 0;
String? lastInstallUrl;
Completer<void>? installGate;
@override
Future<void> installFromUrl(String url, {bool devInstall = false}) async {
installCalls++;
lastInstallUrl = url;
await installGate?.future;
}
int removeCalls = 0;
@override
Future<ExtensionsState> build() async {
final installed = api_extension.Extension(
identity: 'extension-0',
name: 'extension-0',
author: 'Gopeed',
title: 'Extension 0',
description: 'Installed extension',
icon: '',
version: '1.0.0',
homepage: '',
repository: null,
disabled: false,
devMode: false,
devPath: '',
);
return ExtensionsState(
installedExtensions: [installed],
storeExtensions: List.generate(
8,
(index) => StoreExtension(
id: 'extension-$index',
repoFullName: 'gopeed/extension-$index',
repoUrl: 'https://github.com/gopeed/extension-$index',
name: 'extension-$index',
author: 'Gopeed',
title: 'Extension $index',
description: 'Extension description $index',
readme: '# Extension $index README\n\nExtension details for testing.',
homepage: 'https://gopeed.com/extensions/extension-$index',
version: '1.0.0',
installCount: index,
stars: index,
topics: const ['download'],
),
),
);
}
@override
Future<void> installFromStore(StoreExtension extension) async {
installCalls++;
}
@override
Future<void> toggleExtension(api_extension.Extension extension, bool enabled) async {
extension.disabled = !enabled;
state = AsyncValue.data(
state.requireValue.copyWith(installedExtensions: [...state.requireValue.installedExtensions]),
);
}
@override
Future<void> removeExtension(api_extension.Extension extension) async {
removeCalls++;
}
}
class CrowdedExtensionCardController extends ExtensionsController {
@override
Future<ExtensionsState> build() async {
final installed =
api_extension.Extension(
identity: 'crowded-extension',
name: 'crowded-extension',
author: 'Gopeed',
title: 'Crowded extension',
description: 'An extension with every available card action.',
icon: '',
version: '1.0.0',
homepage: 'https://gopeed.com',
repository: api_extension.Repository(url: 'https://github.com/GopeedLab/gopeed', directory: ''),
disabled: false,
devMode: false,
devPath: '',
)
..settings = [
api_extension.Setting(
name: 'enabled',
title: 'Enabled',
description: 'Enable the extension.',
required: false,
type: api_extension.SettingType.boolean,
),
];
final store = StoreExtension(
id: 'crowded-extension',
repoFullName: 'GopeedLab/gopeed',
repoUrl: 'https://github.com/GopeedLab/gopeed',
name: 'crowded-extension',
author: 'Gopeed',
title: 'Crowded extension',
description: 'An extension with every available card action.',
readme: '# Crowded extension',
homepage: 'https://gopeed.com',
version: '1.1.0',
installCount: 1234567890,
stars: 12345,
topics: ['download'],
);
return ExtensionsState(installedExtensions: [installed], storeExtensions: [store]);
}
}
class DevReadmeExtensionsController extends FakeExtensionsController {
DevReadmeExtensionsController(this.extensionDirectory);
final String extensionDirectory;
@override
Future<ExtensionsState> build() async {
final initial = await super.build();
final installed = initial.installedExtensions.first
..devMode = true
..devPath = extensionDirectory;
return initial.copyWith(installedExtensions: [installed]);
}
}
class LoadingExtensionsController extends ExtensionsController {
final _pending = Completer<ExtensionsState>();
@override
Future<ExtensionsState> build() => _pending.future;
}
class LoadingMoreExtensionsController extends FakeExtensionsController {
@override
Future<ExtensionsState> build() async {
final initial = await super.build();
return initial.copyWith(
loadingMoreStore: true,
storePagination: StorePagination(page: 1, limit: 8, total: 16, totalPages: 2, hasNext: true, hasPrev: false),
);
}
}
class PaginatedExtensionsController extends FakeExtensionsController {
int loadMoreCalls = 0;
@override
Future<ExtensionsState> build() async {
final initial = await super.build();
return initial.copyWith(
storePagination: StorePagination(page: 1, limit: 8, total: 16, totalPages: 2, hasNext: true, hasPrev: false),
);
}
@override
Future<void> loadMoreStore() async {
final current = state.requireValue;
if (current.loadingMoreStore || current.storePagination?.hasNext != true) return;
loadMoreCalls++;
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
await Future<void>.delayed(const Duration(milliseconds: 20));
state = AsyncValue.data(
state.requireValue.copyWith(
loadingMoreStore: false,
storePagination: StorePagination(page: 2, limit: 8, total: 16, totalPages: 2, hasNext: false, hasPrev: true),
),
);
}
}
class UpdateableExtensionsController extends FakeExtensionsController {
int updateCalls = 0;
bool failUpdate = false;
Completer<void> updateGate = Completer<void>();
@override
Future<ExtensionsState> build() async =>
(await super.build()).copyWith(listFilter: ExtensionListFilter.installed, updateFlags: {'extension-0': '2.0.0'});
@override
Future<void> upgradeExtension(api_extension.Extension extension) async {
updateCalls++;
await updateGate.future;
if (failUpdate) throw StateError('update failed');
}
}
class BusyInstallExtensionsController extends FakeExtensionsController {
@override
Future<ExtensionsState> build() async {
final initial = await super.build();
return initial.copyWith(
devMode: true,
busyExtensionIds: {ExtensionsController.manualInstallBusyKey, 'extension-1'},
);
}
}
class FakeRuntimeController extends AppRuntimeController {
@override
Future<AppRuntimeState> build() async {
final cfg = StartConfig()
..network = 'tcp'
..address = '127.0.0.1:9999'
..apiEnable = true
..apiToken = ''
..storage = 'bolt'
..storageDir = ''
..refreshInterval = 0;
return AppRuntimeState(
startConfig: cfg,
apiServerState: _testApiServerState(running: true, port: 9999),
downloaderConfig: DownloaderConfig(),
);
}
}
class PendingTrackerRuntimeController extends FakeRuntimeController {
final _trackerUpdateCompleter = Completer<void>();
@override
Future<void> updateTrackers(DownloaderConfig config) => _trackerUpdateCompleter.future;
void completeTrackerUpdate() => _trackerUpdateCompleter.complete();
}
class FailingTrackerRuntimeController extends FakeRuntimeController {
@override
Future<void> updateTrackers(DownloaderConfig config) async {
throw StateError('tracker update failed');
}
}
class RecordingStartConfigRuntimeController extends AppRuntimeController {
StartConfig? savedStartConfig;
@override
Future<AppRuntimeState> build() async {
final config = StartConfig()
..network = 'tcp'
..address = '192.168.1.20:4321'
..apiEnable = true
..apiToken = 'token'
..storage = 'bolt'
..storageDir = ''
..refreshInterval = 0;
return AppRuntimeState(
startConfig: config,
apiServerState: _testApiServerState(running: true, port: 4321, address: config.address),
downloaderConfig: DownloaderConfig(),
);
}
@override
Future<void> saveApiServerConfig(StartConfig config) async =>
_record(config, running: state.requireValue.apiServerState.running);
@override
Future<void> startApiServer() async => _record(state.requireValue.startConfig..apiEnable = true, running: true);
@override
Future<void> stopApiServer() async => _record(state.requireValue.startConfig..apiEnable = false, running: false);
@override
Future<void> restartApiServer(StartConfig config) async => _record(config..apiEnable = true, running: true);
void _record(StartConfig config, {required bool running}) {
savedStartConfig = StartConfig()
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
..mcpEnable = config.mcpEnable
..apiToken = config.apiToken
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval;
final current = state.requireValue;
state = AsyncValue.data(
AppRuntimeState(
startConfig: savedStartConfig!,
apiServerState: _testApiServerState(
running: running,
port: running ? 54321 : 0,
address: config.address,
enabled: config.apiEnable,
),
downloaderConfig: current.downloaderConfig,
localBackendStarted: current.localBackendStarted,
startupError: current.startupError,
),
);
}
}
ApiServerState _testApiServerState({
required bool running,
required int port,
String address = '127.0.0.1:9999',
bool? enabled,
}) {
return ApiServerState(
enabled: enabled ?? running,
mcpEnabled: false,
running: running,
network: running ? 'tcp' : '',
address: running ? address : '',
runningPort: port,
pendingApply: false,
lastError: '',
);
}
class FakeSettingsController extends SettingsController {
@override
Future<SettingsState> build() async => SettingsState(config: DownloaderConfig());
@override
Future<void> save(DownloaderConfig config) async {
state = AsyncValue.data(SettingsState(config: config));
}
}
class StaticSettingsController extends FakeSettingsController {
int reloadCalls = 0;
bool? lastReloadShowLoading;
@override
Future<void> reload({bool showLoading = true}) async {
reloadCalls++;
lastReloadShowLoading = showLoading;
final current = state.requireValue;
if (showLoading) state = const AsyncValue.loading();
state = AsyncValue.data(current);
}
}
class CategorySettingsController extends FakeSettingsController {
@override
Future<SettingsState> build() async {
final config = DownloaderConfig();
config.extra.downloadCategories = [DownloadCategory(name: '资料', path: '/tmp/data')];
return SettingsState(config: config);
}
}
class ProxySettingsController extends FakeSettingsController {
@override
Future<SettingsState> build() async {
final config = DownloaderConfig();
config.proxy
..enable = true
..system = false
..scheme = 'socks5'
..host = 'proxy.example.com:1080';
return SettingsState(config: config);
}
}
class Ed2kSettingsController extends FakeSettingsController {
@override
Future<SettingsState> build() async {
final config = DownloaderConfig();
config.protocolConfig.ed2k
..serverAddr = 'server-a:4661,server-b:4661'
..serverMet = 'https://example.com/server.met,https://mirror.example.com/server.met'
..nodesDat = '/tmp/nodes.dat,https://example.com/nodes.dat';
return SettingsState(config: config);
}
}
class FakePlatformController extends AppPlatformController {
@override
Future<AppPlatformState> build() async => const AppPlatformState(started: true);
}
class FakeDeepLinkController extends AppDeepLinkController {
@override
Future<AppDeepLinkState> build() async => const AppDeepLinkState(started: true);
}
class FakeNotificationController extends AppNotificationController {
@override
Future<AppNotificationState> build() async => const AppNotificationState(started: true);
}
class RecordingPendingUpdateTasksController extends TasksController {
String? updatedTaskId;
Request? updatedRequest;
@override
Future<TasksState> build() async => const TasksState(tasks: []);
@override
Future<void> updateRequest(String id, Request request) async {
updatedTaskId = id;
updatedRequest = request;
}
}
class AvailableUpdatePlatformController extends AppPlatformController {
@override
Future<AppPlatformState> build() async => const AppPlatformState(
started: true,
updateStatus: AppUpdateStatus.available,
latestVersion: VersionInfo(
version: '9.9.9',
changeLog: '# 更新日志\n\n- 测试更新',
releaseUrl: 'https://example.com/release',
),
);
}
class CheckingUpdatePlatformController extends AppPlatformController {
@override
Future<AppPlatformState> build() async =>
const AppPlatformState(started: true, updateStatus: AppUpdateStatus.checking);
}
class RecordingTasksController extends TasksController {
List<String>? pausedIds;
@override
Future<TasksState> build() async {
return TasksState(
tasks: [
_taskRecord(id: 'first', name: 'first.zip'),
_taskRecord(id: 'second', name: 'second.zip'),
],
);
}
@override
Future<void> pauseAll(List<String>? ids) async {
pausedIds = ids == null ? null : List<String>.of(ids);
}
}
class CompletedTasksController extends TasksController {
@override
Future<TasksState> build() async {
return TasksState(
tasks: [_taskRecord(id: 'done', name: 'done.zip', status: TaskStatus.completed)],
);
}
}
class RefreshingTaskFilesController extends TasksController {
@override
Future<TasksState> build() async {
return TasksState(
tasks: [_taskRecord(id: 'fresh', name: 'fresh.zip')],
);
}
void publishFiles() {
state = AsyncValue.data(
TasksState(
tasks: [
_taskRecord(
id: 'fresh',
name: 'fresh.zip',
files: const [TaskFileNode(path: '', name: 'late-file.bin', sizeBytes: 128)],
),
],
),
);
}
}
TaskRecord _taskRecord({
required String id,
required String name,
TaskStatus status = TaskStatus.downloading,
List<TaskFileNode> files = const [],
api_task.Protocol protocol = api_task.Protocol.http,
String? remaining,
Duration? downloadDuration,
DateTime? createdAt,
DateTime? doneAt,
bool isFolder = false,
Map<String, String> requestHeaders = const {},
}) {
return TaskRecord(
id: id,
name: name,
status: status,
downloaded: '50 B',
total: '100 B',
speed: '10 B/s',
progress: 0.5,
url: 'https://example.com/$name',
storagePath: '/downloads/$name',
files: files,
uploading: false,
isFolder: isFolder,
protocol: protocol,
remaining: remaining,
downloadDuration: downloadDuration,
createdAt: createdAt,
doneAt: doneAt,
requestHeaders: requestHeaders,
);
}
api_task.Task _apiTransferTask({
required String id,
required api_task.Status status,
required bool uploading,
required int speed,
required int uploadSpeed,
}) {
return api_task.Task(
id: id,
name: id,
meta: Meta(
req: Request(url: 'https://example.com/$id'),
opts: Options(),
),
status: status,
uploading: uploading,
progress: api_task.Progress(used: 0, speed: speed, downloaded: 0, uploadSpeed: uploadSpeed, uploaded: 0),
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}