Skip to content

Commit

Permalink
SLVS-1483 Implement apply fix suggestion (#5726)
Browse files Browse the repository at this point in the history
  • Loading branch information
gabriela-trutan-sonarsource authored and vnaskos-sonar committed Oct 8, 2024
1 parent 693bca7 commit 5627ef9
Show file tree
Hide file tree
Showing 10 changed files with 647 additions and 13 deletions.
76 changes: 72 additions & 4 deletions src/IssueViz.UnitTests/Editor/IssueSpanCalculatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,10 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.VisualStudio.Text;
using Moq;
using SonarLint.VisualStudio.TestInfrastructure;
using SonarLint.VisualStudio.IssueVisualization.Editor;
using SonarLint.VisualStudio.TestInfrastructure;
using SonarQube.Client;

namespace SonarLint.VisualStudio.IssueVisualization.UnitTests.Editor
Expand All @@ -34,6 +32,8 @@ public class IssueSpanCalculatorTests
private Mock<IChecksumCalculator> checksumCalculatorMock;

private IssueSpanCalculator testSubject;
private const int SnapshotLength = 10000;
private const int SnapshotLineCount = 10000;

[TestInitialize]
public void TestInitialize()
Expand Down Expand Up @@ -331,6 +331,60 @@ public void CalculateSpan_TextRangeNull_ReturnsNull()
checksumCalculatorMock.VerifyNoOtherCalls();
}

[TestMethod]
public void CalculateSpan_ForStartAndEndLines_GetsPositionOfCorrectLines()
{
var startLine = CreateLineMock(lineNumber: 66, startPos: 1, endPos: 2);
var endLine = CreateLineMock(lineNumber: 224, startPos: 13, endPos: 23);
var textSnapshotMock = MockTextSnapshotForLines(startLine, endLine);

testSubject.CalculateSpan(textSnapshotMock.Object, startLine.LineNumber, endLine.LineNumber);

textSnapshotMock.Verify(mock => mock.GetLineFromLineNumber(startLine.LineNumber - 1), Times.Once);
textSnapshotMock.Verify(mock => mock.GetLineFromLineNumber(endLine.LineNumber - 1), Times.Once);
}

[TestMethod]
public void CalculateSpan_ForStartAndEndLines_ReturnsSnapshotSpanWithCorrectStartAndEnd()
{
var startLine = CreateLineMock(lineNumber:66, startPos:1, endPos:2);
var endLine = CreateLineMock(lineNumber:224, startPos:13, endPos:23);
var textSnapshotMock = MockTextSnapshotForLines(startLine, endLine);

var snapshotSpan = testSubject.CalculateSpan(textSnapshotMock.Object, startLine.LineNumber, endLine.LineNumber);

snapshotSpan.Start.Position.Should().Be(startLine.Start.Position);
snapshotSpan.End.Position.Should().Be(endLine.End.Position);
}

[TestMethod]
[DataRow(0, 1)]
[DataRow(1, 0)]
[DataRow(SnapshotLineCount + 1, 1)]
[DataRow(1, SnapshotLineCount + 1)]
[DataRow(4, 1)]
public void CalculateSpan_ForInvalidStartAndEndLines_ThrowsException(int startLineNumber, int endLineNumber)
{
var startLine = CreateLineMock(lineNumber:startLineNumber, startPos:1, endPos:2);
var endLine = CreateLineMock(lineNumber:endLineNumber, startPos:13, endPos:23);
var textSnapshotMock = MockTextSnapshotForLines(startLine, endLine);

Action act = () => testSubject.CalculateSpan(textSnapshotMock.Object, startLine.LineNumber, endLine.LineNumber);

act.Should().Throw<ArgumentOutOfRangeException>();
}

private static Mock<ITextSnapshot> MockTextSnapshotForLines(ITextSnapshotLine startLine, ITextSnapshotLine endLine)
{
var textSnapshot = new Mock<ITextSnapshot>();
textSnapshot.SetupGet(x => x.LineCount).Returns(SnapshotLineCount);
textSnapshot.SetupGet(x => x.Length).Returns(SnapshotLength);
textSnapshot.Setup(x => x.GetLineFromLineNumber(startLine.LineNumber - 1)).Returns(startLine);
textSnapshot.Setup(x => x.GetLineFromLineNumber(endLine.LineNumber - 1)).Returns(endLine);

return textSnapshot;
}

private class VSLineDescription
{
public int ZeroBasedLineNumber { get; set; }
Expand All @@ -339,7 +393,7 @@ private class VSLineDescription
public string Text { get; set; }
}

