portico/Tests/PorticoTests/MainLoopSourceTests.swift

139 lines
4.5 KiB
Swift

import Testing
@_spi(Portico) import Portico
@_silgen_name("g_main_context_iteration")
private nonisolated func test_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
@_silgen_name("g_main_context_find_source_by_id")
private nonisolated func test_g_main_context_find_source_by_id(
_ context: UnsafeMutableRawPointer?, _ sourceId: UInt32
) -> UnsafeMutableRawPointer?
@_silgen_name("g_source_destroy")
private nonisolated func test_g_source_destroy(_ source: UnsafeMutableRawPointer)
@MainActor @Suite(.serialized) struct MainLoopSourceTests {
/// Pumps the default main context until `condition` is true OR `timeout`
/// elapses. Returns `true` if the watchdog fired (timeout elapsed without
/// `condition` becoming true).
private func pump(
until condition: () -> Bool, timeout: Duration = .seconds(2)
) -> Bool {
var expired = false
let watchdog = Timeout(interval: timeout, repeats: false) { expired = true }
defer { watchdog.remove() }
while !condition() && !expired {
_ = test_g_main_context_iteration(nil, 1)
}
return expired
}
// MARK: - Duration conversion
@Test func clampedMilliseconds() {
#expect(_clampedMilliseconds(.seconds(1)) == 1000)
#expect(_clampedMilliseconds(.milliseconds(1500)) == 1500)
#expect(_clampedMilliseconds(.microseconds(500)) == 1)
#expect(_clampedMilliseconds(.zero) == 0)
#expect(_clampedMilliseconds(.seconds(-5)) == 0)
#expect(_clampedMilliseconds(.seconds(Int64(UInt32.max) / 1000 + 1)) == UInt32.max)
}
// MARK: - Idle
@Test func idleRunsOnceThenSelfRemoves() {
var fired = 0
let id = Idle { fired += 1 }
#expect(id.rawValue > 0)
let timedOut = pump(until: { fired > 0 })
#expect(!timedOut, "main loop pump timed out")
#expect(fired == 1)
#expect(id.isRemoved)
for _ in 0..<20 {
_ = test_g_main_context_iteration(nil, 0)
}
#expect(fired == 1)
}
@Test func removeBeforeDispatchCancels() {
var fired = false
let id = Idle { fired = true }
id.remove()
#expect(id.isRemoved)
for _ in 0..<50 {
_ = test_g_main_context_iteration(nil, 0)
}
#expect(!fired)
}
@Test func removeIsIdempotent() {
let id = Idle {}
id.remove()
id.remove()
#expect(id.isRemoved)
}
// MARK: - Timeout
@Test func timeoutRepeatsUntilSelfRemoved() {
var ticks = 0
Timeout(interval: .milliseconds(1)) { source in
ticks += 1
if ticks == 3 { source.remove() }
}
let timedOut = pump(until: { ticks >= 3 })
#expect(!timedOut, "main loop pump timed out")
#expect(ticks == 3)
for _ in 0..<50 {
_ = test_g_main_context_iteration(nil, 0)
}
#expect(ticks == 3)
}
@Test func nonRepeatingTimeoutFiresOnce() {
var ticks = 0
let id = Timeout(interval: .milliseconds(1), repeats: false) { ticks += 1 }
let timedOut = pump(until: { ticks > 0 })
#expect(!timedOut, "main loop pump timed out")
#expect(ticks == 1)
#expect(id.isRemoved)
for _ in 0..<50 {
_ = test_g_main_context_iteration(nil, 0)
}
#expect(ticks == 1)
}
// MARK: - Remove from inside callback (regression)
@Test func repeatingTimeoutRemoveFromCallback() {
var ticks = 0
Timeout(interval: .milliseconds(1)) { source in
ticks += 1
source.remove()
}
let timedOut = pump(until: { ticks > 0 })
#expect(!timedOut, "main loop pump timed out")
#expect(ticks == 1)
}
/// Destroy-notify may run on a worker thread and must release the callback
/// box without asserting main-actor isolation.
@Test func destroyNotifyOffMainDoesNotTrap() async {
var fired = false
let id = Idle(repeats: true) { fired = true }
guard let source = test_g_main_context_find_source_by_id(nil, id.rawValue) else {
#expect(Bool(false), "attached source not found by id")
return
}
let address = UInt(bitPattern: source)
await Task.detached {
test_g_source_destroy(UnsafeMutableRawPointer(bitPattern: address)!)
}.value
#expect(id.isRemoved)
for _ in 0..<20 { _ = test_g_main_context_iteration(nil, 0) }
#expect(!fired)
}
}