// EnglishTypingTests.swift // OSGKeyboard · Ext unit tests // // Lexicon / accent / suggestion ranking for the English typing path. @testable import OSGKeyboardShared import XCTest final class EnglishTypingTests: XCTestCase { func testLexiconLoadsAndCompletesPrefix() { let lexicon = EnglishLexicon() lexicon.prepare() 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 = makeTypingSession(language: .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 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() { let engine = EnglishSuggestionEngine() engine.prepare() let decision = engine.correctionDecision( for: "teh", personalTerms: ["teh"], learnedBoosts: [:] ) XCTAssertNil(decision) } func testSuggestionEngineCompletionsPreferPersonalTerms() { let engine = EnglishSuggestionEngine() engine.prepare() let composition = engine.compositionWhileTyping( EnglishSuggestionContext( currentWord: "osg", personalTerms: ["OSGKeyboard"], learnedBoosts: [:] ) ) 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() { XCTAssertTrue( TypingAutocapitalization.shouldCapitalize(precedingText: nil, mode: .sentences) ) XCTAssertTrue( TypingAutocapitalization.shouldCapitalize(precedingText: "", mode: .sentences) ) XCTAssertTrue( TypingAutocapitalization.shouldCapitalize(precedingText: "Hello. ", mode: .sentences) ) XCTAssertTrue( TypingAutocapitalization.shouldCapitalize(precedingText: "Hello!\n", mode: .sentences) ) // System keyboard (.sentences): Return starts a new line → capitalize. // Notes-style document context after Return is typically "…\n". XCTAssertTrue( TypingAutocapitalization.shouldCapitalize(precedingText: "Hello\n", mode: .sentences), "Return/newline must arm Shift (Notes multi-line)" ) XCTAssertTrue( TypingAutocapitalization.shouldCapitalize(precedingText: "Hello\n\n", mode: .sentences) ) XCTAssertFalse( TypingAutocapitalization.shouldCapitalize(precedingText: "Hello ", mode: .sentences) ) XCTAssertTrue( TypingAutocapitalization.shouldCapitalize(precedingText: "Hello ", mode: .words) ) XCTAssertFalse( TypingAutocapitalization.shouldCapitalize(precedingText: nil, mode: .none) ) } @MainActor func testEnglishShiftArmsAfterReturnLikeNotes() { // Simulates Notes: proxy preceding text gains a trailing newline after Return. var preceding = "Hello" let typing = makeTypingSession() typing.precedingTextProvider = { preceding } typing.autocapitalizationModeProvider = { .sentences } typing.syncAutocapitalization() XCTAssertFalse(typing.shiftActive, "mid-word should not arm Shift") _ = typing.handleReturn() preceding = "Hello\n" // host document after inserting newline typing.syncAutocapitalization(accountingForInsert: "\n") XCTAssertTrue( typing.shiftActive, "after Return, Shift must light for next line (system sentences behavior)" ) XCTAssertEqual(typing.keyRows.first?.first, "Q") } @MainActor func testEnglishShiftArmsWhenProxyLagsAfterReturn() { // Notes often still reports pre-Return context right after insertText("\n"). var preceding = "Hello" let typing = makeTypingSession() typing.precedingTextProvider = { preceding } typing.autocapitalizationModeProvider = { .sentences } typing.syncAutocapitalization() _ = typing.handleReturn() // Proxy intentionally stale — still "Hello" without "\n". typing.syncAutocapitalization(accountingForInsert: "\n") XCTAssertTrue(typing.shiftActive) XCTAssertEqual(typing.keyRows.first?.first, "Q") } @MainActor func testEnglishShiftArmsWhenProxyLagsAfterPeriod() { var preceding = "Hello" let typing = makeTypingSession() typing.precedingTextProvider = { preceding } typing.autocapitalizationModeProvider = { .sentences } typing.syncAutocapitalization() _ = typing.handleKey(".") typing.syncAutocapitalization(accountingForInsert: ".") XCTAssertTrue(typing.shiftActive) } @MainActor func testEnglishIdleShowsNoCandidatesUntilLetterTyped() { let typing = makeTypingSession() typing.enterTypingMode() XCTAssertTrue(typing.composition.candidates.isEmpty) _ = typing.handleKey("h") XCTAssertFalse(typing.composition.candidates.isEmpty) XCTAssertEqual(typing.composition.preedit, "h") } func testEnglishTypingHotwordsFilterOutChineseTerms() { var dictionary = PersonalDictionary() _ = dictionary.upsertManual(term: "张三") _ = dictionary.upsertManual(term: "OSGKeyboard") _ = dictionary.upsertManual(term: "微信") guard let wechatID = dictionary.entry(matchingTerm: "微信")?.id else { return XCTFail("expected 微信 entry") } dictionary.updateAliases(for: wechatID, aliases: ["WeChat"]) let hotwords = dictionary.englishTypingHotwords() XCTAssertTrue(hotwords.contains("OSGKeyboard")) XCTAssertTrue(hotwords.contains("WeChat")) XCTAssertFalse(hotwords.contains(where: { $0.contains("张") || $0.contains("微") })) XCTAssertFalse(PersonalDictionary.isEnglishTypingHotword("你好")) XCTAssertTrue(PersonalDictionary.isEnglishTypingHotword("GPT-4")) } @MainActor func testEnglishTypingEmitsCompletionsIntoComposition() { let typing = makeTypingSession() typing.enterTypingMode() _ = typing.handleKey("h") _ = typing.handleKey("e") _ = typing.handleKey("l") XCTAssertFalse(typing.composition.candidates.isEmpty) XCTAssertEqual(typing.composition.preedit, "hel") } @MainActor func testOldWordBackspaceRehydratesSuggestionsFromDocumentContext() { var preceding = "board" let following = "\n" let typing = makeTypingSession() typing.precedingTextProvider = { preceding } typing.followingTextProvider = { following } typing.enterTypingMode() typing.synchronizeEnglishDocumentContext(caretMoved: true) func apply(_ output: TypingOutput) { for _ in 0.. 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 = makeTypingSession(learningStore: store) typing.enterTypingMode() return typing } @MainActor private func makeTypingSession( learningStore: EnglishLearningStore? = nil, language: TypingInputLanguage = .english ) -> TypingSessionController { let store = learningStore ?? EnglishLearningStore( defaults: UserDefaults(suiteName: "english.tests.\(UUID().uuidString)")! ) let typing = TypingSessionController( engine: { NoopRimeEngine() }, learningStore: store ) typing.suggestionsEnabled = true _ = typing.setLanguage(language) return typing } @MainActor private func apply(_ typing: TypingSessionController, _ output: TypingOutput) { typing.syncAutocapitalization( accountingForInsert: output.text, deleteCount: output.deleteCount ) } } @MainActor private final class NoopRimeEngine: RimeEngineBridging { var composition: TypingComposition = .empty var isReady = true var schema: TypingInputSchema = .fullPinyin func prepare() async throws {} func teardown() { composition = .empty } func setLanguage(_ language: TypingInputLanguage) {} @discardableResult func setSchema(_ schema: TypingInputSchema) -> Bool { self.schema = schema return true } func processCharacter(_ character: Character) -> String? { nil } func processBackspace() -> String? { nil } func processSpace() -> String? { " " } func processReturn() -> String? { "\n" } func selectCandidate(at index: Int) -> String { "" } func flushPreedit() -> String { "" } func clearComposition() { composition = .empty } }