mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
186 lines
8.7 KiB
186 lines
8.7 KiB
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team |
|
// |
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of this |
|
// software and associated documentation files (the "Software"), to deal in the Software |
|
// without restriction, including without limitation the rights to use, copy, modify, merge, |
|
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons |
|
// to whom the Software is furnished to do so, subject to the following conditions: |
|
// |
|
// The above copyright notice and this permission notice shall be included in all copies or |
|
// substantial portions of the Software. |
|
// |
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
|
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR |
|
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE |
|
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR |
|
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER |
|
// DEALINGS IN THE SOFTWARE. |
|
|
|
using System; |
|
using System.Linq; |
|
using System.Threading.Tasks; |
|
|
|
using Avalonia.Controls; |
|
using Avalonia.Headless; |
|
using Avalonia.Headless.NUnit; |
|
using Avalonia.Threading; |
|
|
|
using AvaloniaEdit; |
|
using AvaloniaEdit.Document; |
|
using AvaloniaEdit.Rendering; |
|
|
|
using AwesomeAssertions; |
|
|
|
using ICSharpCode.ILSpy.AppEnv; |
|
using ICSharpCode.ILSpy.AssemblyTree; |
|
using ICSharpCode.ILSpy.TextView; |
|
using ICSharpCode.ILSpy.TreeNodes; |
|
using ICSharpCode.ILSpy.ViewModels; |
|
using ICSharpCode.ILSpy.Views; |
|
|
|
using NUnit.Framework; |
|
|
|
namespace ICSharpCode.ILSpy.Tests; |
|
|
|
[TestFixture] |
|
public class CaretHighlightAdornerTests |
|
{ |
|
// audit (2026-05-12): |
|
// * catches commenting out `BackgroundRenderers.Add(adorner)` in |
|
// CaretHighlightAdorner.DisplayCaretHighlightAnimation (the "registered" assertion). |
|
// * catches commenting out the `CaretHighlightAdorner.DisplayCaretHighlightAnimation(...)` |
|
// call inside DecompilerTextView.OnReferenceClicked — the integration path the test |
|
// drives. A direct unit test on DisplayCaretHighlightAnimation would not have caught |
|
// this regression. |
|
[AvaloniaTest] |
|
public async Task Clicking_A_Member_Definition_In_The_Decompiled_Output_Triggers_Caret_Highlight() |
|
{ |
|
// Integration test for the caret-highlight gesture: select a type, wait for the |
|
// decompile to populate the editor + reference collection, then drive the same |
|
// `ReferenceElementGenerator.OnReferenceClicked` entry point that a real |
|
// `VisualLineReferenceText.OnPointerPressed` fires when the user clicks a member |
|
// name. Member definitions register themselves in `DefinitionLookup` (see |
|
// AvaloniaEditTextOutput.AddReference), so `OnReferenceClicked` resolves the click |
|
// to an in-document jump and DecompilerTextView fires the caret highlight. |
|
// |
|
// 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!; |
|
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
|
// Decompile a single method instead of the whole Enumerable type — the full type |
|
// takes >15 s in headless and flakes WaitForDecompiledTextAsync. `Empty<TSource>` |
|
// is one of the smallest methods and still emits a member-definition reference |
|
// (the method name) plus type-definition references on its return type. |
|
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
|
"System.Linq", "System.Linq", "System.Linq.Enumerable"); |
|
typeNode.IsExpanded = true; |
|
var method = typeNode.Children.OfType<MethodTreeNode>() |
|
.First(m => m.MethodDefinition.Name == "Empty"); |
|
vm.AssemblyTreeModel.SelectNode(method); |
|
await vm.DockWorkspace.WaitForDecompiledTextAsync(); |
|
|
|
var view = await window.WaitForComponent<DecompilerTextView>(); |
|
var editor = await view.WaitForComponent<AvaloniaEdit.TextEditor>(); |
|
var renderers = editor.TextArea.TextView.BackgroundRenderers; |
|
|
|
// Sanity check — no adorner from a previous gesture is hanging around. |
|
renderers.Should().NotContain(r => r is CaretHighlightAdorner, |
|
"the test starts before any caret-highlight gesture is triggered"); |
|
|
|
// Locate the live ReferenceElementGenerator and an in-document member-definition |
|
// segment (the spot where the member's name is written; IsDefinition=true causes |
|
// DefinitionLookup to register the entity + offset together, so OnReferenceClicked's |
|
// in-document branch is guaranteed to fire). |
|
var generator = editor.TextArea.TextView.ElementGenerators |
|
.OfType<ReferenceElementGenerator>().Single(); |
|
generator.References.Should().NotBeNull( |
|
"DecompilerTextView assigns the live References collection on every ApplyDocument"); |
|
var memberDef = generator.References! |
|
.FirstOrDefault(s => s.IsDefinition && !s.IsLocal && s.Reference != null); |
|
((object?)memberDef).Should().NotBeNull( |
|
"the decompiled Enumerable class contains at least one member-definition reference"); |
|
|
|
// Same code path a real stationary click on the member name routes through. |
|
view.OnReferenceClicked(memberDef!); |
|
|
|
renderers.Should().Contain(r => r is CaretHighlightAdorner, |
|
"clicking a member definition must route through DecompilerTextView.OnReferenceClicked " |
|
+ "and call CaretHighlightAdorner.DisplayCaretHighlightAnimation"); |
|
|
|
// 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"); |
|
} |
|
|
|
/// <summary> |
|
/// Counts how often AvaloniaEdit repaints the caret layer: background renderers are drawn |
|
/// by the layer's Render pass, so a Draw call here means the layer visual was invalidated |
|
/// and re-rendered. |
|
/// </summary> |
|
sealed class CaretLayerRepaintProbe : IBackgroundRenderer |
|
{ |
|
public int DrawCount; |
|
public KnownLayer Layer => KnownLayer.Caret; |
|
public void Draw(AvaloniaEdit.Rendering.TextView textView, global::Avalonia.Media.DrawingContext drawingContext) |
|
=> DrawCount++; |
|
} |
|
|
|
static void PumpRender() |
|
{ |
|
Dispatcher.UIThread.RunJobs(); |
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick(); |
|
Dispatcher.UIThread.RunJobs(); |
|
} |
|
|
|
// Regression test: while the editor is unfocused (the debug-steps navigation case — focus |
|
// stays in the Debug Steps panel), the caret is hidden and never blinks, so nothing else |
|
// repaints the caret layer. TextView.InvalidateLayer only invalidates measure and never |
|
// reaches the caret layer's own visual, so the adorner must invalidate the layer visuals |
|
// itself — both to animate at all and, crucially, to erase the last painted frame on |
|
// Dismiss. Without that, the final frame stays composited as a caret-sized black line |
|
// fixed in the viewport until some unrelated action repaints the layer. |
|
[AvaloniaTest] |
|
public void Highlight_Start_And_Dismiss_Repaint_The_Caret_Layer_While_The_Editor_Is_Unfocused() |
|
{ |
|
var editor = new TextEditor { |
|
Document = new TextDocument("line one\nline two\nline three") |
|
}; |
|
var window = new Window { Content = editor }; |
|
window.Show(); |
|
window.UpdateLayout(); |
|
editor.TextArea.TextView.EnsureVisualLines(); |
|
|
|
var probe = new CaretLayerRepaintProbe(); |
|
editor.TextArea.TextView.BackgroundRenderers.Add(probe); |
|
PumpRender(); |
|
|
|
// Sanity check that the probe measures dirtiness, not a repaint-everything loop: an |
|
// idle render tick must not repaint the caret layer, or the assertions below would |
|
// pass vacuously. |
|
int baseline = probe.DrawCount; |
|
PumpRender(); |
|
probe.DrawCount.Should().Be(baseline, |
|
"an idle render tick must not repaint the caret layer; the probe would be useless otherwise"); |
|
|
|
// The editor is deliberately NOT focused: the hidden caret never blinks, so the layer |
|
// only repaints if the adorner invalidates it. |
|
CaretHighlightAdorner.DisplayCaretHighlightAnimation(editor.TextArea); |
|
PumpRender(); |
|
probe.DrawCount.Should().BeGreaterThan(baseline, |
|
"starting the caret-highlight animation must repaint the caret layer even when the editor is unfocused"); |
|
|
|
var adorner = editor.TextArea.TextView.BackgroundRenderers |
|
.OfType<CaretHighlightAdorner>().Single(); |
|
baseline = probe.DrawCount; |
|
adorner.Dismiss(); |
|
PumpRender(); |
|
probe.DrawCount.Should().BeGreaterThan(baseline, |
|
"dismissing the adorner must repaint the caret layer so its last painted frame is erased"); |
|
} |
|
}
|
|
|