Skip to content

Commit e8feda6

Browse files
mpauloskyCopilot
andauthored
test: bUnit tests for LabelInput component and label filter chips (#153) (#181)
- Add LabelInputTests.cs with 10 tests covering render, pill display, label add/remove, max-labels guard, duplicate prevention, and autocomplete suggestion fetch and click - Add LabelFilterChipTests.cs with 3 tests covering label chip click to activate filter, active filter X removal, and clear-all - Fix FakeNavigationManager to override NavigateToCore(string, NavigationOptions) so URL navigation in Index.razor works correctly in bUnit context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9966fa7 commit e8feda6

3 files changed

Lines changed: 358 additions & 0 deletions

File tree

tests/Web.Tests.Bunit/BunitTestBase.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,4 +285,9 @@ protected override void NavigateToCore(string uri, bool forceLoad)
285285
{
286286
Uri = ToAbsoluteUri(uri).ToString();
287287
}
288+
289+
protected override void NavigateToCore(string uri, NavigationOptions options)
290+
{
291+
Uri = ToAbsoluteUri(uri).ToString();
292+
}
288293
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// =======================================================
2+
// Copyright (c) 2025. All rights reserved.
3+
// File Name : LabelFilterChipTests.cs
4+
// Company : mpaulosky
5+
// Author : Matthew Paulosky
6+
// Solution Name : IssueTrackerApp
7+
// Project Name : Web.Tests.Bunit
8+
// =======================================================
9+
10+
namespace Web.Tests.Bunit.Components.Pages.Issues;
11+
12+
using IssuesIndexPage = Web.Components.Pages.Issues.Index;
13+
14+
/// <summary>
15+
/// Tests for label filter chip behaviour on the Issues Index page.
16+
/// </summary>
17+
public sealed class LabelFilterChipTests : BunitTestBase
18+
{
19+
/// <summary>
20+
/// Builds a <see cref="PagedResult{T}"/> containing the supplied issues.
21+
/// </summary>
22+
private static PagedResult<IssueDto> BuildPagedResult(params IssueDto[] issues) =>
23+
PagedResult<IssueDto>.Create(issues, issues.Length, 1, 20);
24+
25+
/// <summary>
26+
/// Creates a test issue that carries the given labels.
27+
/// </summary>
28+
private static IssueDto CreateIssueWithLabels(params string[] labels) =>
29+
CreateTestIssue() with { Labels = labels.ToList() };
30+
31+
#region Label chip on issue card
32+
33+
[Fact]
34+
public async Task LabelFilterChip_WhenLabelClicked_AddsToFilterState()
35+
{
36+
// Arrange — return one issue whose card has a "priority" label chip
37+
var issue = CreateIssueWithLabels("priority");
38+
IssueService.SearchIssuesAsync(Arg.Any<IssueSearchRequest>(), Arg.Any<CancellationToken>())
39+
.Returns(Task.FromResult(Result.Ok(BuildPagedResult(issue))));
40+
41+
var cut = Render<IssuesIndexPage>();
42+
43+
// Wait for the async OnInitializedAsync to complete and issues to appear
44+
await cut.WaitForStateAsync(
45+
() => cut.Markup.Contains("priority"),
46+
TimeSpan.FromSeconds(3));
47+
48+
// Act — click the label chip on the issue card
49+
var labelChip = cut.Find("button[title='Filter by this label']");
50+
await cut.InvokeAsync(() => labelChip.Click());
51+
52+
// Assert — the active-filters bar is now visible
53+
await cut.WaitForStateAsync(
54+
() => cut.Markup.Contains("Active label filters:"),
55+
TimeSpan.FromSeconds(2));
56+
57+
cut.Markup.Should().Contain("Active label filters:");
58+
cut.Markup.Should().Contain("priority");
59+
}
60+
61+
#endregion
62+
63+
#region Active filter chip removal
64+
65+
[Fact]
66+
public async Task ActiveFilter_WhenXClicked_RemovesFilter()
67+
{
68+
// Arrange — navigate to a URL that pre-populates the label filter via query params
69+
Services.GetRequiredService<NavigationManager>().NavigateTo("/?label=bug");
70+
71+
// SearchIssuesAsync is called on init; return empty so the page loads quickly
72+
IssueService.SearchIssuesAsync(Arg.Any<IssueSearchRequest>(), Arg.Any<CancellationToken>())
73+
.Returns(Task.FromResult(Result.Ok(PagedResult<IssueDto>.Empty)));
74+
75+
var cut = Render<IssuesIndexPage>();
76+
77+
// Wait for the active-label-filter bar to be rendered (parsed from query params)
78+
await cut.WaitForStateAsync(
79+
() => cut.Markup.Contains("Active label filters:"),
80+
TimeSpan.FromSeconds(3));
81+
82+
// Act — click the "bug" filter button (which also contains the × SVG)
83+
var filterChip = cut.Find("button.rounded-full");
84+
await cut.InvokeAsync(() => filterChip.Click());
85+
86+
// Assert — active filter section is gone
87+
await cut.WaitForStateAsync(
88+
() => !cut.Markup.Contains("Active label filters:"),
89+
TimeSpan.FromSeconds(2));
90+
91+
cut.Markup.Should().NotContain("Active label filters:");
92+
}
93+
94+
[Fact]
95+
public async Task ActiveFilter_ClearAll_RemovesAllFilters()
96+
{
97+
// Arrange — pre-populate two label filters via URL query params
98+
Services.GetRequiredService<NavigationManager>().NavigateTo("/?label=bug,feature");
99+
100+
IssueService.SearchIssuesAsync(Arg.Any<IssueSearchRequest>(), Arg.Any<CancellationToken>())
101+
.Returns(Task.FromResult(Result.Ok(PagedResult<IssueDto>.Empty)));
102+
103+
var cut = Render<IssuesIndexPage>();
104+
105+
// Wait for the active-label-filter bar to be rendered
106+
await cut.WaitForStateAsync(
107+
() => cut.Markup.Contains("Active label filters:"),
108+
TimeSpan.FromSeconds(3));
109+
110+
// Both labels should appear as active chips
111+
cut.Markup.Should().Contain("bug");
112+
cut.Markup.Should().Contain("feature");
113+
114+
// Act — click "Clear all"
115+
var clearAll = cut.Find("button.underline");
116+
await cut.InvokeAsync(() => clearAll.Click());
117+
118+
// Assert — active filter section is gone
119+
await cut.WaitForStateAsync(
120+
() => !cut.Markup.Contains("Active label filters:"),
121+
TimeSpan.FromSeconds(2));
122+
123+
cut.Markup.Should().NotContain("Active label filters:");
124+
}
125+
126+
#endregion
127+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// =======================================================
2+
// Copyright (c) 2025. All rights reserved.
3+
// File Name : LabelInputTests.cs
4+
// Company : mpaulosky
5+
// Author : Matthew Paulosky
6+
// Solution Name : IssueTrackerApp
7+
// Project Name : Web.Tests.Bunit
8+
// =======================================================
9+
10+
using Web.Components.Shared;
11+
12+
namespace Web.Tests.Bunit.Components.Shared;
13+
14+
/// <summary>
15+
/// Tests for the LabelInput component.
16+
/// </summary>
17+
public sealed class LabelInputTests : BunitTestBase
18+
{
19+
#region Render Tests
20+
21+
[Fact]
22+
public void Renders_WithNoLabels_ShowsPlaceholderText()
23+
{
24+
// Arrange & Act
25+
var cut = Render<LabelInput>(parameters => parameters
26+
.Add(p => p.Labels, [])
27+
);
28+
29+
// Assert
30+
var input = cut.Find("input#label-input");
31+
input.Should().NotBeNull();
32+
input.GetAttribute("placeholder").Should().Contain("Add labels");
33+
}
34+
35+
[Fact]
36+
public void Renders_WithLabels_ShowsPillsForEachLabel()
37+
{
38+
// Arrange & Act
39+
var cut = Render<LabelInput>(parameters => parameters
40+
.Add(p => p.Labels, ["bug", "v2"])
41+
);
42+
43+
// Assert — each label pill is a <span> with the label text
44+
var pills = cut.FindAll("span.rounded");
45+
pills.Should().HaveCount(2);
46+
cut.Markup.Should().Contain("bug");
47+
cut.Markup.Should().Contain("v2");
48+
}
49+
50+
[Fact]
51+
public async Task RemoveButton_WhenClicked_RemovesLabel()
52+
{
53+
// Arrange
54+
List<string>? capturedLabels = null;
55+
var cut = Render<LabelInput>(parameters => parameters
56+
.Add(p => p.Labels, ["bug", "v2"])
57+
.Add(p => p.LabelsChanged,
58+
EventCallback.Factory.Create<List<string>>(this, list => capturedLabels = list))
59+
);
60+
61+
// Act — click the remove button for "bug"
62+
var removeButton = cut.Find("button[aria-label='Remove label bug']");
63+
await cut.InvokeAsync(() => removeButton.Click());
64+
65+
// Assert
66+
capturedLabels.Should().NotBeNull();
67+
capturedLabels.Should().NotContain("bug");
68+
capturedLabels.Should().Contain("v2");
69+
}
70+
71+
[Fact]
72+
public async Task Input_WhenEnterPressed_AddsLabel()
73+
{
74+
// Arrange
75+
List<string>? capturedLabels = null;
76+
var cut = Render<LabelInput>(parameters => parameters
77+
.Add(p => p.Labels, [])
78+
.Add(p => p.LabelsChanged,
79+
EventCallback.Factory.Create<List<string>>(this, list => capturedLabels = list))
80+
);
81+
82+
var input = cut.Find("input#label-input");
83+
84+
// Act — set input value then press Enter
85+
await cut.InvokeAsync(() => input.Input("feature"));
86+
await cut.InvokeAsync(() => input.KeyDown(Key.Enter));
87+
88+
// Assert
89+
capturedLabels.Should().NotBeNull();
90+
capturedLabels.Should().Contain("feature");
91+
}
92+
93+
[Fact]
94+
public async Task Input_WhenCommaTyped_AddsLabel()
95+
{
96+
// Arrange
97+
List<string>? capturedLabels = null;
98+
var cut = Render<LabelInput>(parameters => parameters
99+
.Add(p => p.Labels, [])
100+
.Add(p => p.LabelsChanged,
101+
EventCallback.Factory.Create<List<string>>(this, list => capturedLabels = list))
102+
);
103+
104+
var input = cut.Find("input#label-input");
105+
106+
// Act — simulate typing "bug" then pressing the comma key
107+
await cut.InvokeAsync(() => input.Input("bug"));
108+
await cut.InvokeAsync(() => input.KeyDown(","));
109+
110+
// Assert
111+
capturedLabels.Should().NotBeNull();
112+
capturedLabels.Should().Contain("bug");
113+
}
114+
115+
[Fact]
116+
public void Input_AtMaxLabels_HidesInput()
117+
{
118+
// Arrange — 10 labels (== MaxLabels default)
119+
var labels = Enumerable.Range(1, 10).Select(i => $"label-{i}").ToList();
120+
121+
// Act
122+
var cut = Render<LabelInput>(parameters => parameters
123+
.Add(p => p.Labels, labels)
124+
);
125+
126+
// Assert
127+
cut.FindAll("input#label-input").Should().BeEmpty();
128+
}
129+
130+
[Fact]
131+
public void Input_AtMaxLabels_ShowsWarning()
132+
{
133+
// Arrange — 10 labels (== MaxLabels default)
134+
var labels = Enumerable.Range(1, 10).Select(i => $"label-{i}").ToList();
135+
136+
// Act
137+
var cut = Render<LabelInput>(parameters => parameters
138+
.Add(p => p.Labels, labels)
139+
);
140+
141+
// Assert
142+
var warning = cut.Find("p[role='status']");
143+
warning.Should().NotBeNull();
144+
warning.TextContent.Should().Contain("Maximum of 10 labels reached");
145+
}
146+
147+
[Fact]
148+
public async Task Input_WhenDuplicateLabel_DoesNotAdd()
149+
{
150+
// Arrange
151+
var callCount = 0;
152+
var cut = Render<LabelInput>(parameters => parameters
153+
.Add(p => p.Labels, ["bug"])
154+
.Add(p => p.LabelsChanged,
155+
EventCallback.Factory.Create<List<string>>(this, _ => callCount++))
156+
);
157+
158+
var input = cut.Find("input#label-input");
159+
160+
// Act — attempt to add "bug" again (case-insensitive duplicate)
161+
await cut.InvokeAsync(() => input.Input("bug"));
162+
await cut.InvokeAsync(() => input.KeyDown(Key.Enter));
163+
164+
// Assert — callback should NOT have been fired (duplicate is silently ignored)
165+
callCount.Should().Be(0);
166+
}
167+
168+
[Fact]
169+
public async Task Autocomplete_WhenTextTyped_CallsLabelService()
170+
{
171+
// Arrange — override mock to return suggestions
172+
LabelService.GetSuggestionsAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
173+
.Returns(Task.FromResult<IReadOnlyList<string>>(["bug", "feature", "v2"]));
174+
175+
var cut = Render<LabelInput>(parameters => parameters
176+
.Add(p => p.Labels, [])
177+
);
178+
179+
var input = cut.Find("input#label-input");
180+
181+
// Act — type enough characters to trigger a suggestion fetch
182+
await cut.InvokeAsync(() => input.Input("fea"));
183+
184+
// Wait for the 300 ms debounce to elapse and the async fetch to complete
185+
await Task.Delay(500);
186+
187+
// Assert
188+
await LabelService.Received(1)
189+
.GetSuggestionsAsync(Arg.Is("fea"), Arg.Any<int>(), Arg.Any<CancellationToken>());
190+
}
191+
192+
[Fact]
193+
public async Task Autocomplete_WhenSuggestionClicked_AddsLabel()
194+
{
195+
// Arrange — override mock to return suggestions
196+
LabelService.GetSuggestionsAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
197+
.Returns(Task.FromResult<IReadOnlyList<string>>(["feature", "bug", "v2"]));
198+
199+
List<string>? capturedLabels = null;
200+
var cut = Render<LabelInput>(parameters => parameters
201+
.Add(p => p.Labels, [])
202+
.Add(p => p.LabelsChanged,
203+
EventCallback.Factory.Create<List<string>>(this, list => capturedLabels = list))
204+
);
205+
206+
var input = cut.Find("input#label-input");
207+
208+
// Act — type text to trigger autocomplete
209+
await cut.InvokeAsync(() => input.Input("fea"));
210+
211+
// Wait for debounce + StateHasChanged re-render
212+
await cut.WaitForStateAsync(
213+
() => cut.FindAll("button[role='option']").Count > 0,
214+
TimeSpan.FromSeconds(2));
215+
216+
// Click the first suggestion
217+
var suggestion = cut.Find("button[role='option']");
218+
await cut.InvokeAsync(() => suggestion.Click());
219+
220+
// Assert
221+
capturedLabels.Should().NotBeNull();
222+
capturedLabels.Should().Contain("feature");
223+
}
224+
225+
#endregion
226+
}

0 commit comments

Comments
 (0)