221 lines
6.2 KiB
Swift
221 lines
6.2 KiB
Swift
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
|
|
// `<config>/events/<name>/`.
|
|
let blocks = try PHBlock.load(from: """
|
|
[[block]]
|
|
command = "echo hi"
|
|
events = ["totally_made_up"]
|
|
""")
|
|
|
|
#expect(blocks[0].events == [PHEvent("totally_made_up")])
|
|
}
|
|
|
|
@MainActor
|
|
@Test func eventsOptionalWhenOmitted() throws {
|
|
let blocks = try PHBlock.load(from: """
|
|
[[block]]
|
|
command = "echo hi"
|
|
""")
|
|
|
|
#expect(blocks[0].events == nil)
|
|
}
|
|
|
|
// 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()
|
|
}
|