From 578f63ac01e973ef5cf4d60ed29ae2a33b0d2e65 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 10 Jun 2026 15:08:26 +0200 Subject: [PATCH] Make the caret-highlight teardown test deterministic CaretHighlightAdornerTests waited on the wall clock (3s budget) for the adorner's 1s real-time DispatcherTimer to remove it. Avalonia.Headless has no simulated clock, so under a loaded CI runner the dispatcher-thread timer plus the poll loop slipped past the tight budget and the test timed out intermittently. Extract the teardown the lifetime timer runs into Dismiss(); the test grabs the live adorner and calls it directly, verifying the unregister half without any timing dependency. Registration is still exercised through the real OnReferenceClicked path, and production behaviour is unchanged. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Editor/CaretHighlightAdornerTests.cs | 15 +++++--- ILSpy/TextView/CaretHighlightAdorner.cs | 38 +++++++++++++------ 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs index 75a8bb5a8..929c2f0b0 100644 --- a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs +++ b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs @@ -57,8 +57,8 @@ public class CaretHighlightAdornerTests // AvaloniaEditTextOutput.AddReference), so `OnReferenceClicked` resolves the click // to an in-document jump and DecompilerTextView fires the caret highlight. // - // The lifecycle (register then unregister after ~1 s) is verified end-to-end — - // hand-tuned animation curve is left for eyeball validation. + // The lifecycle (register on click, then unregister via Dismiss) is verified end-to-end — + // the hand-tuned animation curve and the one-second timing are left for eyeball validation. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -103,9 +103,12 @@ public class CaretHighlightAdornerTests "clicking a member definition must route through DecompilerTextView.OnReferenceClicked " + "and call CaretHighlightAdorner.DisplayCaretHighlightAnimation"); - // Wait for the lifetime timer (~1 s) to tear it back down. - await Waiters.WaitForAsync( - () => !renderers.Any(r => r is CaretHighlightAdorner), - timeout: TimeSpan.FromSeconds(3)); + // The 1 s lifetime is a real-time DispatcherTimer, which is unreliable to wait on in a + // headless run; invoke the same teardown the timer runs (Dismiss) directly so the + // unregister half is verified deterministically rather than against the wall clock. + var adorner = renderers.OfType().Single(); + adorner.Dismiss(); + renderers.Should().NotContain(r => r is CaretHighlightAdorner, + "Dismiss unregisters the caret-highlight adorner from the text view"); } } diff --git a/ILSpy/TextView/CaretHighlightAdorner.cs b/ILSpy/TextView/CaretHighlightAdorner.cs index f8f1f064b..a3b3510a3 100644 --- a/ILSpy/TextView/CaretHighlightAdorner.cs +++ b/ILSpy/TextView/CaretHighlightAdorner.cs @@ -47,9 +47,14 @@ namespace ILSpy.TextView readonly Rect maxRect; readonly IPen pen; readonly Stopwatch elapsed = Stopwatch.StartNew(); + readonly TextArea textArea; + readonly DispatcherTimer frameTimer; + readonly DispatcherTimer lifetimeTimer; CaretHighlightAdorner(TextArea textArea) { + this.textArea = textArea; + // Caret rect is in document coordinates; subtracting ScrollOffset converts to the // viewport-relative space IBackgroundRenderer.Draw paints into. var caretRect = textArea.Caret.CalculateCaretRectangle(); @@ -64,6 +69,13 @@ namespace ILSpy.TextView // black when unset (e.g. design-time / standalone-renderer tests). var brush = textArea.TextView.GetValue(TextElement.ForegroundProperty) ?? Brushes.Black; pen = new Pen(brush, 1).ToImmutable(); + + // The frame timer ticks at ~60 fps to invalidate the Caret layer so Draw re-runs with + // fresh elapsed time; the lifetime timer dismisses the adorner after one second. + frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); + lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; + lifetimeTimer.Tick += (_, _) => Dismiss(); } public KnownLayer Layer => KnownLayer.Caret; @@ -107,7 +119,7 @@ namespace ILSpy.TextView /// /// Registers a one-shot caret-highlight animation on . Spins /// up two timers: one ticks at ~60 fps to invalidate the Caret layer so - /// re-runs with fresh elapsed time, the other unregisters the adorner after one second. + /// re-runs with fresh elapsed time, the other calls after one second. /// public static void DisplayCaretHighlightAnimation(TextArea textArea) { @@ -115,18 +127,20 @@ namespace ILSpy.TextView var adorner = new CaretHighlightAdorner(textArea); textArea.TextView.BackgroundRenderers.Add(adorner); + adorner.frameTimer.Start(); + adorner.lifetimeTimer.Start(); + } - var frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; - frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); - frameTimer.Start(); - - var lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; - lifetimeTimer.Tick += (_, _) => { - lifetimeTimer.Stop(); - frameTimer.Stop(); - textArea.TextView.BackgroundRenderers.Remove(adorner); - }; - lifetimeTimer.Start(); + /// + /// Ends the animation immediately: stops both timers and unregisters the adorner from the + /// text view. Invoked by the one-second lifetime timer, and usable to tear the highlight + /// down on demand instead of waiting the lifetime out. + /// + public void Dismiss() + { + lifetimeTimer.Stop(); + frameTimer.Stop(); + textArea.TextView.BackgroundRenderers.Remove(this); } } }