325 lines
13 KiB
C#
325 lines
13 KiB
C#
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)");
|
||
}
|
||
}
|