import AppKit @main @MainActor struct CastPlaybackCheck { static func require(_ condition: @autoclosure () -> Bool, _ message: String) { if !condition() { fatalError("FAIL: " + message) } } // A real I/O watchdog, not a clock used by the playback behavior under test. static func until(_ model: PlayerModel, _ message: String, permitError: Bool = false, _ predicate: () -> Bool) async throws { let deadline = ContinuousClock.now.advanced(by: .seconds(45)) while !predicate(), ContinuousClock.now < deadline { if !permitError, let error = model.commandError { fatalError("FAIL: " + error) } try await Task.sleep(for: .milliseconds(20)) } require(predicate(), message) } static func queued(_ model: PlayerModel, source: URL, port: UInt16, gates: URL) async throws { model.openFiles([source, source, source]) try await until(model, "queue local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek } model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port)) try await until(model, "queue receiver starts") { model.castDevice?.id == "loopback" && !model.castConnecting && model.state.status == PLAYER_PLAYING } let initial = model.queue.currentID model.advanceQueue(1) try await until(model, "manual queue next retains receiver") { model.queue.currentID != initial && model.castDevice?.id == "loopback" && !model.castConnecting && model.state.status == PLAYER_PLAYING } let middle = model.queue.currentID try Data().write(to: gates.appendingPathComponent("next"), options: .atomic) try await until(model, "receiver EOF automatically advances queue without disconnecting") { model.queue.currentID != middle && model.castDevice?.id == "loopback" && !model.castConnecting && model.state.status == PLAYER_PLAYING } require(!model.queue.canNext, "receiver automatic advance selects final queue entry") try Data().write(to: gates.appendingPathComponent("advanced"), options: .atomic) try await until(model, "final receiver EOF stops without wrapping") { model.state.status == PLAYER_ENDED } require(model.castDevice?.id == "loopback" && !model.queue.canNext, "final receiver EOF preserves receiver and last selection") model.stopCasting() try await until(model, "Cast restoration keeps local queue") { model.castDevice == nil && !model.isBusy } require(model.queue.items.count == 3 && !model.queue.canNext, "Cast restoration does not replace queue") } static func interrupted(_ model: PlayerModel, source: URL, port: UInt16, scenario: String, gates: URL, defaults: UserDefaults) async throws { func signal(_ name: String) throws { try Data().write(to: gates.appendingPathComponent(name), options: .atomic) } func awaitSignal(_ name: String) async throws { try await until(model, "\(scenario): fixture signals \(name)", permitError: true) { FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path) } } let replacement = gates.appendingPathComponent("replacement.mp4") try FileManager.default.copyItem(at: source, to: replacement) let active = scenario.hasSuffix("-active") let expectedError = scenario == "reject-load" || scenario == "disconnect-active" model.setVolume(0) model.open(source) try await until(model, "interruption source plays") { model.state.status == PLAYER_PLAYING && model.canSeek } require(model.setPlayback(playing: false), "initial local pause accepted") try await until(model, "initial local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } require(model.seekRelative(9.25 - model.state.position), "initial local position accepted") try await until(model, "initial local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 } if !active { require(model.setPlayback(playing: true), "local playback starts before pending handoff") try await until(model, "local playback acknowledged before handoff") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } } let beforeConnect = model.state.position model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port)) if active { try await until(model, "active receiver acknowledges handoff") { model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED } require(abs(model.state.position - 9.25) < 0.15, "active receiver starts at acknowledged local position") require(model.volume == 1, "active receiver volume is visible before local rollback") if scenario == "disconnect-active" { require(model.setPlayback(playing: true), "initial receiver PLAY accepted") try await until(model, "initial receiver PLAY acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } } try signal("active") } try await awaitSignal("held") if !active { require(model.castDevice != nil && model.castConnecting, "held handoff remains pending") if scenario == "cancel-connect" { try await until(model, "pending connection does not pause local playback") { model.state.status == PLAYER_PLAYING && model.state.position > beforeConnect } } else { try await until(model, "pending LOAD follows acknowledged local pause") { model.state.status == PLAYER_PAUSED } } } requirePlaybackPower(system: !active || scenario == "disconnect-active", display: scenario == "cancel-connect", "handoff keeps the relay awake even after the local decoder pauses") if scenario == "reject-load" { try signal("observed") try await until(model, "LOAD rejection restores original local playback", permitError: true) { model.castDevice == nil && !model.castConnecting && model.commandError != nil && model.state.status == PLAYER_PLAYING && model.canSeek } } else { switch scenario { case "cancel-connect", "cancel-load": model.stopCasting() case "replace-load", "replace-active": model.open(replacement) case "close-load", "close-active": model.closeFile() case "shutdown-load", "shutdown-active": model.shutdown() case "disconnect-active": require(model.setPlayback(playing: false) && model.setPlayback(playing: true) && model.setPlayback(playing: false), "latest unacknowledged receiver PAUSE accepted") require(model.playbackIntent == false && model.state.status == PLAYER_PLAYING, "latest intent remains distinct from acknowledged receiver playback") require(model.seekRelative(10), "receiver seek preview accepted before disconnect") require(model.state.seek_pending, "receiver seek remains pending before disconnect") default: fatalError("Unknown interruption scenario: " + scenario) } try signal("interrupted") } try await awaitSignal("released") if scenario.hasPrefix("shutdown-") { require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose, "shutdown releases Cast ownership and permanently disables the local player") requirePlaybackPower(system: false, display: false, "interrupted Cast shutdown releases sleep prevention") require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0, "late receiver traffic cannot resume a destroyed player or replace local volume") print("PASS: " + scenario) return } if scenario == "disconnect-active" { try await until(model, "disconnect restores latest intent at acknowledged position", permitError: true) { model.castDevice == nil && !model.castConnecting && !model.isBusy && model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.playbackIntent == nil && abs(model.state.position - 9.25) < 0.15 } require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 9.25) < 0.15, "unacknowledged remote seek preview never replaces saved acknowledged position") } else if scenario.hasPrefix("close-") { try await until(model, "close acknowledges empty decoder after late receiver traffic") { model.state.status == PLAYER_EMPTY && model.fileName == nil && model.castDevice == nil && !model.isBusy } require(model.playbackIntent == nil, "close does not leave an accidental resume intent") model.open(replacement) } else if scenario.hasPrefix("replace-") { try await until(model, "replacement acknowledges its own local playback after late receiver traffic") { model.castDevice == nil && !model.castConnecting && !model.isBusy && model.fileName == replacement.lastPathComponent && model.state.status == PLAYER_PLAYING && model.canSeek } } else { try await until(model, "cancelled or rejected handoff leaves usable local playback", permitError: expectedError) { model.castDevice == nil && !model.castConnecting && model.state.status == PLAYER_PLAYING && model.canSeek } } require(model.volume == 0, "every interrupted handoff restores local volume") if scenario != "disconnect-active" { // A native acknowledgement is the barrier after released late receiver traffic; // never sleep and infer that stale callbacks did nothing. try await until(model, "local decoder is usable after interruption", permitError: expectedError) { model.canSeek && !model.isBusy } require(model.setPlayback(playing: false), "post-interruption local pause accepted") try await until(model, "post-interruption local pause acknowledged", permitError: expectedError) { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } require(model.seekRelative(4.25 - model.state.position), "post-interruption local seek accepted") try await until(model, "local seek acknowledges after obsolete receiver replies", permitError: expectedError) { !model.state.seek_pending && abs(model.state.position - 4.25) < 0.15 } require(model.castDevice == nil && !model.castConnecting && model.volume == 0, "obsolete receiver callbacks cannot reclaim playback or volume") if scenario.hasPrefix("replace-") || scenario.hasPrefix("close-") { require(model.fileName == replacement.lastPathComponent, "interruption preserves replacement media identity") } } model.closeFile() try await until(model, "interrupted scenario closes all playback", permitError: expectedError) { model.state.status == PLAYER_EMPTY && model.castDevice == nil && !model.castConnecting } print("PASS: " + scenario) } static func main() async throws { _ = NSApplication.shared NSApp.setActivationPolicy(.prohibited) require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle") require(playbackSliderHitCheck(), "Progress slider seeks beside the thumb and preserves cursor alignment throughout dragging") if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" { print("PASS: adjacent seeking, thumb and track drag alignment, and volume tracking feedback") return } let source = URL(fileURLWithPath: CommandLine.arguments[1]) let port = UInt16(CommandLine.arguments[2])! let suite = "terebi.cast-playback-check." + UUID().uuidString let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let model = PlayerModel(defaults: defaults) defer { model.shutdown() } if CommandLine.arguments.count == 5 { if CommandLine.arguments[3] == "queue" { try await queued(model, source: source, port: port, gates: URL(fileURLWithPath: CommandLine.arguments[4])) return } try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3], gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults) return } model.setVolume(0) model.open(source) try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek } requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep") require(model.setPlayback(playing: false), "local pause accepted") try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } requirePlaybackPower(system: false, display: false, "local pause permits sleep") require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted") require(!model.canCast, "Cast cannot transfer an unacknowledged local seek") try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 } model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port)) requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay") try await until(model, "receiver load acknowledges paused handoff") { model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED } requirePlaybackPower(system: false, display: false, "paused Cast permits sleep") require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position") require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting") require(model.setPlayback(playing: true), "remote play accepted") try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep") for _ in 0..<64 { require(model.seekRelative(0.125), "rapid remote skips accumulate beyond queue capacity") } require(model.seekRelative(7), "final remote skip retains accumulated target") try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 } require(model.setPlayback(playing: false), "remote pause accepted") try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } requirePlaybackPower(system: false, display: false, "receiver pause permits sleep") model.stopCasting() try await until(model, "local decoder restores receiver position paused") { model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 && !model.isBusy } requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep") require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15, "remote history is not overwritten by the abandoned local position") require(model.volume == 0, "local volume survives receiver volume changes") require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection") try await until(model, "local resume acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } model.cast(to: CastDevice(id: "invalid", name: "Unavailable TV", host: "127.0.0.1", port: 0)) try await until(model, "failed Cast connection restores local playback", permitError: true) { model.castDevice == nil && model.commandError != nil && model.state.status == PLAYER_PLAYING } require(model.canSeek && model.state.position >= 24, "connection failure preserves a usable local timeline") requirePlaybackPower(system: true, display: true, "failed Cast restores local display protection") model.closeFile() try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY } requirePlaybackPower(system: false, display: false, "closing Cast media releases sleep prevention") print("PASS: real local decode → TLS Cast LOAD/play/seek/pause → local restoration/history; failed connection recovery") } }