Files
multica/packages/views/settings/components/preferences-tab.test.tsx
Naiyuan Qing 5cf1d01076 feat(settings): rename Appearance tab to Preferences and persist active tab in URL (#2131)
- 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>
2026-05-06 16:53:32 +08:00

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