Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions BitDream/Transmission/TransmissionTorrentModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
28 changes: 24 additions & 4 deletions BitDream/Views/Shared/SharedComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions BitDream/Views/Shared/TorrentDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -600,7 +600,7 @@ struct TorrentDetailHeaderView: View {

HStack(spacing: 8) {
RatioChip(
ratio: torrent.uploadRatio,
uploadRatio: torrent.uploadRatio,
size: .compact
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
"""
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ private extension TransmissionStorePlaybackOperationTests {
sizeWhenDone: 0,
status: status.rawValue,
totalSize: 0,
uploadRatio: 0,
uploadRatioRaw: 0,
uploadedEver: 0,
downloadedEver: 0
)
Expand Down
2 changes: 1 addition & 1 deletion BitDreamTests/Views/TorrentBulkLabelEditTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ private extension TorrentBulkLabelEditTests {
sizeWhenDone: 0,
status: TorrentStatus.stopped.rawValue,
totalSize: 0,
uploadRatio: 0,
uploadRatioRaw: 0,
uploadedEver: 0,
downloadedEver: 0
)
Expand Down
Loading