Files
OSGKeyboard/OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests.swift
T
rocky cfe14f67d4 perf(keyboard): track KeyboardState per property with @Observable
- KeyboardState moves from ObservableObject/@Published to @Observable, so a view re-renders only for the properties it reads. Previously unpublished state and injected action hooks are @ObservationIgnored.
- KeyboardViewController replaces the $phase/$surface Combine sinks with Observations loops tied to the same cancellables.
- AIKeyboardView reads the 200 ms mic level and live ASR partials in small child views (AssistantWaveform, LiveTranscriptReader), so those ticks no longer rebuild the whole assistant surface.
- Tests observe changes with withObservationTracking instead of Combine publishers.
2026-10-21 06:12:21 +02:00

267 lines
8.5 KiB
Swift

// 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 Observation
@testable import OSGKeyboardShared
import XCTest
@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()
let republished = expectation(description: "suggestion republished")
republished.isInverted = true
withObservationTracking {
_ = state.clipboardSuggestionText
} onChange: {
republished.fulfill()
}
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
wait(for: [republished], timeout: 0.1)
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)
}
func testSameTextUnderNewChangeCountIsSwallowedAsSameLogicalCopy() {
copy("meeting at 3pm")
coordinator.captureIfNeeded()
let firstEntry = history.newestEntry
// Universal Clipboard re-announce: same text, new changeCount.
pasteboard.setText("meeting at 3pm", changeCount: 12)
coordinator.captureIfNeeded()
XCTAssertEqual(history.entries.count, 1)
XCTAssertEqual(history.newestEntry?.id, firstEntry?.id)
XCTAssertEqual(history.newestEntry?.createdAt, firstEntry?.createdAt)
XCTAssertEqual(history.lastObservedChangeCount, 12)
XCTAssertEqual(state.clipboardSuggestionText, "meeting at 3pm")
}
func testRecopyAfterDifferentContentCreatesNewGeneration() {
copy("first message")
coordinator.captureIfNeeded()
copy("second message")
coordinator.captureIfNeeded()
copy("first message")
coordinator.captureIfNeeded()
XCTAssertEqual(history.entries.map(\.text), ["first message", "second message"])
XCTAssertEqual(state.clipboardSuggestionText, "first message")
}
func testDismissedSuggestionStaysHiddenAcrossUniversalClipboardResync() {
copy("dismiss me")
coordinator.captureIfNeeded()
coordinator.dismissSuggestion()
pasteboard.setText("dismiss me", changeCount: 12)
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.count, 1)
}
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
}
}