275 lines
18 KiB
Swift
275 lines
18 KiB
Swift
import Foundation
|
|
import TerebiConfiguration
|
|
|
|
@main
|
|
struct ConfigurationChecks {
|
|
struct Failure: Error, CustomStringConvertible {
|
|
let description: String
|
|
}
|
|
|
|
static func require(_ condition: Bool, _ message: String) throws {
|
|
if !condition { throw Failure(description: "FAIL: " + message) }
|
|
}
|
|
|
|
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)
|
|
defer { try? files.removeItem(at: root) }
|
|
let home = root.appendingPathComponent("home")
|
|
let xdg = root.appendingPathComponent("xdg")
|
|
let fallback = home.appendingPathComponent(".config/terebi/config.toml")
|
|
let config = xdg.appendingPathComponent("terebi/config.toml")
|
|
let defaults = PlayerConfiguration()
|
|
let printed = root.appendingPathComponent("printed-defaults.toml")
|
|
try Data(PlayerConfiguration.defaultTOML.utf8).write(to: printed)
|
|
try require(try PlayerConfiguration.load(from: printed) == defaults,
|
|
"printed TOML is directly usable and preserves all default behavior and bindings")
|
|
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
|
|
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
|
|
try require(PlayerConfiguration.fileURL(environment: ["XDG_CONFIG_HOME": xdg.path], homeDirectory: home) == config,
|
|
"absolute XDG_CONFIG_HOME selects terebi/config.toml")
|
|
for environment in [[:], ["XDG_CONFIG_HOME": ""], ["XDG_CONFIG_HOME": "relative/config"]] {
|
|
try require(PlayerConfiguration.fileURL(environment: environment, homeDirectory: home) == fallback,
|
|
"unset, empty, and relative XDG_CONFIG_HOME fall back to ~/.config")
|
|
}
|
|
try require(try PlayerConfiguration.load(from: fallback) == defaults, "missing files use built-in defaults")
|
|
try require(try PlayerConfiguration.loadForApplication(arguments: [], environment: [:], homeDirectory: home) == defaults,
|
|
"application startup uses defaults when its resolved config is missing")
|
|
try require(!files.fileExists(atPath: home.path), "missing-file loading creates neither home, config directory, nor file")
|
|
|
|
try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
let original = Data("""
|
|
# Sections and nested keys can be quoted, with comments and whitespace.
|
|
['playback']
|
|
'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 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
|
|
session.automaticQuality = false
|
|
session.snapToEdges = true
|
|
try require(session == defaults, "controls can override all settings within the session")
|
|
try require(try Data(contentsOf: config) == original, "loading and session overrides do not change config bytes")
|
|
try require(try PlayerConfiguration.load(from: config) == configured, "a fresh load resets session overrides to file values")
|
|
|
|
let partial = root.appendingPathComponent("partial.toml")
|
|
try Data("[playback]\nautomatic_quality = true\n".utf8).write(to: partial)
|
|
try require(try PlayerConfiguration.load(from: partial) == PlayerConfiguration(automaticQuality: true),
|
|
"partial configuration retains defaults for omitted settings")
|
|
let empty = root.appendingPathComponent("empty.toml")
|
|
try Data("# Defaults only\n\n".utf8).write(to: empty)
|
|
try require(try PlayerConfiguration.load(from: empty) == defaults, "empty/comment-only TOML uses defaults")
|
|
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 playback 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[playback]\n".utf8).write(to: emptySections)
|
|
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
|
|
let reordered = root.appendingPathComponent("reordered.toml")
|
|
try Data("""
|
|
[keys]
|
|
play = "x"
|
|
pause = "x"
|
|
[ui]
|
|
buffer_info = true
|
|
compact_snap_to_edges = false
|
|
[playback]
|
|
automatic_quality = true
|
|
""".utf8).write(to: reordered)
|
|
try require(try PlayerConfiguration.load(from: reordered) ==
|
|
PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false,
|
|
keys: KeyBindings(overrides: ["play": ["x"], "pause": ["x"]])),
|
|
"playback after keys and UI retains every section's settings and bindings")
|
|
|
|
let invalid: [(String, String, [String])] = [
|
|
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
|
|
("duplicate", "[playback]\nautomatic_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"]),
|
|
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
|
|
("playback-scalar", "playback = true\n", ["playback"]),
|
|
("playback-unknown", "[playback]\nunknown_setting = true\n", ["playback.unknown_setting"]),
|
|
("playback-misplaced", "[ui]\nautomatic_quality = true\n", ["ui.automatic_quality"]),
|
|
("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", "[playback]\nautomatic_quality = 1\n", []),
|
|
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
|
|
("table", "[playback.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, 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", containing: fragments)
|
|
try require(try Data(contentsOf: url) == bytes, "rejected \(name) config remains unchanged")
|
|
}
|
|
let invalidUTF8 = root.appendingPathComponent("invalid-utf8.toml")
|
|
let invalidBytes = Data([0xff, 0xfe, 0x80])
|
|
try invalidBytes.write(to: invalidUTF8)
|
|
try requireLoadError(at: invalidUTF8, "invalid UTF-8 configuration")
|
|
try require(try Data(contentsOf: invalidUTF8) == invalidBytes, "invalid UTF-8 config remains unchanged")
|
|
let directory = root.appendingPathComponent("directory.toml")
|
|
try files.createDirectory(at: directory, withIntermediateDirectories: false)
|
|
try requireLoadError(at: directory, "directory instead of config file")
|
|
|
|
let malformed = root.appendingPathComponent("syntax.toml")
|
|
let inaccessible = partial.appendingPathComponent("config.toml") // A regular file cannot be traversed as a directory.
|
|
for url in [malformed, inaccessible, directory] {
|
|
try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", url.absoluteString, "--no-config"],
|
|
environment: environment, homeDirectory: home) == defaults,
|
|
"--no-config bypasses malformed, inaccessible, and directory config paths")
|
|
}
|
|
let badXDG = root.appendingPathComponent("bad-xdg")
|
|
let badConfig = badXDG.appendingPathComponent("terebi/config.toml")
|
|
try files.createDirectory(at: badConfig.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
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,
|
|
"--no-config also bypasses automatic XDG loading")
|
|
try require(try Data(contentsOf: badConfig) == malformedBytes, "bypassing config never repairs or rewrites it")
|
|
try require(!files.fileExists(atPath: home.path), "all checks leave the isolated fallback home uncreated")
|
|
print("Configuration checks passed")
|
|
}
|
|
}
|