Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
6.5 KiB
Swift
136 lines
6.5 KiB
Swift
import XCTest
|
|
@testable import TVAnarchyCore
|
|
|
|
@MainActor
|
|
final class OfflineCacheTests: XCTestCase {
|
|
private func show(_ name: String, eps: Int, category: String = "tv") -> CachedShow {
|
|
let episodes = (1...eps).map {
|
|
CachedEpisode(path: "/p/\(name)/e\($0).mkv", season: 1, episode: $0, label: "E\($0)")
|
|
}
|
|
return CachedShow(name: name, rootDir: "/r/\(name)", category: category,
|
|
kind: .series, episodes: episodes)
|
|
}
|
|
private func cont(_ name: String, at ep: Int, adult: Bool = false) -> ContinueItem {
|
|
ContinueItem(title: name, path: adult ? "/p/porn/\(name)/e\(ep).mkv" : "/p/\(name)/e\(ep).mkv",
|
|
show: name, season: 1, episode: ep, positionSeconds: 30, lastSeen: nil, source: "test")
|
|
}
|
|
|
|
func testPlanFromContinueWatchingTakesNextYFromAnchor() {
|
|
let shows = [show("A", eps: 6), show("B", eps: 6), show("C", eps: 6)]
|
|
let cw = [cont("A", at: 3), cont("B", at: 1)]
|
|
let plan = OfflineCacheController.plan(
|
|
shows: shows, continueWatching: cw, recent: [],
|
|
fromContinueWatching: true, showCount: 5, episodesAhead: 2, includeAdult: false)
|
|
// A starts at e3 → e3,e4 ; B starts at e1 → e1,e2
|
|
XCTAssertEqual(plan.map(\.plumPath),
|
|
["/p/A/e3.mkv", "/p/A/e4.mkv", "/p/B/e1.mkv", "/p/B/e2.mkv"])
|
|
// toRemote leaves a non-mount path unchanged → the rsync source is set.
|
|
XCTAssertEqual(plan.first?.remotePath, "/p/A/e3.mkv")
|
|
}
|
|
|
|
func testPlanRespectsShowCount() {
|
|
let shows = [show("A", eps: 4), show("B", eps: 4), show("C", eps: 4)]
|
|
let cw = [cont("A", at: 1), cont("B", at: 1), cont("C", at: 1)]
|
|
let plan = OfflineCacheController.plan(
|
|
shows: shows, continueWatching: cw, recent: [],
|
|
fromContinueWatching: true, showCount: 2, episodesAhead: 1, includeAdult: false)
|
|
XCTAssertEqual(Set(plan.map(\.show)), ["A", "B"]) // C dropped by Z=2
|
|
}
|
|
|
|
func testPlanExcludesAdultUnlessIncluded() {
|
|
let shows = [show("Skin", eps: 3, category: "porn"), show("Tv", eps: 3)]
|
|
let cw = [cont("Skin", at: 1, adult: true), cont("Tv", at: 1)]
|
|
let excluded = OfflineCacheController.plan(
|
|
shows: shows, continueWatching: cw, recent: [],
|
|
fromContinueWatching: true, showCount: 5, episodesAhead: 1, includeAdult: false)
|
|
XCTAssertEqual(excluded.map(\.show), ["Tv"])
|
|
let included = OfflineCacheController.plan(
|
|
shows: shows, continueWatching: cw, recent: [],
|
|
fromContinueWatching: true, showCount: 5, episodesAhead: 1, includeAdult: true)
|
|
XCTAssertEqual(Set(included.map(\.show)), ["Skin", "Tv"])
|
|
}
|
|
|
|
func testPlanFromRecentlyAddedStartsAtFirstEpisode() {
|
|
let shows = [show("A", eps: 5)]
|
|
let plan = OfflineCacheController.plan(
|
|
shows: shows, continueWatching: [], recent: [show("A", eps: 5)],
|
|
fromContinueWatching: false, showCount: 5, episodesAhead: 3, includeAdult: false)
|
|
XCTAssertEqual(plan.map(\.plumPath), ["/p/A/e1.mkv", "/p/A/e2.mkv", "/p/A/e3.mkv"])
|
|
}
|
|
|
|
func testPlanIncludesEpisodesBehindResumePoint() {
|
|
let shows = [show("A", eps: 6)]
|
|
let cw = [cont("A", at: 3)]
|
|
let plan = OfflineCacheController.plan(
|
|
shows: shows, continueWatching: cw, recent: [],
|
|
fromContinueWatching: true, showCount: 5, episodesAhead: 2,
|
|
episodesBehind: 1, includeAdult: false)
|
|
XCTAssertEqual(plan.map(\.plumPath), ["/p/A/e2.mkv", "/p/A/e3.mkv", "/p/A/e4.mkv"])
|
|
}
|
|
|
|
func testParseRsyncProgress() {
|
|
XCTAssertEqual(OfflineCacheController.parseRsyncProgress("1.23M 45% 1.00MB/s 0:00:12") ?? -1,
|
|
0.45, accuracy: 0.001)
|
|
XCTAssertNil(OfflineCacheController.parseRsyncProgress("not progress"))
|
|
}
|
|
|
|
func testBudgetBytesUnlimitedPercentWhenCullingOff() {
|
|
var p = OfflineCachePolicy.defaults
|
|
p.cullEnabled = false
|
|
let cacheBytes: Int64 = 1_073_741_824
|
|
let budget = OfflineCacheController.budgetBytes(policy: p, cacheBytesOnDisk: cacheBytes)
|
|
let reserveCap = OfflineCacheController.maxCacheBytesRespectingReserve(
|
|
at: OfflineCacheController.destRoot.path, cacheBytes: cacheBytes,
|
|
reserveBytes: OfflineCacheController.reserveFreeBytes(policy: p))
|
|
// When culling off, budget must respect the reserve floor (may be < any percent).
|
|
XCTAssertEqual(budget, reserveCap)
|
|
}
|
|
|
|
func testBudgetBytesUsesPercentOfDriveStorage() throws {
|
|
var p = OfflineCachePolicy.defaults
|
|
p.cullEnabled = true
|
|
p.budgetPercent = 15
|
|
guard let total = OfflineCacheController.storageTotalBytes(at: OfflineCacheController.destRoot.path) else {
|
|
throw XCTSkip("drive storage size unavailable")
|
|
}
|
|
let budget = OfflineCacheController.budgetBytes(policy: p)
|
|
let expectedPercent = total * 15 / 100
|
|
// Budget is min(percent of drive, reserve floor); on small/loaded drives reserve may bind first.
|
|
XCTAssertLessThanOrEqual(budget, expectedPercent)
|
|
// Still positive and reasonable.
|
|
XCTAssertGreaterThan(budget, 0)
|
|
}
|
|
|
|
func testOfflinePolicyDefaults() {
|
|
let p = OfflineCachePolicy.defaults
|
|
XCTAssertEqual(p.episodesAhead, 3)
|
|
XCTAssertEqual(p.budgetPercent, 15)
|
|
XCTAssertEqual(p.reserveFreeGB, 5)
|
|
XCTAssertTrue(p.fromContinueWatching)
|
|
}
|
|
|
|
func testReserveFreeBytesDefault() {
|
|
XCTAssertEqual(OfflineCacheController.reserveFreeBytes(policy: .defaults), 5 * 1_073_741_824)
|
|
}
|
|
|
|
func testMissingEpisodesFiltersAbsentLocalCopies() {
|
|
let plan = [
|
|
OfflineEpisode(show: "A", label: "E1", plumPath: "/no/such/e1.mkv", remotePath: "/r/e1.mkv"),
|
|
OfflineEpisode(show: "A", label: "E2", plumPath: "/no/such/e2.mkv", remotePath: "/r/e2.mkv"),
|
|
]
|
|
XCTAssertEqual(OfflineCacheController.missingEpisodes(in: plan).count, 2)
|
|
}
|
|
|
|
func testBudgetBytesBoundedByReserveCap() {
|
|
var p = OfflineCachePolicy.defaults
|
|
p.cullEnabled = true
|
|
p.budgetPercent = 50
|
|
p.reserveFreeGB = 5
|
|
let cacheBytes: Int64 = 20 * 1_073_741_824
|
|
let reserveCap = OfflineCacheController.maxCacheBytesRespectingReserve(
|
|
at: "/", cacheBytes: cacheBytes,
|
|
reserveBytes: OfflineCacheController.reserveFreeBytes(policy: p))
|
|
let budget = OfflineCacheController.budgetBytes(policy: p, cacheBytesOnDisk: cacheBytes)
|
|
XCTAssertLessThanOrEqual(budget, reserveCap)
|
|
}
|
|
}
|