Skip to content

Commit c56d19b

Browse files
SLVS-2430 Introduce service that executes a task with debounce
1 parent d0901f1 commit c56d19b

File tree

3 files changed

+221
-0
lines changed

3 files changed

+221
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* SonarLint for Visual Studio
3+
* Copyright (C) 2016-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
using Microsoft.VisualStudio.Text;
22+
using SonarLint.VisualStudio.Core.Synchronization;
23+
using SonarLint.VisualStudio.Integration.Vsix.SonarLintTagger;
24+
25+
namespace SonarLint.VisualStudio.Integration.UnitTests.SonarLintTagger;
26+
27+
[TestClass]
28+
public class TaskExecutorWithDebounceFactoryTest
29+
{
30+
private TaskExecutorWithDebounceFactory testSubject;
31+
private IAsyncLockFactory asyncLockFactory;
32+
33+
[TestInitialize]
34+
public void TestInitialize()
35+
{
36+
asyncLockFactory = Substitute.For<IAsyncLockFactory>();
37+
testSubject = new TaskExecutorWithDebounceFactory(asyncLockFactory);
38+
}
39+
40+
[TestMethod]
41+
public void MefCtor_CheckIsExported() =>
42+
MefTestHelpers.CheckTypeCanBeImported<TaskExecutorWithDebounceFactory, ITaskExecutorWithDebounceFactory>(
43+
MefTestHelpers.CreateExport<IAsyncLockFactory>());
44+
45+
[TestMethod]
46+
public void MefCtor_CheckIsSingleton() => MefTestHelpers.CheckIsSingletonMefComponent<TaskExecutorWithDebounceFactory>();
47+
48+
[TestMethod]
49+
public void Create_ShouldReturnInstance()
50+
{
51+
var result = testSubject.Create<ITextSnapshot>(1.0);
52+
53+
result.Should().NotBeNull();
54+
result.Should().BeOfType<TaskExecutorWithDebounce<ITextSnapshot>>();
55+
}
56+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* SonarLint for Visual Studio
3+
* Copyright (C) 2016-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
using Microsoft.VisualStudio.Threading;
22+
using SonarLint.VisualStudio.Core.Synchronization;
23+
using SonarLint.VisualStudio.Integration.TestInfrastructure;
24+
using SonarLint.VisualStudio.Integration.Vsix.SonarLintTagger;
25+
26+
namespace SonarLint.VisualStudio.Integration.UnitTests.SonarLintTagger;
27+
28+
[TestClass]
29+
public class TaskExecutorWithDebounceTest
30+
{
31+
private const int DebounceTimeInMs = 100;
32+
private IAsyncLock asyncLock;
33+
private IAsyncLockFactory asyncLockFactory;
34+
private TaskExecutorWithDebounce<TestData> testSubject;
35+
36+
[TestInitialize]
37+
public void TestInitialize()
38+
{
39+
asyncLockFactory = Substitute.For<IAsyncLockFactory>();
40+
asyncLock = Substitute.For<IAsyncLock>();
41+
asyncLockFactory.Create().Returns(asyncLock);
42+
testSubject = new TaskExecutorWithDebounce<TestData>(asyncLockFactory, DebounceTimeInMs);
43+
}
44+
45+
[TestMethod]
46+
public async Task DebounceAsync_ExecutesTaskWithDebounce()
47+
{
48+
var currentState = new TestData { Value = 1 };
49+
var tcs = new TaskCompletionSource<int>();
50+
var stopwatch = Stopwatch.StartNew();
51+
52+
testSubject.DebounceAsync(currentState, state =>
53+
{
54+
UpdateState(state, 2, tcs);
55+
stopwatch.Stop();
56+
}).Forget();
57+
await tcs.Task;
58+
59+
asyncLock.Received(1).AcquireAsync().IgnoreAwaitForAssert();
60+
currentState.Value.Should().Be(2);
61+
stopwatch.ElapsedMilliseconds.Should().BeGreaterOrEqualTo(DebounceTimeInMs);
62+
}
63+
64+
[TestMethod]
65+
public async Task DebounceAsync_MultipleTimes_UpdatesWithLatestState()
66+
{
67+
var currentState = new TestData { Value = 1 };
68+
var tcs = new TaskCompletionSource<int>();
69+
70+
testSubject.DebounceAsync(currentState, state => UpdateState(state, 2)).Forget();
71+
testSubject.DebounceAsync(currentState, state => UpdateState(state, 3)).Forget();
72+
testSubject.DebounceAsync(currentState, state => UpdateState(state, 4, tcs)).Forget();
73+
await tcs.Task;
74+
75+
asyncLock.Received(3).AcquireAsync().IgnoreAwaitForAssert();
76+
currentState.Value.Should().Be(4);
77+
}
78+
79+
private static void UpdateState(TestData date, int newValue, TaskCompletionSource<int> taskCompletionSource = null)
80+
{
81+
date.Value = newValue;
82+
taskCompletionSource?.SetResult(1);
83+
}
84+
85+
private record TestData
86+
{
87+
public int Value { get; set; }
88+
}
89+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* SonarLint for Visual Studio
3+
* Copyright (C) 2016-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
using System.ComponentModel.Composition;
22+
using Microsoft.VisualStudio.Threading;
23+
using SonarLint.VisualStudio.Core.Synchronization;
24+
25+
namespace SonarLint.VisualStudio.Integration.Vsix.SonarLintTagger;
26+
27+
internal interface ITaskExecutorWithDebounceFactory
28+
{
29+
ITaskExecutorWithDebounce<T> Create<T>(double debounceMilliseconds);
30+
}
31+
32+
internal interface ITaskExecutorWithDebounce<T>
33+
{
34+
Task DebounceAsync(T state, Action<T> task);
35+
}
36+
37+
[Export(typeof(ITaskExecutorWithDebounceFactory))]
38+
[PartCreationPolicy(CreationPolicy.Shared)]
39+
[method: ImportingConstructor]
40+
internal class TaskExecutorWithDebounceFactory(IAsyncLockFactory asyncLockFactory) : ITaskExecutorWithDebounceFactory
41+
{
42+
public ITaskExecutorWithDebounce<T> Create<T>(double debounceMilliseconds) => new TaskExecutorWithDebounce<T>(asyncLockFactory, debounceMilliseconds);
43+
}
44+
45+
internal class TaskExecutorWithDebounce<T>(IAsyncLockFactory asyncLockFactory, double debounceMilliseconds) : ITaskExecutorWithDebounce<T>
46+
{
47+
private sealed record Debounce(CancellationTokenSource CancellationTokenSource, T State);
48+
private Debounce latestDebounceState;
49+
private readonly IAsyncLock asyncLock = asyncLockFactory.Create();
50+
51+
public async Task DebounceAsync(T state, Action<T> task)
52+
{
53+
using (await asyncLock.AcquireAsync())
54+
{
55+
latestDebounceState?.CancellationTokenSource.Cancel();
56+
latestDebounceState = new Debounce(new CancellationTokenSource(), state);
57+
}
58+
59+
var latestState = latestDebounceState;
60+
Task.Run(async () =>
61+
{
62+
try
63+
{
64+
await Task.Delay(TimeSpan.FromMilliseconds(debounceMilliseconds), latestState.CancellationTokenSource.Token);
65+
if (!latestState.CancellationTokenSource.Token.IsCancellationRequested)
66+
{
67+
task(latestState.State);
68+
}
69+
}
70+
catch (TaskCanceledException)
71+
{
72+
// do nothing
73+
}
74+
}, latestState.CancellationTokenSource.Token).Forget();
75+
}
76+
}

0 commit comments

Comments
 (0)