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 == "") #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") } // MARK: - PHBlock Refresh @MainActor @Test func updateSetsLabel() async throws { let block = try PHBlock.load(from: """ [[block]] command = "printf panini" """)[0] #expect(block.label == "") 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) 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") }