mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 02:40:21 +00:00
fix(properties): harden progress and torrent diagnostics
This commit is contained in:
+48
-17
@@ -8494,6 +8494,8 @@ async fn verify_torrent_data(
|
||||
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||
crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)?;
|
||||
let restore_status = item.status.as_str().to_string();
|
||||
let restore_status_value = item.status;
|
||||
let restore_has_been_dispatched = item.has_been_dispatched;
|
||||
let destination = verification_destination(&app_handle, &item)?;
|
||||
if matches!(item.status, crate::ipc::DownloadStatus::Paused) {
|
||||
detach_download_for_reconfigure_locked(
|
||||
@@ -8508,6 +8510,11 @@ async fn verify_torrent_data(
|
||||
{
|
||||
return Err("Torrent lifecycle changed; pause it before verifying its data".to_string());
|
||||
}
|
||||
// Allocate the internally-created generation after detaching any retained
|
||||
// paused lifecycle, so the value includes every cancellation watermark
|
||||
// observed during that transition. Reservation still remains the atomic
|
||||
// acceptance point immediately before ownership registration.
|
||||
let verification_generation = state.queue_manager.next_enqueue_generation(&id).await?;
|
||||
let enqueue_item = queue::EnqueueItem {
|
||||
id: item.id.clone(),
|
||||
queue_id: item
|
||||
@@ -8559,7 +8566,7 @@ async fn verify_torrent_data(
|
||||
torrent_file_allocation: item.torrent_file_allocation.clone(),
|
||||
torrent_verify_only: Some(true),
|
||||
torrent_verify_restore_status: Some(restore_status.clone()),
|
||||
lifecycle_generation: None,
|
||||
lifecycle_generation: Some(verification_generation.to_string()),
|
||||
};
|
||||
|
||||
{
|
||||
@@ -8589,9 +8596,10 @@ async fn verify_torrent_data(
|
||||
enqueue_download_locked(&app_handle, state.inner(), enqueue_item, &control_guard).await
|
||||
{
|
||||
// Roll back only this verification marker, and only while the row is
|
||||
// still the queued verification lifecycle. Never restore the stale
|
||||
// full array captured before enqueue: frontend persistence or another
|
||||
// command may have changed unrelated rows in the meantime.
|
||||
// still this verification lifecycle (queued or already restored by a
|
||||
// renderer snapshot). Never restore the stale full array captured
|
||||
// before enqueue: frontend persistence or another command may have
|
||||
// changed unrelated rows in the meantime.
|
||||
let rollback_result = (|| {
|
||||
let mut connection = database.lock()?;
|
||||
crate::db::mutate_download(
|
||||
@@ -8599,18 +8607,20 @@ async fn verify_torrent_data(
|
||||
&id,
|
||||
database.is_portable(),
|
||||
|object| {
|
||||
let is_verification_marker = object
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("queued")
|
||||
&& object
|
||||
.get("torrentVerifyOnly")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
== Some(true)
|
||||
let has_verification_marker = object
|
||||
.get("torrentVerifyOnly")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
== Some(true)
|
||||
&& object
|
||||
.get("torrentVerifyRestoreStatus")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(restore_status.as_str());
|
||||
let current_status = object
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str);
|
||||
let is_verification_marker = has_verification_marker
|
||||
&& (current_status == Some("queued")
|
||||
|| current_status == Some(restore_status.as_str()));
|
||||
// The native marker is an additional persistence fence,
|
||||
// not a prerequisite for rollback. A renderer snapshot
|
||||
// may have already acknowledged and removed that native
|
||||
@@ -8621,18 +8631,39 @@ async fn verify_torrent_data(
|
||||
"status".to_string(),
|
||||
serde_json::json!(restore_status),
|
||||
);
|
||||
match restore_has_been_dispatched {
|
||||
Some(value) => {
|
||||
object.insert(
|
||||
"hasBeenDispatched".to_string(),
|
||||
serde_json::json!(value),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
object.remove("hasBeenDispatched");
|
||||
}
|
||||
}
|
||||
object.remove("torrentVerifyOnly");
|
||||
object.remove("torrentVerifyRestoreStatus");
|
||||
object.remove("torrentVerifyNative");
|
||||
}
|
||||
Ok(())
|
||||
Ok(is_verification_marker)
|
||||
},
|
||||
)
|
||||
})();
|
||||
if let Err(rollback_error) = rollback_result {
|
||||
return Err(format!(
|
||||
"{error}; failed to roll back Torrent verification state: {rollback_error}"
|
||||
));
|
||||
let rollback_applied = match rollback_result {
|
||||
Ok(applied) => applied,
|
||||
Err(rollback_error) => {
|
||||
return Err(format!(
|
||||
"{error}; failed to roll back Torrent verification state: {rollback_error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if rollback_applied {
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(&id, restore_status_value),
|
||||
);
|
||||
}
|
||||
return Err(error.to_string());
|
||||
}
|
||||
|
||||
@@ -1615,6 +1615,21 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.or_insert(generation);
|
||||
}
|
||||
|
||||
/// Return a generation newer than every observed or cancelled enqueue for
|
||||
/// an internally-created lifecycle. The caller still passes this value to
|
||||
/// `reserve_enqueue_generation`, which performs the atomic ownership and
|
||||
/// cancellation checks before the enqueue is committed.
|
||||
pub async fn next_enqueue_generation(&self, id: &str) -> Result<u64, String> {
|
||||
let cancellations = self.enqueue_cancellations.lock().await;
|
||||
let generations = self.enqueue_generations.lock().await;
|
||||
let previous_generation = generations.get(id).copied().unwrap_or_default();
|
||||
let cancelled_generation = cancellations.get(id).copied().unwrap_or_default();
|
||||
previous_generation
|
||||
.max(cancelled_generation)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "Download lifecycle generation exhausted".to_string())
|
||||
}
|
||||
|
||||
/// Atomically reserve an ID after rejecting cancelled or replayed generations.
|
||||
/// The returned watermark must be passed to `rollback_enqueue_reservation`
|
||||
/// if ownership registration fails before the task is committed.
|
||||
@@ -8184,6 +8199,27 @@ mod tests {
|
||||
assert!(item.into_task().payload.torrent_remove_unselected_file);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internally_created_enqueue_advances_past_cancelled_generation() {
|
||||
let app = tauri::test::mock_builder()
|
||||
.build(tauri::test::mock_context(tauri::test::noop_assets()))
|
||||
.expect("mock app");
|
||||
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
|
||||
|
||||
manager.cancel_enqueue_generation("torrent", 4).await;
|
||||
let generation = manager
|
||||
.next_enqueue_generation("torrent")
|
||||
.await
|
||||
.expect("a fresh generation should be available");
|
||||
|
||||
assert_eq!(generation, 5);
|
||||
manager
|
||||
.reserve_enqueue_generation("torrent", generation)
|
||||
.await
|
||||
.expect("the fresh generation should not be rejected as stale");
|
||||
assert_eq!(manager.registered_lifecycle_generation("torrent").await, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_options_reject_invalid_seed_values() {
|
||||
let mut options = serde_json::Map::new();
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
downloadProgressColorClass,
|
||||
formatTorrentDuration,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
resolveDownloadSizeDisplay,
|
||||
resolveDownloadFraction
|
||||
} from '../utils/downloadProgress';
|
||||
import {
|
||||
COLUMN_ALIGNMENT_JUSTIFY,
|
||||
@@ -180,11 +181,24 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
};
|
||||
}, [isActionVisible, updateActionPosition]);
|
||||
|
||||
const displayFraction = download.status === 'moving'
|
||||
? moveProgress ?? download.fraction ?? 0
|
||||
const progressFraction = download.status === 'moving'
|
||||
? moveProgress ?? download.fraction
|
||||
: download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
|
||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||
: download.fraction ?? 0;
|
||||
? liveProgress?.fraction ?? download.fraction
|
||||
: download.fraction;
|
||||
const displayFraction = download.status === 'moving' && moveProgress !== undefined
|
||||
? Math.max(0, Math.min(1, moveProgress))
|
||||
: download.status === 'moving'
|
||||
? resolveDownloadFraction({ fraction: progressFraction, status: download.status })
|
||||
: resolveDownloadFraction({
|
||||
fraction: progressFraction,
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||
totalBytes: liveProgress?.total_bytes ?? download.totalBytes,
|
||||
totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate,
|
||||
isMedia: download.isMedia,
|
||||
size: download.size,
|
||||
status: download.status,
|
||||
});
|
||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||
const displaySpeed = download.status === 'seeding'
|
||||
? liveProgress?.upload_speed ?? '-'
|
||||
|
||||
@@ -36,12 +36,14 @@ import {
|
||||
type PropertiesSnapshot,
|
||||
type PropertiesSnapshotEvent,
|
||||
} from '../propertiesBridge';
|
||||
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
||||
import { formatDownloadBytes, formatTorrentRatio, resolveDownloadFraction } from '../utils/downloadProgress';
|
||||
import { changeAppLocale } from '../i18n';
|
||||
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
||||
import { getWindowControlRailWidth } from '../utils/windowControlStyle';
|
||||
import { getPropertiesFooterActions } from '../utils/propertiesFooter';
|
||||
import {
|
||||
formatPropertiesAvailability,
|
||||
formatPropertiesDiagnosticCount,
|
||||
getPropertiesAvailabilityDiagnosticState,
|
||||
getPropertiesPeerDiagnosticState,
|
||||
} from '../utils/propertiesDiagnostics';
|
||||
@@ -1067,7 +1069,15 @@ export const PropertiesWindowApp = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
|
||||
const progress = resolveDownloadFraction({
|
||||
fraction: snapshot.fraction,
|
||||
downloadedBytes: snapshot.downloadedBytes,
|
||||
totalBytes: snapshot.totalBytes,
|
||||
totalIsEstimate: snapshot.totalIsEstimate,
|
||||
isMedia: snapshot.isMedia,
|
||||
size: snapshot.size,
|
||||
status: snapshot.status,
|
||||
});
|
||||
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
||||
const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status);
|
||||
const footerActions = getPropertiesFooterActions({
|
||||
@@ -1329,7 +1339,10 @@ export const PropertiesWindowApp = () => {
|
||||
<span className="properties-diagnostic-label">{t($ => $.properties.torrentPeerDiagnostics)}</span>
|
||||
<p className="properties-diagnostic-value" data-value-state={peerDiagnosticState} role="status">
|
||||
{peers
|
||||
? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders })
|
||||
? t($ => $.properties.torrentPeerCount, {
|
||||
total: formatPropertiesDiagnosticCount(peers.totalPeers, snapshot.appearance.locale),
|
||||
seeders: formatPropertiesDiagnosticCount(peers.totalSeeders, snapshot.appearance.locale),
|
||||
})
|
||||
: diagnosticsLoading
|
||||
? t($ => $.properties.torrentPeerDiagnosticsLoading)
|
||||
: t($ => $.properties.torrentPeerDiagnosticsUnavailable)}
|
||||
@@ -1341,7 +1354,15 @@ export const PropertiesWindowApp = () => {
|
||||
{peerDiagnosticPhase === 'stale' && <p className="properties-diagnostic-detail">{t($ => $.properties.torrentPeerDiagnosticsStale)}</p>}
|
||||
{peers?.truncated && <p className="properties-diagnostic-detail">{t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers })}</p>}
|
||||
</div>
|
||||
<div className="properties-diagnostic-card" data-diagnostic-phase={availabilityDiagnosticPhase}><span className="properties-diagnostic-label">{t($ => $.properties.torrentAvailability)}</span><p className="properties-diagnostic-value" data-value-state={availabilityDiagnosticState}>{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}</p>{availabilityDiagnosticPhase === 'stale' && <p className="properties-diagnostic-detail">{t($ => $.properties.torrentPeerDiagnosticsStale)}</p>}</div>
|
||||
<div className="properties-diagnostic-card" data-diagnostic-phase={availabilityDiagnosticPhase}>
|
||||
<div className="min-w-0">
|
||||
<span className="properties-diagnostic-label">{t($ => $.properties.torrentAvailability)}</span>
|
||||
<p className="properties-diagnostic-value" data-value-state={availabilityDiagnosticState}>
|
||||
{availability ? `${formatPropertiesAvailability(availability.availability, snapshot.appearance.locale)} — ${formatPropertiesDiagnosticCount(availability.pieceCount, snapshot.appearance.locale)} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
{availabilityDiagnosticPhase === 'stale' && <p className="properties-diagnostic-detail">{t($ => $.properties.torrentPeerDiagnosticsStale)}</p>}
|
||||
</div>
|
||||
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentPeerAddress)}</th><th className="p-2">{t($ => $.properties.torrentPeerDownload)}</th><th className="p-2">{t($ => $.properties.torrentPeerUpload)}</th><th className="p-2">{t($ => $.properties.torrentPeerSeeder)}</th><th className="p-2">{t($ => $.properties.torrentPeerChoking)}</th></tr></thead><tbody>{peers?.peers.map((peer, index) => <tr key={`${peer.ip ?? 'peer'}-${peer.port ?? 'unknown'}-${index}`} className="border-t border-border-modal/60"><td className="p-2 font-mono">{peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'}</td><td className="properties-data-value p-2">{formatDownloadBytes(peer.downloadSpeed)}/s</td><td className="properties-data-value p-2">{formatDownloadBytes(peer.uploadSpeed)}/s</td><td className="p-2">{peer.seeder ? '✓' : '—'}</td><td className="p-2">{peer.peerChoking ? '✓' : '—'}</td></tr>)}</tbody></table></div>
|
||||
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
|
||||
</div>}
|
||||
|
||||
@@ -340,7 +340,7 @@ const common = {
|
||||
torrentWebSeedsAdd: 'Add web seed',
|
||||
torrentWebSeedsRemove: 'Remove web seed',
|
||||
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
|
||||
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
|
||||
torrentPeerCount: '{{total}} peers — {{seeders}} seeders',
|
||||
torrentPeerDownload: 'Download',
|
||||
torrentPeerUpload: 'Upload',
|
||||
torrentPeerSeeder: 'Seeder',
|
||||
|
||||
@@ -340,7 +340,7 @@ const fa = {
|
||||
torrentWebSeedsAdd: 'افزودن وبسید',
|
||||
torrentWebSeedsRemove: 'حذف وبسید',
|
||||
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
||||
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
|
||||
torrentPeerCount: '{{total}} همتا — {{seeders}} سید',
|
||||
torrentPeerDownload: 'دریافت',
|
||||
torrentPeerUpload: 'آپلود',
|
||||
torrentPeerSeeder: 'سید',
|
||||
|
||||
@@ -340,7 +340,7 @@ const he = {
|
||||
torrentWebSeedsAdd: 'הוסף זריעת Web',
|
||||
torrentWebSeedsRemove: 'הסר זריעת Web',
|
||||
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
||||
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
|
||||
torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים',
|
||||
torrentPeerDownload: 'הורדה',
|
||||
torrentPeerUpload: 'העלאה',
|
||||
torrentPeerSeeder: 'משתף',
|
||||
|
||||
@@ -340,7 +340,7 @@ const ru = {
|
||||
torrentWebSeedsAdd: 'Добавить веб-сид',
|
||||
torrentWebSeedsRemove: 'Удалить веб-сид',
|
||||
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
||||
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
|
||||
torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров',
|
||||
torrentPeerDownload: 'Загрузка',
|
||||
torrentPeerUpload: 'Отдача',
|
||||
torrentPeerSeeder: 'Сидер',
|
||||
|
||||
@@ -340,7 +340,7 @@ const uk = {
|
||||
torrentWebSeedsAdd: 'Додати вебсід',
|
||||
torrentWebSeedsRemove: 'Видалити вебсід',
|
||||
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
||||
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
|
||||
torrentPeerCount: '{{total}} пірів — {{seeders}} сідів',
|
||||
torrentPeerDownload: 'Завантаження',
|
||||
torrentPeerUpload: 'Віддача',
|
||||
torrentPeerSeeder: 'Сідер',
|
||||
|
||||
@@ -340,7 +340,7 @@ const zhCN = {
|
||||
torrentWebSeedsAdd: '添加 Web 做种',
|
||||
torrentWebSeedsRemove: '移除 Web 做种',
|
||||
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
||||
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
||||
torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点',
|
||||
torrentPeerDownload: '下载',
|
||||
torrentPeerUpload: '上传',
|
||||
torrentPeerSeeder: '做种',
|
||||
|
||||
+12
-12
@@ -983,7 +983,7 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
.properties-diagnostic-card {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--properties-card-border);
|
||||
border-radius: 10px;
|
||||
background: var(--properties-card-surface);
|
||||
@@ -1014,38 +1014,38 @@ html[data-list-density="relaxed"] {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.properties-diagnostic-label {
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.025em;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.properties-diagnostic-value {
|
||||
margin-top: 6px;
|
||||
color: var(--properties-live-value);
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 750;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.properties-diagnostic-hint {
|
||||
max-width: 60ch;
|
||||
margin-top: 8px;
|
||||
margin-top: 6px;
|
||||
color: hsl(var(--text-secondary));
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.properties-diagnostic-detail {
|
||||
margin-top: 8px;
|
||||
margin-top: 6px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
@@ -334,6 +334,8 @@ describe('Properties window bridge', () => {
|
||||
it('recognizes expected diagnostics gaps without hiding real RPC failures', () => {
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('live Torrent file progress is unavailable'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has no current gid mapping'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has a stale control epoch'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent has a stale control epoch'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer diagnostics'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getPeers failed: unavailable response'))).toBe(false);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getFiles failed: connection refused'))).toBe(false);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDownloadBytes, formatDownloadTotal, resolveDownloadSizeDisplay } from './downloadProgress';
|
||||
import {
|
||||
formatDownloadBytes,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadFraction,
|
||||
resolveDownloadSizeDisplay,
|
||||
} from './downloadProgress';
|
||||
|
||||
describe('download progress size display', () => {
|
||||
it('formats byte counts using the binary units used by the download engines', () => {
|
||||
@@ -44,3 +49,62 @@ describe('download progress size display', () => {
|
||||
expect(formatDownloadTotal(display)).toBe('2.40 GB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDownloadFraction', () => {
|
||||
it('reconstructs paused progress from exact persisted byte counters', () => {
|
||||
expect(resolveDownloadFraction({
|
||||
status: 'paused',
|
||||
downloadedBytes: 662 * 1024 ** 2,
|
||||
totalBytes: 2.94 * 1024 ** 3,
|
||||
})).toBeCloseTo(0.2199, 3);
|
||||
});
|
||||
|
||||
it('uses a live fraction when it is available', () => {
|
||||
expect(resolveDownloadFraction({
|
||||
fraction: 0.37,
|
||||
downloadedBytes: 90,
|
||||
totalBytes: 100,
|
||||
status: 'downloading',
|
||||
})).toBe(0.37);
|
||||
});
|
||||
|
||||
it('does not infer progress from an estimated media total', () => {
|
||||
expect(resolveDownloadFraction({
|
||||
fraction: 0,
|
||||
downloadedBytes: 900,
|
||||
totalBytes: 1000,
|
||||
totalIsEstimate: true,
|
||||
isMedia: true,
|
||||
size: '~1000 B',
|
||||
status: 'paused',
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps zero at the start and does not divide by an unknown total', () => {
|
||||
expect(resolveDownloadFraction({
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
status: 'paused',
|
||||
})).toBe(0);
|
||||
expect(resolveDownloadFraction({
|
||||
downloadedBytes: 500,
|
||||
status: 'paused',
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
it('shows completed downloads as complete even when volatile fraction was removed', () => {
|
||||
expect(resolveDownloadFraction({
|
||||
status: 'completed',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 100,
|
||||
})).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps inconsistent exact byte counters', () => {
|
||||
expect(resolveDownloadFraction({
|
||||
downloadedBytes: 150,
|
||||
totalBytes: 100,
|
||||
status: 'paused',
|
||||
})).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,62 @@ export interface DownloadSizeDisplay {
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
export type DownloadFractionInput = {
|
||||
fraction?: number | null;
|
||||
downloadedBytes?: number | null;
|
||||
totalBytes?: number | null;
|
||||
totalIsEstimate?: boolean | null;
|
||||
isMedia?: boolean | null;
|
||||
size?: string | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||
|
||||
const isUsableByteCount = (value: number | null | undefined): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const isUsableFraction = (value: number | null | undefined): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
||||
|
||||
const clampFraction = (value: number): number => Math.max(0, Math.min(1, value));
|
||||
|
||||
/**
|
||||
* Resolves the fraction shown by progress bars when a live fraction is not
|
||||
* available. Volatile fractions are intentionally omitted from persisted
|
||||
* downloads, but paused rows retain exact byte counters so their progress can
|
||||
* still be reconstructed after restart. Estimated media totals are excluded:
|
||||
* they describe a provisional denominator and must not become a false visual
|
||||
* claim about completion.
|
||||
*/
|
||||
export const resolveDownloadFraction = ({
|
||||
fraction,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
totalIsEstimate = false,
|
||||
isMedia = false,
|
||||
size,
|
||||
status
|
||||
}: DownloadFractionInput): number => {
|
||||
if (status === 'completed') return 1;
|
||||
|
||||
const storedFraction = isUsableFraction(fraction) ? fraction : undefined;
|
||||
if (storedFraction !== undefined && storedFraction > 0) return storedFraction;
|
||||
|
||||
const hasEstimatedTotal = totalIsEstimate === true ||
|
||||
(isMedia === true && size?.trim().startsWith('~') === true);
|
||||
if (
|
||||
!hasEstimatedTotal &&
|
||||
isUsableByteCount(downloadedBytes) &&
|
||||
isUsableByteCount(totalBytes) &&
|
||||
totalBytes > 0
|
||||
) {
|
||||
return clampFraction(downloadedBytes / totalBytes);
|
||||
}
|
||||
|
||||
return storedFraction ?? 0;
|
||||
};
|
||||
|
||||
const byteUnitIndex = (bytes: number): number => {
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatPropertiesAvailability,
|
||||
formatPropertiesDiagnosticCount,
|
||||
getPropertiesAvailabilityDiagnosticState,
|
||||
getPropertiesPeerDiagnosticState,
|
||||
} from './propertiesDiagnostics';
|
||||
@@ -12,6 +14,20 @@ const emptyPeerDiagnostics = {
|
||||
};
|
||||
|
||||
describe('Properties peer diagnostics presentation state', () => {
|
||||
it('formats swarm availability without exposing floating-point noise', () => {
|
||||
expect(formatPropertiesAvailability(6.05186170212766, 'en-US')).toBe('6.05');
|
||||
expect(formatPropertiesAvailability(1.5, 'en-US')).toBe('1.5');
|
||||
expect(formatPropertiesAvailability(1.5, '')).toBe('1.5');
|
||||
expect(formatPropertiesAvailability(Number.NaN, 'en-US')).toBe('—');
|
||||
});
|
||||
|
||||
it('formats diagnostic counts with the same locale as availability', () => {
|
||||
expect(formatPropertiesDiagnosticCount(1234, 'en-US')).toBe('1,234');
|
||||
expect(formatPropertiesDiagnosticCount(1234, 'fa')).toBe(new Intl.NumberFormat('fa').format(1234));
|
||||
expect(formatPropertiesDiagnosticCount(-1, 'en-US')).toBe('—');
|
||||
expect(formatPropertiesDiagnosticCount(Number.MAX_SAFE_INTEGER + 1, 'en-US')).toBe('—');
|
||||
});
|
||||
|
||||
it('keeps a genuine empty response live instead of treating it as unavailable', () => {
|
||||
expect(getPropertiesPeerDiagnosticState(emptyPeerDiagnostics, false, 'idle')).toBe('live');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
||||
import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
|
||||
import { resolveAppLocale } from '../i18n/locales';
|
||||
import type { PropertiesDiagnosticPhase } from '../propertiesBridge';
|
||||
|
||||
export type PropertiesDiagnosticValueState = 'live' | 'loading' | 'stale' | 'error' | 'unavailable';
|
||||
|
||||
export const formatPropertiesDiagnosticCount = (value: number, locale: string): string => {
|
||||
if (!Number.isSafeInteger(value) || value < 0) return '—';
|
||||
return new Intl.NumberFormat(resolveAppLocale(locale)).format(value);
|
||||
};
|
||||
|
||||
export const formatPropertiesAvailability = (availability: number, locale: string): string => {
|
||||
if (!Number.isFinite(availability) || availability < 0) return '—';
|
||||
return new Intl.NumberFormat(resolveAppLocale(locale), { maximumFractionDigits: 2 }).format(availability);
|
||||
};
|
||||
|
||||
const getPropertiesDiagnosticValueState = (
|
||||
hasValue: boolean,
|
||||
diagnosticsLoading: boolean,
|
||||
|
||||
Reference in New Issue
Block a user