diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8477ea5..f69381e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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()); } diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 62db1b0..c69da3e 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1615,6 +1615,21 @@ impl QueueManager { .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 { + 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(); diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 9524638..59ebc96 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -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(({ }; }, [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 ?? '-' diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 1727dc2..f0968c0 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -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 = () => { {t($ => $.properties.torrentPeerDiagnostics)}

{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' &&

{t($ => $.properties.torrentPeerDiagnosticsStale)}

} {peers?.truncated &&

{t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers })}

} -
{t($ => $.properties.torrentAvailability)}

{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}

{availabilityDiagnosticPhase === 'stale' &&

{t($ => $.properties.torrentPeerDiagnosticsStale)}

}
+
+
+ {t($ => $.properties.torrentAvailability)} +

+ {availability ? `${formatPropertiesAvailability(availability.availability, snapshot.appearance.locale)} — ${formatPropertiesDiagnosticCount(availability.pieceCount, snapshot.appearance.locale)} ${t($ => $.properties.torrentDetailsPieces)}` : '—'} +

+
+ {availabilityDiagnosticPhase === 'stale' &&

{t($ => $.properties.torrentPeerDiagnosticsStale)}

} +
{peers?.peers.map((peer, index) => )}
{t($ => $.properties.torrentPeerAddress)}{t($ => $.properties.torrentPeerDownload)}{t($ => $.properties.torrentPeerUpload)}{t($ => $.properties.torrentPeerSeeder)}{t($ => $.properties.torrentPeerChoking)}
{peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'}{formatDownloadBytes(peer.downloadSpeed)}/s{formatDownloadBytes(peer.uploadSpeed)}/s{peer.seeder ? '✓' : '—'}{peer.peerChoking ? '✓' : '—'}
{diagnosticError &&

{diagnosticError}

} } diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 2581de0..28515c3 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index c836ab6..1a6f75d 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -340,7 +340,7 @@ const fa = { torrentWebSeedsAdd: 'افزودن وب‌سید', torrentWebSeedsRemove: 'حذف وب‌سید', torrentWebSeedsInvalid: 'هر ردیف وب‌سید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.', - torrentPeerCount: '{{total}} همتا · {{seeders}} سید', + torrentPeerCount: '{{total}} همتا — {{seeders}} سید', torrentPeerDownload: 'دریافت', torrentPeerUpload: 'آپلود', torrentPeerSeeder: 'سید', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index bc82853..eb47765 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -340,7 +340,7 @@ const he = { torrentWebSeedsAdd: 'הוסף זריעת Web', torrentWebSeedsRemove: 'הסר זריעת Web', torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.', - torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים', + torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים', torrentPeerDownload: 'הורדה', torrentPeerUpload: 'העלאה', torrentPeerSeeder: 'משתף', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 9d0c26c..28784b2 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -340,7 +340,7 @@ const ru = { torrentWebSeedsAdd: 'Добавить веб-сид', torrentWebSeedsRemove: 'Удалить веб-сид', torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.', - torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров', + torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров', torrentPeerDownload: 'Загрузка', torrentPeerUpload: 'Отдача', torrentPeerSeeder: 'Сидер', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 6ee7fb7..e223c77 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -340,7 +340,7 @@ const uk = { torrentWebSeedsAdd: 'Додати вебсід', torrentWebSeedsRemove: 'Видалити вебсід', torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.', - torrentPeerCount: '{{total}} пірів · {{seeders}} сідів', + torrentPeerCount: '{{total}} пірів — {{seeders}} сідів', torrentPeerDownload: 'Завантаження', torrentPeerUpload: 'Віддача', torrentPeerSeeder: 'Сідер', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 6cb9a7d..ea28c72 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -340,7 +340,7 @@ const zhCN = { torrentWebSeedsAdd: '添加 Web 做种', torrentWebSeedsRemove: '移除 Web 做种', torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。', - torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点', + torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点', torrentPeerDownload: '下载', torrentPeerUpload: '上传', torrentPeerSeeder: '做种', diff --git a/src/index.css b/src/index.css index c091a6d..dd478c1 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index b25aeb2..3268ac4 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -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); diff --git a/src/utils/downloadProgress.test.ts b/src/utils/downloadProgress.test.ts index f691cd4..b233073 100644 --- a/src/utils/downloadProgress.test.ts +++ b/src/utils/downloadProgress.test.ts @@ -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); + }); +}); diff --git a/src/utils/downloadProgress.ts b/src/utils/downloadProgress.ts index b36c57e..41f36b5 100644 --- a/src/utils/downloadProgress.ts +++ b/src/utils/downloadProgress.ts @@ -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; diff --git a/src/utils/propertiesDiagnostics.test.ts b/src/utils/propertiesDiagnostics.test.ts index 295e267..7f4b10f 100644 --- a/src/utils/propertiesDiagnostics.test.ts +++ b/src/utils/propertiesDiagnostics.test.ts @@ -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'); }); diff --git a/src/utils/propertiesDiagnostics.ts b/src/utils/propertiesDiagnostics.ts index ca71763..625323c 100644 --- a/src/utils/propertiesDiagnostics.ts +++ b/src/utils/propertiesDiagnostics.ts @@ -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,