From 8f36dda813d6c128b98bb2c7cc514f382b3b787c Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:28 +0200 Subject: [PATCH 1/7] Tests: ManualClock wakes a waiter when its sleeper arrives, and waitUntil joins eventually in PladderTestSupport `waitForSleeper(at:)` polled `deadlines` a millisecond at a time; a sleep now resumes the waiters it satisfies, and `waitForSleepers(_:)` does the same for a count, replacing four `waitUntil { clock.sleeperCount >= 2 }`. Both still end by themselves after two seconds, as a Gate does. `waitUntil` moves from DictationCoordinatorTests to PladderTestSupport, and the coordinator tests' own `eventually` goes in favour of the shared one. Co-Authored-By: Claude Opus 5.5 --- .../CoordinatorRegressionTests.swift | 4 +- .../DictationCoordinatorTests.swift | 24 +----- .../PladderCoreTests/EngineLoaderTests.swift | 1 + Tests/PladderTestSupport/ManualClock.swift | 77 ++++++++++++++----- Tests/PladderTestSupport/TestSupport.swift | 21 +++++ 5 files changed, 85 insertions(+), 42 deletions(-) diff --git a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift index c9d3681..67702f2 100644 --- a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift +++ b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift @@ -290,7 +290,7 @@ import Testing let (c, output, _) = makeCoordinator(clock: clock) await c.startIdle() await c.hotkeyPressed() - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration - .seconds(1)) #expect(c.state.isRecording) clock.advance(by: .seconds(1)) @@ -354,7 +354,7 @@ import Testing await c.hotkeyPressed() #expect(await waitUntil { warmPasses() == 1 }) for expected in 2...4 { - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.warmupInterval) #expect(await waitUntil { warmPasses() == expected }) } diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 8401aa1..9379c77 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -389,26 +389,6 @@ func makeStreamingCoordinator( return (coordinator, output, capture, engine) } -@MainActor -func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - return condition() -} - -// A dropped recording is idle at once; its microphone stops on a task after. -func eventually(_ timeout: Duration = .seconds(2), _ condition: () async -> Bool) async -> Bool { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if await condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - return await condition() -} - extension DictationCoordinator { func startIdle(sourceLocation: SourceLocation = #_sourceLocation) async { start() @@ -1327,7 +1307,7 @@ final class EventLog: @unchecked Sendable { await c.press(hotkey) hotkey.release() #expect(await waitUntil { c.isLatched }) - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration) await c.cycleFinished() #expect(output.inserted.count == 1) @@ -1464,7 +1444,7 @@ final class EventLog: @unchecked Sendable { #expect(await waitUntil { c.state == .idle }) #expect(hotkey.cancelKeyEnabled == [true, false, true, false]) await c.hotkeyPressed() - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration) #expect(await waitUntil { !c.state.isRecording }) await c.inFlight?.value diff --git a/Tests/PladderCoreTests/EngineLoaderTests.swift b/Tests/PladderCoreTests/EngineLoaderTests.swift index ab2242e..49d0cee 100644 --- a/Tests/PladderCoreTests/EngineLoaderTests.swift +++ b/Tests/PladderCoreTests/EngineLoaderTests.swift @@ -1,4 +1,5 @@ import Foundation +import PladderTestSupport import Testing @testable import PladderCore diff --git a/Tests/PladderTestSupport/ManualClock.swift b/Tests/PladderTestSupport/ManualClock.swift index 2ab6f7b..e7d199d 100644 --- a/Tests/PladderTestSupport/ManualClock.swift +++ b/Tests/PladderTestSupport/ManualClock.swift @@ -10,11 +10,31 @@ public final class ManualClock: Clock, Sendable { let continuation: CheckedContinuation } + private struct Watcher { + let id: Int + let isMet: @Sendable ([Instant]) -> Bool + let continuation: CheckedContinuation + } + private struct State { var now: Instant var nextID = 0 var sleepers: [Sleeper] = [] var cancelled: Set = [] + var watchers: [Watcher] = [] + var expired: Set = [] + + mutating func takeID() -> Int { + defer { nextID += 1 } + return nextID + } + + mutating func takeMetWatchers() -> [Watcher] { + let deadlines = sleepers.map(\.deadline) + let met = watchers.filter { $0.isMet(deadlines) } + watchers.removeAll { watcher in met.contains { $0.id == watcher.id } } + return met + } } private let state: Mutex @@ -47,18 +67,16 @@ public final class ManualClock: Clock, Sendable { } public func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { - let id = state.withLock { state -> Int in - defer { state.nextID += 1 } - return state.nextID - } + let id = state.withLock { $0.takeID() } try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let early = state.withLock { state -> Result? in - if state.cancelled.remove(id) != nil || Task.isCancelled { return .failure(CancellationError()) } - if deadline <= state.now { return .success(()) } + let (early, met) = state.withLock { state -> (Result?, [Watcher]) in + if state.cancelled.remove(id) != nil || Task.isCancelled { return (.failure(CancellationError()), []) } + if deadline <= state.now { return (.success(()), []) } state.sleepers.append(Sleeper(id: id, deadline: deadline, continuation: continuation)) - return nil + return (nil, state.takeMetWatchers()) } + for watcher in met { watcher.continuation.resume(returning: true) } if let early { continuation.resume(with: early) } } } onCancel: { @@ -73,19 +91,42 @@ public final class ManualClock: Clock, Sendable { } } - // Waits in real time, but briefly, until a detached task has reached its sleep. public func waitForSleeper(at offset: Duration) async -> Bool { let deadline = start + offset - return await eventually { self.deadlines.contains(deadline) } + return await waitFor { $0.contains(deadline) } } -} -// Polls in real time, a millisecond at a time for up to about two seconds, for what a -// detached task does on its own schedule. -public func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { - for _ in 0..<2_000 { - if await condition() { return true } - try? await Task.sleep(for: .milliseconds(1)) + public func waitForSleepers(_ count: Int) async -> Bool { + await waitFor { $0.count >= count } + } + + private func waitFor(_ isMet: @escaping @Sendable ([Instant]) -> Bool) async -> Bool { + let id = state.withLock { $0.takeID() } + // Ends the wait after two seconds, as a `Gate` does, so a regression fails rather than hangs. + let timeout = Task { + guard (try? await Task.sleep(for: .seconds(2))) != nil else { return } + self.expire(id) + } + defer { timeout.cancel() } + return await withCheckedContinuation { (continuation: CheckedContinuation) in + let early = state.withLock { state -> Bool? in + if state.expired.remove(id) != nil { return false } + if isMet(state.sleepers.map(\.deadline)) { return true } + state.watchers.append(Watcher(id: id, isMet: isMet, continuation: continuation)) + return nil + } + if let early { continuation.resume(returning: early) } + } + } + + private func expire(_ id: Int) { + let watcher = state.withLock { state -> Watcher? in + if let index = state.watchers.firstIndex(where: { $0.id == id }) { + return state.watchers.remove(at: index) + } + state.expired.insert(id) + return nil + } + watcher?.continuation.resume(returning: false) } - return await condition() } diff --git a/Tests/PladderTestSupport/TestSupport.swift b/Tests/PladderTestSupport/TestSupport.swift index 8d350f3..b662b60 100644 --- a/Tests/PladderTestSupport/TestSupport.swift +++ b/Tests/PladderTestSupport/TestSupport.swift @@ -61,3 +61,24 @@ public func scratchDirectory(_ name: String) throws -> URL { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } + +// Polls in real time: the fakes' counters are not observable. +@MainActor +public func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() +} + +// Polls in real time, a millisecond at a time for up to about two seconds, for what a +// detached task does on its own schedule. +public func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { + for _ in 0..<2_000 { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(1)) + } + return await condition() +} From bb459393a22f59815cde2b254f291ab6bfe313db Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:35 +0200 Subject: [PATCH 2/7] Tests: the coordinator fakes hold on a Gate instead of sleeping `FakeCapture.stop`, `FakeRefiner.refine` and the counting engine's warm pass slept 50 to 200 ms so a cancel, a press or a release would land while they ran. Each now waits on a Gate the test opens once it has seen that state. The warm pass's wait cannot be cancelled, like a CoreML pass in flight. Co-Authored-By: Claude Opus 5.5 --- .../CoordinatorRegressionTests.swift | 12 ++++-- .../DictationCoordinatorTests.swift | 42 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift index 67702f2..a7f6996 100644 --- a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift +++ b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift @@ -53,12 +53,16 @@ import Testing @Test func aPressRightAfterACancelKeepsItsUtterance() async { let engine = FakeStreamingEngine() let (c, output, capture, _) = await makeStreamingCoordinator(style: .compact, engine: engine) - await capture.setStopDelay(.milliseconds(150)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.hotkeyPressed() let cancel = Task { await c.cancelRecording() } #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed() + let press = Task { await c.hotkeyPressed() } + #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() + await press.value #expect(c.state.isRecording) await cancel.value c.hotkeyReleased() @@ -92,7 +96,8 @@ import Testing @Test func aPressCancelledWhileItWaitsLeavesTheMicrophoneAlone() async { let (c, _, capture) = makeCoordinator() - await capture.setStopDelay(.milliseconds(150)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.hotkeyPressed() let cancel = Task { await c.cancelRecording() } @@ -103,6 +108,7 @@ import Testing #expect(await waitUntil { c.state == .idle }) let press = Task { await c.hotkeyPressed() } #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() _ = await (cancel.value, cancelledPress.value, cancelPress.value, press.value) #expect(c.state.isRecording) #expect(await capture.startCount == 2) diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 9379c77..c3e071e 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -17,7 +17,7 @@ actor FakeCapture: AudioCapture { var startCount = 0 var stopCount = 0 var levelContinuation: AsyncStream.Continuation? - var stopDelay: Duration = .zero + var stopGate: Gate? func start() async throws -> AsyncStream { startCount += 1 @@ -33,7 +33,7 @@ actor FakeCapture: AudioCapture { func stop() async -> CapturedAudio { stopCount += 1 levelContinuation?.finish() - if stopDelay > .zero { try? await Task.sleep(for: stopDelay) } + await stopGate?.wait() return CapturedAudio(samples: samplesToReturn) } @@ -41,7 +41,7 @@ actor FakeCapture: AudioCapture { func setSamples(_ s: [Float]) { samplesToReturn = s } func setDrainSamples(_ s: [Float]) { drainSamples = s } - func setStopDelay(_ d: Duration) { stopDelay = d } + func setStopGate(_ gate: Gate) { stopGate = gate } func emitLevel(_ l: Float) { levelContinuation?.yield(l) } } @@ -126,20 +126,20 @@ final class FakeRefiner: TranscriptRefiner, @unchecked Sendable { private var _calls: [String] = [] private var _prepareCount = 0 private let result: String? - private let delay: Duration + private let gate: Gate? var calls: [String] { lock.withLock { _calls } } var prepareCount: Int { lock.withLock { _prepareCount } } - init(result: String? = "polished", delay: Duration = .zero) { + init(result: String? = "polished", gate: Gate? = nil) { self.result = result - self.delay = delay + self.gate = gate } func prepare() async { lock.withLock { _prepareCount += 1 } } func refine(_ text: String) async -> String? { lock.withLock { _calls.append(text) } - if delay > .zero { try? await Task.sleep(for: delay) } + await gate?.wait() return result } } @@ -184,15 +184,15 @@ actor CountingEngine: TranscriptionEngine { nonisolated let displayName = "Counting" private(set) var status: EngineStatus = .unloaded private let recorder = Recorder() - private let delay: Duration + private let warmGate: Gate? - init(delay: Duration = .zero) { self.delay = delay } + init(warmGate: Gate? = nil) { self.warmGate = warmGate } nonisolated var calls: [Int] { recorder.all } func load() async throws { status = .ready } func transcribe(_ samples: [Float]) async throws -> Transcript { recorder.append(samples.count) - if delay > .zero { try? await Task.sleep(for: delay) } + if samples.count == 8_000 { await warmGate?.wait() } return Transcript(text: "counted", audioDuration: Double(samples.count) / CapturedAudio.sampleRate, processingTime: 0, engineID: id) } func unload() { status = .unloaded } @@ -357,10 +357,10 @@ extension EngineRegistry.Entry { @MainActor func makeCountingCoordinator( - engineDelay: Duration = .zero, + warmGate: Gate? = nil, clock: any Clock = ContinuousClock() ) -> (DictationCoordinator, FakeOutput, FakeCapture, CountingEngine) { - let engine = CountingEngine(delay: engineDelay) + let engine = CountingEngine(warmGate: warmGate) var settings = DictationSettings(engineID: CountingEngine.engineID) settings.appendTrailingSpace = false let (coordinator, output, capture) = makeCoordinator(engines: [.serving(engine)], settings: settings, clock: clock) @@ -632,12 +632,15 @@ final class EventLog: @unchecked Sendable { let a = FakeHotkey() let b = FakeHotkey() let (c, _, capture) = makeCoordinator(hotkeyMonitor: a) - await capture.setStopDelay(.milliseconds(50)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.press(a) c.update(c.settings, standInHotkey: c.standInHotkey, monitor: b) #expect(c.state == .idle) b.press() + #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() #expect(await waitUntil { c.handledHotkeyEvents == 2 }) #expect(c.state.isRecording) #expect(await capture.startCount == 2) @@ -831,9 +834,14 @@ final class EventLog: @unchecked Sendable { } @Test func releaseDuringWarmupStillInserts() async { - let (c, output, _, _) = makeCountingCoordinator(engineDelay: .milliseconds(200)) + let warmGate = Gate() + let (c, output, _, engine) = makeCountingCoordinator(warmGate: warmGate) await c.startIdle() - await c.dictate() + await c.hotkeyPressed() + #expect(await waitUntil { engine.calls == [8_000] }) + c.hotkeyReleased() + await warmGate.open() + await c.inFlight?.value #expect(output.inserted.count == 1) #expect(c.state == .idle) } @@ -1070,7 +1078,8 @@ final class EventLog: @unchecked Sendable { } @Test func polishingStateIsPublishedWhileTheModelRuns() async { - let refiner = FakeRefiner(delay: .milliseconds(200)) + let gate = Gate() + let refiner = FakeRefiner(gate: gate) let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner) await c.startIdle() await c.hotkeyPressed() @@ -1079,6 +1088,7 @@ final class EventLog: @unchecked Sendable { #expect(await waitUntil { c.state == .polishing }) #expect(c.willPolish) #expect(output.inserted.isEmpty) + await gate.open() await c.inFlight?.value #expect(c.state == .idle) #expect(!c.willPolish) From f80cb0a09ab2f3cfcf03d20ad3bb4a45950dbd44 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:39 +0200 Subject: [PATCH 3/7] Tests: the engine loader test waits for the superseded load on a Gate, not a 10 ms poll Co-Authored-By: Claude Opus 5.5 --- Tests/PladderCoreTests/EngineLoaderTests.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Tests/PladderCoreTests/EngineLoaderTests.swift b/Tests/PladderCoreTests/EngineLoaderTests.swift index 49d0cee..62bdf56 100644 --- a/Tests/PladderCoreTests/EngineLoaderTests.swift +++ b/Tests/PladderCoreTests/EngineLoaderTests.swift @@ -83,6 +83,7 @@ private actor GatedEngine: TranscriptionEngine { private var progress: Double private var isOpen = false private var waiters: [CheckedContinuation] = [] + private let loaded = Gate() init(id: EngineID, progress: Double) { self.id = id @@ -95,13 +96,11 @@ private actor GatedEngine: TranscriptionEngine { await withCheckedContinuation { waiters.append($0) } } status = .ready + await loaded.open() } func waitUntilLoaded() async -> Bool { - for _ in 0..<100 { - if status == .ready { return true } - try? await Task.sleep(for: .milliseconds(10)) - } + await loaded.wait() return status == .ready } From b188c13e5cb8214bf16836fc54af13104ee7742e Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:42 +0200 Subject: [PATCH 4/7] Tests: OrderedTasks' cancel winds down on a Gate, in one run instead of fifty The stand-in cancel slept 5 ms and the test ran fifty times so the ensure would sometimes reach the actor first. It now waits on a Gate and marks the download stopped only after it, so an ensure that does not wait for the cancel loses its download whether it reaches the actor before the cancel or during its wind-down. Co-Authored-By: Claude Opus 5.5 --- .../PladderCoreTests/OrderedTasksTests.swift | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/Tests/PladderCoreTests/OrderedTasksTests.swift b/Tests/PladderCoreTests/OrderedTasksTests.swift index 4f59dd6..47aa34d 100644 --- a/Tests/PladderCoreTests/OrderedTasksTests.swift +++ b/Tests/PladderCoreTests/OrderedTasksTests.swift @@ -5,26 +5,31 @@ import Testing private actor Downloads { private(set) var running = false + private let windDown: Gate + + init(windDown: Gate) { self.windDown = windDown } + func ensure() { running = true } + // Winding down, as a cancelled URLSession task does, and only then marked stopped. func cancel() async { + await windDown.wait() running = false - // Winding down, as a cancelled URLSession task does. - try? await Task.sleep(for: .milliseconds(5)) } } @MainActor @Suite(.timeLimit(.minutes(1))) struct OrderedTasksTests { @Test func aCancelThenEnsureLeavesTheDownloadRunning() async { - for _ in 0..<50 { - let downloads = Downloads() - await downloads.ensure() - let tasks = OrderedTasks() - tasks.enqueue { await downloads.cancel() } - tasks.enqueue { await downloads.ensure() } - await tasks.drained() - #expect(await downloads.running) - } + let windDown = Gate() + let downloads = Downloads(windDown: windDown) + await downloads.ensure() + let tasks = OrderedTasks() + tasks.enqueue { await downloads.cancel() } + tasks.enqueue { await downloads.ensure() } + await windDown.untilSomeoneWaits() + await windDown.open() + await tasks.drained() + #expect(await downloads.running) } @Test func eachPieceStartsAfterThePreviousOneFinished() async { From 68b51ab2128a6fdc113bc6d13727ef2854d1e553 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:52 +0200 Subject: [PATCH 5/7] Output mute: the arm delay sleeps on an injected clock Defaulted to ContinuousClock, so the app's call site is unchanged. The two tests that slept four times the delay to be sure an arm had given up now drive it with ManualClock. Off the release path: only `recordingStarted`, called at key-down, reads the clock. Co-Authored-By: Claude Opus 5.5 --- .../Support/OutputMuteController.swift | 5 +++- .../OutputMuteControllerTests.swift | 26 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Sources/PladderCore/Support/OutputMuteController.swift b/Sources/PladderCore/Support/OutputMuteController.swift index 369ac66..1e15d0e 100644 --- a/Sources/PladderCore/Support/OutputMuteController.swift +++ b/Sources/PladderCore/Support/OutputMuteController.swift @@ -5,6 +5,7 @@ import Foundation public actor OutputMuteController: OutputMuter { private let control: any OutputMuteControl private let delay: Duration + private let clock: any Clock private let log: @Sendable (String) -> Void // A pending arm that wakes to find this changed belongs to a session already over. @@ -15,10 +16,12 @@ public actor OutputMuteController: OutputMuter { public init( control: any OutputMuteControl, delay: Duration = .milliseconds(200), + clock: any Clock = ContinuousClock(), log: @escaping @Sendable (String) -> Void = { _ in } ) { self.control = control self.delay = delay + self.clock = clock self.log = log } @@ -29,7 +32,7 @@ public actor OutputMuteController: OutputMuter { latestSession = session generation += 1 let mine = generation - try? await Task.sleep(for: delay) + try? await clock.sleep(for: delay) guard generation == mine else { return } guard restore == nil else { // An earlier session muted it and its end has not come; this session's end restores it. diff --git a/Tests/PladderCoreTests/OutputMuteControllerTests.swift b/Tests/PladderCoreTests/OutputMuteControllerTests.swift index 12ccace..dfd5dc4 100644 --- a/Tests/PladderCoreTests/OutputMuteControllerTests.swift +++ b/Tests/PladderCoreTests/OutputMuteControllerTests.swift @@ -61,36 +61,33 @@ private final class FakeMuteControl: OutputMuteControl, @unchecked Sendable { } } -// Short enough to keep the tests fast, long enough that a "before the delay" call -// really lands before it. +// Short, so the tests that let it pass in real time stay fast. private let testDelay = Duration.milliseconds(10) private func makeController( _ control: FakeMuteControl, delay: Duration = testDelay, + clock: any Clock = ContinuousClock(), log: @escaping @Sendable (String) -> Void = { _ in } ) -> OutputMuteController { - OutputMuteController(control: control, delay: delay, log: log) + OutputMuteController(control: control, delay: delay, clock: clock, log: log) } private func waitForGeneration(_ muter: OutputMuteController, _ target: Int) async { while await muter.armedGeneration < target { await Task.yield() } } -private func pastTheDelay() async { - try? await Task.sleep(for: testDelay * 4) -} - @Suite(.timeLimit(.minutes(1))) struct OutputMuteControllerTests { @Test func tapAndReleaseBeforeTheDelayNeverMutes() async { let control = FakeMuteControl() - let muter = makeController(control) + let clock = ManualClock() + let muter = makeController(control, clock: clock) let started = Task { await muter.recordingStarted(session: 1) } - await waitForGeneration(muter, 1) + #expect(await clock.waitForSleeper(at: testDelay)) await muter.recordingEnded(session: 1) + clock.advance(by: testDelay) await started.value - await pastTheDelay() #expect(control.setCalls.isEmpty) #expect(control.state(of: 1) == false) @@ -186,16 +183,17 @@ private func pastTheDelay() async { @Test func aRestartInsideTheDelayStillEndsUpMuted() async { let control = FakeMuteControl() - let muter = makeController(control) + let clock = ManualClock() + let muter = makeController(control, clock: clock) let first = Task { await muter.recordingStarted(session: 1) } - await waitForGeneration(muter, 1) + #expect(await clock.waitForSleepers(1)) await muter.recordingEnded(session: 1) let second = Task { await muter.recordingStarted(session: 2) } - await waitForGeneration(muter, 3) + #expect(await clock.waitForSleepers(2)) + clock.advance(by: testDelay) await first.value await second.value - await pastTheDelay() #expect(control.state(of: 1) == true) #expect(control.setCalls == [.mute(1)]) From 077c9a8bb78694ae4f3db382263ad8fa30982c8d Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:55 +0200 Subject: [PATCH 6/7] S1-mini: the test of a load interrupted by unload awaits the abandoned load instead of sleeping 20 ms `abandoned` becomes `private(set)` so the test, through `@testable import`, can wait for the first load to finish and try to store its model. Co-Authored-By: Claude Opus 5.5 --- Sources/PladderRefine/S1MiniPolisher.swift | 2 +- Tests/PladderRefineTests/S1MiniTests.swift | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/PladderRefine/S1MiniPolisher.swift b/Sources/PladderRefine/S1MiniPolisher.swift index 40b7c16..c46d394 100644 --- a/Sources/PladderRefine/S1MiniPolisher.swift +++ b/Sources/PladderRefine/S1MiniPolisher.swift @@ -39,7 +39,7 @@ public actor S1MiniPolisher: ReportingRefiner { private var loading: Task<(any PromptCompleter)?, Never>? // llama.cpp ignores cancellation, so a load `unload()` gave up on still reads weights; // the next load waits for it rather than holding two models at once. - private var abandoned: Task<(any PromptCompleter)?, Never>? + private(set) var abandoned: Task<(any PromptCompleter)?, Never>? // Bumped by `unload()`, so a load it interrupted cannot store its model. private var generation = 0 private static let log = Logger(subsystem: "de.dinooo13.pladder", category: "polish") diff --git a/Tests/PladderRefineTests/S1MiniTests.swift b/Tests/PladderRefineTests/S1MiniTests.swift index 9c437b1..ce53931 100644 --- a/Tests/PladderRefineTests/S1MiniTests.swift +++ b/Tests/PladderRefineTests/S1MiniTests.swift @@ -136,9 +136,7 @@ private struct StandInModel: PromptCompleter { _ = await polisher.polish("send it") await polisher.unload() await gate.open() - // Time for the first load to finish and try to store its model; the right answer - // does not depend on it. - try await Task.sleep(for: .milliseconds(20)) + _ = await polisher.abandoned?.value #expect(await polisher.refine("send it") == "Second.") } From 4d33e7baedbd1417355889708dc8b3064de43d49 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:42:01 +0200 Subject: [PATCH 7/7] Tests: the sleeps that must stay real time say why Each is a negative path, where nothing happens that a test could wait for, or a cancellable sleep standing in for a download that the cancel must interrupt. Co-Authored-By: Claude Opus 5.5 --- Tests/PladderCoreTests/EngineLoaderTests.swift | 3 ++- Tests/PladderCoreTests/OrderedTasksTests.swift | 1 + Tests/PladderRefineTests/ModelFilesTests.swift | 1 + Tests/PladderRefineTests/S1MiniTests.swift | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests/PladderCoreTests/EngineLoaderTests.swift b/Tests/PladderCoreTests/EngineLoaderTests.swift index 62bdf56..f5ac5d7 100644 --- a/Tests/PladderCoreTests/EngineLoaderTests.swift +++ b/Tests/PladderCoreTests/EngineLoaderTests.swift @@ -64,7 +64,8 @@ import Testing await first.open() #expect(await first.waitUntilLoaded()) - // Time for the superseded load task to resume on the main actor. + // Negative path: a superseded load leaves nothing to wait for, so this gives its task + // time to resume on the main actor. try? await Task.sleep(for: .milliseconds(50)) #expect(loader.engine.id == second.id) #expect(loader.status == .downloading(progress: 0.5)) diff --git a/Tests/PladderCoreTests/OrderedTasksTests.swift b/Tests/PladderCoreTests/OrderedTasksTests.swift index 47aa34d..b7e9870 100644 --- a/Tests/PladderCoreTests/OrderedTasksTests.swift +++ b/Tests/PladderCoreTests/OrderedTasksTests.swift @@ -42,6 +42,7 @@ private actor Downloads { } tasks.enqueue { order.append("second") } await gate.untilSomeoneWaits() + // Gives a wrongly started second piece time to show. try? await Task.sleep(for: .milliseconds(10)) #expect(order.all.isEmpty) await gate.open() diff --git a/Tests/PladderRefineTests/ModelFilesTests.swift b/Tests/PladderRefineTests/ModelFilesTests.swift index 498d190..59edee4 100644 --- a/Tests/PladderRefineTests/ModelFilesTests.swift +++ b/Tests/PladderRefineTests/ModelFilesTests.swift @@ -121,6 +121,7 @@ private actor StandInTransport: ModelFileTransport { if call == 1 { try Data("wei".utf8).write(to: staging) await started.open() + // A cancellable sleep stands in for the download; the test is that cancel interrupts it. try await Task.sleep(for: .seconds(3)) } try Data("weights".utf8).write(to: staging) diff --git a/Tests/PladderRefineTests/S1MiniTests.swift b/Tests/PladderRefineTests/S1MiniTests.swift index ce53931..a64f986 100644 --- a/Tests/PladderRefineTests/S1MiniTests.swift +++ b/Tests/PladderRefineTests/S1MiniTests.swift @@ -154,6 +154,7 @@ private struct StandInModel: PromptCompleter { _ = await polisher.polish("send it") await polisher.unload() let second = Task { await polisher.prepare() } + // Negative path: gives a second load that wrongly skipped the wait time to start. try await Task.sleep(for: .milliseconds(30)) #expect(loadsStarted.all == [0]) await gate.open()