Basics Done
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<Playwright>
|
||||
<BrowserName>chromium</BrowserName>
|
||||
<LaunchOptions>
|
||||
<Headless>true</Headless>
|
||||
</LaunchOptions>
|
||||
</Playwright>
|
||||
</RunSettings>
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Launches the demo Blazor app as a separate process on a random free port.
|
||||
/// Shared across all tests in the assembly via [SetUpFixture].
|
||||
/// </summary>
|
||||
public sealed class DemoServerFixture : IDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
|
||||
public string BaseUrl { get; private set; } = string.Empty;
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
BaseUrl = $"http://localhost:{port}";
|
||||
|
||||
// Resolve the demo project directory (navigate up from test bin output)
|
||||
var testDir = AppContext.BaseDirectory; // …Tests/bin/Debug/net9.0
|
||||
var solutionRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", ".."));
|
||||
var demoProjectDir = Path.Combine(solutionRoot, "Enciphered.Blazor.UIComponents.Demo");
|
||||
|
||||
if (!Directory.Exists(demoProjectDir))
|
||||
throw new DirectoryNotFoundException($"Demo project not found at: {demoProjectDir}");
|
||||
|
||||
_process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run --no-build --urls {BaseUrl}",
|
||||
WorkingDirectory = demoProjectDir,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
Environment =
|
||||
{
|
||||
["ASPNETCORE_ENVIRONMENT"] = "Development",
|
||||
["DOTNET_NOLOGO"] = "1"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_process.Start();
|
||||
|
||||
// Wait for the server to be ready by polling the URL
|
||||
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync(BaseUrl);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Server not ready yet
|
||||
}
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Demo server did not start within 30 seconds at {BaseUrl}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_process is not null && !_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
_process.WaitForExit(5000);
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.59.0" />
|
||||
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="NUnit.Framework" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,605 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class FormsTests : PlaywrightTestBase
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task GoToFormsAsync()
|
||||
{
|
||||
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
// Wait for Blazor interactive mode to be ready
|
||||
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||
}
|
||||
|
||||
private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||
private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||
private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||
|
||||
/// <summary>
|
||||
/// Select a date via the calendar popover.
|
||||
/// Opens the trigger, uses month/year pickers to navigate, then clicks the day.
|
||||
/// </summary>
|
||||
private async Task SelectDateAsync(string triggerId, DateOnly target)
|
||||
{
|
||||
// Open the popover
|
||||
await Trigger(triggerId).ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(200);
|
||||
|
||||
await NavigateCalendarToDate(target);
|
||||
|
||||
// Click the target day (only enabled buttons in the calendar day grid)
|
||||
// The day grid is the last grid-cols-7 div; find the button with matching day text
|
||||
var dayGrid = Page.Locator(".grid.grid-cols-7").Last;
|
||||
var dayButton = dayGrid.Locator($"button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First;
|
||||
await dayButton.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigate the open calendar to a specific month/year using the month and year pickers.
|
||||
/// </summary>
|
||||
private async Task NavigateCalendarToDate(DateOnly target)
|
||||
{
|
||||
// Click year header to open year picker, then select the year
|
||||
var yearButton = Page.Locator("[data-calendar-year]");
|
||||
await yearButton.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
|
||||
// The year picker is a scrollable grid; find and click the target year
|
||||
var yearGrid = Page.Locator(".grid.grid-cols-4");
|
||||
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
|
||||
|
||||
// If the year isn't visible, use prev/next to shift the year range (±20 per click)
|
||||
var attempts = 0;
|
||||
while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
|
||||
{
|
||||
// Read the first year button text to determine which direction to go
|
||||
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
|
||||
var firstYear = int.Parse(firstYearText.Trim());
|
||||
|
||||
if (target.Year < firstYear)
|
||||
await Page.Locator("button[aria-label='Previous month']").ClickAsync();
|
||||
else
|
||||
await Page.Locator("button[aria-label='Next month']").ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(50);
|
||||
yearGrid = Page.Locator(".grid.grid-cols-4");
|
||||
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
|
||||
attempts++;
|
||||
}
|
||||
|
||||
await targetYearBtn.First.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
|
||||
// Now click month header to open month picker, then select the month
|
||||
var monthButton = Page.Locator("[data-calendar-month]");
|
||||
await monthButton.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
|
||||
var monthGrid = Page.Locator(".grid.grid-cols-3");
|
||||
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
|
||||
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select a time via the time picker popover.
|
||||
/// Opens the trigger, clicks the hour, minute, and AM/PM.
|
||||
/// </summary>
|
||||
private async Task SelectTimeAsync(string triggerId, int hour, int minute)
|
||||
{
|
||||
// Open the popover
|
||||
await Trigger(triggerId).ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(200);
|
||||
|
||||
await PickTimeInOpenPopover(hour, minute);
|
||||
|
||||
// Close popover by clicking the backdrop overlay
|
||||
await Page.Locator(".fixed.inset-0.z-40").ClickAsync(new LocatorClickOptions { Force = true });
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick hour, minute, and AM/PM in an already-open time picker.
|
||||
/// Scopes all locators to the visible popover content to avoid backdrop interception.
|
||||
/// </summary>
|
||||
private async Task PickTimeInOpenPopover(int hour, int minute)
|
||||
{
|
||||
// The popover content sits in a z-50 absolutely positioned container
|
||||
var popoverContent = Page.Locator(".absolute.z-50");
|
||||
|
||||
// Convert to 12-hour format
|
||||
var isPm = hour >= 12;
|
||||
var hour12 = hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
|
||||
// Click the hour in the first scrollable column (within the popover)
|
||||
var hourText = hour12.ToString("D2");
|
||||
var hourColumn = popoverContent.Locator(".scrollbar-thin").First;
|
||||
await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(50);
|
||||
|
||||
// Click the minute in the second scrollable column (within the popover)
|
||||
var minuteText = minute.ToString("D2");
|
||||
var minuteColumn = popoverContent.Locator(".scrollbar-thin").Nth(1);
|
||||
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(50);
|
||||
|
||||
// Click AM/PM (within the popover)
|
||||
var periodText = isPm ? "PM" : "AM";
|
||||
await popoverContent.Locator($"button:has-text('{periodText}')").First.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(50);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Rendering
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task FormsPage_Loads_Successfully()
|
||||
{
|
||||
var response = await Page.GotoAsync($"{BaseUrl}/forms");
|
||||
Assert.That(response!.Status, Is.EqualTo(200));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FormsPage_Has_Title()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
|
||||
await Expect(heading).ToHaveTextAsync("Forms Demo");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task All_Inputs_Are_Rendered()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Expect(Input("input-name")).ToBeVisibleAsync();
|
||||
await Expect(Input("input-email")).ToBeVisibleAsync();
|
||||
await Expect(Input("input-password")).ToBeVisibleAsync();
|
||||
await Expect(Input("input-age")).ToBeVisibleAsync();
|
||||
// Date/Time/DateTime use popover triggers instead of visible native inputs
|
||||
await Expect(Trigger("trigger-birthdate")).ToBeVisibleAsync();
|
||||
await Expect(Trigger("trigger-preferredtime")).ToBeVisibleAsync();
|
||||
await Expect(Trigger("trigger-appointment-date")).ToBeVisibleAsync();
|
||||
await Expect(Trigger("trigger-appointment-time")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task All_Buttons_Are_Rendered()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Expect(Btn("btn-submit")).ToBeVisibleAsync();
|
||||
await Expect(Btn("btn-reset")).ToBeVisibleAsync();
|
||||
await Expect(Btn("btn-disabled")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Input types
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task TextInput_Has_Correct_Type()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-name")).ToHaveAttributeAsync("type", "text");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EmailInput_Has_Correct_Type()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-email")).ToHaveAttributeAsync("type", "email");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PasswordInput_Has_Correct_Type()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-password")).ToHaveAttributeAsync("type", "password");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NumberInput_Has_Correct_Type()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-age")).ToHaveAttributeAsync("type", "number");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DateInput_Has_Correct_Type()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-birthdate")).ToHaveAttributeAsync("type", "date");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TimeInput_Has_Correct_Type()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-time")).ToHaveAttributeAsync("type", "time");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DateTimeInput_Has_Correct_Type()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-appointment")).ToHaveAttributeAsync("type", "datetime-local");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Placeholders
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task TextInput_Shows_Placeholder()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-name")).ToHaveAttributeAsync("placeholder", "Jane Doe");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EmailInput_Shows_Placeholder()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
await Expect(Input("input-email")).ToHaveAttributeAsync("placeholder", "jane@example.com");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Labels
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task FormFields_Have_Labels()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var labels = Page.Locator("label");
|
||||
var count = await labels.CountAsync();
|
||||
Assert.That(count, Is.EqualTo(7), "Expected 7 labels (one per form field)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Label_For_Attribute_Matches_Input_Id()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var label = Page.Locator("label[for='name']");
|
||||
await Expect(label).ToHaveTextAsync("Full Name");
|
||||
|
||||
var input = Input("input-name");
|
||||
await Expect(input).ToHaveAttributeAsync("id", "name");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Two-way binding
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task TextInput_Binds_Value()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-name");
|
||||
await input.FillAsync("Alice");
|
||||
await Expect(input).ToHaveValueAsync("Alice");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NumberInput_Binds_Value()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-age");
|
||||
await input.FillAsync("30");
|
||||
await Expect(input).ToHaveValueAsync("30");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DateInput_Binds_Value()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
// Use the calendar popover to select June 15, 2000
|
||||
await SelectDateAsync("trigger-birthdate", new DateOnly(2000, 6, 15));
|
||||
|
||||
// The hidden input should reflect the selected date
|
||||
await Expect(Input("input-birthdate")).ToHaveValueAsync("2000-06-15");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TimeInput_Binds_Value()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
// Use the time picker popover to select 14:30 (2:30 PM)
|
||||
await SelectTimeAsync("trigger-preferredtime", 14, 30);
|
||||
|
||||
// The hidden input should reflect the selected time
|
||||
await Expect(Input("input-time")).ToHaveValueAsync("14:30");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DateTimeInput_Binds_Value()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
// Pick the date part via the date trigger
|
||||
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
|
||||
|
||||
// Pick the time part via the time trigger
|
||||
await SelectTimeAsync("trigger-appointment-time", 10, 0);
|
||||
|
||||
// The hidden input should have the combined datetime value
|
||||
var value = await Input("input-appointment").InputValueAsync();
|
||||
Assert.That(value, Does.StartWith("2025-12-25T10:00"));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Validation — empty submit shows errors
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Submit_Shows_Validation_Errors()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
|
||||
// Wait for at least one error message to appear
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var errors = Page.Locator("p.text-destructive");
|
||||
var count = await errors.CountAsync();
|
||||
Assert.That(count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 validation errors (one per required field)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Validation_Error_Shows_Name_Required()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var nameError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Name is required" });
|
||||
await Expect(nameError).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Validation_Error_Shows_Email_Required()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var emailError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Email is required" });
|
||||
await Expect(emailError).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Validation — specific error messages
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Short_Name_Shows_Length_Error()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-name");
|
||||
await input.FillAsync("A");
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "2–100 characters" });
|
||||
await Expect(error).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Email_Shows_Error()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-email");
|
||||
await input.FillAsync("not-an-email");
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Invalid email" });
|
||||
await Expect(error).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Short_Password_Shows_Error()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-password");
|
||||
await input.FillAsync("123");
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "8–64 characters" });
|
||||
await Expect(error).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Valid submission
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Valid_Form_Shows_Success_Message()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Input("input-name").FillAsync("Jane Doe");
|
||||
await Input("input-email").FillAsync("jane@example.com");
|
||||
await Input("input-password").FillAsync("securepassword123");
|
||||
await Input("input-age").FillAsync("30");
|
||||
|
||||
// Use popover pickers for date/time fields
|
||||
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
|
||||
await SelectTimeAsync("trigger-preferredtime", 9, 30);
|
||||
|
||||
// DateTime: pick date and time via separate triggers
|
||||
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
|
||||
await SelectTimeAsync("trigger-appointment-time", 10, 0);
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
|
||||
var success = Page.Locator("[data-testid='success-message']");
|
||||
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
|
||||
await Expect(success).ToContainTextAsync("Form submitted successfully");
|
||||
await Expect(success).ToContainTextAsync("Jane Doe");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task No_Success_Message_Before_Submit()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var success = Page.Locator("[data-testid='success-message']");
|
||||
await Expect(success).ToBeHiddenAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Button variants
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Submit_Button_Has_Default_Variant_Classes()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var btn = Btn("btn-submit");
|
||||
var cls = await btn.GetAttributeAsync("class");
|
||||
Assert.That(cls, Does.Contain("bg-primary"), "Submit should use default variant");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_Button_Has_Outline_Variant_Classes()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var btn = Btn("btn-reset");
|
||||
var cls = await btn.GetAttributeAsync("class");
|
||||
Assert.That(cls, Does.Contain("border"), "Reset should use outline variant");
|
||||
Assert.That(cls, Does.Contain("bg-background"), "Reset should use outline variant");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Disabled_Button_Is_Actually_Disabled()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var btn = Btn("btn-disabled");
|
||||
await Expect(btn).ToBeDisabledAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Disabled_Button_Has_Destructive_Variant()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var btn = Btn("btn-disabled");
|
||||
var cls = await btn.GetAttributeAsync("class");
|
||||
Assert.That(cls, Does.Contain("bg-destructive"), "Disabled button should have destructive variant");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Reset
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Reset_Button_Clears_Form()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
// Fill some fields
|
||||
await Input("input-name").FillAsync("Alice");
|
||||
await Input("input-email").FillAsync("alice@test.com");
|
||||
|
||||
// Reset
|
||||
await Btn("btn-reset").ClickAsync();
|
||||
|
||||
// Fields should be empty
|
||||
await Expect(Input("input-name")).ToHaveValueAsync("");
|
||||
await Expect(Input("input-email")).ToHaveValueAsync("");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_Button_Clears_Success_Message()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
// Submit valid form
|
||||
await Input("input-name").FillAsync("Jane Doe");
|
||||
await Input("input-email").FillAsync("jane@example.com");
|
||||
await Input("input-password").FillAsync("securepassword123");
|
||||
await Input("input-age").FillAsync("30");
|
||||
|
||||
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
|
||||
await SelectTimeAsync("trigger-preferredtime", 9, 30);
|
||||
|
||||
// DateTime picker — date and time via separate triggers
|
||||
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
|
||||
await SelectTimeAsync("trigger-appointment-time", 10, 0);
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
|
||||
var success = Page.Locator("[data-testid='success-message']");
|
||||
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
|
||||
|
||||
// Reset
|
||||
await Btn("btn-reset").ClickAsync();
|
||||
|
||||
await Expect(success).ToBeHiddenAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Input styling (base CSS classes present)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Inputs_Have_Base_Styling_Classes()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-name");
|
||||
var cls = await input.GetAttributeAsync("class");
|
||||
Assert.That(cls, Does.Contain("rounded-md"), "Input should have rounded-md class");
|
||||
Assert.That(cls, Does.Contain("border"), "Input should have border class");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Navigation to forms page
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Sidebar_Has_Forms_Link()
|
||||
{
|
||||
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||
|
||||
var formsLink = Page.Locator("a[href='/forms']");
|
||||
await Expect(formsLink).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Navigate_To_Forms_Via_Sidebar()
|
||||
{
|
||||
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||
|
||||
await Page.Locator("a[href='/forms']").ClickAsync();
|
||||
await Page.WaitForURLAsync($"{BaseUrl}/forms");
|
||||
|
||||
var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
|
||||
await Expect(heading).ToHaveTextAsync("Forms Demo");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Assembly-level setup: boots the demo server once before any test runs.
|
||||
/// </summary>
|
||||
[SetUpFixture]
|
||||
public class GlobalSetup
|
||||
{
|
||||
public static DemoServerFixture Server { get; private set; } = null!;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public async Task OneTimeSetUp()
|
||||
{
|
||||
Server = new DemoServerFixture();
|
||||
await Server.StartAsync();
|
||||
TestContext.Out.WriteLine($"Demo server started at {Server.BaseUrl}");
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void OneTimeTearDown()
|
||||
{
|
||||
Server?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for Playwright tests. Creates a fresh browser context per test.
|
||||
/// </summary>
|
||||
public abstract class PlaywrightTestBase
|
||||
{
|
||||
protected IBrowser Browser { get; private set; } = null!;
|
||||
protected IBrowserContext Context { get; private set; } = null!;
|
||||
protected IPage Page { get; private set; } = null!;
|
||||
protected string BaseUrl => GlobalSetup.Server.BaseUrl;
|
||||
|
||||
protected static ILocatorAssertions Expect(ILocator locator) => Assertions.Expect(locator);
|
||||
protected static IPageAssertions Expect(IPage page) => Assertions.Expect(page);
|
||||
|
||||
private static IPlaywright? _playwright;
|
||||
private static IBrowser? _sharedBrowser;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public async Task PlaywrightOneTimeSetUp()
|
||||
{
|
||||
_playwright ??= await Playwright.CreateAsync();
|
||||
_sharedBrowser ??= await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true
|
||||
});
|
||||
Browser = _sharedBrowser;
|
||||
}
|
||||
|
||||
[SetUp]
|
||||
public async Task PlaywrightSetUp()
|
||||
{
|
||||
Context = await Browser.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
IgnoreHTTPSErrors = true
|
||||
});
|
||||
Page = await Context.NewPageAsync();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task PlaywrightTearDown()
|
||||
{
|
||||
await Context.CloseAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class SidebarTests : PlaywrightTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper: navigate to home and wait for sidebar JS to initialize.
|
||||
/// </summary>
|
||||
private async Task GoHomeAsync()
|
||||
{
|
||||
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
|
||||
// Wait for the sidebar JS to apply state (data-state attribute appears on wrapper)
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 10_000
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Initial render
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Page_Loads_Successfully()
|
||||
{
|
||||
var response = await Page.GotoAsync(BaseUrl);
|
||||
Assert.That(response, Is.Not.Null);
|
||||
Assert.That(response!.Status, Is.EqualTo(200));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Sidebar_Wrapper_Has_DataState_After_Init()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
var state = await wrapper.GetAttributeAsync("data-state");
|
||||
Assert.That(state, Is.Not.Null.And.Not.Empty, "Wrapper should have data-state after JS init");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Sidebar_Starts_Expanded_On_Desktop()
|
||||
{
|
||||
// Ensure desktop viewport
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
var state = await wrapper.GetAttributeAsync("data-state");
|
||||
Assert.That(state, Is.EqualTo("expanded"), "Sidebar should start expanded on desktop");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Sidebar_Element_Exists()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var sidebar = Page.Locator("[data-sidebar]");
|
||||
await Expect(sidebar).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Sidebar_Trigger_Exists_On_Header()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
// The sidebar header itself is the trigger on desktop
|
||||
var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
|
||||
await Expect(trigger).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Sidebar_Has_Navigation_Links()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var sidebar = Page.Locator("[data-sidebar]");
|
||||
var links = sidebar.Locator("a[href]");
|
||||
var count = await links.CountAsync();
|
||||
Assert.That(count, Is.GreaterThanOrEqualTo(3), "Should have at least Home, Counter, Weather links");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Sidebar_Has_Footer()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var footer = Page.Locator("[data-sidebar-footer]");
|
||||
await Expect(footer).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Desktop toggle (collapse / expand)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Click_Trigger_Collapses_Sidebar()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Verify starts expanded
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||
|
||||
// Click the sidebar header (which is the trigger on desktop)
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
|
||||
// Wait for state change
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 5_000
|
||||
});
|
||||
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Click_Trigger_Twice_Re_Expands_Sidebar()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
|
||||
|
||||
// Collapse
|
||||
await trigger.ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
|
||||
// Expand
|
||||
await trigger.ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||
|
||||
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
|
||||
Assert.That(state, Is.EqualTo("expanded"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Collapsed_Sidebar_Has_Reduced_Width()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var sidebar = Page.Locator("[data-sidebar]");
|
||||
|
||||
// Get expanded width
|
||||
var expandedWidth = await sidebar.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||
Assert.That(expandedWidth, Is.GreaterThan(100), "Expanded sidebar should be wider than 100px");
|
||||
|
||||
// Collapse
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
|
||||
// Wait for CSS transition
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
|
||||
// Get collapsed width
|
||||
var collapsedWidth = await sidebar.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||
Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Collapsed sidebar should be narrower");
|
||||
Assert.That(collapsedWidth, Is.LessThanOrEqualTo(60), "Collapsed sidebar should be icon-only width (~3rem = 48px)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Collapsed_Hides_Menu_Labels()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Target only the menu item label spans (inside sidebar-content, not sidebar-header)
|
||||
var labelSpans = Page.Locator("[data-sidebar-content] a span.truncate");
|
||||
var countBefore = await labelSpans.CountAsync();
|
||||
Assert.That(countBefore, Is.GreaterThan(0), "Should have label spans");
|
||||
|
||||
// Collapse
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
|
||||
// Check that label text is hidden (CSS-driven via group-data-[state=collapsed])
|
||||
for (int i = 0; i < countBefore; i++)
|
||||
{
|
||||
var display = await labelSpans.Nth(i).EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||
Assert.That(display, Is.EqualTo("none"), $"Label span {i} should have display:none when sidebar is collapsed (got '{display}')");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Collapsed_Hides_Group_Label()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var groupLabel = Page.Locator("[data-sidebar-group-label]");
|
||||
await Expect(groupLabel).ToBeVisibleAsync();
|
||||
|
||||
// Collapse
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
|
||||
await Expect(groupLabel).Not.ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Spacer width tracks sidebar state
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Spacer_Width_Changes_On_Collapse()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var spacer = Page.Locator("[data-sidebar-spacer]");
|
||||
var expandedWidth = await spacer.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||
Assert.That(expandedWidth, Is.GreaterThan(100), "Spacer should be wide when expanded");
|
||||
|
||||
// Collapse
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
|
||||
var collapsedWidth = await spacer.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||
Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Spacer should shrink when collapsed");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Mobile behavior
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Mobile_Sidebar_Starts_Closed()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(375, 812);
|
||||
await GoHomeAsync();
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
var state = await wrapper.GetAttributeAsync("data-state");
|
||||
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should start collapsed on mobile");
|
||||
|
||||
var mobile = await wrapper.GetAttributeAsync("data-mobile");
|
||||
Assert.That(mobile, Is.EqualTo("true"), "data-mobile should be 'true' on small viewport");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mobile_Click_Trigger_Opens_Sidebar_And_Shows_Overlay()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(375, 812);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Click the mobile trigger button in the inset (visible only on mobile)
|
||||
await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||
|
||||
// Overlay should be visible
|
||||
var overlay = Page.Locator("[data-sidebar-overlay]");
|
||||
var display = await overlay.EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||
Assert.That(display, Is.Not.EqualTo("none"), "Overlay should be visible when mobile sidebar is open");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mobile_Click_Overlay_Closes_Sidebar()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(375, 812);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Open via the inset trigger
|
||||
await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||
|
||||
// Click overlay
|
||||
await Page.Locator("[data-sidebar-overlay]").ClickAsync(new LocatorClickOptions { Force = true });
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
|
||||
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
|
||||
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should close when overlay is clicked");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mobile_Overlay_Hidden_When_Sidebar_Closed()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(375, 812);
|
||||
await GoHomeAsync();
|
||||
|
||||
var overlay = Page.Locator("[data-sidebar-overlay]");
|
||||
var display = await overlay.EvaluateAsync<string>("el => el.style.display || getComputedStyle(el).display");
|
||||
Assert.That(display, Is.EqualTo("none"), "Overlay should be hidden when mobile sidebar is closed");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Navigation links work
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Clicking_Counter_Link_Navigates_To_Counter()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
|
||||
await counterLink.ClickAsync();
|
||||
|
||||
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||
Assert.That(Page.Url, Does.Contain("/counter"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clicking_Weather_Link_Navigates_To_Weather()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var weatherLink = Page.Locator("[data-sidebar] a[href='/weather']");
|
||||
await weatherLink.ClickAsync();
|
||||
|
||||
await Page.WaitForURLAsync($"{BaseUrl}/weather");
|
||||
Assert.That(Page.Url, Does.Contain("/weather"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clicking_Header_Toggles_Sidebar()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Starts expanded
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||
|
||||
// Click the sidebar header (logo area) to collapse
|
||||
await Page.Locator("[data-sidebar-header]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||
|
||||
// Click again to expand
|
||||
await Page.Locator("[data-sidebar-header]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Navigation should NOT change sidebar state
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Navigation()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Verify starts expanded
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||
|
||||
// Collapse the sidebar
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||
|
||||
// Click a navigation link (Counter)
|
||||
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
|
||||
await counterLink.ClickAsync();
|
||||
|
||||
// Wait for navigation to complete
|
||||
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||
|
||||
// Wait for the sidebar JS to re-apply state after enhanced navigation
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 5_000
|
||||
});
|
||||
|
||||
// Give any transitions/scripts time to settle
|
||||
await Page.WaitForTimeoutAsync(500);
|
||||
|
||||
// Sidebar should STILL be collapsed
|
||||
var stateAfterNav = await wrapper.GetAttributeAsync("data-state");
|
||||
Assert.That(stateAfterNav, Is.EqualTo("collapsed"),
|
||||
"Sidebar should remain collapsed after clicking a navigation link");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Multiple_Navigations()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Collapse the sidebar
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
|
||||
// Navigate to Counter
|
||||
await Page.Locator("[data-sidebar] a[href='/counter']").ClickAsync();
|
||||
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||
await Page.WaitForTimeoutAsync(500);
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||
"Sidebar should remain collapsed after navigating to Counter");
|
||||
|
||||
// Navigate to Weather
|
||||
await Page.Locator("[data-sidebar] a[href='/weather']").ClickAsync();
|
||||
await Page.WaitForURLAsync($"{BaseUrl}/weather");
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||
await Page.WaitForTimeoutAsync(500);
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||
"Sidebar should remain collapsed after navigating to Weather");
|
||||
|
||||
// Navigate back Home
|
||||
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
|
||||
await Page.WaitForURLAsync(url => url == BaseUrl || url == BaseUrl + "/");
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||
await Page.WaitForTimeoutAsync(500);
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||
"Sidebar should remain collapsed after navigating back to Home");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Sidebar_Toggle_Works_After_Same_Page_Navigation()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
|
||||
// Starts expanded
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||
|
||||
// Click the Home link while already on home (same-page navigation)
|
||||
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
|
||||
|
||||
// Wait for any enhanced navigation / DOM mutation to settle
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 5_000
|
||||
});
|
||||
await Page.WaitForTimeoutAsync(500);
|
||||
|
||||
// Now click the sidebar trigger to collapse
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
|
||||
var stateAfterFirstToggle = await wrapper.GetAttributeAsync("data-state");
|
||||
Assert.That(stateAfterFirstToggle, Is.EqualTo("collapsed"),
|
||||
"Sidebar should be collapsed after one trigger click following same-page nav");
|
||||
|
||||
// Click the sidebar trigger again to expand
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
|
||||
var stateAfterSecondToggle = await wrapper.GetAttributeAsync("data-state");
|
||||
Assert.That(stateAfterSecondToggle, Is.EqualTo("expanded"),
|
||||
"Sidebar should be expanded after second trigger click following same-page nav");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Same_Page_Navigation()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
|
||||
// Collapse the sidebar first
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||
|
||||
// Click the Home link while already on home (same-page navigation)
|
||||
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
|
||||
|
||||
// Wait for any enhanced navigation / DOM mutation to settle
|
||||
await Page.WaitForTimeoutAsync(500);
|
||||
|
||||
// Sidebar should STILL be collapsed
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||
"Sidebar should remain collapsed after same-page navigation");
|
||||
|
||||
// Toggle should still work correctly: collapse -> expand
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"),
|
||||
"Trigger should expand sidebar after same-page nav while collapsed");
|
||||
|
||||
// And back to collapsed
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(300);
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||
"Trigger should collapse sidebar again after same-page nav");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Cookie persistence
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Desktop_State_Persists_Via_Cookie()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Collapse
|
||||
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||
|
||||
// Check cookie
|
||||
var cookies = await Context.CookiesAsync();
|
||||
var sidebarCookie = cookies.FirstOrDefault(c => c.Name == "sidebar:state");
|
||||
Assert.That(sidebarCookie, Is.Not.Null, "sidebar:state cookie should exist");
|
||||
Assert.That(sidebarCookie!.Value, Is.EqualTo("closed"), "Cookie should be 'closed' after collapse");
|
||||
|
||||
// Reload page — sidebar should remain collapsed
|
||||
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||
|
||||
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
|
||||
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar state should persist after reload");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Viewport resize transitions
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Resize_From_Desktop_To_Mobile_Collapses_Sidebar()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||
|
||||
// Shrink to mobile
|
||||
await Page.SetViewportSizeAsync(375, 812);
|
||||
|
||||
// Wait for resize handler to fire
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='true']", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 5_000
|
||||
});
|
||||
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("true"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Resize_From_Mobile_To_Desktop_Expands_Sidebar()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(375, 812);
|
||||
await GoHomeAsync();
|
||||
|
||||
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||
|
||||
// Grow to desktop
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='false']", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 5_000
|
||||
});
|
||||
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||
Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("false"));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Sidebar inset (main content area)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task SidebarInset_Exists_And_Contains_Page_Content()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var inset = Page.Locator("[data-sidebar-inset]");
|
||||
await Expect(inset).ToBeVisibleAsync();
|
||||
|
||||
// On mobile, the inset should contain the trigger button
|
||||
await Page.SetViewportSizeAsync(375, 812);
|
||||
await GoHomeAsync();
|
||||
|
||||
var mobileTrigger = inset.Locator("[data-sidebar-trigger]");
|
||||
await Expect(mobileTrigger).ToBeVisibleAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ThemeToggleTests : PlaywrightTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigate to home, wait for sidebar JS + darkmode JS to finish initializing.
|
||||
/// </summary>
|
||||
private async Task GoHomeAsync()
|
||||
{
|
||||
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
|
||||
// Wait for sidebar JS to apply state
|
||||
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 10_000
|
||||
});
|
||||
|
||||
// Wait for the ThemeToggle button to appear
|
||||
await Page.WaitForSelectorAsync("[data-theme-toggle]", new PageWaitForSelectorOptions
|
||||
{
|
||||
Timeout = 10_000
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click the toggle and wait for the dark class to be added to <html>.
|
||||
/// </summary>
|
||||
private async Task ToggleToDarkAsync()
|
||||
{
|
||||
await Page.Locator("[data-theme-toggle]").ClickAsync();
|
||||
await Page.WaitForFunctionAsync(
|
||||
"() => document.documentElement.classList.contains('dark')",
|
||||
null,
|
||||
new PageWaitForFunctionOptions { Timeout = 5_000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click the toggle and wait for the dark class to be removed from <html>.
|
||||
/// </summary>
|
||||
private async Task ToggleToLightAsync()
|
||||
{
|
||||
await Page.Locator("[data-theme-toggle]").ClickAsync();
|
||||
await Page.WaitForFunctionAsync(
|
||||
"() => !document.documentElement.classList.contains('dark')",
|
||||
null,
|
||||
new PageWaitForFunctionOptions { Timeout = 5_000 });
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Initial render
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task ThemeToggle_Button_Is_Visible()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var toggle = Page.Locator("[data-theme-toggle]");
|
||||
await Expect(toggle).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThemeToggle_Starts_In_Light_Mode_By_Default()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var hasDark = await Page.EvaluateAsync<bool>(
|
||||
"() => document.documentElement.classList.contains('dark')");
|
||||
Assert.That(hasDark, Is.False, "Page should start in light mode when no preference is stored");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThemeToggle_Shows_Moon_Icon_In_Light_Mode()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
|
||||
var moonDisplay = await moon.EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||
Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible in light mode");
|
||||
|
||||
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
|
||||
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
|
||||
Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden in light mode");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Toggle to dark mode
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Click_Toggle_Adds_Dark_Class_To_Html()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
var hasDark = await Page.EvaluateAsync<bool>(
|
||||
"() => document.documentElement.classList.contains('dark')");
|
||||
Assert.That(hasDark, Is.True, "Clicking toggle should add 'dark' class to <html>");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Click_Toggle_Shows_Sun_Icon_In_Dark_Mode()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
|
||||
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
|
||||
Assert.That(sunDisplay, Is.Not.EqualTo("none"), "Sun icon should be visible in dark mode");
|
||||
|
||||
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
|
||||
var moonDisplay = await moon.EvaluateAsync<string>("el => el.style.display");
|
||||
Assert.That(moonDisplay, Is.EqualTo("none"), "Moon icon should be hidden in dark mode");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Click_Toggle_Stores_Dark_In_LocalStorage()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
|
||||
Assert.That(stored, Is.EqualTo("dark"), "localStorage 'theme' should be 'dark' after toggle");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Toggle back to light mode
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Double_Click_Toggle_Returns_To_Light_Mode()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
// Toggle to dark
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
// Toggle back to light
|
||||
await ToggleToLightAsync();
|
||||
|
||||
var hasDark = await Page.EvaluateAsync<bool>(
|
||||
"() => document.documentElement.classList.contains('dark')");
|
||||
Assert.That(hasDark, Is.False, "Double-clicking toggle should return to light mode");
|
||||
|
||||
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
|
||||
Assert.That(stored, Is.EqualTo("light"), "localStorage should be 'light' after toggling back");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Double_Click_Toggle_Shows_Moon_Icon_Again()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
// Dark
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
// Light again
|
||||
await ToggleToLightAsync();
|
||||
|
||||
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
|
||||
var moonDisplay = await moon.EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||
Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible again after toggling back");
|
||||
|
||||
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
|
||||
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
|
||||
Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden again after toggling back");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Persistence across page reloads
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Dark_Mode_Persists_After_Reload()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
// Reload the page
|
||||
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
|
||||
// The inline <script> in <head> should apply 'dark' before paint
|
||||
var hasDark = await Page.EvaluateAsync<bool>(
|
||||
"() => document.documentElement.classList.contains('dark')");
|
||||
Assert.That(hasDark, Is.True, "Dark mode should persist after page reload");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Dark_Mode_Persists_After_Navigation()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
// Navigate to counter page via sidebar link
|
||||
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
|
||||
await counterLink.ClickAsync();
|
||||
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||
|
||||
// Wait for the page to fully settle after enhanced navigation
|
||||
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Dark class should still be present on <html>
|
||||
var hasDark = await Page.EvaluateAsync<bool>(
|
||||
"() => document.documentElement.classList.contains('dark')");
|
||||
Assert.That(hasDark, Is.True, "Dark mode should persist across navigation");
|
||||
|
||||
// localStorage should also still have 'dark'
|
||||
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
|
||||
Assert.That(stored, Is.EqualTo("dark"), "localStorage should still be 'dark' after navigation");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Light_Mode_Persists_After_Reload()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
// Toggle to dark then back to light
|
||||
await ToggleToDarkAsync();
|
||||
await ToggleToLightAsync();
|
||||
|
||||
// Reload
|
||||
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
|
||||
var hasDark = await Page.EvaluateAsync<bool>(
|
||||
"() => document.documentElement.classList.contains('dark')");
|
||||
Assert.That(hasDark, Is.False, "Light mode should persist after page reload");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// No FOUC (flash of unstyled content)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Dark_Mode_Applied_Before_First_Paint_No_FOUC()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
await ToggleToDarkAsync();
|
||||
|
||||
// Reload and check at DOMContentLoaded — the inline script should have already set .dark
|
||||
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.DOMContentLoaded });
|
||||
|
||||
var hasDarkImmediately = await Page.EvaluateAsync<bool>(
|
||||
"() => document.documentElement.classList.contains('dark')");
|
||||
Assert.That(hasDarkImmediately, Is.True,
|
||||
"Dark class should be on <html> immediately on DOMContentLoaded (no FOUC)");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Visual theming verification
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task Dark_Mode_Changes_Background_Color()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
// Read the --background CSS custom property which drives bg-background
|
||||
var lightBgVar = await Page.EvaluateAsync<string>(
|
||||
"() => getComputedStyle(document.documentElement).getPropertyValue('--background').trim()");
|
||||
|
||||
// Toggle to dark
|
||||
await ToggleToDarkAsync();
|
||||
await Page.WaitForTimeoutAsync(200);
|
||||
|
||||
var darkBgVar = await Page.EvaluateAsync<string>(
|
||||
"() => getComputedStyle(document.documentElement).getPropertyValue('--background').trim()");
|
||||
|
||||
Assert.That(darkBgVar, Is.Not.EqualTo(lightBgVar),
|
||||
$"--background CSS variable should change in dark mode. Light={lightBgVar}, Dark={darkBgVar}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Dark_Mode_Changes_Sidebar_Background()
|
||||
{
|
||||
await Page.SetViewportSizeAsync(1280, 800);
|
||||
await GoHomeAsync();
|
||||
|
||||
var sidebar = Page.Locator("[data-sidebar]");
|
||||
var lightSidebarBg = await sidebar.EvaluateAsync<string>(
|
||||
"el => getComputedStyle(el).backgroundColor");
|
||||
|
||||
// Toggle to dark
|
||||
await ToggleToDarkAsync();
|
||||
await Page.WaitForTimeoutAsync(200);
|
||||
|
||||
var darkSidebarBg = await sidebar.EvaluateAsync<string>(
|
||||
"el => getComputedStyle(el).backgroundColor");
|
||||
Assert.That(darkSidebarBg, Is.Not.EqualTo(lightSidebarBg),
|
||||
"Sidebar background should change in dark mode");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Button styling (shadcn ghost button)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public async Task ThemeToggle_Has_Correct_Dimensions()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var toggle = Page.Locator("[data-theme-toggle]");
|
||||
var box = await toggle.BoundingBoxAsync();
|
||||
|
||||
Assert.That(box, Is.Not.Null, "Toggle button should have a bounding box");
|
||||
// h-9 w-9 = 36px × 36px
|
||||
Assert.That(box!.Width, Is.InRange(34, 38), "Toggle button width should be ~36px (h-9)");
|
||||
Assert.That(box.Height, Is.InRange(34, 38), "Toggle button height should be ~36px (w-9)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThemeToggle_Contains_Exactly_Two_Svg_Icons()
|
||||
{
|
||||
await GoHomeAsync();
|
||||
|
||||
var toggle = Page.Locator("[data-theme-toggle]");
|
||||
var svgCount = await toggle.Locator("svg").CountAsync();
|
||||
Assert.That(svgCount, Is.EqualTo(2),
|
||||
"Toggle should contain exactly two SVG icons (moon and sun)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user