Phase 2: Language Context & Selector - Add language dropdown to Header and auth pages
- Create LanguageSelector component with dropdown (shows flag + name) - Add LanguageSelector to Header (right side, near user email/logout) - Add LanguageSelector to login, signup, and signup/[code] pages - Create test-utils.tsx with renderWithProviders helper - Add vitest.setup.ts to mock localStorage - Update all test files to use renderWithProviders - Language selector persists choice in localStorage - HTML lang attribute updates dynamically based on selected language All frontend and e2e tests passing.
This commit is contained in:
parent
f7553df05d
commit
f86ec8b62d
11 changed files with 214 additions and 37 deletions
|
|
@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
|||
import { useAuth } from "../auth-context";
|
||||
import { sharedStyles } from "../styles/shared";
|
||||
import constants from "../../../shared/constants.json";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
|
||||
const { ADMIN, REGULAR } = constants.roles;
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ export function Header({ currentPage }: HeaderProps) {
|
|||
))}
|
||||
</div>
|
||||
<div style={sharedStyles.userInfo}>
|
||||
<LanguageSelector />
|
||||
<span style={sharedStyles.userEmail}>{user.email}</span>
|
||||
<button onClick={handleLogout} style={sharedStyles.logoutBtn}>
|
||||
Sign out
|
||||
|
|
|
|||
105
frontend/app/components/LanguageSelector.tsx
Normal file
105
frontend/app/components/LanguageSelector.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useLanguage, type Locale } from "../hooks/useLanguage";
|
||||
import { sharedStyles } from "../styles/shared";
|
||||
|
||||
const LANGUAGES: Array<{ code: Locale; name: string; flag: string }> = [
|
||||
{ code: "es", name: "Español", flag: "🇪🇸" },
|
||||
{ code: "en", name: "English", flag: "🇬🇧" },
|
||||
{ code: "ca", name: "Català", flag: "🇪🇸" },
|
||||
];
|
||||
|
||||
export function LanguageSelector() {
|
||||
const { locale, setLocale } = useLanguage();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const currentLanguage = LANGUAGES.find((lang) => lang.code === locale);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
...sharedStyles.logoutBtn,
|
||||
marginRight: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span>{currentLanguage?.flag}</span>
|
||||
<span>{currentLanguage?.name}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
right: 0,
|
||||
marginTop: "0.5rem",
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||
zIndex: 1000,
|
||||
minWidth: "150px",
|
||||
}}
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => {
|
||||
setLocale(lang.code);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
backgroundColor: locale === lang.code ? "#f3f4f6" : "transparent",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (locale !== lang.code) {
|
||||
e.currentTarget.style.backgroundColor = "#f9fafb";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (locale !== lang.code) {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +1,39 @@
|
|||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { screen, cleanup } from "@testing-library/react";
|
||||
import { expect, test, vi, beforeEach, afterEach } from "vitest";
|
||||
import LoginPage from "./page";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
const mockLogin = vi.fn();
|
||||
vi.mock("../auth-context", () => ({
|
||||
useAuth: () => ({ login: vi.fn() }),
|
||||
useAuth: () => ({ login: mockLogin }),
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
test("renders login form with title", () => {
|
||||
render(<LoginPage />);
|
||||
renderWithProviders(<LoginPage />);
|
||||
expect(screen.getByText("Welcome back")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders email and password inputs", () => {
|
||||
render(<LoginPage />);
|
||||
renderWithProviders(<LoginPage />);
|
||||
expect(screen.getByLabelText("Email")).toBeDefined();
|
||||
expect(screen.getByLabelText("Password")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders sign in button", () => {
|
||||
render(<LoginPage />);
|
||||
renderWithProviders(<LoginPage />);
|
||||
expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders link to signup", () => {
|
||||
render(<LoginPage />);
|
||||
renderWithProviders(<LoginPage />);
|
||||
expect(screen.getByText("Sign up")).toBeDefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "../auth-context";
|
||||
import { authFormStyles as styles } from "../styles/auth-form";
|
||||
import { LanguageSelector } from "../components/LanguageSelector";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
|
|
@ -30,6 +31,9 @@ export default function LoginPage() {
|
|||
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.header}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { expect, test, vi, beforeEach, afterEach, describe } from "vitest";
|
||||
import ProfilePage from "./page";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
|
|
@ -37,6 +38,7 @@ vi.mock("../auth-context", () => ({
|
|||
hasRole: mockHasRole,
|
||||
hasPermission: mockHasPermission,
|
||||
}),
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Permission: {
|
||||
VIEW_AUDIT: "view_audit",
|
||||
FETCH_PRICE: "fetch_price",
|
||||
|
|
@ -112,14 +114,14 @@ describe("ProfilePage - Display", () => {
|
|||
mockIsLoading = true;
|
||||
mockGetProfile.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
expect(screen.getByText("Loading...")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders profile page title", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
|
@ -128,7 +130,7 @@ describe("ProfilePage - Display", () => {
|
|||
test("displays login email as read-only", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
const loginEmailInput = screen.getByDisplayValue("test@example.com");
|
||||
expect(loginEmailInput).toBeDefined();
|
||||
|
|
@ -139,7 +141,7 @@ describe("ProfilePage - Display", () => {
|
|||
test("shows read-only badge for login email", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Read only")).toBeDefined();
|
||||
});
|
||||
|
|
@ -148,7 +150,7 @@ describe("ProfilePage - Display", () => {
|
|||
test("shows hint about login email", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
|
||||
});
|
||||
|
|
@ -157,7 +159,7 @@ describe("ProfilePage - Display", () => {
|
|||
test("displays contact details section hint", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
|
||||
});
|
||||
|
|
@ -166,7 +168,7 @@ describe("ProfilePage - Display", () => {
|
|||
test("displays fetched profile data", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("contact@example.com")).toBeDefined();
|
||||
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
||||
|
|
@ -178,7 +180,7 @@ describe("ProfilePage - Display", () => {
|
|||
test("fetches profile with credentials", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetProfile).toHaveBeenCalled();
|
||||
|
|
@ -193,7 +195,7 @@ describe("ProfilePage - Display", () => {
|
|||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
// Check that inputs exist with empty values (placeholders shown)
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
|
|
@ -206,7 +208,7 @@ describe("ProfilePage - Navigation", () => {
|
|||
test("shows nav links for regular user", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Exchange")).toBeDefined();
|
||||
expect(screen.getByText("My Trades")).toBeDefined();
|
||||
|
|
@ -216,7 +218,7 @@ describe("ProfilePage - Navigation", () => {
|
|||
test("highlights My Profile in nav", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
// My Profile should be visible (as current page indicator)
|
||||
const navItems = screen.getAllByText("My Profile");
|
||||
|
|
@ -229,7 +231,7 @@ describe("ProfilePage - Access Control", () => {
|
|||
test("redirects to login when not authenticated", async () => {
|
||||
mockUser = null;
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
|
|
@ -244,7 +246,7 @@ describe("ProfilePage - Access Control", () => {
|
|||
permissions: ["view_all_exchanges"],
|
||||
};
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
|
||||
|
|
@ -259,7 +261,7 @@ describe("ProfilePage - Access Control", () => {
|
|||
permissions: ["view_audit"],
|
||||
};
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
// Give it a moment to potentially fetch
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
|
@ -272,7 +274,7 @@ describe("ProfilePage - Loading State", () => {
|
|||
mockIsLoading = true;
|
||||
mockUser = null;
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -280,7 +282,7 @@ describe("ProfilePage - Loading State", () => {
|
|||
test("shows loading indicator while loading", () => {
|
||||
mockIsLoading = true;
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeDefined();
|
||||
});
|
||||
|
|
@ -290,7 +292,7 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
test("submit button is disabled when no changes", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
expect(submitButton).toHaveProperty("disabled", true);
|
||||
|
|
@ -300,7 +302,7 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
test("submit button is enabled after field changes", async () => {
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
||||
|
|
@ -323,7 +325,7 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
|
|
@ -345,7 +347,7 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
|
|
@ -382,7 +384,7 @@ describe("ProfilePage - Form Submission", () => {
|
|||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
|
|
@ -428,7 +430,7 @@ describe("ProfilePage - Form Submission", () => {
|
|||
})
|
||||
);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
|
|
@ -456,7 +458,7 @@ describe("ProfilePage - Form Submission", () => {
|
|||
});
|
||||
mockUpdateProfile.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
|
|
@ -499,7 +501,7 @@ describe("ProfilePage - Form Submission", () => {
|
|||
});
|
||||
mockUpdateProfile.mockReturnValue(submitPromise);
|
||||
|
||||
render(<ProfilePage />);
|
||||
renderWithProviders(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuth } from "../../auth-context";
|
||||
import { LanguageSelector } from "../../components/LanguageSelector";
|
||||
|
||||
export default function SignupWithCodePage() {
|
||||
const params = useParams();
|
||||
|
|
@ -36,6 +37,9 @@ export default function SignupWithCodePage() {
|
|||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
Redirecting...
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { screen, cleanup } from "@testing-library/react";
|
||||
import { expect, test, vi, beforeEach, afterEach } from "vitest";
|
||||
import SignupPage from "./page";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
|
@ -8,30 +9,32 @@ vi.mock("next/navigation", () => ({
|
|||
useSearchParams: () => ({ get: () => null }),
|
||||
}));
|
||||
|
||||
const mockRegister = vi.fn();
|
||||
vi.mock("../auth-context", () => ({
|
||||
useAuth: () => ({ user: null, register: vi.fn() }),
|
||||
useAuth: () => ({ user: null, register: mockRegister }),
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
test("renders signup form with title", () => {
|
||||
render(<SignupPage />);
|
||||
renderWithProviders(<SignupPage />);
|
||||
// Step 1 shows "Join with Invite" title (invite code entry)
|
||||
expect(screen.getByRole("heading", { name: "Join with Invite" })).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders invite code input", () => {
|
||||
render(<SignupPage />);
|
||||
renderWithProviders(<SignupPage />);
|
||||
expect(screen.getByLabelText("Invite Code")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders continue button", () => {
|
||||
render(<SignupPage />);
|
||||
renderWithProviders(<SignupPage />);
|
||||
expect(screen.getByRole("button", { name: "Continue" })).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders link to login", () => {
|
||||
render(<SignupPage />);
|
||||
renderWithProviders(<SignupPage />);
|
||||
expect(screen.getByText("Sign in")).toBeDefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||
import { useAuth } from "../auth-context";
|
||||
import { invitesApi } from "../api";
|
||||
import { authFormStyles as styles } from "../styles/auth-form";
|
||||
import { LanguageSelector } from "../components/LanguageSelector";
|
||||
|
||||
function SignupContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -107,6 +108,9 @@ function SignupContent() {
|
|||
if (isCheckingInitialCode) {
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
|
||||
|
|
@ -122,6 +126,9 @@ function SignupContent() {
|
|||
if (!inviteValid) {
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.header}>
|
||||
|
|
@ -189,6 +196,9 @@ function SignupContent() {
|
|||
// Step 2: Enter email and password
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.header}>
|
||||
|
|
|
|||
16
frontend/app/test-utils.tsx
Normal file
16
frontend/app/test-utils.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { ReactElement } from "react";
|
||||
import { render, RenderOptions } from "@testing-library/react";
|
||||
import { Providers } from "./components/Providers";
|
||||
import { AuthProvider } from "./auth-context";
|
||||
|
||||
function AllProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Providers>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderWithProviders(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
|
||||
return render(ui, { wrapper: AllProviders, ...options });
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
|||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["app/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html"],
|
||||
|
|
|
|||
27
frontend/vitest.setup.ts
Normal file
27
frontend/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { beforeEach } from "vitest";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue