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>
101 lines
3.7 KiB
Swift
101 lines
3.7 KiB
Swift
import XCTest
|
|
@testable import TVAnarchyCore
|
|
|
|
/// The player's sleep timer: arming a preset sets a fire date ~N minutes out;
|
|
/// end-of-episode arms without a fire date; cancel clears it.
|
|
final class SleepTimerTests: XCTestCase {
|
|
@MainActor
|
|
func testTimedArmSetsFireDate() {
|
|
let p = PlayerController()
|
|
XCTAssertFalse(p.sleepArmed)
|
|
XCTAssertNil(p.sleepFiresAt)
|
|
|
|
p.setSleepTimer(minutes: 30)
|
|
XCTAssertTrue(p.sleepArmed)
|
|
let secs = try? XCTUnwrap(p.sleepFiresAt).timeIntervalSinceNow
|
|
XCTAssertNotNil(secs)
|
|
XCTAssertTrue((29 * 60)...(30 * 60) ~= (secs ?? 0), "≈30 min ahead, got \(secs ?? -1)")
|
|
|
|
p.cancelSleep()
|
|
XCTAssertFalse(p.sleepArmed)
|
|
XCTAssertEqual(p.sleep, .off)
|
|
XCTAssertNil(p.sleepFiresAt)
|
|
}
|
|
|
|
@MainActor
|
|
func testEndOfEpisodeHasNoFireDate() {
|
|
let p = PlayerController()
|
|
p.setSleepAtEpisodeEnd()
|
|
XCTAssertEqual(p.sleep, .endOfEpisode)
|
|
XCTAssertTrue(p.sleepArmed)
|
|
XCTAssertNil(p.sleepFiresAt, "end-of-episode fires on item end, not a clock time")
|
|
}
|
|
|
|
@MainActor
|
|
func testZeroMinutesIsOff() {
|
|
let p = PlayerController()
|
|
p.setSleepTimer(minutes: 0)
|
|
XCTAssertFalse(p.sleepArmed)
|
|
}
|
|
|
|
// MARK: end-of-episode firing decision (the bug: it never fired)
|
|
|
|
private func status(title: String? = nil, pos: Int? = nil,
|
|
position: Double? = nil, duration: Double? = nil) -> PlaybackStatus {
|
|
PlaybackStatus(playing: true, title: title, position: position,
|
|
duration: duration, playlistPos: pos)
|
|
}
|
|
|
|
func testFiresWhenPlaylistAdvances() {
|
|
XCTAssertTrue(PlayerController.shouldFireEndOfEpisode(
|
|
armedTitle: "S01E15", armedPos: 2, status: status(title: "S01E15", pos: 3)))
|
|
}
|
|
|
|
func testFiresWhenTitleChanges_noPlaylistPos() {
|
|
XCTAssertTrue(PlayerController.shouldFireEndOfEpisode(
|
|
armedTitle: "Ep A", armedPos: nil, status: status(title: "Ep B")))
|
|
}
|
|
|
|
func testFiresAtEndOfLastItem() {
|
|
// Last episode: nothing advances, title unchanged — must still fire when
|
|
// the item reaches its end (this is what was dead before for mpv).
|
|
XCTAssertTrue(PlayerController.shouldFireEndOfEpisode(
|
|
armedTitle: "Finale", armedPos: 12,
|
|
status: status(title: "Finale", pos: 12, position: 2599, duration: 2600)))
|
|
}
|
|
|
|
func testDoesNotFireMidEpisode() {
|
|
XCTAssertFalse(PlayerController.shouldFireEndOfEpisode(
|
|
armedTitle: "S01E15", armedPos: 2,
|
|
status: status(title: "S01E15", pos: 2, position: 600, duration: 2600)))
|
|
}
|
|
|
|
// MARK: auto-advance — only when stuck at end (not on top of host auto-advance)
|
|
|
|
func testIsAtEpisodeEnd() {
|
|
XCTAssertTrue(PlayerController.isAtEpisodeEnd(
|
|
status: status(position: 2599, duration: 2600)))
|
|
}
|
|
|
|
func testIsAtEpisodeEnd_falseMidEpisode() {
|
|
XCTAssertFalse(PlayerController.isAtEpisodeEnd(
|
|
status: status(position: 600, duration: 2600)))
|
|
}
|
|
|
|
func testIsAtEpisodeEnd_needsDuration() {
|
|
XCTAssertFalse(PlayerController.isAtEpisodeEnd(
|
|
status: status(position: 100, duration: nil)))
|
|
}
|
|
|
|
func testIsEpisodeFinished_at92Percent() {
|
|
XCTAssertTrue(PlayerController.isEpisodeFinished(
|
|
status: status(position: 920, duration: 1000)))
|
|
XCTAssertFalse(PlayerController.isEpisodeFinished(
|
|
status: status(position: 900, duration: 1000)))
|
|
}
|
|
|
|
func testIsAtEpisodeEnd_falseWhenVlcStoppedWithZeroClocks() {
|
|
XCTAssertFalse(PlayerController.isAtEpisodeEnd(
|
|
status: PlaybackStatus(playing: false, position: 0, duration: 0)))
|
|
}
|
|
}
|