From 36b08a0b1c49043d01494962a8e82e9c245e2900 Mon Sep 17 00:00:00 2001 From: Jelle Bootsma Date: Fri, 18 Apr 2025 18:10:56 +0200 Subject: [PATCH] Add basic unit tests for c# portion and update CI --- .github/workflows/dotnet.yml | 35 +-- BlazorObservers.sln | 6 + ObserverLibrary/ObserverLibrary.csproj | 5 + .../Services/ResizeObserverService.cs | 3 + ObserverLibrary/Tasks/ObserverTask.cs | 2 +- .../ObserverLibraryTests.csproj | 20 ++ .../Services/ResizeObserverServiceTests.cs | 230 ++++++++++++++++++ ObserverLibraryTests/Tasks/ResizeTaskTests.cs | 128 ++++++++++ 8 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 ObserverLibraryTests/ObserverLibraryTests.csproj create mode 100644 ObserverLibraryTests/Services/ResizeObserverServiceTests.cs create mode 100644 ObserverLibraryTests/Tasks/ResizeTaskTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index dca836c..6101978 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -7,33 +7,34 @@ on: branches: [ main ] jobs: - build: - + build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v2 - with: - dotnet-version: 6.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal + - uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Run Tests + run: dotnet test --no-build --verbosity normal + doxygen: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Doxygen Action - # You may pin to the exact commit or the version. - # uses: mattnotmitt/doxygen-action@cdd5472f8e48e141b89d2633c1ae72991a21cb6a uses: mattnotmitt/doxygen-action@1.9.2 with: - # Path to Doxyfile doxyfile-path: ./Doxyfile - # Working directory working-directory: . diff --git a/BlazorObservers.sln b/BlazorObservers.sln index 2ff95b6..19ae61a 100644 --- a/BlazorObservers.sln +++ b/BlazorObservers.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ObserverLibrary", "Observer EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObserverExample", "ObserverExample\ObserverExample.csproj", "{5F06C015-629D-432E-93E7-C6EA2FAA5ACD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObserverLibraryTests", "ObserverLibraryTests\ObserverLibraryTests.csproj", "{1A3B38A4-7739-49B6-AC27-EB0D916293B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {5F06C015-629D-432E-93E7-C6EA2FAA5ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU {5F06C015-629D-432E-93E7-C6EA2FAA5ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU {5F06C015-629D-432E-93E7-C6EA2FAA5ACD}.Release|Any CPU.Build.0 = Release|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ObserverLibrary/ObserverLibrary.csproj b/ObserverLibrary/ObserverLibrary.csproj index 8aeda0c..2783b2d 100644 --- a/ObserverLibrary/ObserverLibrary.csproj +++ b/ObserverLibrary/ObserverLibrary.csproj @@ -36,6 +36,11 @@ Currently only Resize observer is present. + + + <_Parameter1>ObserverLibraryTests + + diff --git a/ObserverLibrary/Services/ResizeObserverService.cs b/ObserverLibrary/Services/ResizeObserverService.cs index 6180a81..d419f6b 100644 --- a/ObserverLibrary/Services/ResizeObserverService.cs +++ b/ObserverLibrary/Services/ResizeObserverService.cs @@ -37,6 +37,9 @@ public ResizeObserverService(IJSRuntime jsRuntime) : base(jsRuntime) /// Thrown if targetElements is an empty array public Task RegisterObserver(Action onObserve, params ElementReference[] targetElements) { + if (onObserve is null) + throw new ArgumentNullException(nameof(onObserve)); + return ValidateObserverRegistration((entries) => { onObserve(entries); return ValueTask.CompletedTask; }, targetElements); } diff --git a/ObserverLibrary/Tasks/ObserverTask.cs b/ObserverLibrary/Tasks/ObserverTask.cs index 8934c0b..39d6609 100644 --- a/ObserverLibrary/Tasks/ObserverTask.cs +++ b/ObserverLibrary/Tasks/ObserverTask.cs @@ -38,7 +38,7 @@ private protected ObserverTask(Func taskFunc) /// public void OnlyTriggerLast(int delay) { - if (delay < 0) throw new ArgumentException("Delay can not be negative"); + if (delay < 0) throw new ArgumentException($"{nameof(delay)} must be positive"); _paused = false; _delay = delay; _delayTriggering = true; diff --git a/ObserverLibraryTests/ObserverLibraryTests.csproj b/ObserverLibraryTests/ObserverLibraryTests.csproj new file mode 100644 index 0000000..df8a122 --- /dev/null +++ b/ObserverLibraryTests/ObserverLibraryTests.csproj @@ -0,0 +1,20 @@ + + + + net6.0 + latest + enable + enable + + + + + + + + + + + + + diff --git a/ObserverLibraryTests/Services/ResizeObserverServiceTests.cs b/ObserverLibraryTests/Services/ResizeObserverServiceTests.cs new file mode 100644 index 0000000..52afff5 --- /dev/null +++ b/ObserverLibraryTests/Services/ResizeObserverServiceTests.cs @@ -0,0 +1,230 @@ +using System.Xml.Linq; +using BlazorObservers.ObserverLibrary.JsModels; +using BlazorObservers.ObserverLibrary.Services; +using BlazorObservers.ObserverLibrary.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using Moq; +using NUnit.Framework; + +namespace BlazorObservers.ObserverLibrary.Tests.Services +{ + [TestFixture] + public class ResizeObserverServiceTests + { + private Mock _jsRuntimeMock = null!; + private Mock _jsModuleMock = null!; + private ResizeObserverService _service = null!; + + private readonly Dictionary _elementIdMap = new(); + + [SetUp] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly", Justification = "Mock Setup")] + public void SetUp() + { + // clear the elementIdMap before each test + _elementIdMap.Clear(); + + // Setup the JS module mock + _jsModuleMock = new Mock(); + _jsModuleMock + .Setup(m => m.InvokeAsync( + "ObserverManager.CreateNewResizeObserver", + It.IsAny())) + .Returns((string _, object[] args) => + { + var elements = args.Skip(2).Cast().ToArray(); + + List ids = new List(elements.Length); + foreach (var element in elements) + { + if (!_elementIdMap.TryGetValue(element, out var id)) + { + id = Guid.NewGuid().ToString(); + _elementIdMap[element] = id; + ids.Add(id); + } + } + + return new ValueTask(Task.FromResult(ids.ToArray())); + }); + + _jsModuleMock + .Setup(m => m.InvokeAsync( + "ObserverManager.StartObserving", + It.IsAny())) + .Returns((string _, object[] args) => + { + var element = (ElementReference)args[1]; + + if (!_elementIdMap.TryGetValue(element, out var id)) + { + id = Guid.NewGuid().ToString(); + _elementIdMap[element] = id; + } + + return new ValueTask(Task.FromResult(id)); + }); + + _jsModuleMock + .Setup(m => m.InvokeAsync( + "ObserverManager.StopObserving", + It.IsAny())) + .Returns((string _, object[] args) => + { + var taskId = args[0]?.ToString(); + var element = (ElementReference)args[1]; + + return new ValueTask(Task.FromResult(_elementIdMap.Remove(element))); + }); + + + // Setup the JS runtime mock + _jsRuntimeMock = new Mock(); + _jsRuntimeMock + .Setup(r => r.InvokeAsync( + "import", + It.Is(args => args.Length == 1 && args[0]!.ToString() == "./_content/BlazorObservers/ObserverManager.js"))) + .Returns(new ValueTask(Task.FromResult(_jsModuleMock.Object))); + + // Create the service under test + _service = new ResizeObserverService(_jsRuntimeMock.Object); + } + + [Test] + public async Task RegisterObserver_SynchronousFunction_ValidatesAndRegisters() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Action(entries => { }); + + var task = await _service.RegisterObserver(onObserve, element); + + Assert.IsNotNull(task); + Assert.AreEqual(1, task.ConnectedElements.Count); + + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.CreateNewResizeObserver", + It.Is(args => args.Contains(element)) + ), + Times.Once); + } + + [Test] + public async Task RegisterObserver_AsyncFunction_ValidatesAndRegisters() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Func(entries => Task.CompletedTask); + + var task = await _service.RegisterObserver(onObserve, element); + + Assert.IsNotNull(task); + Assert.AreEqual(1, task.ConnectedElements.Count); + + + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.CreateNewResizeObserver", + It.Is(args => args.Contains(element)) + ), + Times.Once); + } + + [Test] + public async Task StartObserving_ValidElement_AddsToObservedElements() + { + var element1 = new ElementReference(Guid.NewGuid().ToString()); + var element2 = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Action(entries => { }); + var task = await _service.RegisterObserver(onObserve, element1); + + var result = await _service.StartObserving(task, element2); + + Assert.IsTrue(result); + Assert.AreEqual(2, task.ConnectedElements.Count); + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.StartObserving", + It.Is(args => + args.Length == 2 && + args[0]!.ToString() == task.TaskId.ToString() && + args[1].GetType() == typeof(ElementReference) && ((ElementReference)args[1]).Equals(element2) + ) + ), + Times.Once); + } + + [Test] + public async Task StopObserving_ValidElement_RemovesFromObservedElements() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Action(entries => { }); + var task = await _service.RegisterObserver(onObserve, element); + + var result = await _service.StopObserving(task, element); + + Assert.IsTrue(result); + Assert.AreEqual(0, task.ConnectedElements.Count); + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.StopObserving", + It.Is(args => + args.Length == 2 && + args[0]!.ToString() == task.TaskId.ToString() && + args[1].GetType() == typeof(ElementReference) && element.Equals((ElementReference)args[1]) + ) + ), + Times.Once); + } + + [Test] + public async Task DeregisterObserver_RemovesObserverAndElements() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var task = await _service.RegisterObserver(entries => { }, element); + + await _service.DeregisterObserver(task); + + Assert.AreEqual(0, task.ConnectedElements.Count); + } + + [Test] + public async Task DeregisterObserver_ById_RemovesObserverAndElements() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var task = await _service.RegisterObserver(entries => { }, element); + + await _service.DeregisterObserver(task.TaskId); + + Assert.AreEqual(0, task.ConnectedElements.Count); + } + + [Test] + public void RegisterObserver_ThrowsOnNullAction() + { + Assert.ThrowsAsync(() => + _service.RegisterObserver((Action)null, new ElementReference())); + } + + [Test] + public void RegisterObserver_ThrowsOnNullElement() + { + Assert.ThrowsAsync(() => + _service.RegisterObserver(entries => Task.CompletedTask, null!)); + } + + [Test] + public void StartObserving_ThrowsOnNullTask() + { + Assert.ThrowsAsync(async () => + await _service.StartObserving(null!, new ElementReference())); + } + + [Test] + public void StopObserving_ThrowsOnNullTask() + { + Assert.ThrowsAsync(async () => + await _service.StopObserving(null!, new ElementReference())); + } + } +} diff --git a/ObserverLibraryTests/Tasks/ResizeTaskTests.cs b/ObserverLibraryTests/Tasks/ResizeTaskTests.cs new file mode 100644 index 0000000..31f1ef4 --- /dev/null +++ b/ObserverLibraryTests/Tasks/ResizeTaskTests.cs @@ -0,0 +1,128 @@ +using BlazorObservers.ObserverLibrary.JsModels; +using BlazorObservers.ObserverLibrary.Tasks; +using Microsoft.AspNetCore.Components; +using NUnit.Framework; + +namespace BlazorObservers.ObserverLibrary.Tests.Tasks +{ + [TestFixture] + public class ResizeTaskTests + { + private List _executedData; + private ResizeTask _resizeTask; + + [SetUp] + public void SetUp() + { + _executedData = new List(); + _resizeTask = new ResizeTask(entries => + { + _executedData.Add(entries); + return ValueTask.CompletedTask; + }); + } + + [Test] + public async Task Execute_WithValidTrackingId_SetsTargetElement() + { + // Arrange + var element = new ElementReference(Guid.NewGuid().ToString()); + var trackingId = Guid.NewGuid(); + _resizeTask.ConnectedElements[trackingId] = element; + + var entries = new[] + { + new JsResizeObserverEntry { TargetElementTrackingId = trackingId.ToString() } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.AreEqual(1, _executedData.Count); + Assert.AreEqual(element, entries[0].TargetElement); + } + + [Test] + public async Task Execute_WithInvalidTrackingId_DoesNotSetTargetElement() + { + // Arrange + var entries = new[] + { + new JsResizeObserverEntry { TargetElementTrackingId = "invalid-guid" } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.AreEqual(1, _executedData.Count); + Assert.IsNull(entries[0].TargetElement); + } + + [Test] + public async Task Execute_WhenPaused_DoesNotExecute() + { + // Arrange + _resizeTask.HaltTaskTriggering(); + + var entries = new[] + { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.IsEmpty(_executedData); + } + + [Test] + public async Task Execute_WhenOnlyTriggerLast_DebouncesExecution() + { + // Arrange + _resizeTask.OnlyTriggerLast(100); // 100 ms delay + + var entries1 = new[] { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + var entries2 = new[] { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + // Act + var valueTask1 = _resizeTask.Execute(entries1); + var valueTask2 = _resizeTask.Execute(entries2); + + // Wait long enough to trigger debounce + await Task.Delay(200); + + await valueTask1; + await valueTask2; + + // Assert + Assert.AreEqual(1, _executedData.Count); + Assert.AreEqual(entries2, _executedData[0]); // Only latest should execute + } + + [Test] + public async Task ResumeTaskTriggering_AllowsExecution() + { + // Arrange + _resizeTask.HaltTaskTriggering(); + _resizeTask.ResumeTaskTriggering(); + + var entries = new[] { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.AreEqual(1, _executedData.Count); + } + } +}