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
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/patch_task.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
@@ -39,6 +40,32 @@ void main() {
|
||||
expect((createBody['opts'] as Map<String, dynamic>)['selectFiles'], [1]);
|
||||
});
|
||||
|
||||
test('batch sends rid and options and decodes the unified response in both modes', () async {
|
||||
for (final grouped in [false, true]) {
|
||||
final transport = _RecordingTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
final result = await api.createTaskBatch(
|
||||
CreateTaskBatch(
|
||||
group: grouped,
|
||||
groupName: grouped ? 'folder' : '',
|
||||
reqs: [
|
||||
CreateTaskBatchItem(
|
||||
rid: 'resolved-id',
|
||||
opts: Options(selectFiles: [1]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(result.taskIds, ['created-id']);
|
||||
expect(result.parentId, grouped ? 'parent' : null);
|
||||
final body = transport.data as Map<String, dynamic>;
|
||||
final member = (body['reqs'] as List).single as Map<String, dynamic>;
|
||||
expect(member['rid'], 'resolved-id');
|
||||
expect((member['opts'] as Map)['selectFiles'], [1]);
|
||||
expect(body, isNot(contains('groupId')));
|
||||
}
|
||||
});
|
||||
|
||||
test('patch preserves options in its independent request model', () async {
|
||||
final transport = _RecordingTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
@@ -59,6 +86,15 @@ class _RecordingTransport implements GopeedTransport {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
this.data = jsonDecode(jsonEncode(data));
|
||||
if (path == 'api/v1/tasks/batch') {
|
||||
return {
|
||||
'code': 0,
|
||||
'data': {
|
||||
'taskIds': ['created-id'],
|
||||
if (this.data['group'] == true) 'parentId': 'parent',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path == 'api/v1/tasks') {
|
||||
return {'code': 0, 'data': 'created-id'};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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/patch_task.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
@@ -21,6 +22,47 @@ class _Payload {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('group creation crosses the window boundary without losing directory or child options', () async {
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
CreateTaskBatch? submitted;
|
||||
final registry = CapabilityRegistry(codecs)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['child'], parentId: 'generated');
|
||||
});
|
||||
final batch = CreateTaskBatch(
|
||||
group: true,
|
||||
groupName: 'folder',
|
||||
opts: Options(path: '/downloads'),
|
||||
reqs: [
|
||||
CreateTaskBatchItem(
|
||||
rid: 'resolved-rid',
|
||||
req: Request(url: 'https://example.com/file'),
|
||||
opts: Options(path: '/downloads/folder/nested', name: 'file.txt'),
|
||||
),
|
||||
],
|
||||
);
|
||||
final result = codecs.decode<CreateTaskBatchResult>(
|
||||
await registry.invoke(GopeedMethods.createTaskBatch.name, codecs.encode(batch)),
|
||||
);
|
||||
expect(result.taskIds, ['child']);
|
||||
expect(result.parentId, 'generated');
|
||||
expect(submitted?.group, isTrue);
|
||||
expect(submitted?.reqs?.single.rid, 'resolved-rid');
|
||||
expect(submitted?.toJson().containsKey('groupId'), isFalse);
|
||||
expect(submitted?.opts?.path, '/downloads');
|
||||
expect(submitted?.reqs?.single.opts?.path, '/downloads/folder/nested');
|
||||
expect(CreateTaskBatch.fromJson({'reqs': []}).group, isFalse);
|
||||
expect(
|
||||
CreateTaskBatchResult.fromJson({
|
||||
'taskIds': ['ordinary'],
|
||||
}).toJson(),
|
||||
{
|
||||
'taskIds': ['ordinary'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('serialized resolve and patch capabilities keep separate request contracts', () async {
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
String? resolvedUrl;
|
||||
@@ -28,7 +70,10 @@ void main() {
|
||||
final registry = CapabilityRegistry(codecs)
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolvedUrl = request.req?.url;
|
||||
return ResolveResult(id: 'resolved-id', res: Resource(files: []));
|
||||
return ResolveResult(
|
||||
id: 'resolved-id',
|
||||
res: Resource(files: []),
|
||||
);
|
||||
})
|
||||
..bind(GopeedMethods.patchTask, (request) {
|
||||
patch = request;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/storage_capability.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
final host = LocalAppCapabilities.instance;
|
||||
late _ConfigTransport transport;
|
||||
|
||||
setUp(() {
|
||||
transport = _ConfigTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
});
|
||||
|
||||
test('local and child saves serialize the full history read-modify-write', () async {
|
||||
final first = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await transport.firstPutStarted.future;
|
||||
final second = host.registry.invoke(StorageMethods.saveCreateHistory.name, ['B']);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final readsWhileSaving = transport.reads;
|
||||
transport.releaseFirstPut.complete();
|
||||
await Future.wait<Object?>([first, second]);
|
||||
expect(transport.config.extra.createHistory, ['B', 'A']);
|
||||
expect(readsWhileSaving, 1, reason: 'the next GET must wait for the previous PUT');
|
||||
});
|
||||
|
||||
test('history and config reads wait for previously requested saves', () async {
|
||||
final saving = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await transport.firstPutStarted.future;
|
||||
final history = host.capabilities.storage.getCreateHistory();
|
||||
final config = host.capabilities.gopeed.getConfig();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final readsWhileSaving = transport.reads;
|
||||
transport.releaseFirstPut.complete();
|
||||
await saving;
|
||||
expect(await history, ['A']);
|
||||
expect((await config).extra.createHistory, ['A']);
|
||||
expect(readsWhileSaving, 1);
|
||||
});
|
||||
|
||||
for (final historyFirst in [true, false]) {
|
||||
test('overlapping full config and history saves preserve both changes (history first: $historyFirst)', () async {
|
||||
transport.config.extra.createHistory = ['existing'];
|
||||
final snapshot = await host.capabilities.gopeed.getConfig();
|
||||
snapshot.downloadDir = '/chosen';
|
||||
snapshot.maxRunning = 7;
|
||||
final first = historyFirst
|
||||
? host.capabilities.storage.saveCreateHistory(['A'])
|
||||
: host.capabilities.gopeed.putConfig(snapshot);
|
||||
await transport.firstPutStarted.future;
|
||||
final second = historyFirst
|
||||
? host.capabilities.gopeed.putConfig(snapshot)
|
||||
: host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
transport.releaseFirstPut.complete();
|
||||
await Future.wait([first, second]);
|
||||
expect(transport.config.extra.createHistory, ['A', 'existing']);
|
||||
expect(transport.config.downloadDir, '/chosen');
|
||||
expect(transport.config.maxRunning, 7);
|
||||
});
|
||||
}
|
||||
|
||||
test('an old full config cannot restore removed or cleared history', () async {
|
||||
transport.blockFirstPut = false;
|
||||
transport.config.extra.createHistory = ['A', 'B'];
|
||||
final snapshot = await host.capabilities.gopeed.getConfig();
|
||||
await host.capabilities.storage.removeCreateHistory('A');
|
||||
await host.capabilities.gopeed.putConfig(snapshot);
|
||||
expect(transport.config.extra.createHistory, ['B']);
|
||||
await host.capabilities.storage.clearCreateHistory();
|
||||
await host.capabilities.gopeed.putConfig(snapshot);
|
||||
expect(transport.config.extra.createHistory, isEmpty);
|
||||
expect(snapshot.extra.createHistory, ['A', 'B'], reason: 'saving must not mutate the caller snapshot');
|
||||
});
|
||||
|
||||
test('history save, remove, clear and save follow request order', () async {
|
||||
transport.config.extra.createHistory = ['existing'];
|
||||
final first = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await transport.firstPutStarted.future;
|
||||
final remove = host.capabilities.storage.removeCreateHistory('existing');
|
||||
final clear = host.capabilities.storage.clearCreateHistory();
|
||||
final second = host.capabilities.storage.saveCreateHistory(['B']);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
transport.releaseFirstPut.complete();
|
||||
await Future.wait([first, remove, clear, second]);
|
||||
expect(transport.config.extra.createHistory, ['B']);
|
||||
});
|
||||
|
||||
test('a failed save reports its error without blocking subsequent saves or reads', () async {
|
||||
transport.failNextPut = true;
|
||||
final first = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
final failed = expectLater(first, throwsA(isA<StateError>()));
|
||||
await transport.firstPutStarted.future;
|
||||
final second = host.capabilities.storage.saveCreateHistory(['B']);
|
||||
final history = host.capabilities.storage.getCreateHistory();
|
||||
transport.releaseFirstPut.complete();
|
||||
await failed;
|
||||
await second;
|
||||
expect(await history, ['B']);
|
||||
});
|
||||
|
||||
testWidgets('confirm A, cancel, confirm B saves both while previews open immediately', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
// The host queue is created outside the widget clock.
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pumpAndSettle();
|
||||
final input = find.byKey(const ValueKey('create-task-url-input'));
|
||||
await tester.enterText(input, 'https://example.com/A');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(transport.firstPutStarted.isCompleted, isTrue);
|
||||
expect(transport.releaseFirstPut.isCompleted, isFalse);
|
||||
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(input, 'https://example.com/B');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(shad.Icons.history));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
transport.releaseFirstPut.complete();
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pumpAndSettle();
|
||||
expect(transport.config.extra.createHistory, ['https://example.com/B', 'https://example.com/A']);
|
||||
for (final url in ['https://example.com/A', 'https://example.com/B']) {
|
||||
expect(find.byKey(ValueKey('create-history-item-$url')), findsOneWidget);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
class _ConfigTransport implements GopeedTransport {
|
||||
DownloaderConfig config = DownloaderConfig(downloadDir: '/downloads');
|
||||
final firstPutStarted = Completer<void>();
|
||||
final releaseFirstPut = Completer<void>();
|
||||
bool blockFirstPut = true;
|
||||
bool failNextPut = false;
|
||||
int reads = 0;
|
||||
int writes = 0;
|
||||
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
if (path == 'api/v1/resolve') {
|
||||
return {
|
||||
'code': 0,
|
||||
'data': {
|
||||
'id': 'rid',
|
||||
'res': {
|
||||
'files': [
|
||||
{'name': 'file.txt'},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path != 'api/v1/config') throw StateError('Unexpected request: $method $path');
|
||||
if (method == 'GET') {
|
||||
reads++;
|
||||
return {'code': 0, 'data': jsonDecode(jsonEncode(config))};
|
||||
}
|
||||
final snapshot = DownloaderConfig.fromJson(jsonDecode(jsonEncode(data)) as Map<String, dynamic>);
|
||||
final shouldFail = failNextPut;
|
||||
failNextPut = false;
|
||||
if (++writes == 1 && blockFirstPut) {
|
||||
firstPutStarted.complete();
|
||||
await releaseFirstPut.future;
|
||||
}
|
||||
if (shouldFail) {
|
||||
throw StateError('config save failed');
|
||||
}
|
||||
config = snapshot;
|
||||
return {'code': 0};
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/task_details_page.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/task_files_page.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_drawer.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/core/common/start_config.dart';
|
||||
import 'package:gopeed/core/common/api_server_state.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
api.Task member(String id, {String name = 'member.txt', bool bt = false, String status = 'running'}) =>
|
||||
api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': name,
|
||||
'parentId': 'group',
|
||||
'group': {'id': 'group', 'name': 'bundle', 'path': '/downloads/bundle', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'protocol': bt ? 'bt' : 'http',
|
||||
'status': status,
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': bt ? 'magnet:?xt=urn:btih:test' : 'https://example.com/$id'},
|
||||
'opts': {'path': '/downloads/bundle', 'name': name, 'size': 100},
|
||||
'res': {
|
||||
'name': bt ? name : '',
|
||||
'size': 100,
|
||||
'files': [
|
||||
{'name': bt ? 'bt-first.txt' : name, 'size': 50},
|
||||
if (bt) {'name': 'bt-second.txt', 'size': 50},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
|
||||
});
|
||||
|
||||
class GroupTasks extends TasksController {
|
||||
String name = 'member.txt';
|
||||
String childStatus = 'running';
|
||||
final paused = <String>[];
|
||||
final resumed = <String>[];
|
||||
final deleted = <String>[];
|
||||
|
||||
TasksState snapshot() => TasksState(
|
||||
tasks: taskRecordsFromApi([
|
||||
member('child', name: name, status: childStatus),
|
||||
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
|
||||
]),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<TasksState> build() async => snapshot();
|
||||
|
||||
void rename() {
|
||||
name = 'renamed.txt';
|
||||
state = AsyncData(snapshot());
|
||||
}
|
||||
|
||||
void completeMember() {
|
||||
childStatus = 'done';
|
||||
state = AsyncData(snapshot());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(String id) async => paused.add(id);
|
||||
@override
|
||||
Future<void> pauseAll(List<String>? ids) async => paused.addAll(ids!);
|
||||
@override
|
||||
Future<void> resume(String id) async => resumed.add(id);
|
||||
@override
|
||||
Future<void> resumeAll(List<String>? ids) async => resumed.addAll(ids!);
|
||||
@override
|
||||
Future<void> deleteSelected(List<String>? ids, {bool force = false}) async {
|
||||
deleted.addAll(ids!);
|
||||
state = AsyncData(
|
||||
TasksState(
|
||||
tasks: taskRecordsFromApi([
|
||||
if (!deleted.contains('child') && !deleted.contains('group')) member('child', name: name),
|
||||
if (!deleted.contains('torrent') && !deleted.contains('group'))
|
||||
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GroupRuntime extends AppRuntimeController {
|
||||
@override
|
||||
Future<AppRuntimeState> build() async => AppRuntimeState(
|
||||
startConfig: StartConfig(),
|
||||
apiServerState: const ApiServerState(
|
||||
enabled: false,
|
||||
mcpEnabled: false,
|
||||
running: false,
|
||||
network: '',
|
||||
address: '',
|
||||
runningPort: 0,
|
||||
pendingApply: false,
|
||||
lastError: '',
|
||||
),
|
||||
downloaderConfig: DownloaderConfig(),
|
||||
);
|
||||
@override
|
||||
Future<void> reloadConfig() async {}
|
||||
}
|
||||
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
Finder card(String id) => find.byWidgetPredicate((widget) => widget is TaskCard && widget.task.id == id);
|
||||
|
||||
Future<({GroupTasks controller, GoRouter router})> showHome(WidgetTester tester, double width) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const HomePage()),
|
||||
GoRoute(
|
||||
path: '/tasks/:id',
|
||||
builder: (_, state) =>
|
||||
TaskDetailsPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks/:id/files',
|
||||
builder: (_, state) =>
|
||||
TaskFilesPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
late GroupTasks controller;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.putConfig, (_) => const RpcUnit());
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
tasksControllerProvider.overrideWith(() => controller = GroupTasks()),
|
||||
appRuntimeControllerProvider.overrideWith(GroupRuntime.new),
|
||||
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
|
||||
],
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
await settle(tester);
|
||||
return (controller: controller, router: router);
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1100.0]) {
|
||||
testWidgets('group expands actual member tasks and keeps a multi-file BT as one card at $width', (tester) async {
|
||||
final app = await showHome(tester, width);
|
||||
expect(card('group'), findsOneWidget);
|
||||
expect(card('child'), findsNothing);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
expect(card('child'), findsOneWidget);
|
||||
expect(card('torrent'), findsOneWidget);
|
||||
expect(find.text('bt-first.txt'), findsNothing);
|
||||
expect(find.text('bt-second.txt'), findsNothing);
|
||||
expect(find.byType(TaskFileManagerView), findsNothing);
|
||||
expect(find.byType(TaskDetailsView), findsNothing);
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskCard>(card('child')).task.name, 'renamed.txt');
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
expect(card('child'), findsNothing);
|
||||
expect(card('torrent'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member click opens its own details and polling refreshes the selected member at $width', (
|
||||
tester,
|
||||
) async {
|
||||
final app = await showHome(tester, width);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('member.txt'));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('member context menu and card actions target member IDs while group actions keep the group ID', (
|
||||
tester,
|
||||
) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-pause')));
|
||||
await settle(tester);
|
||||
expect(app.controller.paused, ['child']);
|
||||
tester.widget<TaskCard>(card('torrent')).onResume!();
|
||||
await settle(tester);
|
||||
expect(app.controller.resumed, ['torrent']);
|
||||
tester.widget<TaskCard>(card('group')).onPause!();
|
||||
await settle(tester);
|
||||
expect(app.controller.paused, ['child', 'group']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final width in [390.0, 1100.0]) {
|
||||
testWidgets('group resume button targets the group at $width', (tester) async {
|
||||
final app = await showHome(tester, width);
|
||||
await tester.tap(find.descendant(of: card('group'), matching: find.byIcon(Icons.play_arrow)));
|
||||
await settle(tester);
|
||||
expect(app.controller.resumed, ['group']);
|
||||
expect(card('child'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final open in [true, false]) {
|
||||
testWidgets('member card and menu ${open ? 'open' : 'reveal'} use the member path', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
app.controller.completeMember();
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
final checkedPaths = <String>[];
|
||||
final child = tester.widget<TaskCard>(card('child'));
|
||||
await IOOverrides.runZoned(
|
||||
() async {
|
||||
(open ? child.onOpen : child.onReveal)!();
|
||||
await settle(tester);
|
||||
expect(checkedPaths.first, '/downloads/bundle/member.txt');
|
||||
checkedPaths.clear();
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(ValueKey(open ? 'task-context-open-file' : 'task-context-open-directory')));
|
||||
await settle(tester);
|
||||
expect(checkedPaths.first, '/downloads/bundle/member.txt');
|
||||
},
|
||||
fseGetType: (path, followLinks) async {
|
||||
checkedPaths.add(path);
|
||||
return FileSystemEntityType.notFound;
|
||||
},
|
||||
);
|
||||
// Allow the failure-path toast to close without launching native tools.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('member menu details uses the member instead of the group', (tester) async {
|
||||
await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('torrent')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-details')));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'torrent');
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.files, hasLength(2));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('selecting a member through its context menu enables batch actions for that member', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-select')));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskCard>(card('child')).selectedInBatch, isTrue);
|
||||
final pause = find.byIcon(Icons.pause_outlined);
|
||||
await tester.tap(pause);
|
||||
await settle(tester);
|
||||
expect(app.controller.paused, ['child']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('deleting a member leaves its sibling group intact', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
|
||||
await settle(tester);
|
||||
expect(find.byType(shad.DestructiveButton), findsOneWidget);
|
||||
await tester.tap(find.byType(shad.DestructiveButton));
|
||||
await settle(tester);
|
||||
expect(app.controller.deleted, ['child']);
|
||||
expect(card('group'), findsOneWidget);
|
||||
expect(card('child'), findsNothing);
|
||||
expect(card('torrent'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('deleting a selected group and its members counts and submits the group once', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('group')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-select-all')));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
|
||||
await settle(tester);
|
||||
expect(find.text('Delete task?'), findsOneWidget);
|
||||
await tester.tap(find.byType(shad.DestructiveButton));
|
||||
await settle(tester);
|
||||
expect(app.controller.deleted, ['group']);
|
||||
expect(card('group'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member context file browser polls its member record', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-browse-files')));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('group clicks in batch mode select without collapsing members', (tester) async {
|
||||
await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-select')));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskCard>(card('group')).selectedInBatch, isTrue);
|
||||
expect(tester.widget<TaskCard>(card('group')).expanded, isTrue);
|
||||
expect(card('child'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member detail route resolves the member without an initial snapshot', (tester) async {
|
||||
final app = await showHome(tester, 390);
|
||||
app.router.go('/tasks/child');
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member file page resolves current data from its group without a route snapshot', (tester) async {
|
||||
final app = await showHome(tester, 390);
|
||||
app.router.go('/tasks/child/files');
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
|
||||
PendingDownload source(String id, {String name = 'bundle', String rid = '', int count = 2, String? url}) =>
|
||||
PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: url ?? 'https://example.com/$id'),
|
||||
options: Options(path: '/downloads'),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
id: rid,
|
||||
res: Resource(
|
||||
name: name,
|
||||
files: List.generate(
|
||||
count,
|
||||
(i) => FileInfo(
|
||||
name: '$i.txt',
|
||||
size: 100 + i,
|
||||
req: Request(url: 'https://example.com/$id/$i'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
..selected.addAll(List.generate(count, (i) => i));
|
||||
|
||||
GopeedService service(CapabilityRegistry registry) => GopeedService(LocalCapabilityInvoker(registry));
|
||||
|
||||
void main() {
|
||||
test('one named extension group and all independent sources produce exactly two batches', () {
|
||||
final group = source('a')..selected.remove(0);
|
||||
final unnamed = source('b', name: '');
|
||||
final torrent = source('c', rid: 'bt-rid')..selected.remove(0);
|
||||
final http = source('d', rid: 'http-rid', count: 1, name: '');
|
||||
final batches = planDownloads([group, unnamed, torrent, http]);
|
||||
expect(batches, hasLength(2));
|
||||
final grouped = batches.first.request;
|
||||
expect(grouped.group, isTrue);
|
||||
expect(grouped.groupName, 'bundle');
|
||||
expect(grouped.opts!.path, '/downloads');
|
||||
expect(grouped.reqs!.single.req!.url, 'https://example.com/a/1');
|
||||
expect(grouped.toJson(), isNot(contains('groupId')));
|
||||
final normal = batches.last.request;
|
||||
expect(normal.group, isFalse);
|
||||
expect(normal.reqs, hasLength(4));
|
||||
expect(normal.reqs!.take(2).every((item) => item.req != null && item.rid == null), isTrue);
|
||||
expect(normal.reqs![2].rid, 'bt-rid');
|
||||
expect(normal.reqs![2].opts!.selectFiles, [1]);
|
||||
expect(normal.reqs![3].rid, 'http-rid');
|
||||
expect(normal.reqs![3].req, isNull);
|
||||
});
|
||||
|
||||
test('successful batches are complete even when only some task IDs are returned', () async {
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (_) {
|
||||
calls++;
|
||||
return CreateTaskBatchResult(taskIds: ['only-one']);
|
||||
});
|
||||
final sources = [source('a', name: ''), source('b', rid: 'rid')];
|
||||
await planDownloads(sources).single.submit(service(registry));
|
||||
expect(sources.every((s) => s.created && s.createError == null), isTrue);
|
||||
expect(planDownloads(sources), isEmpty);
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('retry only submits failed batches and never appends to the successful group', () async {
|
||||
final submitted = <CreateTaskBatch>[];
|
||||
var failNormal = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted.add(batch);
|
||||
if (!batch.group && failNormal) throw StateError('all failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created'], parentId: batch.group ? 'parent' : null);
|
||||
});
|
||||
final sources = [source('group'), source('normal', name: '')];
|
||||
for (final batch in planDownloads(sources)) {
|
||||
await batch.submit(service(registry));
|
||||
}
|
||||
expect(sources.first.created, isTrue);
|
||||
expect(sources.last.created, isFalse);
|
||||
expect(sources.last.createError, contains('all failed'));
|
||||
failNormal = false;
|
||||
final retry = planDownloads(sources);
|
||||
expect(retry, hasLength(1));
|
||||
await retry.single.submit(service(registry));
|
||||
expect(submitted.where((batch) => batch.group), hasLength(1));
|
||||
expect(planDownloads(sources), isEmpty);
|
||||
});
|
||||
|
||||
test('single extension keeps nested resource directory and empty base remains empty', () {
|
||||
final single = source('single', count: 1, name: 'album');
|
||||
single.result!.res.files.single.path = 'disc1';
|
||||
expect(single.isGroup, isFalse);
|
||||
expect(planDownloads([single]).single.request.reqs!.single.opts!.path, '/downloads/album/disc1');
|
||||
final plain = source('plain', count: 1, name: '')..options.path = '';
|
||||
expect(plain.creationItems().single.opts!.path, '');
|
||||
});
|
||||
|
||||
for (final parent in ['', 'downloads', './downloads', '/downloads']) {
|
||||
test('group members use paths relative to their group for parent "$parent"', () {
|
||||
final pending = source('group')..options.path = parent;
|
||||
pending.files[1].path = 'nested';
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isTrue);
|
||||
expect(batch.opts!.path, parent);
|
||||
expect(batch.reqs!.map((item) => item.opts!.path), ['', 'nested']);
|
||||
expect(pending.options.path, parent);
|
||||
});
|
||||
}
|
||||
|
||||
test('unnamed extension members retain their ordinary batch destination', () {
|
||||
final pending = source('independent', name: '')..options.path = 'downloads';
|
||||
pending.files[1].path = 'nested';
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isFalse);
|
||||
expect(batch.reqs!.map((item) => item.opts!.path), ['downloads', 'downloads/nested']);
|
||||
});
|
||||
|
||||
test('members preserve directory intent, HTTP sizes and per-file checksum rules', () {
|
||||
final pending = source('group');
|
||||
pending.options.extra = OptsExtraHttp(
|
||||
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
|
||||
).toJson();
|
||||
final members = pending.creationItems();
|
||||
expect(members.first.opts!.size, 100);
|
||||
expect(members.first.opts!.asDefaultPath, isFalse);
|
||||
expect(members.first.opts!.skipAutoCategorize, isTrue);
|
||||
expect(members.first.opts!.extra as Map, isNot(contains('checksum')));
|
||||
pending.selected.remove(0);
|
||||
expect(pending.creationItems().single.opts!.extra as Map, contains('checksum'));
|
||||
expect(pending.options.extra as Map, contains('checksum'));
|
||||
pending.result!.res.files[1].req = Request(url: 'magnet:?xt=urn:btih:test');
|
||||
expect(pending.creationItems().single.opts!.size, 0);
|
||||
});
|
||||
|
||||
test('FTP extension members use FTP options and keep their request TLS mode', () {
|
||||
final pending = source('group');
|
||||
pending.options.extra = OptsExtraHttp(
|
||||
connections: 64,
|
||||
autoExtract: true,
|
||||
archivePassword: 'secret',
|
||||
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
|
||||
).toJson();
|
||||
pending.files[0].req = Request(
|
||||
url: 'ftp://files.example/archive.zip',
|
||||
extra: ReqExtraFtp(tls: 'explicit').toJson(),
|
||||
);
|
||||
pending.selected.remove(1);
|
||||
final item = pending.creationItems().single;
|
||||
expect(item.req!.extra, {'tls': 'explicit'});
|
||||
expect(item.opts!.size, 0);
|
||||
final extra = item.opts!.extra as Map;
|
||||
expect(extra['connections'], 32);
|
||||
expect(extra['autoExtract'], isTrue);
|
||||
expect(extra['archivePassword'], 'secret');
|
||||
expect(extra.containsKey('checksum'), isFalse);
|
||||
expect((pending.options.extra as Map)['connections'], 64);
|
||||
});
|
||||
|
||||
test('member option changes do not affect other members or source options', () {
|
||||
final pending = source('group');
|
||||
pending.options
|
||||
..name = 'custom'
|
||||
..size = 500
|
||||
..selectFiles = [0, 1]
|
||||
..asDefaultPath = true;
|
||||
final original = pending.options.toJson();
|
||||
final members = pending.creationItems();
|
||||
members.first.opts!
|
||||
..name = 'changed.txt'
|
||||
..path = '/elsewhere'
|
||||
..size = 1
|
||||
..selectFiles.add(99);
|
||||
expect(members.last.opts!.name, '1.txt');
|
||||
expect(members.last.opts!.path, '');
|
||||
expect(members.last.opts!.size, 101);
|
||||
expect(members.last.opts!.selectFiles, [0, 1]);
|
||||
expect(pending.options.toJson(), original);
|
||||
});
|
||||
|
||||
for (final rid in ['', 'resolved-rid']) {
|
||||
test('empty resource preserves options and selects the creation method for rid="$rid"', () {
|
||||
final pending = source('empty', rid: rid, count: 0)..selected.add(0);
|
||||
pending.options
|
||||
..name = 'custom'
|
||||
..selectFiles = [2];
|
||||
final item = pending.creationItems().single;
|
||||
expect(item.rid, rid.isEmpty ? null : rid);
|
||||
expect(item.req, rid.isEmpty ? pending.request : null);
|
||||
expect(identical(item.opts, pending.options), isFalse);
|
||||
expect(item.opts!.toJson(), pending.options.toJson());
|
||||
});
|
||||
}
|
||||
|
||||
test('a magnet resolved by an extension keeps its HTTP members and group identity', () {
|
||||
final pending = source('extension', url: 'magnet:?xt=urn:btih:infohash');
|
||||
expect(pending.isBt, isFalse);
|
||||
expect(pending.isGroup, isTrue);
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isTrue);
|
||||
expect(batch.reqs, hasLength(2));
|
||||
expect(batch.reqs!.every((item) => item.rid == null && item.req!.url.startsWith('https:')), isTrue);
|
||||
});
|
||||
|
||||
test('root names use HTTP file names, BT resource names and URL fallback', () {
|
||||
final http = source('http', rid: 'http-rid', count: 1, name: 'unused-resource-name');
|
||||
expect(http.name, '0.txt');
|
||||
final bt = source('bt', rid: 'bt-rid', count: 1, name: 'torrent', url: 'magnet:?xt=urn:btih:infohash');
|
||||
bt.result!.res.hash = 'infohash';
|
||||
expect(bt.isBt, isTrue);
|
||||
expect(bt.name, 'torrent');
|
||||
final unnamed = source('empty', rid: 'rid', count: 0, name: '');
|
||||
expect(unnamed.name, unnamed.request.url);
|
||||
});
|
||||
|
||||
for (final entry in <String, bool>{
|
||||
'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/': false,
|
||||
'https://example.com/file': false,
|
||||
'https://example.com/file.torrent': false,
|
||||
'ftp://files.example/file.torrent': false,
|
||||
'ftps://files.example/file.torrent': false,
|
||||
'FTPES://files.example/file.torrent': false,
|
||||
'magnet:?xt=urn:btih:infohash': true,
|
||||
'MAGNET:?xt=urn:btih:infohash': true,
|
||||
'file:///downloads/file.torrent': true,
|
||||
'/downloads/FILE.TORRENT': true,
|
||||
'file:///downloads/file.torrent?query=1': true,
|
||||
'data:application/x-bittorrent;base64,AAAA': true,
|
||||
'data:application/octet-stream;base64,AAAA': false,
|
||||
}.entries) {
|
||||
test('source protocol determines BT identity for ${entry.key}', () {
|
||||
final pending = source('protocol', rid: 'rid', count: 1, name: 'resource', url: entry.key);
|
||||
if (RegExp(r'^ftp', caseSensitive: false).hasMatch(entry.key)) pending.result!.res.name = '';
|
||||
pending.result!.res.hash = '0123456789ABCDEF0123456789ABCDEF';
|
||||
expect(pending.isBt, entry.value);
|
||||
expect(pending.name, entry.value ? 'resource' : '0.txt');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed resolutions are excluded and each newly opened source resolves fresh', () async {
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) {
|
||||
calls++;
|
||||
return ResolveResult(
|
||||
id: 'rid-$calls',
|
||||
res: Resource(files: [FileInfo(name: 'file')]),
|
||||
);
|
||||
});
|
||||
final first = PendingDownload(
|
||||
id: 'a',
|
||||
request: Request(url: 'https://example.com'),
|
||||
options: Options(),
|
||||
);
|
||||
await first.resolve(service(registry));
|
||||
await first.resolve(service(registry));
|
||||
expect(calls, 1);
|
||||
final reopened = PendingDownload(id: 'a', request: first.request, options: Options());
|
||||
await reopened.resolve(service(registry));
|
||||
expect(calls, 2);
|
||||
final failed = PendingDownload(id: 'b', request: first.request, options: Options())..resolveError = 'failed';
|
||||
expect(planDownloads([failed, reopened]).single.sources, [reopened]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('the list counts a group once and sends group IDs for every action', () async {
|
||||
api.Task task(String id) => api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': '$id.txt',
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'parentId': 'group',
|
||||
'protocol': 'http',
|
||||
'status': 'pause',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/$id'},
|
||||
'opts': {'path': '/downloads/folder', 'size': 100},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
|
||||
});
|
||||
final raw = [task('first'), task('second')];
|
||||
final actions = <String>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getTasks, (_) => raw)
|
||||
..bind(GopeedMethods.pauseTask, (id) {
|
||||
actions.add('pause:$id');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.continueTask, (id) {
|
||||
actions.add('resume:$id');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.pauseTasks, (params) {
|
||||
actions.add('pauseBatch:${params['ids']}');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.continueTasks, (params) {
|
||||
actions.add('resumeBatch:${params['ids']}');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.deleteTasks, (params) {
|
||||
actions.add('delete:${params['ids']}:${params['force']}');
|
||||
raw.clear();
|
||||
return const RpcUnit();
|
||||
});
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final state = await container.read(tasksControllerProvider.future);
|
||||
expect(state.tasks.length, 1);
|
||||
expect(state.tasks.single.members.length, 2);
|
||||
expect(state.allTasks.map((task) => task.id), ['group', 'first', 'second']);
|
||||
expect(state.findTask('group'), same(state.tasks.single));
|
||||
expect(state.findTask('first'), same(state.tasks.single.members.first));
|
||||
expect(state.findTask('missing'), isNull);
|
||||
expect(state.tasks.single.matchesSearch('second.txt'), isTrue);
|
||||
expect(state.tasks.single.progress, 0.25);
|
||||
final controller = container.read(tasksControllerProvider.notifier);
|
||||
await controller.pause('group');
|
||||
await controller.resume('group');
|
||||
await controller.pauseAll(['group']);
|
||||
await controller.resumeAll(['group']);
|
||||
await controller.deleteSelected(['group'], force: true);
|
||||
expect(actions, [
|
||||
'pause:group',
|
||||
'resume:group',
|
||||
'pauseBatch:[group]',
|
||||
'resumeBatch:[group]',
|
||||
'delete:[group]:true',
|
||||
]);
|
||||
expect(container.read(tasksControllerProvider).value?.tasks, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,219 @@ void main() {
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 10, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
|
||||
test('members aggregate through parentId without a parent task', () {
|
||||
final records = taskRecordsFromApi([
|
||||
groupTestTask('first', parentId: 'group'),
|
||||
groupTestTask(
|
||||
'second',
|
||||
parentId: 'group',
|
||||
directory: '/downloads/folder/nested',
|
||||
name: 'movie.mp4',
|
||||
size: 300,
|
||||
downloaded: 75,
|
||||
),
|
||||
groupTestTask('standalone'),
|
||||
]);
|
||||
expect(records.map((record) => record.id), ['group', 'standalone']);
|
||||
final record = records.first;
|
||||
expect(record.isGroup, isTrue);
|
||||
expect(record.members.length, 2);
|
||||
expect(record.totalBytes, 400);
|
||||
expect(record.downloadedBytes, 100);
|
||||
expect(record.speedBytes, 20);
|
||||
expect(record.progress, 0.25);
|
||||
expect(record.storagePath, '/downloads/folder');
|
||||
expect(record.files.map((file) => file.path), ['', 'nested']);
|
||||
expect(record.files.last.name, 'movie.mp4');
|
||||
expect(record.matchesSearch('second'), isTrue);
|
||||
expect(record.canUpdateUrl, isFalse);
|
||||
});
|
||||
|
||||
test('group preserves uploading while member upload speed is momentarily zero', () {
|
||||
final member = groupTestTask('uploading', parentId: 'group');
|
||||
member.uploading = true;
|
||||
member.progress.uploadSpeed = 0;
|
||||
final group = taskRecordsFromApi([member]).single;
|
||||
expect(group.uploading, isTrue);
|
||||
expect(group.uploadSpeedBytes, 0);
|
||||
});
|
||||
|
||||
for (final scenario in <(List<String>, TaskStatus, bool)>[
|
||||
(['running', 'error', 'pause'], TaskStatus.downloading, false),
|
||||
(['error', 'pause', 'wait'], TaskStatus.failed, false),
|
||||
(['pause', 'wait', 'done'], TaskStatus.paused, false),
|
||||
(['done', 'done'], TaskStatus.completed, false),
|
||||
(['wait', 'done'], TaskStatus.downloading, true),
|
||||
]) {
|
||||
test('group status priority for ${scenario.$1}', () {
|
||||
final record = taskRecordsFromApi([
|
||||
for (var i = 0; i < scenario.$1.length; i++)
|
||||
groupTestTask('child-$i', parentId: 'group', status: scenario.$1[i]),
|
||||
]).single;
|
||||
expect(record.status, scenario.$2);
|
||||
expect(record.waiting, scenario.$3);
|
||||
if (scenario.$2 == TaskStatus.completed) {
|
||||
expect(record.progress, 1);
|
||||
expect(record.doneAt, DateTime.utc(2026, 1, 3));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('unknown member sizes stay indeterminate and empty files can finish', () {
|
||||
final unknown = groupTestTask('unknown', parentId: 'group', size: 0);
|
||||
final record = taskRecordsFromApi([unknown, groupTestTask('known', parentId: 'group')]).single;
|
||||
expect(record.totalBytes, isNull);
|
||||
expect(record.progress, isNull);
|
||||
unknown.status = api.Status.done;
|
||||
final empty = taskRecordsFromApi([unknown]).single;
|
||||
expect(empty.totalBytes, 0);
|
||||
expect(empty.progress, 1);
|
||||
});
|
||||
|
||||
test('Windows group directories retain their relative file tree', () {
|
||||
final records = taskRecordsFromApi([
|
||||
groupTestTask('child', parentId: 'group', directory: r'C:\Downloads\folder\nested'),
|
||||
]);
|
||||
expect(records.single.files.single.path, 'nested');
|
||||
expect(records.single.files.single.name, 'file.txt');
|
||||
});
|
||||
|
||||
test('folder member file progress spreads the member ratio over its files', () {
|
||||
final member = api.Task.fromJson({
|
||||
'id': 'pack',
|
||||
'name': 'pack',
|
||||
'parentId': 'group',
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'protocol': 'http',
|
||||
'status': 'running',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-02T00:00:00Z',
|
||||
'doneAt': null,
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/pack'},
|
||||
'opts': {'path': '/downloads/folder/pack', 'name': 'pack', 'size': 30},
|
||||
'res': {
|
||||
'name': 'pack',
|
||||
'size': 30,
|
||||
'files': [
|
||||
{'name': 'a.bin', 'size': 10},
|
||||
{'name': 'b.bin', 'size': 20},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
final record = taskRecordsFromApi([member]).single;
|
||||
expect(record.files.map((file) => file.name), ['a.bin', 'b.bin']);
|
||||
expect(record.files.first.downloadedBytes, 5);
|
||||
expect(record.files.last.downloadedBytes, 10);
|
||||
});
|
||||
|
||||
test('members outside the group root render under their own folder name', () {
|
||||
final folderMember = api.Task.fromJson({
|
||||
'id': 'pack',
|
||||
'name': 'pack',
|
||||
'parentId': 'group',
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'protocol': 'http',
|
||||
'status': 'running',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-02T00:00:00Z',
|
||||
'doneAt': null,
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/pack'},
|
||||
'opts': {'path': '/elsewhere/pack', 'name': 'pack', 'size': 30},
|
||||
'res': {
|
||||
'name': 'pack',
|
||||
'size': 30,
|
||||
'files': [
|
||||
{'name': 'a.bin', 'size': 30},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
final record = taskRecordsFromApi([
|
||||
groupTestTask('loose', parentId: 'group', directory: '/elsewhere'),
|
||||
folderMember,
|
||||
]).single;
|
||||
for (final file in record.files) {
|
||||
expect(file.path.startsWith('..'), isFalse);
|
||||
}
|
||||
expect(record.files.first.path, '');
|
||||
expect(record.files.first.name, 'file.txt');
|
||||
expect(record.files.last.path, 'pack');
|
||||
expect(record.files.last.name, 'a.bin');
|
||||
});
|
||||
|
||||
test('completed group reports the wall span from creation to finish', () {
|
||||
final record = taskRecordsFromApi([groupTestTask('child', parentId: 'group', status: 'done')]).single;
|
||||
expect(record.downloadDuration, const Duration(days: 2));
|
||||
});
|
||||
|
||||
for (final completed in [false, true]) {
|
||||
test('group timestamps use chronological maxima when completed=$completed', () {
|
||||
final first = groupTestTask('first', parentId: 'group', status: completed ? 'done' : 'running')
|
||||
..updatedAt = DateTime.utc(2026, 1, 6)
|
||||
..doneAt = DateTime.utc(2026, 1, 5);
|
||||
final second = groupTestTask('second', parentId: 'group', status: 'done')
|
||||
..updatedAt = DateTime.utc(2026, 1, 3)
|
||||
..doneAt = DateTime.utc(2026, 1, 4);
|
||||
final legacy = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
|
||||
final record = taskRecordsFromApi([first, second, legacy]).single;
|
||||
expect(record.updatedAt, DateTime.utc(2026, 1, 6));
|
||||
expect(record.doneAt, completed ? DateTime.utc(2026, 1, 5) : null);
|
||||
expect(record.downloadDuration, completed ? const Duration(days: 4) : null);
|
||||
});
|
||||
}
|
||||
|
||||
test('group creation bounds updated time and legacy completion stays unknown', () {
|
||||
final member = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
|
||||
member.group = api.TaskGroup(
|
||||
id: 'group',
|
||||
name: 'folder',
|
||||
path: '/downloads/folder',
|
||||
createdAt: DateTime.utc(2026, 1, 4),
|
||||
);
|
||||
final record = taskRecordsFromApi([member]).single;
|
||||
expect(record.updatedAt, member.group!.createdAt);
|
||||
expect(record.doneAt, isNull);
|
||||
expect(record.downloadDuration, isNull);
|
||||
});
|
||||
|
||||
test('completed group reports the members average speed instead of 0 B/s', () {
|
||||
final record = taskRecordsFromApi([
|
||||
groupTestTask(
|
||||
'first',
|
||||
parentId: 'group',
|
||||
status: 'done',
|
||||
size: 100,
|
||||
downloaded: 100,
|
||||
usedNanoseconds: 10 * 1000000000,
|
||||
),
|
||||
groupTestTask(
|
||||
'second',
|
||||
parentId: 'group',
|
||||
status: 'done',
|
||||
size: 50,
|
||||
downloaded: 50,
|
||||
usedNanoseconds: 5 * 1000000000,
|
||||
),
|
||||
]).single;
|
||||
// 150 bytes over 15 combined active seconds, not the 0 of the idle aggregate.
|
||||
expect(record.speedBytes, 10);
|
||||
expect(record.speed, '10 B/s');
|
||||
});
|
||||
|
||||
test('paused group keeps the idle aggregate instead of an average speed', () {
|
||||
final record = taskRecordsFromApi([
|
||||
groupTestTask('first', parentId: 'group', status: 'pause', downloaded: 100, usedNanoseconds: 10 * 1000000000),
|
||||
]).single;
|
||||
expect(record.speedBytes, 0);
|
||||
});
|
||||
|
||||
test('selected files retain original indexes for runtime progress', () {
|
||||
final record = TaskRecord.fromApi(buildTask([2, 0], 40));
|
||||
expect(record.files.map((file) => file.name), ['a.txt', 'c.txt']);
|
||||
@@ -97,3 +310,43 @@ void main() {
|
||||
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
|
||||
});
|
||||
}
|
||||
|
||||
api.Task groupTestTask(
|
||||
String id, {
|
||||
String parentId = '',
|
||||
String status = 'running',
|
||||
int size = 100,
|
||||
int downloaded = 25,
|
||||
int usedNanoseconds = 0,
|
||||
String directory = '/downloads/folder',
|
||||
String name = 'file.txt',
|
||||
}) => api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': name,
|
||||
'parentId': parentId,
|
||||
if (parentId.isNotEmpty)
|
||||
'group': {
|
||||
'id': parentId,
|
||||
'name': 'folder',
|
||||
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
'protocol': 'http',
|
||||
'status': status,
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-02T00:00:00Z',
|
||||
'doneAt': status == 'done' ? '2026-01-03T00:00:00Z' : null,
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/$id'},
|
||||
'opts': {'path': directory, 'name': name, 'size': size},
|
||||
if (size > 0)
|
||||
'res': {
|
||||
'size': size,
|
||||
'files': [
|
||||
{'name': name, 'size': size},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': usedNanoseconds, 'speed': 10, 'downloaded': downloaded, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/core/capabilities/storage_capability.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_http_headers_editor.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('cancel and confirm again resolves every unsubmitted URL afresh', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final resolved = <String>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolved.add(request.req!.url);
|
||||
return ResolveResult(
|
||||
id: 'rid-${resolved.length}',
|
||||
res: Resource(files: [FileInfo(name: 'file')]),
|
||||
);
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final url = find.byKey(const ValueKey('create-task-url-input'));
|
||||
await tester.enterText(url, 'https://example.com/a\nhttps://example.com/b');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolved, ['https://example.com/a', 'https://example.com/b']);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolved, [
|
||||
'https://example.com/a',
|
||||
'https://example.com/b',
|
||||
'https://example.com/a',
|
||||
'https://example.com/b',
|
||||
]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final failGroup in [true, false]) {
|
||||
testWidgets('reopening a preview retries only the failed batch (group=$failGroup)', (tester) async {
|
||||
final resolutions = <String>[];
|
||||
final batches = <CreateTaskBatch>[];
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions.add(request.req!.url);
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
if (batch.group == failGroup && fail) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(batches, hasLength(2));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
fail = false;
|
||||
await _openPreview(tester);
|
||||
final failedUrl = 'https://example.com/${failGroup ? 'group' : 'other'}';
|
||||
expect(resolutions, ['https://example.com/group', 'https://example.com/other', failedUrl]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(batches, hasLength(3));
|
||||
expect(batches.where((batch) => batch.group != failGroup), hasLength(1));
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('completion survives reordering and distinguishes duplicate source occurrences', (tester) async {
|
||||
final resolutions = <String>[];
|
||||
var groupCalls = 0;
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions.add(request.req!.url);
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
if (batch.group) {
|
||||
groupCalls++;
|
||||
if (groupCalls == 2 && fail) throw StateError('second occurrence failed');
|
||||
}
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other\nhttps://example.com/group');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
fail = false;
|
||||
await _openPreview(tester, urls: 'https://example.com/other\nhttps://example.com/group\nhttps://example.com/group');
|
||||
expect(resolutions, [
|
||||
'https://example.com/group',
|
||||
'https://example.com/other',
|
||||
'https://example.com/group',
|
||||
'https://example.com/group',
|
||||
]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(groupCalls, 3);
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('unfinished sources resolve with edited options without recreating a completed group', (tester) async {
|
||||
final resolutions = <Request>[];
|
||||
final batches = <CreateTaskBatch>[];
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions.add(request.req!);
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
if (!batch.group && fail) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
tester.widget<AppHttpHeadersEditor>(find.byType(AppHttpHeadersEditor)).controller.replace({'Cookie': 'new-cookie'});
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/retry');
|
||||
fail = false;
|
||||
await _openPreview(tester);
|
||||
expect(resolutions.map((request) => request.url), [
|
||||
'https://example.com/group',
|
||||
'https://example.com/other',
|
||||
'https://example.com/other',
|
||||
]);
|
||||
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-cookie');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(batches.where((batch) => batch.group), hasLength(1));
|
||||
expect(batches.last.reqs!.single.opts!.path, '/retry');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a new creation window can submit the same source again', (tester) async {
|
||||
var resolutions = 0;
|
||||
var submissions = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions++;
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (_) {
|
||||
submissions++;
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
final router = await _openPage(tester, registry);
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
if (attempt > 0) {
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await _openPreview(tester, urls: 'https://example.com/group');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
}
|
||||
expect(resolutions, 2);
|
||||
expect(submissions, 2);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final duringSubmission in [false, true]) {
|
||||
testWidgets('a new external task starts a fresh session (during submission: $duringSubmission)', (tester) async {
|
||||
final firstSubmission = Completer<CreateTaskBatchResult>();
|
||||
final resolutions = <Request>[];
|
||||
var groupSubmissions = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
final req = request.req!;
|
||||
resolutions.add(req);
|
||||
return _resolved(req.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
if (batch.group) {
|
||||
if (++groupSubmissions == 1 && duringSubmission) return firstSubmission.future;
|
||||
} else if (!duringSubmission) {
|
||||
throw StateError('ordinary batch failed');
|
||||
}
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
final page = find.byType(CreateTaskWindowPage);
|
||||
final pageState = tester.state(page);
|
||||
final container = ProviderScope.containerOf(tester.element(page));
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(groupSubmissions, 1);
|
||||
if (!duringSubmission) {
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
container
|
||||
.read(pendingCreateTaskProvider.notifier)
|
||||
.set(
|
||||
CreateTask(
|
||||
req: Request(
|
||||
url: 'https://example.com/group',
|
||||
extra: ReqExtraHttp(method: 'GET', header: {'Cookie': 'new-intent'}, body: '').toJson(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
if (duringSubmission) {
|
||||
firstSubmission.complete(CreateTaskBatchResult(taskIds: ['old-created']));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.state(page), same(pageState));
|
||||
await _openPreview(tester);
|
||||
expect(resolutions.map((request) => request.url), [
|
||||
'https://example.com/group',
|
||||
'https://example.com/other',
|
||||
'https://example.com/group',
|
||||
]);
|
||||
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-intent');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(groupSubmissions, 2);
|
||||
expect(page, findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('a failed stale direct submission releases the new task form', (tester) async {
|
||||
final firstSubmission = Completer<CreateTaskBatchResult>();
|
||||
final requests = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
requests.add(batch);
|
||||
if (requests.length == 1) return firstSubmission.future;
|
||||
return CreateTaskBatchResult(taskIds: ['new-created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
final container = ProviderScope.containerOf(tester.element(find.byType(CreateTaskWindowPage)));
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/old');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
expect(requests, hasLength(1));
|
||||
|
||||
container.read(pendingCreateTaskProvider.notifier).set(CreateTask(req: Request(url: 'https://example.com/new')));
|
||||
await tester.pump();
|
||||
firstSubmission.completeError(StateError('old submission failed'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byType(CreateTaskWindowPage), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(requests, hasLength(2));
|
||||
expect(requests.last.reqs!.single.req!.url, 'https://example.com/new');
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('confirming only completed sources finishes without opening another preview', (tester) async {
|
||||
var resolutions = 0;
|
||||
var submissions = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions++;
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submissions++;
|
||||
if (!batch.group) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/group');
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'invalid');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolutions, 2);
|
||||
expect(submissions, 2);
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('switching to direct download skips completed preview batches', (tester) async {
|
||||
var resolutions = 0;
|
||||
final batches = <CreateTaskBatch>[];
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions++;
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
if (!batch.group && fail) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
|
||||
await tester.pump();
|
||||
fail = false;
|
||||
await _openPreview(tester);
|
||||
expect(resolutions, 2);
|
||||
expect(batches, hasLength(3));
|
||||
expect(batches.last.reqs!.single.req!.url, 'https://example.com/other');
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
ResolveResult _resolved(String url) => url.endsWith('/group')
|
||||
? ResolveResult(
|
||||
res: Resource(
|
||||
name: 'album',
|
||||
files: [
|
||||
FileInfo(
|
||||
name: 'a.txt',
|
||||
req: Request(url: 'https://example.com/a.txt'),
|
||||
),
|
||||
FileInfo(
|
||||
name: 'b.txt',
|
||||
req: Request(url: 'https://example.com/b.txt'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ResolveResult(
|
||||
id: 'rid-$url',
|
||||
res: Resource(files: [FileInfo(name: 'other.txt')]),
|
||||
);
|
||||
|
||||
Future<GoRouter> _openPage(WidgetTester tester, CapabilityRegistry registry) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
registry
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return router;
|
||||
}
|
||||
|
||||
Future<void> _openPreview(WidgetTester tester, {String? urls}) async {
|
||||
if (urls != null) await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), urls);
|
||||
final confirm = find.byKey(const ValueKey('create-task-confirm-button'));
|
||||
await tester.ensureVisible(confirm);
|
||||
await tester.tap(confirm);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import 'package:gopeed/l10n/app_localizations.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('extension file estimates survive batch submission and window serialization', (tester) async {
|
||||
testWidgets('extension file paths and estimates survive batch submission and window serialization', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -49,6 +49,7 @@ void main() {
|
||||
(_) => ResolveResult(
|
||||
id: '',
|
||||
res: Resource(
|
||||
name: 'videos',
|
||||
files: [
|
||||
FileInfo(
|
||||
name: 'under.mp4',
|
||||
@@ -57,6 +58,7 @@ void main() {
|
||||
),
|
||||
FileInfo(
|
||||
name: 'over.mp4',
|
||||
path: 'nested',
|
||||
size: 1048576,
|
||||
req: Request(url: 'https://example.com/audio'),
|
||||
),
|
||||
@@ -66,7 +68,7 @@ void main() {
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = CreateTaskBatch.fromJson(jsonDecode(jsonEncode(batch)) as Map<String, dynamic>);
|
||||
return ['first', 'second'];
|
||||
return CreateTaskBatchResult(taskIds: ['first', 'second'], parentId: 'generated');
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final router = GoRouter(
|
||||
@@ -97,6 +99,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted?.reqs?.map((item) => item.opts?.size), [3, 1048576]);
|
||||
expect(submitted?.reqs?.map((item) => item.req?.url), ['https://example.com/video', 'https://example.com/audio']);
|
||||
expect(submitted?.group, isTrue);
|
||||
expect(submitted?.groupName, 'videos');
|
||||
expect(submitted?.opts?.path, '/downloads');
|
||||
expect(submitted?.reqs?.map((item) => item.opts?.path), ['', 'nested']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -118,9 +124,9 @@ void main() {
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.createTask, (_) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
events.add('created');
|
||||
return 'task-id';
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||
events.add('downloads');
|
||||
@@ -163,9 +169,14 @@ void main() {
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'created-id';
|
||||
return CreateTaskBatchResult(taskIds: ['created-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final container = ProviderContainer(
|
||||
@@ -246,9 +257,14 @@ void main() {
|
||||
GopeedMethods.getConfig,
|
||||
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
|
||||
)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'created-id';
|
||||
return CreateTaskBatchResult(taskIds: ['created-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final container = ProviderContainer(
|
||||
@@ -374,9 +390,15 @@ void main() {
|
||||
..protocolConfig.http.connections = 8;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.putConfig, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'created-task';
|
||||
return CreateTaskBatchResult(taskIds: ['created-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
@@ -877,9 +899,14 @@ void main() {
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
@@ -985,9 +1012,14 @@ void main() {
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
@@ -1140,9 +1172,18 @@ void main() {
|
||||
final createdTasks = <CreateTask>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
createdTasks.add(task);
|
||||
return 'task-id';
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
id: 'resolved',
|
||||
res: Resource(files: [FileInfo(name: 'file.zip')]),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
for (final item in batch.reqs!) {
|
||||
createdTasks.add(CreateTask(rid: item.rid, req: item.req, opts: item.opts));
|
||||
}
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
@@ -1189,8 +1230,12 @@ void main() {
|
||||
|
||||
// Confirm creation
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(createdTasks, isEmpty);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(createdTasks.length, 2);
|
||||
for (final t in createdTasks) {
|
||||
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
|
||||
@@ -1345,7 +1390,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the resolve dialog shows the suggested category directory', (tester) async {
|
||||
testWidgets('the file list keeps category suggestions out of its header', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -1381,8 +1426,8 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
final suggestion = find.byKey(const ValueKey('resolve-category-directory'));
|
||||
expect(suggestion, findsOneWidget);
|
||||
expect(tester.widget<Text>(suggestion).data, contains('/downloads/Program'));
|
||||
expect(suggestion, findsNothing);
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -1437,7 +1482,7 @@ void main() {
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('the resolve dialog shows the directory the user named, not the suggestion', (tester) async {
|
||||
testWidgets('the file list uses its fixed title for a manual directory', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -1475,13 +1520,87 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
final shown = find.byKey(const ValueKey('resolve-category-directory'));
|
||||
expect(shown, findsOneWidget);
|
||||
expect(tester.widget<Text>(shown).data, contains('/work'));
|
||||
expect(tester.widget<Text>(shown).data, isNot(contains('/downloads/Program')));
|
||||
expect(shown, findsNothing);
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a resolved file list is split into tasks that keep the directory structure', (tester) async {
|
||||
testWidgets('a resolved file list keeps structure and saves the selected parent directory', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTaskBatch? batch;
|
||||
final config = DownloaderConfig(downloadDir: '/downloads');
|
||||
DownloaderConfig? savedConfig;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.putConfig, (next) {
|
||||
savedConfig = next;
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
|
||||
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (request) {
|
||||
batch = request;
|
||||
return CreateTaskBatchResult(taskIds: ['task-1', 'task-2'], parentId: 'generated');
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/chosen');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(batch, isNotNull);
|
||||
expect(batch!.reqs, hasLength(2));
|
||||
expect(batch!.group, isTrue);
|
||||
expect(batch!.groupName, 'bundle');
|
||||
expect(batch!.toJson().containsKey('groupId'), isFalse);
|
||||
expect(batch!.opts?.path, '/chosen');
|
||||
expect(batch!.opts?.asDefaultPath, isTrue);
|
||||
for (final item in batch!.reqs!) {
|
||||
// The split tasks keep the structure of the resolved resource, so they
|
||||
// are never classified into a category or replace the selected parent.
|
||||
expect(item.opts?.skipAutoCategorize, isTrue);
|
||||
expect(item.opts?.asDefaultPath, isFalse);
|
||||
}
|
||||
expect(savedConfig?.downloadDir, '/chosen');
|
||||
expect(batch!.group, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -1504,64 +1623,7 @@ void main() {
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (request) {
|
||||
batch = request;
|
||||
return ['task-1', 'task-2'];
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(batch, isNotNull);
|
||||
expect(batch!.reqs, hasLength(2));
|
||||
for (final item in batch!.reqs!) {
|
||||
// The split tasks keep the structure of the resolved resource, so they
|
||||
// are never classified into a category.
|
||||
expect(item.opts?.skipAutoCategorize, isTrue);
|
||||
}
|
||||
expect(batch!.opts?.skipAutoCategorize, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTaskBatch? batch;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
res: Resource(
|
||||
files: [
|
||||
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
|
||||
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (request) {
|
||||
batch = request;
|
||||
return ['task-1'];
|
||||
return CreateTaskBatchResult(taskIds: ['task-1'], parentId: 'generated');
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
@@ -1594,10 +1656,11 @@ void main() {
|
||||
|
||||
expect(batch, isNotNull);
|
||||
expect(batch!.reqs, hasLength(1));
|
||||
expect(batch!.group, isTrue);
|
||||
for (final item in batch!.reqs!) {
|
||||
expect(item.opts?.skipAutoCategorize, isTrue);
|
||||
}
|
||||
expect(batch!.opts?.skipAutoCategorize, isTrue);
|
||||
expect(batch!.group, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
@@ -1642,7 +1705,7 @@ void _ftpTests() {
|
||||
|
||||
testWidgets('ftp links offer the TLS mode and carry FTP options through resolve', (tester) async {
|
||||
ResolveTask? resolved;
|
||||
CreateTask? submitted;
|
||||
CreateTaskBatch? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.resolve, (task) {
|
||||
@@ -1655,9 +1718,9 @@ void _ftpTests() {
|
||||
),
|
||||
);
|
||||
})
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'ftp-task';
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
@@ -1693,20 +1756,20 @@ void _ftpTests() {
|
||||
// The TLS mode travels on the request, so the resolve already uses it.
|
||||
expect(resolved?.req?.url, 'FTP://files.example/pub/big.iso');
|
||||
expect(resolved?.req?.extra, {'tls': 'explicit'});
|
||||
expect(submitted?.rid, 'ftp-resolved');
|
||||
final createOptions = submitted?.opts?.extra! as Map<String, dynamic>;
|
||||
expect(submitted?.reqs?.single.rid, 'ftp-resolved');
|
||||
final createOptions = submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>;
|
||||
expect(createOptions['connections'], 32);
|
||||
expect(createOptions.containsKey('tls'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('an ftp .torrent link restores its TLS mode and is not treated as BitTorrent', (tester) async {
|
||||
CreateTask? submitted;
|
||||
CreateTaskBatch? submitted;
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'ftp-task';
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(
|
||||
@@ -1733,19 +1796,22 @@ void _ftpTests() {
|
||||
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted?.req?.url, 'ftp://files.example/pub/linux.torrent');
|
||||
expect(submitted?.req?.extra, {'tls': 'implicit'});
|
||||
final options = OptsExtraFtp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
|
||||
expect(submitted?.reqs?.single.req?.url, 'ftp://files.example/pub/linux.torrent');
|
||||
expect(submitted?.reqs?.single.req?.extra, {'tls': 'implicit'});
|
||||
final options = OptsExtraFtp.fromJson(submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>);
|
||||
expect(options.connections, 6);
|
||||
});
|
||||
|
||||
testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async {
|
||||
final submitted = <CreateTask>[];
|
||||
final submitted = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted.add(task);
|
||||
return 'task-${submitted.length}';
|
||||
..bind(
|
||||
GopeedMethods.getConfig,
|
||||
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted.add(batch);
|
||||
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
@@ -1758,13 +1824,60 @@ void _ftpTests() {
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final byUrl = {for (final task in submitted) task.req!.url: task.opts!.extra! as Map<String, dynamic>};
|
||||
expect(submitted, hasLength(1));
|
||||
final byUrl = {for (final item in submitted.single.reqs!) item.req!.url: item.opts!.extra! as Map<String, dynamic>};
|
||||
expect(byUrl.keys, unorderedEquals(['https://example.com/a.zip', 'ftpes://files.example/b.iso']));
|
||||
expect(byUrl['https://example.com/a.zip']!['connections'], 64);
|
||||
expect(byUrl['https://example.com/a.zip']!.containsKey('tls'), isFalse);
|
||||
expect(byUrl['ftpes://files.example/b.iso']!['connections'], 32);
|
||||
expect(byUrl['ftpes://files.example/b.iso']!.containsKey('tls'), isFalse);
|
||||
expect(submitted.map((task) => task.req!.extra), everyElement(isNull));
|
||||
expect(submitted.single.reqs!.map((item) => item.req!.extra), everyElement(isNull));
|
||||
});
|
||||
testWidgets('mixed resolved HTTP and FTP resources share one batch with separate options', (tester) async {
|
||||
final resolvedUrls = <String>[];
|
||||
CreateTaskBatch? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.resolve, (task) {
|
||||
final url = task.req!.url;
|
||||
resolvedUrls.add(url);
|
||||
final ftp = url.startsWith('ftp');
|
||||
return ResolveResult(
|
||||
id: ftp ? 'ftp-rid' : 'http-rid',
|
||||
res: Resource(
|
||||
name: ftp ? 'folder' : '',
|
||||
files: [
|
||||
FileInfo(name: ftp ? 'movie.mkv' : 'file.zip', size: 10),
|
||||
if (ftp) FileInfo(name: 'extra.txt', size: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
await tester.enterText(find.byKey(urlInput), 'https://example.com/a.zip\nftpes://files.example/folder');
|
||||
await tester.enterText(find.byKey(connectionsInput), '64');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolvedUrls, ['https://example.com/a.zip', 'ftpes://files.example/folder']);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted!.group, isFalse);
|
||||
expect(submitted!.reqs, hasLength(2));
|
||||
final http = submitted!.reqs![0];
|
||||
final ftp = submitted!.reqs![1];
|
||||
expect(http.rid, 'http-rid');
|
||||
expect(ftp.rid, 'ftp-rid');
|
||||
expect((http.opts!.extra as Map)['connections'], 64);
|
||||
expect((ftp.opts!.extra as Map)['connections'], 32);
|
||||
expect(http.opts!.selectFiles, [0]);
|
||||
expect(ftp.opts!.selectFiles, [0, 1]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1795,9 +1908,10 @@ Future<AppPalette> _pumpCreateTaskForSubmit(
|
||||
..categories = categories;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
|
||||
onCreate(task);
|
||||
return 'created-task';
|
||||
return CreateTaskBatchResult(taskIds: ['created-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
@@ -1850,9 +1964,10 @@ Future<AppPalette> _pumpCreateTaskWithCategories(
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
if (onCreate != null) {
|
||||
registry.bind(GopeedMethods.createTask, (task) {
|
||||
registry.bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
|
||||
onCreate(task);
|
||||
return 'created-task';
|
||||
return CreateTaskBatchResult(taskIds: ['created-task']);
|
||||
});
|
||||
}
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/resolve_download_dialog.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
PendingDownload pending(String id) => PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: 'https://example.com/$id'),
|
||||
options: Options(),
|
||||
);
|
||||
ResolveResult resolved(String name) => ResolveResult(
|
||||
id: name,
|
||||
res: Resource(files: [FileInfo(name: name)]),
|
||||
);
|
||||
|
||||
Future<void> open(
|
||||
WidgetTester tester,
|
||||
List<PendingDownload> sources,
|
||||
CapabilityRegistry registry, {
|
||||
ValueChanged<bool>? onClose,
|
||||
double width = 900,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Builder(
|
||||
builder: (context) => shad.PrimaryButton(
|
||||
onPressed: () async {
|
||||
final result = await showResolveDownloadsDialog(
|
||||
context,
|
||||
sources: sources,
|
||||
service: GopeedService(LocalCapabilityInvoker(registry)),
|
||||
);
|
||||
onClose?.call(result);
|
||||
},
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('file list fits and exposes ready selections at width $width', (tester) async {
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) => resolved('file.txt'));
|
||||
await open(tester, [pending('source')], registry, width: width);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async {
|
||||
final calls = <String>[];
|
||||
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
calls.add(request.req!.url);
|
||||
return responses[calls.length - 1].future;
|
||||
});
|
||||
final sources = [pending('a'), pending('b')];
|
||||
await open(tester, sources, registry);
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(find.text('https://example.com/a'), findsOneWidget);
|
||||
expect(find.text('https://example.com/b'), findsOneWidget);
|
||||
expect(calls, ['https://example.com/a']);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
|
||||
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message == 'Waiting to resolve'), findsOneWidget);
|
||||
responses.first.complete(resolved('first.txt'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(calls, ['https://example.com/a', 'https://example.com/b']);
|
||||
expect(find.text('first.txt'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:a:0')));
|
||||
await tester.pump();
|
||||
expect(sources.first.selected, isEmpty);
|
||||
responses.last.complete(resolved('second.txt'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, isEmpty);
|
||||
expect(sources.last.selected, {0});
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final filters in [
|
||||
<String>['video'],
|
||||
<String>['video', 'audio'],
|
||||
]) {
|
||||
testWidgets('new files clear type highlights without changing previous selections ($filters)', (tester) async {
|
||||
final response = Completer<ResolveResult>();
|
||||
final first = pending('first')
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'video.mp4', size: 10),
|
||||
FileInfo(name: 'song.mp3', size: 20),
|
||||
FileInfo(name: 'picture.png', size: 30),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1, 2]);
|
||||
final second = pending('second');
|
||||
final batches = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) => response.future)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await open(tester, [first, second], registry);
|
||||
for (final filter in filters) {
|
||||
await tester.tap(find.byKey(ValueKey('resolve-tree-filter-$filter')));
|
||||
await tester.pump();
|
||||
// Each selection is written back by the parent and rebuilds the tree.
|
||||
expect(typeFilterActive(tester, filter), isTrue);
|
||||
}
|
||||
final expectedFirst = filters.length == 1 ? {0} : {0, 1};
|
||||
expect(first.selected, expectedFirst);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
|
||||
final incoming = filters.length == 1 ? 'new-audio.mp3' : 'new-image.png';
|
||||
response.complete(
|
||||
ResolveResult(
|
||||
id: 'incoming-rid',
|
||||
res: Resource(files: [FileInfo(name: incoming, size: 40)]),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(first.selected, expectedFirst);
|
||||
expect(second.selected, {0});
|
||||
for (final filter in filters) {
|
||||
expect(typeFilterActive(tester, filter), isFalse);
|
||||
}
|
||||
expect(find.text('${expectedFirst.length + 1} / 4 selected'), findsOneWidget);
|
||||
final size = tester.widget<Text>(find.byKey(const ValueKey('resolve-tree-selected-size'))).data;
|
||||
expect(size, '${filters.length == 1 ? 50 : 70} B');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(batches, hasLength(2));
|
||||
expect(batches.first.group, isTrue);
|
||||
expect(
|
||||
batches.first.reqs!.map((item) => item.opts!.name),
|
||||
filters.length == 1 ? ['video.mp4'] : ['video.mp4', 'song.mp3'],
|
||||
);
|
||||
expect(batches.last.group, isFalse);
|
||||
expect(batches.last.reqs!.single.rid, 'incoming-rid');
|
||||
expect(batches.last.reqs!.single.opts!.selectFiles, [0]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('a failed source preserves type highlights and the successful source selection', (tester) async {
|
||||
final response = Completer<ResolveResult>();
|
||||
final first = pending('first')
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
files: [
|
||||
FileInfo(name: 'video.mp4'),
|
||||
FileInfo(name: 'song.mp3'),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1]);
|
||||
final second = pending('failed');
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) => response.future);
|
||||
await open(tester, [first, second], registry);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
|
||||
await tester.pump();
|
||||
expect(typeFilterActive(tester, 'video'), isTrue);
|
||||
response.completeError(StateError('unavailable'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(first.selected, {0});
|
||||
expect(second.selected, isEmpty);
|
||||
expect(typeFilterActive(tester, 'video'), isTrue);
|
||||
expect(find.text('1 / 2 selected'), findsOneWidget);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('same-name sources synchronize individual, root and all-file selections', (tester) async {
|
||||
final sources = [
|
||||
for (final id in ['first', 'second'])
|
||||
pending(id)
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'a.txt'),
|
||||
FileInfo(name: 'b.txt'),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1]),
|
||||
];
|
||||
await open(tester, sources, CapabilityRegistry(createAppCapabilityCodecs()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, {1});
|
||||
expect(sources.last.selected, {0, 1});
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, {1});
|
||||
expect(sources.last.selected, isEmpty);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, isEmpty);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
|
||||
|
||||
final selectAll = find.descendant(of: find.byType(ResolveFileTree), matching: find.byType(shad.Checkbox)).first;
|
||||
await tester.tap(selectAll);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.every((source) => source.selected.length == 2 && source.selected.containsAll([0, 1])), isTrue);
|
||||
await tester.tap(selectAll);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.every((source) => source.selected.isEmpty), isTrue);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('cancel stops the queue and ignores the late response', (tester) async {
|
||||
final response = Completer<ResolveResult>();
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) {
|
||||
calls++;
|
||||
return response.future;
|
||||
});
|
||||
bool? closed;
|
||||
await open(tester, [pending('a'), pending('b')], registry, onClose: (value) => closed = value);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-close-button')));
|
||||
await tester.pumpAndSettle();
|
||||
response.complete(resolved('late.txt'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 1);
|
||||
expect(closed, isFalse);
|
||||
expect(find.text('late.txt'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('failed root is disabled and only ready selections enter a single batch', (tester) async {
|
||||
final batches = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
if (request.req!.url.endsWith('/bad')) throw StateError('unavailable');
|
||||
return resolved('ready.txt');
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
bool? closed;
|
||||
await open(tester, [pending('bad'), pending('good')], registry, onClose: (value) => closed = value);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('unavailable'), findsNothing);
|
||||
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message.contains('unavailable')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(batches.single.reqs!.single.rid, 'ready.txt');
|
||||
expect(closed, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
bool typeFilterActive(WidgetTester tester, String filter) {
|
||||
final decoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(ValueKey('resolve-tree-filter-$filter')),
|
||||
matching: find.byType(DecoratedBox),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
return decoration.color != null;
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:gopeed/core/icons/gopeed_icons.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/file_tree_view.dart';
|
||||
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
|
||||
PendingDownload source(String id) =>
|
||||
PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: 'https://example.com/$id'),
|
||||
options: Options(),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: '0.txt'),
|
||||
FileInfo(name: '1.txt'),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1]);
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('live mixed sources keep input order, selection and expansion at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final sources = [
|
||||
for (final id in ['http', 'group', 'ungrouped', 'bt', 'failed'])
|
||||
PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: 'https://example.com/$id'),
|
||||
options: Options(),
|
||||
),
|
||||
];
|
||||
final http = sources[0];
|
||||
final group = sources[1];
|
||||
final ungrouped = sources[2];
|
||||
final bt = sources[3];
|
||||
bt.request.url = 'magnet:?xt=urn:btih:infohash';
|
||||
Future<void> show() async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(
|
||||
sources: sources,
|
||||
onSelectionChanged: (keys) {
|
||||
for (final pending in sources) {
|
||||
pending.selected
|
||||
..clear()
|
||||
..addAll(keys.where((key) => key.$1 == pending.id).map((key) => key.$2));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
List<shad.TreeItemNode<FileTreeNode<PendingFileKey>>> roots() => tester
|
||||
.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
)
|
||||
.nodes
|
||||
.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
|
||||
void expectOrder(List<String> labels) => expect(roots().map((node) => node.data.label), labels);
|
||||
|
||||
await show();
|
||||
expectOrder(sources.map((pending) => pending.request.url).toList());
|
||||
http.result = ResolveResult(
|
||||
id: 'http-rid',
|
||||
res: Resource(files: [FileInfo(name: 'z-http.txt')]),
|
||||
);
|
||||
http.selected.add(0);
|
||||
await show();
|
||||
expectOrder(['z-http.txt', ...sources.skip(1).map((pending) => pending.request.url)]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:http:0')));
|
||||
await tester.pump();
|
||||
expect(http.selected, isEmpty);
|
||||
|
||||
group.result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'one.txt'),
|
||||
FileInfo(name: 'two.txt'),
|
||||
],
|
||||
),
|
||||
);
|
||||
group.selected.addAll([0, 1]);
|
||||
await show();
|
||||
expectOrder(['z-http.txt', 'bundle', ...sources.skip(2).map((pending) => pending.request.url)]);
|
||||
|
||||
ungrouped.result = ResolveResult(
|
||||
res: Resource(
|
||||
files: [
|
||||
FileInfo(name: 'z-first.txt'),
|
||||
FileInfo(name: 'nested.txt', path: 'directory'),
|
||||
FileInfo(name: 'a-last.txt'),
|
||||
],
|
||||
),
|
||||
);
|
||||
ungrouped.selected.addAll([0, 1, 2]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:group')));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await show();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(roots()[1].expanded, isFalse);
|
||||
expectOrder([
|
||||
'z-http.txt',
|
||||
'bundle',
|
||||
'z-first.txt',
|
||||
'directory',
|
||||
'a-last.txt',
|
||||
bt.request.url,
|
||||
sources.last.request.url,
|
||||
]);
|
||||
|
||||
bt.result = ResolveResult(
|
||||
id: 'bt-rid',
|
||||
res: Resource(
|
||||
name: 'torrent',
|
||||
files: [
|
||||
FileInfo(name: 'bt-one'),
|
||||
FileInfo(name: 'bt-two'),
|
||||
],
|
||||
),
|
||||
);
|
||||
bt.selected.addAll([0, 1]);
|
||||
await show();
|
||||
sources.last.resolveError = 'cannot resolve';
|
||||
await show();
|
||||
expectOrder([
|
||||
'z-http.txt',
|
||||
'bundle',
|
||||
'z-first.txt',
|
||||
'directory',
|
||||
'a-last.txt',
|
||||
'torrent',
|
||||
sources.last.request.url,
|
||||
]);
|
||||
expect(roots()[1].expanded, isFalse);
|
||||
expect(http.selected, isEmpty);
|
||||
expect(group.selected, {0, 1});
|
||||
expect(ungrouped.selected, {0, 1, 2});
|
||||
expect(bt.selected, {0, 1});
|
||||
expect(sources.last.selected, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('ED2K uses a file icon and filename while BT uses its resource name and folder icon', (tester) async {
|
||||
final ed2k =
|
||||
PendingDownload(
|
||||
id: 'ed2k',
|
||||
request: Request(url: 'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/'),
|
||||
options: Options(),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
id: 'ed2k-rid',
|
||||
res: Resource(
|
||||
hash: '0123456789ABCDEF0123456789ABCDEF',
|
||||
files: [FileInfo(name: 'payload.bin')],
|
||||
),
|
||||
)
|
||||
..selected.add(0);
|
||||
final bt =
|
||||
PendingDownload(
|
||||
id: 'bt',
|
||||
request: Request(url: 'magnet:?xt=urn:btih:infohash'),
|
||||
options: Options(),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
id: 'bt-rid',
|
||||
res: Resource(
|
||||
name: 'torrent resource',
|
||||
hash: 'infohash',
|
||||
files: [FileInfo(name: 'torrent-file.bin')],
|
||||
),
|
||||
)
|
||||
..selected.add(0);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [ed2k, bt], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('payload.bin'), findsOneWidget);
|
||||
expect(find.text('torrent resource'), findsOneWidget);
|
||||
expect(find.byIcon(GopeedIcons.folderBt), findsOneWidget);
|
||||
expect(find.text(ed2k.request.url), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final directory in [false, true]) {
|
||||
testWidgets('FTP resource ${directory ? 'directory' : 'file'} keeps its actual structure', (tester) async {
|
||||
final pending = source('ftp');
|
||||
pending.request.url = directory ? 'ftp://files.example/folder' : 'ftpes://files.example/file.torrent';
|
||||
pending.result = ResolveResult(
|
||||
id: 'ftp-rid',
|
||||
res: Resource(
|
||||
name: directory ? 'folder' : '',
|
||||
files: [FileInfo(name: 'file.torrent')],
|
||||
),
|
||||
);
|
||||
pending.selected
|
||||
..clear()
|
||||
..add(0);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(pending.isBt, isFalse);
|
||||
expect(pending.isGroup, isFalse);
|
||||
expect(pending.name, directory ? 'folder' : 'file.torrent');
|
||||
expect(find.text('folder'), directory ? findsOneWidget : findsNothing);
|
||||
expect(find.byIcon(shad.BootstrapIcons.folder2), directory ? findsOneWidget : findsNothing);
|
||||
expect(find.byIcon(GopeedIcons.folderBt), findsNothing);
|
||||
expect(find.text('file.torrent'), findsOneWidget);
|
||||
expect(planDownloads([pending]).single.request.reqs!.single.rid, 'ftp-rid');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final extension in [true, false]) {
|
||||
testWidgets('magnet root icon follows the resolved resource (extension=$extension)', (tester) async {
|
||||
final pending = source('magnet');
|
||||
pending.request.url = 'magnet:?xt=urn:btih:infohash';
|
||||
pending.result!.id = extension ? '' : 'bt-rid';
|
||||
for (final file in pending.files) {
|
||||
if (extension) file.req = Request(url: 'https://example.com/${file.name}');
|
||||
}
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(pending.isGroup, extension);
|
||||
expect(find.byIcon(GopeedIcons.folderBt), extension ? findsNothing : findsOneWidget);
|
||||
expect(find.byIcon(GopeedIcons.file), findsNWidgets(2));
|
||||
expect(find.byIcon(shad.BootstrapIcons.folder2), extension ? findsOneWidget : findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('ordinary single files replace URL placeholders without a source directory', (tester) async {
|
||||
final pending = PendingDownload(
|
||||
id: 'http',
|
||||
request: Request(url: 'https://example.com/file'),
|
||||
options: Options(),
|
||||
);
|
||||
Future<void> show() async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
await show();
|
||||
expect(find.text(pending.request.url), findsOneWidget);
|
||||
pending.result = ResolveResult(
|
||||
id: 'http-rid',
|
||||
res: Resource(
|
||||
name: '',
|
||||
files: [FileInfo(name: 'file.txt')],
|
||||
),
|
||||
);
|
||||
pending.selected.add(0);
|
||||
await show();
|
||||
expect(find.text('file.txt'), findsOneWidget);
|
||||
expect(find.text(pending.request.url), findsNothing);
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
);
|
||||
expect(tree.nodes, hasLength(1));
|
||||
expect(tree.nodes.single.children, isEmpty);
|
||||
expect((tree.nodes.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.item!.data, ('http', 0));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final filePath in ['', 'disc1']) {
|
||||
testWidgets('named single-file extension keeps its resource directory (path=$filePath)', (tester) async {
|
||||
final pending =
|
||||
PendingDownload(
|
||||
id: 'extension',
|
||||
request: Request(url: 'https://example.com/track.mp3'),
|
||||
options: Options(path: '/downloads'),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'album',
|
||||
files: [
|
||||
FileInfo(
|
||||
name: 'track.mp3',
|
||||
path: filePath,
|
||||
req: Request(url: 'https://cdn.example.com/track.mp3'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.add(0);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
);
|
||||
final roots = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
|
||||
expect(roots.map((node) => node.data.label), ['album']);
|
||||
expect(roots.single.expanded, isTrue);
|
||||
final child = roots.single.children.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>;
|
||||
expect(child.data.label, filePath.isEmpty ? 'track.mp3' : 'disc1');
|
||||
final plan = planDownloads([pending]).single.request;
|
||||
expect(plan.group, isFalse);
|
||||
expect(plan.reqs!.single.opts!.path, filePath.isEmpty ? '/downloads/album' : '/downloads/album/disc1');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('a live group replaces its URL placeholder with an expanded group directory', (tester) async {
|
||||
final pending = PendingDownload(
|
||||
id: 'group',
|
||||
request: Request(url: 'https://example.com/group'),
|
||||
options: Options(),
|
||||
);
|
||||
Future<void> show() async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
await show();
|
||||
pending.result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'one.txt'),
|
||||
FileInfo(name: 'two.txt'),
|
||||
],
|
||||
),
|
||||
);
|
||||
pending.selected.addAll([0, 1]);
|
||||
await show();
|
||||
expect(find.text('bundle'), findsOneWidget);
|
||||
expect(find.text('one.txt'), findsOneWidget);
|
||||
expect(find.text('two.txt'), findsOneWidget);
|
||||
expect(find.byIcon(shad.BootstrapIcons.folder2), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('ungrouped roots preserve relative directories and source selection at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final sources = [source('first'), source('second')];
|
||||
for (final pending in sources) {
|
||||
pending.result!.res.name = '';
|
||||
pending.files[0].name = 'same.txt';
|
||||
pending.files[1].name = 'video.mp4';
|
||||
pending.files[1].path = 'nested';
|
||||
}
|
||||
Set<PendingFileKey> selected = {};
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: sources, onSelectionChanged: (keys) => selected = keys),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
);
|
||||
expect(tree.nodes, hasLength(4));
|
||||
expect(tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.label), [
|
||||
'same.txt',
|
||||
'nested',
|
||||
'same.txt',
|
||||
'nested',
|
||||
]);
|
||||
expect(
|
||||
tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.key).toSet(),
|
||||
hasLength(4),
|
||||
);
|
||||
expect(find.text('nested'), findsNWidgets(2));
|
||||
expect(find.text('same.txt'), findsNWidgets(2));
|
||||
expect(find.text(sources.first.request.url), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
|
||||
final secondDirectoryKey = (tree.nodes.last as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.key;
|
||||
await tester.tap(find.byKey(ValueKey('resolve-tree-name-scroll-$secondDirectoryKey')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 0)});
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 1)});
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('same-name roots isolate file indexes and root selection at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final first = source('first');
|
||||
final second = source('second');
|
||||
Set<PendingFileKey> selected = {('first', 0), ('first', 1), ('second', 0), ('second', 1)};
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [first, second], onSelectionChanged: (keys) => selected = keys),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('bundle'), findsNWidgets(2));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1)});
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_delete_dialog.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('group deletion confirms affected downloads and files at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
bool? keepFiles;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Builder(
|
||||
builder: (context) => shad.PrimaryButton(
|
||||
onPressed: () async {
|
||||
keepFiles = await showTaskDeleteDialog(
|
||||
context,
|
||||
taskCount: 1,
|
||||
keepFiles: true,
|
||||
memberCount: 65,
|
||||
fileCount: 65,
|
||||
);
|
||||
},
|
||||
child: const Text('Open delete dialog'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open delete dialog'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Affects 65 downloads and 65 files.'), findsOneWidget);
|
||||
expect(keepFiles, isNull);
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(keepFiles, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
||||
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_browser_dialog.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
|
||||
api.Task raw(
|
||||
String id, {
|
||||
String parent = '',
|
||||
String status = 'running',
|
||||
String directory = '/downloads/folder',
|
||||
String name = 'file.txt',
|
||||
}) => api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (parent.isNotEmpty)
|
||||
'group': {
|
||||
'id': parent,
|
||||
'name': 'folder',
|
||||
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
'parentId': parent,
|
||||
'protocol': 'http',
|
||||
'status': status,
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/$id'},
|
||||
'opts': {'path': directory, 'name': name, 'size': 100},
|
||||
'res': {
|
||||
'size': 100,
|
||||
'files': [
|
||||
{'name': name, 'size': 100},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 10, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
|
||||
});
|
||||
TaskRecord grouped({String name = 'first.txt'}) =>
|
||||
taskRecordsFromApi([raw('child', parent: 'group', name: name)]).single;
|
||||
|
||||
class UpdatingTasks extends TasksController {
|
||||
@override
|
||||
Future<TasksState> build() async => TasksState(tasks: [grouped()]);
|
||||
void rename() => state = AsyncData(TasksState(tasks: [grouped(name: 'first (1).txt')]));
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
|
||||
testWidgets('tablet group expands its member tasks on $platform', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(1024, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [tasksControllerProvider.overrideWith(UpdatingTasks.new)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const HomePage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.tap(find.text('folder').first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(find.byType(TaskFileManagerView), findsNothing);
|
||||
expect(find.byType(TaskCard), findsNWidgets(2));
|
||||
expect(find.text('first.txt'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
test('Windows backend root and child path are rebased on a POSIX client', () {
|
||||
final record = taskRecordsFromApi([raw('child', parent: 'group', directory: r'C:\Downloads\folder\nested')]).single;
|
||||
expect(record.files.single.path, 'nested');
|
||||
expect(record.files.single.name, 'file.txt');
|
||||
});
|
||||
testWidgets('open group file browser updates renamed child from task polling', (tester) async {
|
||||
tester.view.physicalSize = const Size(1100, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
late UpdatingTasks controller;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [tasksControllerProvider.overrideWith(() => controller = UpdatingTasks())],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final tasks = ref.watch(tasksControllerProvider).value?.tasks;
|
||||
return shad.PrimaryButton(
|
||||
onPressed: tasks == null ? null : () => showTaskFileBrowserDialog(context, tasks.single),
|
||||
child: const Text('Open'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first.txt'), findsOneWidget);
|
||||
controller.rename();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first (1).txt'), findsOneWidget);
|
||||
});
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('a running group with paused members offers resume on its card at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final record = taskRecordsFromApi([
|
||||
raw('running', parent: 'group'),
|
||||
raw('paused', parent: 'group', status: 'pause'),
|
||||
]).single;
|
||||
expect(record.canResume, isTrue);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Center(
|
||||
child: TaskCard(
|
||||
task: record,
|
||||
selected: false,
|
||||
batchMode: false,
|
||||
selectedInBatch: false,
|
||||
onPressed: () {},
|
||||
onToggleBatch: () {},
|
||||
onPause: () {},
|
||||
onResume: () {},
|
||||
onDelete: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api_client;
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class _Transport implements GopeedTransport {
|
||||
@override
|
||||
String join(String path) => 'https://server.example$path';
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async => null;
|
||||
}
|
||||
|
||||
api.Task fixture(String id, {required String directory, String name = 'episode #2?.txt', bool folder = false}) =>
|
||||
api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': folder ? 'nested-torrent' : name,
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'parentId': 'group',
|
||||
'protocol': 'http',
|
||||
'status': 'done',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/file'},
|
||||
'opts': {'path': directory, 'name': folder ? 'nested-torrent' : name, 'size': 100},
|
||||
'res': {
|
||||
'name': folder ? 'nested-torrent' : '',
|
||||
'size': 100,
|
||||
'files': [
|
||||
{'name': name, 'path': folder ? 'subdir' : '', 'size': 100},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 100, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
void main() {
|
||||
for (final folder in [false, true]) {
|
||||
testWidgets('local group actions use member path folder=$folder', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
const channel = MethodChannel('dev.fluttercommunity.plus/share');
|
||||
List<dynamic>? shared;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||
shared = (call.arguments as Map)['paths'] as List<dynamic>;
|
||||
return 'dev.fluttercommunity.plus/share/unavailable';
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
|
||||
final memberRoot = path.style == path.Style.windows ? r'D:\Other' : '/elsewhere';
|
||||
final group = taskRecordsFromApi([fixture('child', directory: memberRoot, folder: folder)]).single;
|
||||
final expected = folder
|
||||
? path.join(memberRoot, 'nested-torrent', 'subdir', 'episode #2?.txt')
|
||||
: path.join(memberRoot, 'episode #2?.txt');
|
||||
expect(group.files.single.localPath, expected);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: TaskFileManagerView(task: group, webActions: false, desktopActions: false),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
var current = '';
|
||||
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
|
||||
current += '/$segment';
|
||||
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.tap(find.byIcon(Icons.share_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
expect(shared, [expected]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
for (final windows in [false, true]) {
|
||||
for (final legacy in [false, true]) {
|
||||
for (final folder in [false, true]) {
|
||||
testWidgets('member URL windows=$windows legacy=$legacy folder=$folder', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
api_client.setTransportForTesting(_Transport());
|
||||
String? launched;
|
||||
const channel = MethodChannel('plugins.flutter.io/url_launcher');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'launch') {
|
||||
launched = (call.arguments as Map)['url'] as String;
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
|
||||
final dir = windows ? r'C:\Downloads\folder\nested' : '/downloads/folder/nested';
|
||||
final group = taskRecordsFromApi([fixture('child #1', directory: dir, folder: folder)]).single;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: TaskFileManagerView(task: group, webActions: true, desktopActions: false),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
var current = '';
|
||||
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
|
||||
current += '/$segment';
|
||||
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.tap(find.byIcon(Icons.open_in_new));
|
||||
await tester.pumpAndSettle();
|
||||
final expected =
|
||||
'https://server.example/fs/tasks/child%20%231/${folder ? 'subdir/' : ''}episode%20%232%3F.txt';
|
||||
expect(launched, expected);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,72 @@ import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('group tree shows member progress and status at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final task = TaskRecord(
|
||||
id: 'group',
|
||||
name: 'folder',
|
||||
status: TaskStatus.downloading,
|
||||
downloaded: '150 B',
|
||||
total: '200 B',
|
||||
progress: 0.75,
|
||||
url: '',
|
||||
storagePath: '/downloads/folder',
|
||||
isGroup: true,
|
||||
isFolder: true,
|
||||
uploading: false,
|
||||
files: const [
|
||||
TaskFileNode(
|
||||
path: '',
|
||||
name: 'running.txt',
|
||||
sizeBytes: 100,
|
||||
downloadedBytes: 50,
|
||||
resourceIndex: 0,
|
||||
status: TaskStatus.downloading,
|
||||
),
|
||||
TaskFileNode(
|
||||
path: '',
|
||||
name: 'completed.txt',
|
||||
sizeBytes: 100,
|
||||
downloadedBytes: 100,
|
||||
resourceIndex: 1,
|
||||
status: TaskStatus.completed,
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TaskFileTree(task: task),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.widget<TaskProgressBar>(find.byKey(const ValueKey('task-files-total-progress'))).value, 0.75);
|
||||
final running = find.descendant(
|
||||
of: find.byKey(const ValueKey('task-file-progress-file:0')),
|
||||
matching: find.byType(shad.CircularProgressIndicator),
|
||||
);
|
||||
expect(tester.widget<shad.CircularProgressIndicator>(running).value, 0.5);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('task-file-progress-file:1')),
|
||||
matching: find.byIcon(shad.BootstrapIcons.checkCircleFill),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
|
||||
for (final scenario in [
|
||||
(protocol: api.Protocol.http, completed: false),
|
||||
(protocol: api.Protocol.http, completed: true),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart' show MaterialApp, Tooltip;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||
|
||||
@@ -16,6 +17,35 @@ void main() {
|
||||
expect(tester.widget<Tooltip>(find.byType(Tooltip)).message, 'Details');
|
||||
});
|
||||
|
||||
testWidgets('focusable status tooltip exposes its message on keyboard focus', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: AppTooltip(message: 'Waiting for resolution', focusable: true, child: SizedBox.square(dimension: 24)),
|
||||
),
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Waiting for resolution'), findsOneWidget);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Waiting for resolution'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('status tooltip can be read with a touch long press', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Center(
|
||||
child: AppTooltip(message: 'Resolution failed', focusable: true, child: SizedBox.square(dimension: 24)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.longPress(find.byType(AppTooltip));
|
||||
await tester.pump();
|
||||
expect(find.text('Resolution failed'), findsOneWidget);
|
||||
Tooltip.dismissAllToolTips();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
test('product source does not instantiate Tooltip directly', () {
|
||||
final violations = <String>[];
|
||||
final tooltipConstructor = RegExp(r'\b(?:\w+\.)?Tooltip\s*\(');
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.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;
|
||||
|
||||
void main() {
|
||||
testWidgets('clearing an explicit sort restores mixed file and directory input order', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: FileTreeView<int>(
|
||||
items: const [
|
||||
FileTreeItem(key: 'z', path: '', name: 'z.txt', size: 3, data: 0),
|
||||
FileTreeItem(key: 'nested', path: 'folder', name: 'child.txt', size: 2, data: 1),
|
||||
FileTreeItem(key: 'a', path: '', name: 'a.txt', size: 1, data: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
List<String> labels() => tester
|
||||
.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>))
|
||||
.nodes
|
||||
.cast<shad.TreeItemNode<FileTreeNode<int>>>()
|
||||
.map((node) => node.data.label)
|
||||
.toList();
|
||||
expect(labels(), ['z.txt', 'folder', 'a.txt']);
|
||||
final sort = find.byKey(const ValueKey('file-tree-sort-name-asc'));
|
||||
await tester.tap(sort);
|
||||
await tester.pump();
|
||||
expect(labels(), ['folder', 'a.txt', 'z.txt']);
|
||||
await tester.tap(sort);
|
||||
await tester.pump();
|
||||
expect(labels(), ['folder', 'z.txt', 'a.txt']);
|
||||
await tester.tap(sort);
|
||||
await tester.pump();
|
||||
expect(labels(), ['z.txt', 'folder', 'a.txt']);
|
||||
});
|
||||
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('long directory display labels remain readable at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final name = List.filled(12, 'long-resource-title').join('-');
|
||||
final nestedName = List.filled(14, 'nested-directory-title').join('-');
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: FileTreeView<int>(
|
||||
items: const [FileTreeItem(key: 'child', path: 'source-0/nested', name: 'a.txt', size: 10, data: 0)],
|
||||
directoryLabels: {'source-0': name, 'source-0/nested': nestedName},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
for (final label in [name, nestedName]) {
|
||||
final paragraph = tester.renderObject<RenderParagraph>(find.text(label));
|
||||
expect(paragraph.size.width, greaterThanOrEqualTo(paragraph.getMaxIntrinsicWidth(double.infinity)));
|
||||
}
|
||||
expect(find.byKey(const ValueKey('file-tree-horizontal-scrollbar')), findsOneWidget);
|
||||
final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0'));
|
||||
final before = tester.getTopLeft(find.text(name));
|
||||
final trailing = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0')));
|
||||
await tester.drag(root, const Offset(-120, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(find.text(name)).dx, lessThan(before.dx));
|
||||
expect(tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))), trailing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async {
|
||||
late StateSetter update;
|
||||
var items = [
|
||||
const FileTreeItem(key: 'root', path: 'root', name: 'pending', size: 0, data: -1, directoryOnly: true),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
update = setState;
|
||||
return FileTreeView<int>(
|
||||
items: items,
|
||||
directoryLabels: const {'root': 'Source'},
|
||||
initialExpandedDepth: 1,
|
||||
iconWidgetBuilder: (_, node) => node.isFolder ? const Text('status') : const Text('file'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Source'), findsOneWidget);
|
||||
expect(find.text('pending'), findsNothing);
|
||||
expect(find.text('status'), findsOneWidget);
|
||||
update(() => items = [const FileTreeItem(key: 'child', path: 'root', name: 'payload.txt', size: 12, data: 0)]);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Source'), findsOneWidget);
|
||||
expect(find.text('payload.txt'), findsOneWidget);
|
||||
expect(find.text('file'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:root')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('payload.txt'), findsNothing);
|
||||
expect(find.text('Source'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('live tree updates preserve a collapse requested during its animation', (tester) async {
|
||||
late StateSetter update;
|
||||
var items = const [
|
||||
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
|
||||
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
|
||||
FileTreeItem(key: 'pending', path: 'source-b', name: 'source B', size: 0, data: 2, directoryOnly: true),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
update = setState;
|
||||
return FileTreeView<int>(
|
||||
items: items,
|
||||
directoryLabels: const {'bundle': 'bundle', 'source-b': 'source B'},
|
||||
initialExpandedDepth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:bundle')));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
update(() {
|
||||
items = const [
|
||||
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
|
||||
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
|
||||
FileTreeItem(key: 'arrived', path: '', name: 'later.txt', size: 1, data: 3),
|
||||
];
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>));
|
||||
final bundle = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<int>>>().first;
|
||||
expect(bundle.data.label, 'bundle');
|
||||
expect(bundle.expanded, isFalse);
|
||||
expect(find.text('later.txt'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
import 'package:gopeed/api/model/options.dart' as preview_options;
|
||||
import 'package:gopeed/api/model/request.dart' as preview_request;
|
||||
import 'package:gopeed/api/model/resolve_result.dart' as preview_resolve;
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -30,6 +34,7 @@ import 'package:gopeed/app/application/app_notification_controller.dart';
|
||||
import 'package:gopeed/app/application/app_platform_controller.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/install_extension.dart';
|
||||
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
@@ -128,9 +133,9 @@ void main() {
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTask, (_) {
|
||||
..bind(GopeedMethods.createTaskBatch, (_) {
|
||||
created++;
|
||||
return 'new-task';
|
||||
return CreateTaskBatchResult(taskIds: ['new-task']);
|
||||
});
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
@@ -3329,7 +3334,7 @@ void main() {
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(
|
||||
home: _singleResolveTree(
|
||||
files: [
|
||||
FileInfo(path: '', name: 'root.txt', size: 1),
|
||||
FileInfo(path: 'folder', name: 'first.txt', size: 2),
|
||||
@@ -3349,7 +3354,7 @@ void main() {
|
||||
expect(find.text('third.txt'), findsNothing);
|
||||
expect(selected, [0, 1, 2, 3]);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:folder/nested')));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/folder/nested')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('second.txt'), findsOneWidget);
|
||||
expect(find.text('deeper'), findsOneWidget);
|
||||
@@ -3362,7 +3367,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first.txt'), findsNothing);
|
||||
expect(find.text('root.txt'), findsOneWidget);
|
||||
expect(find.text('root.txt'), findsNothing);
|
||||
expect(selected, [0, 1, 2, 3]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
@@ -3376,7 +3381,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [
|
||||
FileInfo(path: 'videos', name: 'clip.mp4', size: 0),
|
||||
FileInfo(path: 'docs', name: 'notes.txt', size: 0),
|
||||
@@ -3446,30 +3451,30 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('clip.mp4'), findsOneWidget);
|
||||
|
||||
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:videos'));
|
||||
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/videos'));
|
||||
expect(videosExpand, findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:0')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:source:0')), findsOneWidget);
|
||||
|
||||
await tester.tap(videosExpand);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
|
||||
expect(find.text('clip.mp4').hitTestable(), findsNothing);
|
||||
expect(find.text('videos').hitTestable(), findsOneWidget);
|
||||
expect(selected, [0, 1, 2]);
|
||||
|
||||
await tester.tap(find.text('videos'));
|
||||
await tester.pump();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
|
||||
expect(selected, [1, 2]);
|
||||
|
||||
await tester.tap(find.text('videos'));
|
||||
await tester.pump();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
|
||||
expect(selected, [0, 1, 2]);
|
||||
|
||||
await tester.tap(videosExpand);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isTrue);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isTrue);
|
||||
expect(find.text('clip.mp4'), findsOneWidget);
|
||||
|
||||
BoxDecoration sortDecoration(String key) {
|
||||
@@ -3520,7 +3525,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [
|
||||
FileInfo(path: '', name: 'clip.mp4', size: 100),
|
||||
FileInfo(path: '', name: 'song.mp3', size: 200),
|
||||
@@ -3569,7 +3574,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [FileInfo(name: longName, size: 1024)],
|
||||
initialSelection: const [0],
|
||||
onSelectionChanged: (_) {},
|
||||
@@ -3580,7 +3585,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final horizontalScroll = find.byKey(const ValueKey('resolve-tree-horizontal-scroll'));
|
||||
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:0'));
|
||||
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:source:0'));
|
||||
expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsOneWidget);
|
||||
expect(horizontalScroll, findsOneWidget);
|
||||
expect(trailing, findsOneWidget);
|
||||
@@ -3600,7 +3605,7 @@ void main() {
|
||||
|
||||
position.jumpTo(0);
|
||||
await tester.pumpAndSettle();
|
||||
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:0'));
|
||||
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:source:0'));
|
||||
await tester.drag(nameArea, const Offset(-100, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(position.pixels, greaterThan(0));
|
||||
@@ -3622,7 +3627,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [FileInfo(name: shortName, size: 1024)],
|
||||
initialSelection: const [0],
|
||||
onSelectionChanged: (_) {},
|
||||
@@ -3650,7 +3655,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
|
||||
child: _singleResolveTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -6402,3 +6407,25 @@ api_task.Task _apiTransferTask({
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _singleResolveTree({
|
||||
required List<FileInfo> files,
|
||||
required List<int> initialSelection,
|
||||
required ValueChanged<List<int>> onSelectionChanged,
|
||||
}) {
|
||||
final source =
|
||||
PendingDownload(
|
||||
id: 'source',
|
||||
request: preview_request.Request(url: 'https://example.com'),
|
||||
options: preview_options.Options(),
|
||||
)
|
||||
..result = preview_resolve.ResolveResult(
|
||||
res: Resource(name: 'source', files: files),
|
||||
)
|
||||
..selected.addAll(initialSelection);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onSelectionChanged(initialSelection));
|
||||
return ResolveFileTree(
|
||||
sources: [source],
|
||||
onSelectionChanged: (keys) => onSelectionChanged(keys.map((key) => key.$2).toList()..sort()),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user