private static Mock<ITextSnapshot> CreateSnapshotMock(int bufferLineCount = 1000, int snapShotLength = 10000, params VSLineDescription[] lines)
private static Mock<ITextSnapshot> CreateSnapshotMock(int bufferLineCount = 1000, int snapShotLength = SnapshotLength, params VSLineDescription[] lines)
{
var textSnapshotMock = new Mock<ITextSnapshot>();

Expand Down Expand Up @@ -378,5 +432,19 @@ private static ITextSnapshotLine CreateLineMock(ITextSnapshot textSnapshot, VSLi

return startLineMock.Object;
}

private static ITextSnapshotLine CreateLineMock(int lineNumber, int startPos, int endPos)
{
var startLineMock = new Mock<ITextSnapshotLine>();
var textSnapshot = new Mock<ITextSnapshot>();

textSnapshot.SetupGet(x => x.Length).Returns(() => endPos +1);

startLineMock.SetupGet(x => x.LineNumber).Returns(() => lineNumber);
startLineMock.SetupGet(x => x.Start).Returns(() => new SnapshotPoint(textSnapshot.Object, startPos));
startLineMock.SetupGet(x => x.End).Returns(() => new SnapshotPoint(textSnapshot.Object, endPos));

return startLineMock.Object;
}
}
}
227 changes: 224 additions & 3 deletions src/IssueViz.UnitTests/FixSuggestion/FixSuggestionHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,246 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using NSubstitute.ExceptionExtensions;
using SonarLint.VisualStudio.Core;
using SonarLint.VisualStudio.IssueVisualization.Editor;
using SonarLint.VisualStudio.IssueVisualization.FixSuggestion;
using SonarLint.VisualStudio.IssueVisualization.OpenInIde;
using SonarLint.VisualStudio.SLCore.Listener.FixSuggestion;
using SonarLint.VisualStudio.SLCore.Listener.FixSuggestion.Models;
using SonarLint.VisualStudio.TestInfrastructure;
using FileEditDto = SonarLint.VisualStudio.SLCore.Listener.FixSuggestion.Models.FileEditDto;

namespace SonarLint.VisualStudio.IssueVisualization.UnitTests.FixSuggestion;

