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,396 @@
import 'dart:io';
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
import 'package:flutter/material.dart' show Icons;
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/task.dart' as api;
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/pages/task_details_page.dart';
import 'package:gopeed/features/tasks/presentation/pages/task_files_page.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_drawer.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/core/common/api_server_state.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/presentation/widgets/task_file_manager.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
api.Task member(String id, {String name = 'member.txt', bool bt = false, String status = 'running'}) =>
api.Task.fromJson({
'id': id,
'name': name,
'parentId': 'group',
'group': {'id': 'group', 'name': 'bundle', 'path': '/downloads/bundle', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': bt ? 'bt' : 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': bt ? 'magnet:?xt=urn:btih:test' : 'https://example.com/$id'},
'opts': {'path': '/downloads/bundle', 'name': name, 'size': 100},
'res': {
'name': bt ? name : '',
'size': 100,
'files': [
{'name': bt ? 'bt-first.txt' : name, 'size': 50},
if (bt) {'name': 'bt-second.txt', 'size': 50},
],
},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
class GroupTasks extends TasksController {
String name = 'member.txt';
String childStatus = 'running';
final paused = <String>[];
final resumed = <String>[];
final deleted = <String>[];
TasksState snapshot() => TasksState(
tasks: taskRecordsFromApi([
member('child', name: name, status: childStatus),
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
]),
);
@override
Future<TasksState> build() async => snapshot();
void rename() {
name = 'renamed.txt';
state = AsyncData(snapshot());
}
void completeMember() {
childStatus = 'done';
state = AsyncData(snapshot());
}
@override
Future<void> pause(String id) async => paused.add(id);
@override
Future<void> pauseAll(List<String>? ids) async => paused.addAll(ids!);
@override
Future<void> resume(String id) async => resumed.add(id);
@override
Future<void> resumeAll(List<String>? ids) async => resumed.addAll(ids!);
@override
Future<void> deleteSelected(List<String>? ids, {bool force = false}) async {
deleted.addAll(ids!);
state = AsyncData(
TasksState(
tasks: taskRecordsFromApi([
if (!deleted.contains('child') && !deleted.contains('group')) member('child', name: name),
if (!deleted.contains('torrent') && !deleted.contains('group'))
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
]),
),
);
}
}
class GroupRuntime extends AppRuntimeController {
@override
Future<AppRuntimeState> build() async => AppRuntimeState(
startConfig: StartConfig(),
apiServerState: const ApiServerState(
enabled: false,
mcpEnabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
),
downloaderConfig: DownloaderConfig(),
);
@override
Future<void> reloadConfig() async {}
}
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
}
Finder card(String id) => find.byWidgetPredicate((widget) => widget is TaskCard && widget.task.id == id);
Future<({GroupTasks controller, GoRouter router})> showHome(WidgetTester tester, double width) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (_, _) => const HomePage()),
GoRoute(
path: '/tasks/:id',
builder: (_, state) =>
TaskDetailsPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
),
GoRoute(
path: '/tasks/:id/files',
builder: (_, state) =>
TaskFilesPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
),
],
);
addTearDown(router.dispose);
late GroupTasks controller;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.putConfig, (_) => const RpcUnit());
await tester.pumpWidget(
ProviderScope(
overrides: [
tasksControllerProvider.overrideWith(() => controller = GroupTasks()),
appRuntimeControllerProvider.overrideWith(GroupRuntime.new),
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
),
),
);
await settle(tester);
return (controller: controller, router: router);
}
void main() {
for (final width in [390.0, 700.0, 1100.0]) {
testWidgets('group expands actual member tasks and keeps a multi-file BT as one card at $width', (tester) async {
final app = await showHome(tester, width);
expect(card('group'), findsOneWidget);
expect(card('child'), findsNothing);
await tester.tap(find.text('bundle'));
await settle(tester);
expect(card('child'), findsOneWidget);
expect(card('torrent'), findsOneWidget);
expect(find.text('bt-first.txt'), findsNothing);
expect(find.text('bt-second.txt'), findsNothing);
expect(find.byType(TaskFileManagerView), findsNothing);
expect(find.byType(TaskDetailsView), findsNothing);
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskCard>(card('child')).task.name, 'renamed.txt');
await tester.tap(find.text('bundle'));
await settle(tester);
expect(card('child'), findsNothing);
expect(card('torrent'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('member click opens its own details and polling refreshes the selected member at $width', (
tester,
) async {
final app = await showHome(tester, width);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tap(find.text('member.txt'));
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
}
testWidgets('member context menu and card actions target member IDs while group actions keep the group ID', (
tester,
) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-pause')));
await settle(tester);
expect(app.controller.paused, ['child']);
tester.widget<TaskCard>(card('torrent')).onResume!();
await settle(tester);
expect(app.controller.resumed, ['torrent']);
tester.widget<TaskCard>(card('group')).onPause!();
await settle(tester);
expect(app.controller.paused, ['child', 'group']);
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 1100.0]) {
testWidgets('group resume button targets the group at $width', (tester) async {
final app = await showHome(tester, width);
await tester.tap(find.descendant(of: card('group'), matching: find.byIcon(Icons.play_arrow)));
await settle(tester);
expect(app.controller.resumed, ['group']);
expect(card('child'), findsNothing);
expect(tester.takeException(), isNull);
});
}
for (final open in [true, false]) {
testWidgets('member card and menu ${open ? 'open' : 'reveal'} use the member path', (tester) async {
final app = await showHome(tester, 1100);
app.controller.completeMember();
await settle(tester);
await tester.tap(find.text('bundle'));
await settle(tester);
final checkedPaths = <String>[];
final child = tester.widget<TaskCard>(card('child'));
await IOOverrides.runZoned(
() async {
(open ? child.onOpen : child.onReveal)!();
await settle(tester);
expect(checkedPaths.first, '/downloads/bundle/member.txt');
checkedPaths.clear();
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(ValueKey(open ? 'task-context-open-file' : 'task-context-open-directory')));
await settle(tester);
expect(checkedPaths.first, '/downloads/bundle/member.txt');
},
fseGetType: (path, followLinks) async {
checkedPaths.add(path);
return FileSystemEntityType.notFound;
},
);
// Allow the failure-path toast to close without launching native tools.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 6));
expect(tester.takeException(), isNull);
});
}
testWidgets('member menu details uses the member instead of the group', (tester) async {
await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('torrent')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-details')));
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'torrent');
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.files, hasLength(2));
expect(tester.takeException(), isNull);
});
testWidgets('selecting a member through its context menu enables batch actions for that member', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select')));
await settle(tester);
expect(tester.widget<TaskCard>(card('child')).selectedInBatch, isTrue);
final pause = find.byIcon(Icons.pause_outlined);
await tester.tap(pause);
await settle(tester);
expect(app.controller.paused, ['child']);
expect(tester.takeException(), isNull);
});
testWidgets('deleting a member leaves its sibling group intact', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
await settle(tester);
expect(find.byType(shad.DestructiveButton), findsOneWidget);
await tester.tap(find.byType(shad.DestructiveButton));
await settle(tester);
expect(app.controller.deleted, ['child']);
expect(card('group'), findsOneWidget);
expect(card('child'), findsNothing);
expect(card('torrent'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('deleting a selected group and its members counts and submits the group once', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('group')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select-all')));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
await settle(tester);
expect(find.text('Delete task?'), findsOneWidget);
await tester.tap(find.byType(shad.DestructiveButton));
await settle(tester);
expect(app.controller.deleted, ['group']);
expect(card('group'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('member context file browser polls its member record', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-browse-files')));
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
testWidgets('group clicks in batch mode select without collapsing members', (tester) async {
await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select')));
await settle(tester);
await tester.tap(find.text('bundle'));
await settle(tester);
expect(tester.widget<TaskCard>(card('group')).selectedInBatch, isTrue);
expect(tester.widget<TaskCard>(card('group')).expanded, isTrue);
expect(card('child'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('member detail route resolves the member without an initial snapshot', (tester) async {
final app = await showHome(tester, 390);
app.router.go('/tasks/child');
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
testWidgets('member file page resolves current data from its group without a route snapshot', (tester) async {
final app = await showHome(tester, 390);
app.router.go('/tasks/child/files');
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,272 @@
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';
PendingDownload source(String id, {String name = 'bundle', String rid = '', int count = 2, String? url}) =>
PendingDownload(
id: id,
request: Request(url: url ?? 'https://example.com/$id'),
options: Options(path: '/downloads'),
)
..result = ResolveResult(
id: rid,
res: Resource(
name: name,
files: List.generate(
count,
(i) => FileInfo(
name: '$i.txt',
size: 100 + i,
req: Request(url: 'https://example.com/$id/$i'),
),
),
),
)
..selected.addAll(List.generate(count, (i) => i));
GopeedService service(CapabilityRegistry registry) => GopeedService(LocalCapabilityInvoker(registry));
void main() {
test('one named extension group and all independent sources produce exactly two batches', () {
final group = source('a')..selected.remove(0);
final unnamed = source('b', name: '');
final torrent = source('c', rid: 'bt-rid')..selected.remove(0);
final http = source('d', rid: 'http-rid', count: 1, name: '');
final batches = planDownloads([group, unnamed, torrent, http]);
expect(batches, hasLength(2));
final grouped = batches.first.request;
expect(grouped.group, isTrue);
expect(grouped.groupName, 'bundle');
expect(grouped.opts!.path, '/downloads');
expect(grouped.reqs!.single.req!.url, 'https://example.com/a/1');
expect(grouped.toJson(), isNot(contains('groupId')));
final normal = batches.last.request;
expect(normal.group, isFalse);
expect(normal.reqs, hasLength(4));
expect(normal.reqs!.take(2).every((item) => item.req != null && item.rid == null), isTrue);
expect(normal.reqs![2].rid, 'bt-rid');
expect(normal.reqs![2].opts!.selectFiles, [1]);
expect(normal.reqs![3].rid, 'http-rid');
expect(normal.reqs![3].req, isNull);
});
test('successful batches are complete even when only some task IDs are returned', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (_) {
calls++;
return CreateTaskBatchResult(taskIds: ['only-one']);
});
final sources = [source('a', name: ''), source('b', rid: 'rid')];
await planDownloads(sources).single.submit(service(registry));
expect(sources.every((s) => s.created && s.createError == null), isTrue);
expect(planDownloads(sources), isEmpty);
expect(calls, 1);
});
test('retry only submits failed batches and never appends to the successful group', () async {
final submitted = <CreateTaskBatch>[];
var failNormal = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted.add(batch);
if (!batch.group && failNormal) throw StateError('all failed');
return CreateTaskBatchResult(taskIds: ['created'], parentId: batch.group ? 'parent' : null);
});
final sources = [source('group'), source('normal', name: '')];
for (final batch in planDownloads(sources)) {
await batch.submit(service(registry));
}
expect(sources.first.created, isTrue);
expect(sources.last.created, isFalse);
expect(sources.last.createError, contains('all failed'));
failNormal = false;
final retry = planDownloads(sources);
expect(retry, hasLength(1));
await retry.single.submit(service(registry));
expect(submitted.where((batch) => batch.group), hasLength(1));
expect(planDownloads(sources), isEmpty);
});
test('single extension keeps nested resource directory and empty base remains empty', () {
final single = source('single', count: 1, name: 'album');
single.result!.res.files.single.path = 'disc1';
expect(single.isGroup, isFalse);
expect(planDownloads([single]).single.request.reqs!.single.opts!.path, '/downloads/album/disc1');
final plain = source('plain', count: 1, name: '')..options.path = '';
expect(plain.creationItems().single.opts!.path, '');
});
for (final parent in ['', 'downloads', './downloads', '/downloads']) {
test('group members use paths relative to their group for parent "$parent"', () {
final pending = source('group')..options.path = parent;
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.opts!.path, parent);
expect(batch.reqs!.map((item) => item.opts!.path), ['', 'nested']);
expect(pending.options.path, parent);
});
}
test('unnamed extension members retain their ordinary batch destination', () {
final pending = source('independent', name: '')..options.path = 'downloads';
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isFalse);
expect(batch.reqs!.map((item) => item.opts!.path), ['downloads', 'downloads/nested']);
});
test('members preserve directory intent, HTTP sizes and per-file checksum rules', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
final members = pending.creationItems();
expect(members.first.opts!.size, 100);
expect(members.first.opts!.asDefaultPath, isFalse);
expect(members.first.opts!.skipAutoCategorize, isTrue);
expect(members.first.opts!.extra as Map, isNot(contains('checksum')));
pending.selected.remove(0);
expect(pending.creationItems().single.opts!.extra as Map, contains('checksum'));
expect(pending.options.extra as Map, contains('checksum'));
pending.result!.res.files[1].req = Request(url: 'magnet:?xt=urn:btih:test');
expect(pending.creationItems().single.opts!.size, 0);
});
test('FTP extension members use FTP options and keep their request TLS mode', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
connections: 64,
autoExtract: true,
archivePassword: 'secret',
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
pending.files[0].req = Request(
url: 'ftp://files.example/archive.zip',
extra: ReqExtraFtp(tls: 'explicit').toJson(),
);
pending.selected.remove(1);
final item = pending.creationItems().single;
expect(item.req!.extra, {'tls': 'explicit'});
expect(item.opts!.size, 0);
final extra = item.opts!.extra as Map;
expect(extra['connections'], 32);
expect(extra['autoExtract'], isTrue);
expect(extra['archivePassword'], 'secret');
expect(extra.containsKey('checksum'), isFalse);
expect((pending.options.extra as Map)['connections'], 64);
});
test('member option changes do not affect other members or source options', () {
final pending = source('group');
pending.options
..name = 'custom'
..size = 500
..selectFiles = [0, 1]
..asDefaultPath = true;
final original = pending.options.toJson();
final members = pending.creationItems();
members.first.opts!
..name = 'changed.txt'
..path = '/elsewhere'
..size = 1
..selectFiles.add(99);
expect(members.last.opts!.name, '1.txt');
expect(members.last.opts!.path, '');
expect(members.last.opts!.size, 101);
expect(members.last.opts!.selectFiles, [0, 1]);
expect(pending.options.toJson(), original);
});
for (final rid in ['', 'resolved-rid']) {
test('empty resource preserves options and selects the creation method for rid="$rid"', () {
final pending = source('empty', rid: rid, count: 0)..selected.add(0);
pending.options
..name = 'custom'
..selectFiles = [2];
final item = pending.creationItems().single;
expect(item.rid, rid.isEmpty ? null : rid);
expect(item.req, rid.isEmpty ? pending.request : null);
expect(identical(item.opts, pending.options), isFalse);
expect(item.opts!.toJson(), pending.options.toJson());
});
}
test('a magnet resolved by an extension keeps its HTTP members and group identity', () {
final pending = source('extension', url: 'magnet:?xt=urn:btih:infohash');
expect(pending.isBt, isFalse);
expect(pending.isGroup, isTrue);
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.reqs, hasLength(2));
expect(batch.reqs!.every((item) => item.rid == null && item.req!.url.startsWith('https:')), isTrue);
});
test('root names use HTTP file names, BT resource names and URL fallback', () {
final http = source('http', rid: 'http-rid', count: 1, name: 'unused-resource-name');
expect(http.name, '0.txt');
final bt = source('bt', rid: 'bt-rid', count: 1, name: 'torrent', url: 'magnet:?xt=urn:btih:infohash');
bt.result!.res.hash = 'infohash';
expect(bt.isBt, isTrue);
expect(bt.name, 'torrent');
final unnamed = source('empty', rid: 'rid', count: 0, name: '');
expect(unnamed.name, unnamed.request.url);
});
for (final entry in <String, bool>{
'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/': false,
'https://example.com/file': false,
'https://example.com/file.torrent': false,
'ftp://files.example/file.torrent': false,
'ftps://files.example/file.torrent': false,
'FTPES://files.example/file.torrent': false,
'magnet:?xt=urn:btih:infohash': true,
'MAGNET:?xt=urn:btih:infohash': true,
'file:///downloads/file.torrent': true,
'/downloads/FILE.TORRENT': true,
'file:///downloads/file.torrent?query=1': true,
'data:application/x-bittorrent;base64,AAAA': true,
'data:application/octet-stream;base64,AAAA': false,
}.entries) {
test('source protocol determines BT identity for ${entry.key}', () {
final pending = source('protocol', rid: 'rid', count: 1, name: 'resource', url: entry.key);
if (RegExp(r'^ftp', caseSensitive: false).hasMatch(entry.key)) pending.result!.res.name = '';
pending.result!.res.hash = '0123456789ABCDEF0123456789ABCDEF';
expect(pending.isBt, entry.value);
expect(pending.name, entry.value ? 'resource' : '0.txt');
});
}
test('failed resolutions are excluded and each newly opened source resolves fresh', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) {
calls++;
return ResolveResult(
id: 'rid-$calls',
res: Resource(files: [FileInfo(name: 'file')]),
);
});
final first = PendingDownload(
id: 'a',
request: Request(url: 'https://example.com'),
options: Options(),
);
await first.resolve(service(registry));
await first.resolve(service(registry));
expect(calls, 1);
final reopened = PendingDownload(id: 'a', request: first.request, options: Options());
await reopened.resolve(service(registry));
expect(calls, 2);
final failed = PendingDownload(id: 'b', request: first.request, options: Options())..resolveError = 'failed';
expect(planDownloads([failed, reopened]).single.sources, [reopened]);
});
}
@@ -0,0 +1,80 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/task.dart' as api;
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/tasks_controller.dart';
void main() {
test('the list counts a group once and sends group IDs for every action', () async {
api.Task task(String id) => api.Task.fromJson({
'id': id,
'name': '$id.txt',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'parentId': 'group',
'protocol': 'http',
'status': 'pause',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': '/downloads/folder', 'size': 100},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
final raw = [task('first'), task('second')];
final actions = <String>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getTasks, (_) => raw)
..bind(GopeedMethods.pauseTask, (id) {
actions.add('pause:$id');
return const RpcUnit();
})
..bind(GopeedMethods.continueTask, (id) {
actions.add('resume:$id');
return const RpcUnit();
})
..bind(GopeedMethods.pauseTasks, (params) {
actions.add('pauseBatch:${params['ids']}');
return const RpcUnit();
})
..bind(GopeedMethods.continueTasks, (params) {
actions.add('resumeBatch:${params['ids']}');
return const RpcUnit();
})
..bind(GopeedMethods.deleteTasks, (params) {
actions.add('delete:${params['ids']}:${params['force']}');
raw.clear();
return const RpcUnit();
});
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final state = await container.read(tasksControllerProvider.future);
expect(state.tasks.length, 1);
expect(state.tasks.single.members.length, 2);
expect(state.allTasks.map((task) => task.id), ['group', 'first', 'second']);
expect(state.findTask('group'), same(state.tasks.single));
expect(state.findTask('first'), same(state.tasks.single.members.first));
expect(state.findTask('missing'), isNull);
expect(state.tasks.single.matchesSearch('second.txt'), isTrue);
expect(state.tasks.single.progress, 0.25);
final controller = container.read(tasksControllerProvider.notifier);
await controller.pause('group');
await controller.resume('group');
await controller.pauseAll(['group']);
await controller.resumeAll(['group']);
await controller.deleteSelected(['group'], force: true);
expect(actions, [
'pause:group',
'resume:group',
'pauseBatch:[group]',
'resumeBatch:[group]',
'delete:[group]:true',
]);
expect(container.read(tasksControllerProvider).value?.tasks, isEmpty);
});
}
@@ -29,6 +29,219 @@ void main() {
'progress': {'used': 0, 'speed': 0, 'downloaded': 10, 'uploadSpeed': 0, 'uploaded': 0},
});
test('members aggregate through parentId without a parent task', () {
final records = taskRecordsFromApi([
groupTestTask('first', parentId: 'group'),
groupTestTask(
'second',
parentId: 'group',
directory: '/downloads/folder/nested',
name: 'movie.mp4',
size: 300,
downloaded: 75,
),
groupTestTask('standalone'),
]);
expect(records.map((record) => record.id), ['group', 'standalone']);
final record = records.first;
expect(record.isGroup, isTrue);
expect(record.members.length, 2);
expect(record.totalBytes, 400);
expect(record.downloadedBytes, 100);
expect(record.speedBytes, 20);
expect(record.progress, 0.25);
expect(record.storagePath, '/downloads/folder');
expect(record.files.map((file) => file.path), ['', 'nested']);
expect(record.files.last.name, 'movie.mp4');
expect(record.matchesSearch('second'), isTrue);
expect(record.canUpdateUrl, isFalse);
});
test('group preserves uploading while member upload speed is momentarily zero', () {
final member = groupTestTask('uploading', parentId: 'group');
member.uploading = true;
member.progress.uploadSpeed = 0;
final group = taskRecordsFromApi([member]).single;
expect(group.uploading, isTrue);
expect(group.uploadSpeedBytes, 0);
});
for (final scenario in <(List<String>, TaskStatus, bool)>[
(['running', 'error', 'pause'], TaskStatus.downloading, false),
(['error', 'pause', 'wait'], TaskStatus.failed, false),
(['pause', 'wait', 'done'], TaskStatus.paused, false),
(['done', 'done'], TaskStatus.completed, false),
(['wait', 'done'], TaskStatus.downloading, true),
]) {
test('group status priority for ${scenario.$1}', () {
final record = taskRecordsFromApi([
for (var i = 0; i < scenario.$1.length; i++)
groupTestTask('child-$i', parentId: 'group', status: scenario.$1[i]),
]).single;
expect(record.status, scenario.$2);
expect(record.waiting, scenario.$3);
if (scenario.$2 == TaskStatus.completed) {
expect(record.progress, 1);
expect(record.doneAt, DateTime.utc(2026, 1, 3));
}
});
}
test('unknown member sizes stay indeterminate and empty files can finish', () {
final unknown = groupTestTask('unknown', parentId: 'group', size: 0);
final record = taskRecordsFromApi([unknown, groupTestTask('known', parentId: 'group')]).single;
expect(record.totalBytes, isNull);
expect(record.progress, isNull);
unknown.status = api.Status.done;
final empty = taskRecordsFromApi([unknown]).single;
expect(empty.totalBytes, 0);
expect(empty.progress, 1);
});
test('Windows group directories retain their relative file tree', () {
final records = taskRecordsFromApi([
groupTestTask('child', parentId: 'group', directory: r'C:\Downloads\folder\nested'),
]);
expect(records.single.files.single.path, 'nested');
expect(records.single.files.single.name, 'file.txt');
});
test('folder member file progress spreads the member ratio over its files', () {
final member = api.Task.fromJson({
'id': 'pack',
'name': 'pack',
'parentId': 'group',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': 'http',
'status': 'running',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': null,
'meta': {
'req': {'url': 'https://example.com/pack'},
'opts': {'path': '/downloads/folder/pack', 'name': 'pack', 'size': 30},
'res': {
'name': 'pack',
'size': 30,
'files': [
{'name': 'a.bin', 'size': 10},
{'name': 'b.bin', 'size': 20},
],
},
},
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
});
final record = taskRecordsFromApi([member]).single;
expect(record.files.map((file) => file.name), ['a.bin', 'b.bin']);
expect(record.files.first.downloadedBytes, 5);
expect(record.files.last.downloadedBytes, 10);
});
test('members outside the group root render under their own folder name', () {
final folderMember = api.Task.fromJson({
'id': 'pack',
'name': 'pack',
'parentId': 'group',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': 'http',
'status': 'running',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': null,
'meta': {
'req': {'url': 'https://example.com/pack'},
'opts': {'path': '/elsewhere/pack', 'name': 'pack', 'size': 30},
'res': {
'name': 'pack',
'size': 30,
'files': [
{'name': 'a.bin', 'size': 30},
],
},
},
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
});
final record = taskRecordsFromApi([
groupTestTask('loose', parentId: 'group', directory: '/elsewhere'),
folderMember,
]).single;
for (final file in record.files) {
expect(file.path.startsWith('..'), isFalse);
}
expect(record.files.first.path, '');
expect(record.files.first.name, 'file.txt');
expect(record.files.last.path, 'pack');
expect(record.files.last.name, 'a.bin');
});
test('completed group reports the wall span from creation to finish', () {
final record = taskRecordsFromApi([groupTestTask('child', parentId: 'group', status: 'done')]).single;
expect(record.downloadDuration, const Duration(days: 2));
});
for (final completed in [false, true]) {
test('group timestamps use chronological maxima when completed=$completed', () {
final first = groupTestTask('first', parentId: 'group', status: completed ? 'done' : 'running')
..updatedAt = DateTime.utc(2026, 1, 6)
..doneAt = DateTime.utc(2026, 1, 5);
final second = groupTestTask('second', parentId: 'group', status: 'done')
..updatedAt = DateTime.utc(2026, 1, 3)
..doneAt = DateTime.utc(2026, 1, 4);
final legacy = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
final record = taskRecordsFromApi([first, second, legacy]).single;
expect(record.updatedAt, DateTime.utc(2026, 1, 6));
expect(record.doneAt, completed ? DateTime.utc(2026, 1, 5) : null);
expect(record.downloadDuration, completed ? const Duration(days: 4) : null);
});
}
test('group creation bounds updated time and legacy completion stays unknown', () {
final member = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
member.group = api.TaskGroup(
id: 'group',
name: 'folder',
path: '/downloads/folder',
createdAt: DateTime.utc(2026, 1, 4),
);
final record = taskRecordsFromApi([member]).single;
expect(record.updatedAt, member.group!.createdAt);
expect(record.doneAt, isNull);
expect(record.downloadDuration, isNull);
});
test('completed group reports the members average speed instead of 0 B/s', () {
final record = taskRecordsFromApi([
groupTestTask(
'first',
parentId: 'group',
status: 'done',
size: 100,
downloaded: 100,
usedNanoseconds: 10 * 1000000000,
),
groupTestTask(
'second',
parentId: 'group',
status: 'done',
size: 50,
downloaded: 50,
usedNanoseconds: 5 * 1000000000,
),
]).single;
// 150 bytes over 15 combined active seconds, not the 0 of the idle aggregate.
expect(record.speedBytes, 10);
expect(record.speed, '10 B/s');
});
test('paused group keeps the idle aggregate instead of an average speed', () {
final record = taskRecordsFromApi([
groupTestTask('first', parentId: 'group', status: 'pause', downloaded: 100, usedNanoseconds: 10 * 1000000000),
]).single;
expect(record.speedBytes, 0);
});
test('selected files retain original indexes for runtime progress', () {
final record = TaskRecord.fromApi(buildTask([2, 0], 40));
expect(record.files.map((file) => file.name), ['a.txt', 'c.txt']);
@@ -97,3 +310,43 @@ void main() {
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
});
}
api.Task groupTestTask(
String id, {
String parentId = '',
String status = 'running',
int size = 100,
int downloaded = 25,
int usedNanoseconds = 0,
String directory = '/downloads/folder',
String name = 'file.txt',
}) => api.Task.fromJson({
'id': id,
'name': name,
'parentId': parentId,
if (parentId.isNotEmpty)
'group': {
'id': parentId,
'name': 'folder',
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
'createdAt': '2026-01-01T00:00:00Z',
},
'protocol': 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': status == 'done' ? '2026-01-03T00:00:00Z' : null,
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': directory, 'name': name, 'size': size},
if (size > 0)
'res': {
'size': size,
'files': [
{'name': name, 'size': size},
],
},
},
'progress': {'used': usedNanoseconds, 'speed': 10, 'downloaded': downloaded, 'uploadSpeed': 0, 'uploaded': 0},
});
@@ -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),