fix: improve merge verification and dashboard behavior

This commit is contained in:
gsxdsm
2026-04-10 18:27:44 -07:00
parent c56e44bff8
commit f7322ab541
20 changed files with 411 additions and 43 deletions

View File

@@ -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());

View File

@@ -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" });

View File

@@ -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);