Skip to content

Commit d38aebe

Browse files
feat: Add bUnit test coverage for Settings component (#161)
- Refactor `SettingsViewModel` to use `TimeProvider` and `IThemeJsInterop` for testability. - Add `IThemeJsInterop` and `ThemeJsInterop` to abstract JS Runtime calls. - Add `data-test` attributes to `Settings.razor`, `SettingsSidebar.razor`, and `SettingItem.razor`. - Add unit tests in `SettingsViewModelTests.cs`. - Add component tests in `SettingsTests.cs` using bUnit and FakeTimeProvider. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent bc12665 commit d38aebe

File tree

9 files changed

+322
-19
lines changed

9 files changed

+322
-19
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
</PropertyGroup>
55
<ItemGroup>
6+
<PackageVersion Include="bunit" Version="2.4.2" />
67
<PackageVersion Include="Mediator.Abstractions" Version="3.0.1" />
78
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.1" />
89
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">

Octans.Client/Components/Settings/Settings.razor

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<CascadingValue Value="ViewModel.Context">
88
<div class="settings-layout">
9-
<input class="settings-search" @bind="ViewModel.SearchText" placeholder="Search settings..."/>
9+
<input class="settings-search" @bind="ViewModel.SearchText" placeholder="Search settings..." data-test="settings-search"/>
1010
<div class="settings-body">
1111
<SettingsSidebar Pages="ViewModel.Context.Pages" ActivePage="ViewModel.ActivePage"
1212
OnSelectPage="SelectPage"/>
@@ -18,7 +18,7 @@
1818
"import"
1919
})>
2020
<Control>
21-
<input @bind="ViewModel.Settings.ImportSource"/>
21+
<input @bind="ViewModel.Settings.ImportSource" data-test="setting-import-source"/>
2222
</Control>
2323
</SettingItem>
2424
</SettingsPage>
@@ -29,7 +29,7 @@
2929
"colour", "tag"
3030
})>
3131
<Control>
32-
<input type="color" @bind="ViewModel.Settings.TagColor"/>
32+
<input type="color" @bind="ViewModel.Settings.TagColor" data-test="setting-tag-color"/>
3333
</Control>
3434
</SettingItem>
3535
</SettingsPage>
@@ -41,7 +41,7 @@
4141
"theme", "appearance"
4242
})>
4343
<Control>
44-
<select @bind="ViewModel.Settings.Theme" @bind:after="OnThemeChanged">
44+
<select @bind="ViewModel.Settings.Theme" @bind:after="OnThemeChanged" data-test="setting-theme">
4545
<option value="light">Light</option>
4646
<option value="dark">Dark</option>
4747
<option value="sepia">Sepia</option>
@@ -56,7 +56,7 @@
5656
"root", "directory", "storage"
5757
})>
5858
<Control>
59-
<input @bind="ViewModel.Settings.AppRoot"/>
59+
<input @bind="ViewModel.Settings.AppRoot" data-test="setting-app-root"/>
6060
</Control>
6161
</SettingItem>
6262

