diff --git a/BitDream/Transmission/TransmissionTorrentModels.swift b/BitDream/Transmission/TransmissionTorrentModels.swift index 9e06b10..543b523 100644 --- a/BitDream/Transmission/TransmissionTorrentModels.swift +++ b/BitDream/Transmission/TransmissionTorrentModels.swift @@ -48,6 +48,84 @@ public enum TorrentStatusCalc: String, CaseIterable { case unknown = "Unknown" } +enum TorrentUploadRatio: Equatable, Sendable { + case unavailable + case infinite + case value(Double) + + // Transmission uses raw sentinel values here: -1 means "ratio unavailable" + // and -2 means "uploaded without any recorded download history." + private static let unavailableRawValue = -1.0 + private static let infiniteRawValue = -2.0 + + init(rawValue: Double) { + if rawValue == Self.unavailableRawValue { + self = .unavailable + } else if rawValue == Self.infiniteRawValue { + self = .infinite + } else { + self = .value(rawValue) + } + } + + var displayValue: Double { + switch self { + case .unavailable: + // No ratio to show yet, so keep the ring empty. + return 0 + case .infinite: + // The chip caps out at a full ring, so this shows as complete. + return 1 + case .value(let value): + return value + } + } + + var displayText: String { + switch self { + case .unavailable: + // Avoid pretending this is a real numeric ratio. + return "None" + case .infinite: + // Keep this readable without surfacing the raw sentinel value. + return "1.00+" + case .value(let value): + return String(format: "%.2f", value) + } + } + + var ringProgressValue: Double { + switch self { + case .unavailable: + return 0 + case .infinite: + return 1 + case .value(let value): + return min(value, 1.0) + } + } + + var usesCompletionColor: Bool { + switch self { + case .infinite: + return true + case .unavailable: + return false + case .value(let value): + return value >= 1.0 + } + } + + var isAvailable: Bool { + switch self { + case .unavailable: + return false + case .infinite, .value: + return true + } + } +} + // MARK: - Generic Request/Response Models /// Generic request struct for all Transmission RPC methods @@ -92,10 +170,14 @@ public struct Torrent: Codable, Hashable, Identifiable, Sendable { let sizeWhenDone: Int64 let status: Int let totalSize: Int64 - let uploadRatio: Double + // Keep the raw RPC value so we do not lose which sentinel Transmission sent. + let uploadRatioRaw: Double let uploadedEver: Int64 let downloadedEver: Int64 var downloadedCalc: Int64 { haveUnchecked + haveValid} + // Views should use the interpreted ratio state instead of reading the raw + // RPC value directly. + var uploadRatio: TorrentUploadRatio { TorrentUploadRatio(rawValue: uploadRatioRaw) } var statusCalc: TorrentStatusCalc { if status == TorrentStatus.stopped.rawValue && percentDone == 1 { return TorrentStatusCalc.complete @@ -159,7 +241,7 @@ public struct Torrent: Codable, Hashable, Identifiable, Sendable { case sizeWhenDone case status case totalSize - case uploadRatio + case uploadRatioRaw = "uploadRatio" case uploadedEver case downloadedEver } diff --git a/BitDream/Views/Shared/SharedComponents.swift b/BitDream/Views/Shared/SharedComponents.swift index e83ccc5..e52d7ba 100644 --- a/BitDream/Views/Shared/SharedComponents.swift +++ b/BitDream/Views/Shared/SharedComponents.swift @@ -103,10 +103,30 @@ struct SpeedChip: View { // MARK: - RatioChip Component struct RatioChip: View { - let ratio: Double + private let ringProgress: Double + private let displayText: String + private let showsCompletionColor: Bool var size: SpeedChipSize = .compact var helpText: String? + init(ratio: Double, size: SpeedChipSize = .compact, helpText: String? = nil) { + self.ringProgress = min(ratio, 1.0) + self.displayText = String(format: "%.2f", ratio) + self.showsCompletionColor = ratio >= 1.0 + self.size = size + self.helpText = helpText + } + + init(uploadRatio: TorrentUploadRatio, size: SpeedChipSize = .compact, helpText: String? = nil) { + // Torrent ratios can come through as raw sentinel values, so the chip + // takes the already-interpreted state here. + self.ringProgress = uploadRatio.ringProgressValue + self.displayText = uploadRatio.displayText + self.showsCompletionColor = uploadRatio.usesCompletionColor + self.size = size + self.helpText = helpText + } + private var progressRingSize: CGFloat { switch size { case .compact: return 14 @@ -122,13 +142,13 @@ struct RatioChip: View { .frame(width: progressRingSize, height: progressRingSize) Circle() - .trim(from: 0, to: min(ratio, 1.0)) - .stroke(ratio >= 1.0 ? .green : .orange, lineWidth: 2) + .trim(from: 0, to: ringProgress) + .stroke(showsCompletionColor ? .green : .orange, lineWidth: 2) .frame(width: progressRingSize, height: progressRingSize) .rotationEffect(.degrees(-90)) } - Text(String(format: "%.2f", ratio)) + Text(displayText) .monospacedDigit() } .font(size.font) diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index 3292da1..b8d35ba 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -573,7 +573,7 @@ func formatTorrentDetails(torrent: Torrent) -> TorrentDetailsDisplay { let downloadedFormatted = formatByteCount(torrent.downloadedCalc) let sizeWhenDoneFormatted = formatByteCount(torrent.sizeWhenDone) let uploadedFormatted = formatByteCount(torrent.uploadedEver) - let uploadRatio = String(format: "%.2f", torrent.uploadRatio) + let uploadRatio = torrent.uploadRatio.displayText let activityDate = formatTorrentDetailDate(torrent.activityDate) let addedDate = formatTorrentDetailDate(torrent.addedDate) @@ -600,7 +600,7 @@ struct TorrentDetailHeaderView: View { HStack(spacing: 8) { RatioChip( - ratio: torrent.uploadRatio, + uploadRatio: torrent.uploadRatio, size: .compact ) diff --git a/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift b/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift index b0ec605..ea7a79f 100644 --- a/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift +++ b/BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift @@ -15,6 +15,10 @@ final class TransmissionConnectionQueryTests: XCTestCase { let torrents = try await connection.fetchTorrentSummary() XCTAssertFalse(torrents.isEmpty) + let sampleTorrent = try XCTUnwrap(torrents.first(where: { $0.id == 2 })) + XCTAssertEqual(sampleTorrent.uploadRatioRaw, 0) + XCTAssertEqual(sampleTorrent.uploadRatio, .value(0)) + XCTAssertEqual(sampleTorrent.uploadRatio.displayText, "0.00") let requests = await sender.capturedRequests() XCTAssertEqual(try capturedRequestFields(requests[0]), TransmissionTorrentQuerySpec.torrentSummary.fields) } @@ -35,6 +39,36 @@ final class TransmissionConnectionQueryTests: XCTestCase { XCTAssertEqual(try capturedRequestFields(requests[0]), TransmissionTorrentQuerySpec.widgetSummary.fields) } + func testFetchTorrentSummaryDistinguishesUnavailableAndInfiniteRawRatioValues() async throws { + let sender = QueueSender(steps: [ + .http( + statusCode: 200, + body: makeTorrentSummaryWithRatioSentinelsBody() + ) + ]) + let connection = TransmissionConnection( + endpoint: try makeEndpoint(), + auth: makeAuth(), + transport: TransmissionTransport(sender: sender) + ) + + let torrents = try await connection.fetchTorrentSummary() + + let unavailableTorrent = try XCTUnwrap(torrents.first(where: { $0.id == 1 })) + XCTAssertEqual(unavailableTorrent.uploadRatioRaw, -1) + XCTAssertEqual(unavailableTorrent.uploadRatio, .unavailable) + XCTAssertEqual(unavailableTorrent.uploadRatio.displayText, "None") + XCTAssertEqual(unavailableTorrent.uploadRatio.ringProgressValue, 0) + XCTAssertFalse(unavailableTorrent.uploadRatio.usesCompletionColor) + + let infiniteTorrent = try XCTUnwrap(torrents.first(where: { $0.id == 2 })) + XCTAssertEqual(infiniteTorrent.uploadRatioRaw, -2) + XCTAssertEqual(infiniteTorrent.uploadRatio, .infinite) + XCTAssertEqual(infiniteTorrent.uploadRatio.displayText, "1.00+") + XCTAssertEqual(infiniteTorrent.uploadRatio.ringProgressValue, 1) + XCTAssertTrue(infiniteTorrent.uploadRatio.usesCompletionColor) + } + func testFetchTorrentFilesUsesNamedFieldsAndDecodesFirstTorrent() async throws { let sender = QueueSender(steps: [ .http( @@ -366,6 +400,96 @@ private func makeTorrentPeersSuccessBody() -> String { """ } +private func makeTorrentSummaryWithRatioSentinelsBody() -> String { + """ + { + "arguments": { + "torrents": [ + \(makeUnavailableRatioTorrentBody()), + \(makeInfiniteRatioTorrentBody()) + ] + }, + "result": "success" + } + """ +} + +private func makeUnavailableRatioTorrentBody() -> String { + """ + { + "activityDate": 0, + "addedDate": 0, + "desiredAvailable": 0, + "error": 0, + "errorString": "", + "eta": 0, + "haveUnchecked": 0, + "haveValid": 0, + "id": 1, + "isFinished": false, + "isStalled": false, + "labels": [], + "leftUntilDone": 0, + "magnetLink": "", + "metadataPercentComplete": 1, + "name": "Unavailable", + "peersConnected": 0, + "peersGettingFromUs": 0, + "peersSendingToUs": 0, + "percentDone": 0, + "primary-mime-type": null, + "downloadDir": "/downloads", + "queuePosition": 0, + "rateDownload": 0, + "rateUpload": 0, + "sizeWhenDone": 0, + "status": 0, + "totalSize": 0, + "uploadRatio": -1, + "uploadedEver": 0, + "downloadedEver": 0 + } + """ +} + +private func makeInfiniteRatioTorrentBody() -> String { + """ + { + "activityDate": 0, + "addedDate": 0, + "desiredAvailable": 0, + "error": 0, + "errorString": "", + "eta": 0, + "haveUnchecked": 0, + "haveValid": 0, + "id": 2, + "isFinished": false, + "isStalled": false, + "labels": [], + "leftUntilDone": 0, + "magnetLink": "", + "metadataPercentComplete": 1, + "name": "Infinite", + "peersConnected": 0, + "peersGettingFromUs": 0, + "peersSendingToUs": 0, + "percentDone": 0, + "primary-mime-type": null, + "downloadDir": "/downloads", + "queuePosition": 0, + "rateDownload": 0, + "rateUpload": 0, + "sizeWhenDone": 0, + "status": 0, + "totalSize": 0, + "uploadRatio": -2, + "uploadedEver": 1, + "downloadedEver": 0 + } + """ +} + private func makeTorrentDetailSuccessBody() -> String { """ { diff --git a/BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift index 29759e3..042d0cb 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift @@ -116,7 +116,7 @@ private extension TransmissionStorePlaybackOperationTests { sizeWhenDone: 0, status: status.rawValue, totalSize: 0, - uploadRatio: 0, + uploadRatioRaw: 0, uploadedEver: 0, downloadedEver: 0 ) diff --git a/BitDreamTests/Views/TorrentBulkLabelEditTests.swift b/BitDreamTests/Views/TorrentBulkLabelEditTests.swift index 59d1683..313ef0e 100644 --- a/BitDreamTests/Views/TorrentBulkLabelEditTests.swift +++ b/BitDreamTests/Views/TorrentBulkLabelEditTests.swift @@ -142,7 +142,7 @@ private extension TorrentBulkLabelEditTests { sizeWhenDone: 0, status: TorrentStatus.stopped.rawValue, totalSize: 0, - uploadRatio: 0, + uploadRatioRaw: 0, uploadedEver: 0, downloadedEver: 0 )