feat: configure explicit state-dependent player keybindings
This commit is contained in:
+58
-2
@@ -48,6 +48,17 @@ def check_cli_instances(root, app, fixtures):
|
||||
def settings(state):
|
||||
return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"])
|
||||
|
||||
def input_request(pid, count, ready, message, events=None, media=None):
|
||||
request = {"id": uuid.uuid4().hex, "events": events or []}
|
||||
if media is not None:
|
||||
request["media"] = str(media)
|
||||
path = root / f"keys-{pid}.json"
|
||||
pending = path.with_suffix(".tmp")
|
||||
pending.write_text(json.dumps(request))
|
||||
pending.replace(path)
|
||||
return wait_states(count, lambda states: states[pid]["requestID"] == request["id"] and ready(states[pid]),
|
||||
message)
|
||||
|
||||
with StreamingServer(fixtures) as server:
|
||||
if extractor and runtime:
|
||||
metadata = root / "youtube.json"
|
||||
@@ -80,9 +91,25 @@ def check_cli_instances(root, app, fixtures):
|
||||
invoke([])
|
||||
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
|
||||
"warm default CLI reuses the existing process without replacing media")
|
||||
input_request(first_pid, 1, lambda state: state["status"] == 2, "default Space pauses once",
|
||||
events=[{"key": " ", "code": 49}])
|
||||
input_request(first_pid, 1, lambda state: state["status"] == 2, "held Space cannot oscillate",
|
||||
events=[{"key": " ", "code": 49, "repeat": True}])
|
||||
input_request(first_pid, 1, lambda state: state["status"] == 3, "same Space resumes",
|
||||
events=[{"key": " ", "code": 49}])
|
||||
input_request(first_pid, 1, lambda state: state["status"] == 3, "rapid pause/play preserves latest intent",
|
||||
events=[{"key": " ", "code": 49}, {"key": " ", "code": 49}])
|
||||
input_request(first_pid, 1, lambda state: state["compact"], "P enters the same compact window",
|
||||
events=[{"key": "p", "code": 35}])
|
||||
input_request(first_pid, 1, lambda state: state["compact"], "held P cannot exit compact mode",
|
||||
events=[{"key": "p", "code": 35, "repeat": True}])
|
||||
input_request(first_pid, 1, lambda state: not state["compact"], "same P restores normal mode",
|
||||
events=[{"key": "p", "code": 35}])
|
||||
|
||||
config.parent.mkdir(parents=True)
|
||||
config_text = '"show_buffer_info" = true # optional display\n automatic_quality = true\nsnap_to_edges = false\n'
|
||||
config_text = ('automatic_quality = true\n[ui]\n"buffer_info" = true # optional display\n'
|
||||
'compact_snap_to_edges = false\n[keys]\nplay = ["cmd+8", "cmd+x", "x"]\n'
|
||||
'pause = ["cmd+8", "cmd+x", "x"]\nshow_buffer_info = "b"\nhide_buffer_info = "b"\n')
|
||||
config.write_text(config_text)
|
||||
invoke(["--new-instance"])
|
||||
second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0
|
||||
@@ -92,6 +119,35 @@ def check_cli_instances(root, app, fixtures):
|
||||
empty_pid = next(pid for pid in second if pid != first_pid)
|
||||
if settings(second[first_pid]) != (False, False, True) or config.read_text() != config_text:
|
||||
raise RuntimeError(f"config loading changed an existing session or wrote the file: {second}")
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured player loads real video",
|
||||
media=movie)
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 2, "configured X pauses actual playback",
|
||||
events=[{"key": "x"}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 2, "old K binding is removed",
|
||||
events=[{"key": "k", "code": 40}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 2 and state["editorText"] == "x",
|
||||
"typing X reaches the editor without controlling playback",
|
||||
events=[{"key": "x", "editing": True}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 2, "configured Cmd+8 leaves text editing alone",
|
||||
events=[{"key": "8", "code": 28, "flags": 1 << 20, "editing": True}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured Cmd+X resumes actual playback",
|
||||
events=[{"key": "x", "flags": 1 << 20}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 3, "held modified playback chord does not toggle",
|
||||
events=[{"key": "x", "flags": 1 << 20, "repeat": True}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 2, "same Cmd+X pauses actual playback",
|
||||
events=[{"key": "x", "flags": 1 << 20}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured native menu Cmd+8 resumes",
|
||||
events=[{"key": "8", "code": 28, "flags": 1 << 20, "menu": True}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 2, "native Pause item applies current state",
|
||||
events=[{"select": "Pause"}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 3, "native Play item resumes the same player",
|
||||
events=[{"select": "Play"}])
|
||||
input_request(empty_pid, 2, lambda state: not state["showBufferInfo"], "paired B hides buffer info",
|
||||
events=[{"key": "b", "code": 11}])
|
||||
input_request(empty_pid, 2, lambda state: state["showBufferInfo"], "same B shows buffer info",
|
||||
events=[{"key": "b", "code": 11}])
|
||||
input_request(empty_pid, 2, lambda state: state["status"] == 0, "configured close-file menu restores empty player",
|
||||
events=[{"key": "w", "code": 13, "flags": (1 << 20) | (1 << 17), "menu": True}])
|
||||
invoke(["--new-instance", str(audio)])
|
||||
third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3
|
||||
and state["hasAudio"] and not state["hasVideo"]
|
||||
@@ -99,7 +155,7 @@ def check_cli_instances(root, app, fixtures):
|
||||
"independent local media reaches only the freshly launched process")
|
||||
if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]:
|
||||
raise RuntimeError(f"independent launch disturbed an existing player: {third}")
|
||||
config.write_text("show_buffer_info = [\n")
|
||||
config.write_text("[ui]\nbuffer_info = [\n")
|
||||
failure = invoke(["--new-instance"], expected_code=78)
|
||||
if str(config) not in failure.stderr:
|
||||
raise RuntimeError(f"configuration failure omitted its path: {failure.stderr}")
|
||||
|
||||
@@ -65,6 +65,29 @@ private enum AppStartupCheck {
|
||||
@MainActor
|
||||
private enum CLIInstanceCheck {
|
||||
static var timer: Timer?
|
||||
static var requestID = ""
|
||||
static var editorText = ""
|
||||
static func selectMenuItem(_ title: String, in menu: NSMenu) -> Bool {
|
||||
for (index, item) in menu.items.enumerated() {
|
||||
if item.title == title, item.action != nil {
|
||||
precondition(item.isEnabled, "Native menu action \(title) must be available")
|
||||
menu.performActionForItem(at: index)
|
||||
return true
|
||||
}
|
||||
if let submenu = item.submenu, selectMenuItem(title, in: submenu) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func prepareMenu(_ menu: NSMenu) {
|
||||
// Direct key-equivalent dispatch does not open/track the native menu first.
|
||||
// Ask AppKit to prepare and validate its real items just as normal menu use does.
|
||||
menu.delegate?.menuNeedsUpdate?(menu)
|
||||
menu.update()
|
||||
for item in menu.items {
|
||||
if let submenu = item.submenu { prepareMenu(submenu) }
|
||||
}
|
||||
}
|
||||
|
||||
static func run(_ configuration: URL) {
|
||||
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
||||
@@ -77,14 +100,66 @@ private enum CLIInstanceCheck {
|
||||
}
|
||||
// Exercise the real SwiftUI scene and delegate without displaying test windows.
|
||||
_ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification, object: nil, queue: .main) { _ in
|
||||
MainActor.assumeIsolated { for window in NSApp.windows { window.alphaValue = 0 } }
|
||||
MainActor.assumeIsolated {
|
||||
for window in NSApp.windows {
|
||||
window.alphaValue = 0
|
||||
window.ignoresMouseEvents = true
|
||||
}
|
||||
}
|
||||
}
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
|
||||
MainActor.assumeIsolated {
|
||||
let model = PlayerModel.shared
|
||||
let pid = ProcessInfo.processInfo.processIdentifier
|
||||
let windows = NSApp.windows.filter { !($0 is NSPanel) && $0.contentView != nil }
|
||||
// Text input can create non-main auxiliary windows; count actual player windows.
|
||||
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil }
|
||||
for window in windows { window.alphaValue = 0 }
|
||||
let request = directory.appendingPathComponent("keys-\(pid).json")
|
||||
if let data = try? Data(contentsOf: request),
|
||||
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = command["id"] as? String, id != requestID, let window = windows.first {
|
||||
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
|
||||
NSApp.activate()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.makeMain()
|
||||
return
|
||||
}
|
||||
requestID = id
|
||||
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
|
||||
if let events = command["events"] as? [[String: Any]] {
|
||||
window.makeKey()
|
||||
for input in events {
|
||||
if let title = input["select"] as? String {
|
||||
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
|
||||
prepareMenu(menu)
|
||||
precondition(selectMenuItem(title, in: menu), "Native menu action \(title) must exist")
|
||||
continue
|
||||
}
|
||||
let editor = NSTextView(frame: .zero)
|
||||
if input["editing"] as? Bool == true {
|
||||
window.contentView?.addSubview(editor)
|
||||
window.makeFirstResponder(editor)
|
||||
} else { window.makeFirstResponder(nil) }
|
||||
let text = input["key"] as! String
|
||||
let event = NSEvent.keyEvent(with: .keyDown, location: .zero,
|
||||
modifierFlags: NSEvent.ModifierFlags(rawValue: UInt(input["flags"] as? Int ?? 0)),
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
characters: text, charactersIgnoringModifiers: text,
|
||||
isARepeat: input["repeat"] as? Bool ?? false,
|
||||
keyCode: UInt16(input["code"] as? Int ?? 7))!
|
||||
if input["menu"] as? Bool == true {
|
||||
if let menu = NSApp.mainMenu { prepareMenu(menu) }
|
||||
precondition(NSApp.mainMenu?.performKeyEquivalent(with: event) == true,
|
||||
"Configured shortcut must be accepted by the native menu")
|
||||
} else { NSApp.sendEvent(event) }
|
||||
if input["editing"] as? Bool == true {
|
||||
editorText = editor.string
|
||||
window.makeFirstResponder(nil)
|
||||
editor.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let applications = NSWorkspace.shared.runningApplications.filter {
|
||||
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
|
||||
}
|
||||
@@ -101,6 +176,10 @@ private enum CLIInstanceCheck {
|
||||
"showBufferInfo": model.showsBufferInfo,
|
||||
"automaticQuality": model.automaticYouTubeQuality,
|
||||
"snapToEdges": model.compactWindow.snappingEnabled,
|
||||
"requestID": requestID,
|
||||
"editorText": editorText,
|
||||
"compact": model.isCompact,
|
||||
"onTop": model.isOnTop,
|
||||
]
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: snapshot)
|
||||
|
||||
+143
-17
@@ -11,18 +11,96 @@ struct ConfigurationChecks {
|
||||
if !condition { throw Failure(description: "FAIL: " + message) }
|
||||
}
|
||||
|
||||
static func requireLoadError(at url: URL, _ message: String) throws {
|
||||
static func requireLoadError(at url: URL, _ message: String, containing fragments: [String] = []) throws {
|
||||
do {
|
||||
_ = try PlayerConfiguration.load(from: url)
|
||||
} catch let caught {
|
||||
let diagnostic = caught.localizedDescription + " " + String(describing: caught)
|
||||
try require(diagnostic.contains(url.path), message + " identifies the actual config path")
|
||||
for fragment in fragments {
|
||||
try require(diagnostic.contains(fragment), message + " identifies '\(fragment)': " + diagnostic)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw Failure(description: "FAIL: " + message + " was accepted")
|
||||
}
|
||||
|
||||
static func requireError(_ fragments: [String], _ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch {
|
||||
for fragment in fragments {
|
||||
try require(error.localizedDescription.contains(fragment), "error identifies '\(fragment)': \(error)")
|
||||
}
|
||||
return
|
||||
}
|
||||
throw Failure(description: "FAIL: invalid binding was accepted")
|
||||
}
|
||||
|
||||
static func checkBindings() throws {
|
||||
let bindings = KeyBindings()
|
||||
let inversePairs: [(PlayerAction, PlayerAction)] = [
|
||||
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
|
||||
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
|
||||
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
|
||||
(.enableAutomaticQuality, .disableAutomaticQuality)
|
||||
]
|
||||
for (first, second) in inversePairs {
|
||||
try require(first.inverse == second && second.inverse == first, "symmetric inverses")
|
||||
let overrides = try KeyBindings(overrides: [first.name: ["ctrl+alt+x"], second.name: ["ctrl+alt+x"]])
|
||||
try require(Set(overrides.actions(for: try KeyChord("ctrl+alt+x"))) == [first, second],
|
||||
"every known inverse pair may share a chord")
|
||||
}
|
||||
for height in [1, 144, 720, 1080, 2160, 4320] {
|
||||
let action = PlayerAction.quality(height)
|
||||
try require(PlayerAction(name: "quality_\(height)p") == action && action.name == "quality_\(height)p",
|
||||
"explicit positive quality height round-trips")
|
||||
try require(action.inverse == nil && !action.allowsRepeat && bindings.shortcuts(for: action).isEmpty,
|
||||
"quality actions default to unbound and non-repeating")
|
||||
}
|
||||
for name in ["", "Play", "toggle_play", "toggle_fullscreen", "captions", "enable_captions", "disable_captions",
|
||||
"quality_0p", "quality_-720p", "quality_0720p", "quality_720", "quality_720P", "quality_7.2p",
|
||||
"quality_999999999999999999999999999p"] {
|
||||
try require(PlayerAction(name: name) == nil, "unsupported action \(name) rejected")
|
||||
try requireError(name.isEmpty ? [] : [name]) { _ = try KeyBindings(overrides: [name: []]) }
|
||||
}
|
||||
let normalized = try KeyChord("SHIFT+CMD+Option+CTRL+A")
|
||||
try require(normalized == KeyChord(key: "a", modifiers: [.shift, .command, .option, .control]),
|
||||
"letter casing and modifier order normalize")
|
||||
try require(try KeyChord(normalized.description) == normalized, "canonical description round-trips")
|
||||
try require(try KeyChord("option+x") == KeyChord("alt+x"), "option alias normalizes")
|
||||
try require(try KeyChord("plus").key == "+" && KeyChord("cmd+plus").description == "cmd+plus",
|
||||
"named plus identifies the printable + key")
|
||||
for key in ["space", "escape", "return", "tab", "backspace", "delete", "left", "right", "up", "down",
|
||||
"home", "end", "pageup", "pagedown", ".", ",", "é"] + (1...35).map({ "f\($0)" }) {
|
||||
let chord = try KeyChord(key)
|
||||
try require(try KeyChord(chord.description) == chord, "\(key) parses and round-trips")
|
||||
}
|
||||
for chord in ["", "+", "cmd+", "+a", "cmd++a", "cmd+cmd+a", "alt+option+a", "super+a",
|
||||
"cmd+wat", "f0", "f36", "f01", " ", "\n", "a+b", "media_play", "cmd+tab", "cmd+shift+tab"] {
|
||||
try requireError(chord.isEmpty ? [] : [chord]) { _ = try KeyChord(chord) }
|
||||
}
|
||||
let merged = try KeyBindings(overrides: ["play": ["x", "ctrl+x"], "pause": ["x"], "mute": [],
|
||||
"quality_720p": ["alt+7"], "quality_default": ["alt+0"]])
|
||||
try require(merged.shortcuts(for: .play) == [KeyChord("x"), KeyChord("ctrl+x")], "array replaces, not appends")
|
||||
try require(merged.shortcuts(for: .mute).isEmpty && merged.actions(for: KeyChord("m")) == [.unmute],
|
||||
"empty array unbinds only its action")
|
||||
try require(merged.actions(for: KeyChord("k")).isEmpty && merged.shortcuts(for: .openFile) == bindings.shortcuts(for: .openFile),
|
||||
"replaced shortcuts disappear while omitted actions retain defaults")
|
||||
try require(merged.actions(for: KeyChord("alt+7")) == [.quality(720)]
|
||||
&& merged.actions(for: KeyChord("alt+0")) == [.qualityDefault], "quality shortcuts indexed")
|
||||
try require(bindings.actions(for: KeyChord("cmd+k")).isEmpty, "modifiers match exactly")
|
||||
try require(try KeyBindings(overrides: ["show_buffer_info": []]) == bindings, "unbinding an already-unbound action is unchanged")
|
||||
try requireError(["play", "k"]) { _ = try KeyBindings(overrides: ["play": ["k", "K"]]) }
|
||||
try requireError(["play", "ctrl+cmd+x"]) { _ = try KeyBindings(overrides: ["play": ["ctrl+cmd+x", "cmd+ctrl+X"]]) }
|
||||
try requireError(["play", "quit", "q"]) { _ = try KeyBindings(overrides: ["play": ["q"]]) }
|
||||
try requireError(["play", "mute", "x"]) { _ = try KeyBindings(overrides: ["play": ["x"], "mute": ["x"]]) }
|
||||
try requireError(["quality_720p", "quality_1080p", "alt+7"]) {
|
||||
_ = try KeyBindings(overrides: ["quality_720p": ["alt+7"], "quality_1080p": ["alt+7"]])
|
||||
}
|
||||
}
|
||||
|
||||
static func main() throws {
|
||||
try checkBindings()
|
||||
let files = FileManager.default
|
||||
let root = files.temporaryDirectory.appendingPathComponent("terebi.configuration-check.\(UUID().uuidString)")
|
||||
try files.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
@@ -47,16 +125,18 @@ struct ConfigurationChecks {
|
||||
|
||||
try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let original = Data("""
|
||||
# Root keys can be quoted, with comments and whitespace.
|
||||
"show_buffer_info" = true # session display default
|
||||
'automatic_quality' = true
|
||||
snap_to_edges = false
|
||||
# Root and nested keys can be quoted, with comments and whitespace.
|
||||
'automatic_quality' = true
|
||||
[ui]
|
||||
"buffer_info" = true # session display default
|
||||
compact_snap_to_edges = false
|
||||
""".utf8)
|
||||
try original.write(to: config)
|
||||
try files.setAttributes([.posixPermissions: 0o444], ofItemAtPath: config.path)
|
||||
let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false)
|
||||
let environment = ["XDG_CONFIG_HOME": xdg.path]
|
||||
try require(try PlayerConfiguration.load(from: config) == configured,
|
||||
"TOML comments, whitespace, quoted root keys, and both boolean values load")
|
||||
"TOML comments, whitespace, quoted nested keys, and both boolean values load from a read-only file")
|
||||
var session = try PlayerConfiguration.loadForApplication(arguments: [], environment: environment, homeDirectory: home)
|
||||
try require(session == configured, "application startup automatically loads the XDG config")
|
||||
session.showBufferInfo = false
|
||||
@@ -76,21 +156,67 @@ struct ConfigurationChecks {
|
||||
try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", partial.absoluteString],
|
||||
environment: environment, homeDirectory: home)
|
||||
== PlayerConfiguration(automaticQuality: true), "explicit file URL overrides the resolved XDG config")
|
||||
try require(try Data(contentsOf: config) == original, "read-only file bytes remain unchanged")
|
||||
let nested = root.appendingPathComponent("nested.toml")
|
||||
try Data("""
|
||||
[ui]
|
||||
buffer_info = true
|
||||
[keys]
|
||||
play = "x"
|
||||
pause = ["x", "ctrl+x"]
|
||||
mute = []
|
||||
quality_1080p = "alt+1"
|
||||
show_buffer_info = "alt+b"
|
||||
hide_buffer_info = "alt+b"
|
||||
""".utf8).write(to: nested)
|
||||
let nestedConfiguration = try PlayerConfiguration.load(from: nested)
|
||||
try require(nestedConfiguration.showBufferInfo && nestedConfiguration.snapToEdges && !nestedConfiguration.automaticQuality,
|
||||
"partial nested UI keeps omitted root and UI defaults")
|
||||
let expectedKeys = try KeyBindings(overrides: ["play": ["x"], "pause": ["x", "ctrl+x"], "mute": [],
|
||||
"quality_1080p": ["alt+1"], "show_buffer_info": ["alt+b"],
|
||||
"hide_buffer_info": ["alt+b"]])
|
||||
try require(nestedConfiguration.keys == expectedKeys, "heterogeneous string, array, and empty shortcuts decode")
|
||||
var keySession = nestedConfiguration
|
||||
keySession.keys = KeyBindings()
|
||||
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
|
||||
"key session overrides never persist")
|
||||
let emptySections = root.appendingPathComponent("empty-sections.toml")
|
||||
try Data("[ui]\n[keys]\n".utf8).write(to: emptySections)
|
||||
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
|
||||
|
||||
let invalid: [(String, String)] = [
|
||||
("syntax", "show_buffer_info = [\n"),
|
||||
("duplicate", "show_buffer_info = true\n\"show_buffer_info\" = false\n"),
|
||||
("unknown", "unknown_setting = true\n"),
|
||||
("string", "show_buffer_info = \"true\"\n"),
|
||||
("integer", "automatic_quality = 1\n"),
|
||||
("array", "snap_to_edges = [true]\n"),
|
||||
("table", "[show_buffer_info]\nenabled = true\n")
|
||||
let invalid: [(String, String, [String])] = [
|
||||
("syntax", "automatic_quality = [\n", ["automatic_quality"]),
|
||||
("duplicate", "automatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
|
||||
("unknown", "unknown_setting = true\n", ["unknown_setting"]),
|
||||
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
|
||||
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
|
||||
("ui-unknown", "[ui]\nunknown_setting = true\n", ["ui.unknown_setting"]),
|
||||
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
|
||||
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
||||
("integer", "automatic_quality = 1\n", []),
|
||||
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
|
||||
("table", "[automatic_quality]\nenabled = true\n", ["automatic_quality"]),
|
||||
("ui-scalar", "ui = true\n", ["ui"]),
|
||||
("keys-scalar", "keys = true\n", ["keys"]),
|
||||
("key-bool", "[keys]\nplay = true\n", ["keys.play"]),
|
||||
("key-int", "[keys]\nplay = 42\n", ["keys.play"]),
|
||||
("key-mixed-array", "[keys]\nplay = [\"x\", 42]\n", ["play"]),
|
||||
("key-table", "[keys.play]\nkey = \"x\"\n", ["keys.play"]),
|
||||
("key-unknown", "[keys]\nunknown_action = \"x\"\n", ["keys.unknown_action"]),
|
||||
("key-toggle", "[keys]\ntoggle_play = \"x\"\n", ["keys.toggle_play"]),
|
||||
("key-captions", "[keys]\nenable_captions = []\n", ["keys.enable_captions"]),
|
||||
("key-quality", "[keys]\nquality_0p = \"alt+0\"\n", ["keys.quality_0p"]),
|
||||
("key-chord", "[keys]\nplay = \"cmd++x\"\n", ["keys.play", "cmd++x"]),
|
||||
("key-duplicate", "[keys]\nplay = [\"x\", \"X\"]\n", ["keys.play", "duplicate", "X"]),
|
||||
("key-default-conflict", "[keys]\nplay = \"q\"\n", ["keys.play", "keys.quit", "q"]),
|
||||
("key-explicit-conflict", "[keys]\nplay = \"x\"\nmute = \"x\"\n", ["keys.play", "keys.mute", "x"]),
|
||||
("key-reserved", "[keys]\nplay = \"cmd+tab\"\n", ["keys.play", "cmd+tab", "unsupported"])
|
||||
]
|
||||
for (name, text) in invalid {
|
||||
for (name, text, fragments) in invalid {
|
||||
let url = root.appendingPathComponent(name + ".toml")
|
||||
let bytes = Data(text.utf8)
|
||||
try bytes.write(to: url)
|
||||
try requireLoadError(at: url, "invalid \(name) configuration")
|
||||
try requireLoadError(at: url, "invalid \(name) configuration", containing: fragments)
|
||||
try require(try Data(contentsOf: url) == bytes, "rejected \(name) config remains unchanged")
|
||||
}
|
||||
let invalidUTF8 = root.appendingPathComponent("invalid-utf8.toml")
|
||||
@@ -112,7 +238,7 @@ struct ConfigurationChecks {
|
||||
let badXDG = root.appendingPathComponent("bad-xdg")
|
||||
let badConfig = badXDG.appendingPathComponent("terebi/config.toml")
|
||||
try files.createDirectory(at: badConfig.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let malformedBytes = Data("snap_to_edges =\n".utf8)
|
||||
let malformedBytes = Data("[ui]\ncompact_snap_to_edges =\n".utf8)
|
||||
try malformedBytes.write(to: badConfig)
|
||||
try require(try PlayerConfiguration.loadForApplication(arguments: ["--no-config"],
|
||||
environment: ["XDG_CONFIG_HOME": badXDG.path], homeDirectory: home) == defaults,
|
||||
|
||||
@@ -62,6 +62,16 @@ struct SystemPlaybackChecks {
|
||||
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")
|
||||
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 settle to paused") { model.state.status == PLAYER_PAUSED }
|
||||
require(!model.perform(.pause) && !model.effectivePlaying,
|
||||
"explicit pause cannot resume an already paused player")
|
||||
|
||||
let seekableState = model.state
|
||||
var nonseekableState = seekableState
|
||||
|
||||
Reference in New Issue
Block a user