feat(http): support advisory extension download sizes (#1599)

This commit is contained in:
Levi authored and GitHub committed 2026-10-22 23:35:01 +08:00
1 parent 4214765d39
commit d125d4ab75
17 files changed
+1047 -182

No files matched your search

@@ -44,6 +44,30 @@ void main() {
expect(record.totalBytes, 60);
});
test('underestimated HTTP size caps progress without completing the task', () {
final task = buildTask([], 3)..protocol = api.Protocol.http;
task.progress.speed = 2;
final record = TaskRecord.fromApi(task);
expect(record.progress, 1);
expect(record.status, TaskStatus.downloading);
expect(record.downloadedBytes, 10);
expect(record.totalBytes, 3);
expect(record.remainingSeconds, isNull);
expect(const TaskFileNode(path: '', name: 'video.mp4', sizeBytes: 3, downloadedBytes: 10).progress, 1);
});
test('queued task uses the creation estimate until a resolved size is available', () {
final task = buildTask([], 0)..protocol = api.Protocol.http;
task.meta.opts.size = 1024;
task.meta.res = null;
task.status = api.Status.wait;
expect(TaskRecord.fromApi(task).totalBytes, 1024);
task.meta.res = buildTask([], 0).meta.res;
expect(TaskRecord.fromApi(task).totalBytes, isNull); // A confirmed zero must not fall back to 1024.
task.meta.res!.size = 512;
expect(TaskRecord.fromApi(task).totalBytes, 512);
});
test('completion time maps from api and stays null for legacy tasks', () {
final completed = TaskRecord.fromApi(buildTask([], 60, doneAt: '2026-01-02T03:04:05Z'));
expect(completed.doneAt, DateTime.parse('2026-01-02T03:04:05Z'));
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart';
@@ -8,6 +10,7 @@ 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.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
@@ -30,6 +33,73 @@ 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 {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: '',
res: Resource(
files: [
FileInfo(
name: 'under.mp4',
size: 3,
req: Request(url: 'https://example.com/video'),
),
FileInfo(
name: 'over.mp4',
size: 1048576,
req: Request(url: 'https://example.com/audio'),
),
],
),
),
)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = CreateTaskBatch.fromJson(jsonDecode(jsonEncode(batch)) as Map<String, dynamic>);
return ['first', 'second'];
})
..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();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/watch');
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(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(tester.takeException(), isNull);
});
for (final navigationFails in [false, true]) {
testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async {
tester.view.physicalSize = const Size(1024, 720);
@@ -264,6 +334,7 @@ void main() {
skipVerifyCert: true,
),
opts: Options(
size: 1234,
name: 'archive.zip',
path: 'D:/Downloads',
extra: OptsExtraHttp(
@@ -457,6 +528,7 @@ void main() {
expect(request?.skipVerifyCert, isTrue);
expect(ReqExtraHttp.fromJson(request?.extra! as Map<String, dynamic>).toJson(), initialTask.req?.extra);
expect(submitted?.opts?.path, 'G:/Manually selected');
expect(submitted?.opts?.size, 1234);
final options = OptsExtraHttp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
expect(options.connections, 12);
expect(options.autoTorrent, isTrue);
@@ -676,7 +748,10 @@ void main() {
// Expand advanced
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-checksum-expected'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
expect(
_fieldText(tester, 'create-task-checksum-expected'),
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
);
// Task B form 1: null opts
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB1.zip')));
@@ -685,7 +760,12 @@ void main() {
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
// Task B form 2: empty Options
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB2.zip'), opts: Options()));
pending.set(
CreateTask(
req: Request(url: 'https://example.com/taskB2.zip'),
opts: Options(),
),
);
router.go('/create');
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
@@ -804,7 +884,10 @@ void main() {
submitted = null;
await tester.tap(find.text('MD5'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), '0123456789ABCDEF0123456789ABCDEF');
await tester.enterText(
find.byKey(const ValueKey('create-task-checksum-expected')),
'0123456789ABCDEF0123456789ABCDEF',
);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNotNull);
@@ -821,7 +904,10 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('SHA-1'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
await tester.enterText(
find.byKey(const ValueKey('create-task-checksum-expected')),
'da39a3ee5e6b4b0d3255bfef95601890afd80709',
);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNotNull);
@@ -838,7 +924,10 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('SHA-256'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
await tester.enterText(
find.byKey(const ValueKey('create-task-checksum-expected')),
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNotNull);
@@ -0,0 +1,75 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_tree.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_progress_bar.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final scenario in [
(protocol: api.Protocol.http, completed: false),
(protocol: api.Protocol.http, completed: true),
(protocol: api.Protocol.bt, completed: false),
]) {
testWidgets('file progress stays bounded and respects completion state: $scenario', (tester) async {
tester.view.physicalSize = const Size(520, 420);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final total = scenario.completed ? 10 : 3;
final task = TaskRecord(
id: 'estimated-size',
name: 'video.mp4',
protocol: scenario.protocol,
status: scenario.completed ? TaskStatus.completed : TaskStatus.downloading,
downloaded: '10 B',
total: '$total B',
progress: 1,
url: 'https://example.com/video',
storagePath: '/downloads/video.mp4',
files: [TaskFileNode(path: '', name: 'video.mp4', sizeBytes: total, resourceIndex: 0)],
uploading: false,
);
final runtime = api.TaskRuntimeStatus(
status: scenario.completed ? api.Status.done : api.Status.running,
used: 0,
speed: 2,
downloaded: 10,
total: total,
uploadSpeed: 0,
uploaded: 0,
files: [api.FileRuntimeStatus(index: 0, size: total, downloaded: 10)],
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: TaskFileTree(task: task, runtimeStatus: runtime),
),
),
);
await tester.pump();
final progress = tester.widget<TaskProgressBar>(find.byKey(const ValueKey('task-files-total-progress')));
expect(progress.value, 1);
expect(find.text('100%'), findsOneWidget);
expect(find.text('333%'), findsNothing);
final file = find.byKey(const ValueKey('task-file-progress-file:0'));
final check = find.descendant(of: file, matching: find.byIcon(shad.BootstrapIcons.checkCircleFill));
final ring = find.descendant(of: file, matching: find.byType(shad.CircularProgressIndicator));
if (!scenario.completed) {
expect(check, findsNothing);
expect(ring, findsOneWidget);
expect(tester.widget<shad.CircularProgressIndicator>(ring).value, 1);
} else {
expect(check, findsOneWidget);
expect(ring, findsNothing);
}
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
}