tv-anarchy/Tests/TVAnarchyCoreTests/LibraryScannerTests.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

267 lines
16 KiB
Swift

import XCTest
@testable import TVAnarchyCore
/// Locks the port of tv-anarchy-mcp's library.ts scan logic name
/// normalization and SxxEyy parsing must match so black's `play-show` fuzzy
/// match resolves the same titles the Swift scan produces.
final class LibraryScannerTests: XCTestCase {
func testParseSxxEyy() {
XCTAssertEqual(LibraryScanner.parseSxxEyy("Psych.S01E04.720p.mkv").map { [$0.0, $0.1] }, [1, 4])
XCTAssertEqual(LibraryScanner.parseSxxEyy("show s02e15 web.mp4").map { [$0.0, $0.1] }, [2, 15])
XCTAssertEqual(LibraryScanner.parseSxxEyy("Anime - S01E120.mkv").map { [$0.0, $0.1] }, [1, 120])
XCTAssertNil(LibraryScanner.parseSxxEyy("just-a-movie-1080p.mkv"))
}
func testNormalizeStripsReleaseNoise() {
XCTAssertEqual(LibraryScanner.normalizeShowName("Psych.S01.1080p.BluRay.x265-GROUP"), "Psych")
XCTAssertEqual(LibraryScanner.normalizeShowName("Community (2009) [1080p]"), "Community")
XCTAssertEqual(LibraryScanner.normalizeShowName("The.Office.US.Complete.720p"), "The Office US")
XCTAssertEqual(LibraryScanner.normalizeShowName("Bridgerton.S02.HEVC"), "Bridgerton")
}
func testNormalizeKeepsPlainName() {
XCTAssertEqual(LibraryScanner.normalizeShowName("Psych"), "Psych")
XCTAssertEqual(LibraryScanner.normalizeShowName("It's Always Sunny"), "It's Always Sunny")
}
func testRegistryParse() {
let md = """
# Media Registry
_Generated 2026-05-28 on black_
## tv (164)
- Santa Clarita Diet — S02 720p x264
- Agatha All Along (2024)
- Babylon 5 (1993) — S01-S05
"""
let shows = RegistryIngest.parse(md)
let names = shows.map(\.name)
XCTAssertTrue(names.contains("Santa Clarita Diet"), "got \(names)")
XCTAssertTrue(names.contains("Agatha All Along"))
XCTAssertTrue(names.contains("Babylon 5"))
XCTAssertFalse(names.contains(where: { $0.contains("Generated") }), "header leaked: \(names)")
XCTAssertTrue(shows.allSatisfy { $0.episodes.isEmpty })
// Season range from the note known season count (not "0/0").
let babylon = shows.first { $0.name == "Babylon 5" }
XCTAssertEqual(babylon?.seasonCount, 5) // S01-S05
XCTAssertEqual(babylon?.knownSeasonCount, 5)
XCTAssertEqual(shows.first { $0.name == "Santa Clarita Diet" }?.seasonCount, 1) // S02
XCTAssertEqual(shows.first { $0.name == "Agatha All Along" }?.seasonCount, nil) // no note
XCTAssertEqual(babylon?.countSummary, "5 seasons")
}
func testSeasonCountParsing() {
XCTAssertEqual(RegistryIngest.seasonCount(from: "S01-S05 720p H264"), 5)
XCTAssertEqual(RegistryIngest.seasonCount(from: "S02 720p x264"), 1)
XCTAssertEqual(RegistryIngest.seasonCount(from: "S01-S06"), 6)
XCTAssertNil(RegistryIngest.seasonCount(from: "720p BluRay x265"))
}
func testFranchisePrefixBoundary() {
XCTAssertTrue(LibraryController.nameHasPrefix("psych 2 lassie come home", "psych"))
XCTAssertTrue(LibraryController.nameHasPrefix("psych: the movie", "psych"))
XCTAssertTrue(LibraryController.nameHasPrefix("psych 3 this is gus", "psych"))
XCTAssertFalse(LibraryController.nameHasPrefix("psycho", "psych"))
XCTAssertFalse(LibraryController.nameHasPrefix("psychoelectric girl", "psych"))
XCTAssertFalse(LibraryController.nameHasPrefix("psych", "psych"))
}
func testParseYear() {
XCTAssertEqual(LibraryScanner.parseYear("/m/tv/Psych (2006)/Season 1/S01E06.mkv"), 2006)
XCTAssertEqual(LibraryScanner.parseYear("/m/movies/The Matrix 1999 1080p.mkv"), 1999)
XCTAssertNil(LibraryScanner.parseYear("/m/movies/No Year Here.mkv"))
}
func testComponentsAfterAndSampleDetection() {
XCTAssertEqual(LibraryScanner.componentsAfter(mediaRoot: "/media", path: "/media/movies/Inception"),
["movies", "Inception"])
XCTAssertEqual(LibraryScanner.componentsAfter(mediaRoot: "/media", path: "/media/tv"), ["tv"])
XCTAssertTrue(LibraryScanner.isSampleOrExtra("/m/Film/sample.mkv"))
XCTAssertTrue(LibraryScanner.isSampleOrExtra("/m/Film/Film-trailer.mp4"))
XCTAssertFalse(LibraryScanner.isSampleOrExtra("/m/Film/Film.1080p.mkv"))
}
/// scan() must bucket by category, keep SxxEyy folders as series, and capture
/// non-episodic videos as movies (folder largest non-sample file; loose
/// files one each) the latter were silently dropped before.
func testScanCategoriesSeriesAndMovies() throws {
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent("plumscan-\(UUID().uuidString)")
let tv = root.appendingPathComponent("tv/Psych/Season 1")
let film = root.appendingPathComponent("movies/Inception (2010)")
try fm.createDirectory(at: tv, withIntermediateDirectories: true)
try fm.createDirectory(at: film, withIntermediateDirectories: true)
try Data(count: 10).write(to: tv.appendingPathComponent("Psych.S01E01.720p.mkv"))
try Data(count: 10).write(to: film.appendingPathComponent("sample.mkv"))
try Data(count: 9000).write(to: film.appendingPathComponent("Inception.2010.1080p.mkv"))
try Data(count: 500).write(to: root.appendingPathComponent("movies/Loose.Movie.2020.1080p.mkv"))
setenv("MEDIA_ROOTS", root.path, 1)
defer { unsetenv("MEDIA_ROOTS"); try? fm.removeItem(at: root) }
let shows = LibraryScanner.scan()
let psych = shows.first { $0.name == "Psych" }
XCTAssertEqual(psych?.kind, .series)
XCTAssertEqual(psych?.category, "tv")
XCTAssertEqual(psych?.episodes.count, 1)
let inception = shows.first { $0.name == "Inception" }
XCTAssertEqual(inception?.kind, .movie)
XCTAssertEqual(inception?.category, "movies")
XCTAssertEqual(inception?.episodes.first?.path.hasSuffix("Inception.2010.1080p.mkv"), true,
"movie folder should pick the main file, not the sample")
let loose = shows.first { $0.name == "Loose Movie" }
XCTAssertEqual(loose?.kind, .movie)
XCTAssertEqual(loose?.category, "movies")
}
/// Bridgerton ships as sibling top-level folders (Bridgerton.S01, .S02, .S03)
/// that each scan to a separate "Bridgerton" series. mergeSeriesByName must
/// collapse them into one show, unioning seasons and deduping by SxxEyy, while
/// leaving distinct titles and movies untouched.
func testMergeSeriesByName() {
func series(_ name: String, _ cat: String, _ eps: [(Int, Int)], year: Int? = nil) -> CachedShow {
CachedShow(name: name, rootDir: "/m/\(cat)/\(name)", category: cat, kind: .series,
episodes: eps.map { CachedEpisode(path: "/m/\(cat)/\(name)/S\($0.0)E\($0.1).mkv",
season: $0.0, episode: $0.1, label: "E\($0.1)") },
year: year)
}
// Bridgerton ships as three sibling season folders DISJOINT seasons
// collapse into one show.
let s1 = series("Bridgerton", "tv", [(1, 1), (1, 2)], year: 2020)
let s2 = series("Bridgerton", "tv", [(2, 1)], year: 2022)
let s3 = series("Bridgerton", "tv", [(3, 1)], year: 2024)
// Two genuinely different shows that share a name + category, both starting
// at S01 OVERLAPPING seasons must stay separate, not interleave.
let dupA = series("Cherry Magic", "anime", [(1, 1), (1, 2)], year: 2020)
let dupB = series("Cherry Magic", "anime", [(1, 1)], year: 2024)
let other = series("Psych", "tv", [(1, 1)])
let movie = CachedShow(name: "Bridgerton", rootDir: "/m/movies/Bridgerton",
category: "movies", kind: .movie,
episodes: [CachedEpisode(path: "/m/movies/Bridgerton/film.mkv",
season: 0, episode: 0, label: "film")])
let merged = LibraryScanner.mergeSeriesByName([s1, s2, s3, dupA, dupB, other, movie])
let bridgerton = merged.filter { $0.name == "Bridgerton" && $0.kind == .series }
XCTAssertEqual(bridgerton.count, 1, "three disjoint season-folders collapse to one show")
XCTAssertEqual(bridgerton.first?.episodes.count, 4, "S01E01,S01E02,S02E01,S03E01")
XCTAssertEqual(bridgerton.first?.year, 2020, "earliest year wins")
// overlapping-season same-name shows are kept distinct
XCTAssertEqual(merged.filter { $0.name == "Cherry Magic" && $0.kind == .series }.count, 2,
"distinct same-name shows must not interleave")
// distinct series and same-name movie are left alone
XCTAssertEqual(merged.filter { $0.name == "Psych" }.count, 1)
XCTAssertEqual(merged.filter { $0.kind == .movie }.count, 1)
}
/// Loose files sharing a category root must each stay a movie even when one of
/// them has a stray SxxEyy in its name that match must NOT flip the whole pile
/// into a single "series" and drop the rest (the bug that ate ~940 loose files).
func testLooseFilesWithStrayEpisodeMatchStayMovies() throws {
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent("plumscan-\(UUID().uuidString)")
let cat = root.appendingPathComponent("clips")
try fm.createDirectory(at: cat, withIntermediateDirectories: true)
try Data(count: 100).write(to: cat.appendingPathComponent("A Random Clip.mp4"))
try Data(count: 100).write(to: cat.appendingPathComponent("Another Clip.mp4"))
try Data(count: 100).write(to: cat.appendingPathComponent("Some S01E01 thing.mp4"))
setenv("MEDIA_ROOTS", root.path, 1)
defer { unsetenv("MEDIA_ROOTS"); try? fm.removeItem(at: root) }
let clips = LibraryScanner.scan().filter { $0.category == "clips" }
XCTAssertEqual(clips.count, 3, "all 3 loose files are movies; the stray SxxEyy one must not eat the others")
XCTAssertTrue(clips.allSatisfy { $0.kind == .movie })
}
@MainActor
func testLaunchRequestRoutingByKind() {
let lib = LibraryController(watchHistory: WatchHistoryController())
let ep = CachedEpisode(path: "/m/Psych/S01E04.mkv", season: 1, episode: 4, label: "E4")
let show = CachedShow(name: "Psych", rootDir: "/m/Psych", episodes: [ep])
// black plays a CHOSEN episode by its exact path (not by re-resolving the
// show name which misses a merged multi-season-folder show like Daria S3).
XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .blacktv),
.file(path: "/m/Psych/S01E04.mkv"))
// black with no episode resume target by PATH (here the only episode); no
// name resolution anywhere anymore.
XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .blacktv),
.file(path: "/m/Psych/S01E04.mkv"))
// VLC needs a concrete file path
XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .vlc),
.file(path: "/m/Psych/S01E04.mkv"))
XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .vlc),
.file(path: "/m/Psych/S01E04.mkv"))
}
/// Regression for the Daria bug but general: a show MERGED from separate
/// per-season folders (each season under its own top-level dir) must play EVERY
/// season's episode by that episode's real path on black, not by re-resolving the
/// show name (which found only the primary folder S3 unplayable). Covers the
/// whole class of `mergeSeriesByName` shows, not just Daria.
@MainActor
func testMergedMultiFolderShowPlaysEachSeasonByPath() {
let lib = LibraryController(watchHistory: WatchHistoryController())
// Episodes live under DIFFERENT season folders, as on disk after a merge.
let s2 = CachedEpisode(path: "/m/cartoons/Daria Season 2 [grpA]/Daria S02E01.mkv", season: 2, episode: 1, label: "S2E1")
let s3 = CachedEpisode(path: "/m/cartoons/Daria Season 3 [grpB]/Daria S03E01.mkv", season: 3, episode: 1, label: "S3E1")
let s4 = CachedEpisode(path: "/m/cartoons/Daria Season 4 [grpC]/Daria S04E01.mkv", season: 4, episode: 1, label: "S4E1")
// rootDir is the Season-4 folder (what the merge happened to pick) the very
// mismatch that broke name-resolution for S2/S3.
let daria = CachedShow(name: "Daria", rootDir: "/m/cartoons/Daria Season 4 [grpC]",
category: "cartoons", kind: .series, episodes: [s2, s3, s4])
for ep in [s2, s3, s4] {
XCTAssertEqual(lib.launchRequest(show: daria, episode: ep, targetKind: .mpvIPC),
.file(path: ep.path), "S\(ep.season) must play its own folder's file")
}
// and the unified queue from S2 walks straight into S3+ by path (no name step).
let slice = PlaylistController.fromHere(show: daria, startPath: s2.path).map(\.path)
XCTAssertEqual(slice, [s2.path, s3.path, s4.path])
XCTAssertTrue(slice.contains(s3.path)) // S3 is reachable the original complaint
// Show-level Resume is ALSO path-based now (no `.resume` name lookup):
// an in-progress S3 resumes S3 at its position
let positions = [MediaPaths.toRemote(s3.path): 600.0]
let t = LibraryController.resumeTarget(for: daria, positions: positions, played: [])
XCTAssertEqual(t?.episode.path, s3.path)
XCTAssertEqual(t?.position, 600)
// and with nothing in progress, Resume = the next unwatched (S2 watched S3).
let next = LibraryController.resumeTarget(
for: daria, positions: [:], played: [MediaPaths.toRemote(s2.path)])
XCTAssertEqual(next?.episode.path, s3.path)
XCTAssertNil(next?.position)
}
/// Regression for "press Resume on Daria it starts S2". A stray, sub-threshold
/// VLC scrub on the FURTHEST-touched episode (S2E6 @82s) must not pin Resume to S2:
/// that episode is behind the frontier, so Resume advances to the next one (S3E1)
/// from its start. A larger position on an EARLIER episode (S2E5 @838s) is
/// irrelevant only the furthest-touched episode drives the frontier. A genuinely
/// mid-watch position ( `resumeMidEpisodeFloor`) on the furthest episode still
/// resumes it in place.
@MainActor
func testResumeAdvancesPastStrayScrubOnFurthestEpisode() {
let s2e5 = CachedEpisode(path: "/m/cartoons/Daria Season 2 [grpA]/Daria S02E05.mkv", season: 2, episode: 5, label: "S2E5")
let s2e6 = CachedEpisode(path: "/m/cartoons/Daria Season 2 [grpA]/Daria S02E06.mkv", season: 2, episode: 6, label: "S2E6")
let s3e1 = CachedEpisode(path: "/m/cartoons/Daria Season 3 [grpB]/Daria S03E01.mkv", season: 3, episode: 1, label: "S3E1")
let s3e2 = CachedEpisode(path: "/m/cartoons/Daria Season 3 [grpB]/Daria S03E02.mkv", season: 3, episode: 2, label: "S3E2")
let daria = CachedShow(name: "Daria", rootDir: "/m/cartoons/Daria Season 3 [grpB]",
category: "cartoons", kind: .series, episodes: [s2e5, s2e6, s3e1, s3e2])
// Stray scrubs across S2 furthest is S2E6 @82s (below the floor) advance to S3E1.
let stray = [MediaPaths.toRemote(s2e5.path): 838.0, MediaPaths.toRemote(s2e6.path): 82.0]
let r = LibraryController.resumeTarget(for: daria, positions: stray, played: [])
XCTAssertEqual(r?.episode.path, s3e1.path, "advance past the furthest scrub to the next episode")
XCTAssertNil(r?.position)
// But a real mid-watch position on the furthest episode resumes it in place.
let midWatch = [MediaPaths.toRemote(s3e1.path): 600.0]
let r2 = LibraryController.resumeTarget(for: daria, positions: midWatch, played: [])
XCTAssertEqual(r2?.episode.path, s3e1.path)
XCTAssertEqual(r2?.position, 600)
}
}