feat(downloads): add details controls and aggregate summaries

This commit is contained in:
NimBold
2026-07-23 02:31:34 +03:30
parent 9d737598c6
commit f4e5e211cc
13 changed files with 480 additions and 1 deletions
+13
View File
@@ -3,6 +3,7 @@ import {
canPauseDownload,
canRedownload,
canStartDownload,
getPauseResumeAction,
isIdentityLocked,
isTransferLocked,
startActionLabel,
@@ -27,6 +28,18 @@ describe('download action policy', () => {
expect(canRedownload('downloading')).toBe(false);
});
it('only exposes pause or resume for the details-view toggle', () => {
expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume');
for (const status of ['ready', 'staged', 'completed', 'failed'] as const) {
expect(getPauseResumeAction(status)).toBeNull();
}
});
it('provides consistent labels and edit locks', () => {
expect(startActionLabel('ready')).toBe('Start');
expect(startActionLabel('failed')).toBe('Start');
+8
View File
@@ -26,6 +26,14 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
export const canPauseDownload = (status: DownloadStatus): boolean =>
PAUSABLE_STATUSES.has(status);
export type PauseResumeAction = 'pause' | 'resume';
export const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {
if (canPauseDownload(status)) return 'pause';
if (status === 'paused') return 'resume';
return null;
};
export const canRedownload = (status: DownloadStatus): boolean =>
REDOWNLOADABLE_STATUSES.has(status);
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { summarizeDownloads } from './downloadSummary';
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
id,
url: `https://example.com/${id}`,
fileName: `${id}.bin`,
status: 'ready',
category: 'Other',
dateAdded: '2026-01-01T00:00:00.000Z',
...overrides,
});
const progress = (id: string, overrides: Partial<DownloadProgressEvent> = {}): DownloadProgressEvent => ({
id,
fraction: 0.5,
speed: '1 MiB/s',
eta: '1m',
size: null,
size_is_final: false,
...overrides,
});
describe('download summaries', () => {
it('aggregates exact bytes and transfer-active counts', () => {
expect(summarizeDownloads([
item('active', { status: 'downloading', downloadedBytes: 1024, totalBytes: 4096 }),
item('done', { status: 'completed', totalBytes: 2048 }),
])).toEqual({
itemCount: 2,
activeCount: 1,
downloadedBytes: 3072,
remainingBytes: 3072,
remainingIsEstimated: false,
});
});
it('does not present partial byte totals as complete aggregates', () => {
expect(summarizeDownloads([
item('known', { totalBytes: 100, downloadedBytes: 20 }),
item('unknown', { status: 'failed' }),
])).toMatchObject({
itemCount: 2,
downloadedBytes: null,
remainingBytes: null,
});
});
it('infers zero bytes for an unstarted paused item but preserves partial unknowns', () => {
expect(summarizeDownloads([
item('paused', { status: 'paused', totalBytes: 1000 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1000,
remainingIsEstimated: false,
});
expect(summarizeDownloads([
item('partial', { status: 'paused', fraction: 0.4, totalBytes: 1000 }),
]).downloadedBytes).toBeNull();
});
it('treats a fresh media item as having no downloaded bytes before its first progress event', () => {
expect(summarizeDownloads([
item('media', {
status: 'ready',
isMedia: true,
totalBytes: 1024,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1024,
remainingIsEstimated: true,
});
});
it('keeps non-final media progress on the stored estimated denominator', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
size: '~2 MB',
}),
], {
media: progress('media', {
downloaded_bytes: 512 * 1024,
total_bytes: 1024,
total_is_estimate: true,
}),
})).toEqual({
itemCount: 1,
activeCount: 1,
downloadedBytes: 512 * 1024,
remainingBytes: 2 * 1024 ** 2 - 512 * 1024,
remainingIsEstimated: true,
});
});
it('does not claim zero remaining when an estimate is already below observed bytes', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
downloadedBytes: 150,
totalBytes: 100,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 150,
remainingBytes: null,
remainingIsEstimated: false,
});
});
it('uses a final media progress total when one is available', () => {
expect(summarizeDownloads([
item('media', {
status: 'processing',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
totalIsEstimate: true,
}),
], {
media: progress('media', {
downloaded_bytes: 3 * 1024 ** 2,
total_bytes: 3 * 1024 ** 2,
size_is_final: true,
total_is_estimate: false,
}),
})).toMatchObject({
downloadedBytes: 3 * 1024 ** 2,
remainingBytes: 0,
remainingIsEstimated: false,
});
});
it('does not overflow aggregate byte counters', () => {
const huge = Number.MAX_VALUE;
expect(summarizeDownloads([
item('one', { totalBytes: huge, downloadedBytes: huge }),
item('two', { totalBytes: huge, downloadedBytes: huge }),
])).toMatchObject({
downloadedBytes: null,
remainingBytes: 0,
});
expect(summarizeDownloads([
item('three', { totalBytes: huge, downloadedBytes: 0 }),
item('four', { totalBytes: huge, downloadedBytes: 0 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: null,
});
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { isTransferActiveStatus } from './downloads';
export interface DownloadSummary {
itemCount: number;
activeCount: number;
downloadedBytes: number | null;
remainingBytes: number | null;
remainingIsEstimated: boolean;
}
type ProgressMap = Readonly<Record<string, DownloadProgressEvent | undefined>>;
const usableBytes = (value: number | null | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'ready' ||
status === 'staged' ||
status === 'queued' ||
status === 'downloading' ||
status === 'processing' ||
status === 'retrying';
const hasPositiveProgress = (download: DownloadItem): boolean =>
typeof download.fraction === 'number' &&
Number.isFinite(download.fraction) &&
download.fraction > 0;
const canInferNoDownloadedBytes = (download: DownloadItem): boolean =>
!hasPositiveProgress(download) &&
(isFreshDownloadStatus(download.status) || download.status === 'paused');
const effectiveByteState = (
download: DownloadItem,
progress: DownloadProgressEvent | undefined
): { downloadedBytes?: number; totalBytes?: number; totalIsEstimate: boolean } => {
const usesStoredMediaTotal = download.isMedia === true && progress && !progress.size_is_final;
const storedTotalIsEstimate = download.totalIsEstimate === true ||
download.size?.trim().startsWith('~') === true;
const totalBytes = usesStoredMediaTotal
? usableBytes(download.totalBytes)
: usableBytes(progress?.total_bytes) ?? usableBytes(download.totalBytes);
const downloadedBytes =
usableBytes(progress?.downloaded_bytes) ??
usableBytes(download.downloadedBytes) ??
(download.status === 'completed' ? totalBytes : undefined) ??
(canInferNoDownloadedBytes(download) ? 0 : undefined);
const totalIsEstimate = usesStoredMediaTotal
? storedTotalIsEstimate
: (progress?.total_is_estimate ?? storedTotalIsEstimate) === true;
return { downloadedBytes, totalBytes, totalIsEstimate };
};
export const summarizeDownloads = (
downloads: readonly DownloadItem[],
progressMap: ProgressMap = {}
): DownloadSummary => {
if (downloads.length === 0) {
return {
itemCount: 0,
activeCount: 0,
downloadedBytes: null,
remainingBytes: null,
remainingIsEstimated: false,
};
}
let downloadedBytes = 0;
let remainingBytes = 0;
let downloadedKnown = true;
let remainingKnown = true;
let remainingIsEstimated = false;
let activeCount = 0;
for (const download of downloads) {
const state = effectiveByteState(download, progressMap[download.id]);
if (isTransferActiveStatus(download.status)) activeCount += 1;
if (state.downloadedBytes === undefined) {
downloadedKnown = false;
} else {
const nextDownloadedBytes = downloadedBytes + state.downloadedBytes;
if (Number.isFinite(nextDownloadedBytes)) {
downloadedBytes = nextDownloadedBytes;
} else {
downloadedKnown = false;
}
}
if (
state.totalBytes === undefined ||
state.downloadedBytes === undefined ||
state.downloadedBytes > state.totalBytes
) {
remainingKnown = false;
} else {
const nextRemainingBytes = remainingBytes + Math.max(0, state.totalBytes - state.downloadedBytes);
if (Number.isFinite(nextRemainingBytes)) {
remainingBytes = nextRemainingBytes;
remainingIsEstimated ||= state.totalIsEstimate;
} else {
remainingKnown = false;
}
}
}
return {
itemCount: downloads.length,
activeCount,
downloadedBytes: downloadedKnown ? downloadedBytes : null,
remainingBytes: remainingKnown ? remainingBytes : null,
remainingIsEstimated,
};
};