Compare commits
4 Commits
19a8f09e5a
...
77717c9654
| Author | SHA1 | Date | |
|---|---|---|---|
|
77717c9654
|
|||
|
d4d1b7bec4
|
|||
|
b872659796
|
|||
|
0ff1664974
|
@@ -7,18 +7,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var observers: [IPCObserver] = []
|
private var observers: [IPCObserver] = []
|
||||||
|
|
||||||
/// Create one bar controller per screen. Each screen resolves its own
|
/// Create one bar controller per screen. Each screen resolves its own
|
||||||
/// window and block set from the config, so refresh state and layout stay
|
/// theme, window and block set from the config, so refresh state and layout stay
|
||||||
/// independent across monitors.
|
/// independent across monitors.
|
||||||
init(
|
init(
|
||||||
config: PHConfig,
|
config: PHConfig,
|
||||||
screens: [NSScreen],
|
screens: [NSScreen],
|
||||||
theme: PHTheme,
|
|
||||||
debug: Bool
|
debug: Bool
|
||||||
) throws {
|
) throws {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
for screen in screens {
|
for screen in screens {
|
||||||
let blocks = try PHBlock.load(config.blocks(for: screen))
|
let name = screen.localizedName
|
||||||
|
let index = NSScreen.screens.firstIndex(of: screen)
|
||||||
|
let theme = try PHTheme.load(config.theme(screenName: name, screenIndex: index))
|
||||||
|
let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index))
|
||||||
|
|
||||||
controllers.append(
|
controllers.append(
|
||||||
BarController(
|
BarController(
|
||||||
config: config,
|
config: config,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ extension phbar {
|
|||||||
|
|
||||||
mutating func run() throws {
|
mutating func run() throws {
|
||||||
let config = try PHConfig.load()
|
let config = try PHConfig.load()
|
||||||
let theme = try PHTheme.load("voltage")
|
|
||||||
|
|
||||||
// NOTE: Make sure NSApp.run() runs in the main thread
|
// NOTE: Make sure NSApp.run() runs in the main thread
|
||||||
dispatchPrecondition(condition: .onQueue(.main))
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
@@ -28,7 +27,6 @@ extension phbar {
|
|||||||
let delegate = try AppDelegate(
|
let delegate = try AppDelegate(
|
||||||
config: config,
|
config: config,
|
||||||
screens: screens,
|
screens: screens,
|
||||||
theme: theme,
|
|
||||||
debug: debug
|
debug: debug
|
||||||
)
|
)
|
||||||
let app = NSApplication.shared
|
let app = NSApplication.shared
|
||||||
|
|||||||
@@ -1,43 +1,12 @@
|
|||||||
struct PHConfig: Decodable {
|
struct PHConfig: Decodable {
|
||||||
|
let theme: String
|
||||||
let window: String
|
let window: String
|
||||||
let blocks: String?
|
let blocks: String?
|
||||||
let env: [String: String]?
|
let env: [String: String]?
|
||||||
let monitors: [String: PHMonitorOverride]?
|
let monitors: [String: PHConfigMonitorOverride]?
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case window, blocks, env
|
case theme, window, blocks, env
|
||||||
case monitors = "monitor"
|
case monitors = "monitor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A per-monitor entry from the config's `[monitor]` table.
|
|
||||||
///
|
|
||||||
/// Either field is optional; an unset field inherits the matching global
|
|
||||||
/// (`window` or `blocks`) so a monitor can override just one of the two.
|
|
||||||
struct PHMonitorOverride: Decodable {
|
|
||||||
let window: String?
|
|
||||||
let blocks: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PHConfig {
|
|
||||||
/// The override matching a screen identifier, if any.
|
|
||||||
/// Pure (no AppKit). Precedence: monitor name → monitor index.
|
|
||||||
func monitorOverride(screenName: String, screenIndex: Int?) -> PHMonitorOverride? {
|
|
||||||
guard let monitors else { return nil }
|
|
||||||
if let byName = monitors[screenName] { return byName }
|
|
||||||
if let index = screenIndex, let byIndex = monitors[String(index)] { return byIndex }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Window definition name for a screen (pure resolver).
|
|
||||||
/// Override's `window` if set, else the global `window`.
|
|
||||||
func windowName(screenName: String, screenIndex: Int?) -> String {
|
|
||||||
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Block-set name for a screen (pure resolver).
|
|
||||||
/// Override's `blocks` if set, else the global `blocks` (default `"default"`).
|
|
||||||
func blocksName(screenName: String, screenIndex: Int?) -> String {
|
|
||||||
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
|
|
||||||
extension PHConfig {
|
|
||||||
/// The per-monitor override that applies to `screen`, if any.
|
|
||||||
/// Precedence: monitor name → monitor index.
|
|
||||||
func monitorOverride(for screen: NSScreen) -> PHMonitorOverride? {
|
|
||||||
let index = NSScreen.screens.firstIndex(of: screen)
|
|
||||||
return monitorOverride(screenName: screen.localizedName, screenIndex: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Window definition name for `screen`: the override's `window` if set,
|
|
||||||
/// otherwise the global `window`.
|
|
||||||
func windowName(for screen: NSScreen) -> String {
|
|
||||||
monitorOverride(for: screen)?.window ?? window
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Block-set name for `screen`: the override's `blocks` if set, otherwise
|
|
||||||
/// the global `blocks` (defaulting to `"default"`).
|
|
||||||
func blocks(for screen: NSScreen) -> String {
|
|
||||||
monitorOverride(for: screen)?.blocks ?? (blocks ?? "default")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A per-monitor entry from the config's `[monitor]` table.
|
||||||
|
///
|
||||||
|
/// Either field is optional; an unset field inherits the matching global
|
||||||
|
/// (`theme`, `window`, or `blocks`) so a monitor can override just one of them.
|
||||||
|
struct PHConfigMonitorOverride: Decodable {
|
||||||
|
let theme: String?
|
||||||
|
let window: String?
|
||||||
|
let blocks: String?
|
||||||
|
|
||||||
|
init(theme: String? = nil, window: String? = nil, blocks: String? = nil) {
|
||||||
|
self.theme = theme
|
||||||
|
self.window = window
|
||||||
|
self.blocks = blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PHConfig {
|
||||||
|
/// The per-monitor override matching the given screen identity, if any.
|
||||||
|
///
|
||||||
|
/// This is the single entry point for per-monitor resolution: the
|
||||||
|
/// `theme`/`window`/`blocks` helpers below all delegate to it. Kept free of
|
||||||
|
/// AppKit so the resolution logic is testable without an `NSScreen`.
|
||||||
|
/// Precedence: monitor name → monitor index.
|
||||||
|
func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? {
|
||||||
|
guard let monitors else { return nil }
|
||||||
|
if let byName = monitors[screenName] { return byName }
|
||||||
|
if let index = screenIndex, let byIndex = monitors[String(index)] { return byIndex }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Theme name for a screen: the override's `theme` if set, otherwise the
|
||||||
|
/// global `theme`.
|
||||||
|
func theme(screenName: String, screenIndex: Int?) -> String {
|
||||||
|
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.theme ?? theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window definition name for a screen: the override's `window` if set,
|
||||||
|
/// otherwise the global `window`.
|
||||||
|
func window(screenName: String, screenIndex: Int?) -> String {
|
||||||
|
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block-set name for a screen: the override's `blocks` if set, otherwise
|
||||||
|
/// the global `blocks` (defaulting to `"default"`).
|
||||||
|
func blocks(screenName: String, screenIndex: Int?) -> String {
|
||||||
|
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
# Default window and block set for every monitor.
|
# Default window and block set for every monitor.
|
||||||
|
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
|
||||||
# - window: a [[window]] definition from the theme file
|
# - window: a [[window]] definition from the theme file
|
||||||
# - blocks: a named set at ~/.config/phbar/blocks/<name>.toml
|
# - blocks: a named set at ~/.config/phbar/blocks/<name>.toml
|
||||||
|
theme = "default"
|
||||||
window = "default"
|
window = "default"
|
||||||
blocks = "default"
|
blocks = "default"
|
||||||
|
|
||||||
# Optional: override the window and/or block set per monitor.
|
# Optional: override the theme, window, and/or block set per monitor.
|
||||||
# - quoted numeric key → NSScreen index
|
# - quoted numeric key → NSScreen index
|
||||||
# - string key → NSScreen.localizedName (stable across replugs)
|
# - string key → NSScreen.localizedName (stable across replugs)
|
||||||
# Precedence: monitor name → monitor index → the globals above.
|
# Precedence: monitor name → monitor index → the globals above.
|
||||||
# Either field may be omitted to inherit its global value.
|
# Either field may be omitted to inherit its global value.
|
||||||
#
|
#
|
||||||
# [monitor."1"]
|
# [monitor."1"]
|
||||||
|
# theme = "default"
|
||||||
# window = "bottom"
|
# window = "bottom"
|
||||||
# blocks = "laptop"
|
# blocks = "laptop"
|
||||||
#
|
#
|
||||||
# [monitor."DELL U2723QE"]
|
# [monitor."DELL U2723QE"]
|
||||||
|
# theme = "external"
|
||||||
# window = "clock"
|
# window = "clock"
|
||||||
# blocks = "external"
|
# blocks = "external"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct PHThemeStyle: Decodable {
|
|||||||
name: "_default",
|
name: "_default",
|
||||||
foreground: .init(color: "#000000", alpha: nil),
|
foreground: .init(color: "#000000", alpha: nil),
|
||||||
background: .init(color: "#ffffff", alpha: nil),
|
background: .init(color: "#ffffff", alpha: nil),
|
||||||
padding: .init(trailing: 12, leading: 12)
|
padding: .init(leading: 12, trailing: 12)
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
struct PHThemeStylePadding: Decodable {
|
struct PHThemeStylePadding: Decodable {
|
||||||
let trailing: Double?
|
|
||||||
let leading: Double?
|
let leading: Double?
|
||||||
|
let trailing: Double?
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case trailing = "right"
|
|
||||||
case leading = "left"
|
case leading = "left"
|
||||||
|
case trailing = "right"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import Foundation
|
|||||||
/// `center` centers the window on both axes.
|
/// `center` centers the window on both axes.
|
||||||
enum PHThemeWindowAnchor: String, Decodable {
|
enum PHThemeWindowAnchor: String, Decodable {
|
||||||
case top, bottom, leading, trailing
|
case top, bottom, leading, trailing
|
||||||
case topLeading = "top-leading"
|
case topLeading = "top-left"
|
||||||
case topTrailing = "top-trailing"
|
case topTrailing = "top-right"
|
||||||
case bottomLeading = "bottom-leading"
|
case bottomLeading = "bottom-left"
|
||||||
case bottomTrailing = "bottom-trailing"
|
case bottomTrailing = "bottom-right"
|
||||||
case center
|
case center
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ struct PHThemeWindowMargin: Decodable, Equatable {
|
|||||||
let leading: Double?
|
let leading: Double?
|
||||||
let trailing: Double?
|
let trailing: Double?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case top
|
||||||
|
case bottom
|
||||||
|
case leading = "left"
|
||||||
|
case trailing = "right"
|
||||||
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
top: Double? = nil,
|
top: Double? = nil,
|
||||||
bottom: Double? = nil,
|
bottom: Double? = nil,
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
# phbar theme file ~ Voltage
|
# phbar theme file
|
||||||
#
|
#
|
||||||
# Place at ~/.config/phbar/themes/voltage.toml
|
# Place at ~/.config/phbar/themes/default.toml
|
||||||
|
|
||||||
[[window]]
|
[[window]]
|
||||||
name = "default"
|
name = "default"
|
||||||
anchor = "top"
|
anchor = "top"
|
||||||
height = 30
|
height = 30
|
||||||
width = "100%"
|
width = "100%"
|
||||||
margin = { leading = 30, top = 30, trailing = 30, bottom = 30 }
|
|
||||||
|
|
||||||
[[window]]
|
[[window]]
|
||||||
name = "bottom"
|
name = "bottom"
|
||||||
|
|||||||
@@ -35,10 +35,15 @@ final class BarController: ObservableObject {
|
|||||||
|
|
||||||
extension BarController {
|
extension BarController {
|
||||||
/// Resolve the window definition for a given screen, applying any
|
/// Resolve the window definition for a given screen, applying any
|
||||||
/// per-monitor override from the config. Selection itself lives on
|
/// per-monitor override from the config. Selection lives on `PHConfig`
|
||||||
/// `PHConfig` (see `windowName(for:)`).
|
/// (see `window(screenName:screenIndex:)`); this looks the name up in the
|
||||||
|
/// theme, falling back to "default".
|
||||||
static func window(for config: PHConfig, screen: NSScreen, from theme: PHTheme) -> PHThemeWindow {
|
static func window(for config: PHConfig, screen: NSScreen, from theme: PHTheme) -> PHThemeWindow {
|
||||||
resolve(window: config.windowName(for: screen), from: theme)
|
let name = config.window(
|
||||||
|
screenName: screen.localizedName,
|
||||||
|
screenIndex: NSScreen.screens.firstIndex(of: screen)
|
||||||
|
)
|
||||||
|
return resolve(window: name, from: theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up a window definition by name, falling back to "default" then the
|
/// Look up a window definition by name, falling back to "default" then the
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Refresh command
|
||||||
|
|
||||||
|
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
||||||
|
let config = phbar.refresh.configuration
|
||||||
|
#expect(config.commandName == "refresh")
|
||||||
|
#expect(config.abstract == "Refresh the running status bar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BarController refresh
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func controllerRefreshUpdatesAllBlocks() async throws {
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf a"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
command = "printf b"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let config = try PHConfig.load()
|
||||||
|
let theme = try PHTheme.load("voltage")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
controller.refresh()
|
||||||
|
|
||||||
|
// Allow the per-block update tasks to run.
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(blocks[0].label == "a")
|
||||||
|
#expect(blocks[1].label == "b")
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Payload Types for Testing
|
||||||
|
|
||||||
|
private struct TestPayload: Codable, Equatable {
|
||||||
|
let message: String
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Name
|
||||||
|
|
||||||
|
@Test func notificationNameIsHashable() async throws {
|
||||||
|
let a = IPCNotificationName("com.example.test")
|
||||||
|
let b = IPCNotificationName("com.example.test")
|
||||||
|
let c = IPCNotificationName("com.example.other")
|
||||||
|
|
||||||
|
#expect(a == b)
|
||||||
|
#expect(a != c)
|
||||||
|
#expect(a.hashValue == b.hashValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
|
||||||
|
let name: IPCNotificationName = "com.example.test"
|
||||||
|
#expect(name.rawValue == "com.example.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notificationNameRawRepresentable() async throws {
|
||||||
|
let name = IPCNotificationName(rawValue: "com.example.test")
|
||||||
|
#expect(name.rawValue == "com.example.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Payload Decoding
|
||||||
|
|
||||||
|
@Test func decodePayloadFromNotification() async throws {
|
||||||
|
let payload = TestPayload(message: "hello", count: 42)
|
||||||
|
let userInfo: [String: Any] = ["message": "hello", "count": 42]
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
||||||
|
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: nil)
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodePayloadReturnsNilForInvalidData() async throws {
|
||||||
|
let userInfo: [String: Any] = ["wrong": "data"]
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Posting
|
||||||
|
|
||||||
|
@Test func postWithoutPayloadDoesNotCrash() async throws {
|
||||||
|
IPC.post(.refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func postWithUserInfoDoesNotCrash() async throws {
|
||||||
|
IPC.post(.refresh, userInfo: ["key": "value"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
|
||||||
|
let payload = TestPayload(message: "test", count: 1)
|
||||||
|
let result = IPC.post(.refresh, payload: payload)
|
||||||
|
#expect(result == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observer
|
||||||
|
|
||||||
|
@Test func observerCleansUpOnDeinit() async throws {
|
||||||
|
// Verify that creating and releasing an observer doesn't crash.
|
||||||
|
// The token removes itself from the distributed center on deinit.
|
||||||
|
let token = IPC.observe(.refresh) { _ in }
|
||||||
|
withExtendedLifetime(token) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadBlocksFromTOML() throws {
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hello"
|
||||||
|
name = "greeting"
|
||||||
|
refresh = 5.0
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
command = "echo world"
|
||||||
|
"""
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: toml)
|
||||||
|
|
||||||
|
#expect(blocks.count == 2)
|
||||||
|
#expect(blocks[0].command == "echo hello")
|
||||||
|
#expect(blocks[0].name == "greeting")
|
||||||
|
#expect(blocks[0].refresh == 5.0)
|
||||||
|
#expect(blocks[0].label == nil)
|
||||||
|
#expect(blocks[1].name == nil)
|
||||||
|
#expect(blocks[1].refresh == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadBlocksRejectsInvalidTOML() {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try PHBlock.load(from: "not = valid = toml = =")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Named set loading
|
||||||
|
|
||||||
|
private func makeBlocksConfigDir() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_blocks_\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadSetReadsNamedFile() throws {
|
||||||
|
let dir = try makeBlocksConfigDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
let blocksDir = dir.appending(path: "blocks")
|
||||||
|
try FileManager.default.createDirectory(at: blocksDir, withIntermediateDirectories: true)
|
||||||
|
try Data("""
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
name = "greeting"
|
||||||
|
""".utf8).write(to: blocksDir.appending(path: "laptop.toml"))
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load("laptop", in: dir)
|
||||||
|
|
||||||
|
#expect(blocks.count == 1)
|
||||||
|
#expect(blocks[0].name == "greeting")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadSetThrowsForMissingSet() {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_missing_\(UUID().uuidString)")
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try PHBlock.load("nope", in: dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compute
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeReturnsCommandStdout() async throws {
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf panini"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let output = await blocks[0].compute()
|
||||||
|
|
||||||
|
#expect(output == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeRunsInConfigDirectory() async throws {
|
||||||
|
// Block commands execute with the resolved config root (see `PHPaths`) as
|
||||||
|
// their working directory, so a block from any set resolves relative paths
|
||||||
|
// the same way. Guards against the config dir being absent in sandboxed CIs.
|
||||||
|
guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return }
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "pwd"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let output = await blocks[0].compute()
|
||||||
|
|
||||||
|
// Resolve symlinks on both sides: the config dir is commonly a symlink into
|
||||||
|
// a dotfiles repo, and `pwd` reports the physical path.
|
||||||
|
let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path
|
||||||
|
#expect(output == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auto-refresh
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func updateSetsLabel() async throws {
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf panini"
|
||||||
|
""")[0]
|
||||||
|
|
||||||
|
#expect(block.label == nil)
|
||||||
|
|
||||||
|
await block.update()
|
||||||
|
|
||||||
|
#expect(block.label == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf hi"
|
||||||
|
""")[0]
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
|
||||||
|
// Allow the one-shot update task to run.
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(block.label == "hi")
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func startAutoRefreshRepeatsAtInterval() async throws {
|
||||||
|
// A counter file lets us observe how many times the command ran.
|
||||||
|
let counter = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "phbar_test_\(UUID().uuidString)")
|
||||||
|
defer { try? FileManager.default.removeItem(at: counter) }
|
||||||
|
|
||||||
|
let path = counter.path
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
refresh = 0.05
|
||||||
|
"""
|
||||||
|
|
||||||
|
let block = try PHBlock.load(from: toml)[0]
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(250))
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
|
||||||
|
let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
|
|
||||||
|
// At ~20Hz over 250ms the command should have run more than once.
|
||||||
|
#expect(count >= 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Per-monitor resolution
|
||||||
|
//
|
||||||
|
// `PHConfig` exposes one resolution API: `theme`/`window`/`blocks`/
|
||||||
|
// `monitorOverride`, each taking a pure `(screenName, screenIndex)` pair.
|
||||||
|
// Precedence for every field: override-by-name → override-by-index → global.
|
||||||
|
|
||||||
|
/// Build a `PHConfig` with sensible defaults so each test only spells out the
|
||||||
|
/// fields it cares about.
|
||||||
|
private func makeConfig(
|
||||||
|
theme: String = "default",
|
||||||
|
window: String = "default",
|
||||||
|
blocks: String? = nil,
|
||||||
|
monitors: [String: PHConfigMonitorOverride]? = nil
|
||||||
|
) -> PHConfig {
|
||||||
|
PHConfig(theme: theme, window: window, blocks: blocks, env: nil, monitors: monitors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: window
|
||||||
|
|
||||||
|
@Test func windowFallsBackToGlobal() {
|
||||||
|
let config = makeConfig(window: "top")
|
||||||
|
#expect(config.window(screenName: "Built-in", screenIndex: 0) == "top")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func windowMatchesByIndex() {
|
||||||
|
let config = makeConfig(monitors: ["1": .init(window: "bottom", blocks: nil)])
|
||||||
|
#expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func windowMatchesByName() {
|
||||||
|
let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", blocks: nil)])
|
||||||
|
#expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func windowPrefersNameOverIndex() {
|
||||||
|
let config = makeConfig(monitors: [
|
||||||
|
"0": .init(window: "byIndex", blocks: nil),
|
||||||
|
"DELL": .init(window: "byName", blocks: nil),
|
||||||
|
])
|
||||||
|
#expect(config.window(screenName: "DELL", screenIndex: 0) == "byName")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: blocks
|
||||||
|
|
||||||
|
@Test func blocksDefaultsToDefault() {
|
||||||
|
#expect(makeConfig().blocks(screenName: "S", screenIndex: 0) == "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func blocksUsesGlobalWhenNoOverride() {
|
||||||
|
let config = makeConfig(blocks: "main")
|
||||||
|
#expect(config.blocks(screenName: "S", screenIndex: 0) == "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func blocksOverrideBeatsGlobal() {
|
||||||
|
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, blocks: "alt")])
|
||||||
|
#expect(config.blocks(screenName: "S", screenIndex: 1) == "alt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: theme
|
||||||
|
|
||||||
|
@Test func themeFallsBackToGlobal() {
|
||||||
|
let config = makeConfig(theme: "voltage")
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 0) == "voltage")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func themeOverrideBeatsGlobal() {
|
||||||
|
let config = makeConfig(theme: "voltage", monitors: ["1": .init(theme: "mono", window: nil, blocks: nil)])
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 1) == "mono")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: independence
|
||||||
|
|
||||||
|
@Test func overrideInheritsUnsetFieldsFromGlobal() {
|
||||||
|
// An override that sets only `blocks` still inherits the global window/theme.
|
||||||
|
let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, blocks: "laptop")])
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
|
||||||
|
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
|
||||||
|
#expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func themeWindowAndBlocksResolveIndependently() {
|
||||||
|
let config = makeConfig(
|
||||||
|
theme: "voltage", window: "top", blocks: "main",
|
||||||
|
monitors: [
|
||||||
|
"DELL": .init(theme: "mono", window: "clock", blocks: nil), // inherits blocks "main"
|
||||||
|
"1": .init(theme: nil, window: nil, blocks: "laptop"), // inherits theme/window
|
||||||
|
]
|
||||||
|
)
|
||||||
|
#expect(config.theme(screenName: "DELL", screenIndex: 0) == "mono")
|
||||||
|
#expect(config.window(screenName: "DELL", screenIndex: 0) == "clock")
|
||||||
|
#expect(config.blocks(screenName: "DELL", screenIndex: 0) == "main")
|
||||||
|
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
|
||||||
|
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
|
||||||
|
#expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: monitorOverride
|
||||||
|
|
||||||
|
@Test func monitorOverrideReturnsNilWhenTableAbsent() {
|
||||||
|
#expect(makeConfig().monitorOverride(screenName: "S", screenIndex: 0) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func monitorOverrideReturnsNilForUnmatchedScreen() {
|
||||||
|
let config = makeConfig(monitors: ["DELL": .init(window: "clock", blocks: nil)])
|
||||||
|
#expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Fake event source
|
||||||
|
|
||||||
|
/// A no-op source that records its lifecycle and can be fired on demand.
|
||||||
|
@MainActor
|
||||||
|
private final class FakeEventSource: PHEventSource {
|
||||||
|
private(set) var startCount = 0
|
||||||
|
private(set) var stopCount = 0
|
||||||
|
private var notify: (@MainActor @Sendable () -> Void)?
|
||||||
|
|
||||||
|
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
startCount += 1
|
||||||
|
self.notify = notify
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopCount += 1
|
||||||
|
notify = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fire() { notify?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event decoding
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventDecodesFromKnownStrings() throws {
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
events = ["volume", "network", "appearance", "power", "mpd"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: toml)
|
||||||
|
|
||||||
|
#expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventAcceptsArbitraryStringAsCustom() throws {
|
||||||
|
// Unknown strings become custom event names resolved at runtime against
|
||||||
|
// `<config>/events/<name>/`.
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
events = ["totally_made_up"]
|
||||||
|
""")
|
||||||
|
|
||||||
|
#expect(blocks[0].events == [PHEvent("totally_made_up")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventsOptionalWhenOmitted() throws {
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
""")
|
||||||
|
|
||||||
|
#expect(blocks[0].events == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event loader
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
|
||||||
|
// No recognizer installed at a clean directory → factory yields nil.
|
||||||
|
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
||||||
|
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
|
||||||
|
guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil }
|
||||||
|
return PHExternalEventSource(recognizer: recognizer)
|
||||||
|
}
|
||||||
|
#expect(factory(PHEvent("anything")) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventLoaderReturnsNilForMissingEvent() {
|
||||||
|
// A clean directory has no event folders, so every name fails to load.
|
||||||
|
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
||||||
|
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
|
||||||
|
#expect(recognizer == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event registry
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
|
||||||
|
let fake = FakeEventSource()
|
||||||
|
let registry = PHEventRegistry(factory: { _ in fake })
|
||||||
|
|
||||||
|
#expect(fake.startCount == 0)
|
||||||
|
#expect(fake.stopCount == 0)
|
||||||
|
|
||||||
|
var fired = 0
|
||||||
|
let s1 = registry.subscribe(PHEvent("volume")) { fired += 1 }
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
||||||
|
|
||||||
|
// A second subscriber must reuse the already-running source.
|
||||||
|
let s2 = registry.subscribe(PHEvent("volume")) { fired += 1 }
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 2)
|
||||||
|
|
||||||
|
// One firing fans out to both subscribers.
|
||||||
|
fake.fire()
|
||||||
|
#expect(fired == 2)
|
||||||
|
|
||||||
|
// Cancelling one keeps the source alive for the other.
|
||||||
|
s1.cancel()
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
#expect(fake.stopCount == 0)
|
||||||
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
||||||
|
|
||||||
|
fake.fire()
|
||||||
|
#expect(fired == 3)
|
||||||
|
|
||||||
|
// Cancelling the last subscriber tears the source down.
|
||||||
|
s2.cancel()
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
#expect(fake.stopCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func registryStartsSeparateSourcePerEvent() async throws {
|
||||||
|
let volumeFake = FakeEventSource()
|
||||||
|
let networkFake = FakeEventSource()
|
||||||
|
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
|
||||||
|
if event == PHEvent("volume") { return volumeFake }
|
||||||
|
return networkFake
|
||||||
|
}
|
||||||
|
let registry = PHEventRegistry(factory: factory)
|
||||||
|
|
||||||
|
let v = registry.subscribe(PHEvent("volume")) {}
|
||||||
|
let n = registry.subscribe(PHEvent("network")) {}
|
||||||
|
|
||||||
|
#expect(volumeFake.startCount == 1)
|
||||||
|
#expect(networkFake.startCount == 1)
|
||||||
|
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
||||||
|
#expect(registry.subscriberCount(for: PHEvent("network")) == 1)
|
||||||
|
|
||||||
|
v.cancel()
|
||||||
|
n.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Block + events
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func blockRefreshesOnEvent() async throws {
|
||||||
|
let counter = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "phbar_evt_\(UUID().uuidString)")
|
||||||
|
defer { try? FileManager.default.removeItem(at: counter) }
|
||||||
|
let path = counter.path
|
||||||
|
|
||||||
|
let fake = FakeEventSource()
|
||||||
|
let registry = PHEventRegistry(factory: { _ in fake })
|
||||||
|
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
events = ["volume"]
|
||||||
|
""")[0]
|
||||||
|
block.registry = registry
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "1")
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
|
||||||
|
// Firing the event triggers a second refresh.
|
||||||
|
fake.fire()
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "2")
|
||||||
|
|
||||||
|
// Stopping cancels the subscription and tears the source down.
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
|
||||||
|
#expect(fake.stopCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func blockCombinesIntervalAndEvents() async throws {
|
||||||
|
let counter = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "phbar_combo_\(UUID().uuidString)")
|
||||||
|
defer { try? FileManager.default.removeItem(at: counter) }
|
||||||
|
let path = counter.path
|
||||||
|
|
||||||
|
let fake = FakeEventSource()
|
||||||
|
let registry = PHEventRegistry(factory: { _ in fake })
|
||||||
|
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
refresh = 0.2
|
||||||
|
events = ["volume"]
|
||||||
|
""")[0]
|
||||||
|
block.registry = registry
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
|
||||||
|
let afterInitial = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
|
#expect(afterInitial == 1)
|
||||||
|
#expect(fake.startCount == 1)
|
||||||
|
|
||||||
|
// Both an event and the interval can drive updates independently.
|
||||||
|
fake.fire()
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
|
||||||
|
let afterEvent = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
|
#expect(afterEvent >= 2)
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Config directory cascade
|
||||||
|
//
|
||||||
|
// `PHPaths` resolves the config root in priority order:
|
||||||
|
// 1. $XDG_CONFIG_HOME/phbar (only for an absolute XDG path)
|
||||||
|
// 2. ~/.config/phbar
|
||||||
|
// 3. ~/.phbar
|
||||||
|
|
||||||
|
@Test func pathsCandidatesIncludeAbsoluteXdgFirst() {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "/custom/xdg"])
|
||||||
|
|
||||||
|
#expect(candidates.count == 3)
|
||||||
|
#expect(candidates[0].path == "/custom/xdg/phbar")
|
||||||
|
#expect(candidates[1].path == home.appending(path: ".config/phbar").path)
|
||||||
|
#expect(candidates[2].path == home.appending(path: ".phbar").path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsCandidatesOmitRelativeXdg() {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "relative/path"])
|
||||||
|
|
||||||
|
#expect(candidates.count == 2)
|
||||||
|
#expect(candidates[0].path == home.appending(path: ".config/phbar").path)
|
||||||
|
#expect(candidates[1].path == home.appending(path: ".phbar").path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsCandidatesOmitEmptyXdg() {
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": ""])
|
||||||
|
#expect(candidates.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsCandidatesOmitXdgWhenUnset() {
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: [:])
|
||||||
|
#expect(candidates.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsResolvePicksFirstExistingCandidate() throws {
|
||||||
|
// An existing XDG-rooted dir takes priority over ~/.config/phbar because
|
||||||
|
// it sorts first in the candidate list.
|
||||||
|
let xdgRoot = FileManager.default.temporaryDirectory.appending(path: "phbar_xdg_\(UUID().uuidString)")
|
||||||
|
let xdgConfig = xdgRoot.appending(path: "phbar")
|
||||||
|
try FileManager.default.createDirectory(at: xdgConfig, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: xdgRoot) }
|
||||||
|
|
||||||
|
let resolved = PHPaths.resolveConfigDirectory(environment: ["XDG_CONFIG_HOME": xdgRoot.path])
|
||||||
|
|
||||||
|
#expect(resolved == xdgConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsConfigDirectoryMatchesResolve() {
|
||||||
|
// The cached static agrees with a fresh resolve against the live process env.
|
||||||
|
#expect(PHPaths.configDirectory == PHPaths.resolveConfigDirectory())
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - PHThemeWindowDimension
|
||||||
|
|
||||||
|
private struct DimensionWrapper: Decodable {
|
||||||
|
let v: PHThemeWindowDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionDecodesPoints() throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400}"#.utf8)).v == .points(400))
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400.5}"#.utf8)).v == .points(400.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionDecodesPercentage() throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"100%"}"#.utf8)).v == .percentage(1.0))
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"50%"}"#.utf8)).v == .percentage(0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionRejectsGarbage() {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"wat"}"#.utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionResolvesAgainstLength() {
|
||||||
|
#expect(PHThemeWindowDimension.points(250).resolve(against: 1000) == 250)
|
||||||
|
#expect(PHThemeWindowDimension.percentage(0.25).resolve(against: 1000) == 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHThemeWindowAnchor geometry
|
||||||
|
|
||||||
|
@Test func anchorPlacesAtTopEdge() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 1000, height: 30)
|
||||||
|
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 0, y: 770))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorRespectsTopMargin() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 1000, height: 30)
|
||||||
|
let rect = PHThemeWindowMargin(top: 10).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.top.origin(in: rect, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 0, y: 760))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorPlacesBottomLeadingCorner() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 200, height: 40)
|
||||||
|
let rect = PHThemeWindowMargin(bottom: 8, leading: 12).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.bottomLeading.origin(in: rect, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 12, y: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorTrailingCentersVertically() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 200, height: 40)
|
||||||
|
let rect = PHThemeWindowMargin(trailing: 20).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.trailing.origin(in: rect, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 780, y: 380))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorCentersWithinContentRect() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 200, height: 40)
|
||||||
|
// `.center` centers within the inset rect: a top margin lifts the center,
|
||||||
|
// it doesn't ignore the margin.
|
||||||
|
let rect = PHThemeWindowMargin(top: 100).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.center.origin(in: rect, size: size)
|
||||||
|
// rect: y ∈ [0, 700], midY = 350 → 350 − 20 = 330
|
||||||
|
#expect(origin == CGPoint(x: 400, y: 330))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHThemeWindowMargin.inset
|
||||||
|
|
||||||
|
@Test func marginInsetAppliesAllEdges() {
|
||||||
|
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let inset = PHThemeWindowMargin(top: 10, bottom: 20, leading: 30, trailing: 40).inset(of: rect)
|
||||||
|
#expect(inset == CGRect(x: 30, y: 20, width: 930, height: 770))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func marginInsetLeavesUnspecifiedEdgesUntouched() {
|
||||||
|
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
// Only leading set; top/bottom/trailing are left as-is (treated as 0).
|
||||||
|
let inset = PHThemeWindowMargin(leading: 50).inset(of: rect)
|
||||||
|
#expect(inset == CGRect(x: 50, y: 0, width: 950, height: 800))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BarWindow.computeFrame
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
// A window that omits geometry exercises computeFrame's defaults:
|
||||||
|
// width 100%, top anchor, no margin → a full-screen top bar. Built locally
|
||||||
|
// (rather than loading the bundled theme) so the test tracks the engine's
|
||||||
|
// defaults, not the theme file's editorial margins.
|
||||||
|
let theme = PHTheme(
|
||||||
|
windows: [PHThemeWindow(name: "default")],
|
||||||
|
texts: nil,
|
||||||
|
styles: nil
|
||||||
|
)
|
||||||
|
let config = PHConfig(theme: "default", window: "default", blocks: nil, env: nil, monitors: nil)
|
||||||
|
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
|
|
||||||
|
#expect(frame.width == screen.frame.width)
|
||||||
|
#expect(frame.minX == screen.frame.minX)
|
||||||
|
#expect(frame.minY == screen.frame.maxY - 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeFrameUsesAbsoluteOriginOverAnchor() throws {
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
let theme = PHTheme(
|
||||||
|
windows: [PHThemeWindow(name: "abs", anchor: .bottom, origin: PHThemeWindowPoint(x: 100, y: 200))],
|
||||||
|
texts: nil,
|
||||||
|
styles: nil
|
||||||
|
)
|
||||||
|
let config = PHConfig(theme: "default", window: "abs", blocks: nil, env: nil, monitors: nil)
|
||||||
|
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
|
|
||||||
|
#expect(frame.origin == CGPoint(x: 100, y: 200))
|
||||||
|
#expect(frame.height == 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeFrameInsetsFullWidthBarByMargins() throws {
|
||||||
|
// Regression: `width = "100%"` previously ignored leading/trailing margins
|
||||||
|
// and spanned edge to edge. Margins now inset the content rectangle, so the
|
||||||
|
// bar spans only between them.
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
let sf = screen.frame
|
||||||
|
let theme = PHTheme(
|
||||||
|
windows: [PHThemeWindow(
|
||||||
|
name: "inset",
|
||||||
|
width: .percentage(1.0),
|
||||||
|
anchor: .top,
|
||||||
|
margin: PHThemeWindowMargin(leading: 20, trailing: 20)
|
||||||
|
)],
|
||||||
|
texts: nil,
|
||||||
|
styles: nil
|
||||||
|
)
|
||||||
|
let config = PHConfig(theme: "default", window: "inset", blocks: nil, env: nil, monitors: nil)
|
||||||
|
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
|
|
||||||
|
#expect(frame.width == sf.width - 40)
|
||||||
|
#expect(frame.minX == sf.minX + 20)
|
||||||
|
#expect(frame.maxX == sf.maxX - 20)
|
||||||
|
}
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
|
|
||||||
@testable import phbar
|
|
||||||
|
|
||||||
// MARK: - Payload Types for Testing
|
|
||||||
|
|
||||||
private struct TestPayload: Codable, Equatable {
|
|
||||||
let message: String
|
|
||||||
let count: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Name Tests
|
|
||||||
|
|
||||||
@Test func notificationNameIsHashable() async throws {
|
|
||||||
let a = IPCNotificationName("com.example.test")
|
|
||||||
let b = IPCNotificationName("com.example.test")
|
|
||||||
let c = IPCNotificationName("com.example.other")
|
|
||||||
|
|
||||||
#expect(a == b)
|
|
||||||
#expect(a != c)
|
|
||||||
#expect(a.hashValue == b.hashValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
|
|
||||||
let name: IPCNotificationName = "com.example.test"
|
|
||||||
#expect(name.rawValue == "com.example.test")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func notificationNameRawRepresentable() async throws {
|
|
||||||
let name = IPCNotificationName(rawValue: "com.example.test")
|
|
||||||
#expect(name.rawValue == "com.example.test")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Payload Decoding Tests
|
|
||||||
|
|
||||||
@Test func decodePayloadFromNotification() async throws {
|
|
||||||
let payload = TestPayload(message: "hello", count: 42)
|
|
||||||
let userInfo: [String: Any] = ["message": "hello", "count": 42]
|
|
||||||
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
|
||||||
|
|
||||||
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
|
||||||
#expect(decoded == payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
|
|
||||||
let notification = IPCNotification(name: .refresh, userInfo: nil)
|
|
||||||
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
|
||||||
#expect(decoded == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decodePayloadReturnsNilForInvalidData() async throws {
|
|
||||||
let userInfo: [String: Any] = ["wrong": "data"]
|
|
||||||
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
|
||||||
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
|
||||||
#expect(decoded == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Posting Tests
|
|
||||||
|
|
||||||
@Test func postWithoutPayloadDoesNotCrash() async throws {
|
|
||||||
IPC.post(.refresh)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func postWithUserInfoDoesNotCrash() async throws {
|
|
||||||
IPC.post(.refresh, userInfo: ["key": "value"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
|
|
||||||
let payload = TestPayload(message: "test", count: 1)
|
|
||||||
let result = IPC.post(.refresh, payload: payload)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Observer Tests
|
|
||||||
|
|
||||||
@Test func observerCleansUpOnDeinit() async throws {
|
|
||||||
// Verify that creating and releasing an observer doesn't crash.
|
|
||||||
// The token removes itself from the distributed center on deinit.
|
|
||||||
let token = IPC.observe(.refresh) { _ in }
|
|
||||||
withExtendedLifetime(token) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Refresh Command Config
|
|
||||||
|
|
||||||
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
|
||||||
let config = phbar.refresh.configuration
|
|
||||||
#expect(config.commandName == "refresh")
|
|
||||||
#expect(config.abstract == "Refresh the running status bar.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHBlock Loading & Compute
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func loadBlocksFromTOML() throws {
|
|
||||||
let toml = """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hello"
|
|
||||||
name = "greeting"
|
|
||||||
refresh = 5.0
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
command = "echo world"
|
|
||||||
"""
|
|
||||||
|
|
||||||
let blocks = try PHBlock.load(from: toml)
|
|
||||||
|
|
||||||
#expect(blocks.count == 2)
|
|
||||||
#expect(blocks[0].command == "echo hello")
|
|
||||||
#expect(blocks[0].name == "greeting")
|
|
||||||
#expect(blocks[0].refresh == 5.0)
|
|
||||||
#expect(blocks[0].label == nil)
|
|
||||||
#expect(blocks[1].name == nil)
|
|
||||||
#expect(blocks[1].refresh == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func loadBlocksRejectsInvalidTOML() {
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try PHBlock.load(from: "not = valid = toml = =")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func computeReturnsCommandStdout() async throws {
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf panini"
|
|
||||||
""")
|
|
||||||
|
|
||||||
let output = await blocks[0].compute()
|
|
||||||
|
|
||||||
#expect(output == "panini")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func computeRunsInConfigDirectory() async throws {
|
|
||||||
// Block commands execute with the resolved config root (see `PHPaths`) as
|
|
||||||
// their working directory, so a block from any set resolves relative paths
|
|
||||||
// the same way. Guards against the config dir being absent in sandboxed CIs.
|
|
||||||
guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return }
|
|
||||||
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "pwd"
|
|
||||||
""")
|
|
||||||
|
|
||||||
let output = await blocks[0].compute()
|
|
||||||
|
|
||||||
// Resolve symlinks on both sides: the config dir is commonly a symlink into
|
|
||||||
// a dotfiles repo, and `pwd` reports the physical path.
|
|
||||||
let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path
|
|
||||||
#expect(output == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHBlock Refresh
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func updateSetsLabel() async throws {
|
|
||||||
let block = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf panini"
|
|
||||||
""")[0]
|
|
||||||
|
|
||||||
#expect(block.label == nil)
|
|
||||||
|
|
||||||
await block.update()
|
|
||||||
|
|
||||||
#expect(block.label == "panini")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
|
|
||||||
let block = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf hi"
|
|
||||||
""")[0]
|
|
||||||
|
|
||||||
block.startAutoRefresh()
|
|
||||||
|
|
||||||
// Allow the one-shot update task to run.
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
|
|
||||||
#expect(block.label == "hi")
|
|
||||||
|
|
||||||
block.stopAutoRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func startAutoRefreshRepeatsAtInterval() async throws {
|
|
||||||
// A counter file lets us observe how many times the command ran.
|
|
||||||
let counter = FileManager.default.temporaryDirectory
|
|
||||||
.appending(path: "phbar_test_\(UUID().uuidString)")
|
|
||||||
defer { try? FileManager.default.removeItem(at: counter) }
|
|
||||||
|
|
||||||
let path = counter.path
|
|
||||||
let toml = """
|
|
||||||
[[block]]
|
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
|
||||||
refresh = 0.05
|
|
||||||
"""
|
|
||||||
|
|
||||||
let block = try PHBlock.load(from: toml)[0]
|
|
||||||
|
|
||||||
block.startAutoRefresh()
|
|
||||||
|
|
||||||
try await Task.sleep(for: .milliseconds(250))
|
|
||||||
|
|
||||||
block.stopAutoRefresh()
|
|
||||||
|
|
||||||
let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
|
||||||
|
|
||||||
// At ~20Hz over 250ms the command should have run more than once.
|
|
||||||
#expect(count >= 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHEvent
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventDecodesFromKnownStrings() throws {
|
|
||||||
let toml = """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hi"
|
|
||||||
events = ["volume", "network", "appearance", "power", "mpd"]
|
|
||||||
"""
|
|
||||||
|
|
||||||
let blocks = try PHBlock.load(from: toml)
|
|
||||||
|
|
||||||
#expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")])
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventAcceptsArbitraryStringAsCustom() throws {
|
|
||||||
// Unknown strings no longer fail decoding: they become custom event names
|
|
||||||
// resolved at runtime against ~/.config/phbar/events/<name>/.
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hi"
|
|
||||||
events = ["totally_made_up"]
|
|
||||||
""")
|
|
||||||
|
|
||||||
#expect(blocks[0].events == [PHEvent("totally_made_up")])
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventsOptionalWhenOmitted() throws {
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hi"
|
|
||||||
""")
|
|
||||||
|
|
||||||
#expect(blocks[0].events == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
|
|
||||||
// No recognizer installed at a clean directory → factory yields nil.
|
|
||||||
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
|
||||||
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
|
|
||||||
guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil }
|
|
||||||
return PHExternalEventSource(recognizer: recognizer)
|
|
||||||
}
|
|
||||||
#expect(factory(PHEvent("anything")) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventLoaderReturnsNilForMissingEvent() {
|
|
||||||
// A clean directory has no event folders, so every name fails to load.
|
|
||||||
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
|
||||||
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
|
|
||||||
#expect(recognizer == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHEventRegistry
|
|
||||||
|
|
||||||
/// A no-op source that records its lifecycle and can be fired on demand.
|
|
||||||
@MainActor
|
|
||||||
private final class FakeEventSource: PHEventSource {
|
|
||||||
private(set) var startCount = 0
|
|
||||||
private(set) var stopCount = 0
|
|
||||||
private var notify: (@MainActor @Sendable () -> Void)?
|
|
||||||
|
|
||||||
func start(notify: @escaping @MainActor @Sendable () -> Void) {
|
|
||||||
startCount += 1
|
|
||||||
self.notify = notify
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
stopCount += 1
|
|
||||||
notify = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fire() { notify?() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
|
|
||||||
let fake = FakeEventSource()
|
|
||||||
let registry = PHEventRegistry(factory: { _ in fake })
|
|
||||||
|
|
||||||
#expect(fake.startCount == 0)
|
|
||||||
#expect(fake.stopCount == 0)
|
|
||||||
|
|
||||||
var fired = 0
|
|
||||||
let s1 = registry.subscribe(PHEvent("volume")) { fired += 1 }
|
|
||||||
#expect(fake.startCount == 1)
|
|
||||||
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
|
||||||
|
|
||||||
// A second subscriber must reuse the already-running source.
|
|
||||||
let s2 = registry.subscribe(PHEvent("volume")) { fired += 1 }
|
|
||||||
#expect(fake.startCount == 1)
|
|
||||||
#expect(registry.subscriberCount(for: PHEvent("volume")) == 2)
|
|
||||||
|
|
||||||
// One firing fans out to both subscribers.
|
|
||||||
fake.fire()
|
|
||||||
#expect(fired == 2)
|
|
||||||
|
|
||||||
// Cancelling one keeps the source alive for the other.
|
|
||||||
s1.cancel()
|
|
||||||
try await Task.sleep(for: .milliseconds(20))
|
|
||||||
#expect(fake.stopCount == 0)
|
|
||||||
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
|
||||||
|
|
||||||
fake.fire()
|
|
||||||
#expect(fired == 3)
|
|
||||||
|
|
||||||
// Cancelling the last subscriber tears the source down.
|
|
||||||
s2.cancel()
|
|
||||||
try await Task.sleep(for: .milliseconds(20))
|
|
||||||
#expect(fake.stopCount == 1)
|
|
||||||
#expect(registry.subscriberCount(for: PHEvent("volume")) == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func registryStartsSeparateSourcePerEvent() async throws {
|
|
||||||
let volumeFake = FakeEventSource()
|
|
||||||
let networkFake = FakeEventSource()
|
|
||||||
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
|
|
||||||
if event == PHEvent("volume") { return volumeFake }
|
|
||||||
return networkFake
|
|
||||||
}
|
|
||||||
let registry = PHEventRegistry(factory: factory)
|
|
||||||
|
|
||||||
let v = registry.subscribe(PHEvent("volume")) {}
|
|
||||||
let n = registry.subscribe(PHEvent("network")) {}
|
|
||||||
|
|
||||||
#expect(volumeFake.startCount == 1)
|
|
||||||
#expect(networkFake.startCount == 1)
|
|
||||||
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
|
|
||||||
#expect(registry.subscriberCount(for: PHEvent("network")) == 1)
|
|
||||||
|
|
||||||
v.cancel()
|
|
||||||
n.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHBlock + Events
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func blockRefreshesOnEvent() async throws {
|
|
||||||
let counter = FileManager.default.temporaryDirectory
|
|
||||||
.appending(path: "phbar_evt_\(UUID().uuidString)")
|
|
||||||
defer { try? FileManager.default.removeItem(at: counter) }
|
|
||||||
let path = counter.path
|
|
||||||
|
|
||||||
let fake = FakeEventSource()
|
|
||||||
let registry = PHEventRegistry(factory: { _ in fake })
|
|
||||||
|
|
||||||
let block = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
|
||||||
events = ["volume"]
|
|
||||||
""")[0]
|
|
||||||
block.registry = registry
|
|
||||||
|
|
||||||
block.startAutoRefresh()
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
|
|
||||||
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "1")
|
|
||||||
#expect(fake.startCount == 1)
|
|
||||||
|
|
||||||
// Firing the event triggers a second refresh.
|
|
||||||
fake.fire()
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
|
|
||||||
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "2")
|
|
||||||
|
|
||||||
// Stopping cancels the subscription and tears the source down.
|
|
||||||
block.stopAutoRefresh()
|
|
||||||
try await Task.sleep(for: .milliseconds(20))
|
|
||||||
|
|
||||||
#expect(fake.stopCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func blockCombinesIntervalAndEvents() async throws {
|
|
||||||
let counter = FileManager.default.temporaryDirectory
|
|
||||||
.appending(path: "phbar_combo_\(UUID().uuidString)")
|
|
||||||
defer { try? FileManager.default.removeItem(at: counter) }
|
|
||||||
let path = counter.path
|
|
||||||
|
|
||||||
let fake = FakeEventSource()
|
|
||||||
let registry = PHEventRegistry(factory: { _ in fake })
|
|
||||||
|
|
||||||
let block = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
|
||||||
refresh = 0.2
|
|
||||||
events = ["volume"]
|
|
||||||
""")[0]
|
|
||||||
block.registry = registry
|
|
||||||
|
|
||||||
block.startAutoRefresh()
|
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
|
|
||||||
let afterInitial = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
|
||||||
#expect(afterInitial == 1)
|
|
||||||
#expect(fake.startCount == 1)
|
|
||||||
|
|
||||||
// Both an event and the interval can drive updates independently.
|
|
||||||
fake.fire()
|
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
|
|
||||||
let afterEvent = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
|
||||||
#expect(afterEvent >= 2)
|
|
||||||
|
|
||||||
block.stopAutoRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHController
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func controllerRefreshUpdatesAllBlocks() async throws {
|
|
||||||
let screen = try #require(NSScreen.main)
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf a"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
command = "printf b"
|
|
||||||
""")
|
|
||||||
|
|
||||||
let config = try PHConfig.load()
|
|
||||||
let theme = try PHTheme.load("voltage")
|
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
|
||||||
|
|
||||||
controller.refresh()
|
|
||||||
|
|
||||||
// Allow the per-block update tasks to run.
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
|
|
||||||
#expect(blocks[0].label == "a")
|
|
||||||
#expect(blocks[1].label == "b")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHThemeDimension
|
|
||||||
|
|
||||||
private struct DimensionWrapper: Decodable {
|
|
||||||
let v: PHThemeWindowDimension
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func dimensionDecodesPoints() throws {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400}"#.utf8)).v == .points(400))
|
|
||||||
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400.5}"#.utf8)).v == .points(400.5))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func dimensionDecodesPercentage() throws {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"100%"}"#.utf8)).v == .percentage(1.0))
|
|
||||||
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"50%"}"#.utf8)).v == .percentage(0.5))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func dimensionRejectsGarbage() {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"wat"}"#.utf8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func dimensionResolvesAgainstLength() {
|
|
||||||
#expect(PHThemeWindowDimension.points(250).resolve(against: 1000) == 250)
|
|
||||||
#expect(PHThemeWindowDimension.percentage(0.25).resolve(against: 1000) == 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHThemeAnchor geometry
|
|
||||||
|
|
||||||
@Test func anchorPlacesAtTopEdge() {
|
|
||||||
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
|
||||||
let size = CGSize(width: 1000, height: 30)
|
|
||||||
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size)
|
|
||||||
#expect(origin == CGPoint(x: 0, y: 770))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func anchorRespectsTopMargin() {
|
|
||||||
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
|
||||||
let size = CGSize(width: 1000, height: 30)
|
|
||||||
let rect = PHThemeWindowMargin(top: 10).inset(of: screen)
|
|
||||||
let origin = PHThemeWindowAnchor.top.origin(in: rect, size: size)
|
|
||||||
#expect(origin == CGPoint(x: 0, y: 760))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func anchorPlacesBottomLeadingCorner() {
|
|
||||||
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
|
||||||
let size = CGSize(width: 200, height: 40)
|
|
||||||
let rect = PHThemeWindowMargin(bottom: 8, leading: 12).inset(of: screen)
|
|
||||||
let origin = PHThemeWindowAnchor.bottomLeading.origin(in: rect, size: size)
|
|
||||||
#expect(origin == CGPoint(x: 12, y: 8))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func anchorTrailingCentersVertically() {
|
|
||||||
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
|
||||||
let size = CGSize(width: 200, height: 40)
|
|
||||||
let rect = PHThemeWindowMargin(trailing: 20).inset(of: screen)
|
|
||||||
let origin = PHThemeWindowAnchor.trailing.origin(in: rect, size: size)
|
|
||||||
#expect(origin == CGPoint(x: 780, y: 380))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func anchorCentersWithinContentRect() {
|
|
||||||
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
|
||||||
let size = CGSize(width: 200, height: 40)
|
|
||||||
// `.center` centers within the inset rect: a top margin lifts the center,
|
|
||||||
// it doesn't ignore the margin.
|
|
||||||
let rect = PHThemeWindowMargin(top: 100).inset(of: screen)
|
|
||||||
let origin = PHThemeWindowAnchor.center.origin(in: rect, size: size)
|
|
||||||
// rect: y ∈ [0, 700], midY = 350 → 350 − 20 = 330
|
|
||||||
#expect(origin == CGPoint(x: 400, y: 330))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Monitor → window/blocks resolver
|
|
||||||
|
|
||||||
@Test func windowNameFallsBackToGlobal() {
|
|
||||||
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
|
|
||||||
#expect(config.windowName(screenName: "Built-in", screenIndex: 0) == "default")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func windowNameMatchesByIndex() {
|
|
||||||
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: ["1": .init(window: "bottom", blocks: nil)])
|
|
||||||
#expect(config.windowName(screenName: "Whatever", screenIndex: 1) == "bottom")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func windowNameMatchesByName() {
|
|
||||||
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: ["DELL U2723QE": .init(window: "clock", blocks: nil)])
|
|
||||||
#expect(config.windowName(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func monitorOverridePrefersNameOverIndex() {
|
|
||||||
let config = PHConfig(
|
|
||||||
window: "default", blocks: nil, env: nil,
|
|
||||||
monitors: ["0": .init(window: "byIndex", blocks: nil), "DELL": .init(window: "byName", blocks: nil)]
|
|
||||||
)
|
|
||||||
#expect(config.windowName(screenName: "DELL", screenIndex: 0) == "byName")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func windowInheritsGlobalWhenOverrideOmitsIt() {
|
|
||||||
// Override sets blocks only → window falls back to the global.
|
|
||||||
let config = PHConfig(window: "top", blocks: nil, env: nil, monitors: ["1": .init(window: nil, blocks: "laptop")])
|
|
||||||
#expect(config.windowName(screenName: "S", screenIndex: 1) == "top")
|
|
||||||
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "laptop")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func blocksNameDefaultsToDefault() {
|
|
||||||
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
|
|
||||||
#expect(config.blocksName(screenName: "S", screenIndex: 0) == "default")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func blocksNameUsesGlobalWhenNoOverride() {
|
|
||||||
let config = PHConfig(window: "default", blocks: "main", env: nil, monitors: nil)
|
|
||||||
#expect(config.blocksName(screenName: "S", screenIndex: 0) == "main")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func blocksOverrideBeatsGlobal() {
|
|
||||||
let config = PHConfig(window: "default", blocks: "main", env: nil, monitors: ["1": .init(window: nil, blocks: "alt")])
|
|
||||||
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "alt")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func windowAndBlocksResolveIndependently() {
|
|
||||||
let config = PHConfig(
|
|
||||||
window: "top", blocks: "main", env: nil,
|
|
||||||
monitors: [
|
|
||||||
"DELL": .init(window: "clock", blocks: nil), // inherits blocks "main"
|
|
||||||
"1": .init(window: nil, blocks: "laptop"), // inherits window "top"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
#expect(config.windowName(screenName: "DELL", screenIndex: 0) == "clock")
|
|
||||||
#expect(config.blocksName(screenName: "DELL", screenIndex: 0) == "main")
|
|
||||||
#expect(config.windowName(screenName: "S", screenIndex: 1) == "top")
|
|
||||||
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "laptop")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHBlock set loading
|
|
||||||
|
|
||||||
private func makeBlocksConfigDir() throws -> URL {
|
|
||||||
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_blocks_\(UUID().uuidString)")
|
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func loadSetReadsNamedFile() throws {
|
|
||||||
let dir = try makeBlocksConfigDir()
|
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
|
||||||
|
|
||||||
let blocksDir = dir.appending(path: "blocks")
|
|
||||||
try FileManager.default.createDirectory(at: blocksDir, withIntermediateDirectories: true)
|
|
||||||
try Data("""
|
|
||||||
[[block]]
|
|
||||||
command = "echo hi"
|
|
||||||
name = "greeting"
|
|
||||||
""".utf8).write(to: blocksDir.appending(path: "laptop.toml"))
|
|
||||||
|
|
||||||
let blocks = try PHBlock.load("laptop", in: dir)
|
|
||||||
|
|
||||||
#expect(blocks.count == 1)
|
|
||||||
#expect(blocks[0].name == "greeting")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func loadSetThrowsForMissingSet() {
|
|
||||||
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_missing_\(UUID().uuidString)")
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try PHBlock.load("nope", in: dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BarWindow.computeFrame
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
|
|
||||||
let screen = try #require(NSScreen.main)
|
|
||||||
// A window that omits geometry exercises computeFrame's defaults:
|
|
||||||
// width 100%, top anchor, no margin → a full-screen top bar. Built locally
|
|
||||||
// (rather than loading the bundled theme) so the test tracks the engine's
|
|
||||||
// defaults, not the theme file's editorial margins.
|
|
||||||
let theme = PHTheme(
|
|
||||||
windows: [PHThemeWindow(name: "default")],
|
|
||||||
texts: nil,
|
|
||||||
styles: nil
|
|
||||||
)
|
|
||||||
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
|
|
||||||
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
|
||||||
|
|
||||||
let frame = BarWindow.computeFrame(from: controller)
|
|
||||||
|
|
||||||
#expect(frame.width == screen.frame.width)
|
|
||||||
#expect(frame.minX == screen.frame.minX)
|
|
||||||
#expect(frame.minY == screen.frame.maxY - 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func computeFrameUsesAbsoluteOriginOverAnchor() throws {
|
|
||||||
let screen = try #require(NSScreen.main)
|
|
||||||
let theme = PHTheme(
|
|
||||||
windows: [PHThemeWindow(name: "abs", anchor: .bottom, origin: PHThemeWindowPoint(x: 100, y: 200))],
|
|
||||||
texts: nil,
|
|
||||||
styles: nil
|
|
||||||
)
|
|
||||||
let config = PHConfig(window: "abs", blocks: nil, env: nil, monitors: nil)
|
|
||||||
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
|
||||||
|
|
||||||
let frame = BarWindow.computeFrame(from: controller)
|
|
||||||
|
|
||||||
#expect(frame.origin == CGPoint(x: 100, y: 200))
|
|
||||||
#expect(frame.height == 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func computeFrameInsetsFullWidthBarByMargins() throws {
|
|
||||||
// Regression: `width = "100%"` previously ignored leading/trailing margins
|
|
||||||
// and spanned edge to edge. Margins now inset the content rectangle, so the
|
|
||||||
// bar spans only between them.
|
|
||||||
let screen = try #require(NSScreen.main)
|
|
||||||
let sf = screen.frame
|
|
||||||
let theme = PHTheme(
|
|
||||||
windows: [PHThemeWindow(
|
|
||||||
name: "inset",
|
|
||||||
width: .percentage(1.0),
|
|
||||||
anchor: .top,
|
|
||||||
margin: PHThemeWindowMargin(leading: 20, trailing: 20)
|
|
||||||
)],
|
|
||||||
texts: nil,
|
|
||||||
styles: nil
|
|
||||||
)
|
|
||||||
let config = PHConfig(window: "inset", blocks: nil, env: nil, monitors: nil)
|
|
||||||
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
|
||||||
|
|
||||||
let frame = BarWindow.computeFrame(from: controller)
|
|
||||||
|
|
||||||
#expect(frame.width == sf.width - 40)
|
|
||||||
#expect(frame.minX == sf.minX + 20)
|
|
||||||
#expect(frame.maxX == sf.maxX - 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHPaths (config directory cascade)
|
|
||||||
|
|
||||||
@Test func pathsCandidatesIncludeAbsoluteXdgFirst() {
|
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "/custom/xdg"])
|
|
||||||
|
|
||||||
#expect(candidates.count == 3)
|
|
||||||
#expect(candidates[0].path == "/custom/xdg/phbar")
|
|
||||||
#expect(candidates[1].path == home.appending(path: ".config/phbar").path)
|
|
||||||
#expect(candidates[2].path == home.appending(path: ".phbar").path)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func pathsCandidatesOmitRelativeXdg() {
|
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "relative/path"])
|
|
||||||
|
|
||||||
#expect(candidates.count == 2)
|
|
||||||
#expect(candidates[0].path == home.appending(path: ".config/phbar").path)
|
|
||||||
#expect(candidates[1].path == home.appending(path: ".phbar").path)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func pathsCandidatesOmitEmptyXdg() {
|
|
||||||
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": ""])
|
|
||||||
#expect(candidates.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func pathsCandidatesOmitXdgWhenUnset() {
|
|
||||||
let candidates = PHPaths.configDirectoryCandidates(environment: [:])
|
|
||||||
#expect(candidates.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func pathsResolvePicksFirstExistingCandidate() throws {
|
|
||||||
// An existing XDG-rooted dir takes priority over ~/.config/phbar because
|
|
||||||
// it sorts first in the candidate list.
|
|
||||||
let xdgRoot = FileManager.default.temporaryDirectory.appending(path: "phbar_xdg_\(UUID().uuidString)")
|
|
||||||
let xdgConfig = xdgRoot.appending(path: "phbar")
|
|
||||||
try FileManager.default.createDirectory(at: xdgConfig, withIntermediateDirectories: true)
|
|
||||||
defer { try? FileManager.default.removeItem(at: xdgRoot) }
|
|
||||||
|
|
||||||
let resolved = PHPaths.resolveConfigDirectory(environment: ["XDG_CONFIG_HOME": xdgRoot.path])
|
|
||||||
|
|
||||||
#expect(resolved == xdgConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func pathsConfigDirectoryMatchesResolve() {
|
|
||||||
// The cached static agrees with a fresh resolve against the live process env.
|
|
||||||
#expect(PHPaths.configDirectory == PHPaths.resolveConfigDirectory())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user