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() try require(PlayerAction(name: "open_stream") == .openStream && PlayerAction.openStream.name == "open_stream", "stream action uses the general config name") try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias") try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")], "stream action preserves its shortcut") try require(PlayerAction(name: "show_queue") == .showQueue && PlayerAction.hideQueue.name == "hide_queue", "queue visibility actions round-trip through configuration") try require(Set(bindings.actions(for: try KeyChord("cmd+shift+l"))) == [.showQueue, .hideQueue] && !PlayerAction.showQueue.allowsRepeat && !PlayerAction.hideQueue.allowsRepeat, "queue shortcut toggles without auto-repeat") try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) } let inversePairs: [(PlayerAction, PlayerAction)] = [ (.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen), (.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop), (.showQueue, .hideQueue), (.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(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty, "Miruro defaults select automatic video and captions independently") let miruro = root.appendingPathComponent("miruro.toml") for (settings, video, captions) in [ ("video_provider = \"new-Host_2\"", "new-Host_2", ""), ("caption_provider = \"moon\"", "", "moon"), ("video_provider = \"sun\"\ncaption_provider = \"moon\"", "sun", "moon"), ("video_provider = \"\"\ncaption_provider = \"\"", "", ""), ("video_provider = \"\(String(repeating: "a", count: 128))\"", String(repeating: "a", count: 128), "") ] { try Data("[miruro]\n\(settings)\n".utf8).write(to: miruro) try require(try PlayerConfiguration.load(from: miruro) == PlayerConfiguration(miruroVideoProvider: video, miruroCaptionProvider: captions), "partial Miruro settings preserve omitted defaults and accept runtime provider names") } for key in ["video_provider", "caption_provider"] { for name in ["with space", " sun", "sun ", "é", "host.name", "host/name", String(repeating: "a", count: 129)] { try Data("[miruro]\n\(key) = \"\(name)\"\n".utf8).write(to: miruro) try requireLoadError(at: miruro, "invalid Miruro provider") } for value in ["true", "1", "[\"sun\"]", "{}"] { try Data("[miruro]\n\(key) = \(value)\n".utf8).write(to: miruro) try requireLoadError(at: miruro, "non-string Miruro provider") } } for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] { let progress = root.appendingPathComponent("compact-progress-\(value).toml") try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress) try require(try PlayerConfiguration.load(from: progress).compactTitleProgress == expected, "compact title format \(value) loads from a partial UI section") } 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[miruro]\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"]), ("miruro-scalar", "miruro = true\n", ["miruro"]), ("miruro-unknown", "[miruro]\nprovider = \"sun\"\n", ["miruro.provider"]), ("miruro-nested", "[miruro.video_provider]\nname = \"sun\"\n", []), ("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", []), ("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]), ("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]), ("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") } }