tv-anarchy/Tests/TVAnarchyCoreTests/LibraryScannerTests.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename
the auto-commit service couldn't stage — it git-add'd the old, now-gone
paths and aborted every cycle), and commits the accumulated work:

- Library: black-built index fast path (LibraryIndex + scanFromIndex) with
  NFS-walk fallback; incremental --add on download-complete; mtime staleness
  gate; loose-file series-collapse fix; determinate scan/index progress.
- Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year).
- Player: sleep timer (timed + end-of-episode); visibility-gated polling.
- Home: Continue Watching cover art + live refresh; Recently Added; adult gate.
- Logs: multi-line selection + copy; truncated giant tx-list errors.
- Hover previews (opt-in) via black ffmpeg + scp.

Also gitignores foreign project trees (governor/mcp/fleet/recommender) that
sit in this directory but belong to their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:04:22 -07:00

198 lines
11 KiB
Swift

import XCTest
@testable import TVAnarchyCore
/// Locks the port of plum-control-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()
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 is library-aware: resolve by name + S/E
XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .blacktv),
.show(name: "Psych", season: 1, episode: 4))
// black with no episode resume the show
XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .blacktv),
.resume(name: "Psych"))
// 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"))
}
}