fix(properties): harden progress and torrent diagnostics

This commit is contained in:
NimBold
2026-08-07 13:32:52 +03:30
parent e402603edb
commit facb7b3300
16 changed files with 291 additions and 45 deletions
+48 -17
View File
@@ -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());
}
+36
View File
@@ -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();
+19 -5
View File
@@ -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 ?? '-'
+25 -4
View File
@@ -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>}
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -340,7 +340,7 @@ const fa = {
torrentWebSeedsAdd: 'افزودن وب‌سید',
torrentWebSeedsRemove: 'حذف وب‌سید',
torrentWebSeedsInvalid: 'هر ردیف وب‌سید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
torrentPeerCount: '{{total}} همتا {{seeders}} سید',
torrentPeerDownload: 'دریافت',
torrentPeerUpload: 'آپلود',
torrentPeerSeeder: 'سید',
+1 -1
View File
@@ -340,7 +340,7 @@ const he = {
torrentWebSeedsAdd: 'הוסף זריעת Web',
torrentWebSeedsRemove: 'הסר זריעת Web',
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
torrentPeerCount: '{{total}} עמיתים {{seeders}} משתפים',
torrentPeerDownload: 'הורדה',
torrentPeerUpload: 'העלאה',
torrentPeerSeeder: 'משתף',
+1 -1
View File
@@ -340,7 +340,7 @@ const ru = {
torrentWebSeedsAdd: 'Добавить веб-сид',
torrentWebSeedsRemove: 'Удалить веб-сид',
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
torrentPeerCount: '{{total}} пиров {{seeders}} сидеров',
torrentPeerDownload: 'Загрузка',
torrentPeerUpload: 'Отдача',
torrentPeerSeeder: 'Сидер',
+1 -1
View File
@@ -340,7 +340,7 @@ const uk = {
torrentWebSeedsAdd: 'Додати вебсід',
torrentWebSeedsRemove: 'Видалити вебсід',
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
torrentPeerCount: '{{total}} пірів {{seeders}} сідів',
torrentPeerDownload: 'Завантаження',
torrentPeerUpload: 'Віддача',
torrentPeerSeeder: 'Сідер',
+1 -1
View File
@@ -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
View File
@@ -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;
}
+2
View File
@@ -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);
+65 -1
View File
@@ -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);
});
});
+51
View File
@@ -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;
+16
View File
@@ -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');
});
+11
View File
@@ -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,