feat(keyboard): add English QuickType bar and system lexicon
Show verbatim/correction/completion slots, mmap a 40k-word list, and use UITextChecker plus supplementary lexicon for conservative autocorrect.
This commit is contained in:
@@ -10,18 +10,42 @@ final class EnglishTypingTests: XCTestCase {
|
||||
func testLexiconLoadsAndCompletesPrefix() {
|
||||
let lexicon = EnglishLexicon()
|
||||
lexicon.prepare()
|
||||
XCTAssertGreaterThan(lexicon.wordCount, 1_000)
|
||||
XCTAssertTrue(lexicon.isLoaded)
|
||||
XCTAssertGreaterThan(lexicon.wordCount, 10_000)
|
||||
let hits = lexicon.completions(prefix: "hel", limit: 5)
|
||||
XCTAssertTrue(hits.contains("hello") || hits.contains("help") || hits.contains("held"))
|
||||
lexicon.unload()
|
||||
XCTAssertFalse(lexicon.isLoaded)
|
||||
XCTAssertEqual(lexicon.wordCount, 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testChineseTypingDoesNotLoadEnglishLexicon() {
|
||||
EnglishLexicon.shared.unload()
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.chinese)
|
||||
typing.enterTypingMode()
|
||||
XCTAssertFalse(EnglishLexicon.shared.isLoaded)
|
||||
_ = typing.setLanguage(.english)
|
||||
XCTAssertTrue(EnglishLexicon.shared.isLoaded)
|
||||
_ = typing.setLanguage(.chinese)
|
||||
XCTAssertFalse(EnglishLexicon.shared.isLoaded)
|
||||
typing.leaveTypingMode()
|
||||
}
|
||||
|
||||
func testCorrectionFindsNearbyWord() {
|
||||
let lexicon = EnglishLexicon()
|
||||
lexicon.prepare()
|
||||
// "teh" is a classic typo for "the".
|
||||
let correction = lexicon.bestCorrection(for: "teh")
|
||||
XCTAssertEqual(correction, "the")
|
||||
XCTAssertNil(lexicon.bestCorrection(for: "the"))
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
// "teh" leaks into web unigrams; the engine must still treat it as a typo.
|
||||
let decision = engine.correctionDecision(
|
||||
for: "teh",
|
||||
personalTerms: [],
|
||||
learnedBoosts: [:]
|
||||
)
|
||||
XCTAssertEqual(decision?.replacement, "the")
|
||||
XCTAssertNil(
|
||||
engine.correctionDecision(for: "the", personalTerms: [], learnedBoosts: [:])
|
||||
)
|
||||
}
|
||||
|
||||
func testSuggestionEngineSkipsPersonalDictionaryTypos() {
|
||||
@@ -45,7 +69,22 @@ final class EnglishTypingTests: XCTestCase {
|
||||
learnedBoosts: [:]
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(composition.candidates.first?.text, "OSGKeyboard")
|
||||
XCTAssertEqual(composition.candidates.first?.role, .verbatim)
|
||||
XCTAssertEqual(composition.candidates.first?.text, "osg")
|
||||
XCTAssertTrue(composition.candidates.contains { $0.text == "OSGKeyboard" })
|
||||
}
|
||||
|
||||
func testSuggestionEngineReturnsNoCandidatesWithoutCurrentWord() {
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
let composition = engine.compositionWhileTyping(
|
||||
EnglishSuggestionContext(
|
||||
previousWord: "hello",
|
||||
personalTerms: ["OSGKeyboard"]
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(composition, .empty)
|
||||
}
|
||||
|
||||
func testAutocapitalizationAtFieldStartAndAfterSentence() {
|
||||
@@ -208,7 +247,7 @@ final class EnglishTypingTests: XCTestCase {
|
||||
XCTAssertEqual(typing.composition.preedit, "boa")
|
||||
XCTAssertTrue(
|
||||
typing.composition.candidates.contains {
|
||||
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
|
||||
$0.text.compare("board", options: .caseInsensitive) == .orderedSame
|
||||
}
|
||||
)
|
||||
|
||||
@@ -232,14 +271,14 @@ final class EnglishTypingTests: XCTestCase {
|
||||
preceding += output.text
|
||||
typing.syncAutocapitalization(accountingForInsert: output.text)
|
||||
}
|
||||
guard let boatIndex = typing.composition.candidates.firstIndex(where: {
|
||||
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
|
||||
guard let boardIndex = typing.composition.candidates.firstIndex(where: {
|
||||
$0.text.compare("board", options: .caseInsensitive) == .orderedSame
|
||||
}) else {
|
||||
return XCTFail("expected boat completion")
|
||||
return XCTFail("expected board completion")
|
||||
}
|
||||
|
||||
preceding = "board"
|
||||
let output = typing.selectCandidate(at: boatIndex)
|
||||
let output = typing.selectCandidate(at: boardIndex)
|
||||
|
||||
XCTAssertEqual(output, .none)
|
||||
XCTAssertEqual(typing.composition.preedit.lowercased(), "board")
|
||||
@@ -283,22 +322,17 @@ final class EnglishTypingTests: XCTestCase {
|
||||
|
||||
@MainActor
|
||||
func testAutocorrectUndoRestoresOriginal() {
|
||||
let typing = TypingSessionController()
|
||||
typing.suggestionsEnabled = true
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
let typing = makeIsolatedEnglishSession(suite: "english.undo.test")
|
||||
|
||||
for ch in ["t", "e", "h"] {
|
||||
_ = typing.handleKey(ch)
|
||||
}
|
||||
let spaced = typing.handleSpace()
|
||||
// Either corrected to "the " or left as-is if lexicon missing in test bundle.
|
||||
if spaced.deleteCount > 0 {
|
||||
XCTAssertTrue(spaced.text.hasPrefix("the"))
|
||||
let undone = typing.handleKey("⌫")
|
||||
XCTAssertEqual(undone.text, "teh")
|
||||
XCTAssertEqual(undone.deleteCount, spaced.text.count)
|
||||
}
|
||||
XCTAssertEqual(spaced.deleteCount, 3)
|
||||
XCTAssertTrue(spaced.text.hasPrefix("the"))
|
||||
let undone = typing.handleKey("⌫")
|
||||
XCTAssertEqual(undone.text, "teh")
|
||||
XCTAssertEqual(undone.deleteCount, spaced.text.count)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -370,6 +404,177 @@ final class EnglishTypingTests: XCTestCase {
|
||||
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello "))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testEnglishQuickTypePutsVerbatimFirstAndMarksCorrection() {
|
||||
let typing = makeIsolatedEnglishSession(suite: "english.quicktype.bar.test")
|
||||
|
||||
for character in ["t", "e", "h"] {
|
||||
_ = typing.handleKey(character)
|
||||
}
|
||||
XCTAssertEqual(typing.composition.candidates.first?.role, .verbatim)
|
||||
XCTAssertEqual(
|
||||
typing.composition.candidates.first?.text.lowercased(),
|
||||
"teh"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
typing.composition.candidates.contains {
|
||||
$0.role == .correction && $0.text.lowercased() == "the"
|
||||
}
|
||||
)
|
||||
XCTAssertLessThanOrEqual(typing.composition.candidates.count, EnglishSuggestionEngine.slotCount)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testEnglishSpaceAppliesCorrectionSlotOnly() {
|
||||
let typing = makeIsolatedEnglishSession(suite: "english.quicktype.space.test")
|
||||
|
||||
for character in ["t", "e", "h"] {
|
||||
_ = typing.handleKey(character)
|
||||
}
|
||||
let spaced = typing.handleSpace()
|
||||
XCTAssertEqual(spaced.deleteCount, 3)
|
||||
XCTAssertTrue(spaced.text.lowercased().hasPrefix("the"))
|
||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testEnglishSpaceKeepsVerbatimWhenNoCorrection() {
|
||||
let typing = makeIsolatedEnglishSession(suite: "english.quicktype.verbatim.test")
|
||||
|
||||
for character in ["h", "e", "l"] {
|
||||
_ = typing.handleKey(character)
|
||||
}
|
||||
let spaced = typing.handleSpace()
|
||||
XCTAssertEqual(spaced, .insert(" "))
|
||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||
}
|
||||
|
||||
func testTitleCaseNamesAreNotAutocorrected() {
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
XCTAssertNil(engine.correctionDecision(for: "Rocky", personalTerms: [], learnedBoosts: [:]))
|
||||
XCTAssertNil(engine.correctionDecision(for: "Wang", personalTerms: [], learnedBoosts: [:]))
|
||||
XCTAssertNil(engine.correctionDecision(for: "Chen", personalTerms: [], learnedBoosts: [:]))
|
||||
XCTAssertNil(engine.correctionDecision(for: "Li", personalTerms: [], learnedBoosts: [:]))
|
||||
}
|
||||
|
||||
func testTitleCaseTranspositionStillCorrects() {
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
XCTAssertEqual(
|
||||
engine.correctionDecision(for: "Teh", personalTerms: [], learnedBoosts: [:])?.replacement,
|
||||
"The"
|
||||
)
|
||||
}
|
||||
|
||||
func testProximityCorrectsAdjacentKeyTypos() {
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
XCTAssertEqual(
|
||||
engine.correctionDecision(for: "gppd", personalTerms: [], learnedBoosts: [:])?.replacement,
|
||||
"good"
|
||||
)
|
||||
}
|
||||
|
||||
func testRealWordFormIsNotCorrectedToFrom() {
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
XCTAssertNil(engine.correctionDecision(for: "form", personalTerms: [], learnedBoosts: [:]))
|
||||
}
|
||||
|
||||
func testSupplementaryLexiconBlocksAutocorrect() {
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
XCTAssertNil(
|
||||
engine.correctionDecision(
|
||||
for: "teh",
|
||||
personalTerms: [],
|
||||
learnedBoosts: [:],
|
||||
systemWords: ["teh"]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLearnedDefenseBlocksAutocorrect() {
|
||||
let engine = EnglishSuggestionEngine()
|
||||
engine.prepare()
|
||||
XCTAssertNil(
|
||||
engine.correctionDecision(
|
||||
for: "teh",
|
||||
personalTerms: [],
|
||||
learnedBoosts: ["teh": 5]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testAutocorrectDoesNotBoostReplacement() {
|
||||
let suite = "english.learning.polarity.test"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let store = EnglishLearningStore(defaults: defaults)
|
||||
let typing = TypingSessionController(learningStore: store)
|
||||
typing.suggestionsEnabled = true
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
|
||||
for character in ["t", "e", "h"] {
|
||||
_ = typing.handleKey(character)
|
||||
}
|
||||
_ = typing.handleSpace()
|
||||
XCTAssertEqual(store.boost(for: "the"), 0)
|
||||
XCTAssertEqual(store.boost(for: "teh"), 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRejectingAutocorrectLearnsOriginal() {
|
||||
let suite = "english.learning.defense.test"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let store = EnglishLearningStore(defaults: defaults)
|
||||
let typing = TypingSessionController(learningStore: store)
|
||||
typing.suggestionsEnabled = true
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
|
||||
for character in ["t", "e", "h"] {
|
||||
_ = typing.handleKey(character)
|
||||
}
|
||||
let spaced = typing.handleSpace()
|
||||
if spaced.deleteCount > 0 {
|
||||
_ = typing.handleKey("⌫")
|
||||
XCTAssertGreaterThanOrEqual(store.boost(for: "teh"), 5)
|
||||
}
|
||||
}
|
||||
|
||||
func testQWERTYNeighborsIncludeDiagonals() {
|
||||
let aroundG = EnglishQWERTYProximity.neighbors(of: "g", includingSelf: true)
|
||||
XCTAssertTrue(aroundG.contains("t"))
|
||||
XCTAssertTrue(aroundG.contains("f"))
|
||||
XCTAssertTrue(aroundG.contains("h"))
|
||||
XCTAssertTrue(aroundG.contains("b"))
|
||||
XCTAssertFalse(aroundG.contains("q"))
|
||||
}
|
||||
|
||||
func testBigramsPredictNextWords() {
|
||||
let lexicon = EnglishLexicon()
|
||||
lexicon.prepare()
|
||||
let next = lexicon.nextWords(after: "thank", limit: 4)
|
||||
XCTAssertTrue(next.contains("you"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeIsolatedEnglishSession(suite: String) -> TypingSessionController {
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let store = EnglishLearningStore(defaults: defaults)
|
||||
let typing = TypingSessionController(learningStore: store)
|
||||
typing.suggestionsEnabled = true
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
return typing
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func apply(_ typing: TypingSessionController, _ output: TypingOutput) {
|
||||
typing.syncAutocapitalization(
|
||||
|
||||
Reference in New Issue
Block a user