Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs, docs/llms.txt). fix(flow): host-app identification now compiles in every build configuration (Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and could not auto-return after a startflow handoff. Debug-only logging and the Home test card are unchanged. Also includes the pending work in the tree: - Home: single account/setup card surface (HomeAccountSurfacePolicy/Views) - Wide screens: voice key row and centred mic placement, iPhone Duo layout - Keyboard: clipboard skill ranking and AI skill layout updates - Debug: account session handoff to the Simulator (+ import script) - Docs: portfolio assets, privacy policy URL, site/README refresh
776 lines
32 KiB
Swift
776 lines
32 KiB
Swift
// KeyboardSurfaceStateTests.swift
|
||
// OSGKeyboard · Ext unit tests
|
||
|
||
@testable import OSGKeyboardShared
|
||
import XCTest
|
||
|
||
@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(engine: { TrackingStubRimeEngine() })
|
||
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)
|
||
}
|
||
|
||
// MARK: - Wide-host voice key row
|
||
|
||
func testVoiceKeyRowOnlyOnWideHosts() {
|
||
// Unfolded iPhone Duo, full width and in Split View.
|
||
XCTAssertTrue(keyRowAvailable(expandedPhone: true, width: 801))
|
||
XCTAssertTrue(keyRowAvailable(expandedPhone: true, width: 655))
|
||
XCTAssertTrue(keyRowAvailable(iPad: true, width: 834))
|
||
// A phone outside the expanded / landscape tiers keeps the circle
|
||
// cluster, however wide.
|
||
XCTAssertFalse(keyRowAvailable(width: 932))
|
||
// Too narrow for comfortable icon keys, and the pre-layout zero width.
|
||
XCTAssertFalse(keyRowAvailable(iPad: true, width: 400))
|
||
XCTAssertFalse(keyRowAvailable(iPad: true, width: 0))
|
||
XCTAssertFalse(keyRowAvailable(compactHeightPhone: true, width: 0))
|
||
}
|
||
|
||
private func keyRowAvailable(
|
||
iPad: Bool = false,
|
||
expandedPhone: Bool = false,
|
||
compactHeightPhone: Bool = false,
|
||
width: CGFloat
|
||
) -> Bool {
|
||
VoiceKeyRowLayout.isAvailable(
|
||
usesIPadMetrics: iPad,
|
||
usesExpandedPhoneMetrics: expandedPhone,
|
||
usesCompactHeightPhoneMetrics: compactHeightPhone,
|
||
hostWidth: width
|
||
)
|
||
}
|
||
|
||
func testCompactVoiceMetricsIgnoreUnmeasuredWidth() {
|
||
// 0 = the controller has not measured yet; never treat it as compact.
|
||
XCTAssertFalse(KeyboardChromeLayout.usesCompactVoiceMetrics(width: 0))
|
||
// Display Zoom on a small iPhone, or iPad Slide Over.
|
||
XCTAssertTrue(KeyboardChromeLayout.usesCompactVoiceMetrics(width: 320))
|
||
XCTAssertFalse(KeyboardChromeLayout.usesCompactVoiceMetrics(width: 375))
|
||
|
||
XCTAssertFalse(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: true, width: 0))
|
||
XCTAssertTrue(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: true, width: 400))
|
||
XCTAssertFalse(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: false, width: 400))
|
||
XCTAssertFalse(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: true, width: 834))
|
||
}
|
||
|
||
func testVoiceContentSpansTypingWidthOnlyWithKeyRow() {
|
||
XCTAssertEqual(KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: false), 700)
|
||
XCTAssertEqual(KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: true), .infinity)
|
||
}
|
||
|
||
func testVoiceKeyRowFillsRowWithEqualIconKeysAndDoubleSend() {
|
||
let spacing = KeyboardChromeLayout.actionKeySpacing
|
||
// Duo inner display (801 − 2 × 8 inset), iPad portrait, iPad landscape.
|
||
for rowWidth: CGFloat in [785, 818, 1350] {
|
||
let row = VoiceKeyRowLayout.make(rowWidth: rowWidth)
|
||
|
||
let keyWidth = row.leadingSide.width
|
||
for slot in [row.leadingContext, row.trailingContext, row.trailingSide] {
|
||
XCTAssertEqual(slot.width, keyWidth, accuracy: 0.001)
|
||
}
|
||
XCTAssertEqual(row.send.width, keyWidth * VoiceKeyRowLayout.sendWidthMultiplier, accuracy: 0.001)
|
||
|
||
// Edge to edge with uniform 8 pt gaps — no hole.
|
||
let slots = [row.leadingSide, row.leadingContext, row.send, row.trailingContext, row.trailingSide]
|
||
for (left, right) in zip(slots, slots.dropFirst()) {
|
||
XCTAssertEqual(right.minX - left.maxX, spacing, accuracy: 0.001)
|
||
}
|
||
XCTAssertEqual(row.leadingSide.minX, 0)
|
||
XCTAssertEqual(row.trailingSide.maxX, rowWidth, accuracy: 0.001)
|
||
// Symmetric row, so send sits under the microphone.
|
||
XCTAssertEqual(row.send.midX, rowWidth / 2, 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 testExpandedPhoneMetricsRequirePhoneWithRegularSpaceOnBothAxes() {
|
||
XCTAssertTrue(
|
||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||
isPad: false,
|
||
hasRegularWidth: true,
|
||
hasRegularHeight: true
|
||
),
|
||
"The unfolded iPhone Duo inner display is regular × regular"
|
||
)
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||
isPad: false,
|
||
hasRegularWidth: false,
|
||
hasRegularHeight: true
|
||
),
|
||
"Portrait iPhones and the folded Duo stay on phone metrics"
|
||
)
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||
isPad: false,
|
||
hasRegularWidth: true,
|
||
hasRegularHeight: false
|
||
),
|
||
"Landscape Max-size iPhones stay on phone metrics"
|
||
)
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||
isPad: true,
|
||
hasRegularWidth: true,
|
||
hasRegularHeight: true
|
||
),
|
||
"iPad keeps its own metrics"
|
||
)
|
||
}
|
||
|
||
func testGlobeKeyFollowsSystemSwitchRequirementOutsideIPad() {
|
||
XCTAssertTrue(KeyboardChromeLayout.showsInputModeSwitchKey(isPad: true, systemRequiresSwitchKey: false))
|
||
XCTAssertTrue(KeyboardChromeLayout.showsInputModeSwitchKey(isPad: false, systemRequiresSwitchKey: true))
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.showsInputModeSwitchKey(isPad: false, systemRequiresSwitchKey: false),
|
||
"Face ID iPhones already show the system switch below the keyboard"
|
||
)
|
||
}
|
||
|
||
func testExpandedPhoneHeightStaysCloseToTheSystemKeyboard() {
|
||
let phone = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 801)
|
||
let expanded = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 801, isExpandedPhone: true)
|
||
|
||
XCTAssertEqual(phone, KeyboardChromeLayout.totalHeight, "Phone height must not move")
|
||
XCTAssertEqual(expanded, 260, accuracy: 0.001)
|
||
XCTAssertEqual(
|
||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 834, isExpandedPhone: true),
|
||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 834),
|
||
"iPad ignores the expanded-phone flag"
|
||
)
|
||
}
|
||
|
||
func testCompactHeightPhoneMetricsCoverEveryLandscapeIPhone() {
|
||
XCTAssertTrue(
|
||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(isPad: false, hasCompactHeight: true),
|
||
"Landscape iPhones, including the folded Duo, are vertically compact"
|
||
)
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(isPad: false, hasCompactHeight: false),
|
||
"Portrait iPhones and the unfolded Duo keep their tiers"
|
||
)
|
||
XCTAssertFalse(
|
||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(isPad: true, hasCompactHeight: true),
|
||
"iPad keeps its own metrics"
|
||
)
|
||
}
|
||
|
||
func testVoiceMicSitsCentredBelowTopBarOnEveryShell() {
|
||
// (shell, mic row, bottom row) for phone, unfolded Duo, landscape, iPad narrow / wide.
|
||
let shells: [(name: String, shell: CGFloat, mic: CGFloat, bottom: CGFloat)] = [
|
||
("phone", KeyboardChromeLayout.totalHeight, 60, 52),
|
||
("duo", TypingSurfaceMetrics.contentHeight(isIPad: false, width: 785, isExpandedPhone: true), 60, 41),
|
||
("landscape", TypingSurfaceMetrics.contentHeight(isIPad: false, width: 874, isCompactHeightPhone: true), 56, 40),
|
||
("iPadNarrow", TypingSurfaceMetrics.contentHeight(isIPad: true, width: 834), 60, 54),
|
||
("iPadWide", TypingSurfaceMetrics.contentHeight(isIPad: true, width: 1194), 60, 76)
|
||
]
|
||
for item in shells {
|
||
let free = item.shell - 8 - 44 - item.mic - item.bottom
|
||
let context = VoiceMicPlacement.contextHeight(
|
||
contextAndGap: free,
|
||
bottomRowHeight: item.bottom,
|
||
minimumContextHeight: 66
|
||
)
|
||
let gap = free - context
|
||
XCTAssertEqual(context, gap + item.bottom, accuracy: 0.001, "\(item.name): mic centred below the top bar")
|
||
XCTAssertGreaterThanOrEqual(context, 66, "\(item.name): live transcript must fit")
|
||
XCTAssertGreaterThanOrEqual(gap, VoiceMicPlacement.minimumGap, item.name)
|
||
}
|
||
}
|
||
|
||
func testVoiceMicPlacementKeepsTranscriptFloorAndGapCeiling() {
|
||
// Too short to centre: the transcript floor wins over centring.
|
||
XCTAssertEqual(
|
||
VoiceMicPlacement.contextHeight(contextAndGap: 80, bottomRowHeight: 20, minimumContextHeight: 66),
|
||
66
|
||
)
|
||
// Even shorter: the mic still keeps its minimum gap to the bottom row.
|
||
XCTAssertEqual(
|
||
VoiceMicPlacement.contextHeight(contextAndGap: 70, bottomRowHeight: 20, minimumContextHeight: 66),
|
||
70 - VoiceMicPlacement.minimumGap
|
||
)
|
||
}
|
||
|
||
func testCompactHeightPhoneShortensPanelButKeepsContextSlot() {
|
||
let portrait = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 874)
|
||
let landscape = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 874, isCompactHeightPhone: true)
|
||
XCTAssertEqual(portrait, KeyboardChromeLayout.totalHeight, "Portrait phone height must not move")
|
||
XCTAssertEqual(landscape, 244, accuracy: 0.001)
|
||
|
||
// Voice surface: 4 + 4 padding, 44 top bar, 56 mic row, key row; the
|
||
// rest splits into the context slot and the gap below the mic.
|
||
let keyRow = TypingSurfaceMetrics.metrics(isIPad: false, width: 874, isCompactHeightPhone: true).bottomRowHeight
|
||
let context = VoiceMicPlacement.contextHeight(
|
||
contextAndGap: landscape - 8 - 44 - 56 - keyRow,
|
||
bottomRowHeight: keyRow,
|
||
minimumContextHeight: 66
|
||
)
|
||
XCTAssertEqual(context, 68, accuracy: 0.001, "Context slot must fit the live transcript")
|
||
|
||
XCTAssertEqual(
|
||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 1194, isCompactHeightPhone: true),
|
||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 1194),
|
||
"iPad ignores the compact-height-phone flag"
|
||
)
|
||
// Landscape iPhones are all ≥ 667 pt wide, so the voice key row replaces the circles.
|
||
XCTAssertTrue(VoiceKeyRowLayout.isAvailable(
|
||
usesIPadMetrics: false,
|
||
usesExpandedPhoneMetrics: false,
|
||
usesCompactHeightPhoneMetrics: true,
|
||
hostWidth: 667
|
||
))
|
||
}
|
||
|
||
func testExpandedPhoneBottomRowWithGlobeFillsWidth() {
|
||
let width: CGFloat = 785
|
||
let metrics = TypingSurfaceMetrics.metrics(isIPad: false, width: width, isExpandedPhone: true)
|
||
let layout = TypingKeyLayoutBuilder.build(
|
||
size: CGSize(width: width, height: 260),
|
||
letterRows: [
|
||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
||
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
|
||
["z", "x"]
|
||
],
|
||
pageSwitchLabel: "123",
|
||
spaceLabel: "space",
|
||
returnLabel: "return",
|
||
metrics: metrics,
|
||
includeGlobeKey: true,
|
||
keyWeight: { _, _, _ in 1 }
|
||
)
|
||
|
||
let slots = [
|
||
TypingKeyLayoutBuilder.BottomKeyID.globe.rawValue,
|
||
TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue,
|
||
TypingKeyLayoutBuilder.BottomKeyID.space.rawValue,
|
||
TypingKeyLayoutBuilder.BottomKeyID.return.rawValue
|
||
]
|
||
let frames = slots.compactMap { layout.key(id: $0)?.visualFrame }
|
||
XCTAssertEqual(frames.count, 4)
|
||
XCTAssertEqual(frames.last?.maxX ?? 0, width, accuracy: 0.001)
|
||
XCTAssertEqual(frames.first?.height ?? 0, TypingSurfaceMetrics.expandedPhoneKeyRowHeight)
|
||
}
|
||
|
||
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(engine: { TrackingStubRimeEngine() })
|
||
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(engine: { TrackingStubRimeEngine() })
|
||
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(engine: { TrackingStubRimeEngine() })
|
||
XCTAssertFalse(typing.shiftActive)
|
||
|
||
_ = typing.handleKey("⇧")
|
||
|
||
XCTAssertTrue(typing.shiftActive)
|
||
XCTAssertTrue(typing.isShiftEnabled)
|
||
XCTAssertEqual(typing.keyRows.first?.first, "Q")
|
||
}
|
||
|
||
func testManualShiftSurvivesAutocapitalizationSync() {
|
||
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||
// 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(engine: { TrackingStubRimeEngine() })
|
||
_ = 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(engine: { TrackingStubRimeEngine() })
|
||
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
|
||
}
|
||
}
|