498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
214 lines
6.7 KiB
Swift
214 lines
6.7 KiB
Swift
// EditHintSchedulerTests.swift
|
|
// OSGKeyboard · Keyboard Extension Tests
|
|
|
|
@testable import OSGKeyboardShared
|
|
import XCTest
|
|
|
|
@MainActor
|
|
final class EditHintSchedulerTests: XCTestCase {
|
|
func testShowPublishesImmediately() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
let scheduler = makeScheduler(state: state, sleeper: sleeper)
|
|
|
|
scheduler.show(message: "Unavailable", isPositive: false, duration: .seconds(2.5))
|
|
|
|
XCTAssertEqual(state.editHint, "Unavailable")
|
|
XCTAssertFalse(state.editHintIsPositive)
|
|
|
|
await sleeper.waitForRequestCount(1)
|
|
scheduler.invalidate()
|
|
sleeper.resumeAll()
|
|
await drainTasks()
|
|
}
|
|
|
|
func testForwardsErrorAndPositiveDurations() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
let scheduler = makeScheduler(state: state, sleeper: sleeper)
|
|
|
|
scheduler.show(message: "Failure", isPositive: false, duration: .milliseconds(2_500))
|
|
await sleeper.waitForRequestCount(1)
|
|
scheduler.show(message: "Available", isPositive: true, duration: .seconds(10))
|
|
await sleeper.waitForRequestCount(2)
|
|
|
|
XCTAssertEqual(sleeper.recordedDurations, [.milliseconds(2_500), .seconds(10)])
|
|
|
|
scheduler.invalidate()
|
|
sleeper.resumeAll()
|
|
await drainTasks()
|
|
}
|
|
|
|
func testExpirationClearsHint() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
let scheduler = makeScheduler(state: state, sleeper: sleeper)
|
|
|
|
scheduler.show(message: "Available", isPositive: true, duration: .seconds(10))
|
|
await sleeper.waitForRequestCount(1)
|
|
sleeper.resumeFirst()
|
|
await drainTasks()
|
|
|
|
XCTAssertNil(state.editHint)
|
|
XCTAssertFalse(state.editHintIsPositive)
|
|
}
|
|
|
|
func testSameMessageABADoesNotLetOldExpirationClearNewHint() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
let scheduler = makeScheduler(state: state, sleeper: sleeper)
|
|
|
|
scheduler.show(message: "Same", isPositive: true, duration: .seconds(10))
|
|
await sleeper.waitForRequestCount(1)
|
|
scheduler.show(message: "Same", isPositive: true, duration: .seconds(10))
|
|
await sleeper.waitForRequestCount(2)
|
|
|
|
sleeper.resumeFirst()
|
|
await drainTasks()
|
|
XCTAssertEqual(state.editHint, "Same")
|
|
XCTAssertTrue(state.editHintIsPositive)
|
|
|
|
sleeper.resumeFirst()
|
|
await drainTasks()
|
|
XCTAssertNil(state.editHint)
|
|
XCTAssertFalse(state.editHintIsPositive)
|
|
}
|
|
|
|
func testOldTaskThatIgnoresCancellationCannotClearNewHint() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
let scheduler = makeScheduler(state: state, sleeper: sleeper)
|
|
|
|
scheduler.show(message: "Old", isPositive: false, duration: .seconds(2.5))
|
|
await sleeper.waitForRequestCount(1)
|
|
scheduler.show(message: "New", isPositive: true, duration: .seconds(10))
|
|
await sleeper.waitForRequestCount(2)
|
|
|
|
// ControlledSleeper intentionally ignores cancellation and still wakes
|
|
// the old task, so generation is the only correctness boundary.
|
|
sleeper.resumeFirst()
|
|
await drainTasks()
|
|
|
|
XCTAssertEqual(state.editHint, "New")
|
|
XCTAssertTrue(state.editHintIsPositive)
|
|
|
|
scheduler.invalidate()
|
|
sleeper.resumeAll()
|
|
await drainTasks()
|
|
}
|
|
|
|
func testClearPositiveDoesNotClearFailure() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
let scheduler = makeScheduler(state: state, sleeper: sleeper)
|
|
|
|
scheduler.show(message: "Failure", isPositive: false, duration: .seconds(2.5))
|
|
await sleeper.waitForRequestCount(1)
|
|
scheduler.clearPositive()
|
|
|
|
XCTAssertEqual(state.editHint, "Failure")
|
|
XCTAssertFalse(state.editHintIsPositive)
|
|
|
|
sleeper.resumeFirst()
|
|
await drainTasks()
|
|
XCTAssertNil(state.editHint)
|
|
XCTAssertFalse(state.editHintIsPositive)
|
|
}
|
|
|
|
func testInvalidateClearsAndPreventsDelayedWriteBack() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
let scheduler = makeScheduler(state: state, sleeper: sleeper)
|
|
|
|
scheduler.show(message: "Available", isPositive: true, duration: .seconds(10))
|
|
await sleeper.waitForRequestCount(1)
|
|
scheduler.invalidate()
|
|
|
|
XCTAssertNil(state.editHint)
|
|
XCTAssertFalse(state.editHintIsPositive)
|
|
|
|
sleeper.resumeFirst()
|
|
await drainTasks()
|
|
XCTAssertNil(state.editHint)
|
|
XCTAssertFalse(state.editHintIsPositive)
|
|
}
|
|
|
|
func testReleasedSchedulerDoesNotWriteBack() async {
|
|
let state = KeyboardState()
|
|
let sleeper = ControlledSleeper()
|
|
var scheduler: EditHintScheduler? = makeScheduler(state: state, sleeper: sleeper)
|
|
let schedulerReference = WeakSchedulerReference(scheduler)
|
|
|
|
scheduler?.show(message: "Available", isPositive: true, duration: .seconds(10))
|
|
await sleeper.waitForRequestCount(1)
|
|
scheduler = nil
|
|
|
|
XCTAssertNil(schedulerReference.value)
|
|
|
|
sleeper.resumeFirst()
|
|
await drainTasks()
|
|
XCTAssertEqual(state.editHint, "Available")
|
|
XCTAssertTrue(state.editHintIsPositive)
|
|
}
|
|
|
|
private func makeScheduler(
|
|
state: KeyboardState,
|
|
sleeper: ControlledSleeper
|
|
) -> EditHintScheduler {
|
|
EditHintScheduler(
|
|
state: state,
|
|
sleeper: { duration in
|
|
await sleeper.sleep(for: duration)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func drainTasks() async {
|
|
for _ in 0..<3 {
|
|
await Task.yield()
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class WeakSchedulerReference {
|
|
weak var value: EditHintScheduler?
|
|
|
|
init(_ value: EditHintScheduler?) {
|
|
self.value = value
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class ControlledSleeper {
|
|
private(set) var recordedDurations: [Duration] = []
|
|
private var continuations: [CheckedContinuation<Void, Never>] = []
|
|
|
|
func sleep(for duration: Duration) async {
|
|
recordedDurations.append(duration)
|
|
await withCheckedContinuation { continuation in
|
|
continuations.append(continuation)
|
|
}
|
|
}
|
|
|
|
func waitForRequestCount(_ count: Int) async {
|
|
while recordedDurations.count < count {
|
|
await Task.yield()
|
|
}
|
|
}
|
|
|
|
func resumeFirst() {
|
|
guard !continuations.isEmpty else {
|
|
XCTFail("Expected a pending sleep request")
|
|
return
|
|
}
|
|
continuations.removeFirst().resume()
|
|
}
|
|
|
|
func resumeAll() {
|
|
let pending = continuations
|
|
continuations.removeAll()
|
|
pending.forEach { $0.resume() }
|
|
}
|
|
}
|