[TestClass]
public class FixSuggestionHandlerTests
{
private const string ConfigurationScopeRoot = @"C:\";

private FixSuggestionHandler testSubject;
private IThreadHandling threadHandling;
private ILogger logger;
private IDocumentNavigator documentNavigator;
private IIssueSpanCalculator issueSpanCalculator;
private IOpenInIdeConfigScopeValidator openInIdeConfigScopeValidator;
private IIDEWindowService ideWindowService;

[TestInitialize]
public void TestInitialize()
{
threadHandling = new NoOpThreadHandler();
logger = Substitute.For<ILogger>();
documentNavigator = Substitute.For<IDocumentNavigator>();
issueSpanCalculator = Substitute.For<IIssueSpanCalculator>();
openInIdeConfigScopeValidator = Substitute.For<IOpenInIdeConfigScopeValidator>();
ideWindowService = Substitute.For<IIDEWindowService>();

testSubject = new FixSuggestionHandler(threadHandling, logger, documentNavigator, issueSpanCalculator, openInIdeConfigScopeValidator, ideWindowService);
}

[TestMethod]
public void MefCtor_CheckIsSingleton()
{
MefTestHelpers.CheckIsSingletonMefComponent<FixSuggestionHandler>();
}

[TestMethod]
public void ApplyFixSuggestion_ThrowsNotImplementedException()
public void ApplyFixSuggestion_RunsOnUIThread()
{
MockConfigScopeRoot();
var threadHandlingMock = Substitute.For<IThreadHandling>();
var fixSuggestionHandler = new FixSuggestionHandler(threadHandlingMock, logger, documentNavigator, issueSpanCalculator, openInIdeConfigScopeValidator, ideWindowService);

fixSuggestionHandler.ApplyFixSuggestion(CreateFixSuggestionParams());

threadHandlingMock.ReceivedWithAnyArgs().RunOnUIThread(default);
}

[TestMethod]
public void ApplyFixSuggestion_OneChange_AppliesChange()
{
var suggestionParams = CreateFixSuggestionParams();
var suggestedChange = suggestionParams.fixSuggestion.fileEdit.changes[0];
MockCalculateSpan(suggestedChange);
var textView = MockOpenFile();
var edit = Substitute.For<ITextEdit>();
textView.TextBuffer.CreateEdit().Returns(edit);
MockConfigScopeRoot();

testSubject.ApplyFixSuggestion(suggestionParams);

Received.InOrder(() =>
{
logger.WriteLine(FixSuggestionResources.ProcessingRequest, suggestionParams.configurationScopeId, suggestionParams.fixSuggestion.suggestionId);
documentNavigator.Open(@"C:\myFile.cs");
textView.TextBuffer.CreateEdit();
issueSpanCalculator.CalculateSpan(Arg.Any<ITextSnapshot>(), suggestedChange.beforeLineRange.startLine, suggestedChange.beforeLineRange.endLine);
edit.Replace(Arg.Any<Span>(), suggestedChange.after);
edit.Apply();
logger.WriteLine(FixSuggestionResources.DoneProcessingRequest, suggestionParams.configurationScopeId, suggestionParams.fixSuggestion.suggestionId);
});
}

/// <summary>
/// The changes are applied from bottom to top to avoid changing the line numbers
/// of the changes that are below the current change.
///
/// This is important when the change is more lines than the original line range.
/// </summary>
[TestMethod]
public void ApplyFixSuggestion_WhenMoreThanOneFixes_ApplyThemFromBottomToTop()
{
MockConfigScopeRoot();
MockOpenFile();
List<ChangesDto> changes = [CreateChangesDto(1, 1), CreateChangesDto(3, 3)];
var suggestionParams = CreateFixSuggestionParams(changes: changes);

testSubject.ApplyFixSuggestion(suggestionParams);

Received.InOrder(() =>
{
issueSpanCalculator.CalculateSpan(Arg.Any<ITextSnapshot>(), 3, 3);
issueSpanCalculator.CalculateSpan(Arg.Any<ITextSnapshot>(), 1, 1);
});
}

[TestMethod]
public void ApplyFixSuggestion_WhenApplyingChange_BringWindowToFront()
{
var suggestionParams = CreateFixSuggestionParams();
MockConfigScopeRoot();

testSubject.ApplyFixSuggestion(suggestionParams);

ideWindowService.Received().BringToFront();
}

[TestMethod]
public void ApplyFixSuggestion_WhenApplyingChange_BringFocusToFirstChangedLines()
{
List<ChangesDto> changes = [CreateChangesDto(1, 1), CreateChangesDto(3, 3)];
var suggestionParams = CreateFixSuggestionParams(changes: changes);
var firstSuggestedChange = suggestionParams.fixSuggestion.fileEdit.changes[0];
var firstAffectedSnapshot = MockCalculateSpan(firstSuggestedChange);
var textView = MockOpenFile();
MockConfigScopeRoot();

testSubject.ApplyFixSuggestion(suggestionParams);

textView.ViewScroller.ReceivedWithAnyArgs(1).EnsureSpanVisible(Arg.Any<SnapshotSpan>(), default);
textView.ViewScroller.Received().EnsureSpanVisible(firstAffectedSnapshot, EnsureSpanVisibleOptions.AlwaysCenter);
}

[TestMethod]
public void ApplyFixSuggestion_Throws_Logs()
{
var testSubject = new FixSuggestionHandler();
var suggestionParams = CreateFixSuggestionParams();
var exceptionMsg = "error";
MockConfigScopeRoot();
issueSpanCalculator.CalculateSpan(Arg.Any<ITextSnapshot>(), Arg.Any<int>(), Arg.Any<int>()).Throws(new Exception(exceptionMsg));

Exceptions.Expect<NotImplementedException>(() => testSubject.ApplyFixSuggestion());
testSubject.ApplyFixSuggestion(suggestionParams);

Received.InOrder(() =>
{
logger.WriteLine(FixSuggestionResources.ProcessingRequest, suggestionParams.configurationScopeId, suggestionParams.fixSuggestion.suggestionId);
logger.WriteLine(FixSuggestionResources.ProcessingRequestFailed, suggestionParams.configurationScopeId, suggestionParams.fixSuggestion.suggestionId, exceptionMsg);
});
logger.DidNotReceive().WriteLine(FixSuggestionResources.DoneProcessingRequest, suggestionParams.configurationScopeId, suggestionParams.fixSuggestion.suggestionId);
}

[TestMethod]
public void ApplyFixSuggestion_WhenConfigRootScopeNotFound_ShouldLogFailure()
{
MockFailedConfigScopeRoot("Scope not found");
var suggestionParams = CreateFixSuggestionParams("SpecificConfigScopeId");

testSubject.ApplyFixSuggestion(suggestionParams);

logger.Received().WriteLine(FixSuggestionResources.GetConfigScopeRootPathFailed, "SpecificConfigScopeId", "Scope not found");
}

[TestMethod]
public void ApplyFixSuggestion_WhenLineNumbersDoNotMatch_ShouldLogFailure()
{
FailWhenApplyingEdit(out var suggestionWithWrongLineNumbers, "Line numbers do not match");

testSubject.ApplyFixSuggestion(suggestionWithWrongLineNumbers);

logger.Received().WriteLine(FixSuggestionResources.ProcessingRequestFailed, "AScopeId", "key", "Line numbers do not match");
}

[TestMethod]
public void ApplyFixSuggestion_WhenApplyingChangeAndExceptionIsThrown_ShouldCancelEdit()
{
var edit = FailWhenApplyingEdit(out var suggestionWithWrongLineNumbers);

testSubject.ApplyFixSuggestion(suggestionWithWrongLineNumbers);

edit.DidNotReceiveWithAnyArgs().Replace(default, default);
edit.Received().Cancel();
}

private static ShowFixSuggestionParams CreateFixSuggestionParams(string scopeId = "scopeId", string suggestionKey = "suggestionKey", string idePath = @"myFile.cs", List<ChangesDto> changes = null)
{
changes ??= [CreateChangesDto()];
var fixSuggestion = new FixSuggestionDto(suggestionKey, "refactor", new FileEditDto(idePath, changes));
var suggestionParams = new ShowFixSuggestionParams(scopeId, "key", fixSuggestion);
return suggestionParams;
}

private static ChangesDto CreateChangesDto(int startLine = 1, int endLine = 2)
{
return new ChangesDto(new LineRangeDto(startLine, endLine), "var a=1;", "");
}

private void MockConfigScopeRoot()
{
openInIdeConfigScopeValidator.TryGetConfigurationScopeRoot(Arg.Any<string>(), out Arg.Any<string>(), out Arg.Any<string>()).Returns(
x =>
{
x[1] = ConfigurationScopeRoot;
return true;
});
}

private void MockFailedConfigScopeRoot(string failureReason)
{
openInIdeConfigScopeValidator.TryGetConfigurationScopeRoot(Arg.Any<string>(), out Arg.Any<string>(), out Arg.Any<string>()).Returns(
x =>
{
x[2] = failureReason;
return false;
});
}

private ITextView MockOpenFile()
{
var textView = Substitute.For<ITextView>();
documentNavigator.Open(Arg.Any<string>()).Returns(textView);
return textView;
}

private SnapshotSpan MockCalculateSpan(ChangesDto suggestedChange)
{
var affectedSnapshot = new SnapshotSpan();
issueSpanCalculator.CalculateSpan(Arg.Any<ITextSnapshot>(), suggestedChange.beforeLineRange.startLine, suggestedChange.beforeLineRange.endLine)
.Returns(affectedSnapshot);
return affectedSnapshot;
}

private ITextEdit FailWhenApplyingEdit(out ShowFixSuggestionParams suggestionWithWrongLineNumbers, string reason = "")
{
MockConfigScopeRoot();
var edit = Substitute.For<ITextEdit>();
var textView = MockOpenFile();
textView.TextBuffer.CreateEdit().Returns(edit);
suggestionWithWrongLineNumbers = CreateFixSuggestionParams(scopeId: "AScopeId", suggestionKey: "key");
issueSpanCalculator.CalculateSpan(Arg.Any<ITextSnapshot>(), Arg.Any<int>(), Arg.Any<int>())
.Throws(new Exception(reason));
return edit;
}
}
15 changes: 14 additions & 1 deletion src/IssueViz/Editor/IIssueSpanCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

