mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
feat(http): support advisory extension download sizes (#1599)
This commit is contained in:
17 files changed
+1047
-182
No files matched your search
@@ -4,13 +4,22 @@ part 'options.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class Options {
|
||||
/// HTTP-only size estimate, superseded by HTTP metadata or completion.
|
||||
int size;
|
||||
String name;
|
||||
String path;
|
||||
bool asDefaultPath;
|
||||
List<int> selectFiles;
|
||||
Object? extra;
|
||||
|
||||
Options({this.name = '', this.path = '', this.asDefaultPath = false, this.selectFiles = const [], this.extra});
|
||||
Options({
|
||||
this.size = 0,
|
||||
this.name = '',
|
||||
this.path = '',
|
||||
this.asDefaultPath = false,
|
||||
this.selectFiles = const [],
|
||||
this.extra,
|
||||
});
|
||||
|
||||
factory Options.fromJson(Map<String, dynamic> json) => _$OptionsFromJson(json);
|
||||
|
||||
@@ -22,17 +31,14 @@ class ChecksumOption {
|
||||
String algorithm;
|
||||
String expected;
|
||||
|
||||
ChecksumOption({
|
||||
this.algorithm = '',
|
||||
this.expected = '',
|
||||
});
|
||||
ChecksumOption({this.algorithm = '', this.expected = ''});
|
||||
|
||||
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class OptsExtraHttp {
|
||||
int connections;
|
||||
bool? autoTorrent;
|
||||
|
||||
@@ -7,14 +7,20 @@ part of 'options.dart';
|
||||
// **************************************************************************
|
||||
|
||||
Options _$OptionsFromJson(Map<String, dynamic> json) => Options(
|
||||
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||
name: json['name'] as String? ?? '',
|
||||
path: json['path'] as String? ?? '',
|
||||
asDefaultPath: json['asDefaultPath'] as bool? ?? false,
|
||||
selectFiles: (json['selectFiles'] as List<dynamic>?)?.map((e) => (e as num).toInt()).toList() ?? const [],
|
||||
selectFiles:
|
||||
(json['selectFiles'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList() ??
|
||||
const [],
|
||||
extra: json['extra'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
||||
'size': instance.size,
|
||||
'name': instance.name,
|
||||
'path': instance.path,
|
||||
'asDefaultPath': instance.asDefaultPath,
|
||||
@@ -22,34 +28,38 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
||||
'extra': ?instance.extra,
|
||||
};
|
||||
|
||||
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
|
||||
algorithm: json['algorithm'] as String? ?? '',
|
||||
expected: json['expected'] as String? ?? '',
|
||||
);
|
||||
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) =>
|
||||
ChecksumOption(
|
||||
algorithm: json['algorithm'] as String? ?? '',
|
||||
expected: json['expected'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
|
||||
'algorithm': instance.algorithm,
|
||||
'expected': instance.expected,
|
||||
};
|
||||
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) =>
|
||||
<String, dynamic>{
|
||||
'algorithm': instance.algorithm,
|
||||
'expected': instance.expected,
|
||||
};
|
||||
|
||||
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
autoTorrent: json['autoTorrent'] as bool?,
|
||||
deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?,
|
||||
autoExtract: json['autoExtract'] as bool?,
|
||||
archivePassword: json['archivePassword'] as String? ?? '',
|
||||
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
||||
checksum: json['checksum'] == null
|
||||
? null
|
||||
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
|
||||
);
|
||||
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) =>
|
||||
OptsExtraHttp(
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
autoTorrent: json['autoTorrent'] as bool?,
|
||||
deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?,
|
||||
autoExtract: json['autoExtract'] as bool?,
|
||||
archivePassword: json['archivePassword'] as String? ?? '',
|
||||
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
||||
checksum: json['checksum'] == null
|
||||
? null
|
||||
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
|
||||
'connections': instance.connections,
|
||||
'autoTorrent': ?instance.autoTorrent,
|
||||
'deleteTorrentAfterDownload': ?instance.deleteTorrentAfterDownload,
|
||||
'autoExtract': ?instance.autoExtract,
|
||||
'archivePassword': instance.archivePassword,
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
'checksum': ?instance.checksum?.toJson(),
|
||||
};
|
||||
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) =>
|
||||
<String, dynamic>{
|
||||
'connections': instance.connections,
|
||||
'autoTorrent': ?instance.autoTorrent,
|
||||
'deleteTorrentAfterDownload': ?instance.deleteTorrentAfterDownload,
|
||||
'autoExtract': ?instance.autoExtract,
|
||||
'archivePassword': instance.archivePassword,
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
'checksum': ?instance.checksum?.toJson(),
|
||||
};
|
||||
@@ -126,7 +126,7 @@ class TaskRecord {
|
||||
IconData get icon => taskFileTypeIcon(name, isFolder: isFolder, protocol: protocol);
|
||||
|
||||
factory TaskRecord.fromApi(api_task.Task task) {
|
||||
final totalBytes = task.meta.res?.size ?? 0;
|
||||
final totalBytes = task.meta.res?.size ?? task.meta.opts.size;
|
||||
final downloadedBytes = task.progress.downloaded;
|
||||
final progress = totalBytes > 0 ? (downloadedBytes / totalBytes).clamp(0.0, 1.0) : null;
|
||||
final status = _statusFromApi(task.status);
|
||||
|
||||
@@ -72,6 +72,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
String _httpMethod = 'GET';
|
||||
String _httpBody = '';
|
||||
String? _initialRawUrl;
|
||||
int _initialSize = 0;
|
||||
Map<String, String>? _initialLabels;
|
||||
RequestProxyMode _proxyMode = RequestProxyMode.follow;
|
||||
String _proxyScheme = 'http';
|
||||
@@ -182,6 +183,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
void _handleUrlChanged() {
|
||||
final urlText = _urlController.text;
|
||||
if (urlText != _lastUrlText) {
|
||||
_initialSize = 0;
|
||||
_lastUrlText = urlText;
|
||||
_renameController.clear();
|
||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||
@@ -200,6 +202,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final req = task.req;
|
||||
final opts = task.opts;
|
||||
setState(() {
|
||||
_initialSize = 0;
|
||||
_checksumAlgorithm = 'md5';
|
||||
_checksumHashController.clear();
|
||||
if (req?.url.isNotEmpty == true) {
|
||||
@@ -239,6 +242,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
}
|
||||
if (opts != null) {
|
||||
_initialSize = opts.size;
|
||||
if (opts.path.isNotEmpty) {
|
||||
_directoryController.text = opts.path;
|
||||
}
|
||||
@@ -1084,15 +1088,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
|
||||
final urls = _inputUrls();
|
||||
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
|
||||
? api_options.ChecksumOption(
|
||||
algorithm: _checksumAlgorithm,
|
||||
expected: checksumExpected,
|
||||
)
|
||||
? api_options.ChecksumOption(algorithm: _checksumAlgorithm, expected: checksumExpected)
|
||||
: null;
|
||||
return api_options.Options(
|
||||
size: urls.length == 1 && _parseProtocol(_protocolUrlFor(urls.first)) == _TaskProtocol.http ? _initialSize : 0,
|
||||
name: name ?? _renameController.text.trim(),
|
||||
path: _directoryController.text.trim(),
|
||||
asDefaultPath: _asDefaultPath,
|
||||
@@ -1135,16 +1138,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
if (result.res.files.isNotEmpty) {
|
||||
final reqs = selected.map((index) {
|
||||
final file = result.res.files[index];
|
||||
final fileRequest = file.req ?? request;
|
||||
return CreateTaskBatchItem(
|
||||
req: file.req ?? request,
|
||||
req: fileRequest,
|
||||
opts: api_options.Options(
|
||||
size: _parseProtocol(fileRequest.url) == _TaskProtocol.http ? file.size : 0,
|
||||
name: file.name,
|
||||
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
||||
extra: _buildOptions(omitChecksum: isMultiFile).extra,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
|
||||
await ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTaskBatch(
|
||||
CreateTaskBatch(
|
||||
reqs: reqs,
|
||||
opts: _buildOptions(omitChecksum: isMultiFile),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -236,7 +236,6 @@ class _FileDownloadProgress extends StatelessWidget {
|
||||
final sizeKnown = selectedFiles.every((file) => file.size > 0);
|
||||
final size = sizeKnown ? selectedFiles.fold<int>(0, (total, file) => total + file.size) : 0;
|
||||
final progress = size > 0 ? (downloaded / size).clamp(0.0, 1.0) : null;
|
||||
final completed = progress != null && progress >= 1;
|
||||
final progressLabel = progress == null ? Util.fmtByte(downloaded) : '${(progress * 100).toStringAsFixed(0)}%';
|
||||
|
||||
return SizedBox(
|
||||
@@ -244,16 +243,14 @@ class _FileDownloadProgress extends StatelessWidget {
|
||||
width: 116,
|
||||
child: _TaskFileColumns(
|
||||
progress: Semantics(
|
||||
label: completed ? context.l10n.completed : progressLabel,
|
||||
child: completed
|
||||
? Icon(shad.BootstrapIcons.checkCircleFill, size: 16, color: palette.brandProgress)
|
||||
: shad.CircularProgressIndicator(
|
||||
value: progress ?? (downloaded > 0 ? null : 0),
|
||||
size: 16,
|
||||
strokeWidth: 2,
|
||||
color: palette.brandProgress,
|
||||
backgroundColor: palette.progressTrack,
|
||||
),
|
||||
label: progressLabel,
|
||||
child: shad.CircularProgressIndicator(
|
||||
value: progress ?? (downloaded > 0 ? null : 0),
|
||||
size: 16,
|
||||
strokeWidth: 2,
|
||||
color: palette.brandProgress,
|
||||
backgroundColor: palette.progressTrack,
|
||||
),
|
||||
),
|
||||
size: _formatFileSize(node.size),
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user