31f5937a7f
Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
71 lines
2.9 KiB
Swift
71 lines
2.9 KiB
Swift
// PreviewASRControllerStateTests.swift
|
|
// OSGKeyboard · Tests
|
|
//
|
|
// Locks in the state-machine contract for the keyboard preview's
|
|
// ASR controller. The previous bug was: `stop()` called
|
|
// `asrTask?.cancel()` on the consumer task that was waiting for the
|
|
// ASR `.final` event. The cancellation cascaded through
|
|
// `continuation.onTermination → self.cancel()` and suppressed the
|
|
// `.final` yield, leaving the controller in `.processing` forever
|
|
// because no one scheduled the transition out. The fix is: don't
|
|
// cancel on stop, let the consumer naturally see the `.final` and
|
|
// flip the phase.
|
|
//
|
|
// These tests don't drive a real ASR pipeline — they verify the
|
|
// state-machine contract directly. The class is `@MainActor`
|
|
// isolated, so all assertions happen on main. `asrTask` is
|
|
// `internal` (not `private`) precisely so this file can install a
|
|
// known consumer task and observe whether `stop()` cancels it.
|
|
//
|
|
// Note on the 3-second processing-timeout safety net: that
|
|
// fallback (a `Task { sleep 3s; if .processing → .idle }` block
|
|
// inside `stop()`) is not unit-tested here — testing it would
|
|
// require either a 3-second test or extracting the policy to a
|
|
// testable helper. The primary fix above removes the original
|
|
// bug outright; the timeout is a defensive safety net for a
|
|
// separate class of failure (analyzer hang) and is straightforward
|
|
// enough to read that a test adds little. If a future change
|
|
// touches the safety-net block, re-introduce a test that mocks
|
|
// the ASR to never yield `.final` and asserts the timeout fires.
|
|
|
|
import XCTest
|
|
@testable import OSGKeyboardShared
|
|
@testable import OSGKeyboardHostSupport
|
|
|
|
@MainActor
|
|
final class PreviewASRControllerStateTests: XCTestCase {
|
|
|
|
/// Core fix: `stop()` MUST NOT cancel `asrTask`. The consumer
|
|
/// task is the only place the `.final` event can land, and
|
|
/// cancelling it leaves the UI in `.processing` forever. This
|
|
/// is the regression test for the original bug — if it ever
|
|
/// fails, the disc is stuck again.
|
|
func testStopDoesNotCancelConsumerTask() {
|
|
let controller = LiveDictationController()
|
|
let consumerTask = Task<Void, Never> {}
|
|
controller.asrTask = consumerTask
|
|
|
|
controller.stop()
|
|
|
|
XCTAssertFalse(
|
|
consumerTask.isCancelled,
|
|
"stop() must not cancel the consumer task; the .final event needs the consumer alive to land"
|
|
)
|
|
}
|
|
|
|
/// Idempotency: calling `stop()` twice (e.g. `.onDisappear`
|
|
/// fires after the user already stopped manually) must not
|
|
/// misbehave — no double-cancel, no extra state transitions.
|
|
/// This is the contract `.onDisappear` relies on.
|
|
func testStopIsIdempotent() {
|
|
let controller = LiveDictationController()
|
|
let consumerTask = Task<Void, Never> {}
|
|
controller.asrTask = consumerTask
|
|
|
|
controller.stop()
|
|
controller.stop()
|
|
|
|
XCTAssertFalse(consumerTask.isCancelled)
|
|
}
|
|
}
|