full scope implementation
This commit is contained in:
@@ -1,9 +1,431 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import phbar
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
// Swift Testing Documentation
|
||||
// https://developer.apple.com/documentation/testing
|
||||
// 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 == [.volume, .network, .appearance, .power, .mpd])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func eventRejectsUnknownString() {
|
||||
#expect(throws: (any Error).self) {
|
||||
try PHBlock.load(from: """
|
||||
[[block]]
|
||||
command = "echo hi"
|
||||
events = ["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 defaultFactoryReturnsMPDSource() {
|
||||
let source = PHEventRegistry.defaultFactory(.mpd)
|
||||
#expect(source is MPDEventSource)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func mpdSourceIsIdempotentStartStop() {
|
||||
// With MPD absent the source keeps trying to connect; ensure start/stop are
|
||||
// safe and idempotent without a running daemon.
|
||||
let source = MPDEventSource(host: "127.0.0.1", port: 1)
|
||||
source.start(notify: {})
|
||||
source.start(notify: {}) // second start is a no-op
|
||||
source.stop()
|
||||
source.stop() // second stop is a no-op
|
||||
}
|
||||
|
||||
// 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(.volume) { fired += 1 }
|
||||
#expect(fake.startCount == 1)
|
||||
#expect(registry.subscriberCount(for: .volume) == 1)
|
||||
|
||||
// A second subscriber must reuse the already-running source.
|
||||
let s2 = registry.subscribe(.volume) { fired += 1 }
|
||||
#expect(fake.startCount == 1)
|
||||
#expect(registry.subscriberCount(for: .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: .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: .volume) == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func registryStartsSeparateSourcePerEvent() async throws {
|
||||
let volumeFake = FakeEventSource()
|
||||
let networkFake = FakeEventSource()
|
||||
let factory: @MainActor @Sendable (PHEvent) -> any PHEventSource = { event in
|
||||
switch event {
|
||||
case .volume: return volumeFake
|
||||
default: return networkFake
|
||||
}
|
||||
}
|
||||
let registry = PHEventRegistry(factory: factory)
|
||||
|
||||
let v = registry.subscribe(.volume) {}
|
||||
let n = registry.subscribe(.network) {}
|
||||
|
||||
#expect(volumeFake.startCount == 1)
|
||||
#expect(networkFake.startCount == 1)
|
||||
#expect(registry.subscriberCount(for: .volume) == 1)
|
||||
#expect(registry.subscriberCount(for: .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 controller = PHController(screen: screen, 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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user