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