Files
terebi/Checks/system-playback.swift
T

286 lines
17 KiB
Swift

import AppKit
import MediaPlayer
import TerebiConfiguration
@main
@MainActor
struct SystemPlaybackChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
static func until(_ message: String, _ condition: () -> Bool) {
let deadline = ContinuousClock.now.advanced(by: .seconds(10))
while !condition(), ContinuousClock.now < deadline {
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
}
require(condition(), message)
}
static func checkCompactProgress() {
let times: [(Double, Double, String)] = [
(12.9, 45.9, "12 / 45 s"),
(59, 59.999, "59 / 59 s"),
(0, 60, "00:00 / 01:00 m"),
(312, 2528, "05:12 / 42:08 m"),
(3599, 3599.999, "59:59 / 59:59 m"),
(59, 3600, "00:00 / 01:00 h"),
(4980, 7800, "01:23 / 02:10 h"),
(-5, 45, "0 / 45 s"),
(80, 45, "45 / 45 s"),
]
for (position, duration, expected) in times {
require(compactProgressText(position: position, duration: duration, format: .time) == expected,
"compact time uses total-duration units, truncation and bounded progress: \(expected)")
}
for (position, expected) in [(59.9, "59"), (100.0, "100"), (105.0, "100"), (-1.0, "0")] {
require(compactProgressText(position: position, duration: 100, format: .percentage) == expected,
"compact percentage is a bounded integer: \(expected)")
}
for duration in [0.0, -1.0, .nan, .infinity] {
require(compactProgressText(position: 10, duration: duration, format: .time) == nil &&
compactProgressText(position: 10, duration: duration, format: .percentage) == nil,
"unknown/invalid duration has no compact progress")
}
require(compactProgressText(position: .nan, duration: 100, format: .time) == nil &&
compactProgressText(position: .infinity, duration: 100, format: .percentage) == nil,
"nonfinite positions never appear as compact progress")
require(compactProgressText(position: 59, duration: 100, format: .none) == nil,
"disabled compact progress leaves only the title")
}
static func main() {
checkCompactProgress()
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let initialModel = PlayerModel(defaults: defaults)
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
initialModel.setShowsBufferInfo(true)
require(initialModel.showsBufferInfo && initialModel.controlsVisible,
"buffer readout can be enabled before playback and reveals controls")
initialModel.shutdown()
let restoredModel = PlayerModel(defaults: defaults)
require(!restoredModel.showsBufferInfo, "session opt-in does not survive model recreation")
restoredModel.shutdown()
let configuredModel = PlayerModel(defaults: defaults, configuration:
PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false))
require(configuredModel.showsBufferInfo && configuredModel.automaticYouTubeQuality
&& !configuredModel.compactWindow.snappingEnabled, "configuration initializes all three controls")
configuredModel.setShowsBufferInfo(false)
configuredModel.setAutomaticYouTubeQuality(false)
configuredModel.compactWindow.snappingEnabled = true
configuredModel.shutdown()
let model = PlayerModel(defaults: defaults)
defer { model.shutdown() }
require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled,
"session overrides never replace built-in settings")
model.setVolume(0)
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let center = MPNowPlayingInfoCenter.default()
let commands = MPRemoteCommandCenter.shared()
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer { window.close() }
model.attachWindow(window)
model.open(directory.appendingPathComponent("anamorphic.mp4"))
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
"compact entry is available before native media opening acknowledges")
until("decoded anamorphic metadata replaces provisional compact geometry") {
model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01
}
model.closeFile()
require(!model.isCompact && !model.compactWindow.isTransitioning,
"closing loading/loaded media restores immediately")
until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY }
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
model.open(directory.appendingPathComponent("av.mp4"))
if !NSWorkspace.shared.isVoiceOverEnabled {
model.setPointerInsideWindow(false)
require(!model.controlsVisible, "pointer exit hides playback chrome while media is loading")
model.setPointerInsideWindow(true)
}
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
require(center.playbackState == .playing, "playing publishes macOS playback ownership")
require(model.setPlayback(playing: false), "explicit pause accepted")
until("pause acknowledged") { model.state.status == PLAYER_PAUSED }
let pausedPosition = model.state.position
if !NSWorkspace.shared.isVoiceOverEnabled {
model.setControlsHovered(true)
model.setTitleHovered(true)
model.setPointerInsideWindow(false)
require(!model.controlsVisible,
"pointer exit hides paused playback chrome despite stale hover and inactive window")
model.setControlsHovered(false)
model.setTitleHovered(false)
model.updateWindowState()
require(!model.controlsVisible,
"late hover/focus updates cannot reshow chrome while the pointer is outside")
model.revealControls(forKeyboardNavigation: true)
model.revealControls()
require(model.controlsVisible, "Tab navigation can reveal controls without moving the pointer")
model.setPointerInsideWindow(false)
require(!model.controlsVisible, "another pointer exit dismisses keyboard-revealed controls")
model.setPointerInsideWindow(true)
require(model.controlsVisible, "pointer reentry reveals paused playback chrome")
}
require(model.setPlayback(playing: false), "repeated pause accepted")
until("idempotent pause does not restart playback") {
model.state.status == PLAYER_PAUSED && center.playbackState == .paused
}
require(model.state.position == pausedPosition, "repeated pause preserves position")
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
"paused system timeline does not extrapolate")
let playbackKey = try! KeyChord("space")
let oppositeIntentRevision = model.state.revision
require(model.handleKey(playbackKey) && model.effectivePlaying,
"play binding records intent before backend acknowledgement")
require(model.handleKey(playbackKey) && !model.effectivePlaying,
"same binding resolves pause against pending play intent")
require(model.handleKey(playbackKey, isRepeat: true) && !model.effectivePlaying,
"held paired shortcut is consumed without oscillating")
until("rapid opposite intents acknowledge play before the final pause") {
model.state.revision != oppositeIntentRevision && model.state.status == PLAYER_PLAYING
}
let playingRevision = model.state.revision
until("rapid opposite intents settle to an acknowledged native pause") {
model.state.revision != playingRevision && model.state.status == PLAYER_PAUSED &&
!model.effectivePlaying && center.playbackState == .paused
}
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
"latest pause intent stops the system timeline after native acknowledgement")
let settledPosition = model.state.position
require(!model.perform(.pause) && !model.effectivePlaying,
"explicit pause cannot resume an already paused player")
let seekableState = model.state
var nonseekableState = seekableState
nonseekableState.can_seek = false
model.applyState(nonseekableState)
require(commands.playCommand.isEnabled && !commands.nextTrackCommand.isEnabled &&
!commands.skipBackwardCommand.isEnabled && !model.seekRelative(1),
"nonseekable input retains playback but rejects system seeking")
model.applyState(seekableState)
require(commands.nextTrackCommand.isEnabled,
"seekability changes re-enable commands even when playback metadata is unchanged")
for _ in 0..<64 { require(model.seekRelative(1.0 / 64), "rapid native skips retain accumulated target") }
until("paused seek acknowledged") {
model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
abs(model.state.position - settledPosition - 1) < 0.05
}
require(model.seekRelative(-100), "backward boundary seek accepted")
until("backward seek clamps at start") { !model.state.seek_pending && model.state.position == 0 }
require(!model.seekRelative(.nan), "nonfinite external seek rejected")
require(model.setPlayback(playing: true), "explicit play accepted")
until("play resumes advancing timeline") { model.state.status == PLAYER_PLAYING && model.state.position > 0.1 }
require(model.setPlayback(playing: true), "repeated play accepted")
until("repeated play keeps playing") { model.state.status == PLAYER_PLAYING && model.state.position > 0.2 }
if !NSWorkspace.shared.isVoiceOverEnabled {
require(model.perform(.enterCompact), "compact playback is available for hidden-keyboard seeking")
until("compact entry completes before hiding controls") { !model.compactWindow.isTransitioning }
model.hideControls(hideCursor: false)
require(!model.controlsVisible, "playing chrome can be hidden before a keyboard seek")
require(model.handleKey(try! KeyChord("left")), "keyboard seek is accepted while chrome is hidden")
require(!model.controlsVisible, "keyboard seek must not flash playback chrome")
until("hidden keyboard seek acknowledges the start boundary") {
require(!model.controlsVisible, "seek acknowledgement must preserve hidden chrome")
return !model.state.seek_pending && model.state.position < 0.15
}
require(model.perform(.exitCompact), "keyboard seek check restores normal mode")
until("compact exit completes") { !model.compactWindow.isTransitioning }
}
// Closing must hide the last polled retained range before the next state poll.
var retainedSnapshot = model.state
retainedSnapshot.buffered_start = retainedSnapshot.position
retainedSnapshot.buffered_end = min(retainedSnapshot.duration, retainedSnapshot.position + 1)
model.applyState(retainedSnapshot)
require(model.bufferedRange != nil, "loaded selection exposes retained media")
let retainedRange = model.bufferedRange
model.setShowsBufferInfo(true)
require(model.bufferedRange == retainedRange, "enabling buffer readout preserves retained media")
model.setShowsBufferInfo(false)
require(model.bufferedRange == retainedRange, "disabling buffer readout preserves retained media")
model.closeFile()
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
"accepted close releases system playback immediately")
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
"closed source cannot receive system playback or seek commands")
let localURL = directory.appendingPathComponent("av.mp4")
let progress = PlaybackProgress(defaults: defaults)
let savedPosition = seekableState.duration / 2
progress.save(savedPosition, for: localURL)
var openRevision = model.state.revision
model.open(localURL)
until("local saved position is restored before native autoplay") {
guard model.state.revision != openRevision else { return false }
if model.state.status == PLAYER_PLAYING && !model.state.seek_pending {
require(model.state.position >= savedPosition,
"local autoplay never acknowledges playback before the saved position")
return model.state.position < savedPosition + 0.5
}
require(progress.position(for: localURL) == savedPosition,
"pre-restoration snapshots cannot overwrite saved history")
return false
}
require(model.setPlayback(playing: false), "restored local playback can pause")
until("restored local pause acknowledged") { model.state.status == PLAYER_PAUSED && !model.state.seek_pending }
let restoredPosition = model.state.position
model.closeFile()
require(progress.position(for: localURL) == restoredPosition,
"close persists acknowledged restored position")
openRevision = model.state.revision
model.open(localURL)
until("close and reopen resumes the persisted native position") {
guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending else { return false }
require(model.state.position >= restoredPosition,
"reopened local autoplay starts at acknowledged persisted progress")
return model.state.position < restoredPosition + 0.5
}
model.closeFile()
let invalidPosition = seekableState.duration + 1
progress.save(invalidPosition, for: localURL)
openRevision = model.state.revision
model.open(localURL)
until("duration-exceeded bookmark is discarded and native playback advances") {
model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && model.state.position > 0.1 && model.state.position < savedPosition
}
require((progress.position(for: localURL) ?? 0) < seekableState.duration,
"duration-exceeded bookmark is removed from exposed history")
model.closeFile()
let replacementPosition = progress.position(for: localURL)!
require(replacementPosition > 0 && replacementPosition < savedPosition,
"close replaces invalid history with acknowledged playback progress")
openRevision = model.state.revision
model.open(localURL)
until("reopening no longer repeats a duration-exceeded restoration") {
guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending else { return false }
require(model.state.position >= replacementPosition,
"replacement bookmark restores before reopened autoplay")
return model.state.position < replacementPosition + 0.5
}
model.closeFile()
model.open(directory.appendingPathComponent("hdr.mkv"))
until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled,
"failed source does not claim playback keys")
model.shutdown()
require(center.nowPlayingInfo == nil && !commands.pauseCommand.isEnabled,
"shutdown releases system playback")
print("System playback checks passed")
}
}