feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
@@ -0,0 +1,223 @@
// ClipboardSuggestionLifecycleTests.swift
// OSGKeyboard · Keyboard Extension Tests
//
// Verifies that the transient suggestion belongs to one pasteboard generation
// and one keyboard presentation; clipboard history remains independently usable.
import Combine
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class ClipboardSuggestionLifecycleTests: XCTestCase {
private var suiteName = ""
private var defaults: UserDefaults!
private var state: KeyboardState!
private var history: ClipboardHistoryStore!
private var pasteboard: FakeClipboardPasteboard!
private var coordinator: ClipboardCaptureCoordinator!
override func setUp() {
super.setUp()
suiteName = "ClipboardSuggestionLifecycleTests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)
state = KeyboardState()
state.clipboardHistoryEnabled = true
state.clipboardCandidateBarEnabled = true
history = ClipboardHistoryStore(defaults: defaults)
history.lastObservedChangeCount = 10
pasteboard = FakeClipboardPasteboard(changeCount: 10)
coordinator = ClipboardCaptureCoordinator(
state: state,
history: history,
pasteboard: pasteboard
)
coordinator.configure(isSecure: { false }, hasFullAccess: { true })
}
override func tearDown() {
coordinator.keyboardWillDisappear()
coordinator = nil
pasteboard = nil
history = nil
state = nil
defaults.removePersistentDomain(forName: suiteName)
defaults = nil
super.tearDown()
}
func testNewAcceptedCopyShowsSuggestion() {
copy("a fresh sentence")
coordinator.captureIfNeeded()
XCTAssertEqual(state.clipboardSuggestionText, "a fresh sentence")
XCTAssertEqual(state.clipboardSuggestionChangeCount, 11)
}
func testTypingEndsSuggestionForSameGeneration() {
copy("meeting notes")
coordinator.captureIfNeeded()
coordinator.noteUserDidInputText()
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.suggestionDismissedChangeCount, 11)
}
func testKeyboardDismissalEndsSuggestionForSameGeneration() {
copy("shipping address")
coordinator.captureIfNeeded()
coordinator.keyboardWillDisappear()
coordinator.keyboardDidAppear()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.suggestionDismissedChangeCount, 11)
}
func testKeyboardAppearancePrimesPasteAccessWithoutRepublishingCurrentGeneration() {
pasteboard.setText("already observed", changeCount: 10)
coordinator.keyboardDidAppear()
XCTAssertEqual(pasteboard.stringReadCount, 1)
XCTAssertTrue(history.entries.isEmpty)
XCTAssertNil(state.clipboardSuggestionText)
}
func testRejectedNewCopyClearsInsteadOfReplayingHistory() {
copy("draft reply")
coordinator.captureIfNeeded()
copy("482913")
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.map(\.text), ["draft reply"])
XCTAssertEqual(history.lastObservedChangeCount, 12)
}
func testNonTextGenerationClearsInsteadOfReplayingHistory() {
copy("draft reply")
coordinator.captureIfNeeded()
pasteboard.setNonText(changeCount: 12)
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.map(\.text), ["draft reply"])
XCTAssertEqual(history.lastObservedChangeCount, 12)
}
func testEnablingCandidateBarDoesNotReplayHistory() {
state.clipboardCandidateBarEnabled = false
history.ingest(rawText: "old history item", changeCount: 9)
state.clipboardCandidateBarEnabled = true
coordinator.refreshFlagsFromStore()
XCTAssertNil(state.clipboardSuggestionText)
}
func testDeletingCurrentEntryDoesNotPromoteOlderHistory() throws {
history.ingest(rawText: "older item", changeCount: 9)
copy("current item")
coordinator.captureIfNeeded()
let currentID = try XCTUnwrap(history.newestEntry?.id)
coordinator.deleteEntry(id: currentID)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.map(\.text), ["older item"])
}
func testUnchangedPollingDoesNotRepublishSuggestion() {
copy("stable clipboard")
coordinator.captureIfNeeded()
var publications = 0
let cancellable = state.$clipboardSuggestionText
.dropFirst()
.sink { _ in publications += 1 }
defer { cancellable.cancel() }
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
XCTAssertEqual(publications, 0)
XCTAssertEqual(pasteboard.stringReadCount, 1)
}
func testClearHistoryEndsSuggestionBeforeRemovingEntries() {
history.ingest(rawText: "older item", changeCount: 9)
copy("current item")
coordinator.captureIfNeeded()
coordinator.clearHistory()
XCTAssertTrue(history.entries.isEmpty)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertNil(state.clipboardSuggestionChangeCount)
XCTAssertEqual(history.suggestionDismissedChangeCount, 11)
}
func testExplicitDismissAndInsertRemainTerminal() {
copy("dismiss once")
coordinator.captureIfNeeded()
coordinator.dismissSuggestion()
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
copy("insert once")
coordinator.captureIfNeeded()
coordinator.insertText("insert once", via: { _ in })
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
}
func testSecureFieldTransitionEndsSuggestion() {
copy("private note")
coordinator.captureIfNeeded()
coordinator.secureEntryDidChange(isSecure: true)
coordinator.secureEntryDidChange(isSecure: false)
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
}
private func copy(_ text: String) {
pasteboard.setText(text, changeCount: pasteboard.changeCount + 1)
}
}
@MainActor
private final class FakeClipboardPasteboard: ClipboardPasteboardProviding {
private(set) var changeCount: Int
private(set) var hasStrings = false
private var storedString: String?
private(set) var stringReadCount = 0
var string: String? {
stringReadCount += 1
return storedString
}
init(changeCount: Int) {
self.changeCount = changeCount
}
func setText(_ text: String, changeCount: Int) {
self.changeCount = changeCount
hasStrings = true
storedString = text
}
func setNonText(changeCount: Int) {
self.changeCount = changeCount
hasStrings = false
storedString = nil
}
}
@@ -0,0 +1,213 @@
// EditHintSchedulerTests.swift
// OSGKeyboard · Keyboard Extension Tests
import XCTest
@testable import OSGKeyboardShared
@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() }
}
}
@@ -176,6 +176,111 @@ final class EnglishTypingTests: XCTestCase {
XCTAssertEqual(typing.composition.preedit, "hel")
}
@MainActor
func testOldWordBackspaceRehydratesSuggestionsFromDocumentContext() {
var preceding = "board"
let following = "\n"
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { preceding }
typing.followingTextProvider = { following }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
typing.synchronizeEnglishDocumentContext(caretMoved: true)
func apply(_ output: TypingOutput) {
for _ in 0..<output.deleteCount {
if !preceding.isEmpty { preceding.removeLast() }
}
preceding += output.text
typing.syncAutocapitalization(
accountingForInsert: output.text,
deleteCount: output.deleteCount
)
}
apply(typing.handleKey(""))
XCTAssertEqual(preceding, "boar")
XCTAssertEqual(typing.composition.preedit, "boar")
apply(typing.handleKey(""))
XCTAssertEqual(preceding, "boa")
XCTAssertEqual(typing.composition.preedit, "boa")
XCTAssertTrue(
typing.composition.candidates.contains {
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
}
)
apply(typing.handleKey("t"))
XCTAssertEqual(preceding, "boat")
XCTAssertEqual(typing.composition.preedit.lowercased(), "boat")
}
@MainActor
func testCandidateReplacementRejectsStaleDocumentAnchor() {
var preceding = ""
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { preceding }
typing.followingTextProvider = { "" }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
for key in ["b", "o", "a"] {
let output = typing.handleKey(key)
preceding += output.text
typing.syncAutocapitalization(accountingForInsert: output.text)
}
guard let boatIndex = typing.composition.candidates.firstIndex(where: {
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
}) else {
return XCTFail("expected boat completion")
}
preceding = "board"
let output = typing.selectCandidate(at: boatIndex)
XCTAssertEqual(output, .none)
XCTAssertEqual(typing.composition.preedit.lowercased(), "board")
}
@MainActor
func testMidWordCaretSuppressesUnsafeBackwardOnlyReplacement() {
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { "boa" }
typing.followingTextProvider = { "rd" }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
typing.synchronizeEnglishDocumentContext(caretMoved: true)
XCTAssertTrue(typing.composition.candidates.isEmpty)
XCTAssertTrue(typing.composition.preedit.isEmpty)
}
@MainActor
func testStaleHostCallbackDoesNotDiscardLocalEnglishWord() {
var preceding = ""
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { preceding }
typing.followingTextProvider = { "" }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
let output = typing.handleKey("h")
typing.syncAutocapitalization(accountingForInsert: output.text)
typing.synchronizeEnglishDocumentContext(caretMoved: true)
XCTAssertEqual(typing.composition.preedit.lowercased(), "h")
preceding = "h"
typing.synchronizeEnglishDocumentContext()
XCTAssertEqual(typing.composition.preedit.lowercased(), "h")
}
@MainActor
func testAutocorrectUndoRestoresOriginal() {
let typing = TypingSessionController()
@@ -118,4 +118,24 @@ final class KeyboardStateTests: XCTestCase {
XCTAssertEqual(KeyboardState.InputMode.allCases, [.polish])
XCTAssertEqual(KeyboardState.InputMode(rawValue: "polish"), .polish)
}
func testSecureFieldImmediatelyHidesClipboardUIWithoutRestoringBody() {
let state = KeyboardState()
state.clipboardSuggestionText = "private clipboard body"
state.clipboardSuggestionChangeCount = 7
state.clipboardOverlay = .historyPanel
state.setSecureTextEntry(true)
XCTAssertFalse(state.canShowClipboardEntry)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertNil(state.clipboardSuggestionChangeCount)
XCTAssertEqual(state.clipboardOverlay, .none)
state.setSecureTextEntry(false)
XCTAssertTrue(state.canShowClipboardEntry)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(state.clipboardOverlay, .none)
}
}
@@ -50,12 +50,12 @@ final class RimeSchemaGeneratorTests: XCTestCase {
let configuration = TypingInputConfiguration(defaults: defaults)
XCTAssertEqual(configuration.schema, .fullPinyin)
XCTAssertTrue(configuration.fuzzyPairs.isEmpty)
XCTAssertFalse(configuration.defaultToTyping)
XCTAssertEqual(configuration.defaultInputMode, .voice)
XCTAssertFalse(configuration.rememberLastSurface)
}
@MainActor
func testDefaultTypingPreferencePersistsAndDefaultsToOff() {
func testDefaultInputModePersistsAndDefaultsToVoice() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
@@ -65,12 +65,38 @@ final class RimeSchemaGeneratorTests: XCTestCase {
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.voice
)
XCTAssertNil(TypingInputConfiguration.preferredTypingLanguageOnOpen(defaults: defaults))
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.defaultInputMode = .pinyin
XCTAssertTrue(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
XCTAssertTrue(TypingInputConfiguration(defaults: defaults).defaultToTyping)
XCTAssertEqual(TypingInputConfiguration(defaults: defaults).defaultInputMode, .pinyin)
XCTAssertEqual(
TypingInputConfiguration.preferredOpenPreference(defaults: defaults).surface,
.typing
)
XCTAssertEqual(
TypingInputConfiguration.preferredOpenPreference(defaults: defaults).typingLanguage,
.chinese
)
configuration.defaultInputMode = .english
XCTAssertEqual(
TypingInputConfiguration.preferredOpenPreference(defaults: defaults).typingLanguage,
.english
)
}
@MainActor
func testLegacyDefaultToTypingMigratesToPinyin() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: "typing.input.defaultToTyping")
let configuration = TypingInputConfiguration(defaults: defaults)
XCTAssertEqual(configuration.defaultInputMode, .pinyin)
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
@@ -78,13 +104,13 @@ final class RimeSchemaGeneratorTests: XCTestCase {
}
@MainActor
func testRememberLastSurfaceOverridesDefaultToTyping() {
func testRememberLastSurfaceOverridesDefaultInputMode() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.defaultInputMode = .pinyin
configuration.rememberLastSurface = true
TypingInputConfiguration.persistLastSurface(.voice, defaults: defaults)
@@ -94,10 +120,10 @@ final class RimeSchemaGeneratorTests: XCTestCase {
)
TypingInputConfiguration.persistLastSurface(.typing, defaults: defaults)
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
)
TypingInputConfiguration.persistLastTypingLanguage(.english, defaults: defaults)
let preference = TypingInputConfiguration.preferredOpenPreference(defaults: defaults)
XCTAssertEqual(preference.surface, .typing)
XCTAssertEqual(preference.typingLanguage, .english)
}
@MainActor
@@ -107,13 +133,17 @@ final class RimeSchemaGeneratorTests: XCTestCase {
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.defaultInputMode = .pinyin
configuration.rememberLastSurface = true
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
)
XCTAssertEqual(
TypingInputConfiguration.preferredTypingLanguageOnOpen(defaults: defaults),
.chinese
)
}
@MainActor