The Adult Home now mirrors the main Home's resume affordance: the last adult collection playlist that was fired is persisted to its own lane and surfaced as a "Continue Watching" card that re-queues it on the active host, skipping clips already finished and resuming the first unwatched one at its saved position. Separation: adult playlists get a dedicated AdultPlaylistStore (last-adult-playlist.json), distinct from the adult-stripped non-adult QueueStore (play-queue.json), so the two lanes never bleed together. The main Home's interrupt-recovery banner is filtered to non-adult snapshots, keeping adult titles off the regular Home. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
13 KiB
Swift
250 lines
13 KiB
Swift
import XCTest
|
|
@testable import TVAnarchyCore
|
|
|
|
@MainActor
|
|
final class PlaylistTests: XCTestCase {
|
|
/// Redirect the persisted queue to a fresh temp dir per test, so the controller
|
|
/// (which now persists every mutation) never reads or clobbers the real file,
|
|
/// and tests stay independent of each other's writes.
|
|
private var stateDir: URL!
|
|
override func setUp() {
|
|
super.setUp()
|
|
stateDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("tv-anarchy-tests-\(UUID().uuidString)", isDirectory: true)
|
|
setenv("TV_ANARCHY_STATE_DIR", stateDir.path, 1)
|
|
}
|
|
override func tearDown() {
|
|
unsetenv("TV_ANARCHY_STATE_DIR")
|
|
try? FileManager.default.removeItem(at: stateDir)
|
|
super.tearDown()
|
|
}
|
|
|
|
/// A series with one episode per name (labelled "e"); `file: false` = no playable file.
|
|
private func show(_ name: String, file: Bool = true) -> CachedShow {
|
|
CachedShow(name: name, rootDir: "/r/\(name)", category: "tv", kind: .series,
|
|
episodes: file ? [CachedEpisode(path: "/p/\(name).mkv", season: 1, episode: 1, label: "e")] : [])
|
|
}
|
|
/// A series spanning multiple episodes across two seasons (for expansion tests).
|
|
private func series(_ name: String, episodes: Int) -> CachedShow {
|
|
let eps = (1...episodes).map { i in
|
|
CachedEpisode(path: "/p/\(name)/s\(i <= 2 ? 1 : 2)e\(i).mkv",
|
|
season: i <= 2 ? 1 : 2, episode: i, label: "E\(i)")
|
|
}
|
|
return CachedShow(name: name, rootDir: "/r/\(name)", category: "tv", kind: .series, episodes: eps)
|
|
}
|
|
private func movie(_ name: String, category: String = "movies") -> CachedShow {
|
|
CachedShow(name: name, rootDir: "/r/\(name)", category: category, kind: .movie,
|
|
episodes: [CachedEpisode(path: "/p/\(name).mkv", season: 0, episode: 0, label: name)])
|
|
}
|
|
|
|
func testItemForShowNeedsAFile() {
|
|
XCTAssertNotNil(PlaylistController.item(for: show("Psych")))
|
|
XCTAssertNil(PlaylistController.item(for: show("Empty", file: false)))
|
|
}
|
|
|
|
func testAppendDedupesById() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
pc.append(show: show("A")); pc.append(show: show("A")); pc.append(show: show("B"))
|
|
XCTAssertEqual(pc.queue.map(\.title), ["A · e", "B · e"])
|
|
}
|
|
|
|
func testMoveMatchesSwiftUISemantics() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
for n in ["S1", "S2", "S3", "S4"] { pc.append(show: show(n)) }
|
|
pc.move(fromOffsets: IndexSet(integer: 0), toOffset: 3) // S1 → between S3 and S4
|
|
XCTAssertEqual(pc.queue.map(\.title), ["S2 · e", "S3 · e", "S1 · e", "S4 · e"])
|
|
}
|
|
|
|
func testRemoveAtOffsets() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
for n in ["S1", "S2", "S3"] { pc.append(show: show(n)) }
|
|
pc.remove(atOffsets: IndexSet(integer: 1))
|
|
XCTAssertEqual(pc.queue.map(\.title), ["S1 · e", "S3 · e"])
|
|
}
|
|
|
|
// MARK: whole-show / per-episode enqueue
|
|
|
|
func testAppendSeriesExpandsAllEpisodesInOrder() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
pc.append(show: series("Show", episodes: 3))
|
|
XCTAssertEqual(pc.queue.map(\.title), ["Show · E1", "Show · E2", "Show · E3"])
|
|
XCTAssertEqual(pc.queue.map(\.id), ["/p/Show/s1e1.mkv", "/p/Show/s1e2.mkv", "/p/Show/s2e3.mkv"])
|
|
}
|
|
|
|
func testAppendMovieAddsSingleFile() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
pc.append(show: movie("Film"))
|
|
XCTAssertEqual(pc.queue.map(\.title), ["Film"])
|
|
}
|
|
|
|
func testAppendSingleEpisode() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
let s = series("Show", episodes: 3)
|
|
pc.append(episode: s.episodes[1], of: s)
|
|
XCTAssertEqual(pc.queue.map(\.id), ["/p/Show/s1e2.mkv"])
|
|
pc.append(episode: s.episodes[1], of: s) // dedupes
|
|
XCTAssertEqual(pc.queue.count, 1)
|
|
}
|
|
|
|
// MARK: unified playlist (play from here) + season-0/specials ordering
|
|
|
|
/// A show whose season 0 holds the "movies" (Daria's case). Seasons must order
|
|
/// 1,2,…,0 — specials/movies LAST — and orderedEpisodes follow that.
|
|
private func showWithMovies() -> CachedShow {
|
|
let eps = [
|
|
CachedEpisode(path: "/p/D/s1e1.mkv", season: 1, episode: 1, label: "S1E1"),
|
|
CachedEpisode(path: "/p/D/s1e2.mkv", season: 1, episode: 2, label: "S1E2"),
|
|
CachedEpisode(path: "/p/D/s2e1.mkv", season: 2, episode: 1, label: "S2E1"),
|
|
CachedEpisode(path: "/p/D/movie1.mkv", season: 0, episode: 1, label: "Is It Fall Yet?"),
|
|
CachedEpisode(path: "/p/D/movie2.mkv", season: 0, episode: 2, label: "Is It College Yet?"),
|
|
]
|
|
return CachedShow(name: "Daria", rootDir: "/r/Daria", category: "cartoons",
|
|
kind: .series, episodes: eps)
|
|
}
|
|
|
|
func testSeasonZeroSortsLastAsMovies() {
|
|
let d = showWithMovies()
|
|
XCTAssertEqual(d.seasons, [1, 2, 0]) // specials/movies last
|
|
XCTAssertEqual(d.seasonLabel(0), "Specials & Movies")
|
|
XCTAssertEqual(d.orderedEpisodes.map(\.label).last, "Is It College Yet?")
|
|
}
|
|
|
|
func testPlayFromHereQueuesRestThenMovies() {
|
|
// Starting Daria S2 → queue S2E1, then the movies (season 0) at the end.
|
|
let d = showWithMovies()
|
|
let slice = PlaylistController.fromHere(show: d, startPath: "/p/D/s2e1.mkv")
|
|
XCTAssertEqual(slice.map(\.path), ["/p/D/s2e1.mkv", "/p/D/movie1.mkv", "/p/D/movie2.mkv"])
|
|
}
|
|
|
|
@MainActor
|
|
func testLoadFromHerePopulatesQueue() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
pc.loadFromHere(show: showWithMovies(), startPath: "/p/D/s1e2.mkv")
|
|
// from S1E2: S1E2, S2E1, then the two movies.
|
|
XCTAssertEqual(pc.queue.map(\.path),
|
|
["/p/D/s1e2.mkv", "/p/D/s2e1.mkv", "/p/D/movie1.mkv", "/p/D/movie2.mkv"])
|
|
}
|
|
|
|
/// The Daria bug: continuing from a S2 episode must queue S3+ (not just play one
|
|
/// episode). `loadFromHere` from a mid-show episode includes every later one.
|
|
@MainActor
|
|
func testContinueFromSeason2QueuesSeason3() {
|
|
// Daria-like: S1(2) S2(2) S3(2) + a movie.
|
|
let eps = [
|
|
CachedEpisode(path: "/p/D/s1e1.mkv", season: 1, episode: 1, label: "S1E1"),
|
|
CachedEpisode(path: "/p/D/s1e2.mkv", season: 1, episode: 2, label: "S1E2"),
|
|
CachedEpisode(path: "/p/D/s2e1.mkv", season: 2, episode: 1, label: "S2E1"),
|
|
CachedEpisode(path: "/p/D/s2e2.mkv", season: 2, episode: 2, label: "S2E2"),
|
|
CachedEpisode(path: "/p/D/s3e1.mkv", season: 3, episode: 1, label: "S3E1"),
|
|
CachedEpisode(path: "/p/D/s3e2.mkv", season: 3, episode: 2, label: "S3E2"),
|
|
CachedEpisode(path: "/p/D/movie.mkv", season: 0, episode: 1, label: "Movie"),
|
|
]
|
|
let daria = CachedShow(name: "Daria", rootDir: "/r/D", category: "cartoons", kind: .series, episodes: eps)
|
|
let slice = PlaylistController.fromHere(show: daria, startPath: "/p/D/s2e1.mkv").map(\.path)
|
|
XCTAssertEqual(slice, ["/p/D/s2e1.mkv", "/p/D/s2e2.mkv", "/p/D/s3e1.mkv",
|
|
"/p/D/s3e2.mkv", "/p/D/movie.mkv"])
|
|
XCTAssertTrue(slice.contains("/p/D/s3e1.mkv")) // S3 IS queued
|
|
}
|
|
|
|
// MARK: persistence
|
|
|
|
func testQueuePersistsAcrossControllers() {
|
|
let pc = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
pc.append(show: series("Show", episodes: 2))
|
|
let restored = PlaylistController(library: LibraryController(watchHistory: WatchHistoryController()))
|
|
XCTAssertEqual(restored.queue.map(\.id), ["/p/Show/s1e1.mkv", "/p/Show/s1e2.mkv"])
|
|
}
|
|
|
|
func testPersistedQueueNeverContainsAdult() {
|
|
let adult = QueueItem(id: "x", title: "clip", path: "/m/porn/clip.mp4")
|
|
XCTAssertTrue(adult.isAdult)
|
|
QueueStore.save([QueueItem(id: "ok", title: "ep", path: "/m/tv/ep.mkv"), adult])
|
|
XCTAssertEqual(QueueStore.load().map(\.path), ["/m/tv/ep.mkv"]) // adult stripped on save
|
|
}
|
|
|
|
// MARK: SmartPlaylist resolver (pure)
|
|
|
|
private func catShow(_ name: String, _ category: String) -> CachedShow {
|
|
CachedShow(name: name, rootDir: "/r/\(name)", category: category, kind: .series,
|
|
episodes: [CachedEpisode(path: "/p/\(name).mkv", season: 1, episode: 1, label: "e")])
|
|
}
|
|
|
|
func testSmartResolveExcludesPornAndFiltersCategory() {
|
|
let shows = [catShow("A", "anime"), catShow("T", "tv"), catShow("P", "porn")]
|
|
let all = SmartPlaylist(name: "all", shuffle: false).resolve(from: shows, watchedPaths: [])
|
|
XCTAssertEqual(Set(all.map(\.name)), ["A", "T"]) // porn never included
|
|
let anime = SmartPlaylist(name: "anime", category: "anime", shuffle: false)
|
|
.resolve(from: shows, watchedPaths: [])
|
|
XCTAssertEqual(anime.map(\.name), ["A"])
|
|
}
|
|
|
|
func testQueueSnapshotIsAdultOnlyWhenAllAdult() {
|
|
let adult = QueueItem(id: "a", title: "clip", path: "/m/porn/clip.mp4")
|
|
let tv = QueueItem(id: "t", title: "ep", path: "/m/tv/ep.mkv")
|
|
XCTAssertTrue(QueueSnapshot(items: [adult], resumePath: nil, resumeSeconds: nil).isAdult)
|
|
XCTAssertFalse(QueueSnapshot(items: [adult, tv], resumePath: nil, resumeSeconds: nil).isAdult)
|
|
XCTAssertFalse(QueueSnapshot(items: [], resumePath: nil, resumeSeconds: nil).isAdult)
|
|
}
|
|
|
|
#if ENABLE_ADULT
|
|
func testAdultPlaylistStoreRoundTripsAndIsItsOwnLane() {
|
|
let items = [QueueItem(id: "1", title: "a", path: "/m/porn/a.mp4"),
|
|
QueueItem(id: "2", title: "b", path: "/m/porn/b.mp4")]
|
|
AdultPlaylistStore.save(AdultPlaylistSnapshot(label: "Goon", items: items))
|
|
let loaded = AdultPlaylistStore.load()
|
|
XCTAssertEqual(loaded?.label, "Goon")
|
|
XCTAssertEqual(loaded?.items.map(\.path), items.map(\.path)) // adult survives — its own lane
|
|
// The non-adult queue file is untouched by the adult lane.
|
|
XCTAssertTrue(QueueStore.load().isEmpty)
|
|
AdultPlaylistStore.save(nil) // clearing removes the file
|
|
XCTAssertNil(AdultPlaylistStore.load())
|
|
}
|
|
|
|
func testAdultPlaylistStoreRejectsNonAdultPayload() {
|
|
// A tampered/legacy file holding a non-adult path must not load (belt-and-braces).
|
|
AdultPlaylistStore.save(AdultPlaylistSnapshot(
|
|
label: "x", items: [QueueItem(id: "t", title: "ep", path: "/m/tv/ep.mkv")]))
|
|
XCTAssertNil(AdultPlaylistStore.load())
|
|
}
|
|
|
|
func testPrettyPornTitleStripsScrapePrefix() {
|
|
XCTAssertEqual(
|
|
PlaylistController.prettyPornTitle("/m/porn/EPORNER.COM - [9kO7IPk6qIG] Good Goon Mashup (1080).mp4"),
|
|
"Good Goon Mashup (1080)")
|
|
// non-eporner names pass through (minus extension)
|
|
XCTAssertEqual(PlaylistController.prettyPornTitle("/m/porn/My Clip.mp4"), "My Clip")
|
|
}
|
|
#endif
|
|
|
|
func testSmartResolveUnwatchedAndLimit() {
|
|
let shows = [catShow("A", "tv"), catShow("B", "tv"), catShow("C", "tv")]
|
|
let watched: Set<String> = [MediaPaths.toRemote("/p/B.mkv")]
|
|
let unwatched = SmartPlaylist(name: "u", shuffle: false, unwatchedOnly: true)
|
|
.resolve(from: shows, watchedPaths: watched)
|
|
XCTAssertEqual(Set(unwatched.map(\.name)), ["A", "C"]) // B has a resume position
|
|
let capped = SmartPlaylist(name: "lim", shuffle: false, limit: 2)
|
|
.resolve(from: shows, watchedPaths: [])
|
|
XCTAssertEqual(capped.count, 2)
|
|
}
|
|
|
|
@MainActor
|
|
func testGenerateContinueWatchingExpandsTails() {
|
|
let lib = LibraryController(watchHistory: WatchHistoryController())
|
|
let d = series("Daria", episodes: 4)
|
|
lib._test_setShows([d])
|
|
// Simulate a continue rail entry pointing at E2 (e.g. finished E1).
|
|
lib._test_setContinueWatching([
|
|
ContinueItem(title: "Daria · E2", path: "/p/Daria/s1e2.mkv", show: "Daria",
|
|
season: 1, episode: 2, source: "test")
|
|
])
|
|
let pc = PlaylistController(library: lib)
|
|
pc.generate(.continueWatching, limit: 10)
|
|
// Should have queued from the pointed ep through the end of show (note the
|
|
// series() helper puts ep 3/4 under season 2 with e3/e4 in the path name).
|
|
XCTAssertEqual(pc.queue.map(\.path), [
|
|
"/p/Daria/s1e2.mkv",
|
|
"/p/Daria/s2e3.mkv",
|
|
"/p/Daria/s2e4.mkv"
|
|
])
|
|
}
|
|
}
|