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. 38
      ILSpy/TextView/CaretHighlightAdorner.cs

15
ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs

@ -57,8 +57,8 @@ public class CaretHighlightAdornerTests @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
@ -103,9 +103,12 @@ public class CaretHighlightAdornerTests @@ -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<CaretHighlightAdorner>().Single();
adorner.Dismiss();
renderers.Should().NotContain(r => r is CaretHighlightAdorner,
"Dismiss unregisters the caret-highlight adorner from the text view");
}
}

38
ILSpy/TextView/CaretHighlightAdorner.cs

@ -47,9 +47,14 @@ namespace ILSpy.TextView @@ -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 @@ -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 @@ -107,7 +119,7 @@ namespace ILSpy.TextView
/// <summary>
/// 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"/>
/// 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>
public static void DisplayCaretHighlightAnimation(TextArea textArea)
{
@ -115,18 +127,20 @@ namespace ILSpy.TextView @@ -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();
/// <summary>
/// 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.
/// </summary>
public void Dismiss()
{
lifetimeTimer.Stop();
frameTimer.Stop();
textArea.TextView.BackgroundRenderers.Remove(this);
}
}
}

Loading…
Cancel
Save