a8d58d8f0c
Custom polish styles can opt in to emotion-matched emoji (default off), with prompt-level opt-in detection so paste-only styles keep model-added emoji. Also include Volcengine API-Key ASR auth, voice-processing capture, PiP flash fix, and related keyboard Shift/haptics reliability work.
274 lines
9.7 KiB
Swift
274 lines
9.7 KiB
Swift
// KeyboardSurfaceStateTests.swift
|
||
// OSGKeyboard · Ext unit tests
|
||
|
||
import XCTest
|
||
@testable import OSGKeyboardShared
|
||
|
||
@MainActor
|
||
final class KeyboardSurfaceStateTests: XCTestCase {
|
||
func testRecordingLocksTyping() {
|
||
let state = KeyboardState()
|
||
state.phase = .idle
|
||
XCTAssertTrue(state.canEnterTypingSurface)
|
||
state.phase = .recording
|
||
XCTAssertTrue(state.locksTypingSurface)
|
||
XCTAssertFalse(state.canEnterTypingSurface)
|
||
}
|
||
|
||
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)
|
||
XCTAssertEqual(KeyboardChromeLayout.sideActionKeyFraction, 0.2)
|
||
XCTAssertEqual(KeyboardChromeLayout.centerActionKeyFraction, 0.6)
|
||
XCTAssertEqual(KeyboardChromeLayout.horizontalInset, 8)
|
||
XCTAssertEqual(KeyboardChromeLayout.contentMaxWidth, 700)
|
||
|
||
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: 374)
|
||
XCTAssertEqual(widths.side, 71.6, accuracy: 0.001)
|
||
XCTAssertEqual(widths.center, 214.8, accuracy: 0.001)
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
|
||
/// 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 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? {
|
||
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() {
|
||
composition = .empty
|
||
}
|
||
}
|