using System;
using System.ComponentModel.Composition;
using Microsoft.VisualStudio.Text;
using SonarLint.VisualStudio.Core.Analysis;
Expand All @@ -34,6 +33,8 @@ public interface IIssueSpanCalculator
/// Returns null if no textRange is passed
/// </summary>
SnapshotSpan? CalculateSpan(ITextRange range, ITextSnapshot currentSnapshot);

SnapshotSpan CalculateSpan(ITextSnapshot snapshot, int startLine, int endLine);
}

[Export(typeof(IIssueSpanCalculator))]
Expand Down Expand Up @@ -105,6 +106,18 @@ internal IssueSpanCalculator(IChecksumCalculator checksumCalculator)
return snapshotSpan;
}

public SnapshotSpan CalculateSpan(ITextSnapshot snapshot, int startLine, int endLine)
{
if (startLine < 1 || endLine < 1 || startLine > snapshot.LineCount || endLine > snapshot.LineCount || startLine > endLine)
{
throw new ArgumentOutOfRangeException(nameof(startLine), nameof(endLine));
}
var startPosition = snapshot.GetLineFromLineNumber(startLine - 1).Start.Position;
var endPosition = snapshot.GetLineFromLineNumber(endLine - 1).End.Position;
var span = Span.FromBounds(startPosition, endPosition);
return new SnapshotSpan(snapshot, span);
}

private static bool RangeHasHash(ITextRange range)
=> !string.IsNullOrEmpty(range.LineHash);

Expand Down
Loading

0 comments on commit 5627ef9

Please sign in to comment.