mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
- Rename appearance-tab → preferences-tab; AppearanceTab → PreferencesTab - i18n top-level key appearance → preferences; tab label "Appearance" → "Preferences" / "偏好设置" - Swap icon Palette → SlidersHorizontal (preferences semantic) - SettingsPage: read active tab from ?tab= via NavigationAdapter, write back with replace() on change; whitelist valid tabs (incl. desktop extras daemon/updates), unknown values fall back to profile - Update conventions.mdx (en + zh) references to renamed file and i18n key Why preferences over appearance: the tab held both theme and language; "Appearance" semantically excludes localization. "Preferences" follows Linear/Slack/Discord and leaves room to add timezone/date format later. Why query param over path: settings tabs are UI modifier state, not resources; query persistence keeps the existing single Next.js route file and desktop memory router unchanged, gives a natural fallback for unknown values, and avoids 404 risk. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { render, screen, act } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { I18nProvider } from "@multica/core/i18n/react";
|
|
import enCommon from "../../locales/en/common.json";
|
|
import enAuth from "../../locales/en/auth.json";
|
|
import enSettings from "../../locales/en/settings.json";
|
|
|
|
const mockPersist = vi.hoisted(() => vi.fn());
|
|
const mockUpdateMe = vi.hoisted(() => vi.fn());
|
|
const mockReload = vi.hoisted(() => vi.fn());
|
|
const mockToastWarning = vi.hoisted(() => vi.fn());
|
|
const userRef = vi.hoisted(() => ({
|
|
current: null as { id: string } | null,
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/common/theme-provider", () => ({
|
|
useTheme: () => ({ theme: "light", setTheme: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("@multica/core/i18n/react", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/i18n/react")>(
|
|
"@multica/core/i18n/react",
|
|
);
|
|
return {
|
|
...actual,
|
|
useLocaleAdapter: () => ({
|
|
persist: mockPersist,
|
|
getUserChoice: () => null,
|
|
getSystemPreferences: () => [],
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: { updateMe: mockUpdateMe },
|
|
}));
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: { warning: mockToastWarning },
|
|
}));
|
|
|
|
vi.mock("@multica/core/auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/auth")>(
|
|
"@multica/core/auth",
|
|
);
|
|
const useAuthStore = Object.assign(
|
|
(sel?: (s: { user: typeof userRef.current }) => unknown) =>
|
|
sel ? sel({ user: userRef.current }) : { user: userRef.current },
|
|
{ getState: () => ({ user: userRef.current }) },
|
|
);
|
|
return { ...actual, useAuthStore };
|
|
});
|
|
|
|
import { PreferencesTab } from "./preferences-tab";
|
|
|
|
const TEST_RESOURCES = {
|
|
en: { common: enCommon, auth: enAuth, settings: enSettings },
|
|
};
|
|
|
|
function I18nWrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
|
{children}
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
describe("PreferencesTab — Language switcher", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
userRef.current = null;
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
Object.defineProperty(window, "location", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: { reload: mockReload },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does nothing when clicking the current locale", async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await user.click(screen.getByRole("radio", { name: "English" }));
|
|
|
|
expect(mockPersist).not.toHaveBeenCalled();
|
|
expect(mockUpdateMe).not.toHaveBeenCalled();
|
|
expect(mockReload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("when not logged in: persists + reloads, no PATCH", async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await user.click(screen.getByRole("radio", { name: "中文" }));
|
|
|
|
expect(mockPersist).toHaveBeenCalledWith("zh-Hans");
|
|
expect(mockUpdateMe).not.toHaveBeenCalled();
|
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
expect(mockToastWarning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("when logged in + PATCH success: persists + PATCH + reload immediately", async () => {
|
|
userRef.current = { id: "user-1" };
|
|
mockUpdateMe.mockResolvedValueOnce({});
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await user.click(screen.getByRole("radio", { name: "中文" }));
|
|
|
|
expect(mockPersist).toHaveBeenCalledWith("zh-Hans");
|
|
expect(mockUpdateMe).toHaveBeenCalledWith({ language: "zh-Hans" });
|
|
expect(mockToastWarning).not.toHaveBeenCalled();
|
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("when logged in + PATCH fails: shows toast and delays reload by 2.5s", async () => {
|
|
userRef.current = { id: "user-1" };
|
|
mockUpdateMe.mockRejectedValueOnce(new Error("network"));
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await user.click(screen.getByRole("radio", { name: "中文" }));
|
|
|
|
// Local persist still happened so the reload below sees the new locale.
|
|
expect(mockPersist).toHaveBeenCalledWith("zh-Hans");
|
|
expect(mockUpdateMe).toHaveBeenCalledWith({ language: "zh-Hans" });
|
|
// Toast surfaced the sync failure.
|
|
expect(mockToastWarning).toHaveBeenCalledTimes(1);
|
|
// Reload deferred so the toast is visible.
|
|
expect(mockReload).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(2500);
|
|
});
|
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|