tv-anarchy/Tests/TVAnarchyCoreTests/PlaylistTests.swift
Natalie d793d54dfb feat(adult): Continue Watching last adult playlist + separate adult/non-adult playlist lanes
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>
2026-06-30 03:28:12 -04:00

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