@@ -66,7 +66,7 @@
6666
"log", "level", "default"
6767
})>
6868
<Control>
69-
<select @bind="ViewModel.Settings.LogLevel">
69+
<select @bind="ViewModel.Settings.LogLevel" data-test="setting-log-level">
7070
@foreach (var level in ViewModel.AvailableLogLevels)
7171
{
7272
<option value="@level">@level</option>
@@ -81,7 +81,7 @@
8181
"log", "level", "aspnet"
8282
})>
8383
<Control>
84-
<select @bind="ViewModel.Settings.AspNetCoreLogLevel">
84+
<select @bind="ViewModel.Settings.AspNetCoreLogLevel" data-test="setting-aspnet-log-level">
8585
@foreach (var level in ViewModel.AvailableLogLevels)
8686
{
8787
<option value="@level">@level</option>
@@ -94,18 +94,18 @@
9494
</CascadingValue>
9595
@if (ViewModel.SaveSuccess)
9696
{
97-
<div class="alert alert-success" role="alert">
97+
<div class="alert alert-success" role="alert" data-test="save-success-message">
9898
Configuration saved successfully!
9999
</div>
100100
}
101101
@if (ViewModel.SaveError)
102102
{
103-
<div class="alert alert-danger" role="alert">
103+
<div class="alert alert-danger" role="alert" data-test="save-error-message">
104104
Error saving configuration: @ViewModel.ErrorMessage
105105
</div>
106106
}
107107
<div class="settings-action">
108-
<button class="btn btn-primary" @onclick="SaveConfig" disabled="@ViewModel.IsSaving">
108+
<button class="btn btn-primary" @onclick="SaveConfig" disabled="@ViewModel.IsSaving" data-test="save-settings-button">
109109
@if (ViewModel.IsSaving)
110110
{
111111
<span>Saving...</span>

Octans.Client/Components/Settings/SettingsSidebar.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<ul class="settings-sidebar">
22
@foreach (var p in Pages)
33
{
4-
<li class="@(p == ActivePage ? "active" : null)" @onclick="@(() => OnSelectPage.InvokeAsync(p))">@p.Title</li>
4+
<li class="@(p == ActivePage ? "active" : null)" @onclick="@(() => OnSelectPage.InvokeAsync(p))" data-test="settings-page-@p.Title">@p.Title</li>
55
}
66
</ul>
77

Octans.Client/Components/Settings/SettingsViewModel.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.JSInterop;
2+
using Octans.Client.Services;
23
using Octans.Client.Settings;
34
using Octans.Core.Communication;
45

@@ -7,8 +8,9 @@ namespace Octans.Client.Components.Settings;
78
public sealed class SettingsViewModel(
89
ISettingsService settingsService,
910
ILogger<SettingsViewModel> logger,
10-
IJSRuntime jsRuntime,
11-
ThemeService themeService) : IAsyncDisposable, INotifyStateChanged
11+
IThemeJsInterop themeJsInterop,
12+
ThemeService themeService,
13+
TimeProvider timeProvider) : IDisposable, INotifyStateChanged
1214
{
1315
public SettingsContext Context { get; } = new();
1416
public SettingsModel Settings { get; } = new();
@@ -38,7 +40,7 @@ public async Task InitializeAsync()
3840
Settings.ImportSource = loaded.ImportSource;
3941
Settings.TagColor = loaded.TagColor;
4042

41-
var savedTheme = await jsRuntime.InvokeAsync<string>("themeManager.loadThemePreference");
43+
var savedTheme = await themeJsInterop.LoadThemePreferenceAsync();
4244
if (!string.IsNullOrEmpty(savedTheme))
4345
{
4446
Settings.Theme = savedTheme;
@@ -55,8 +57,8 @@ public async Task ThemeChanged()
5557

5658
private async Task ApplyTheme()
5759
{
58-
await jsRuntime.InvokeVoidAsync("themeManager.setTheme", themeService.CurrentTheme);
59-
await jsRuntime.InvokeVoidAsync("themeManager.saveThemePreference", themeService.CurrentTheme);
60+
await themeJsInterop.SetThemeAsync(themeService.CurrentTheme);
61+
await themeJsInterop.SaveThemePreferenceAsync(themeService.CurrentTheme);
6062
}
6163

6264
public async Task SaveConfiguration()
@@ -77,7 +79,7 @@ public async Task SaveConfiguration()
7779

7880
await OnStateChanged();
7981

80-
await Task.Delay(3000);
82+
await Task.Delay(TimeSpan.FromSeconds(3), timeProvider);
8183

8284
SaveSuccess = false;
8385
}
@@ -101,9 +103,8 @@ private async Task OnStateChanged()
101103
}
102104
}
103105

104-
public ValueTask DisposeAsync()
106+
public void Dispose()
105107
{
106108
themeService.OnThemeChanged -= ApplyTheme;
107-
return ValueTask.CompletedTask;
108109
}
109110
}

