Files
phbar/Tests/phbarTests/phbarTests.swift
T

708 lines
22 KiB
Swift

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: PHThemeDimension
}
@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(PHThemeDimension.points(250).resolve(against: 1000) == 250)
#expect(PHThemeDimension.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 = PHThemeAnchor.top.origin(in: screen, size: size, margin: .init())
#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 origin = PHThemeAnchor.top.origin(in: screen, size: size, margin: .init(top: 10))
#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 origin = PHThemeAnchor.bottomLeading.origin(
in: screen, size: size, margin: .init(bottom: 8, leading: 12)
)
#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 origin = PHThemeAnchor.trailing.origin(
in: screen, size: size, margin: .init(trailing: 20)
)
#expect(origin == CGPoint(x: 780, y: 380))
}
@Test func anchorCentersOnBothAxesIgnoringMargin() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let origin = PHThemeAnchor.center.origin(in: screen, size: size, margin: .init(top: 999))
#expect(origin == CGPoint(x: 400, y: 380))
}
// 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)
let theme = try PHTheme.load("voltage")
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: PHThemePoint(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)
}
// 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())
}