diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index bd436b0c..1d3319c1 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -390,9 +390,9 @@ extension SuggestionCoordinator { return } - // Streaming half of the seam guard: the pure junk-run rule only. The spell-lookup half - // is an XPC and partials drain at token cadence, so it stays on the final apply, which - // authoritatively replaces or suppresses whatever streamed. + // Junk checks remain cheap enough for every partial. The first generated word is buffered + // until its boundary arrives, then its spelling decision is cached for the generation so + // the AppKit/XPC lookup never runs at token cadence. guard CompletionSeamGuard.allowsStreamedPartial( precedingText: liveContext.precedingText, completion: partial.text @@ -400,6 +400,27 @@ extension SuggestionCoordinator { return } + switch suggestionStreamingState.leadingWordGateState { + case .pending: + switch CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: liveContext.precedingText, + completion: partial.text, + spellingAssessment: { self.completionSpellingAssessment(for: $0) } + ) { + case .wait: + return + case .allow: + suggestionStreamingState.resolveLeadingWordGate(.allowed) + case .suppress: + suggestionStreamingState.resolveLeadingWordGate(.suppressed) + return + } + case .suppressed: + return + case .allowed: + break + } + _ = interactionState.startSession( fullText: partial.text, liveContext: liveContext, @@ -484,6 +505,20 @@ extension SuggestionCoordinator { ?? spellChecker.bestCorrection(for: word) } + /// Collapses native typo detection and correction availability into the seam guard's single + /// spelling contract. Keeping this adapter at the orchestration boundary lets the pure guard + /// express its policy without knowing about `NSSpellChecker` or accepting contradictory hooks. + private func completionSpellingAssessment( + for word: String + ) -> CompletionSeamGuard.SpellingAssessment { + guard spellChecker.isTypo(word) else { + return .known + } + return spellChecker.bestCorrection(for: word) == nil + ? .uncorrectableTypo + : .correctableTypo + } + /// Replaces a completed typo after Space without creating a visible correction session. /// /// Automatic mutation is intentionally limited to a committed word boundary. The shared planner @@ -608,10 +643,16 @@ extension SuggestionCoordinator { } private static func seamSuppressionReason(for verdict: CompletionSeamGuard.Verdict) -> String { - if case .seamMisspelling = verdict { + switch verdict { + case .seamMisspelling: return "seamMisspelling" + case .leadingWordMisspelling: + return "leadingWordMisspelling" + case .junkPunctuationRun: + return "seamJunkPunctuationRun" + case .allow: + return "unknownSeamGuardSuppression" } - return "seamJunkPunctuationRun" } /// Promotes a generated result to `ready` only when it is still fresh for the current field. @@ -730,13 +771,14 @@ extension SuggestionCoordinator { return } - // Last line of defense before display: junk punctuation runs and mid-word splices that - // misspell the word being typed read as glitches, so showing nothing beats showing them. - // The spell lookup runs at most once per generation and only in the mid-word case. + // Last line of defense before display: junk punctuation runs, mid-word splices, and newly + // started words that the native checker can actually correct read as glitches, so showing + // nothing beats showing them. The leading-word check is intentionally fail-open for names + // and jargon with no correction candidate. let seamVerdict = CompletionSeamGuard.verdict( precedingText: liveContext.precedingText, completion: result.text, - isKnownWord: { !spellChecker.isTypo($0) } + spellingAssessment: { self.completionSpellingAssessment(for: $0) } ) if seamVerdict != .allow { clearSuggestion() diff --git a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift index d1d520cc..5b238e3f 100644 --- a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift +++ b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift @@ -1,25 +1,48 @@ import Foundation -/// Post-generation guard for the two classic visible failures at the caret seam: junk punctuation -/// runs ("....", "$$$$") and mid-word splices that turn the word being typed into a misspelling -/// ("gre" + "atful"). Showing nothing beats showing either, and both checks are pure string work -/// on a single short completion, so the guard costs microseconds once per generation. +/// Post-generation guard for visible output failures: junk punctuation runs ("....", "$$$$"), +/// mid-word splices that misspell the joined word ("gre" + "atful"), and correctable misspellings +/// in the first generated word. Showing nothing beats presenting any of these as an insertion. /// /// Both rules are deliberately narrow so they fire rarely: /// /// - **Junk run**: a run of four or more identical punctuation/symbol characters inside the /// completion, unless the run merely extends an identical run the user already has at the caret /// (continuing an existing `----` divider is legitimate). -/// - **Seam misspelling**: only in the mid-word case (caret inside a word, completion starts with -/// word characters), the joined word formed across the seam must be known to the spell checker. -/// Skipped for capitalized words (names and brands are routinely out-of-dictionary), for short -/// joins (under four letters), for words with digits, and for CJK text (no space-delimited word -/// boundaries, and the dictionaries do not cover it). +/// - **Seam misspelling**: in the mid-word case (caret inside a word, completion starts with word +/// characters), the joined word formed across the seam must be known to the spell checker. +/// - **Leading-word misspelling**: when the completion starts a new word, the first generated word +/// is checked only when the caller can both identify it as a typo and offer a correction. This is +/// deliberately narrower than dictionary membership so names, jargon, and model vocabulary still +/// pass through when the native checker has no actionable fix. +/// +/// Both spelling checks skip capitalized words (names and brands are routinely out-of-dictionary), +/// short words (under four letters), words with digits, and CJK text (no space-delimited word +/// boundaries, and the dictionaries do not cover it). nonisolated enum CompletionSeamGuard { + /// One explicit spelling result keeps callers from supplying contradictory combinations such + /// as "typo without a correction callback". The guard only needs to distinguish actionable + /// typos from unknown-but-uncorrectable vocabulary at a leading-word boundary. + enum SpellingAssessment: Equatable { + case known + case uncorrectableTypo + case correctableTypo + } + enum Verdict: Equatable { case allow case junkPunctuationRun case seamMisspelling(word: String) + case leadingWordMisspelling(word: String) + } + + /// Streaming must not expose the first generated word until it is complete enough to assess. + /// Once this resolves to allow or suppress, the coordinator caches it for the generation so + /// `NSSpellChecker` is never called at token cadence. + enum StreamedLeadingWordVerdict: Equatable { + case wait + case allow + case suppress } /// Identical punctuation/symbol characters in a row that count as junk when freshly introduced. @@ -28,19 +51,19 @@ nonisolated enum CompletionSeamGuard { /// Joined seam words shorter than this are too ambiguous to judge ("a" + "t"). private static let minimumSeamWordLength = 4 - /// Streaming-path variant: only the pure junk-run rule. Partials drain at token cadence, so - /// the spell-lookup half of the guard (an XPC round trip) stays off that path; the full - /// verdict still gates the final result, which authoritatively replaces whatever streamed. + /// Cheap streaming-path junk rule. The separate leading-word streaming verdict buffers until a + /// complete word exists, then performs and caches exactly one spelling decision. static func allowsStreamedPartial(precedingText: String, completion: String) -> Bool { !introducesJunkPunctuationRun(precedingText: precedingText, completion: completion) } - /// `isKnownWord` is injected so the pure rule stays testable and the caller picks the spell - /// checking backend; it is only invoked when the mid-word rule actually applies. + /// The spelling assessment is injected so the pure rule stays testable and the caller picks the + /// backend. A single result describes the whole invariant: mid-word seams reject any typo, + /// while newly generated words reject only correctable typos. static func verdict( precedingText: String, completion: String, - isKnownWord: (String) -> Bool + spellingAssessment: (String) -> SpellingAssessment ) -> Verdict { if introducesJunkPunctuationRun(precedingText: precedingText, completion: completion) { return .junkPunctuationRun @@ -49,13 +72,41 @@ nonisolated enum CompletionSeamGuard { if let seamWord = misspellingCandidateSeamWord( precedingText: precedingText, completion: completion - ), !isKnownWord(seamWord) { + ), spellingAssessment(seamWord) != .known { return .seamMisspelling(word: seamWord) } + if case let .candidate(leadingWord, _) = leadingWordProbe( + precedingText: precedingText, + completion: completion + ), spellingAssessment(leadingWord) == .correctableTypo { + return .leadingWordMisspelling(word: leadingWord) + } + return .allow } + /// Leading-word half of the streaming guard. Incomplete first words remain buffered; testing a + /// prefix such as `ecr` would create false positives and repeating the lookup on every token + /// would put an AppKit/XPC call on the hot streaming path. + static func streamedLeadingWordVerdict( + precedingText: String, + completion: String, + spellingAssessment: (String) -> SpellingAssessment + ) -> StreamedLeadingWordVerdict { + switch leadingWordProbe(precedingText: precedingText, completion: completion) { + case .notApplicable: + return .allow + case .incomplete: + return .wait + case let .candidate(word, isComplete): + guard isComplete else { + return .wait + } + return spellingAssessment(word) == .correctableTypo ? .suppress : .allow + } + } + // MARK: - Junk punctuation runs private static func introducesJunkPunctuationRun( @@ -117,6 +168,90 @@ nonisolated enum CompletionSeamGuard { return seamWord } + private enum LeadingWordProbe { + case notApplicable + case incomplete + case candidate(word: String, isComplete: Bool) + } + + /// Finds the first lexical word after boundary whitespace or punctuation. Apostrophes and + /// hyphens between letters remain part of the word (`doesn't`, `state-of-the-art`) so the spell + /// checker sees the same natural-language token the user sees. + private static func leadingWordProbe( + precedingText: String, + completion: String + ) -> LeadingWordProbe { + guard !completion.isEmpty else { + return .incomplete + } + + guard let wordStart = completion.firstIndex(where: { $0.isLetter }) else { + // Whitespace and opening punctuation may arrive before the first streamed word. Digits + // make the token code/version-like, so the conservative spelling rule does not apply. + return completion.allSatisfy({ $0.isWhitespace || $0.isPunctuation || $0.isSymbol }) + ? .incomplete + : .notApplicable + } + + let boundaryPrefix = completion[..= minimumSeamWordLength else { + return isComplete ? .notApplicable : .incomplete + } + return .candidate(word: word, isComplete: isComplete) + } + + /// Apostrophes and hyphens bind adjacent letter runs into one natural-language token. + private static func isWordConnector(_ character: Character) -> Bool { + character == "'" || character == "’" || character == "-" + } + private static func trailingRunLength(of text: String, character: Character) -> Int { text.reversed().prefix(while: { $0 == character }).count } diff --git a/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift b/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift index 940b4477..8b326e54 100644 --- a/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift +++ b/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift @@ -6,6 +6,12 @@ /// monotonically. It does not schedule work or render UI; the coordinator remains responsible for /// those side effects. struct SuggestionStreamingState { + enum LeadingWordGateState: Equatable { + case pending + case allowed + case suppressed + } + /// One partial paired with the replaceable-work identity that produced it. struct PendingPartial { let result: SuggestionResult @@ -15,6 +21,7 @@ struct SuggestionStreamingState { private(set) var pendingPartial: PendingPartial? private(set) var isDrainScheduled = false private(set) var renderedText: String? + private(set) var leadingWordGateState: LeadingWordGateState = .pending /// Starts a new stream without clearing an already-enqueued drain callback. /// @@ -24,6 +31,7 @@ struct SuggestionStreamingState { mutating func beginGeneration() { renderedText = nil pendingPartial = nil + leadingWordGateState = .pending } /// Stores the newest partial and returns whether the coordinator must schedule a drain. @@ -58,6 +66,13 @@ struct SuggestionStreamingState { renderedText = text } + /// Caches the first-word spelling decision so an allowed stream does not repeat an AppKit/XPC + /// lookup for every subsequent token. Suppression is likewise terminal for this generation. + mutating func resolveLeadingWordGate(_ state: LeadingWordGateState) { + precondition(state != .pending, "The leading-word gate can only resolve to a terminal state") + leadingWordGateState = state + } + /// Drops state associated with a torn-down suggestion session. /// /// As with `beginGeneration`, a scheduled callback remains responsible for clearing the drain @@ -65,5 +80,6 @@ struct SuggestionStreamingState { mutating func clearSession() { renderedText = nil pendingPartial = nil + leadingWordGateState = .pending } } diff --git a/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift b/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift index b6eb36eb..c95ad8a2 100644 --- a/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift +++ b/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift @@ -119,7 +119,14 @@ final class LlamaSuggestionEvalTests: XCTestCase { let verdict = CompletionSeamGuard.verdict( precedingText: evalCase.precedingText, completion: candidate, - isKnownWord: { !spellChecker.isTypo($0) } + spellingAssessment: { word in + guard spellChecker.isTypo(word) else { + return .known + } + return spellChecker.bestCorrection(for: word) == nil + ? .uncorrectableTypo + : .correctableTypo + } ) if verdict != .allow { shownText = nil diff --git a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift index 675e2a56..20883f79 100644 --- a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift +++ b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift @@ -6,13 +6,17 @@ import XCTest /// continuations that surround them. Every guard must fire rarely; most of these tests are /// allow-cases for exactly that reason. final class CompletionSeamGuardTests: XCTestCase { - /// A stub dictionary: the listed words are known, everything else is a misspelling. - private func knowing(_ words: Set) -> (String) -> Bool { - { words.contains($0.lowercased()) } + /// A stub dictionary: the listed words are known, everything else is an uncorrectable typo. + private func knowing( + _ words: Set + ) -> (String) -> CompletionSeamGuard.SpellingAssessment { + { words.contains($0.lowercased()) ? .known : .uncorrectableTypo } } - private let knowsEverything: (String) -> Bool = { _ in true } - private let knowsNothing: (String) -> Bool = { _ in false } + private let knowsEverything: (String) -> CompletionSeamGuard.SpellingAssessment = { _ in .known } + private let knowsNothing: (String) -> CompletionSeamGuard.SpellingAssessment = { + _ in .uncorrectableTypo + } // MARK: - Junk punctuation runs @@ -21,7 +25,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Wait", completion: " what....", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -32,7 +36,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Price: ", completion: "$$$$", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -44,7 +48,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Well", completion: "... maybe", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .allow ) @@ -57,7 +61,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Hello.", completion: "....", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -80,7 +84,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "----", completion: "------", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .allow ) @@ -91,7 +95,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "----", completion: " section ======", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -102,7 +106,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "That is so", completion: " coooool", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .allow ) @@ -115,7 +119,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "I am so gre", completion: "atful for this", - isKnownWord: knowing(["great", "grateful"]) + spellingAssessment: knowing(["great", "grateful"]) ), .seamMisspelling(word: "greatful") ) @@ -126,7 +130,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "I am so gre", completion: "at to hear it", - isKnownWord: knowing(["great"]) + spellingAssessment: knowing(["great"]) ), .allow ) @@ -138,7 +142,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "I am so ", completion: "greatful", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -150,7 +154,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Ask Cota", completion: "bby about it", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -161,7 +165,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "a", completion: "t the office", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -174,7 +178,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "version 2", completion: "024 release", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -185,7 +189,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "これはとても良", completion: "い天気ですね", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -196,7 +200,190 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Thanks again for your help", completion: " with the move last weekend.", - isKnownWord: knowing(["with"]) + spellingAssessment: knowing(["with"]) + ), + .allow + ) + } + + // MARK: - Leading-word misspellings + + /// A lowercase generated typo is hidden only when the checker has an actionable correction. + func testMisspelledLeadingWordWithCorrectionIsSuppressed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Je veux ", + completion: "ecrir plus vite", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .leadingWordMisspelling(word: "ecrir") + ) + } + + /// Unknown vocabulary remains visible when the checker cannot offer a replacement. + func testLeadingWordWithoutCorrectionIsAllowed() { + // An unknown name or domain term should not disappear merely because the native checker has + // no suggestion for it. + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Use ", + completion: "cotabby avec soin", + spellingAssessment: { $0 == "cotabby" ? .uncorrectableTypo : .known } + ), + .allow + ) + } + + /// Capitalized names bypass spelling entirely to avoid dictionary-driven false positives. + func testCapitalizedLeadingWordIsAllowed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Ask ", + completion: "Cotypist about it", + spellingAssessment: { _ in + XCTFail("capitalized leading words must not reach the spell checker") + return .correctableTypo + } + ), + .allow + ) + } + + /// Mid-word completions assess the joined word rather than reclassifying the generated suffix. + func testMidWordCompletionOnlyAssessesTheJoinedSeamWord() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Je veux ecr", + completion: "irregular", + spellingAssessment: { word in + XCTAssertEqual(word, "ecrirregular") + return .known + } + ), + .allow + ) + } + + /// Opening quotation marks still leave the following letters at a valid word boundary. + func testQuotedLeadingWordIsSuppressed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Il répond ", + completion: "“ecrir” plus vite", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .leadingWordMisspelling(word: "ecrir") + ) + } + + /// Punctuation introduced after existing text cannot hide the first generated typo. + func testParenthesizedLeadingWordAfterTextIsSuppressed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Il répond", + completion: ": (ecrir) plus vite", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .leadingWordMisspelling(word: "ecrir") + ) + } + + /// Interior apostrophes stay attached so a contraction is never checked as a truncated stem. + func testContractionIsAssessedAsOneWord() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "It ", + completion: "doesn't matter", + spellingAssessment: { word in + XCTAssertEqual(word, "doesn't") + return .known + } + ), + .allow + ) + } + + /// A digit makes the whole leading token code/version-like, including its letter prefix. + func testLetterAndDigitLeadingTokenIsAllowedWithoutSpellLookup() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Use ", + completion: "ecrir2 here", + spellingAssessment: { _ in + XCTFail("letter-and-digit tokens must bypass spelling") + return .correctableTypo + } + ), + .allow + ) + } + + // MARK: - Streamed leading words + + /// Streaming buffers a lowercase prefix because checking it before its boundary is unreliable. + func testStreamedLeadingWordWaitsUntilItsBoundaryArrives() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Je veux ", + completion: "ecrir", + spellingAssessment: { _ in + XCTFail("an incomplete streamed word must not reach the spell checker") + return .known + } + ), + .wait + ) + } + + /// A trailing apostrophe may still join the next letters, so it cannot finalize the word. + func testStreamedContractionWaitsAfterADanglingApostrophe() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "It ", + completion: "does'", + spellingAssessment: { _ in + XCTFail("a dangling apostrophe may still continue the streamed word") + return .known + } + ), + .wait + ) + } + + /// Once its boundary arrives, a correctable streamed typo is suppressed before presentation. + func testStreamedCorrectableLeadingWordIsSuppressedAtItsBoundary() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Je veux ", + completion: "ecrir ", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .suppress + ) + } + + /// A known streamed word becomes presentable as soon as its boundary makes it complete. + func testStreamedKnownLeadingWordIsAllowedAtItsBoundary() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Je veux ", + completion: "écrire ", + spellingAssessment: { $0 == "écrire" ? .known : .correctableTypo } + ), + .allow + ) + } + + /// Streaming also exempts a completed letter-and-digit token without consulting spelling. + func testStreamedLetterAndDigitLeadingTokenIsAllowedWithoutSpellLookup() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Use ", + completion: "ecrir2 ", + spellingAssessment: { _ in + XCTFail("letter-and-digit tokens must bypass streamed spelling") + return .correctableTypo + } ), .allow ) diff --git a/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift b/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift index b970aa8f..0e7f4760 100644 --- a/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift +++ b/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift @@ -25,10 +25,12 @@ final class SuggestionStreamingStateTests: XCTestCase { XCTAssertTrue(state.enqueue(result(text: " old"), workID: 1)) state.recordRendered(" old") + state.resolveLeadingWordGate(.allowed) state.beginGeneration() XCTAssertNil(state.renderedText) XCTAssertNil(state.pendingPartial) + XCTAssertEqual(state.leadingWordGateState, .pending) XCTAssertTrue(state.isDrainScheduled) XCTAssertFalse(state.enqueue(result(text: " new"), workID: 2)) @@ -41,11 +43,13 @@ final class SuggestionStreamingStateTests: XCTestCase { var state = SuggestionStreamingState() state.enqueue(result(text: " pending"), workID: 4) state.recordRendered(" pending") + state.resolveLeadingWordGate(.suppressed) state.clearSession() XCTAssertNil(state.renderedText) XCTAssertNil(state.pendingPartial) + XCTAssertEqual(state.leadingWordGateState, .pending) XCTAssertTrue(state.isDrainScheduled) XCTAssertNil(state.drain()) XCTAssertFalse(state.isDrainScheduled) @@ -61,6 +65,15 @@ final class SuggestionStreamingStateTests: XCTestCase { XCTAssertFalse(state.canRender(" wild")) } + /// A terminal first-word verdict remains reusable until the next generation resets the state. + func test_leadingWordGateCachesATerminalDecisionForTheGeneration() { + var state = SuggestionStreamingState() + + XCTAssertEqual(state.leadingWordGateState, .pending) + state.resolveLeadingWordGate(.allowed) + XCTAssertEqual(state.leadingWordGateState, .allowed) + } + private func result(text: String) -> SuggestionResult { SuggestionResult( generation: 7,