Octans.Client/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
177177
services.AddScoped<IBrowserStorage, BrowserStorage>();
178178
services.AddScoped<IClipboard, Clipboard>();
179179
services.AddScoped<ThemeService>();
180+
services.AddScoped<IThemeJsInterop, ThemeJsInterop>();
180181
services.AddScoped<ShellService>();
181182
services.AddScoped<ISettingsService, SettingsService>();
182183

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.JSInterop;
2+
3+
namespace Octans.Client.Services;
4+
5+
public interface IThemeJsInterop
6+
{
7+
Task<string> LoadThemePreferenceAsync();
8+
Task SetThemeAsync(string theme);
9+
Task SaveThemePreferenceAsync(string theme);
10+
}
11+
12+
public class ThemeJsInterop(IJSRuntime jsRuntime) : IThemeJsInterop
13+
{
14+
public async Task<string> LoadThemePreferenceAsync()
15+
{
16+
return await jsRuntime.InvokeAsync<string>("themeManager.loadThemePreference");
17+
}
18+
19+
public async Task SetThemeAsync(string theme)
20+
{
21+
await jsRuntime.InvokeVoidAsync("themeManager.setTheme", theme);
22+
}
23+
24+
public async Task SaveThemePreferenceAsync(string theme)
25+
{
26+
await jsRuntime.InvokeVoidAsync("themeManager.saveThemePreference", theme);
27+
}
28+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using Bunit;
2+
using FluentAssertions;
3+
using Microsoft.AspNetCore.Components;
4+
using Microsoft.AspNetCore.Components.Web;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Time.Testing;
8+
using Microsoft.JSInterop;
9+
using NSubstitute;
10+
using Octans.Client;
11+
using Octans.Client.Components.Settings;
12+
using Octans.Client.Services;
13+
using Octans.Client.Settings;
14+
using Xunit;
15+
16+
namespace Octans.Tests.Client.Components.Settings;
17+
18+
#pragma warning disable CS0618 // Type or member is obsolete
19+
public class SettingsTests : TestContext
20+
{
21+
#pragma warning restore CS0618 // Type or member is obsolete
22+
private readonly ISettingsService _settingsService;
23+
private readonly ThemeService _themeService;
24+
private readonly FakeTimeProvider _timeProvider;
25+
private readonly IThemeJsInterop _themeJsInterop;
26+
27+
public SettingsTests()
28+
{
29+
_settingsService = Substitute.For<ISettingsService>();
30+
_themeService = new ThemeService();
31+
_timeProvider = new FakeTimeProvider();
32+
_themeJsInterop = Substitute.For<IThemeJsInterop>();
33+
_themeJsInterop.LoadThemePreferenceAsync().Returns("light");
34+
35+
Services.AddSingleton(_settingsService);
36+
Services.AddSingleton(_themeJsInterop);
37+
Services.AddSingleton(_themeService);
38+
Services.AddSingleton<TimeProvider>(_timeProvider);
39+
Services.AddLogging();
40+
41+
// Register the ViewModel
42+
Services.AddScoped<SettingsViewModel>();
43+
}
44+
45+
[Fact]
46+
public void ShouldRenderSettings()
47+
{
48+
// Arrange
49+
_settingsService.LoadAsync().Returns(new SettingsModel());
50+
51+
// Act
52+
// Using Render instead of RenderComponent as per warning
53+
var cut = Render<Octans.Client.Components.Settings.Settings>();
54+
55+
// Assert
56+
Assert.NotNull(cut.Find("[data-test='settings-search']"));
57+
Assert.NotNull(cut.Find("[data-test='save-settings-button']"));
58+
}
59+
60+
[Fact]
61+
public void ShouldLoadAndDisplaySettings()
62+
{
63+
// Arrange
64+
var settings = new SettingsModel
65+
{
66+
ImportSource = "test-import",
67+
AppRoot = "test-root"
68+
};
69+
_settingsService.LoadAsync().Returns(settings);
70+
71+
// Act
72+
var cut = Render<Octans.Client.Components.Settings.Settings>();
73+
74+
// Assert
75+
// Access VM via Services
76+
var vm = Services.GetRequiredService<SettingsViewModel>();
77+
cut.WaitForState(() => vm.Settings.ImportSource == "test-import");
78+
79+
var importInput = cut.Find("[data-test='setting-import-source']");
80+
Assert.Equal("test-import", importInput.Attributes["value"]?.Value);
81+
}
82+
83+
[Fact]
84+
public void ShouldNavigatePages()
85+
{
86+
// Arrange
87+
_settingsService.LoadAsync().Returns(new SettingsModel());
88+
var cut = Render<Octans.Client.Components.Settings.Settings>();
89+
90+
// Act
91+
// Find "System" page link
92+
var systemPageLink = cut.Find("[data-test='settings-page-System']");
93+
systemPageLink.Click();
94+
95+
// Assert
96+
// Now "System" page content should be visible
97+
// Check for "App Root" input which is on System page
98+
Assert.NotNull(cut.Find("[data-test='setting-app-root']"));
99+
}
100+
101+
[Fact]
102+
public async Task ShouldSaveSettings()
103+
{
104+
// Arrange
105+
_settingsService.LoadAsync().Returns(new SettingsModel());
106+
var cut = Render<Octans.Client.Components.Settings.Settings>();
107+
108+
// Wait for load
109+
var vm = Services.GetRequiredService<SettingsViewModel>();
110+
cut.WaitForState(() => vm.Settings != null);
111+
112+
// Act
113+
var importInput = cut.Find("[data-test='setting-import-source']");
114+
await importInput.ChangeAsync(new ChangeEventArgs { Value = "new-import-source" });
115+
116+
var saveButton = cut.Find("[data-test='save-settings-button']");
117+
118+
// Start the click but don't await completion yet (as it waits for delay)
119+
var clickTask = saveButton.ClickAsync(new MouseEventArgs());
120+
121+
// Assert
122+
// Verify SaveAsync was called with new value
123+
await _settingsService.Received().SaveAsync(Arg.Is<SettingsModel>(s => s.ImportSource == "new-import-source"));
124+
125+
// Verify success message appears BEFORE delay finishes
126+
cut.WaitForState(() => cut.FindAll("[data-test='save-success-message']").Any());
127+
Assert.NotNull(cut.Find("[data-test='save-success-message']"));
128+
129+
// Now advance time to complete the delay
130+
_timeProvider.Advance(TimeSpan.FromSeconds(3));
131+
132+
// Await the click task to ensure clean exit
133+
await clickTask;
134+
135+
// Verify success message disappears
136+
cut.WaitForState(() => !cut.FindAll("[data-test='save-success-message']").Any());
137+
Assert.Empty(cut.FindAll("[data-test='save-success-message']"));
138+
}
139+
}

Octans.Tests/Octans.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
</ItemGroup>
1313

1414
<ItemGroup>
15+
<PackageReference Include="bunit" />
1516
<PackageReference Include="FluentAssertions" />
1617
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
1718
<PackageReference Include="Microsoft.NET.Test.Sdk" />

0 commit comments

Comments
 (0)