Browse Source

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
pull/3756/head
Siegfried Pammer 4 weeks ago
parent
commit
578f63ac01
  1. 15
      ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs
  2. 34
      ILSpy/TextView/CaretHighlightAdorner.cs

15
ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs

@ -57,8 +57,8 @@ public class CaretHighlightAdornerTests
// AvaloniaEditTextOutput.AddReference), so `OnReferenceClicked` resolves the click // AvaloniaEditTextOutput.AddReference), so `OnReferenceClicked` resolves the click
// to an in-document jump and DecompilerTextView fires the caret highlight. // to an in-document jump and DecompilerTextView fires the caret highlight.
// //
// The lifecycle (register then unregister after ~1 s) is verified end-to-end — // The lifecycle (register on click, then unregister via Dismiss) is verified end-to-end —
// hand-tuned animation curve is left for eyeball validation. // the hand-tuned animation curve and the one-second timing are left for eyeball validation.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
@ -103,9 +103,12 @@ public class CaretHighlightAdornerTests
"clicking a member definition must route through DecompilerTextView.OnReferenceClicked " "clicking a member definition must route through DecompilerTextView.OnReferenceClicked "
+ "and call CaretHighlightAdorner.DisplayCaretHighlightAnimation"); + "and call CaretHighlightAdorner.DisplayCaretHighlightAnimation");
// Wait for the lifetime timer (~1 s) to tear it back down. // The 1 s lifetime is a real-time DispatcherTimer, which is unreliable to wait on in a
await Waiters.WaitForAsync( // headless run; invoke the same teardown the timer runs (Dismiss) directly so the
() => !renderers.Any(r => r is CaretHighlightAdorner), // unregister half is verified deterministically rather than against the wall clock.
timeout: TimeSpan.FromSeconds(3)); var adorner = renderers.OfType<CaretHighlightAdorner>().Single();
adorner.Dismiss();
renderers.Should().NotContain(r => r is CaretHighlightAdorner,
"Dismiss unregisters the caret-highlight adorner from the text view");
} }
} }

34
ILSpy/TextView/CaretHighlightAdorner.cs

@ -47,9 +47,14 @@ namespace ILSpy.TextView
readonly Rect maxRect; readonly Rect maxRect;
readonly IPen pen; readonly IPen pen;
readonly Stopwatch elapsed = Stopwatch.StartNew(); readonly Stopwatch elapsed = Stopwatch.StartNew();
readonly TextArea textArea;
readonly DispatcherTimer frameTimer;
readonly DispatcherTimer lifetimeTimer;
CaretHighlightAdorner(TextArea textArea) CaretHighlightAdorner(TextArea textArea)
{ {
this.textArea = textArea;
// Caret rect is in document coordinates; subtracting ScrollOffset converts to the // Caret rect is in document coordinates; subtracting ScrollOffset converts to the
// viewport-relative space IBackgroundRenderer.Draw paints into. // viewport-relative space IBackgroundRenderer.Draw paints into.
var caretRect = textArea.Caret.CalculateCaretRectangle(); var caretRect = textArea.Caret.CalculateCaretRectangle();
@ -64,6 +69,13 @@ namespace ILSpy.TextView
// black when unset (e.g. design-time / standalone-renderer tests). // black when unset (e.g. design-time / standalone-renderer tests).
var brush = textArea.TextView.GetValue(TextElement.ForegroundProperty) ?? Brushes.Black; var brush = textArea.TextView.GetValue(TextElement.ForegroundProperty) ?? Brushes.Black;
pen = new Pen(brush, 1).ToImmutable(); 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; public KnownLayer Layer => KnownLayer.Caret;
@ -107,7 +119,7 @@ namespace ILSpy.TextView
/// <summary> /// <summary>
/// Registers a one-shot caret-highlight animation on <paramref name="textArea"/>. Spins /// Registers a one-shot caret-highlight animation on <paramref name="textArea"/>. Spins
/// up two timers: one ticks at ~60 fps to invalidate the Caret layer so <see cref="Draw"/> /// up two timers: one ticks at ~60 fps to invalidate the Caret layer so <see cref="Draw"/>
/// re-runs with fresh elapsed time, the other unregisters the adorner after one second. /// re-runs with fresh elapsed time, the other calls <see cref="Dismiss"/> after one second.
/// </summary> /// </summary>
public static void DisplayCaretHighlightAnimation(TextArea textArea) public static void DisplayCaretHighlightAnimation(TextArea textArea)
{ {
@ -115,18 +127,20 @@ namespace ILSpy.TextView
var adorner = new CaretHighlightAdorner(textArea); var adorner = new CaretHighlightAdorner(textArea);
textArea.TextView.BackgroundRenderers.Add(adorner); textArea.TextView.BackgroundRenderers.Add(adorner);
adorner.frameTimer.Start();
adorner.lifetimeTimer.Start();
}
var frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; /// <summary>
frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); /// Ends the animation immediately: stops both timers and unregisters the adorner from the
frameTimer.Start(); /// text view. Invoked by the one-second lifetime timer, and usable to tear the highlight
/// down on demand instead of waiting the lifetime out.
var lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; /// </summary>
lifetimeTimer.Tick += (_, _) => { public void Dismiss()
{
lifetimeTimer.Stop(); lifetimeTimer.Stop();
frameTimer.Stop(); frameTimer.Stop();
textArea.TextView.BackgroundRenderers.Remove(adorner); textArea.TextView.BackgroundRenderers.Remove(this);
};
lifetimeTimer.Start();
} }
} }
} }

Loading…
Cancel
Save