feat(typing): improve key hit accuracy and bump to 1.6.1
Add gap-filling hit regions, release-to-commit with slide-to-reselect, touch intent offset, and full-pinyin next-key bias; cut release 1.6.1 (build 45).
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// KeyHitTestingTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
//
|
||||
// Phase 1 / 3: gap fill, nearest-center, intent offset, edge expansion.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class KeyHitTestingTests: XCTestCase {
|
||||
private func makePairTargets(
|
||||
left: CGRect = CGRect(x: 0, y: 0, width: 40, height: 50),
|
||||
gap: CGFloat = 6
|
||||
) -> (targets: [TypingKeyHitTarget], plane: CGRect) {
|
||||
let right = CGRect(
|
||||
x: left.maxX + gap,
|
||||
y: left.minY,
|
||||
width: left.width,
|
||||
height: left.height
|
||||
)
|
||||
let targets = [
|
||||
TypingKeyHitTarget(
|
||||
id: "L",
|
||||
label: "A",
|
||||
visualFrame: left,
|
||||
behavior: .commitOnRelease
|
||||
),
|
||||
TypingKeyHitTarget(
|
||||
id: "R",
|
||||
label: "S",
|
||||
visualFrame: right,
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
]
|
||||
let plane = left.union(right)
|
||||
return (targets, plane)
|
||||
}
|
||||
|
||||
func testGapMidpointHitsNearestKey() {
|
||||
let pair = makePairTargets()
|
||||
let mid = CGPoint(x: 43, y: 25) // center of 6pt gap between 40 and 46
|
||||
let hit = KeyHitTesting.hitTarget(
|
||||
at: mid,
|
||||
targets: pair.targets,
|
||||
keyPlaneBounds: pair.plane,
|
||||
horizontalGap: 6,
|
||||
verticalGap: 7,
|
||||
edgeExpansion: 0
|
||||
)
|
||||
// Midpoint is equidistant; either is acceptable, but must not miss.
|
||||
XCTAssertNotNil(hit)
|
||||
}
|
||||
|
||||
func testGapCloserToLeftSelectsLeft() {
|
||||
let pair = makePairTargets()
|
||||
// Just right of left key visual edge, still in gap, closer to left center.
|
||||
let point = CGPoint(x: 41, y: 25)
|
||||
let hit = KeyHitTesting.hitTarget(
|
||||
at: point,
|
||||
targets: pair.targets,
|
||||
keyPlaneBounds: pair.plane,
|
||||
horizontalGap: 6,
|
||||
verticalGap: 7,
|
||||
edgeExpansion: 0
|
||||
)
|
||||
XCTAssertEqual(hit?.id, "L")
|
||||
}
|
||||
|
||||
func testGapCloserToRightSelectsRight() {
|
||||
let pair = makePairTargets()
|
||||
let point = CGPoint(x: 45, y: 25)
|
||||
let hit = KeyHitTesting.hitTarget(
|
||||
at: point,
|
||||
targets: pair.targets,
|
||||
keyPlaneBounds: pair.plane,
|
||||
horizontalGap: 6,
|
||||
verticalGap: 7,
|
||||
edgeExpansion: 0
|
||||
)
|
||||
XCTAssertEqual(hit?.id, "R")
|
||||
}
|
||||
|
||||
func testOutsidePlaneReturnsNil() {
|
||||
let pair = makePairTargets()
|
||||
let hit = KeyHitTesting.hitTarget(
|
||||
at: CGPoint(x: 200, y: 200),
|
||||
targets: pair.targets,
|
||||
keyPlaneBounds: pair.plane,
|
||||
horizontalGap: 6,
|
||||
verticalGap: 7,
|
||||
edgeExpansion: 0
|
||||
)
|
||||
XCTAssertNil(hit)
|
||||
}
|
||||
|
||||
func testIntentOffsetShiftsHitUpward() {
|
||||
// Key occupies y 10…60. Raw touch at y=62 is below the key; with a
|
||||
// 4pt upward intent offset it maps to y=58 and should still hit.
|
||||
let target = TypingKeyHitTarget(
|
||||
id: "K",
|
||||
label: "M",
|
||||
visualFrame: CGRect(x: 0, y: 10, width: 40, height: 50),
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
let plane = target.visualFrame
|
||||
let raw = CGPoint(x: 20, y: 62)
|
||||
let withoutOffset = KeyHitTesting.hitTarget(
|
||||
at: raw,
|
||||
targets: [target],
|
||||
keyPlaneBounds: plane,
|
||||
horizontalGap: 0,
|
||||
verticalGap: 0,
|
||||
edgeExpansion: 0
|
||||
)
|
||||
let withOffset = KeyHitTesting.hitTarget(
|
||||
rawTouch: raw,
|
||||
targets: [target],
|
||||
keyPlaneBounds: plane,
|
||||
horizontalGap: 0,
|
||||
verticalGap: 0,
|
||||
intentOffsetY: 4,
|
||||
edgeExpansion: 0
|
||||
)
|
||||
XCTAssertNil(withoutOffset)
|
||||
XCTAssertEqual(withOffset?.id, "K")
|
||||
}
|
||||
|
||||
func testEdgeExpansionExtendsOuterHit() {
|
||||
let target = TypingKeyHitTarget(
|
||||
id: "Q",
|
||||
label: "Q",
|
||||
visualFrame: CGRect(x: 10, y: 0, width: 40, height: 50),
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
let plane = target.visualFrame
|
||||
// Point just left of the visual key, inside edge expansion.
|
||||
let point = CGPoint(x: 7, y: 25)
|
||||
let hit = KeyHitTesting.hitTarget(
|
||||
at: point,
|
||||
targets: [target],
|
||||
keyPlaneBounds: plane,
|
||||
horizontalGap: 0,
|
||||
verticalGap: 0,
|
||||
edgeExpansion: 5
|
||||
)
|
||||
XCTAssertEqual(hit?.id, "Q")
|
||||
}
|
||||
|
||||
func testLayoutBuilderCoversGapsBetweenKeys() {
|
||||
let layout = TypingKeyLayoutBuilder.build(
|
||||
size: CGSize(width: 300, height: 200),
|
||||
letterRows: [
|
||||
["Q", "W", "E"],
|
||||
["A", "S", "D"],
|
||||
["Z", "X", "C"]
|
||||
],
|
||||
pageSwitchLabel: "123",
|
||||
spaceLabel: "space",
|
||||
returnLabel: "return",
|
||||
keyWeight: { _, _, _ in 1 }
|
||||
)
|
||||
XCTAssertEqual(layout.keys.count, 12) // 9 letters + 3 bottom
|
||||
|
||||
// Mid-gap between Q and W on first row should hit something.
|
||||
let q = layout.keys.first { $0.label == "Q" }!
|
||||
let w = layout.keys.first { $0.label == "W" }!
|
||||
let mid = CGPoint(x: (q.visualFrame.maxX + w.visualFrame.minX) / 2, y: q.center.y)
|
||||
let hit = KeyHitTesting.hitTarget(
|
||||
at: mid,
|
||||
targets: layout.keys,
|
||||
keyPlaneBounds: layout.keyPlaneBounds,
|
||||
horizontalGap: layout.horizontalGap,
|
||||
verticalGap: layout.verticalGap
|
||||
)
|
||||
XCTAssertNotNil(hit)
|
||||
XCTAssertTrue(hit?.label == "Q" || hit?.label == "W")
|
||||
}
|
||||
|
||||
func testBehaviorResolver() {
|
||||
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: "⌫"), .deleteRepeat)
|
||||
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: "⇧"), .shiftHold)
|
||||
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: "A"), .commitOnRelease)
|
||||
XCTAssertEqual(TypingKeyBehaviorResolver.behavior(for: "123"), .commitOnRelease)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// PinyinNextKeyResolverTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
//
|
||||
// Phase 4: legal next-key sets and weighted ambiguous hit resolution.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class PinyinNextKeyResolverTests: XCTestCase {
|
||||
func testZhongPrefixAllowsG() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "zhon",
|
||||
schema: .fullPinyin,
|
||||
language: .chinese,
|
||||
page: .letters
|
||||
)
|
||||
XCTAssertEqual(keys, ["g"])
|
||||
}
|
||||
|
||||
func testCompleteZhongAllowsNewSyllableInitials() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "zhong",
|
||||
schema: .fullPinyin,
|
||||
language: .chinese,
|
||||
page: .letters
|
||||
)
|
||||
XCTAssertNotNil(keys)
|
||||
XCTAssertTrue(keys?.contains("g") == true) // zhongguo
|
||||
XCTAssertTrue(keys?.contains("w") == true)
|
||||
}
|
||||
|
||||
func testMultiSyllableTrailingGAllowsU() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "zhongg",
|
||||
schema: .fullPinyin,
|
||||
language: .chinese,
|
||||
page: .letters
|
||||
)
|
||||
XCTAssertTrue(keys?.contains("u") == true) // gu / guo
|
||||
}
|
||||
|
||||
func testNiAllowsExtensionAndNewInitial() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "ni",
|
||||
schema: .fullPinyin,
|
||||
language: .chinese,
|
||||
page: .letters
|
||||
)
|
||||
XCTAssertTrue(keys?.contains("a") == true) // nia…
|
||||
XCTAssertTrue(keys?.contains("h") == true) // nihao
|
||||
}
|
||||
|
||||
func testEmptyRawDisablesBias() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "",
|
||||
schema: .fullPinyin,
|
||||
language: .chinese,
|
||||
page: .letters
|
||||
)
|
||||
XCTAssertNil(keys)
|
||||
}
|
||||
|
||||
func testEnglishDisablesBias() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "ni",
|
||||
schema: .fullPinyin,
|
||||
language: .english,
|
||||
page: .letters
|
||||
)
|
||||
XCTAssertNil(keys)
|
||||
}
|
||||
|
||||
func testDoublePinyinDisablesBias() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "nihk",
|
||||
schema: .microsoftDoublePinyin,
|
||||
language: .chinese,
|
||||
page: .letters
|
||||
)
|
||||
XCTAssertNil(keys)
|
||||
}
|
||||
|
||||
func testNumbersPageDisablesBias() {
|
||||
let keys = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: "ni",
|
||||
schema: .fullPinyin,
|
||||
language: .chinese,
|
||||
page: .numbers
|
||||
)
|
||||
XCTAssertNil(keys)
|
||||
}
|
||||
|
||||
func testHitWeightsBoostLegalLetters() {
|
||||
let keys = [
|
||||
TypingKeyHitTarget(
|
||||
id: "grid.0.0",
|
||||
label: "G",
|
||||
visualFrame: .zero,
|
||||
behavior: .commitOnRelease
|
||||
),
|
||||
TypingKeyHitTarget(
|
||||
id: "grid.0.1",
|
||||
label: "H",
|
||||
visualFrame: .zero,
|
||||
behavior: .commitOnRelease
|
||||
),
|
||||
TypingKeyHitTarget(
|
||||
id: "grid.0.2",
|
||||
label: "⌫",
|
||||
visualFrame: .zero,
|
||||
behavior: .deleteRepeat
|
||||
)
|
||||
]
|
||||
let weights = PinyinNextKeyResolver.hitWeights(for: keys, validNext: ["g"])
|
||||
XCTAssertEqual(weights["grid.0.0"], KeyHitBiasMetrics.legalBoost)
|
||||
XCTAssertEqual(weights["grid.0.1"], KeyHitBiasMetrics.illegalShrink)
|
||||
XCTAssertNil(weights["grid.0.2"])
|
||||
}
|
||||
|
||||
func testWeightedNearestPrefersLegalKeyInGap() {
|
||||
let left = TypingKeyHitTarget(
|
||||
id: "L",
|
||||
label: "F",
|
||||
visualFrame: CGRect(x: 0, y: 0, width: 40, height: 50),
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
let right = TypingKeyHitTarget(
|
||||
id: "R",
|
||||
label: "G",
|
||||
visualFrame: CGRect(x: 46, y: 0, width: 40, height: 50),
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
let plane = left.visualFrame.union(right.visualFrame)
|
||||
// Shared expanded edge (half of 6pt gap) — both frames contain x=43.
|
||||
let point = CGPoint(x: 43, y: 25)
|
||||
let weighted = KeyHitTesting.hitTarget(
|
||||
at: point,
|
||||
targets: [left, right],
|
||||
keyPlaneBounds: plane,
|
||||
horizontalGap: 6,
|
||||
verticalGap: 7,
|
||||
edgeExpansion: 0,
|
||||
hitWeights: [
|
||||
"L": KeyHitBiasMetrics.illegalShrink,
|
||||
"R": KeyHitBiasMetrics.legalBoost
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(weighted?.id, "R")
|
||||
}
|
||||
|
||||
func testClearSingleHitIgnoresBias() {
|
||||
// Point clearly inside F — must not jump to boosted G.
|
||||
let left = TypingKeyHitTarget(
|
||||
id: "L",
|
||||
label: "F",
|
||||
visualFrame: CGRect(x: 0, y: 0, width: 40, height: 50),
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
let right = TypingKeyHitTarget(
|
||||
id: "R",
|
||||
label: "G",
|
||||
visualFrame: CGRect(x: 46, y: 0, width: 40, height: 50),
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
let plane = left.visualFrame.union(right.visualFrame)
|
||||
let point = CGPoint(x: 20, y: 25)
|
||||
let hit = KeyHitTesting.hitTarget(
|
||||
at: point,
|
||||
targets: [left, right],
|
||||
keyPlaneBounds: plane,
|
||||
horizontalGap: 6,
|
||||
verticalGap: 7,
|
||||
edgeExpansion: 0,
|
||||
hitWeights: [
|
||||
"L": KeyHitBiasMetrics.illegalShrink,
|
||||
"R": KeyHitBiasMetrics.legalBoost
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(hit?.id, "L")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user