tv-anarchy/Tests/TVAnarchyCoreTests/OfflineCacheTests.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

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)
}
}