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

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

No files matched your search

@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:gopeed/api/model/extension.dart';
import 'package:gopeed/features/extensions/presentation/widgets/extension_setting_field.dart';
import 'package:gopeed/shared/widgets/app_text_field.dart';
void main() {
Future<void> pumpField(WidgetTester tester, Setting setting, TextEditingController controller) async {
await tester.pumpWidget(
ShadcnApp(
home: Scaffold(
child: Center(
child: SizedBox(
width: 280,
child: ExtensionSettingField(setting: setting, controller: controller),
),
),
),
),
);
await tester.pumpAndSettle();
}
Setting setting(SettingType type) =>
Setting(name: 'test', title: 'Test setting', description: 'Description', required: false, type: type);
testWidgets('boolean switch loads and updates the saved value', (tester) async {
final controller = TextEditingController(text: 'true');
addTearDown(controller.dispose);
await pumpField(tester, setting(SettingType.boolean), controller);
expect(find.byType(AppTextField), findsNothing);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(controller.text, 'false');
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
});
for (final type in [SettingType.string, SettingType.number]) {
testWidgets(
'$type options show labels and save selected values',
(tester) async {
final field = setting(type)..options = [Option(label: 'First', value: 1), Option(label: 'Second', value: 2)];
final controller = TextEditingController(text: '1');
addTearDown(controller.dispose);
await pumpField(tester, field, controller);
expect(find.byType(AppTextField), findsNothing);
expect(find.text('First'), findsOneWidget);
await tester.tap(find.byType(Select<String>));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.text('Second'));
await tester.pumpAndSettle();
expect(controller.text, '2');
expect(find.text('Second'), findsOneWidget);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}),
);
}
testWidgets('number field accepts decimals and rejects nonnumeric input', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await pumpField(tester, setting(SettingType.number), controller);
await tester.enterText(find.byType(AppTextField), '-12.5');
expect(controller.text, '-12.5');
await tester.enterText(find.byType(AppTextField), 'invalid');
expect(controller.text, '-12.5');
});
testWidgets('string with empty options remains a text field', (tester) async {
final controller = TextEditingController(text: 'original');
addTearDown(controller.dispose);
await pumpField(tester, setting(SettingType.string)..options = [], controller);
await tester.enterText(find.byType(AppTextField), 'updated');
expect(controller.text, 'updated');
});
}
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
void main() {
@@ -41,4 +42,19 @@ void main() {
expect(record.files.length, 3);
expect(record.totalBytes, 60);
});
test('file names preserve hash and question mark characters for type detection', () {
expect(
taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),
GopeedIcons.fileVideo,
);
expect(taskFileTypeIcon('What is this? #2.MP4'), GopeedIcons.fileVideo);
expect(taskFileTypeIcon('Episode #2.mp3'), GopeedIcons.fileAudio);
});
test('URL fallback ignores query and fragment when detecting file type', () {
expect(taskFileTypeIcon('https://example.com/video.mp4?token=abc#part'), GopeedIcons.fileVideo);
expect(taskFileTypeIcon('https://example.com/download?name=video.mp4'), GopeedIcons.file);
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
});
}
@@ -1,5 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/widgets.dart' as flutter show Row;
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -8,19 +11,197 @@ import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/capabilities/storage_capability.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
import 'package:gopeed/shared/services/download_directory_picker.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_design_tokens.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final navigationFails in [false, true]) {
testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async {
tester.view.physicalSize = const Size(1024, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final events = <String>[];
const channel = MethodChannel('window_manager');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
events.add(call.method);
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.createTask, (_) {
events.add('created');
return 'task-id';
})
..bind(NavigationMethods.showDownloadingTasks, (_) {
events.add('downloads');
if (navigationFails) throw StateError('Navigation unavailable');
return const RpcUnit();
});
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: CreateTaskWindowPage(
windowController: WindowController.fromWindowId('child'),
initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
// The mocked native close deliberately leaves the widget mounted.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(events, ['created', 'downloads', 'close']);
await tester.pumpWidget(const SizedBox());
});
}
testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'created-id';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final pending = container.read(pendingCreateTaskProvider.notifier);
pending.set(
CreateTask(
req: Request(
url: 'https://example.com/first.zip',
rawUrl: 'https://example.com/first',
labels: {'source': 'first'},
extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(),
proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'),
skipVerifyCert: true,
),
),
);
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip');
expect(container.read(pendingCreateTaskProvider), isNull);
final pageState = tester.state(find.byType(CreateTaskWindowPage));
for (final url in [
'https://example.com/second.zip',
'https://example.com/third.zip',
'https://example.com/third.zip',
]) {
pending.set(CreateTask(req: Request(url: url)));
router.go('/create');
await tester.pumpAndSettle();
expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState));
expect(_fieldText(tester, 'create-task-url-input'), url);
expect(container.read(pendingCreateTaskProvider), isNull);
}
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.req?.url, 'https://example.com/third.zip');
expect(submitted?.req?.rawUrl, isNull);
expect(submitted?.req?.labels, isNull);
expect(submitted?.req?.extra, isNull);
expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow);
expect(submitted?.req?.skipVerifyCert, isFalse);
expect(tester.takeException(), isNull);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('mobile create task page stays within the system safe areas', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
tester.view.physicalSize = const Size(700, 760);
tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding(top: 32, bottom: 24);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPadding);
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
try {
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
final contentPadding = tester.widget<Padding>(find.byKey(const ValueKey('create-task-safe-content')));
expect((contentPadding.padding as EdgeInsets).top, 32);
expect(
tester.getBottomRight(find.byKey(const ValueKey('create-task-confirm-button'))).dy,
lessThanOrEqualTo(tester.view.physicalSize.height / tester.view.devicePixelRatio - 24),
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('browser extension request parameters populate the form and survive submission', (
WidgetTester tester,
) async {
@@ -121,7 +302,10 @@ void main() {
final connectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
expect(connectionsInput, findsOneWidget);
expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth);
expect(
tester.getSize(connectionsInput).width,
tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width,
);
final connectionsField = tester.widget<shad.TextField>(connectionsInput);
expect(connectionsField.controller!.text, '12');
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
@@ -280,6 +464,58 @@ void main() {
expect(submitted?.opts?.asDefaultPath, isTrue);
});
testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: 'resolved-id',
res: Resource(
name: 'server-generated-name',
files: [FileInfo(name: 'video.mp4', size: 1024)],
),
),
)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(_fieldText(tester, 'create-task-rename-input'), isEmpty);
final createButton = tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button')));
expect(createButton.child, isA<SizedBox>());
expect((createButton.child as SizedBox).width, 68);
expect(
find.descendant(of: find.byKey(const ValueKey('resolve-create-button')), matching: find.text('Create')),
findsOneWidget,
);
expect(
tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))),
tester.getSize(find.byKey(const ValueKey('resolve-create-button'))),
);
expect(tester.takeException(), isNull);
});
testWidgets('create history supports filtering, deleting one entry, and clearing all entries', (
WidgetTester tester,
) async {
@@ -324,6 +560,7 @@ void main() {
await tester.tap(find.byIcon(Icons.history));
await tester.pumpAndSettle();
expect(find.text('History Links'), findsOneWidget);
expect(
tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width,
tester.getSize(find.byKey(const ValueKey('create-history-content'))).width,
@@ -0,0 +1,59 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/history_search_field.dart';
import 'package:gopeed/l10n/l10n.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) {
for (final scale in [1.0, 1.5, 2.0]) {
testWidgets('history hint and input line align on $platform at scale $scale', (tester) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final controller = TextEditingController();
addTearDown(controller.dispose);
String? query;
for (final width in [280.0, 360.0, 600.0]) {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AppComponentThemes(
child: Builder(
builder: (context) => Localizations.override(
context: context,
locale: const Locale('zh'),
child: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
child: Center(
child: SizedBox(
width: width,
child: HistorySearchField(controller: controller, onChanged: (value) => query = value),
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
final editable = tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
final hint = tester.renderObject<RenderBox>(find.text('搜索历史记录…'));
expect(editable.preferredLineHeight, lessThanOrEqualTo(editable.size.height));
expect(hint.size.height, closeTo(editable.preferredLineHeight, 0.1));
expect(hint.localToGlobal(Offset.zero).dy, closeTo(editable.localToGlobal(Offset.zero).dy, 0.1));
expect(tester.takeException(), isNull);
}
await tester.enterText(find.byType(EditableText), 'example');
expect(query, 'example');
debugDefaultTargetPlatformOverride = null;
});
}
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/create_task_action_buttons.dart';
import 'package:gopeed/l10n/l10n.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 1024.0]) {
for (final language in ['en', 'zh', 'de']) {
testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async {
tester.view.physicalSize = Size(width, 760);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var submitting = false;
var cancelled = false;
await tester.pumpWidget(
shad.ShadcnApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => Localizations.override(
context: context,
locale: Locale(language),
delegates: const [AppLocalizations.delegate],
child: AppComponentThemes(
child: Center(
child: StatefulBuilder(
builder: (context, setState) => shad.AlertDialog(
padding: const EdgeInsets.all(18),
content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100),
actions: [
Flexible(
child: CreateTaskActionButtons(
submitting: submitting,
onCancel: () => cancelled = true,
onSubmit: () => setState(() => submitting = true),
cancelLabel: context.l10n.cancel,
submitLabel: context.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
final cancel = find.byKey(const ValueKey('resolve-cancel-button'));
final create = find.byKey(const ValueKey('resolve-create-button'));
final cancelRect = tester.getRect(cancel);
final createRect = tester.getRect(create);
expect(cancelRect.size, createRect.size);
expect(cancelRect.center.dy, createRect.center.dy);
expect(createRect.left - cancelRect.right, 12);
expect((tester.widget<shad.SecondaryButton>(cancel).child as SizedBox).width, 68);
expect((tester.widget<AppLoadingButton>(create).child as SizedBox).width, 68);
expect(cancelRect.left, greaterThanOrEqualTo(0));
expect(createRect.right, lessThanOrEqualTo(width));
expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget);
await tester.tap(cancel);
expect(cancelled, isTrue);
await tester.tap(create);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(tester.widget<AppLoadingButton>(create).loading, isTrue);
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
expect(tester.getRect(cancel), cancelRect);
expect(tester.getRect(create), createRect);
expect(tester.takeException(), isNull);
});
}
}
}