From d4d1b7bec4c64675a100e8b85bf29ed4266a077b Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Sun, 12 Jul 2026 16:16:46 +0200 Subject: [PATCH] cleanup tests --- Tests/phbarTests/BarControllerTests.swift | 39 ++ Tests/phbarTests/IPCTests.swift | 82 +++ Tests/phbarTests/PHBlockTests.swift | 167 +++++ Tests/phbarTests/PHConfigTests.swift | 112 ++++ Tests/phbarTests/PHEventTests.swift | 220 +++++++ Tests/phbarTests/PHPathsTests.swift | 58 ++ Tests/phbarTests/PHThemeWindowTests.swift | 166 +++++ Tests/phbarTests/phbarTests.swift | 746 ---------------------- 8 files changed, 844 insertions(+), 746 deletions(-) create mode 100644 Tests/phbarTests/BarControllerTests.swift create mode 100644 Tests/phbarTests/IPCTests.swift create mode 100644 Tests/phbarTests/PHBlockTests.swift create mode 100644 Tests/phbarTests/PHConfigTests.swift create mode 100644 Tests/phbarTests/PHEventTests.swift create mode 100644 Tests/phbarTests/PHPathsTests.swift create mode 100644 Tests/phbarTests/PHThemeWindowTests.swift delete mode 100644 Tests/phbarTests/phbarTests.swift diff --git a/Tests/phbarTests/BarControllerTests.swift b/Tests/phbarTests/BarControllerTests.swift new file mode 100644 index 0000000..61b3832 --- /dev/null +++ b/Tests/phbarTests/BarControllerTests.swift @@ -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") +} diff --git a/Tests/phbarTests/IPCTests.swift b/Tests/phbarTests/IPCTests.swift new file mode 100644 index 0000000..aa2d5e0 --- /dev/null +++ b/Tests/phbarTests/IPCTests.swift @@ -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) {} +} diff --git a/Tests/phbarTests/PHBlockTests.swift b/Tests/phbarTests/PHBlockTests.swift new file mode 100644 index 0000000..3c73c19 --- /dev/null +++ b/Tests/phbarTests/PHBlockTests.swift @@ -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) +} diff --git a/Tests/phbarTests/PHConfigTests.swift b/Tests/phbarTests/PHConfigTests.swift new file mode 100644 index 0000000..86c32c3 --- /dev/null +++ b/Tests/phbarTests/PHConfigTests.swift @@ -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) +} diff --git a/Tests/phbarTests/PHEventTests.swift b/Tests/phbarTests/PHEventTests.swift new file mode 100644 index 0000000..f755821 --- /dev/null +++ b/Tests/phbarTests/PHEventTests.swift @@ -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 + // `/events//`. + 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() +} diff --git a/Tests/phbarTests/PHPathsTests.swift b/Tests/phbarTests/PHPathsTests.swift new file mode 100644 index 0000000..546ad2c --- /dev/null +++ b/Tests/phbarTests/PHPathsTests.swift @@ -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()) +} diff --git a/Tests/phbarTests/PHThemeWindowTests.swift b/Tests/phbarTests/PHThemeWindowTests.swift new file mode 100644 index 0000000..e4ab281 --- /dev/null +++ b/Tests/phbarTests/PHThemeWindowTests.swift @@ -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) +} diff --git a/Tests/phbarTests/phbarTests.swift b/Tests/phbarTests/phbarTests.swift deleted file mode 100644 index f5ac6b2..0000000 --- a/Tests/phbarTests/phbarTests.swift +++ /dev/null @@ -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//. - 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()) -} - -