feat(tasks): group extension folder downloads (#1594)

This commit is contained in:
WuFeng authored and GitHub committed 2026-11-02 15:39:08 +08:00
1 parent fc0a1e0ef9
commit 1e36599d52
84 files changed
+6690 -881

No files matched your search

@@ -0,0 +1,445 @@
import 'dart:async';
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:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.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/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_http_headers_editor.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('cancel and confirm again resolves every unsubmitted URL afresh', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final resolved = <String>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.resolve, (request) {
resolved.add(request.req!.url);
return ResolveResult(
id: 'rid-${resolved.length}',
res: Resource(files: [FileInfo(name: 'file')]),
);
});
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
final url = find.byKey(const ValueKey('create-task-url-input'));
await tester.enterText(url, 'https://example.com/a\nhttps://example.com/b');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolved, ['https://example.com/a', 'https://example.com/b']);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolved, [
'https://example.com/a',
'https://example.com/b',
'https://example.com/a',
'https://example.com/b',
]);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
for (final failGroup in [true, false]) {
testWidgets('reopening a preview retries only the failed batch (group=$failGroup)', (tester) async {
final resolutions = <String>[];
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!.url);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (batch.group == failGroup && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches, hasLength(2));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
fail = false;
await _openPreview(tester);
final failedUrl = 'https://example.com/${failGroup ? 'group' : 'other'}';
expect(resolutions, ['https://example.com/group', 'https://example.com/other', failedUrl]);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches, hasLength(3));
expect(batches.where((batch) => batch.group != failGroup), hasLength(1));
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('completion survives reordering and distinguishes duplicate source occurrences', (tester) async {
final resolutions = <String>[];
var groupCalls = 0;
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!.url);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
if (batch.group) {
groupCalls++;
if (groupCalls == 2 && fail) throw StateError('second occurrence failed');
}
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other\nhttps://example.com/group');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
fail = false;
await _openPreview(tester, urls: 'https://example.com/other\nhttps://example.com/group\nhttps://example.com/group');
expect(resolutions, [
'https://example.com/group',
'https://example.com/other',
'https://example.com/group',
'https://example.com/group',
]);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupCalls, 3);
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('unfinished sources resolve with edited options without recreating a completed group', (tester) async {
final resolutions = <Request>[];
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (!batch.group && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
tester.widget<AppHttpHeadersEditor>(find.byType(AppHttpHeadersEditor)).controller.replace({'Cookie': 'new-cookie'});
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/retry');
fail = false;
await _openPreview(tester);
expect(resolutions.map((request) => request.url), [
'https://example.com/group',
'https://example.com/other',
'https://example.com/other',
]);
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-cookie');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches.where((batch) => batch.group), hasLength(1));
expect(batches.last.reqs!.single.opts!.path, '/retry');
expect(tester.takeException(), isNull);
});
testWidgets('a new creation window can submit the same source again', (tester) async {
var resolutions = 0;
var submissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (_) {
submissions++;
return CreateTaskBatchResult(taskIds: ['created']);
});
final router = await _openPage(tester, registry);
for (var attempt = 0; attempt < 2; attempt++) {
if (attempt > 0) {
router.go('/create');
await tester.pumpAndSettle();
}
await _openPreview(tester, urls: 'https://example.com/group');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(CreateTaskWindowPage), findsNothing);
}
expect(resolutions, 2);
expect(submissions, 2);
expect(tester.takeException(), isNull);
});
for (final duringSubmission in [false, true]) {
testWidgets('a new external task starts a fresh session (during submission: $duringSubmission)', (tester) async {
final firstSubmission = Completer<CreateTaskBatchResult>();
final resolutions = <Request>[];
var groupSubmissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
final req = request.req!;
resolutions.add(req);
return _resolved(req.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
if (batch.group) {
if (++groupSubmissions == 1 && duringSubmission) return firstSubmission.future;
} else if (!duringSubmission) {
throw StateError('ordinary batch failed');
}
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
final page = find.byType(CreateTaskWindowPage);
final pageState = tester.state(page);
final container = ProviderScope.containerOf(tester.element(page));
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupSubmissions, 1);
if (!duringSubmission) {
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
}
container
.read(pendingCreateTaskProvider.notifier)
.set(
CreateTask(
req: Request(
url: 'https://example.com/group',
extra: ReqExtraHttp(method: 'GET', header: {'Cookie': 'new-intent'}, body: '').toJson(),
),
),
);
await tester.pump();
if (duringSubmission) {
firstSubmission.complete(CreateTaskBatchResult(taskIds: ['old-created']));
await tester.pump();
}
await tester.pumpAndSettle();
expect(tester.state(page), same(pageState));
await _openPreview(tester);
expect(resolutions.map((request) => request.url), [
'https://example.com/group',
'https://example.com/other',
'https://example.com/group',
]);
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-intent');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupSubmissions, 2);
expect(page, findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('a failed stale direct submission releases the new task form', (tester) async {
final firstSubmission = Completer<CreateTaskBatchResult>();
final requests = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (batch) {
requests.add(batch);
if (requests.length == 1) return firstSubmission.future;
return CreateTaskBatchResult(taskIds: ['new-created']);
});
await _openPage(tester, registry);
final container = ProviderScope.containerOf(tester.element(find.byType(CreateTaskWindowPage)));
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/old');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
expect(requests, hasLength(1));
container.read(pendingCreateTaskProvider.notifier).set(CreateTask(req: Request(url: 'https://example.com/new')));
await tester.pump();
firstSubmission.completeError(StateError('old submission failed'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(CreateTaskWindowPage), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(requests, hasLength(2));
expect(requests.last.reqs!.single.req!.url, 'https://example.com/new');
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('confirming only completed sources finishes without opening another preview', (tester) async {
var resolutions = 0;
var submissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
submissions++;
if (!batch.group) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/group');
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'invalid');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolutions, 2);
expect(submissions, 2);
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('switching to direct download skips completed preview batches', (tester) async {
var resolutions = 0;
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (!batch.group && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
await tester.pump();
fail = false;
await _openPreview(tester);
expect(resolutions, 2);
expect(batches, hasLength(3));
expect(batches.last.reqs!.single.req!.url, 'https://example.com/other');
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
}
ResolveResult _resolved(String url) => url.endsWith('/group')
? ResolveResult(
res: Resource(
name: 'album',
files: [
FileInfo(
name: 'a.txt',
req: Request(url: 'https://example.com/a.txt'),
),
FileInfo(
name: 'b.txt',
req: Request(url: 'https://example.com/b.txt'),
),
],
),
)
: ResolveResult(
id: 'rid-$url',
res: Resource(files: [FileInfo(name: 'other.txt')]),
);
Future<GoRouter> _openPage(WidgetTester tester, CapabilityRegistry registry) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
registry
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
return router;
}
Future<void> _openPreview(WidgetTester tester, {String? urls}) async {
if (urls != null) await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), urls);
final confirm = find.byKey(const ValueKey('create-task-confirm-button'));
await tester.ensureVisible(confirm);
await tester.tap(confirm);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
@@ -36,7 +36,7 @@ import 'package:gopeed/l10n/app_localizations.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('extension file estimates survive batch submission and window serialization', (tester) async {
testWidgets('extension file paths and estimates survive batch submission and window serialization', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -49,6 +49,7 @@ void main() {
(_) => ResolveResult(
id: '',
res: Resource(
name: 'videos',
files: [
FileInfo(
name: 'under.mp4',
@@ -57,6 +58,7 @@ void main() {
),
FileInfo(
name: 'over.mp4',
path: 'nested',
size: 1048576,
req: Request(url: 'https://example.com/audio'),
),
@@ -66,7 +68,7 @@ void main() {
)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = CreateTaskBatch.fromJson(jsonDecode(jsonEncode(batch)) as Map<String, dynamic>);
return ['first', 'second'];
return CreateTaskBatchResult(taskIds: ['first', 'second'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final router = GoRouter(
@@ -97,6 +99,10 @@ void main() {
await tester.pumpAndSettle();
expect(submitted?.reqs?.map((item) => item.opts?.size), [3, 1048576]);
expect(submitted?.reqs?.map((item) => item.req?.url), ['https://example.com/video', 'https://example.com/audio']);
expect(submitted?.group, isTrue);
expect(submitted?.groupName, 'videos');
expect(submitted?.opts?.path, '/downloads');
expect(submitted?.reqs?.map((item) => item.opts?.path), ['', 'nested']);
expect(tester.takeException(), isNull);
});
@@ -118,9 +124,9 @@ void main() {
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.createTask, (_) {
..bind(GopeedMethods.createTaskBatch, (batch) {
events.add('created');
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(NavigationMethods.showDownloadingTasks, (_) {
events.add('downloads');
@@ -163,9 +169,14 @@ void main() {
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-id';
return CreateTaskBatchResult(taskIds: ['created-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
@@ -246,9 +257,14 @@ void main() {
GopeedMethods.getConfig,
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-id';
return CreateTaskBatchResult(taskIds: ['created-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
@@ -374,9 +390,15 @@ void main() {
..protocolConfig.http.connections = 8;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.putConfig, (_) => const RpcUnit())
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -877,9 +899,14 @@ void main() {
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -985,9 +1012,14 @@ void main() {
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -1140,9 +1172,18 @@ void main() {
final createdTasks = <CreateTask>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
createdTasks.add(task);
return 'task-id';
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: 'resolved',
res: Resource(files: [FileInfo(name: 'file.zip')]),
),
)
..bind(GopeedMethods.createTaskBatch, (batch) {
for (final item in batch.reqs!) {
createdTasks.add(CreateTask(rid: item.rid, req: item.req, opts: item.opts));
}
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -1189,8 +1230,12 @@ void main() {
// Confirm creation
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(createdTasks, isEmpty);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(createdTasks.length, 2);
for (final t in createdTasks) {
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
@@ -1345,7 +1390,7 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('the resolve dialog shows the suggested category directory', (tester) async {
testWidgets('the file list keeps category suggestions out of its header', (tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1381,8 +1426,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
final suggestion = find.byKey(const ValueKey('resolve-category-directory'));
expect(suggestion, findsOneWidget);
expect(tester.widget<Text>(suggestion).data, contains('/downloads/Program'));
expect(suggestion, findsNothing);
expect(find.text('File List'), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -1437,7 +1482,7 @@ void main() {
await _settleToastTimers(tester);
});
testWidgets('the resolve dialog shows the directory the user named, not the suggestion', (tester) async {
testWidgets('the file list uses its fixed title for a manual directory', (tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1475,13 +1520,87 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
final shown = find.byKey(const ValueKey('resolve-category-directory'));
expect(shown, findsOneWidget);
expect(tester.widget<Text>(shown).data, contains('/work'));
expect(tester.widget<Text>(shown).data, isNot(contains('/downloads/Program')));
expect(shown, findsNothing);
expect(find.text('File List'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a resolved file list is split into tasks that keep the directory structure', (tester) async {
testWidgets('a resolved file list keeps structure and saves the selected parent directory', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTaskBatch? batch;
final config = DownloaderConfig(downloadDir: '/downloads');
DownloaderConfig? savedConfig;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.putConfig, (next) {
savedConfig = next;
return const RpcUnit();
})
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
],
),
),
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return CreateTaskBatchResult(taskIds: ['task-1', 'task-2'], parentId: 'generated');
})
..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-directory-input')), '/chosen');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(2));
expect(batch!.group, isTrue);
expect(batch!.groupName, 'bundle');
expect(batch!.toJson().containsKey('groupId'), isFalse);
expect(batch!.opts?.path, '/chosen');
expect(batch!.opts?.asDefaultPath, isTrue);
for (final item in batch!.reqs!) {
// The split tasks keep the structure of the resolved resource, so they
// are never classified into a category or replace the selected parent.
expect(item.opts?.skipAutoCategorize, isTrue);
expect(item.opts?.asDefaultPath, isFalse);
}
expect(savedConfig?.downloadDir, '/chosen');
expect(batch!.group, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1504,64 +1623,7 @@ void main() {
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return ['task-1', 'task-2'];
})
..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/bundle');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(2));
for (final item in batch!.reqs!) {
// The split tasks keep the structure of the resolved resource, so they
// are never classified into a category.
expect(item.opts?.skipAutoCategorize, isTrue);
}
expect(batch!.opts?.skipAutoCategorize, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTaskBatch? batch;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
res: Resource(
files: [
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
],
),
),
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return ['task-1'];
return CreateTaskBatchResult(taskIds: ['task-1'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -1594,10 +1656,11 @@ void main() {
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(1));
expect(batch!.group, isTrue);
for (final item in batch!.reqs!) {
expect(item.opts?.skipAutoCategorize, isTrue);
}
expect(batch!.opts?.skipAutoCategorize, isTrue);
expect(batch!.group, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
@@ -1642,7 +1705,7 @@ void _ftpTests() {
testWidgets('ftp links offer the TLS mode and carry FTP options through resolve', (tester) async {
ResolveTask? resolved;
CreateTask? submitted;
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.resolve, (task) {
@@ -1655,9 +1718,9 @@ void _ftpTests() {
),
);
})
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
@@ -1693,20 +1756,20 @@ void _ftpTests() {
// The TLS mode travels on the request, so the resolve already uses it.
expect(resolved?.req?.url, 'FTP://files.example/pub/big.iso');
expect(resolved?.req?.extra, {'tls': 'explicit'});
expect(submitted?.rid, 'ftp-resolved');
final createOptions = submitted?.opts?.extra! as Map<String, dynamic>;
expect(submitted?.reqs?.single.rid, 'ftp-resolved');
final createOptions = submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>;
expect(createOptions['connections'], 32);
expect(createOptions.containsKey('tls'), isFalse);
});
testWidgets('an ftp .torrent link restores its TLS mode and is not treated as BitTorrent', (tester) async {
CreateTask? submitted;
CreateTaskBatch? submitted;
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(
@@ -1733,19 +1796,22 @@ void _ftpTests() {
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.req?.url, 'ftp://files.example/pub/linux.torrent');
expect(submitted?.req?.extra, {'tls': 'implicit'});
final options = OptsExtraFtp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
expect(submitted?.reqs?.single.req?.url, 'ftp://files.example/pub/linux.torrent');
expect(submitted?.reqs?.single.req?.extra, {'tls': 'implicit'});
final options = OptsExtraFtp.fromJson(submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>);
expect(options.connections, 6);
});
testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async {
final submitted = <CreateTask>[];
final submitted = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted.add(task);
return 'task-${submitted.length}';
..bind(
GopeedMethods.getConfig,
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted.add(batch);
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
@@ -1758,13 +1824,60 @@ void _ftpTests() {
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
final byUrl = {for (final task in submitted) task.req!.url: task.opts!.extra! as Map<String, dynamic>};
expect(submitted, hasLength(1));
final byUrl = {for (final item in submitted.single.reqs!) item.req!.url: item.opts!.extra! as Map<String, dynamic>};
expect(byUrl.keys, unorderedEquals(['https://example.com/a.zip', 'ftpes://files.example/b.iso']));
expect(byUrl['https://example.com/a.zip']!['connections'], 64);
expect(byUrl['https://example.com/a.zip']!.containsKey('tls'), isFalse);
expect(byUrl['ftpes://files.example/b.iso']!['connections'], 32);
expect(byUrl['ftpes://files.example/b.iso']!.containsKey('tls'), isFalse);
expect(submitted.map((task) => task.req!.extra), everyElement(isNull));
expect(submitted.single.reqs!.map((item) => item.req!.extra), everyElement(isNull));
});
testWidgets('mixed resolved HTTP and FTP resources share one batch with separate options', (tester) async {
final resolvedUrls = <String>[];
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.resolve, (task) {
final url = task.req!.url;
resolvedUrls.add(url);
final ftp = url.startsWith('ftp');
return ResolveResult(
id: ftp ? 'ftp-rid' : 'http-rid',
res: Resource(
name: ftp ? 'folder' : '',
files: [
FileInfo(name: ftp ? 'movie.mkv' : 'file.zip', size: 10),
if (ftp) FileInfo(name: 'extra.txt', size: 2),
],
),
);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
await tester.enterText(find.byKey(urlInput), 'https://example.com/a.zip\nftpes://files.example/folder');
await tester.enterText(find.byKey(connectionsInput), '64');
await tester.tap(find.text('Confirm'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolvedUrls, ['https://example.com/a.zip', 'ftpes://files.example/folder']);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(submitted!.group, isFalse);
expect(submitted!.reqs, hasLength(2));
final http = submitted!.reqs![0];
final ftp = submitted!.reqs![1];
expect(http.rid, 'http-rid');
expect(ftp.rid, 'ftp-rid');
expect((http.opts!.extra as Map)['connections'], 64);
expect((ftp.opts!.extra as Map)['connections'], 32);
expect(http.opts!.selectFiles, [0]);
expect(ftp.opts!.selectFiles, [0, 1]);
expect(tester.takeException(), isNull);
});
}
@@ -1795,9 +1908,10 @@ Future<AppPalette> _pumpCreateTaskForSubmit(
..categories = categories;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
onCreate(task);
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -1850,9 +1964,10 @@ Future<AppPalette> _pumpCreateTaskWithCategories(
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
if (onCreate != null) {
registry.bind(GopeedMethods.createTask, (task) {
registry.bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
onCreate(task);
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
});
}
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -0,0 +1,310 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.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/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_download_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.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;
PendingDownload pending(String id) => PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
);
ResolveResult resolved(String name) => ResolveResult(
id: name,
res: Resource(files: [FileInfo(name: name)]),
);
Future<void> open(
WidgetTester tester,
List<PendingDownload> sources,
CapabilityRegistry registry, {
ValueChanged<bool>? onClose,
double width = 900,
}) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
final result = await showResolveDownloadsDialog(
context,
sources: sources,
service: GopeedService(LocalCapabilityInvoker(registry)),
);
onClose?.call(result);
},
child: const Text('Open'),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('file list fits and exposes ready selections at width $width', (tester) async {
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => resolved('file.txt'));
await open(tester, [pending('source')], registry, width: width);
await tester.pumpAndSettle();
expect(find.text('File List'), findsOneWidget);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
expect(tester.takeException(), isNull);
});
}
testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async {
final calls = <String>[];
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
calls.add(request.req!.url);
return responses[calls.length - 1].future;
});
final sources = [pending('a'), pending('b')];
await open(tester, sources, registry);
expect(find.text('File List'), findsOneWidget);
expect(find.text('https://example.com/a'), findsOneWidget);
expect(find.text('https://example.com/b'), findsOneWidget);
expect(calls, ['https://example.com/a']);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message == 'Waiting to resolve'), findsOneWidget);
responses.first.complete(resolved('first.txt'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(calls, ['https://example.com/a', 'https://example.com/b']);
expect(find.text('first.txt'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:a:0')));
await tester.pump();
expect(sources.first.selected, isEmpty);
responses.last.complete(resolved('second.txt'));
await tester.pumpAndSettle();
expect(sources.first.selected, isEmpty);
expect(sources.last.selected, {0});
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
expect(tester.takeException(), isNull);
});
for (final filters in [
<String>['video'],
<String>['video', 'audio'],
]) {
testWidgets('new files clear type highlights without changing previous selections ($filters)', (tester) async {
final response = Completer<ResolveResult>();
final first = pending('first')
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'video.mp4', size: 10),
FileInfo(name: 'song.mp3', size: 20),
FileInfo(name: 'picture.png', size: 30),
],
),
)
..selected.addAll([0, 1, 2]);
final second = pending('second');
final batches = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => response.future)
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
return CreateTaskBatchResult(taskIds: ['created']);
});
await open(tester, [first, second], registry);
for (final filter in filters) {
await tester.tap(find.byKey(ValueKey('resolve-tree-filter-$filter')));
await tester.pump();
// Each selection is written back by the parent and rebuilds the tree.
expect(typeFilterActive(tester, filter), isTrue);
}
final expectedFirst = filters.length == 1 ? {0} : {0, 1};
expect(first.selected, expectedFirst);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
final incoming = filters.length == 1 ? 'new-audio.mp3' : 'new-image.png';
response.complete(
ResolveResult(
id: 'incoming-rid',
res: Resource(files: [FileInfo(name: incoming, size: 40)]),
),
);
await tester.pumpAndSettle();
expect(first.selected, expectedFirst);
expect(second.selected, {0});
for (final filter in filters) {
expect(typeFilterActive(tester, filter), isFalse);
}
expect(find.text('${expectedFirst.length + 1} / 4 selected'), findsOneWidget);
final size = tester.widget<Text>(find.byKey(const ValueKey('resolve-tree-selected-size'))).data;
expect(size, '${filters.length == 1 ? 50 : 70} B');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batches, hasLength(2));
expect(batches.first.group, isTrue);
expect(
batches.first.reqs!.map((item) => item.opts!.name),
filters.length == 1 ? ['video.mp4'] : ['video.mp4', 'song.mp3'],
);
expect(batches.last.group, isFalse);
expect(batches.last.reqs!.single.rid, 'incoming-rid');
expect(batches.last.reqs!.single.opts!.selectFiles, [0]);
expect(tester.takeException(), isNull);
});
}
testWidgets('a failed source preserves type highlights and the successful source selection', (tester) async {
final response = Completer<ResolveResult>();
final first = pending('first')
..result = ResolveResult(
res: Resource(
files: [
FileInfo(name: 'video.mp4'),
FileInfo(name: 'song.mp3'),
],
),
)
..selected.addAll([0, 1]);
final second = pending('failed');
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => response.future);
await open(tester, [first, second], registry);
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(typeFilterActive(tester, 'video'), isTrue);
response.completeError(StateError('unavailable'));
await tester.pumpAndSettle();
expect(first.selected, {0});
expect(second.selected, isEmpty);
expect(typeFilterActive(tester, 'video'), isTrue);
expect(find.text('1 / 2 selected'), findsOneWidget);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('same-name sources synchronize individual, root and all-file selections', (tester) async {
final sources = [
for (final id in ['first', 'second'])
pending(id)
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'a.txt'),
FileInfo(name: 'b.txt'),
],
),
)
..selected.addAll([0, 1]),
];
await open(tester, sources, CapabilityRegistry(createAppCapabilityCodecs()));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pumpAndSettle();
expect(sources.first.selected, {1});
expect(sources.last.selected, {0, 1});
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
await tester.pumpAndSettle();
expect(sources.first.selected, {1});
expect(sources.last.selected, isEmpty);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:1')));
await tester.pumpAndSettle();
expect(sources.first.selected, isEmpty);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
final selectAll = find.descendant(of: find.byType(ResolveFileTree), matching: find.byType(shad.Checkbox)).first;
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(sources.every((source) => source.selected.length == 2 && source.selected.containsAll([0, 1])), isTrue);
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(sources.every((source) => source.selected.isEmpty), isTrue);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('cancel stops the queue and ignores the late response', (tester) async {
final response = Completer<ResolveResult>();
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) {
calls++;
return response.future;
});
bool? closed;
await open(tester, [pending('a'), pending('b')], registry, onClose: (value) => closed = value);
await tester.tap(find.byKey(const ValueKey('resolve-close-button')));
await tester.pumpAndSettle();
response.complete(resolved('late.txt'));
await tester.pumpAndSettle();
expect(calls, 1);
expect(closed, isFalse);
expect(find.text('late.txt'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('failed root is disabled and only ready selections enter a single batch', (tester) async {
final batches = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
if (request.req!.url.endsWith('/bad')) throw StateError('unavailable');
return resolved('ready.txt');
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
return CreateTaskBatchResult(taskIds: ['created']);
});
bool? closed;
await open(tester, [pending('bad'), pending('good')], registry, onClose: (value) => closed = value);
await tester.pumpAndSettle();
expect(find.textContaining('unavailable'), findsNothing);
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message.contains('unavailable')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batches.single.reqs!.single.rid, 'ready.txt');
expect(closed, isTrue);
expect(tester.takeException(), isNull);
});
}
bool typeFilterActive(WidgetTester tester, String filter) {
final decoration =
tester
.widget<DecoratedBox>(
find
.descendant(
of: find.byKey(ValueKey('resolve-tree-filter-$filter')),
matching: find.byType(DecoratedBox),
)
.first,
)
.decoration
as BoxDecoration;
return decoration.color != null;
}
@@ -0,0 +1,467 @@
import 'package:flutter/widgets.dart';
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/file_tree_view.dart';
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
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/resolve_result.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
PendingDownload source(String id) =>
PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
)
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: '0.txt'),
FileInfo(name: '1.txt'),
],
),
)
..selected.addAll([0, 1]);
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('live mixed sources keep input order, selection and expansion at $width', (tester) async {
tester.view.physicalSize = Size(width, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final sources = [
for (final id in ['http', 'group', 'ungrouped', 'bt', 'failed'])
PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
),
];
final http = sources[0];
final group = sources[1];
final ungrouped = sources[2];
final bt = sources[3];
bt.request.url = 'magnet:?xt=urn:btih:infohash';
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(
sources: sources,
onSelectionChanged: (keys) {
for (final pending in sources) {
pending.selected
..clear()
..addAll(keys.where((key) => key.$1 == pending.id).map((key) => key.$2));
}
},
),
),
);
await tester.pump();
}
List<shad.TreeItemNode<FileTreeNode<PendingFileKey>>> roots() => tester
.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
)
.nodes
.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
void expectOrder(List<String> labels) => expect(roots().map((node) => node.data.label), labels);
await show();
expectOrder(sources.map((pending) => pending.request.url).toList());
http.result = ResolveResult(
id: 'http-rid',
res: Resource(files: [FileInfo(name: 'z-http.txt')]),
);
http.selected.add(0);
await show();
expectOrder(['z-http.txt', ...sources.skip(1).map((pending) => pending.request.url)]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:http:0')));
await tester.pump();
expect(http.selected, isEmpty);
group.result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'one.txt'),
FileInfo(name: 'two.txt'),
],
),
);
group.selected.addAll([0, 1]);
await show();
expectOrder(['z-http.txt', 'bundle', ...sources.skip(2).map((pending) => pending.request.url)]);
ungrouped.result = ResolveResult(
res: Resource(
files: [
FileInfo(name: 'z-first.txt'),
FileInfo(name: 'nested.txt', path: 'directory'),
FileInfo(name: 'a-last.txt'),
],
),
);
ungrouped.selected.addAll([0, 1, 2]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:group')));
await tester.pump(const Duration(milliseconds: 50));
await show();
await tester.pump(const Duration(milliseconds: 250));
expect(roots()[1].expanded, isFalse);
expectOrder([
'z-http.txt',
'bundle',
'z-first.txt',
'directory',
'a-last.txt',
bt.request.url,
sources.last.request.url,
]);
bt.result = ResolveResult(
id: 'bt-rid',
res: Resource(
name: 'torrent',
files: [
FileInfo(name: 'bt-one'),
FileInfo(name: 'bt-two'),
],
),
);
bt.selected.addAll([0, 1]);
await show();
sources.last.resolveError = 'cannot resolve';
await show();
expectOrder([
'z-http.txt',
'bundle',
'z-first.txt',
'directory',
'a-last.txt',
'torrent',
sources.last.request.url,
]);
expect(roots()[1].expanded, isFalse);
expect(http.selected, isEmpty);
expect(group.selected, {0, 1});
expect(ungrouped.selected, {0, 1, 2});
expect(bt.selected, {0, 1});
expect(sources.last.selected, isEmpty);
expect(tester.takeException(), isNull);
});
}
testWidgets('ED2K uses a file icon and filename while BT uses its resource name and folder icon', (tester) async {
final ed2k =
PendingDownload(
id: 'ed2k',
request: Request(url: 'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/'),
options: Options(),
)
..result = ResolveResult(
id: 'ed2k-rid',
res: Resource(
hash: '0123456789ABCDEF0123456789ABCDEF',
files: [FileInfo(name: 'payload.bin')],
),
)
..selected.add(0);
final bt =
PendingDownload(
id: 'bt',
request: Request(url: 'magnet:?xt=urn:btih:infohash'),
options: Options(),
)
..result = ResolveResult(
id: 'bt-rid',
res: Resource(
name: 'torrent resource',
hash: 'infohash',
files: [FileInfo(name: 'torrent-file.bin')],
),
)
..selected.add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [ed2k, bt], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(find.text('payload.bin'), findsOneWidget);
expect(find.text('torrent resource'), findsOneWidget);
expect(find.byIcon(GopeedIcons.folderBt), findsOneWidget);
expect(find.text(ed2k.request.url), findsNothing);
expect(tester.takeException(), isNull);
});
for (final directory in [false, true]) {
testWidgets('FTP resource ${directory ? 'directory' : 'file'} keeps its actual structure', (tester) async {
final pending = source('ftp');
pending.request.url = directory ? 'ftp://files.example/folder' : 'ftpes://files.example/file.torrent';
pending.result = ResolveResult(
id: 'ftp-rid',
res: Resource(
name: directory ? 'folder' : '',
files: [FileInfo(name: 'file.torrent')],
),
);
pending.selected
..clear()
..add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(pending.isBt, isFalse);
expect(pending.isGroup, isFalse);
expect(pending.name, directory ? 'folder' : 'file.torrent');
expect(find.text('folder'), directory ? findsOneWidget : findsNothing);
expect(find.byIcon(shad.BootstrapIcons.folder2), directory ? findsOneWidget : findsNothing);
expect(find.byIcon(GopeedIcons.folderBt), findsNothing);
expect(find.text('file.torrent'), findsOneWidget);
expect(planDownloads([pending]).single.request.reqs!.single.rid, 'ftp-rid');
expect(tester.takeException(), isNull);
});
}
for (final extension in [true, false]) {
testWidgets('magnet root icon follows the resolved resource (extension=$extension)', (tester) async {
final pending = source('magnet');
pending.request.url = 'magnet:?xt=urn:btih:infohash';
pending.result!.id = extension ? '' : 'bt-rid';
for (final file in pending.files) {
if (extension) file.req = Request(url: 'https://example.com/${file.name}');
}
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(pending.isGroup, extension);
expect(find.byIcon(GopeedIcons.folderBt), extension ? findsNothing : findsOneWidget);
expect(find.byIcon(GopeedIcons.file), findsNWidgets(2));
expect(find.byIcon(shad.BootstrapIcons.folder2), extension ? findsOneWidget : findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('ordinary single files replace URL placeholders without a source directory', (tester) async {
final pending = PendingDownload(
id: 'http',
request: Request(url: 'https://example.com/file'),
options: Options(),
);
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pump();
}
await show();
expect(find.text(pending.request.url), findsOneWidget);
pending.result = ResolveResult(
id: 'http-rid',
res: Resource(
name: '',
files: [FileInfo(name: 'file.txt')],
),
);
pending.selected.add(0);
await show();
expect(find.text('file.txt'), findsOneWidget);
expect(find.text(pending.request.url), findsNothing);
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
expect(tree.nodes, hasLength(1));
expect(tree.nodes.single.children, isEmpty);
expect((tree.nodes.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.item!.data, ('http', 0));
expect(tester.takeException(), isNull);
});
for (final filePath in ['', 'disc1']) {
testWidgets('named single-file extension keeps its resource directory (path=$filePath)', (tester) async {
final pending =
PendingDownload(
id: 'extension',
request: Request(url: 'https://example.com/track.mp3'),
options: Options(path: '/downloads'),
)
..result = ResolveResult(
res: Resource(
name: 'album',
files: [
FileInfo(
name: 'track.mp3',
path: filePath,
req: Request(url: 'https://cdn.example.com/track.mp3'),
),
],
),
)
..selected.add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
final roots = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
expect(roots.map((node) => node.data.label), ['album']);
expect(roots.single.expanded, isTrue);
final child = roots.single.children.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>;
expect(child.data.label, filePath.isEmpty ? 'track.mp3' : 'disc1');
final plan = planDownloads([pending]).single.request;
expect(plan.group, isFalse);
expect(plan.reqs!.single.opts!.path, filePath.isEmpty ? '/downloads/album' : '/downloads/album/disc1');
expect(tester.takeException(), isNull);
});
}
testWidgets('a live group replaces its URL placeholder with an expanded group directory', (tester) async {
final pending = PendingDownload(
id: 'group',
request: Request(url: 'https://example.com/group'),
options: Options(),
);
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pump();
}
await show();
pending.result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'one.txt'),
FileInfo(name: 'two.txt'),
],
),
);
pending.selected.addAll([0, 1]);
await show();
expect(find.text('bundle'), findsOneWidget);
expect(find.text('one.txt'), findsOneWidget);
expect(find.text('two.txt'), findsOneWidget);
expect(find.byIcon(shad.BootstrapIcons.folder2), findsOneWidget);
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('ungrouped roots preserve relative directories and source selection at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final sources = [source('first'), source('second')];
for (final pending in sources) {
pending.result!.res.name = '';
pending.files[0].name = 'same.txt';
pending.files[1].name = 'video.mp4';
pending.files[1].path = 'nested';
}
Set<PendingFileKey> selected = {};
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: sources, onSelectionChanged: (keys) => selected = keys),
),
);
await tester.pumpAndSettle();
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
expect(tree.nodes, hasLength(4));
expect(tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.label), [
'same.txt',
'nested',
'same.txt',
'nested',
]);
expect(
tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.key).toSet(),
hasLength(4),
);
expect(find.text('nested'), findsNWidgets(2));
expect(find.text('same.txt'), findsNWidgets(2));
expect(find.text(sources.first.request.url), findsNothing);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
final secondDirectoryKey = (tree.nodes.last as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.key;
await tester.tap(find.byKey(ValueKey('resolve-tree-name-scroll-$secondDirectoryKey')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0)});
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(selected, {('first', 1), ('second', 1)});
expect(tester.takeException(), isNull);
});
}
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('same-name roots isolate file indexes and root selection at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final first = source('first');
final second = source('second');
Set<PendingFileKey> selected = {('first', 0), ('first', 1), ('second', 0), ('second', 1)};
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [first, second], onSelectionChanged: (keys) => selected = keys),
),
);
await tester.pumpAndSettle();
expect(find.text('bundle'), findsNWidgets(2));
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
await tester.pump();
expect(selected, {('first', 1)});
expect(tester.takeException(), isNull);
});
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_delete_dialog.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('group deletion confirms affected downloads and files at width $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
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: 1,
keepFiles: true,
memberCount: 65,
fileCount: 65,
);
},
child: const Text('Open delete dialog'),
),
),
),
);
await tester.tap(find.text('Open delete dialog'));
await tester.pumpAndSettle();
expect(find.text('Affects 65 downloads and 65 files.'), findsOneWidget);
expect(keepFiles, isNull);
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(keepFiles, isTrue);
expect(tester.takeException(), isNull);
});
}
}
@@ -0,0 +1,163 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_browser_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:flutter/material.dart' show Icons;
api.Task raw(
String id, {
String parent = '',
String status = 'running',
String directory = '/downloads/folder',
String name = 'file.txt',
}) => api.Task.fromJson({
'id': id,
'name': name,
if (parent.isNotEmpty)
'group': {
'id': parent,
'name': 'folder',
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
'createdAt': '2026-01-01T00:00:00Z',
},
'parentId': parent,
'protocol': 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': directory, 'name': name, 'size': 100},
'res': {
'size': 100,
'files': [
{'name': name, 'size': 100},
],
},
},
'progress': {'used': 0, 'speed': 10, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
TaskRecord grouped({String name = 'first.txt'}) =>
taskRecordsFromApi([raw('child', parent: 'group', name: name)]).single;
class UpdatingTasks extends TasksController {
@override
Future<TasksState> build() async => TasksState(tasks: [grouped()]);
void rename() => state = AsyncData(TasksState(tasks: [grouped(name: 'first (1).txt')]));
}
void main() {
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
testWidgets('tablet group expands its member tasks on $platform', (tester) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(1024, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
overrides: [tasksControllerProvider.overrideWith(UpdatingTasks.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const HomePage(),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.text('folder').first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(TaskFileManagerView), findsNothing);
expect(find.byType(TaskCard), findsNWidgets(2));
expect(find.text('first.txt'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
}
test('Windows backend root and child path are rebased on a POSIX client', () {
final record = taskRecordsFromApi([raw('child', parent: 'group', directory: r'C:\Downloads\folder\nested')]).single;
expect(record.files.single.path, 'nested');
expect(record.files.single.name, 'file.txt');
});
testWidgets('open group file browser updates renamed child from task polling', (tester) async {
tester.view.physicalSize = const Size(1100, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
late UpdatingTasks controller;
await tester.pumpWidget(
ProviderScope(
overrides: [tasksControllerProvider.overrideWith(() => controller = UpdatingTasks())],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Consumer(
builder: (context, ref, _) {
final tasks = ref.watch(tasksControllerProvider).value?.tasks;
return shad.PrimaryButton(
onPressed: tasks == null ? null : () => showTaskFileBrowserDialog(context, tasks.single),
child: const Text('Open'),
);
},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('first.txt'), findsOneWidget);
controller.rename();
await tester.pumpAndSettle();
expect(find.text('first (1).txt'), findsOneWidget);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('a running group with paused members offers resume on its card at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final record = taskRecordsFromApi([
raw('running', parent: 'group'),
raw('paused', parent: 'group', status: 'pause'),
]).single;
expect(record.canResume, isTrue);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(
child: TaskCard(
task: record,
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
onPause: () {},
onResume: () {},
onDelete: () {},
),
),
),
);
await tester.pump();
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
}
}
@@ -0,0 +1,135 @@
import 'package:path/path.dart' as path;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api_client;
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:flutter/services.dart';
class _Transport implements GopeedTransport {
@override
String join(String path) => 'https://server.example$path';
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async => null;
}
api.Task fixture(String id, {required String directory, String name = 'episode #2?.txt', bool folder = false}) =>
api.Task.fromJson({
'id': id,
'name': folder ? 'nested-torrent' : name,
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'parentId': 'group',
'protocol': 'http',
'status': 'done',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/file'},
'opts': {'path': directory, 'name': folder ? 'nested-torrent' : name, 'size': 100},
'res': {
'name': folder ? 'nested-torrent' : '',
'size': 100,
'files': [
{'name': name, 'path': folder ? 'subdir' : '', 'size': 100},
],
},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 100, 'uploadSpeed': 0, 'uploaded': 0},
});
void main() {
for (final folder in [false, true]) {
testWidgets('local group actions use member path folder=$folder', (tester) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const channel = MethodChannel('dev.fluttercommunity.plus/share');
List<dynamic>? shared;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
shared = (call.arguments as Map)['paths'] as List<dynamic>;
return 'dev.fluttercommunity.plus/share/unavailable';
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
final memberRoot = path.style == path.Style.windows ? r'D:\Other' : '/elsewhere';
final group = taskRecordsFromApi([fixture('child', directory: memberRoot, folder: folder)]).single;
final expected = folder
? path.join(memberRoot, 'nested-torrent', 'subdir', 'episode #2?.txt')
: path.join(memberRoot, 'episode #2?.txt');
expect(group.files.single.localPath, expected);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskFileManagerView(task: group, webActions: false, desktopActions: false),
),
);
await tester.pumpAndSettle();
var current = '';
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
current += '/$segment';
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
await tester.pumpAndSettle();
}
await tester.tap(find.byIcon(Icons.share_outlined));
await tester.pumpAndSettle();
expect(shared, [expected]);
expect(tester.takeException(), isNull);
});
}
for (final windows in [false, true]) {
for (final legacy in [false, true]) {
for (final folder in [false, true]) {
testWidgets('member URL windows=$windows legacy=$legacy folder=$folder', (tester) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
api_client.setTransportForTesting(_Transport());
String? launched;
const channel = MethodChannel('plugins.flutter.io/url_launcher');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'launch') {
launched = (call.arguments as Map)['url'] as String;
return true;
}
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
final dir = windows ? r'C:\Downloads\folder\nested' : '/downloads/folder/nested';
final group = taskRecordsFromApi([fixture('child #1', directory: dir, folder: folder)]).single;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskFileManagerView(task: group, webActions: true, desktopActions: false),
),
);
await tester.pumpAndSettle();
var current = '';
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
current += '/$segment';
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
await tester.pumpAndSettle();
}
await tester.tap(find.byIcon(Icons.open_in_new));
await tester.pumpAndSettle();
final expected =
'https://server.example/fs/tasks/child%20%231/${folder ? 'subdir/' : ''}episode%20%232%3F.txt';
expect(launched, expected);
expect(tester.takeException(), isNull);
});
}
}
}
}
@@ -8,6 +8,72 @@ import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('group tree shows member progress and status at width $width', (tester) async {
tester.view.physicalSize = Size(width, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final task = TaskRecord(
id: 'group',
name: 'folder',
status: TaskStatus.downloading,
downloaded: '150 B',
total: '200 B',
progress: 0.75,
url: '',
storagePath: '/downloads/folder',
isGroup: true,
isFolder: true,
uploading: false,
files: const [
TaskFileNode(
path: '',
name: 'running.txt',
sizeBytes: 100,
downloadedBytes: 50,
resourceIndex: 0,
status: TaskStatus.downloading,
),
TaskFileNode(
path: '',
name: 'completed.txt',
sizeBytes: 100,
downloadedBytes: 100,
resourceIndex: 1,
status: TaskStatus.completed,
),
],
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(16),
child: TaskFileTree(task: task),
),
),
);
await tester.pump();
expect(tester.widget<TaskProgressBar>(find.byKey(const ValueKey('task-files-total-progress'))).value, 0.75);
final running = find.descendant(
of: find.byKey(const ValueKey('task-file-progress-file:0')),
matching: find.byType(shad.CircularProgressIndicator),
);
expect(tester.widget<shad.CircularProgressIndicator>(running).value, 0.5);
expect(
find.descendant(
of: find.byKey(const ValueKey('task-file-progress-file:1')),
matching: find.byIcon(shad.BootstrapIcons.checkCircleFill),
),
findsOneWidget,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
for (final scenario in [
(protocol: api.Protocol.http, completed: false),
(protocol: api.Protocol.http, completed: true),