Files
Enciphered.Blazor.UIComponents/Enciphered.Blazor.UIComponents.Tests/ThemeToggleTests.cs
T
2026-04-13 15:08:49 +05:00

325 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &lt;html&gt;.
/// </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 &lt;html&gt;.
/// </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)");
}
}