import XCTest @testable import TVAnarchyCore /// Locks the torrent wire formats against real `cli.ts` output. final class TorrentDecoderTests: XCTestCase { func testTransferRowDecodeRPC() throws { // Rich JSON-RPC shape from `cli.ts tx-list` (transmissionListRich). let json = #""" [{"id":1,"name":"Done.S05","percentDone":1,"status":6,"doneDate":1749329821,"addedDate":1749300000, "haveValid":8760000000,"sizeWhenDone":8760000000,"rateDownload":0,"rateUpload":1024,"uploadRatio":0.5,"eta":-1}, {"id":11,"name":"WIP","percentDone":0.15,"status":4,"doneDate":0,"addedDate":1749329000, "haveValid":300000000,"sizeWhenDone":2000000000,"rateDownload":1048576,"rateUpload":0,"uploadRatio":0,"eta":3720}] """# let rows = try JSONDecoder().decode([TorrentRow].self, from: Data(json.utf8)) XCTAssertEqual(rows.count, 2) let done = rows[0] XCTAssertTrue(done.isComplete) XCTAssertEqual(done.statusLabel, "Seeding") XCTAssertNotNil(done.completedAt) XCTAssertEqual(done.completedAt, Date(timeIntervalSince1970: 1749329821)) XCTAssertFalse(done.upText.isEmpty) // 1024 B/s → formatted let wip = rows[1] XCTAssertFalse(wip.isComplete) XCTAssertTrue(wip.isDownloading) XCTAssertEqual(wip.progress, 0.15, accuracy: 0.001) XCTAssertNil(wip.completedAt) // doneDate 0 → not completed XCTAssertEqual(wip.etaText, "1h 2m") // 3720s } func testRecencySort() { func row(_ id: Int, done: Int, added: Int) -> TorrentRow { let j = #"{"id":\#(id),"name":"t\#(id)","percentDone":\#(done > 0 ? 1 : 0),"status":0,"doneDate":\#(done),"addedDate":\#(added),"haveValid":0,"sizeWhenDone":0,"rateDownload":0,"rateUpload":0,"uploadRatio":0,"eta":-1}"# return try! JSONDecoder().decode(TorrentRow.self, from: Data(j.utf8)) } let unsorted = [row(1, done: 0, added: 50), row(2, done: 100, added: 10), row(3, done: 200, added: 20)] let sorted = unsorted.sorted(by: DownloadsController.byRecency) XCTAssertEqual(sorted.map(\.id), [3, 2, 1]) // newest-completed first; in-progress last } func testTransferRowDecodesHealthFields() throws { // The list poll now also carries error/errorString/peersConnected (added to // RPC_FIELDS) — they're optional so a pre-Part-E cli still decodes, but when // present they must drive the health classifier. let json = #""" [{"id":7,"name":"Stuck","percentDone":0.4,"status":4,"doneDate":0,"addedDate":1749300000, "haveValid":1,"sizeWhenDone":10,"rateDownload":0,"rateUpload":0,"uploadRatio":0,"eta":-1, "error":0,"errorString":"","peersConnected":0}] """# let row = try JSONDecoder().decode([TorrentRow].self, from: Data(json.utf8))[0] XCTAssertEqual(row.peersConnected, 0) // 0 peers + 0 rate, stuck past the dead threshold → dead. XCTAssertEqual(row.health(secondsStuck: 400), .dead) // Same row, errored takes precedence regardless of peers/time. let errJSON = #"{"id":8,"name":"E","percentDone":0.4,"status":4,"doneDate":0,"addedDate":1,"haveValid":1,"sizeWhenDone":10,"rateDownload":0,"rateUpload":0,"uploadRatio":0,"eta":-1,"error":3,"errorString":"tracker gone","peersConnected":5}"# let errored = try JSONDecoder().decode(TorrentRow.self, from: Data(errJSON.utf8)) XCTAssertEqual(errored.health(secondsStuck: 10), .errored) } func testTorrentDetailDecode() throws { // Wire shape of `cli.ts tx-detail ` (transmissionDetail). let json = #""" {"id":11,"name":"WIP.S01","percentDone":0.15,"status":4,"error":0,"errorString":"", "eta":3720,"rateDownload":1048576,"rateUpload":0,"uploadRatio":0, "peersConnected":3,"peersSendingToUs":2,"peersGettingFromUs":1, "downloadDir":"/bigdisk/_/media/tv", "trackerStats":[ {"host":"udp://tracker.one:1337","announceState":1,"lastAnnounceResult":"Success","lastAnnounceSucceeded":true,"seederCount":42,"leecherCount":7}, {"host":"udp://dead.tracker:80","announceState":0,"lastAnnounceResult":"Could not connect to tracker","lastAnnounceSucceeded":false,"seederCount":-1,"leecherCount":-1} ]} """# let d = try JSONDecoder().decode(TorrentDetail.self, from: Data(json.utf8)) XCTAssertEqual(d.id, 11) XCTAssertFalse(d.isComplete) XCTAssertFalse(d.hasError) XCTAssertEqual(d.peersText, "3 connected · ↓2 ↑1") XCTAssertEqual(d.trackerStats.count, 2) XCTAssertTrue(d.trackerStats[0].lastAnnounceSucceeded) XCTAssertEqual(d.trackerStats[0].swarmText, "42 seed · 7 leech") XCTAssertEqual(d.trackerStats[1].swarmText, "— seed · — leech") // -1 → unknown XCTAssertEqual(d.health(), .downloading) } func testSearchResultDecodeAndAddable() throws { let json = #""" [{"filename":"Show.S01.1080p","source":"1337x","size":"3.2 GB","seeders":45,"leechers":3,"magnet":"magnet:?xt=urn:btih:abc"}, {"filename":"NoMagnet","source":"tpb","size":"1 GB","seeders":2,"leechers":0,"magnet":null}] """# let results = try JSONDecoder().decode([TorrentResult].self, from: Data(json.utf8)) XCTAssertEqual(results.count, 2) XCTAssertTrue(results[0].addable) XCTAssertEqual(results[0].seeders, 45) XCTAssertFalse(results[1].addable) // null magnet → not addable XCTAssertNotEqual(results[0].id, results[1].id) } }