fix: improve merge verification and dashboard behavior
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsModal } from "../SettingsModal";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import type { Settings, ThemeMode, ColorTheme } from "@fusion/core";
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
@@ -85,6 +86,112 @@ describe("SettingsModal", () => {
|
||||
expect(screen.queryByLabelText("Max Worktrees")).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes appearance callbacks when theme controls are used", async () => {
|
||||
const handleThemeModeChange = vi.fn();
|
||||
const handleColorThemeChange = vi.fn();
|
||||
|
||||
render(
|
||||
<SettingsModal
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
onColorThemeChange={handleColorThemeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Light mode" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Forest theme" }));
|
||||
|
||||
expect(handleThemeModeChange).toHaveBeenCalledWith("light");
|
||||
expect(handleColorThemeChange).toHaveBeenCalledWith("forest");
|
||||
});
|
||||
|
||||
it("reflects selected appearance values when parent updates controlled props", async () => {
|
||||
function ControlledAppearanceModal() {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const [colorTheme, setColorTheme] = useState<ColorTheme>("default");
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
onThemeModeChange={setThemeMode}
|
||||
onColorThemeChange={setColorTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ControlledAppearanceModal />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||||
|
||||
const lightModeButton = screen.getByRole("button", { name: "Light mode" });
|
||||
const forestThemeButton = screen.getByRole("button", { name: "Forest theme" });
|
||||
|
||||
fireEvent.click(lightModeButton);
|
||||
fireEvent.click(forestThemeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lightModeButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(forestThemeButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("Light / Forest")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Appearance controls invoke theme callbacks and keep UI state in sync", async () => {
|
||||
const user = userEvent.setup();
|
||||
const themeModeSpy = vi.fn();
|
||||
const colorThemeSpy = vi.fn();
|
||||
|
||||
function ThemeHarness() {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const [colorTheme, setColorTheme] = useState<ColorTheme>("default");
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
initialSection="appearance"
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
onThemeModeChange={(mode) => {
|
||||
themeModeSpy(mode);
|
||||
setThemeMode(mode);
|
||||
}}
|
||||
onColorThemeChange={(theme) => {
|
||||
colorThemeSpy(theme);
|
||||
setColorTheme(theme);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ThemeHarness />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText(/Dark \/ Default/)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByLabelText("Light mode"));
|
||||
await user.click(screen.getByLabelText("Ocean theme"));
|
||||
|
||||
expect(themeModeSpy).toHaveBeenCalledWith("light");
|
||||
expect(colorThemeSpy).toHaveBeenCalledWith("ocean");
|
||||
expect(screen.getByText(/Light \/ Ocean/)).toBeTruthy();
|
||||
|
||||
const lightButton = screen.getByLabelText("Light mode");
|
||||
const oceanButton = screen.getByLabelText("Ocean theme");
|
||||
expect(lightButton.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(oceanButton.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("switches section when clicking sidebar item", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
@@ -141,6 +141,42 @@ describe("useTheme", () => {
|
||||
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("dark");
|
||||
});
|
||||
|
||||
it("keeps user-selected theme changes when hydration resolves with stale backend values", async () => {
|
||||
let resolveHydration: (value: Settings) => void;
|
||||
const hydrationPromise = new Promise<Settings>((resolve) => {
|
||||
resolveHydration = resolve;
|
||||
});
|
||||
mockFetchGlobalSettings.mockReturnValue(hydrationPromise);
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
// User changes both fields before initial backend hydration resolves.
|
||||
act(() => {
|
||||
result.current.setThemeMode("light");
|
||||
result.current.setColorTheme("ocean");
|
||||
});
|
||||
|
||||
expect(result.current.themeMode).toBe("light");
|
||||
expect(result.current.colorTheme).toBe("ocean");
|
||||
|
||||
// Hydration resolves with stale values from backend cache.
|
||||
resolveHydration!({ themeMode: "dark", colorTheme: "forest" } as Settings);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Regression expectation: user selections remain authoritative.
|
||||
expect(result.current.themeMode).toBe("light");
|
||||
expect(result.current.colorTheme).toBe("ocean");
|
||||
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("light");
|
||||
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("ocean");
|
||||
|
||||
// Ensure stale hydration values did not leak through.
|
||||
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).not.toBe("dark");
|
||||
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).not.toBe("forest");
|
||||
});
|
||||
|
||||
it("keeps localStorage value when backend matches", async () => {
|
||||
localStorageMock[THEME_MODE_STORAGE_KEY] = "dark";
|
||||
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
|
||||
|
||||
@@ -189,6 +189,8 @@ export function useTheme(): UseThemeReturn {
|
||||
|
||||
const themeModeRef = useRef(themeMode);
|
||||
const colorThemeRef = useRef(colorTheme);
|
||||
const userSetThemeModeRef = useRef(false);
|
||||
const userSetColorThemeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
@@ -208,8 +210,11 @@ export function useTheme(): UseThemeReturn {
|
||||
.then((globalSettings) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (isValidThemeMode(globalSettings.themeMode)) {
|
||||
// Hydration should not override user-initiated writes that happened while
|
||||
// fetchGlobalSettings() was in flight. User selections are authoritative.
|
||||
if (isValidThemeMode(globalSettings.themeMode) && !userSetThemeModeRef.current) {
|
||||
if (themeModeRef.current !== globalSettings.themeMode) {
|
||||
themeModeRef.current = globalSettings.themeMode;
|
||||
setThemeModeState(globalSettings.themeMode);
|
||||
}
|
||||
if (readCachedThemeMode() !== globalSettings.themeMode) {
|
||||
@@ -217,8 +222,13 @@ export function useTheme(): UseThemeReturn {
|
||||
}
|
||||
}
|
||||
|
||||
if (globalSettings.colorTheme && VALID_COLOR_THEMES.includes(globalSettings.colorTheme)) {
|
||||
if (
|
||||
globalSettings.colorTheme
|
||||
&& VALID_COLOR_THEMES.includes(globalSettings.colorTheme)
|
||||
&& !userSetColorThemeRef.current
|
||||
) {
|
||||
if (colorThemeRef.current !== globalSettings.colorTheme) {
|
||||
colorThemeRef.current = globalSettings.colorTheme;
|
||||
setColorThemeState(globalSettings.colorTheme);
|
||||
}
|
||||
if (readCachedColorTheme() !== globalSettings.colorTheme) {
|
||||
@@ -271,6 +281,9 @@ export function useTheme(): UseThemeReturn {
|
||||
|
||||
// Wrapper setters with write-through persistence.
|
||||
const setThemeMode = useCallback((mode: ThemeMode) => {
|
||||
// Mark user intent immediately so in-flight hydration cannot overwrite it.
|
||||
userSetThemeModeRef.current = true;
|
||||
themeModeRef.current = mode;
|
||||
setThemeModeState(mode);
|
||||
writeCachedThemeMode(mode);
|
||||
|
||||
@@ -280,6 +293,9 @@ export function useTheme(): UseThemeReturn {
|
||||
}, []);
|
||||
|
||||
const setColorTheme = useCallback((theme: ColorTheme) => {
|
||||
// Mark user intent immediately so in-flight hydration cannot overwrite it.
|
||||
userSetColorThemeRef.current = true;
|
||||
colorThemeRef.current = theme;
|
||||
setColorThemeState(theme);
|
||||
writeCachedColorTheme(theme);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user