@@ -18,12 +18,13 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase {
1818 private static func geometry(
1919 caretRect: CGRect = caretRect,
2020 inputFrameRect: CGRect ? = inputFrame,
21+ caretQuality: CaretGeometryQuality = . exact,
2122 focusChangeSequence: UInt64 = 7
2223 ) -> SuggestionOverlayGeometry {
2324 SuggestionOverlayGeometry (
2425 caretRect: caretRect,
2526 inputFrameRect: inputFrameRect,
26- caretQuality: . exact ,
27+ caretQuality: caretQuality ,
2728 observedCharWidth: 8 ,
2829 isRightToLeft: false ,
2930 focusChangeSequence: focusChangeSequence
@@ -330,4 +331,71 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase {
330331 )
331332 )
332333 }
334+
335+ // MARK: - Layout-estimated anchors (TextKit mirror hosts)
336+
337+ func test_layoutEstimatedAnchor_ignoresRawCaretDrift( ) {
338+ // The held anchor came from the hidden text layout; fresh snapshots still carry the RAW
339+ // resolver caret (an AXFrame proportional guess), which routinely sits a word or more
340+ // away. Treating that gap as drift re-presented and re-estimated on every reconcile
341+ // tick, and around accepts it was the jerk-left-then-back: with text and field unchanged
342+ // the estimate cannot move, so the gate must hold.
343+ let current : OverlayState = . visible(
344+ text: " again " ,
345+ geometry: Self . geometry (
346+ caretRect: CGRect ( x: 320 , y: 210 , width: 2 , height: 18 ) ,
347+ caretQuality: . layoutEstimated
348+ ) ,
349+ mode: . inline
350+ )
351+
352+ XCTAssertFalse (
353+ SuggestionOverlayStabilityGate . shouldRePresent (
354+ currentOverlay: current,
355+ newText: " again " ,
356+ newCaretRect: Self . caretRect,
357+ newInputFrameRect: Self . inputFrame,
358+ newFocusChangeSequence: 7
359+ )
360+ )
361+ }
362+
363+ func test_layoutEstimatedAnchor_stillReAnchorsOnFrameTextOrFieldChange( ) {
364+ // The estimate is a pure function of (text, field frame, style): when one of its real
365+ // inputs changes, or the field itself does, the re-anchor must still happen.
366+ let current : OverlayState = . visible(
367+ text: " again " ,
368+ geometry: Self . geometry ( caretQuality: . layoutEstimated) ,
369+ mode: . inline
370+ )
371+
372+ XCTAssertTrue (
373+ SuggestionOverlayStabilityGate . shouldRePresent (
374+ currentOverlay: current,
375+ newText: " again " ,
376+ newCaretRect: Self . caretRect,
377+ newInputFrameRect: Self . inputFrame. offsetBy ( dx: 40 , dy: 0 ) ,
378+ newFocusChangeSequence: 7
379+ ) ,
380+ " A field-frame move re-positions the estimate and must re-anchor "
381+ )
382+ XCTAssertTrue (
383+ SuggestionOverlayStabilityGate . shouldRePresent (
384+ currentOverlay: current,
385+ newText: " different " ,
386+ newCaretRect: Self . caretRect,
387+ newInputFrameRect: Self . inputFrame,
388+ newFocusChangeSequence: 7
389+ )
390+ )
391+ XCTAssertTrue (
392+ SuggestionOverlayStabilityGate . shouldRePresent (
393+ currentOverlay: current,
394+ newText: " again " ,
395+ newCaretRect: Self . caretRect,
396+ newInputFrameRect: Self . inputFrame,
397+ newFocusChangeSequence: 8
398+ )
399+ )
400+ }
333401}
0 commit comments