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: 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()) }