feat(tasks): group extension folder downloads (#1594)

This commit is contained in:
WuFeng authored and GitHub committed 2026-11-02 15:39:08 +08:00
1 parent fc0a1e0ef9
commit 1e36599d52
84 files changed
+6690 -881

No files matched your search

@@ -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},
});