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