b13a1e904b
Erase dialect syllables before abbrev so wom prefers 我们, flush librime on leave, and add a settings action to forget learned Chinese and English order.
545 lines
21 KiB
Swift
545 lines
21 KiB
Swift
// KeyboardSurfaceStateTests.swift
|
||
// OSGKeyboard · Ext unit tests
|
||
|
||
import XCTest
|
||
@testable import OSGKeyboardShared
|
||
|
||
@MainActor
|
||
final class KeyboardSurfaceStateTests: XCTestCase {
|
||
@MainActor
|
||
func testBusyAISessionLocksOtherSurfacesAndShowsCancel() {
|
||
let state = KeyboardState()
|
||
state.surface = .ai
|
||
state.aiSession.enter()
|
||
state.aiSession.beginPreparing(utteranceID: UUID())
|
||
|
||
XCTAssertTrue(state.locksTypingSurface)
|
||
XCTAssertTrue(state.canCancelAIInput)
|
||
}
|
||
|
||
func testRecordingLocksTyping() {
|
||
let state = KeyboardState()
|
||
state.phase = .idle
|
||
XCTAssertTrue(state.canEnterTypingSurface)
|
||
state.phase = .recording
|
||
XCTAssertTrue(state.locksTypingSurface)
|
||
XCTAssertFalse(state.canEnterTypingSurface)
|
||
}
|
||
|
||
func testNormalVoicePipelineCanBeCancelledUntilItReturnsIdle() {
|
||
let state = KeyboardState()
|
||
state.phase = .requestingPermissions
|
||
XCTAssertTrue(state.canCancelVoiceInput)
|
||
state.phase = .recording
|
||
XCTAssertTrue(state.canCancelVoiceInput)
|
||
state.phase = .processing
|
||
XCTAssertTrue(state.canCancelVoiceInput)
|
||
state.phase = .idle
|
||
XCTAssertFalse(state.canCancelVoiceInput)
|
||
|
||
let reference = EditableInputReference(
|
||
displayText: "原文",
|
||
insertedText: "原文",
|
||
postInsertionFingerprint: nil,
|
||
extensionInstanceID: UUID()
|
||
)
|
||
state.editSession = .listening(EditSessionSource(reference: reference))
|
||
state.phase = .recording
|
||
XCTAssertFalse(state.canCancelVoiceInput)
|
||
}
|
||
|
||
func testStandardLayoutHasQwertyTopRow() {
|
||
let layout = StandardTypingLayout()
|
||
let rows = layout.rows(for: .letters, language: .english, shiftActive: false)
|
||
XCTAssertEqual(rows.first, ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"])
|
||
let shifted = layout.rows(for: .letters, language: .english, shiftActive: true)
|
||
XCTAssertEqual(shifted.first?.first, "Q")
|
||
}
|
||
|
||
func testEnglishNumberAndSymbolPagesMatchIOSUS() {
|
||
let layout = StandardTypingLayout()
|
||
XCTAssertEqual(
|
||
layout.rows(for: .numbers, language: .english, shiftActive: false),
|
||
[
|
||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
|
||
["#+=", ".", ",", "?", "!", "'", "⌫"]
|
||
]
|
||
)
|
||
XCTAssertEqual(
|
||
layout.rows(for: .symbols, language: .english, shiftActive: false),
|
||
[
|
||
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
|
||
["_", "\\", "|", "~", "<", ">", "€", "£", "¥", "·"],
|
||
["123", ".", ",", "?", "!", "'", "⌫"]
|
||
]
|
||
)
|
||
}
|
||
|
||
func testChineseNumberAndSymbolPagesMatchIOSSimplified() {
|
||
let layout = StandardTypingLayout()
|
||
XCTAssertEqual(
|
||
layout.rows(for: .numbers, language: .chinese, shiftActive: false),
|
||
[
|
||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||
["-", "/", ":", ";", "(", ")", "¥", "@", "“", "”"],
|
||
["#+=", "。", ",", "、", "?", "!", ".", "⌫"]
|
||
]
|
||
)
|
||
XCTAssertEqual(
|
||
layout.rows(for: .symbols, language: .chinese, shiftActive: false),
|
||
[
|
||
["【", "】", "「", "」", "#", "%", "^", "*", "+", "="],
|
||
["_", "\\", "|", "~", "《", "》", "€", "£", "¥", "·"],
|
||
["123", "。", ",", "、", "?", "!", ".", "⌫"]
|
||
]
|
||
)
|
||
}
|
||
|
||
func testKeyRowsFollowTypingLanguageOnNumberPage() {
|
||
let typing = TypingSessionController()
|
||
typing.setPage(.numbers)
|
||
XCTAssertEqual(typing.keyRows[1], ["-", "/", ":", ";", "(", ")", "¥", "@", "“", "”"])
|
||
|
||
_ = typing.setLanguage(.english)
|
||
typing.setPage(.numbers)
|
||
XCTAssertEqual(typing.keyRows[1], ["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""])
|
||
}
|
||
|
||
func testVoiceAndTypingChromeShareStableDimensions() {
|
||
XCTAssertEqual(KeyboardChromeLayout.totalHeight, 281)
|
||
XCTAssertEqual(KeyboardChromeLayout.actionKeyHeight, 50)
|
||
XCTAssertEqual(KeyboardChromeLayout.actionKeyCornerRadius, 10)
|
||
XCTAssertEqual(KeyboardChromeLayout.actionKeySpacing, 8)
|
||
// Globe key now sits at the far-left of every bottom action row, so the
|
||
// four-slot layout splits 12 / 18 / 50 / 20 of the residual width.
|
||
XCTAssertEqual(KeyboardChromeLayout.globeActionKeyFraction, 0.12)
|
||
XCTAssertEqual(KeyboardChromeLayout.sideActionKeyFraction, 0.18)
|
||
XCTAssertEqual(KeyboardChromeLayout.centerActionKeyFraction, 0.50)
|
||
XCTAssertEqual(KeyboardChromeLayout.side2ActionKeyFraction, 0.20)
|
||
XCTAssertEqual(KeyboardChromeLayout.horizontalInset, 8)
|
||
// Voice-surface only. The typing grid is uncapped so it can match the
|
||
// system keyboard's absolute key positions on iPad.
|
||
XCTAssertEqual(KeyboardChromeLayout.voiceContentMaxWidth, 700)
|
||
|
||
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: 374)
|
||
// availableWidth 374 − 3 × spacing 8 = 350 of key width
|
||
XCTAssertEqual(widths.globe, 42, accuracy: 0.001)
|
||
XCTAssertEqual(widths.side, 63, accuracy: 0.001)
|
||
XCTAssertEqual(widths.center, 175, accuracy: 0.001)
|
||
XCTAssertEqual(widths.side2, 70, accuracy: 0.001)
|
||
|
||
let phoneWidths = KeyboardChromeLayout.actionKeyWidthsWithoutGlobe(
|
||
availableWidth: 374
|
||
)
|
||
// 374 − 2 × spacing 8 = 358pt, redistributed across the three
|
||
// remaining slots without changing their relative proportions.
|
||
XCTAssertEqual(phoneWidths.side, 73.227, accuracy: 0.001)
|
||
XCTAssertEqual(phoneWidths.center, 203.409, accuracy: 0.001)
|
||
XCTAssertEqual(phoneWidths.side2, 81.364, accuracy: 0.001)
|
||
|
||
let iPadWidths = KeyboardChromeLayout.iPadVoiceActionKeyWidths(
|
||
availableWidth: 1024
|
||
)
|
||
// 1024 − 3 × 8 = 1000pt: keep the globe compact and give return 40%.
|
||
XCTAssertEqual(iPadWidths.globe, 100, accuracy: 0.001)
|
||
XCTAssertEqual(iPadWidths.side, 240, accuracy: 0.001)
|
||
XCTAssertEqual(iPadWidths.center, 400, accuracy: 0.001)
|
||
XCTAssertEqual(iPadWidths.side2, 260, accuracy: 0.001)
|
||
}
|
||
|
||
func testIPhoneTypingBottomRowOmitsCustomGlobeAndFillsWidth() {
|
||
let layout = TypingKeyLayoutBuilder.build(
|
||
size: CGSize(width: 374, height: 281),
|
||
letterRows: [
|
||
["q", "w"],
|
||
["a", "s"],
|
||
["z", "x"]
|
||
],
|
||
pageSwitchLabel: "123",
|
||
spaceLabel: "空格",
|
||
returnLabel: "return",
|
||
includeGlobeKey: false,
|
||
keyWeight: { _, _, _ in 1 }
|
||
)
|
||
|
||
XCTAssertNil(layout.key(id: TypingKeyLayoutBuilder.BottomKeyID.globe.rawValue))
|
||
let slots = [
|
||
TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue,
|
||
TypingKeyLayoutBuilder.BottomKeyID.space.rawValue,
|
||
TypingKeyLayoutBuilder.BottomKeyID.return.rawValue
|
||
]
|
||
let used = slots.compactMap { layout.key(id: $0)?.visualFrame.width }.reduce(0, +)
|
||
XCTAssertEqual(
|
||
used + KeyboardChromeLayout.actionKeySpacing * 2,
|
||
374,
|
||
accuracy: 0.001
|
||
)
|
||
}
|
||
|
||
func testIPadMetricsRequirePadAndRegularWidth() {
|
||
XCTAssertTrue(
|
||
KeyboardChromeLayout.usesIPadMetrics(
|
||
isPad: true,
|
||
hasRegularWidth: true
|
||
)
|
||
)
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.usesIPadMetrics(
|
||
isPad: true,
|
||
hasRegularWidth: false
|
||
),
|
||
"Compact iPad multitasking must use the compact layout"
|
||
)
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.usesIPadMetrics(
|
||
isPad: false,
|
||
hasRegularWidth: true
|
||
),
|
||
"Wide iPhones must never opt into iPad metrics"
|
||
)
|
||
}
|
||
|
||
func testRimeDeploymentRejectsAppExtensionProcess() {
|
||
XCTAssertFalse(
|
||
RimeResourceInstaller.canDeploy(
|
||
bundleURL: URL(fileURLWithPath: "/tmp/OSGKeyboardExt.appex")
|
||
)
|
||
)
|
||
XCTAssertTrue(
|
||
RimeResourceInstaller.canDeploy(
|
||
bundleURL: URL(fileURLWithPath: "/tmp/OSGKeyboard.app")
|
||
)
|
||
)
|
||
}
|
||
|
||
func testWideIPadMetricsTrackWidthNotOrientation() {
|
||
// iPad portrait widths (744 mini … 1024 on 13") stay narrow.
|
||
XCTAssertFalse(KeyboardChromeLayout.usesWideIPadMetrics(isIPad: true, width: 834))
|
||
XCTAssertFalse(KeyboardChromeLayout.usesWideIPadMetrics(isIPad: true, width: 1024))
|
||
// iPad landscape widths (1133 mini … 1366 on 13") go wide.
|
||
XCTAssertTrue(KeyboardChromeLayout.usesWideIPadMetrics(isIPad: true, width: 1133))
|
||
XCTAssertTrue(KeyboardChromeLayout.usesWideIPadMetrics(isIPad: true, width: 1366))
|
||
// A wide iPhone must never reach iPad metrics, however wide it gets.
|
||
XCTAssertFalse(KeyboardChromeLayout.usesWideIPadMetrics(isIPad: false, width: 1366))
|
||
}
|
||
|
||
func testTypingHeightGrowsWithAvailableWidth() {
|
||
let phone = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 393)
|
||
let portrait = TypingSurfaceMetrics.contentHeight(isIPad: true, width: 834)
|
||
let landscape = TypingSurfaceMetrics.contentHeight(isIPad: true, width: 1194)
|
||
|
||
XCTAssertEqual(phone, KeyboardChromeLayout.totalHeight)
|
||
XCTAssertGreaterThan(portrait, phone)
|
||
XCTAssertGreaterThan(
|
||
landscape,
|
||
portrait,
|
||
"A full-width landscape grid needs taller rows or keys turn flat"
|
||
)
|
||
}
|
||
|
||
func testSecondRowInsetKeepsKeysAsWideAsTheFirstRow() {
|
||
// 10 keys on row 0, 9 on row 1, all weight 1 — the classic QWERTY/ASDF
|
||
// relationship. Row 1 should be inset by exactly half a key pitch.
|
||
let layout = TypingKeyLayoutBuilder.build(
|
||
size: CGSize(width: 1194, height: 400),
|
||
letterRows: [
|
||
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
|
||
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
|
||
["Z", "X", "C", "V", "B", "N", "M"]
|
||
],
|
||
pageSwitchLabel: "123",
|
||
spaceLabel: "space",
|
||
returnLabel: "return",
|
||
metrics: TypingKeyLayoutBuilder.Metrics(derivesSecondRowInsetFromKeyWidth: true),
|
||
keyWeight: { _, _, _ in 1 }
|
||
)
|
||
|
||
let q = layout.key(id: "grid.0.0")!
|
||
let a = layout.key(id: "grid.1.0")!
|
||
XCTAssertEqual(
|
||
a.visualFrame.width,
|
||
q.visualFrame.width,
|
||
accuracy: 0.001,
|
||
"Second-row keys must match first-row key width at any total width"
|
||
)
|
||
// Row 1 is centred: its inset equals half of one key plus one gap.
|
||
let expectedInset = (q.visualFrame.width + layout.horizontalGap) / 2
|
||
XCTAssertEqual(a.visualFrame.minX, expectedInset, accuracy: 0.001)
|
||
}
|
||
|
||
func testIPadBottomRowAddsPunctuationAndKeepsSpaceUsable() {
|
||
let rows = [
|
||
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
|
||
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
|
||
["⇧", "Z", "X", "C", "V", "B", "N", "M", "⌫"]
|
||
]
|
||
func bottomRow(punctuation: TypingKeyLayoutBuilder.PunctuationKeys?) -> TypingKeyLayout {
|
||
TypingKeyLayoutBuilder.build(
|
||
size: CGSize(width: 1178, height: 400),
|
||
letterRows: rows,
|
||
pageSwitchLabel: "123",
|
||
spaceLabel: "space",
|
||
returnLabel: "return",
|
||
metrics: TypingKeyLayoutBuilder.Metrics(derivesSecondRowInsetFromKeyWidth: true),
|
||
punctuationKeys: punctuation,
|
||
keyWeight: { _, _, _ in 1 }
|
||
)
|
||
}
|
||
|
||
let phoneStyle = bottomRow(punctuation: nil)
|
||
let iPadStyle = bottomRow(punctuation: .init(comma: ",", period: "。"))
|
||
|
||
// Without punctuation the 50% centre fraction is a runway.
|
||
let wideSpace = phoneStyle.key(id: "bottom.space")!.visualFrame.width
|
||
XCTAssertGreaterThan(wideSpace, 550)
|
||
|
||
let space = iPadStyle.key(id: "bottom.space")!.visualFrame.width
|
||
XCTAssertLessThan(space, wideSpace)
|
||
XCTAssertEqual(space, 432, accuracy: 1, "Space should land near the system's ~430 pt")
|
||
|
||
XCTAssertEqual(iPadStyle.key(id: "bottom.comma")?.label, ",")
|
||
XCTAssertEqual(iPadStyle.key(id: "bottom.period")?.label, "。")
|
||
XCTAssertNil(phoneStyle.key(id: "bottom.comma"))
|
||
|
||
// The row must still consume exactly the available width.
|
||
let slots = ["bottom.globe", "bottom.page", "bottom.comma",
|
||
"bottom.space", "bottom.period", "bottom.return"]
|
||
let used = slots.compactMap { iPadStyle.key(id: $0)?.visualFrame.width }.reduce(0, +)
|
||
let gaps = KeyboardChromeLayout.actionKeySpacing * 5
|
||
XCTAssertEqual(used + gaps, 1178, accuracy: 0.5)
|
||
}
|
||
|
||
func testOnlyHostDeployableRimeErrorsOfferTheSetupJump() {
|
||
// These are fixed by deploying resources in the host app, so the
|
||
// keyboard should surface a tappable jump.
|
||
XCTAssertTrue(RimeResourceError.resourcesNotInstalled.isResolvedByHostDeployment)
|
||
XCTAssertTrue(RimeResourceError.deploymentFailed.isResolvedByHostDeployment)
|
||
XCTAssertTrue(
|
||
RimeResourceError.bundledResourceMissing("osg_pinyin.dict.yaml")
|
||
.isResolvedByHostDeployment
|
||
)
|
||
// These resolve on their own; sending the user to the app does nothing.
|
||
XCTAssertFalse(RimeResourceError.appGroupUnavailable.isResolvedByHostDeployment)
|
||
XCTAssertFalse(RimeResourceError.lockUnavailable.isResolvedByHostDeployment)
|
||
}
|
||
|
||
func testResourceRetryIsSkippedWhenNoErrorIsPending() {
|
||
let typing = TypingSessionController()
|
||
XCTAssertNil(typing.lastError)
|
||
XCTAssertFalse(typing.lastErrorNeedsHostDeployment)
|
||
|
||
// No prior failure — a config-change notification must not kick off a
|
||
// prepare, otherwise every host settings write would wake the engine.
|
||
typing.retryPrepareAfterResourceDeployment()
|
||
|
||
XCTAssertNil(typing.lastError)
|
||
XCTAssertFalse(typing.engineReady)
|
||
}
|
||
|
||
func testSharedCapsuleCanSelectSpecificTypingLanguage() {
|
||
let typing = TypingSessionController()
|
||
XCTAssertEqual(typing.language, .chinese)
|
||
|
||
XCTAssertEqual(typing.setLanguage(.english), .none)
|
||
XCTAssertEqual(typing.language, .english)
|
||
|
||
XCTAssertEqual(typing.setLanguage(.chinese), .none)
|
||
XCTAssertEqual(typing.language, .chinese)
|
||
}
|
||
|
||
func testShiftStateProducesUppercaseKeyRows() {
|
||
let typing = TypingSessionController()
|
||
XCTAssertFalse(typing.shiftActive)
|
||
|
||
_ = typing.handleKey("⇧")
|
||
|
||
XCTAssertTrue(typing.shiftActive)
|
||
XCTAssertTrue(typing.isShiftEnabled)
|
||
XCTAssertEqual(typing.keyRows.first?.first, "Q")
|
||
}
|
||
|
||
func testManualShiftSurvivesAutocapitalizationSync() {
|
||
let typing = TypingSessionController()
|
||
// Providers must be set before language switch (which syncs autocap).
|
||
typing.precedingTextProvider = { "hello " } // mid-sentence: auto stays off
|
||
typing.autocapitalizationModeProvider = { .sentences }
|
||
_ = typing.setLanguage(.english)
|
||
XCTAssertFalse(typing.shiftActive)
|
||
|
||
_ = typing.handleKey("⇧")
|
||
XCTAssertTrue(typing.shiftActive)
|
||
|
||
typing.syncAutocapitalization()
|
||
XCTAssertTrue(typing.shiftActive, "manual one-shot must outrank autocap sync")
|
||
XCTAssertEqual(typing.keyRows.first?.first, "Q")
|
||
}
|
||
|
||
func testShiftHoldTypesUppercaseWithoutEnteringCapsLock() {
|
||
let typing = TypingSessionController()
|
||
_ = typing.setLanguage(.english)
|
||
typing.precedingTextProvider = { "hello " }
|
||
typing.autocapitalizationModeProvider = { .sentences }
|
||
|
||
typing.beginShiftHold()
|
||
XCTAssertTrue(typing.shiftHeld)
|
||
XCTAssertEqual(typing.keyRows.first?.first, "Q")
|
||
|
||
_ = typing.handleKey("S")
|
||
_ = typing.handleKey("m")
|
||
XCTAssertFalse(typing.capsLock)
|
||
|
||
typing.endShiftHold()
|
||
XCTAssertFalse(typing.shiftHeld)
|
||
XCTAssertFalse(typing.capsLock)
|
||
XCTAssertFalse(typing.shiftActive)
|
||
}
|
||
|
||
func testShiftHoldWithoutTypingActsAsTap() {
|
||
let typing = TypingSessionController()
|
||
typing.beginShiftHold()
|
||
typing.endShiftHold()
|
||
XCTAssertTrue(typing.shiftActive)
|
||
XCTAssertFalse(typing.shiftHeld)
|
||
XCTAssertFalse(typing.capsLock)
|
||
}
|
||
|
||
func testChineseShiftInsertsUppercaseLatinBypassingRime() {
|
||
let engine = TrackingStubRimeEngine()
|
||
let typing = TypingSessionController(engine: { engine })
|
||
|
||
_ = typing.handleKey("⇧")
|
||
XCTAssertTrue(typing.shiftActive)
|
||
|
||
let output = typing.handleKey("N")
|
||
XCTAssertEqual(output, .insert("N"))
|
||
XCTAssertEqual(engine.processCharacterCallCount, 0)
|
||
XCTAssertFalse(typing.shiftActive, "one-shot Shift clears after Latin insert")
|
||
XCTAssertTrue(typing.composition.preedit.isEmpty)
|
||
}
|
||
|
||
func testChineseShiftPreservesExistingComposition() {
|
||
let engine = TrackingStubRimeEngine()
|
||
let typing = TypingSessionController(engine: { engine })
|
||
// Seed session composition via a lowercase letter (goes to Rime).
|
||
_ = typing.handleKey("n")
|
||
XCTAssertEqual(typing.composition.preedit, "n")
|
||
|
||
_ = typing.handleKey("⇧")
|
||
let output = typing.handleKey("A")
|
||
XCTAssertEqual(output, .insert("A"))
|
||
XCTAssertEqual(engine.processCharacterCallCount, 1, "only the unshifted letter hits Rime")
|
||
XCTAssertEqual(typing.composition.preedit, "n", "Shift Latin must not clear preedit")
|
||
}
|
||
|
||
func testChineseCapsLockKeepsInsertingUppercaseLatin() {
|
||
let engine = TrackingStubRimeEngine()
|
||
let typing = TypingSessionController(engine: { engine })
|
||
|
||
_ = typing.handleKey("⇧")
|
||
_ = typing.handleKey("⇧") // second tap → Caps Lock
|
||
XCTAssertTrue(typing.capsLock)
|
||
|
||
XCTAssertEqual(typing.handleKey("O"), .insert("O"))
|
||
XCTAssertEqual(typing.handleKey("S"), .insert("S"))
|
||
XCTAssertEqual(engine.processCharacterCallCount, 0)
|
||
XCTAssertTrue(typing.capsLock)
|
||
}
|
||
|
||
func testChineseUnshiftedLetterStillComposes() {
|
||
let engine = TrackingStubRimeEngine()
|
||
let typing = TypingSessionController(engine: { engine })
|
||
|
||
let output = typing.handleKey("n")
|
||
XCTAssertEqual(output, .none)
|
||
XCTAssertEqual(engine.processCharacterCallCount, 1)
|
||
XCTAssertEqual(engine.lastProcessedCharacter, "n")
|
||
XCTAssertEqual(typing.composition.preedit, "n")
|
||
}
|
||
|
||
func testSecureFieldInsertsLatinWithoutRime() {
|
||
let engine = TrackingStubRimeEngine()
|
||
let typing = TypingSessionController(engine: { engine })
|
||
|
||
_ = typing.handleKey("n")
|
||
XCTAssertEqual(engine.processCharacterCallCount, 1)
|
||
|
||
typing.suggestionsEnabled = false
|
||
XCTAssertTrue(typing.composition.preedit.isEmpty)
|
||
XCTAssertEqual(engine.clearCompositionCallCount, 1)
|
||
|
||
XCTAssertEqual(typing.handleKey("a"), .insert("a"))
|
||
XCTAssertEqual(engine.processCharacterCallCount, 1)
|
||
XCTAssertEqual(typing.handleSpace(), .insert(" "))
|
||
XCTAssertEqual(engine.processSpaceCallCount, 0)
|
||
XCTAssertEqual(typing.selectCandidate(at: 0), .none)
|
||
}
|
||
}
|
||
|
||
/// Stub that records `processCharacter` calls for Chinese Shift bypass tests.
|
||
@MainActor
|
||
private final class TrackingStubRimeEngine: RimeEngineBridging {
|
||
var composition: TypingComposition = .empty
|
||
var isReady: Bool = true
|
||
var schema: TypingInputSchema = .fullPinyin
|
||
private(set) var processCharacterCallCount = 0
|
||
private(set) var processSpaceCallCount = 0
|
||
private(set) var clearCompositionCallCount = 0
|
||
private(set) var lastProcessedCharacter: Character?
|
||
|
||
func prepare() async throws {}
|
||
func teardown() { composition = .empty }
|
||
|
||
func setLanguage(_ language: TypingInputLanguage) {
|
||
if language == .english { composition = .empty }
|
||
}
|
||
|
||
@discardableResult
|
||
func setSchema(_ schema: TypingInputSchema) -> Bool {
|
||
self.schema = schema
|
||
return true
|
||
}
|
||
|
||
func processCharacter(_ character: Character) -> String? {
|
||
processCharacterCallCount += 1
|
||
lastProcessedCharacter = character
|
||
composition = TypingComposition(
|
||
preedit: String(character).lowercased(),
|
||
candidates: [TypingCandidate(text: "你")]
|
||
)
|
||
return nil
|
||
}
|
||
|
||
func processBackspace() -> String? {
|
||
composition = .empty
|
||
return nil
|
||
}
|
||
|
||
func processSpace() -> String? {
|
||
processSpaceCallCount += 1
|
||
composition = .empty
|
||
return " "
|
||
}
|
||
|
||
func processReturn() -> String? {
|
||
composition = .empty
|
||
return "\n"
|
||
}
|
||
|
||
func selectCandidate(at index: Int) -> String {
|
||
composition = .empty
|
||
return ""
|
||
}
|
||
|
||
func flushPreedit() -> String {
|
||
let raw = composition.preedit
|
||
composition = .empty
|
||
return raw
|
||
}
|
||
|
||
func clearComposition() {
|
||
clearCompositionCallCount += 1
|
||
composition = .empty
|
||
}
|
||
}
|