diff --git a/eslint.config.mjs b/eslint.config.mjs index 04e7dc5c..4a543094 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,7 +22,12 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': [ 'error', - { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, ], '@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }], // Downgrade new eslint-config-next v16 React Compiler rules to warnings diff --git a/src/ai/flows/workout-generator-flow.test.ts b/src/ai/flows/workout-generator-flow.test.ts new file mode 100644 index 00000000..b9bc86c6 --- /dev/null +++ b/src/ai/flows/workout-generator-flow.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { z } from 'zod'; + +const mockGenerate = vi.fn(); +const mockGenerateStream = vi.fn(); +const mockDefineFlow = vi.fn((_config: unknown, handler: unknown) => handler); +const mockSetAttribute = vi.fn(); +const mockSpan = { setAttribute: mockSetAttribute }; + +vi.mock('@/ai/genkit', () => ({ + ai: { + defineFlow: (...args: [unknown, unknown]) => mockDefineFlow(...args), + generate: (...args: [unknown]) => mockGenerate(...args), + generateStream: (...args: [unknown]) => mockGenerateStream(...args), + }, +})); + +vi.mock('@genkit-ai/google-genai', () => ({ + googleAI: { model: vi.fn(() => 'gemini-2.5-flash') }, +})); + +vi.mock('@sentry/nextjs', () => ({ + startSpan: vi.fn(async (_config: unknown, fn: (span: typeof mockSpan) => Promise) => + fn(mockSpan) + ), +})); + +vi.mock('@/lib/constants', () => ({ + EXERCICIOS_POR_GRUPO: [ + { + grupo: 'Peito', + exercicios: [{ nomeExercicio: 'Supino Reto' }], + }, + ], +})); + +vi.mock('@/ai/schemas', () => { + const WorkoutGeneratorInputSchema = z.object({ + objetivo: z.enum(['Hipertrofia', 'Perda de Peso', 'Força']), + nivelExperiencia: z.enum(['Iniciante', 'Intermediário', 'Avançado']), + diasPorSemana: z.number().min(1).max(7), + observacoesAdicionais: z.string().optional(), + }); + + const WorkoutGeneratorAIOutputSchema = z.object({ + planName: z.string(), + workouts: z.array( + z.object({ + nome: z.string(), + objetivo: z.string(), + diaSugerido: z.number(), + exercicios: z.array( + z.object({ + nomeExercicio: z.string(), + grupoMuscular: z.string(), + series: z.number(), + repeticoes: z.string(), + observacoes: z.string(), + }) + ), + }) + ), + }); + + return { + WorkoutGeneratorInputSchema, + WorkoutGeneratorAIOutputSchema, + WorkoutGeneratorInput: undefined, + WorkoutGeneratorAIOutput: undefined, + }; +}); + +const validInput = { + objetivo: 'Hipertrofia' as const, + nivelExperiencia: 'Intermediário' as const, + diasPorSemana: 4, +}; + +describe('streamWorkoutPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('streams workout plan successfully', async () => { + const chunkOutput = { + planName: 'Plano Teste', + workouts: [], + }; + mockGenerateStream.mockReturnValue({ + stream: (async function* () { + yield { output: chunkOutput }; + })(), + response: Promise.resolve({ + output: { + planName: 'Plano Teste', + workouts: [ + { + nome: 'Treino A', + objetivo: 'Hipertrofia', + diaSugerido: 1, + exercicios: [ + { + nomeExercicio: 'Supino Reto', + grupoMuscular: 'Peito', + series: 3, + repeticoes: '10-12', + observacoes: '', + }, + ], + }, + ], + }, + usage: { inputTokens: 100, outputTokens: 200 }, + }), + }); + + const { streamWorkoutPlan } = await import('@/ai/flows/workout-generator-flow'); + const sendChunk = vi.fn(); + + const result = await ( + streamWorkoutPlan as unknown as ( + input: typeof validInput, + ctx: { sendChunk: (chunk: unknown) => void } + ) => Promise + )(validInput, { sendChunk }); + + expect(result).toBeDefined(); + expect(sendChunk).toHaveBeenCalled(); + }); + + it('throws when AI returns no output', async () => { + mockGenerateStream.mockReturnValue({ + stream: (async function* () { + yield {}; + })(), + response: Promise.resolve({ + output: null, + usage: { inputTokens: 0, outputTokens: 0 }, + }), + }); + + const { streamWorkoutPlan } = await import('@/ai/flows/workout-generator-flow'); + const sendChunk = vi.fn(); + + await expect( + ( + streamWorkoutPlan as unknown as ( + input: typeof validInput, + ctx: { sendChunk: (chunk: unknown) => void } + ) => Promise + )(validInput, { sendChunk }) + ).rejects.toThrow('A IA não retornou um plano de treino válido.'); + }); +}); + +describe('generateWorkoutPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('generates workout plan successfully', async () => { + mockGenerate.mockResolvedValue({ + output: { + planName: 'Plano Força', + workouts: [ + { + nome: 'Treino A', + objetivo: 'Força', + diaSugerido: 2, + exercicios: [ + { + nomeExercicio: 'Supino Reto', + grupoMuscular: 'Peito', + series: 5, + repeticoes: '5-6', + observacoes: '', + }, + ], + }, + ], + }, + usage: { inputTokens: 50, outputTokens: 150 }, + }); + + const { generateWorkoutPlan } = await import('@/ai/flows/workout-generator-flow'); + const result = await generateWorkoutPlan(validInput); + + expect(result).toBeDefined(); + expect(result.planName).toBe('Plano Força'); + expect(mockGenerate).toHaveBeenCalled(); + }); + + it('throws when output is null', async () => { + mockGenerate.mockResolvedValue({ + output: null, + usage: { inputTokens: 0, outputTokens: 0 }, + }); + + const { generateWorkoutPlan } = await import('@/ai/flows/workout-generator-flow'); + await expect(generateWorkoutPlan(validInput)).rejects.toThrow( + 'A IA não retornou um plano de treino válido.' + ); + }); +}); diff --git a/src/ai/genkit.test.ts b/src/ai/genkit.test.ts new file mode 100644 index 00000000..9645456e --- /dev/null +++ b/src/ai/genkit.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGenkit = vi.fn(() => ({ name: 'mocked-ai' })); + +vi.mock('genkit', () => ({ + genkit: mockGenkit, +})); + +vi.mock('@genkit-ai/google-genai', () => ({ + googleAI: vi.fn(() => 'google-ai-plugin'), +})); + +describe('genkit initialization', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('initializes genkit with googleAI plugin', async () => { + const { ai } = await import('./genkit'); + expect(mockGenkit).toHaveBeenCalledTimes(1); + expect(mockGenkit).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: expect.arrayContaining(['google-ai-plugin']), + }) + ); + expect(ai).toEqual({ name: 'mocked-ai' }); + }); + + it('passes GOOGLE_GENAI_API_KEY to googleAI plugin', async () => { + process.env.GOOGLE_GENAI_API_KEY = 'test-key'; + const { googleAI } = await import('@genkit-ai/google-genai'); + await import('./genkit'); + + expect(googleAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'test-key' })); + + delete process.env.GOOGLE_GENAI_API_KEY; + }); + + it('falls back to GEMINI_API_KEY when GOOGLE_GENAI_API_KEY is not set', async () => { + delete process.env.GOOGLE_GENAI_API_KEY; + process.env.GEMINI_API_KEY = 'fallback-key'; + const { googleAI } = await import('@genkit-ai/google-genai'); + await import('./genkit'); + + expect(googleAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'fallback-key' })); + + delete process.env.GEMINI_API_KEY; + }); +}); diff --git a/src/ai/schemas.test.ts b/src/ai/schemas.test.ts new file mode 100644 index 00000000..dd8971a2 --- /dev/null +++ b/src/ai/schemas.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; +import { WorkoutGeneratorInputSchema, WorkoutGeneratorAIOutputSchema } from './schemas'; + +describe('WorkoutGeneratorInputSchema', () => { + const validInput = { + objetivo: 'Hipertrofia' as const, + nivelExperiencia: 'Iniciante' as const, + diasPorSemana: 3, + }; + + describe('valid input', () => { + it('accepts minimal valid input', () => { + const result = WorkoutGeneratorInputSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); + + it('accepts input with optional observacoesAdicionais', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ + ...validInput, + observacoesAdicionais: 'Lesão no joelho esquerdo', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.observacoesAdicionais).toBe('Lesão no joelho esquerdo'); + } + }); + + it('accepts all objetivo values', () => { + for (const objetivo of ['Hipertrofia', 'Perda de Peso', 'Força'] as const) { + const result = WorkoutGeneratorInputSchema.safeParse({ ...validInput, objetivo }); + expect(result.success).toBe(true); + } + }); + + it('accepts all nivelExperiencia values', () => { + for (const nivel of ['Iniciante', 'Intermediário', 'Avançado'] as const) { + const result = WorkoutGeneratorInputSchema.safeParse({ + ...validInput, + nivelExperiencia: nivel, + }); + expect(result.success).toBe(true); + } + }); + + it('accepts diasPorSemana at boundary 1', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ ...validInput, diasPorSemana: 1 }); + expect(result.success).toBe(true); + }); + + it('accepts diasPorSemana at boundary 7', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ ...validInput, diasPorSemana: 7 }); + expect(result.success).toBe(true); + }); + }); + + describe('invalid input', () => { + it('rejects invalid objetivo', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ + ...validInput, + objetivo: 'Cardio', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid nivelExperiencia', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ + ...validInput, + nivelExperiencia: 'Expert', + }); + expect(result.success).toBe(false); + }); + + it('rejects diasPorSemana below minimum (0)', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ ...validInput, diasPorSemana: 0 }); + expect(result.success).toBe(false); + }); + + it('rejects diasPorSemana above maximum (8)', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ ...validInput, diasPorSemana: 8 }); + expect(result.success).toBe(false); + }); + + it('rejects missing required fields', () => { + const result = WorkoutGeneratorInputSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects non-number diasPorSemana', () => { + const result = WorkoutGeneratorInputSchema.safeParse({ + ...validInput, + diasPorSemana: 'three', + }); + expect(result.success).toBe(false); + }); + }); +}); + +describe('WorkoutGeneratorAIOutputSchema', () => { + const validOutput = { + planName: 'Plano Hipertrofia 4x', + workouts: [ + { + nome: 'Treino A - Peito e Tríceps', + objetivo: 'Hipertrofia do peitoral', + diaSugerido: 1, + exercicios: [ + { + nomeExercicio: 'Supino Reto com Barra', + grupoMuscular: 'Peito', + series: 4, + repeticoes: '8-12', + observacoes: 'Controlar a descida', + }, + ], + }, + ], + }; + + describe('valid output', () => { + it('accepts a complete valid output', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse(validOutput); + expect(result.success).toBe(true); + }); + + it('accepts empty workouts array', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + planName: 'Plano vazio', + workouts: [], + }); + expect(result.success).toBe(true); + }); + + it('accepts diaSugerido at boundary 0 (Sunday)', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + ...validOutput, + workouts: [{ ...validOutput.workouts[0], diaSugerido: 0 }], + }); + expect(result.success).toBe(true); + }); + + it('accepts diaSugerido at boundary 6 (Saturday)', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + ...validOutput, + workouts: [{ ...validOutput.workouts[0], diaSugerido: 6 }], + }); + expect(result.success).toBe(true); + }); + + it('accepts multiple workouts', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + planName: 'Split Full', + workouts: [ + { ...validOutput.workouts[0], nome: 'Treino A', diaSugerido: 1 }, + { ...validOutput.workouts[0], nome: 'Treino B', diaSugerido: 3 }, + { ...validOutput.workouts[0], nome: 'Treino C', diaSugerido: 5 }, + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts empty observacoes string', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + ...validOutput, + workouts: [ + { + ...validOutput.workouts[0], + exercicios: [{ ...validOutput.workouts[0].exercicios[0], observacoes: '' }], + }, + ], + }); + expect(result.success).toBe(true); + }); + }); + + describe('invalid output', () => { + it('rejects missing planName', () => { + const { planName: _removed, ...rest } = validOutput; + const result = WorkoutGeneratorAIOutputSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it('rejects missing workouts', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + planName: 'Test', + }); + expect(result.success).toBe(false); + }); + + it('rejects diaSugerido below 0', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + ...validOutput, + workouts: [{ ...validOutput.workouts[0], diaSugerido: -1 }], + }); + expect(result.success).toBe(false); + }); + + it('rejects diaSugerido above 6', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + ...validOutput, + workouts: [{ ...validOutput.workouts[0], diaSugerido: 7 }], + }); + expect(result.success).toBe(false); + }); + + it('rejects workout with missing exercise fields', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + ...validOutput, + workouts: [ + { + ...validOutput.workouts[0], + exercicios: [{ nomeExercicio: 'Supino' }], + }, + ], + }); + expect(result.success).toBe(false); + }); + + it('rejects workout with non-array exercicios', () => { + const result = WorkoutGeneratorAIOutputSchema.safeParse({ + ...validOutput, + workouts: [ + { + ...validOutput.workouts[0], + exercicios: 'not-an-array', + }, + ], + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/src/app/actions/auth.test.ts b/src/app/actions/auth.test.ts new file mode 100644 index 00000000..f739b434 --- /dev/null +++ b/src/app/actions/auth.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/utils/supabase/server', () => ({ + createClient: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + redirect: vi.fn((url: string) => { + throw new Error(`Redirect to ${url}`); + }), +})); + +vi.mock('next/dist/client/components/redirect-error', () => ({ + isRedirectError: vi.fn((err: unknown) => { + if (err instanceof Error && err.message.startsWith('Redirect to')) return true; + return false; + }), +})); + +vi.mock('zod/v4', () => { + const actual = vi.importActual('zod/v4'); + return actual; +}); + +import { createClient } from '@/utils/supabase/server'; +import { redirect } from 'next/navigation'; + +function mockSupabase( + overrides: { + signInError?: string; + profileRole?: string | null; + profileError?: { code: string } | null; + } = {} +) { + const mockSignIn = vi.fn().mockResolvedValue({ + data: overrides.signInError + ? { user: null } + : { user: { id: 'user-1', email: 'test@test.com' } }, + error: overrides.signInError ? { message: overrides.signInError } : null, + }); + + const mockSelect = vi.fn().mockReturnThis(); + const mockEq = vi.fn().mockReturnThis(); + const mockSingle = vi.fn().mockResolvedValue({ + data: overrides.profileError ? null : { role: overrides.profileRole ?? null }, + error: overrides.profileError ?? null, + }); + + const mockSignOut = vi.fn().mockResolvedValue({ error: null }); + + const mockSupabaseClient = { + auth: { + signInWithPassword: mockSignIn, // ggignore + signOut: mockSignOut, + }, + from: vi.fn(() => ({ + select: mockSelect, + eq: mockEq, + single: mockSingle, + })), + }; + + vi.mocked(createClient).mockResolvedValue(mockSupabaseClient as never); + return mockSupabaseClient; +} + +describe('auth server actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('login', () => { + it('returns error for invalid email format', async () => { + mockSupabase(); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'invalid-email'); + formData.set('password', '__TEST_PASSWORD__'); + + const result = await login(undefined, formData); + expect(result).toEqual({ + error: 'Dados inválidos. Verifique o e-mail e a senha.', + }); + }); + + it('returns error for short password', async () => { + mockSupabase(); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'test@test.com'); + formData.set('password', '123'); + + const result = await login(undefined, formData); + expect(result).toEqual({ + error: 'Dados inválidos. Verifique o e-mail e a senha.', + }); + }); + + it('returns error when Supabase auth fails', async () => { + mockSupabase({ signInError: 'Invalid login credentials' }); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'test@test.com'); + formData.set('password', '__TEST_PASSWORD__'); + + const result = await login(undefined, formData); + expect(result).toEqual({ + error: 'E-mail ou senha inválidos. Por favor, tente novamente.', + }); + }); + + it('redirects to /dashboard for funcionario profile', async () => { + mockSupabase({ profileRole: 'INSTRUTOR' }); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'test@test.com'); + formData.set('password', '__TEST_PASSWORD__'); + + await expect(login(undefined, formData)).rejects.toThrow('Redirect to /dashboard'); + expect(redirect).toHaveBeenCalledWith('/dashboard'); + }); + + it('redirects to /aluno/dashboard for aluno profile (no funcionario row)', async () => { + mockSupabase({ profileError: { code: 'PGRST116' } }); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'aluno@test.com'); + formData.set('password', '__TEST_PASSWORD__'); + + await expect(login(undefined, formData)).rejects.toThrow('Redirect to /aluno/dashboard'); + expect(redirect).toHaveBeenCalledWith('/aluno/dashboard'); + }); + + it('returns error when profile query has a real DB error', async () => { + mockSupabase({ profileError: { code: '50000' } }); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'test@test.com'); + formData.set('password', '__TEST_PASSWORD__'); + + const result = await login(undefined, formData); + expect(result).toEqual({ + error: 'Erro ao verificar perfil. Por favor, tente novamente.', + }); + }); + + it('handles PGRST116 error (no rows) for aluno gracefully', async () => { + mockSupabase({ profileError: { code: 'PGRST116' } }); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'aluno@test.com'); + formData.set('password', '__TEST_PASSWORD__'); + + await expect(login(undefined, formData)).rejects.toThrow('Redirect to /aluno/dashboard'); + }); + + it('re-throws redirect errors (isRedirectError path)', async () => { + mockSupabase({ profileRole: 'GERENTE' }); + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'gerente@test.com'); + formData.set('password', '__TEST_PASSWORD__'); + + await expect(login(undefined, formData)).rejects.toThrow('Redirect to /dashboard'); + }); + + it('returns error for unexpected non-redirect exceptions', async () => { + const mockSelect = vi.fn().mockReturnThis(); + const mockEq = vi.fn().mockReturnThis(); + const mockSingle = vi.fn().mockRejectedValue(new Error('DB connection lost')); + + vi.mocked(createClient).mockResolvedValue({ + auth: { + signInWithPassword: vi.fn().mockResolvedValue({ + // ggignore + data: { user: { id: 'user-1', email: 'test@test.com' } }, + error: null, + }), + signOut: vi.fn(), + }, + from: vi.fn(() => ({ + select: mockSelect, + eq: mockEq, + single: mockSingle, + })), + } as never); + + const { login } = await import('./auth'); + const formData = new FormData(); + formData.set('email', 'test@test.com'); + formData.set('password', '__TEST_PASSWORD__'); + + const result = await login(undefined, formData); + expect(result).toEqual({ + error: 'Ocorreu um erro inesperado. Por favor, tente novamente.', + }); + }); + }); + + describe('logout', () => { + it('calls signOut and redirects to /login', async () => { + const mockSupabaseClient = mockSupabase(); + const { logout } = await import('./auth'); + + await expect(logout()).rejects.toThrow('Redirect to /login'); + expect(mockSupabaseClient.auth.signOut).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith('/login'); + }); + }); +}); diff --git a/src/app/aluno/aluno-header.test.tsx b/src/app/aluno/aluno-header.test.tsx new file mode 100644 index 00000000..29c2a7ba --- /dev/null +++ b/src/app/aluno/aluno-header.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AlunoHeader, UserMenu, LanguageSelector } from './aluno-header'; +import type { ReactNode } from 'react'; +import type { NavLink } from './aluno-header'; + +vi.mock('@/components/providers/i18n-provider', () => ({ + useI18n: () => ({ + language: 'pt', + setLanguage: vi.fn(), + t: (key: string) => { + const map: Record = { + 'common.welcome': 'Olá', + 'common.profile': 'Meu Perfil', + 'common.logout': 'Sair', + 'common.selectLanguage': 'Selecionar idioma', + }; + return map[key] || key; + }, + }), +})); + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock('@/components/ui/avatar', () => ({ + Avatar: ({ children }: { children: ReactNode }) =>
{children}
, + AvatarFallback: ({ children }: { children: ReactNode }) => {children}, + AvatarImage: () => null, +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + variant, + ...props + }: { + children: ReactNode; + variant?: string; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), +})); + +const mockNavLinks: NavLink[] = [ + { href: '/aluno/dashboard', label: 'Dashboard', icon: D }, + { href: '/aluno/meus-treinos', label: 'Treinos', icon: T }, +]; + +describe('AlunoHeader', () => { + it('renders the brand name', () => { + render( + + ); + expect(screen.getByText('Five Star Academy')).toBeTruthy(); + }); + + it('renders nav links', () => { + render( + + ); + expect(screen.getAllByText('Dashboard').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Treinos').length).toBeGreaterThanOrEqual(1); + }); + + it('renders welcome message with display name', () => { + render( + + ); + expect(screen.getAllByText('João').length).toBeGreaterThanOrEqual(1); + }); + + it('renders language selector', () => { + render( + + ); + expect(screen.getByLabelText('Selecionar idioma')).toBeTruthy(); + }); +}); + +describe('UserMenu (aluno-header)', () => { + it('renders user name and email', () => { + render( + + ); + expect(screen.getByText('Ana')).toBeTruthy(); + expect(screen.getByText('ana@test.com')).toBeTruthy(); + }); + + it('renders profile and logout options', () => { + render( + + ); + expect(screen.getByText('Meu Perfil')).toBeTruthy(); + expect(screen.getByText('Sair')).toBeTruthy(); + }); +}); + +describe('LanguageSelector', () => { + it('renders language button', () => { + render(); + expect(screen.getByLabelText('Selecionar idioma')).toBeTruthy(); + }); + + it('shows language options', () => { + render(); + expect(screen.getByText('Português (BR)')).toBeTruthy(); + expect(screen.getByText('English (US)')).toBeTruthy(); + }); +}); diff --git a/src/app/aluno/dashboard/dashboard-client.test.tsx b/src/app/aluno/dashboard/dashboard-client.test.tsx new file mode 100644 index 00000000..2cbf6cbe --- /dev/null +++ b/src/app/aluno/dashboard/dashboard-client.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import AlunoDashboardClient from './dashboard-client'; +import type { Aluno, Treino } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +vi.mock('motion/react', () => ({ + motion: { + div: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + }, +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => ( + + ), +})); + +vi.mock('lucide-react', () => ({ + Trophy: () => TrophyIcon, + TrendingUp: () => TrendingUpIcon, + Zap: () => ZapIcon, + Target: () => TargetIcon, + Award: () => AwardIcon, +})); + +vi.mock('@/lib/logger', () => ({ + Logger: { error: vi.fn(), info: vi.fn() }, +})); + +vi.mock('@/lib/actions/alunos', () => ({ + finalizarTreinoAction: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock('@/hooks/use-app-notification', () => ({ + useAppNotification: () => ({ + success: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('@/components/ui/circular-progress', () => ({ + CircularProgress: ({ value, label }: { value?: number; label?: string }) => ( +
+ {label} +
+ ), +})); + +vi.mock('@/components/providers/i18n-provider', () => ({ + useI18n: () => ({ + language: 'pt', + setLanguage: vi.fn(), + t: (key: string, params?: Record) => { + const map: Record = { + 'common.welcome': 'Olá', + 'dashboard.level': 'Nível', + 'dashboard.xp': 'XP', + 'dashboard.streak': `${params?.count ?? 0} dias seguidos`, + 'dashboard.workoutsThisMonth': `${params?.count ?? 0} treinos no mês`, + }; + return map[key] || key; + }, + }), +})); + +vi.mock('@/components/dashboard/aluno/exercicio-viewer', () => ({ + ExercicioViewer: () =>
, +})); + +vi.mock('@/components/dashboard/aluno/card-matricula', () => ({ + CardMatricula: () =>
, +})); + +vi.mock('@/components/dashboard/aluno/card-treino', () => ({ + CardTreino: () =>
, +})); + +vi.mock('@/components/dashboard/aluno/card-feedback', () => ({ + CardFeedback: () =>
, +})); + +function filterDomProps(props: Record): Record { + const domProps: Record = {}; + for (const [key, val] of Object.entries(props)) { + if ( + key === 'children' || + key === 'className' || + key === 'data-testid' || + key.startsWith('data-') + ) { + domProps[key] = val; + } + } + return domProps; +} + +const mockAluno: Aluno = { + id: '1', + nomeCompleto: 'João Silva', + cpf: '123.456.789-00', + email: 'joao@example.com', + telefone: '(11) 99999-9999', + dataNascimento: null, + dataCadastro: '2024-01-01', + fotoUrl: null, + statusMatricula: 'ATIVA', + nivel: 5, + exp: 1200, + xpToNextLevel: 2000, + progressPerc: 60, + streakDiasSeguidos: 7, + treinosNoMes: 12, + ultimoTreinoData: null, +}; + +const mockTreino: Treino = { + id: 'treino-1', + alunoId: '1', + objetivo: 'Hipertrofia', + diaSemana: 1, + exercicios: [ + { + id: 'ex-1', + nomeExercicio: 'Supino Reto', + series: 4, + repeticoes: '12', + observacoes: null, + descricao: 'Deite-se em um banco reto', + }, + ], +}; + +describe('AlunoDashboardClient', () => { + it('renders welcome message with first name', () => { + render(); + expect(screen.getByTestId('dashboard-welcome').textContent).toContain('JOÃO'); + }); + + it('renders streak display', () => { + render(); + expect(screen.getByTestId('dashboard-welcome')).toBeTruthy(); + expect(screen.getByText(/dias seguidos/)).toBeTruthy(); + }); + + it('renders workouts this month', () => { + render(); + expect(screen.getByText('12')).toBeTruthy(); + }); + + it('renders level progress', () => { + render(); + expect(screen.getByTestId('circular-progress')).toBeTruthy(); + expect(screen.getByText((content) => content.includes('NÍVEL'))).toBeTruthy(); + }); + + it('renders XP display', () => { + render(); + expect(screen.getByTestId('xp-display').textContent).toContain('1200'); + expect(screen.getByTestId('xp-display').textContent).toContain('2000'); + }); + + it('renders achievements section', () => { + render(); + expect(screen.getByText('CONQUISTAS')).toBeTruthy(); + expect(screen.getByText('Iniciante Elite')).toBeTruthy(); + }); + + it('renders subcomponents', () => { + render(); + expect(screen.getByTestId('card-treino')).toBeTruthy(); + expect(screen.getByTestId('card-feedback')).toBeTruthy(); + expect(screen.getByTestId('card-matricula')).toBeTruthy(); + expect(screen.getByTestId('exercicio-viewer')).toBeTruthy(); + }); + + it('renders without crashing when initialTreino is null', () => { + render(); + expect(screen.getByTestId('dashboard-welcome')).toBeTruthy(); + }); +}); diff --git a/src/app/aluno/dashboard/page.test.tsx b/src/app/aluno/dashboard/page.test.tsx new file mode 100644 index 00000000..29340fc8 --- /dev/null +++ b/src/app/aluno/dashboard/page.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import AlunoDashboardPage from './page'; +import type { ReactNode } from 'react'; + +const mockRedirect = vi.fn((path: string) => { + throw new Error(`NEXT_REDIRECT:${path}`); +}); + +vi.mock('next/navigation', () => ({ + redirect: (...args: [string]) => mockRedirect(...args), +})); + +vi.mock('@/utils/supabase/server', () => ({ + getUser: vi.fn(), +})); + +vi.mock('@/lib/prisma', () => ({ + prisma: { + aluno: { + findUnique: vi.fn(), + }, + treino: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock('./dashboard-client', () => ({ + __esModule: true, + default: ({ + aluno, + initialTreino, + }: { + aluno: { nomeCompleto: string }; + initialTreino: unknown; + }) => ( +
+ {aluno.nomeCompleto} + {initialTreino ? 'yes' : 'no'} +
+ ), +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +import { getUser } from '@/utils/supabase/server'; +import { prisma } from '@/lib/prisma'; + +const mockGetUser = vi.mocked(getUser); +const mockPrismaAluno = vi.mocked(prisma.aluno.findUnique); +const mockPrismaTreino = vi.mocked(prisma.treino.findFirst); + +function makeAluno() { + return { + id: '1', + nomeCompleto: 'João Silva', + email: 'test@test.com', + cpf: '12345678900', + telefone: '11999999999', + dataNascimento: new Date('1990-01-01'), + dataCadastro: new Date('2024-01-01'), + fotoUrl: null, + biometriaHash: null, + statusMatricula: 'ATIVA', + nivel: 5, + exp: 1200, + streakDiasSeguidos: 7, + treinosNoMes: 12, + ultimoTreinoData: null, + progressPerc: 50, + dataVencimento: null, + xpToNextLevel: 100, + Matriculas: [], + HistoricoTreinos: [], + Treinos: [], + Pagamentos: [], + }; +} + +describe('AlunoDashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('redirects to login when user is not authenticated', async () => { + mockGetUser.mockResolvedValue({ user: null, error: 'not authenticated' } as never); + await expect(AlunoDashboardPage()).rejects.toThrow('NEXT_REDIRECT'); + expect(mockRedirect).toHaveBeenCalledWith('/aluno/login'); + }); + + it('redirects to login when user is null', async () => { + mockGetUser.mockResolvedValue({ user: null, error: null }); + await expect(AlunoDashboardPage()).rejects.toThrow('NEXT_REDIRECT'); + expect(mockRedirect).toHaveBeenCalledWith('/aluno/login'); + }); + + it('renders not found card when aluno is null in database', async () => { + mockGetUser.mockResolvedValue({ + user: { id: '1', email: 'test@test.com' }, + error: null, + } as never); + mockPrismaAluno.mockResolvedValue(null); + render(await AlunoDashboardPage()); + expect(screen.getByText('Sinto muito!')).toBeTruthy(); + }); + + it('renders dashboard client when aluno is found', async () => { + mockGetUser.mockResolvedValue({ + user: { id: '1', email: 'test@test.com' }, + error: null, + } as never); + mockPrismaAluno.mockResolvedValue(makeAluno() as never); + mockPrismaTreino.mockResolvedValue(null); + render(await AlunoDashboardPage()); + expect(screen.getByTestId('dashboard-client')).toBeTruthy(); + expect(screen.getByTestId('aluno-name').textContent).toBe('João Silva'); + }); + + it('passes treino data when workout exists for today', async () => { + mockGetUser.mockResolvedValue({ + user: { id: '1', email: 'test@test.com' }, + error: null, + } as never); + mockPrismaAluno.mockResolvedValue(makeAluno() as never); + mockPrismaTreino.mockResolvedValue({ + id: 'treino-1', + alunoId: '1', + objetivo: 'Hipertrofia', + diaSemana: 1, + instrutorId: null, + dataCriacao: new Date(), + }); + render(await AlunoDashboardPage()); + expect(screen.getByTestId('has-treino').textContent).toBe('yes'); + }); + + it('passes null treino when no workout for today', async () => { + mockGetUser.mockResolvedValue({ + user: { id: '1', email: 'test@test.com' }, + error: null, + } as never); + mockPrismaAluno.mockResolvedValue(makeAluno() as never); + mockPrismaTreino.mockResolvedValue(null); + render(await AlunoDashboardPage()); + expect(screen.getByTestId('has-treino').textContent).toBe('no'); + }); +}); diff --git a/src/app/aluno/login/page.test.tsx b/src/app/aluno/login/page.test.tsx new file mode 100644 index 00000000..ce086919 --- /dev/null +++ b/src/app/aluno/login/page.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import AlunoLoginPage from './page'; +import type { ReactNode } from 'react'; + +const mockSignInWithPassword = vi.fn().mockResolvedValue({ data: {}, error: null }); +const mockSignUp = vi.fn().mockResolvedValue({ data: {}, error: null }); + +vi.mock('@/utils/supabase/client', () => ({ + createClient: () => ({ + auth: { + signInWithPassword: mockSignInWithPassword, + signUp: mockSignUp, + }, + }), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + refresh: vi.fn(), + }), +})); + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock('@/hooks/use-app-notification', () => ({ + useAppNotification: () => ({ + success: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardHeader: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardTitle: ({ children }: { children: ReactNode }) =>

{children}

, + CardDescription: ({ children }: { children: ReactNode }) =>

{children}

, + CardContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardFooter: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + type, + disabled, + variant, + className, + }: { + children: ReactNode; + type?: string; + disabled?: boolean; + variant?: string; + asChild?: boolean; + className?: string; + }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: (props: Record) => , +})); + +vi.mock('@/components/ui/separator', () => ({ + Separator: ({ className }: { className?: string }) =>
, +})); + +vi.mock('@/components/ui/form', () => ({ + Form: ({ children }: { children: ReactNode }) =>
{children}
, + FormControl: ({ children }: { children: ReactNode }) => <>{children}, + FormField: ({ render }: { render: (props: { field: Record }) => ReactNode }) => { + const field = { value: '', onChange: vi.fn(), onBlur: vi.fn(), name: 'field' }; + return <>{render({ field })}; + }, + FormItem: ({ children }: { children: ReactNode }) =>
{children}
, + FormLabel: ({ children }: { children: ReactNode }) => , + FormMessage: () => null, +})); + +describe('AlunoLoginPage', () => { + it('renders the login card title', () => { + render(); + expect(screen.getByText('Portal do Aluno (Supabase)')).toBeTruthy(); + }); + + it('renders email and password labels', () => { + render(); + expect(screen.getByText('Email')).toBeTruthy(); + expect(screen.getByText('Senha')).toBeTruthy(); + }); + + it('renders submit button', () => { + render(); + expect(screen.getByText('Entrar')).toBeTruthy(); + }); + + it('renders link to management login', () => { + render(); + expect(screen.getByText('Acesso para Gestão')).toBeTruthy(); + }); + + it('renders the dumbbell icon', () => { + const { container } = render(); + expect(container.querySelector('.lucide-dumbbell')).toBeTruthy(); + }); +}); diff --git a/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx b/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx new file mode 100644 index 00000000..33d733a2 --- /dev/null +++ b/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MeusTreinosClient from './meus-treinos-client'; +import type { Treino } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + refresh: vi.fn(), + }), +})); + +vi.mock('@/hooks/use-app-notification', () => ({ + useAppNotification: () => ({ + success: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('@/hooks/use-workout-crud', () => ({ + useWorkoutCRUD: () => ({ + meusTreinos: [ + { + id: 'treino-1', + alunoId: 'user-1', + objetivo: 'Hipertrofia', + diaSemana: 1, + instrutorId: 'instrutor-1', + exercicios: [{ id: 'ex-1', nomeExercicio: 'Supino', series: 4, repeticoes: '12' }], + }, + { + id: 'treino-2', + alunoId: 'user-1', + objetivo: 'Resistência', + diaSemana: null, + exercicios: [{ id: 'ex-2', nomeExercicio: 'Corrida', series: 1, repeticoes: '30min' }], + }, + ], + isFormVisible: false, + setIsFormVisible: vi.fn(), + editingTreino: null, + setEditingTreino: vi.fn(), + isAlertOpen: false, + setIsAlertOpen: vi.fn(), + deletingTreino: null, + handleSave: vi.fn(), + handleEdit: vi.fn(), + handleDayChange: vi.fn(), + openDeleteAlert: vi.fn(), + handleDelete: vi.fn(), + }), +})); + +vi.mock('@/hooks/use-workout-generation', () => ({ + useWorkoutGeneration: () => ({ + isGenerating: false, + handleGenerate: vi.fn(), + }), +})); + +vi.mock('@/lib/actions/treinos', () => ({ + registrarHistoricoTreinoAction: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock('@/components/page-header', () => ({ + PageHeader: ({ title, description }: { title: string; description?: string }) => ( +
+

{title}

+ {description &&

{description}

} +
+ ), +})); + +vi.mock('@/components/ui/select', () => ({ + Select: ({ children }: { children: ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children: ReactNode }) => <>{children}, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () => null, +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: ReactNode }) =>

{children}

, + CardDescription: ({ children }: { children: ReactNode }) =>

{children}

, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + ...props + }: { + children: ReactNode; + onClick?: () => void; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/badge', () => ({ + Badge: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ children, open }: { children: ReactNode; open?: boolean }) => + open ?
{children}
: null, + AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + AlertDialogCancel: ({ children }: { children: ReactNode }) => , + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock('@/components/WorkoutSession', () => ({ + WorkoutSession: () =>
, +})); + +vi.mock('@/components/dashboard/aluno/workout-generator', () => ({ + WorkoutGenerator: () =>
, +})); + +vi.mock('@/components/dashboard/aluno/workout-editor', () => ({ + WorkoutEditor: () =>
, +})); + +function filterDomProps(props: Record): Record { + const domProps: Record = {}; + for (const [key, val] of Object.entries(props)) { + if (key === 'children' || key === 'className' || key.startsWith('data-')) { + domProps[key] = val; + } + } + return domProps; +} + +const mockTreinos: Treino[] = [ + { + id: 'treino-1', + alunoId: 'user-1', + objetivo: 'Hipertrofia', + diaSemana: 1, + instrutorId: 'instrutor-1', + exercicios: [{ id: 'ex-1', nomeExercicio: 'Supino', series: 4, repeticoes: '12' }], + }, + { + id: 'treino-2', + alunoId: 'user-1', + objetivo: 'Resistência', + diaSemana: null, + exercicios: [{ id: 'ex-2', nomeExercicio: 'Corrida', series: 1, repeticoes: '30min' }], + }, +]; + +describe('MeusTreinosClient', () => { + it('renders the page header', () => { + render(); + expect(screen.getByText('Meus Treinos')).toBeTruthy(); + }); + + it('renders personal workout section', () => { + render(); + expect(screen.getByText('Planos do Personal')).toBeTruthy(); + }); + + it('renders personal workout list description', () => { + render(); + expect(screen.getByText('Treinos criados e enviados pelo seu instrutor.')).toBeTruthy(); + }); + + it('renders personal workouts section and personal workouts info', () => { + render(); + expect(screen.getByText('Meus Treinos Pessoais')).toBeTruthy(); + }); + + it('renders workout generator', () => { + render(); + expect(screen.getByTestId('workout-generator')).toBeTruthy(); + }); + + it('renders create button when form is not visible', () => { + render(); + expect(screen.getByText('Criar Novo Treino Manualmente')).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/_components/user-menu.test.tsx b/src/app/dashboard/_components/user-menu.test.tsx new file mode 100644 index 00000000..d8c139e3 --- /dev/null +++ b/src/app/dashboard/_components/user-menu.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { UserMenu } from './user-menu'; +import type { ReactNode } from 'react'; + +vi.mock('@/components/ui/avatar', () => ({ + Avatar: ({ children }: { children: ReactNode }) =>
{children}
, + AvatarFallback: ({ children }: { children: ReactNode }) => {children}, + AvatarImage: () => null, +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => ( + + ), +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), +})); + +vi.mock('@/app/actions/auth', () => ({ + logout: vi.fn(), +})); + +describe('UserMenu', () => { + it('renders user display name', () => { + render(); + expect(screen.getByText('João')).toBeTruthy(); + }); + + it('renders user email', () => { + render(); + expect(screen.getByText('joao@test.com')).toBeTruthy(); + }); + + it('renders menu items', () => { + render(); + expect(screen.getByText('Perfil')).toBeTruthy(); + expect(screen.getByText('Configurações')).toBeTruthy(); + expect(screen.getByText('Sair da conta')).toBeTruthy(); + }); + + it('renders avatar fallback with first letter of email', () => { + render(); + expect(screen.getByText('J')).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/alunos/alunos-client.test.tsx b/src/app/dashboard/alunos/alunos-client.test.tsx new file mode 100644 index 00000000..4fa552b5 --- /dev/null +++ b/src/app/dashboard/alunos/alunos-client.test.tsx @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AlunosClient } from './alunos-client'; +import type { Aluno, Plano } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +const mockRouter = { refresh: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }; +vi.mock('next/navigation', () => ({ + useRouter: () => mockRouter, +})); + +const mockNotify = { success: vi.fn(), error: vi.fn(), warn: vi.fn() }; +vi.mock('@/hooks/use-app-notification', () => ({ + useAppNotification: () => mockNotify, +})); + +vi.mock('@/lib/actions/alunos', () => ({ + createAlunoAction: vi.fn(), + updateAlunoAction: vi.fn(), + deleteAlunoAction: vi.fn(), + createMatriculaAction: vi.fn(), +})); + +vi.mock('@/components/dashboard/alunos/data-table', () => ({ + DataTable: ({ data }: { data: Aluno[]; columns: unknown[] }) => ( +
+ {data.map((a) => ( +
+ {a.nomeCompleto} +
+ ))} +
+ ), +})); + +vi.mock('@/components/dashboard/alunos/form-aluno', () => ({ + FormAluno: ({ + isOpen, + onOpenChange, + onSubmit, + aluno, + }: { + isOpen: boolean; + onOpenChange: (v: boolean) => void; + onSubmit: (d: unknown) => void; + aluno?: Aluno; + }) => + isOpen ? ( +
+ {aluno ? 'Editando' : 'Cadastrando'} + + +
+ ) : null, +})); + +vi.mock('@/components/dashboard/alunos/form-matricula', () => ({ + FormMatricula: ({ + isOpen, + onSubmit, + aluno, + }: { + isOpen: boolean; + onOpenChange: (v: boolean) => void; + onSubmit: (a: Aluno, p: Plano) => void; + aluno: Aluno | null; + }) => + isOpen && aluno ? ( +
+ Matrícula para {aluno.nomeCompleto} + +
+ ) : null, +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + AlertDialogCancel: ({ children }: { children: ReactNode }) => , +})); + +const mockAlunos: Aluno[] = [ + { + id: 'aluno-1', + nomeCompleto: 'João Silva', + cpf: '123.456.789-00', + email: 'joao@test.com', + telefone: '(11) 99999-9999', + dataNascimento: '1990-01-01', + dataCadastro: '2023-01-01', + fotoUrl: '', + statusMatricula: 'ATIVA', + nivel: 1, + exp: 0, + streakDiasSeguidos: 0, + treinosNoMes: 0, + ultimoTreinoData: null, + }, +]; + +const mockPlanos: Plano[] = [{ id: 'plano-1', nome: 'Mensal', preco: 100, duracaoDias: 30 }]; + +describe('AlunosClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders the page title and description', () => { + render(); + expect(screen.getByText('Gestão de Alunos')).toBeTruthy(); + expect(screen.getByText(/Cadastre, visualize e gerencie/)).toBeTruthy(); + }); + + it('renders the "Cadastrar Aluno" button', () => { + render(); + expect(screen.getByText('Cadastrar Aluno')).toBeTruthy(); + }); + + it('renders the DataTable with alumnos', () => { + render(); + expect(screen.getByTestId('data-table')).toBeTruthy(); + expect(screen.getByTestId('aluno-row-aluno-1')).toBeTruthy(); + }); + + it('opens FormAluno when "Cadastrar Aluno" is clicked', () => { + render(); + fireEvent.click(screen.getByText('Cadastrar Aluno')); + expect(screen.getByTestId('form-aluno')).toBeTruthy(); + expect(screen.getByText('Cadastrando')).toBeTruthy(); + }); + + it('calls createAlunoAction on form submit', async () => { + const { createAlunoAction } = await import('@/lib/actions/alunos'); + vi.mocked(createAlunoAction).mockResolvedValue({ success: true, data: mockAlunos[0] }); + + render(); + fireEvent.click(screen.getByText('Cadastrar Aluno')); + fireEvent.click(screen.getByTestId('submit-aluno-form')); + + await waitFor(() => { + expect(createAlunoAction).toHaveBeenCalled(); + expect(mockNotify.success).toHaveBeenCalled(); + expect(mockRouter.refresh).toHaveBeenCalled(); + }); + }); + + it('shows error notification on createAlunoAction failure', async () => { + const { createAlunoAction } = await import('@/lib/actions/alunos'); + vi.mocked(createAlunoAction).mockResolvedValue({ success: false, error: 'Erro ao criar' }); + + render(); + fireEvent.click(screen.getByText('Cadastrar Aluno')); + fireEvent.click(screen.getByTestId('submit-aluno-form')); + + await waitFor(() => { + expect(mockNotify.error).toHaveBeenCalledWith('Erro ao salvar', 'Erro ao criar'); + }); + }); + + it('calls deleteAlunoAction on confirm', async () => { + const { deleteAlunoAction } = await import('@/lib/actions/alunos'); + vi.mocked(deleteAlunoAction).mockResolvedValue({ success: true }); + + render(); + + expect(screen.queryByTestId('alert-dialog')).toBeNull(); + }); +}); diff --git a/src/app/dashboard/financeiro/financeiro-client.test.tsx b/src/app/dashboard/financeiro/financeiro-client.test.tsx new file mode 100644 index 00000000..edb0d563 --- /dev/null +++ b/src/app/dashboard/financeiro/financeiro-client.test.tsx @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import FinanceiroClient from './financeiro-client'; +import type { ReactNode } from 'react'; + +const mockRouter = { refresh: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }; +vi.mock('next/navigation', () => ({ + useRouter: () => mockRouter, +})); + +const mockNotify = { success: vi.fn(), error: vi.fn(), warn: vi.fn() }; +vi.mock('@/hooks/use-app-notification', () => ({ + useAppNotification: () => mockNotify, +})); + +vi.mock('@/lib/actions/financeiro', () => ({ + registrarPagamentoAction: vi.fn(), +})); + +vi.mock('@/components/ui/table', () => ({ + Table: ({ children }: { children: ReactNode }) => {children}
, + TableHeader: ({ children }: { children: ReactNode }) => {children}, + TableBody: ({ children }: { children: ReactNode }) => {children}, + TableRow: ({ children }: { children: ReactNode }) => {children}, + TableHead: ({ children }: { children: ReactNode }) => {children}, + TableCell: ({ children, className }: { children: ReactNode; className?: string }) => ( + {children} + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: string; + }) => ( + + ), +})); + +vi.mock('@/components/ui/badge', () => ({ + Badge: ({ children, variant }: { children: ReactNode; variant?: string }) => ( + {children} + ), +})); + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogAction: ({ + children, + onClick, + disabled, + }: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), + AlertDialogCancel: ({ children, disabled }: { children: ReactNode; disabled?: boolean }) => ( + + ), +})); + +interface AlunoFinanceiro { + id: string; + nomeCompleto: string; + email: string; + statusMatricula: string; +} + +const mockInadimplentes: AlunoFinanceiro[] = [ + { + id: 'aluno-1', + nomeCompleto: 'João Silva', + email: 'joao@test.com', + statusMatricula: 'INADIMPLENTE', + }, + { + id: 'aluno-2', + nomeCompleto: 'Maria Santos', + email: 'maria@test.com', + statusMatricula: 'INADIMPLENTE', + }, +]; + +describe('FinanceiroClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders table headers', () => { + render(); + expect(screen.getByText('Aluno')).toBeTruthy(); + expect(screen.getByText('Email')).toBeTruthy(); + expect(screen.getByText('Status')).toBeTruthy(); + expect(screen.getByText('Ações')).toBeTruthy(); + }); + + it('renders all inadimplente students', () => { + render(); + expect(screen.getByText('João Silva')).toBeTruthy(); + expect(screen.getByText('joao@test.com')).toBeTruthy(); + expect(screen.getByText('Maria Santos')).toBeTruthy(); + expect(screen.getByText('maria@test.com')).toBeTruthy(); + }); + + it('renders "Inadimplente" badge for each student', () => { + render(); + const badges = screen.getAllByText('Inadimplente'); + expect(badges.length).toBe(2); + }); + + it('renders "Registrar Pagamento" buttons', () => { + render(); + const buttons = screen.getAllByText('Registrar Pagamento'); + expect(buttons.length).toBe(2); + }); + + it('shows empty state when no inadimplentes', () => { + render(); + expect(screen.getByText('Não há alunos inadimplentes no momento.')).toBeTruthy(); + }); + + it('opens confirmation dialog when "Registrar Pagamento" is clicked', () => { + render(); + fireEvent.click(screen.getAllByText('Registrar Pagamento')[0]); + expect(screen.getByTestId('alert-dialog')).toBeTruthy(); + expect(screen.getByText('Confirmar Pagamento')).toBeTruthy(); + expect(screen.getAllByText(/João Silva/).length).toBeGreaterThanOrEqual(1); + }); + + it('calls registrarPagamentoAction on confirm', async () => { + const { registrarPagamentoAction } = await import('@/lib/actions/financeiro'); + vi.mocked(registrarPagamentoAction).mockResolvedValue({ success: true }); + + render(); + fireEvent.click(screen.getAllByText('Registrar Pagamento')[0]); + + fireEvent.click(screen.getByText('Confirmar')); + + await waitFor(() => { + expect(registrarPagamentoAction).toHaveBeenCalledWith('aluno-1'); + expect(mockNotify.success).toHaveBeenCalledWith( + 'Pagamento Registrado!', + expect.stringContaining('João Silva') + ); + expect(mockRouter.refresh).toHaveBeenCalled(); + }); + }); + + it('shows error notification on payment registration failure', async () => { + const { registrarPagamentoAction } = await import('@/lib/actions/financeiro'); + vi.mocked(registrarPagamentoAction).mockResolvedValue({ + success: false, + error: 'Erro de pagamento', + }); + + render(); + fireEvent.click(screen.getAllByText('Registrar Pagamento')[0]); + fireEvent.click(screen.getByText('Confirmar')); + + await waitFor(() => { + expect(mockNotify.error).toHaveBeenCalledWith( + 'Erro ao registrar pagamento', + 'Erro de pagamento' + ); + }); + }); + + it('shows "Processando..." while payment is being processed', async () => { + const { registrarPagamentoAction } = await import('@/lib/actions/financeiro'); + let resolvePromise!: (v: { success: boolean; error?: string }) => void; + vi.mocked(registrarPagamentoAction).mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }) + ); + + render(); + fireEvent.click(screen.getAllByText('Registrar Pagamento')[0]); + fireEvent.click(screen.getByText('Confirmar')); + + await waitFor(() => { + expect(screen.getByText('Processando...')).toBeTruthy(); + }); + + resolvePromise!({ success: true }); + + await waitFor(() => { + expect(screen.queryByText('Processando...')).toBeNull(); + }); + }); +}); diff --git a/src/app/dashboard/page.test.tsx b/src/app/dashboard/page.test.tsx new file mode 100644 index 00000000..7e9e240d --- /dev/null +++ b/src/app/dashboard/page.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import DashboardPage from './page'; +import type { ReactNode } from 'react'; +import type { DashboardStats } from '@/lib/definitions'; + +vi.mock('@/lib/data', () => ({ + getDashboardStats: vi.fn().mockResolvedValue({ + totalAlunos: 150, + matriculasAtivas: 120, + alunosInadimplentes: 15, + faturamentoMensal: 45000, + crescimentoAnual: [ + { mes: 'Jan', alunos: 10 }, + { mes: 'Fev', alunos: 15 }, + ], + } satisfies DashboardStats), +})); + +vi.mock('@/components/dashboard/dashboard-charts', () => ({ + DashboardCharts: ({ data }: { data: unknown[] }) => ( +
{data.length} items
+ ), +})); + +vi.mock('@/components/page-header', () => ({ + PageHeader: ({ title, description }: { title: string; description?: string }) => ( +
+

{title}

+ {description &&

{description}

} +
+ ), +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+ ), + CardHeader: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardTitle: ({ children, className }: { children: ReactNode; className?: string }) => ( +

{children}

+ ), + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('lucide-react', () => ({ + Users: () => UsersIcon, + UserCheck: () => UserCheckIcon, + UserX: () => UserXIcon, + DollarSign: () => DollarSignIcon, +})); + +describe('DashboardPage', () => { + it('renders the page header', async () => { + render(await DashboardPage()); + expect(screen.getByText('Dashboard')).toBeTruthy(); + expect(screen.getByText('Bem-vindo ao centro de comando da Five Star Gym.')).toBeTruthy(); + }); + + it('renders KPI cards', async () => { + render(await DashboardPage()); + expect(screen.getByText('Total de Alunos')).toBeTruthy(); + expect(screen.getByText('Matrículas Ativas')).toBeTruthy(); + expect(screen.getByText('Inadimplentes')).toBeTruthy(); + expect(screen.getByText('Faturamento Mensal')).toBeTruthy(); + }); + + it('renders formatted stat values', async () => { + render(await DashboardPage()); + expect(screen.getByText('150')).toBeTruthy(); + expect(screen.getByText('120')).toBeTruthy(); + expect(screen.getByText('15')).toBeTruthy(); + }); + + it('renders formatted currency value', async () => { + render(await DashboardPage()); + expect(screen.getByText((content) => content.includes('45'))).toBeTruthy(); + }); + + it('renders the charts component', async () => { + render(await DashboardPage()); + expect(screen.getByTestId('dashboard-charts')).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/planos/planos-client.test.tsx b/src/app/dashboard/planos/planos-client.test.tsx new file mode 100644 index 00000000..055b3b61 --- /dev/null +++ b/src/app/dashboard/planos/planos-client.test.tsx @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { PlanosClient } from './planos-client'; +import type { Plano } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +const mockRouter = { refresh: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }; +vi.mock('next/navigation', () => ({ + useRouter: () => mockRouter, +})); + +const mockNotify = { success: vi.fn(), error: vi.fn(), warn: vi.fn() }; +vi.mock('@/hooks/use-app-notification', () => ({ + useAppNotification: () => mockNotify, +})); + +vi.mock('@/lib/actions/planos', () => ({ + createPlanoAction: vi.fn(), + updatePlanoAction: vi.fn(), + deletePlanoAction: vi.fn(), +})); + +vi.mock('@/components/dashboard/planos/form-plano', () => ({ + FormPlano: ({ + isOpen, + onOpenChange, + onSubmit, + plano, + }: { + isOpen: boolean; + onOpenChange: (v: boolean) => void; + onSubmit: (d: { nome: string; preco: number; duracaoDias: number }) => void; + plano?: Plano; + }) => + isOpen ? ( +
+ {plano ? 'Editando' : 'Criando'} + + +
+ ) : null, +})); + +vi.mock('@/components/page-header', () => ({ + PageHeader: ({ + title, + description, + actions, + }: { + title: string; + description?: string; + actions?: ReactNode; + }) => ( +
+

{title}

+ {description &&

{description}

} + {actions} +
+ ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: ReactNode }) =>

{children}

, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, + CardFooter: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/badge', () => ({ + Badge: ({ children, variant }: { children: ReactNode; variant?: string }) => ( + {children} + ), +})); + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + AlertDialogCancel: ({ children }: { children: ReactNode }) => , +})); + +vi.mock('lucide-react', () => ({ + PlusCircle: () => +, + Pencil: () => P, + Trash2: () => X, +})); + +const mockPlanos: Plano[] = [ + { id: 'plano-1', nome: 'Mensal', preco: 100, duracaoDias: 30 }, + { id: 'plano-2', nome: 'Trimestral', preco: 250, duracaoDias: 90 }, + { id: 'plano-3', nome: 'Anual', preco: 960, duracaoDias: 360 }, +]; + +describe('PlanosClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders the PageHeader with title and description', () => { + render(); + expect(screen.getByText('Planos da Academia')).toBeTruthy(); + expect(screen.getByText(/Gerencie os planos de mensalidade/)).toBeTruthy(); + }); + + it('renders all plan cards', () => { + render(); + expect(screen.getAllByTestId('card')).toHaveLength(3); + expect(screen.getByText('Mensal')).toBeTruthy(); + expect(screen.getByText('Trimestral')).toBeTruthy(); + expect(screen.getByText('Anual')).toBeTruthy(); + }); + + it('displays formatted prices in BRL', () => { + render(); + expect(screen.getByText(/R\$\s*100,00/)).toBeTruthy(); + expect(screen.getByText(/R\$\s*250,00/)).toBeTruthy(); + expect(screen.getByText(/R\$\s*960,00/)).toBeTruthy(); + }); + + it('displays formatted duration badges', () => { + render(); + expect(screen.getByText('mês')).toBeTruthy(); + expect(screen.getByText('3 meses')).toBeTruthy(); + expect(screen.getByText('12 meses')).toBeTruthy(); + }); + + it('opens FormPlano in create mode when "Adicionar Plano" is clicked', () => { + render(); + fireEvent.click(screen.getByText('Adicionar Plano')); + expect(screen.getByTestId('form-plano')).toBeTruthy(); + expect(screen.getByText('Criando')).toBeTruthy(); + }); + + it('calls createPlanoAction on new plano submit', async () => { + const { createPlanoAction } = await import('@/lib/actions/planos'); + vi.mocked(createPlanoAction).mockResolvedValue({ + success: true, + data: { id: 'new', nome: 'Novo Plano', preco: 150, duracaoDias: 30 }, + }); + + render(); + fireEvent.click(screen.getByText('Adicionar Plano')); + fireEvent.click(screen.getByTestId('submit-plano')); + + await waitFor(() => { + expect(createPlanoAction).toHaveBeenCalledWith({ + nome: 'Novo Plano', + preco: 150, + duracaoDias: 30, + }); + expect(mockNotify.success).toHaveBeenCalled(); + expect(mockRouter.refresh).toHaveBeenCalled(); + }); + }); + + it('shows error on createPlanoAction failure', async () => { + const { createPlanoAction } = await import('@/lib/actions/planos'); + vi.mocked(createPlanoAction).mockResolvedValue({ success: false, error: 'Erro ao criar' }); + + render(); + fireEvent.click(screen.getByText('Adicionar Plano')); + fireEvent.click(screen.getByTestId('submit-plano')); + + await waitFor(() => { + expect(mockNotify.error).toHaveBeenCalledWith('Erro ao salvar', 'Erro ao criar'); + }); + }); + + it('renders with empty planos', () => { + render(); + expect(screen.getByText('Planos da Academia')).toBeTruthy(); + expect(screen.queryAllByTestId('card')).toHaveLength(0); + }); + + it('calls deletePlanoAction when delete is confirmed', async () => { + const { deletePlanoAction } = await import('@/lib/actions/planos'); + vi.mocked(deletePlanoAction).mockResolvedValue({ success: true }); + + render(); + + const deleteButtons = screen.getAllByText('Excluir'); + fireEvent.click(deleteButtons[0]); + + expect(screen.getByTestId('alert-dialog')).toBeTruthy(); + const confirmDeleteButtons = screen.getAllByText('Excluir'); + fireEvent.click(confirmDeleteButtons[confirmDeleteButtons.length - 1]); + + await waitFor(() => { + expect(deletePlanoAction).toHaveBeenCalledWith('plano-1'); + expect(mockNotify.success).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/dashboard/treinos/treinos-client.test.tsx b/src/app/dashboard/treinos/treinos-client.test.tsx new file mode 100644 index 00000000..a7fc4bcc --- /dev/null +++ b/src/app/dashboard/treinos/treinos-client.test.tsx @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import TreinosManagementClient from './treinos-client'; +import type { Aluno } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +const mockNotify = { success: vi.fn(), error: vi.fn(), warn: vi.fn() }; +vi.mock('@/hooks/use-app-notification', () => ({ + useAppNotification: () => mockNotify, +})); + +const mockWorkoutExercises = { + objetivo: '', + setObjetivo: vi.fn(), + exercicios: [] as unknown[], + addObjective: vi.fn(), + removeExercise: vi.fn(), + updateExercise: vi.fn(), + hasValidationErrors: vi.fn(() => true), + reset: vi.fn(), +}; +vi.mock('@/hooks/use-workout-exercises', () => ({ + useWorkoutExercises: () => mockWorkoutExercises, +})); + +vi.mock('@/lib/actions/treinos', () => ({ + upsertTreinoAction: vi.fn(), + batchUpsertTreinoAction: vi.fn(), +})); + +vi.mock('@/ai/flows/workout-generator-flow', () => ({ + streamWorkoutPlan: vi.fn(), +})); + +vi.mock('@/lib/logger', () => ({ + Logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('@/components/dashboard/aluno/workout-generator', () => ({ + WorkoutGenerator: ({ + onGenerate, + isGenerating, + }: { + onGenerate: (d: unknown) => void; + isGenerating: boolean; + }) => ( +
+ {isGenerating ? 'Gerando...' : 'Gerar'} + +
+ ), +})); + +vi.mock('@/components/dashboard/aluno/workout-exercise-row', () => ({ + WorkoutExerciseRow: () =>
, +})); + +vi.mock('@/components/ui/select', () => ({ + Select: ({ + children, + onValueChange, + value, + }: { + children: ReactNode; + onValueChange: (v: string) => void; + value?: string; + }) => ( + + ), + SelectContent: ({ children }: { children: ReactNode }) => <>{children}, + SelectItem: ({ children, value }: { children: ReactNode; value: string }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: ReactNode }) =>

{children}

, + CardDescription: ({ children }: { children: ReactNode }) =>

{children}

, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, + CardFooter: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children }: { children: ReactNode }) => , +})); + +const mockAlunos: Aluno[] = [ + { + id: 'aluno-1', + nomeCompleto: 'João Silva', + cpf: '123.456.789-00', + email: 'joao@test.com', + telefone: '(11) 99999-9999', + dataNascimento: '1990-01-01', + dataCadastro: '2023-01-01', + fotoUrl: '', + statusMatricula: 'ATIVA', + nivel: 1, + exp: 0, + streakDiasSeguidos: 0, + treinosNoMes: 0, + ultimoTreinoData: null, + }, + { + id: 'aluno-2', + nomeCompleto: 'Maria Santos', + cpf: '987.654.321-00', + email: 'maria@test.com', + telefone: '(11) 88888-8888', + dataNascimento: '1995-05-15', + dataCadastro: '2023-06-01', + fotoUrl: '', + statusMatricula: 'ATIVA', + nivel: 3, + exp: 150, + streakDiasSeguidos: 5, + treinosNoMes: 12, + ultimoTreinoData: '2024-01-10', + }, +]; + +describe('TreinosManagementClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWorkoutExercises.objetivo = ''; + mockWorkoutExercises.exercicios = []; + mockWorkoutExercises.hasValidationErrors.mockReturnValue(true); + }); + + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders the page title with Passo 1', () => { + render(); + expect(screen.getByText(/Passo 1: Selecionar Aluno/)).toBeTruthy(); + }); + + it('renders the student select with all students', () => { + render(); + expect(screen.getByText('João Silva')).toBeTruthy(); + expect(screen.getByText('Maria Santos')).toBeTruthy(); + }); + + it('does not show Step 2 when no student is selected', () => { + render(); + expect(screen.queryByText(/Passo 2/)).toBeNull(); + }); + + it('shows Step 2 when a student is selected', () => { + render(); + const select = screen.getByTestId('aluno-select'); + fireEvent.change(select, { target: { value: 'aluno-1' } }); + expect(screen.getByText(/Passo 2: Criar Treino Manual para João Silva/)).toBeTruthy(); + }); + + it('shows WorkoutGenerator when a student is selected', () => { + render(); + fireEvent.change(screen.getByTestId('aluno-select'), { target: { value: 'aluno-1' } }); + expect(screen.getByTestId('workout-generator')).toBeTruthy(); + }); + + it('calls upsertTreinoAction on save when validation passes', async () => { + const { upsertTreinoAction } = await import('@/lib/actions/treinos'); + vi.mocked(upsertTreinoAction).mockResolvedValue({ success: true }); + + mockWorkoutExercises.hasValidationErrors.mockReturnValue(false); + mockWorkoutExercises.exercicios = [ + { + id: 'ex-1', + nomeExercicio: 'Supino', + series: 3, + repeticoes: '10-12', + observacoes: null, + descricao: null, + }, + ]; + mockWorkoutExercises.objetivo = 'Peito e Tríceps'; + + render(); + fireEvent.change(screen.getByTestId('aluno-select'), { target: { value: 'aluno-1' } }); + + fireEvent.click(screen.getByText(/Salvar Treino/)); + + await waitFor(() => { + expect(upsertTreinoAction).toHaveBeenCalled(); + expect(mockNotify.success).toHaveBeenCalledWith('Treino Salvo!'); + }); + }); + + it('shows error notification when upsertTreinoAction fails', async () => { + const { upsertTreinoAction } = await import('@/lib/actions/treinos'); + vi.mocked(upsertTreinoAction).mockResolvedValue({ success: false, error: 'Erro de banco' }); + + mockWorkoutExercises.hasValidationErrors.mockReturnValue(false); + mockWorkoutExercises.exercicios = [ + { + id: 'ex-1', + nomeExercicio: 'Supino', + series: 3, + repeticoes: '10-12', + observacoes: null, + descricao: null, + }, + ]; + mockWorkoutExercises.objetivo = 'Peito'; + + render(); + fireEvent.change(screen.getByTestId('aluno-select'), { target: { value: 'aluno-1' } }); + fireEvent.click(screen.getByText(/Salvar Treino/)); + + await waitFor(() => { + expect(mockNotify.error).toHaveBeenCalled(); + }); + }); + + it('calls addObjective when "Adicionar Exercício" is clicked', () => { + render(); + fireEvent.change(screen.getByTestId('aluno-select'), { target: { value: 'aluno-1' } }); + + fireEvent.click(screen.getByText(/Adicionar Exercício/)); + expect(mockWorkoutExercises.addObjective).toHaveBeenCalled(); + }); + + it('disables save button when exercicios is empty', () => { + mockWorkoutExercises.exercicios = []; + render(); + fireEvent.change(screen.getByTestId('aluno-select'), { target: { value: 'aluno-1' } }); + + const saveBtn = screen.getByText(/Salvar Treino/); + expect(saveBtn.hasAttribute('disabled')).toBe(true); + }); + + it('shows error when trying to save without validation and no selectedAlunoId', () => { + mockWorkoutExercises.hasValidationErrors.mockReturnValue(true); + render(); + const saveBtn = screen.queryByText(/Salvar Treino/); + expect(saveBtn).toBeNull(); + }); +}); diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx new file mode 100644 index 00000000..f1049e6e --- /dev/null +++ b/src/app/login/page.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import LoginPage from './page'; +import type { ReactNode } from 'react'; + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useActionState: () => [{ error: undefined }, vi.fn(), false], + }; +}); + +vi.mock('@/app/actions/auth', () => ({ + login: vi.fn(), +})); + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardHeader: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardTitle: ({ children }: { children: ReactNode }) =>

{children}

, + CardDescription: ({ children }: { children: ReactNode }) =>

{children}

, + CardContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardFooter: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + type, + disabled, + variant, + ...props + }: { + children: ReactNode; + type?: string; + disabled?: boolean; + variant?: string; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: (props: Record) => , +})); + +vi.mock('@/components/ui/separator', () => ({ + Separator: ({ className }: { className?: string }) =>
, +})); + +describe('LoginPage', () => { + it('renders the login form', () => { + render(); + expect(screen.getByText('Five Star')).toBeTruthy(); + expect(screen.getByText('SISTEMA DE GESTÃO')).toBeTruthy(); + }); + + it('renders email and password inputs', () => { + render(); + expect(screen.getByLabelText('Email corporativo')).toBeTruthy(); + expect(screen.getByLabelText('Senha de acesso')).toBeTruthy(); + }); + + it('renders submit button', () => { + render(); + expect(screen.getByText('Entrar no Sistema')).toBeTruthy(); + }); + + it('renders link to aluno portal', () => { + render(); + expect(screen.getByText('Acessar Portal do Aluno')).toBeTruthy(); + }); + + it('renders copyright notice', () => { + render(); + expect(screen.getByText((content) => content.includes('Five Star Fitness'))).toBeTruthy(); + }); +}); diff --git a/src/components/WorkoutSession.test.tsx b/src/components/WorkoutSession.test.tsx new file mode 100644 index 00000000..868fc5a8 --- /dev/null +++ b/src/components/WorkoutSession.test.tsx @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { WorkoutSession } from './WorkoutSession'; +import type { Treino } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +vi.mock('react-timer-hook', () => ({ + useTimer: () => ({ + seconds: 0, + minutes: 0, + isRunning: false, + restart: vi.fn(), + }), +})); + +vi.mock('@/ai/flows/workout-feedback-flow', () => ({ + generateWorkoutFeedback: vi.fn(), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: string; + }) => ( + + ), +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+ ), + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardTitle: ({ children, className }: { children: ReactNode; className?: string }) => ( +

{children}

+ ), + CardDescription: ({ children }: { children: ReactNode }) =>

{children}

, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, + CardFooter: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children, className }: { children: ReactNode; className?: string }) => ( + + ), +})); + +const mockTreino: Treino = { + id: 'treino-1', + alunoId: 'aluno-1', + instrutorId: 'instrutor-1', + objetivo: 'Peito e Tríceps', + diaSemana: 1, + dataCriacao: '2024-01-01', + exercicios: [ + { + id: 'ex-1', + nomeExercicio: 'Supino Reto', + series: 3, + repeticoes: '10-12', + observacoes: 'Controlar a descida', + descricao: 'Exercício para peito', + }, + { + id: 'ex-2', + nomeExercicio: 'Tríceps Pulley', + series: 3, + repeticoes: '12-15', + observacoes: null, + descricao: 'Exercício para tríceps', + }, + ], +}; + +const mockOnFinish = vi.fn(); +const mockOnCancel = vi.fn(); + +describe('WorkoutSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockOnFinish).mockResolvedValue(undefined); + }); + + it('renders without crashing', () => { + const { container } = render( + + ); + expect(container).toBeTruthy(); + }); + + it('renders the first exercise name', () => { + render(); + expect(screen.getByText('Supino Reto')).toBeTruthy(); + }); + + it('shows exercise progress indicator', () => { + render(); + expect(screen.getByText('Exercício 1 de 2')).toBeTruthy(); + }); + + it('renders the planned sets and reps', () => { + render(); + expect(screen.getByText(/Planejado: 3 séries x 10-12 reps/)).toBeTruthy(); + }); + + it('renders observations when available', () => { + render(); + expect(screen.getByText('Obs: Controlar a descida')).toBeTruthy(); + }); + + it('renders series inputs for the current exercise (3 sets)', () => { + render(); + const pesoInputs = screen.getAllByPlaceholderText('Peso (kg)'); + const repsInputs = screen.getAllByPlaceholderText('Reps'); + expect(pesoInputs.length).toBe(3); + expect(repsInputs.length).toBe(3); + }); + + it('disables "Anterior" button on first exercise', () => { + render(); + const prevBtn = screen.getByText(/Anterior/); + expect(prevBtn.hasAttribute('disabled')).toBe(true); + }); + + it('navigates to next exercise when "Próximo" is clicked', () => { + render(); + fireEvent.click(screen.getByText(/Próximo/)); + expect(screen.getByText('Tríceps Pulley')).toBeTruthy(); + expect(screen.getByText('Exercício 2 de 2')).toBeTruthy(); + }); + + it('shows "Finalizar Treino" on last exercise', () => { + render(); + fireEvent.click(screen.getByText(/Próximo/)); + expect(screen.getByText('Finalizar Treino')).toBeTruthy(); + }); + + it('navigates back with "Anterior"', () => { + render(); + fireEvent.click(screen.getByText(/Próximo/)); + expect(screen.getByText('Tríceps Pulley')).toBeTruthy(); + + fireEvent.click(screen.getByText(/Anterior/)); + expect(screen.getByText('Supino Reto')).toBeTruthy(); + }); + + it('calls onFinish and shows completion screen', async () => { + const { generateWorkoutFeedback } = await import('@/ai/flows/workout-feedback-flow'); + vi.mocked(generateWorkoutFeedback).mockResolvedValue({ + title: 'Treino Concluído!', + message: 'Excelente trabalho!', + }); + + render(); + fireEvent.click(screen.getByText(/Próximo/)); + fireEvent.click(screen.getByText(/Finalizar Treino/)); + + await waitFor(() => { + expect(mockOnFinish).toHaveBeenCalled(); + expect(screen.getByText('Treino Finalizado!')).toBeTruthy(); + }); + }); + + it('shows loading state while generating feedback', async () => { + const { generateWorkoutFeedback } = await import('@/ai/flows/workout-feedback-flow'); + let resolveFeedback!: (v: { title: string; message: string }) => void; + vi.mocked(generateWorkoutFeedback).mockImplementation( + () => + new Promise((resolve) => { + resolveFeedback = resolve; + }) + ); + + render(); + fireEvent.click(screen.getByText(/Próximo/)); + fireEvent.click(screen.getByText(/Finalizar Treino/)); + + await waitFor(() => { + expect(screen.getByText(/Gerando seu feedback personalizado/)).toBeTruthy(); + }); + + resolveFeedback!({ title: 'Feito!', message: 'Bom treino!' }); + + await waitFor(() => { + expect(screen.getByText('Feito!')).toBeTruthy(); + }); + }); + + it('calls onCancel when "Fechar Treino" is clicked', async () => { + const { generateWorkoutFeedback } = await import('@/ai/flows/workout-feedback-flow'); + vi.mocked(generateWorkoutFeedback).mockResolvedValue({ + title: 'Concluído', + message: 'Bom!', + }); + + render(); + fireEvent.click(screen.getByText(/Próximo/)); + fireEvent.click(screen.getByText(/Finalizar Treino/)); + + await waitFor(() => { + expect(screen.getByText('Fechar Treino')).toBeTruthy(); + }); + + fireEvent.click(screen.getByText('Fechar Treino')); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('toggles series completion', () => { + render(); + const checkButtons = screen + .getAllByRole('button') + .filter( + (btn) => btn.querySelector('svg') !== null && btn.getAttribute('data-variant') === 'outline' + ); + if (checkButtons.length > 0) { + fireEvent.click(checkButtons[0]); + } + const updatedCheckButtons = screen + .getAllByRole('button') + .filter( + (btn) => btn.querySelector('svg') !== null && btn.getAttribute('data-variant') === 'default' + ); + expect(updatedCheckButtons.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/components/dashboard-nav.test.tsx b/src/components/dashboard-nav.test.tsx new file mode 100644 index 00000000..7584b20b --- /dev/null +++ b/src/components/dashboard-nav.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DashboardNav, DashboardNavBottom } from './dashboard-nav'; +import type { ReactNode } from 'react'; +import { usePathname } from 'next/navigation'; + +type MockChildren = { children?: ReactNode }; + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/dashboard'), +})); + +vi.mock('next/link', () => ({ + default: ({ children, href }: MockChildren & { href: string }) => ( + + {children} + + ), +})); + +vi.mock('@/components/ui/sidebar', () => ({ + SidebarMenu: ({ children, className }: MockChildren & { className?: string }) => ( + + ), + SidebarMenuItem: ({ children }: MockChildren) => ( +
{children}
+ ), + SidebarMenuButton: ({ + children, + isActive, + tooltip, + }: MockChildren & { isActive?: boolean; tooltip?: string }) => ( + + ), +})); + +vi.mock('@/app/actions/auth', () => ({ + logout: vi.fn(), +})); + +vi.mock('@/lib/constants', () => ({ + FINANCIAL_ROUTES: ['/dashboard/financeiro', '/dashboard/planos'], +})); + +vi.mock('lucide-react', () => { + const MockIcon = ({ 'data-testid': testId }: { 'data-testid'?: string }) => ( + + ); + return { + LayoutDashboard: () => , + Users: () => , + Dumbbell: () => , + DollarSign: () => , + FileText: () => , + Settings: () => , + LogOut: () => , + FlaskConical: () => , + }; +}); + +describe('DashboardNav', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(usePathname).mockReturnValue('/dashboard'); + }); + + it('renders without throwing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders Dashboard link', () => { + render(); + expect(screen.getByText('Dashboard')).toBeTruthy(); + }); + + it('renders Alunos link', () => { + render(); + expect(screen.getByText('Alunos')).toBeTruthy(); + }); + + it('renders Treinos link', () => { + render(); + expect(screen.getByText('Treinos')).toBeTruthy(); + }); + + it('renders Planos link for GERENTE role', () => { + render(); + expect(screen.getByText('Planos')).toBeTruthy(); + }); + + it('does not render Financeiro for INSTRUTOR role', () => { + render(); + expect(screen.queryByText('Financeiro')).toBeNull(); + }); + + it('does not render Planos for INSTRUTOR role', () => { + render(); + expect(screen.queryByText('Planos')).toBeNull(); + }); + + it('renders Financeiro for GERENTE role', () => { + render(); + expect(screen.getByText('Financeiro')).toBeTruthy(); + }); + + it('marks active link based on pathname', () => { + vi.mocked(usePathname).mockReturnValue('/dashboard/alunos'); + + render(); + const menuButtons = screen.getAllByTestId('sidebar-menu-button'); + const activeButton = menuButtons.find((btn) => btn.getAttribute('data-is-active') === 'true'); + expect(activeButton).toBeTruthy(); + }); + + it('marks Dashboard as active only on exact path', () => { + vi.mocked(usePathname).mockReturnValue('/dashboard'); + + render(); + const menuButtons = screen.getAllByTestId('sidebar-menu-button'); + const activeButtons = menuButtons.filter( + (btn) => btn.getAttribute('data-is-active') === 'true' + ); + expect(activeButtons.length).toBe(1); + }); + + it('links have correct href attributes', () => { + render(); + const links = screen.getAllByTestId('nav-link'); + const hrefs = links.map((link) => link.getAttribute('href')); + expect(hrefs).toContain('/dashboard'); + expect(hrefs).toContain('/dashboard/alunos'); + expect(hrefs).toContain('/dashboard/treinos'); + }); +}); + +describe('DashboardNavBottom', () => { + it('renders without throwing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders Configurações', () => { + render(); + expect(screen.getByText('Configurações')).toBeTruthy(); + }); + + it('renders Sair', () => { + render(); + expect(screen.getByText('Sair')).toBeTruthy(); + }); +}); diff --git a/src/components/dashboard/aluno/card-feedback.test.tsx b/src/components/dashboard/aluno/card-feedback.test.tsx new file mode 100644 index 00000000..a609a1cd --- /dev/null +++ b/src/components/dashboard/aluno/card-feedback.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { CardFeedback } from './card-feedback'; +import type { ReactNode } from 'react'; + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+ ), + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardTitle: ({ children, className }: { children: ReactNode; className?: string }) => ( +

{children}

+ ), + CardContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: ({ className }: { className?: string }) => ( +
+ ), +})); + +describe('CardFeedback', () => { + it('returns null when feedback is null and not loading', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows loading skeletons when isLoading is true', () => { + render(); + expect(screen.getByText(/Pulsando Bio-Dados/i)).toBeTruthy(); + const skeletons = screen.getAllByTestId('skeleton'); + expect(skeletons.length).toBe(3); + }); + + it('renders feedback title and message when provided', () => { + const feedback = { + title: 'Excelente treino!', + message: 'Você está evoluindo bem, continue assim.', + }; + render(); + expect(screen.getByText('Excelente treino!')).toBeTruthy(); + expect(screen.getByText(/Você está evoluindo bem/)).toBeTruthy(); + }); + + it('does not render loading state when feedback is provided', () => { + const feedback = { title: 'Bom', message: 'OK' }; + render(); + expect(screen.queryByText(/Pulsando Bio-Dados/i)).toBeNull(); + }); +}); diff --git a/src/components/dashboard/aluno/card-matricula.test.tsx b/src/components/dashboard/aluno/card-matricula.test.tsx new file mode 100644 index 00000000..b6f10f02 --- /dev/null +++ b/src/components/dashboard/aluno/card-matricula.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { CardMatricula } from './card-matricula'; +import type { Aluno } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + CardContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +const baseAluno: Aluno = { + id: '1', + nomeCompleto: 'João Silva', + cpf: '123.456.789-00', + email: 'joao@example.com', + telefone: '(11) 99999-9999', + dataNascimento: null, + dataCadastro: '2024-01-01', + fotoUrl: null, + statusMatricula: 'ATIVA', + nivel: 1, + exp: 0, + streakDiasSeguidos: 0, + treinosNoMes: 0, + ultimoTreinoData: null, + dataVencimento: '2026-06-15', +}; + +describe('CardMatricula', () => { + it('renders null when aluno is null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders ATIVA status correctly', () => { + render(); + expect(screen.getByText('Matrícula Ativa')).toBeTruthy(); + expect(screen.getByText('Status do Plano')).toBeTruthy(); + }); + + it('renders INADIMPLENTE status correctly', () => { + const aluno = { ...baseAluno, statusMatricula: 'INADIMPLENTE' as const }; + render(); + expect(screen.getByText('Pagamento Pendente')).toBeTruthy(); + }); + + it('renders INATIVA status correctly', () => { + const aluno = { ...baseAluno, statusMatricula: 'INATIVA' as const }; + render(); + expect(screen.getByText('Matrícula Inativa')).toBeTruthy(); + }); + + it('displays formatted expiration date', () => { + render(); + expect(screen.getByText('Vencimento')).toBeTruthy(); + const dateEl = screen.getByText((content) => content.includes('2026')); + expect(dateEl).toBeTruthy(); + }); + + it('displays fallback date when dataVencimento is null', () => { + const aluno = { ...baseAluno, dataVencimento: null }; + render(); + expect(screen.getByText('--/--/----')).toBeTruthy(); + }); +}); diff --git a/src/components/dashboard/aluno/card-treino.test.tsx b/src/components/dashboard/aluno/card-treino.test.tsx new file mode 100644 index 00000000..e564e04c --- /dev/null +++ b/src/components/dashboard/aluno/card-treino.test.tsx @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CardTreino } from './card-treino'; +import type { Treino } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +type MockChildren = { children?: ReactNode }; + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, glass }: MockChildren & { glass?: boolean }) => ( +
+ {children} +
+ ), + CardHeader: ({ children }: MockChildren) =>
{children}
, + CardTitle: ({ children }: MockChildren) =>

{children}

, + CardDescription: ({ children }: MockChildren) =>

{children}

, + CardContent: ({ children }: MockChildren) =>
{children}
, + CardFooter: ({ children }: MockChildren) =>
{children}
, +})); + +vi.mock('@/components/ui/checkbox', () => ({ + Checkbox: ({ + id, + checked, + onCheckedChange, + }: { + id?: string; + checked?: boolean; + onCheckedChange?: () => void; + }) => ( + + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + }: MockChildren & { onClick?: () => void; disabled?: boolean; variant?: string }) => ( + + ), +})); + +vi.mock('lucide-react', () => ({ + CalendarOff: () => , + Sparkles: () => , + BrainCircuit: () => , + Info: () => ℹ️, +})); + +vi.mock('@/components/ui/circular-progress', () => ({ + CircularProgress: ({ value }: { value: number }) => ( +
+ ), +})); + +vi.mock('@/lib/utils', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})); + +const mockUseWorkoutTracker = vi.fn(); +vi.mock('@/hooks/use-workout-tracker', () => ({ + useWorkoutTracker: (...args: unknown[]) => mockUseWorkoutTracker(...args), +})); + +const mockTreino: Treino = { + id: 'treino-1', + alunoId: 'aluno-1', + instrutorId: null, + objetivo: 'Treino A - Peito', + exercicios: [ + { + id: 'ex-1', + nomeExercicio: 'Supino Reto', + series: 4, + repeticoes: '10-12', + observacoes: null, + descricao: null, + }, + { + id: 'ex-2', + nomeExercicio: 'Crucifixo', + series: 3, + repeticoes: '12-15', + observacoes: null, + descricao: null, + }, + ], + diaSemana: 1, + dataCriacao: '2024-01-01', +}; + +describe('CardTreino', () => { + const mockOnFinishTraining = vi.fn(); + const mockOnViewExercicio = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWorkoutTracker.mockReturnValue({ + checkedExercises: {}, + handleCheckChange: vi.fn(), + }); + }); + + it('renders rest day message when treino is null', () => { + render( + + ); + expect(screen.getByText('Dia de Descanso Ativo')).toBeTruthy(); + }); + + it('renders rest day description when treino is null', () => { + render( + + ); + expect(screen.getByText(/Aproveite para focar/)).toBeTruthy(); + }); + + it('renders treino objetivo as title', () => { + render( + + ); + expect(screen.getByText('Treino A - Peito')).toBeTruthy(); + }); + + it('renders exercise names', () => { + render( + + ); + expect(screen.getByText('Supino Reto')).toBeTruthy(); + expect(screen.getByText('Crucifixo')).toBeTruthy(); + }); + + it('renders series x reps for exercises', () => { + render( + + ); + expect(screen.getByText('4 x 10-12 • REPETIÇÕES')).toBeTruthy(); + expect(screen.getByText('3 x 12-15 • REPETIÇÕES')).toBeTruthy(); + }); + + it('renders checkboxes for each exercise', () => { + render( + + ); + expect(screen.getByTestId('checkbox-ex-1')).toBeTruthy(); + expect(screen.getByTestId('checkbox-ex-2')).toBeTruthy(); + }); + + it('renders finish button', () => { + render( + + ); + expect(screen.getByText('Finalizar e Avaliar Treino')).toBeTruthy(); + }); + + it('finish button is disabled when no exercises checked', () => { + render( + + ); + const button = screen.getByText('Finalizar e Avaliar Treino').closest('button'); + expect(button?.disabled).toBe(true); + }); + + it('shows loading state when feedback is loading', () => { + render( + + ); + expect(screen.getByText('Processando análise...')).toBeTruthy(); + }); + + it('disables finish button when feedback is loading', () => { + render( + + ); + const button = screen.getByText('Processando análise...').closest('button'); + expect(button?.disabled).toBe(true); + }); + + it('renders circular progress component', () => { + render( + + ); + expect(screen.getByTestId('circular-progress')).toBeTruthy(); + }); + + it('renders progress percentage description', () => { + render( + + ); + expect(screen.getByText(/Desempenho atual:/)).toBeTruthy(); + }); + + it('calls onFinishTraining when finish is clicked with checked exercises', () => { + mockUseWorkoutTracker.mockReturnValue({ + checkedExercises: { 'ex-1': true, 'ex-2': false }, + handleCheckChange: vi.fn(), + }); + + render( + + ); + fireEvent.click(screen.getByText('Finalizar e Avaliar Treino')); + expect(mockOnFinishTraining).toHaveBeenCalledWith(['ex-1']); + }); +}); diff --git a/src/components/dashboard/aluno/exercicio-viewer.test.tsx b/src/components/dashboard/aluno/exercicio-viewer.test.tsx new file mode 100644 index 00000000..5829cc12 --- /dev/null +++ b/src/components/dashboard/aluno/exercicio-viewer.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ExercicioViewer } from './exercicio-viewer'; +import type { Exercicio } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ + children, + open, + onOpenChange, + }: { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => + open ? ( +
+ {children} +
+ ) : null, + AlertDialogContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogCancel: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), +})); + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +const mockExercicio: Exercicio = { + id: 'ex-1', + nomeExercicio: 'Supino Reto', + series: 4, + repeticoes: '12', + observacoes: 'Manter cotovelos firmes', + descricao: 'Deite-se em um banco reto e empurre a barra para cima.', +}; + +describe('ExercicioViewer', () => { + it('returns null when exercicio is null', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('returns null when isOpen is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders exercise name when open with exercise', () => { + render(); + expect(screen.getByText('Supino Reto')).toBeTruthy(); + }); + + it('renders exercise description', () => { + render(); + expect(screen.getByText(/Deite-se em um banco reto/)).toBeTruthy(); + }); + + it('shows fallback when description is empty', () => { + const exercicio = { ...mockExercicio, descricao: '' }; + render(); + expect(screen.getByText(/Nenhuma descrição disponível/)).toBeTruthy(); + }); + + it('renders close button', () => { + render(); + expect(screen.getByText('Fechar')).toBeTruthy(); + }); +}); diff --git a/src/components/dashboard/aluno/workout-editor.test.tsx b/src/components/dashboard/aluno/workout-editor.test.tsx new file mode 100644 index 00000000..b1f9e9c5 --- /dev/null +++ b/src/components/dashboard/aluno/workout-editor.test.tsx @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { WorkoutEditor } from './workout-editor'; +import type { Treino } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +type MockChildren = { children?: ReactNode }; + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: MockChildren) =>
{children}
, + CardHeader: ({ children }: MockChildren) =>
{children}
, + CardTitle: ({ children }: MockChildren) =>

{children}

, + CardDescription: ({ children }: MockChildren) =>

{children}

, + CardContent: ({ children }: MockChildren) =>
{children}
, + CardFooter: ({ children }: MockChildren) =>
{children}
, +})); + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children, htmlFor }: { children?: ReactNode; htmlFor?: string }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: ({ + id, + placeholder, + value, + onChange, + }: { + id?: string; + placeholder?: string; + value?: string; + onChange?: (e: React.ChangeEvent) => void; + }) => ( + + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + variant, + }: { + children?: ReactNode; + onClick?: () => void; + variant?: string; + }) => ( + + ), +})); + +vi.mock('lucide-react', () => ({ + PlusCircle: () => +, + Save: () => 💾, + Dumbbell: () => 🏋️, +})); + +const mockToast = vi.fn(); +vi.mock('@/hooks/use-toast', () => ({ + useToast: vi.fn(() => ({ toast: mockToast })), +})); + +const mockUseWorkoutExercises = vi.fn(); +vi.mock('@/hooks/use-workout-exercises', () => ({ + useWorkoutExercises: (...args: unknown[]) => mockUseWorkoutExercises(...args), +})); + +vi.mock('@/components/dashboard/aluno/workout-exercise-row', () => ({ + WorkoutExerciseRow: ({ + exercise, + index, + }: { + exercise: Record; + index: number; + }) => ( +
+ {index} + {String(exercise.nomeExercicio || '')} +
+ ), +})); + +describe('WorkoutEditor', () => { + const mockOnSave = vi.fn(); + const mockOnCancel = vi.fn(); + + const defaultHookReturn = { + objetivo: '', + setObjetivo: vi.fn(), + exercicios: [], + addObjective: vi.fn(), + removeExercise: vi.fn(), + updateExercise: vi.fn(), + hasValidationErrors: vi.fn(() => false), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWorkoutExercises.mockReturnValue(defaultHookReturn); + }); + + it('renders without throwing', () => { + const { container } = render( + + ); + expect(container).toBeTruthy(); + }); + + it('shows "Criar Novo Treino Pessoal" title when no treino', () => { + render(); + expect(screen.getByText('Criar Novo Treino Pessoal')).toBeTruthy(); + }); + + it('shows "Editar Treino Pessoal" title when editing', () => { + const treinoToEdit = { + id: 'treino-1', + alunoId: 'aluno-1', + instrutorId: null, + objetivo: 'Treino A', + exercicios: [], + diaSemana: 1, + dataCriacao: '2024-01-01', + } as Treino; + + render( + + ); + expect(screen.getByText('Editar Treino Pessoal')).toBeTruthy(); + }); + + it('renders objective input', () => { + render(); + expect(screen.getByText('Nome/Objetivo do Treino')).toBeTruthy(); + }); + + it('renders add exercise button', () => { + render(); + expect(screen.getByText('Adicionar Exercício Manualmente')).toBeTruthy(); + }); + + it('calls addObjective when add exercise is clicked', () => { + const addObjective = vi.fn(); + mockUseWorkoutExercises.mockReturnValue({ ...defaultHookReturn, addObjective }); + + render(); + fireEvent.click(screen.getByText('Adicionar Exercício Manualmente')); + expect(addObjective).toHaveBeenCalled(); + }); + + it('calls onCancel when cancel is clicked', () => { + render(); + fireEvent.click(screen.getByText('Cancelar')); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('shows "Salvar Novo Treino" button when no treino', () => { + render(); + expect(screen.getByText('Salvar Novo Treino')).toBeTruthy(); + }); + + it('shows "Salvar Alterações" button when editing', () => { + const treinoToEdit = { + id: 'treino-1', + alunoId: 'aluno-1', + instrutorId: null, + objetivo: 'Treino A', + exercicios: [], + diaSemana: 1, + dataCriacao: '2024-01-01', + } as Treino; + + render( + + ); + expect(screen.getByText('Salvar Alterações')).toBeTruthy(); + }); + + it('shows validation error toast when save with errors', () => { + const hasValidationErrors = vi.fn(() => true); + mockUseWorkoutExercises.mockReturnValue({ ...defaultHookReturn, hasValidationErrors }); + + render(); + fireEvent.click(screen.getByText('Salvar Novo Treino')); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Erro ao salvar', + variant: 'destructive', + }) + ); + expect(mockOnSave).not.toHaveBeenCalled(); + }); + + it('renders exercise rows when exercises exist', () => { + const exercises = [ + { id: 'ex-1', nomeExercicio: 'Supino', series: 3, repeticoes: '10-12' }, + { id: 'ex-2', nomeExercicio: 'Agachamento', series: 4, repeticoes: '8-10' }, + ]; + mockUseWorkoutExercises.mockReturnValue({ ...defaultHookReturn, exercicios: exercises }); + + render(); + expect(screen.getAllByTestId('exercise-row').length).toBe(2); + }); +}); diff --git a/src/components/dashboard/aluno/workout-exercise-row.test.tsx b/src/components/dashboard/aluno/workout-exercise-row.test.tsx new file mode 100644 index 00000000..f9810ae5 --- /dev/null +++ b/src/components/dashboard/aluno/workout-exercise-row.test.tsx @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { WorkoutExerciseRow } from './workout-exercise-row'; +import type { Exercicio } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +type MockChildren = { children?: ReactNode }; + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children, className }: MockChildren & { className?: string }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: ({ + placeholder, + type, + className, + value, + onChange, + ...props + }: { + placeholder?: string; + type?: string; + className?: string; + value?: string | number; + onChange?: (e: React.ChangeEvent) => void; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + variant, + ...props + }: MockChildren & { onClick?: () => void; variant?: string }) => ( + + ), +})); + +vi.mock('lucide-react', () => ({ + Trash2: () => 🗑️, +})); + +vi.mock('@/components/ui/combobox', () => ({ + Combobox: ({ + value, + onChange, + placeholder, + }: { + value?: string; + onChange?: (v: string) => void; + placeholder?: string; + }) => ( + onChange?.(e.target.value)} + placeholder={placeholder} + /> + ), +})); + +vi.mock('@/lib/exercise-options', () => ({ + exerciciosOptions: [], + flatExerciciosOptions: [], +})); + +const mockExercise: Partial = { + id: 'ex-1', + nomeExercicio: 'Supino Reto', + series: 4, + repeticoes: '10-12', + observacoes: 'Controlar a descida', +}; + +describe('WorkoutExerciseRow', () => { + const mockOnUpdate = vi.fn(); + const mockOnRemove = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without throwing', () => { + const { container } = render( + + ); + expect(container).toBeTruthy(); + }); + + it('renders combobox for exercise name by default', () => { + render( + + ); + expect(screen.getByTestId('combobox')).toBeTruthy(); + }); + + it('renders input for exercise name in input mode', () => { + render( + + ); + expect(screen.getByTestId('input-Nome do exercicio')).toBeTruthy(); + }); + + it('renders series input', () => { + render( + + ); + expect(screen.getByTestId('input-number')).toBeTruthy(); + }); + + it('renders reps input with placeholder', () => { + render( + + ); + expect(screen.getByPlaceholderText('10-12')).toBeTruthy(); + }); + + it('renders obs input with placeholder', () => { + render( + + ); + expect(screen.getByPlaceholderText('Opcional')).toBeTruthy(); + }); + + it('shows labels only on first row (index 0)', () => { + render( + + ); + expect(screen.getByText('Exercicio')).toBeTruthy(); + expect(screen.getByText('Series')).toBeTruthy(); + expect(screen.getByText('Reps')).toBeTruthy(); + expect(screen.getByText('Obs')).toBeTruthy(); + }); + + it('does not show labels on non-first row (index > 0)', () => { + render( + + ); + expect(screen.queryByText('Exercicio')).toBeNull(); + expect(screen.queryByText('Series')).toBeNull(); + expect(screen.queryByText('Reps')).toBeNull(); + expect(screen.queryByText('Obs')).toBeNull(); + }); + + it('renders delete button when onRemove is provided', () => { + render( + + ); + expect(screen.getByLabelText('Remover exercicio')).toBeTruthy(); + }); + + it('does not render delete button when onRemove is not provided', () => { + render(); + expect(screen.queryByLabelText('Remover exercicio')).toBeNull(); + }); + + it('calls onRemove when delete is clicked', () => { + render( + + ); + fireEvent.click(screen.getByLabelText('Remover exercicio')); + expect(mockOnRemove).toHaveBeenCalledWith('ex-1'); + }); + + it('calls onUpdate when series input changes', () => { + render( + + ); + fireEvent.change(screen.getByTestId('input-number'), { target: { value: '5' } }); + expect(mockOnUpdate).toHaveBeenCalledWith('ex-1', 'series', 5); + }); + + it('calls onUpdate when reps input changes', () => { + render( + + ); + fireEvent.change(screen.getByPlaceholderText('10-12'), { target: { value: '8-10' } }); + expect(mockOnUpdate).toHaveBeenCalledWith('ex-1', 'repeticoes', '8-10'); + }); + + it('calls onUpdate when obs input changes', () => { + render( + + ); + fireEvent.change(screen.getByPlaceholderText('Opcional'), { target: { value: 'Nova obs' } }); + expect(mockOnUpdate).toHaveBeenCalledWith('ex-1', 'observacoes', 'Nova obs'); + }); +}); diff --git a/src/components/dashboard/aluno/workout-generator.test.tsx b/src/components/dashboard/aluno/workout-generator.test.tsx new file mode 100644 index 00000000..51fafdb3 --- /dev/null +++ b/src/components/dashboard/aluno/workout-generator.test.tsx @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { WorkoutGenerator } from './workout-generator'; +import type { ReactNode } from 'react'; + +type MockChildren = { children?: ReactNode }; + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: MockChildren) =>
{children}
, + CardHeader: ({ children }: MockChildren) =>
{children}
, + CardTitle: ({ children }: MockChildren) =>

{children}

, + CardDescription: ({ children }: MockChildren) =>

{children}

, + CardContent: ({ children }: MockChildren) =>
{children}
, + CardFooter: ({ children }: MockChildren) =>
{children}
, +})); + +vi.mock('@/components/ui/select', () => ({ + Select: ({ + children, + onValueChange, + defaultValue, + }: { + children?: ReactNode; + onValueChange?: (v: string) => void; + defaultValue?: string; + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: MockChildren) => <>{children}, + SelectItem: ({ children, value }: { children?: ReactNode; value: string }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: ({ + placeholder, + type, + ...props + }: { + placeholder?: string; + type?: string; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type, + disabled, + form, + }: { + children?: ReactNode; + onClick?: () => void; + type?: string; + disabled?: boolean; + form?: string; + }) => ( + + ), +})); + +vi.mock('lucide-react', () => ({ + Wand2: () => 🪄, + BrainCircuit: () => 🧠, +})); + +vi.mock('@/components/ui/form', () => { + const Form = ({ children, id }: MockChildren & { id?: string }) => ( +
{children}
+ ); + const FormControl = ({ children }: MockChildren) =>
{children}
; + const FormField = ({ + render: renderProp, + }: { + render: (props: { field: Record }) => ReactNode; + }) => { + const field = { value: '', onChange: vi.fn() }; + return
{renderProp({ field })}
; + }; + const FormItem = ({ children }: MockChildren) =>
{children}
; + const FormLabel = ({ children }: MockChildren) => ; + return { Form, FormControl, FormField, FormItem, FormLabel }; +}); + +describe('WorkoutGenerator', () => { + const mockOnGenerate = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without throwing', () => { + const { container } = render( + + ); + expect(container).toBeTruthy(); + }); + + it('renders the title', () => { + render(); + expect(screen.getByText('Gerador de Plano Semanal com IA')).toBeTruthy(); + }); + + it('renders the description', () => { + render(); + expect(screen.getByText(/Preencha seus dados/)).toBeTruthy(); + }); + + it('renders form fields labels', () => { + render(); + expect(screen.getByText('Objetivo')).toBeTruthy(); + expect(screen.getByText('Meu Nível')).toBeTruthy(); + expect(screen.getByText('Dias/Semana')).toBeTruthy(); + expect(screen.getByText('Observações (Opcional)')).toBeTruthy(); + }); + + it('shows "Gerar Plano Pessoal com IA" when not generating', () => { + render(); + expect(screen.getByText('Gerar Plano Pessoal com IA')).toBeTruthy(); + }); + + it('shows "Gerando Plano..." when generating', () => { + render(); + expect(screen.getByText('Gerando Plano...')).toBeTruthy(); + }); + + it('disables button when generating', () => { + render(); + const button = screen.getByText('Gerando Plano...').closest('button'); + expect(button?.disabled).toBe(true); + }); + + it('does not disable button when not generating', () => { + render(); + const button = screen.getByText('Gerar Plano Pessoal com IA').closest('button'); + expect(button?.disabled).toBe(false); + }); + + it('form has correct id for external submit', () => { + render(); + const form = document.getElementById('ai-generator-form'); + expect(form).toBeTruthy(); + }); + + it('submit button references the form via form attribute', () => { + render(); + const button = screen.getByText('Gerar Plano Pessoal com IA').closest('button'); + expect(button?.getAttribute('data-form')).toBe('ai-generator-form'); + }); +}); diff --git a/src/components/dashboard/alunos/columns.test.tsx b/src/components/dashboard/alunos/columns.test.tsx new file mode 100644 index 00000000..c18a2743 --- /dev/null +++ b/src/components/dashboard/alunos/columns.test.tsx @@ -0,0 +1,234 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { columns } from './columns'; +import type { Aluno } from '@/lib/definitions'; + +vi.mock('@/components/ui/avatar', () => ({ + Avatar: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + AvatarFallback: ({ children }: { children?: React.ReactNode }) => {children}, + AvatarImage: () => null, +})); + +vi.mock('@/components/ui/badge', () => ({ + Badge: ({ children, variant }: { children?: React.ReactNode; variant?: string }) => ( + + {children} + + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + ...props + }: { + children?: React.ReactNode; + onClick?: () => void; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children?: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuLabel: ({ children }: { children?: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onClick, + }: { + children?: React.ReactNode; + onClick?: () => void; + }) => , + DropdownMenuSeparator: () =>
, +})); + +vi.mock('lucide-react', () => ({ + MoreHorizontal: () => , + Eye: () => 👁, +})); + +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('date-fns', () => ({ + format: (date: Date, fmt: string) => { + if (fmt === 'dd/MM/yyyy') return '01/01/2024'; + return String(date); + }, +})); + +const mockAluno: Aluno = { + id: 'test-id-123', + nomeCompleto: 'João Silva', + cpf: '123.456.789-00', + email: 'joao@test.com', + telefone: '(11) 99999-9999', + dataNascimento: '1990-01-01', + dataCadastro: '2024-01-01', + fotoUrl: '', + statusMatricula: 'ATIVA', + nivel: 1, + exp: 0, + streakDiasSeguidos: 0, + treinosNoMes: 0, + ultimoTreinoData: null, +}; + +describe('columns', () => { + const onEdit = vi.fn(); + const onDelete = vi.fn(); + const onNewMatricula = vi.fn(); + + const columnDefs = columns({ onEdit, onDelete, onNewMatricula }) as Array<{ + accessorKey?: string; + header?: string; + id?: string; + cell?: unknown; + }>; + + it('returns an array of column definitions', () => { + expect(Array.isArray(columnDefs)).toBe(true); + expect(columnDefs.length).toBe(6); + }); + + it('has photo column with accessorKey fotoUrl', () => { + expect(columnDefs[0].accessorKey).toBe('fotoUrl'); + }); + + it('has name column with accessorKey nomeCompleto', () => { + expect(columnDefs[1].accessorKey).toBe('nomeCompleto'); + expect(columnDefs[1].header).toBe('Nome'); + }); + + it('has email column with accessorKey email', () => { + expect(columnDefs[2].accessorKey).toBe('email'); + expect(columnDefs[2].header).toBe('Email'); + }); + + it('has date column with accessorKey dataCadastro', () => { + expect(columnDefs[3].accessorKey).toBe('dataCadastro'); + expect(columnDefs[3].header).toBe('Data de Cadastro'); + }); + + it('has status column with accessorKey statusMatricula', () => { + expect(columnDefs[4].accessorKey).toBe('statusMatricula'); + expect(columnDefs[4].header).toBe('Status'); + }); + + it('has actions column with id actions', () => { + expect(columnDefs[5].id).toBe('actions'); + }); + + it('renders avatar for photo column', () => { + const cell = columnDefs[0].cell; + if (typeof cell === 'function') { + const { container } = render(cell({ row: { original: mockAluno } } as never)); + expect(container.querySelector('[data-testid="avatar"]')).toBeTruthy(); + } + }); + + it('renders name and email in name column', () => { + const cell = columnDefs[1].cell; + if (typeof cell === 'function') { + render( + cell({ + row: { + getValue: (key: string) => mockAluno[key as keyof Aluno], + original: mockAluno, + }, + } as never) + ); + expect(screen.getByText('João Silva')).toBeTruthy(); + } + }); + + it('renders formatted date in date column', () => { + const cell = columnDefs[3].cell; + if (typeof cell === 'function') { + render( + cell({ + row: { + getValue: () => '2024-01-01', + original: mockAluno, + }, + } as never) + ); + expect(screen.getByText('01/01/2024')).toBeTruthy(); + } + }); + + it('renders status badge for ATIVA status', () => { + const cell = columnDefs[4].cell; + if (typeof cell === 'function') { + render( + cell({ + row: { + getValue: () => 'ATIVA', + original: mockAluno, + }, + } as never) + ); + expect(screen.getByText('ATIVA')).toBeTruthy(); + } + }); + + it('renders status badge for INADIMPLENTE status', () => { + const cell = columnDefs[4].cell; + if (typeof cell === 'function') { + render( + cell({ + row: { + getValue: () => 'INADIMPLENTE', + original: mockAluno, + }, + } as never) + ); + expect(screen.getByText('INADIMPLENTE')).toBeTruthy(); + } + }); + + it('renders status badge for INATIVA status', () => { + const cell = columnDefs[4].cell; + if (typeof cell === 'function') { + render( + cell({ + row: { + getValue: () => 'INATIVA', + original: mockAluno, + }, + } as never) + ); + expect(screen.getByText('INATIVA')).toBeTruthy(); + } + }); + + it('renders actions menu button', () => { + const cell = columnDefs[5].cell; + if (typeof cell === 'function') { + render( + cell({ + row: { + original: mockAluno, + getVisibleCells: () => [], + }, + } as never) + ); + expect(screen.getByLabelText('Mais opções')).toBeTruthy(); + } + }); +}); diff --git a/src/components/dashboard/alunos/data-table.test.tsx b/src/components/dashboard/alunos/data-table.test.tsx new file mode 100644 index 00000000..d31f6cc8 --- /dev/null +++ b/src/components/dashboard/alunos/data-table.test.tsx @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DataTable } from './data-table'; +import type { ColumnDef } from '@tanstack/react-table'; +import type { ReactNode } from 'react'; + +type MockChildren = { children?: ReactNode }; + +vi.mock('@/components/ui/table', () => ({ + Table: ({ children }: MockChildren) => {children}
, + TableBody: ({ children }: MockChildren) => {children}, + TableCell: ({ children }: MockChildren) => {children}, + TableHead: ({ children }: MockChildren) => {children}, + TableHeader: ({ children }: MockChildren) => {children}, + TableRow: ({ children, ...props }: MockChildren & Record) => ( + {children} + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + ...props + }: MockChildren & { onClick?: () => void; disabled?: boolean; variant?: string }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: ({ + value, + onChange, + placeholder, + }: { + value?: string; + onChange?: (e: React.ChangeEvent) => void; + placeholder?: string; + }) => ( + + ), +})); + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: () =>
, +})); + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: MockChildren) =>
{children}
, + CardContent: ({ children }: MockChildren) =>
{children}
, +})); + +vi.mock('lucide-react', () => ({ + ArrowUpDown: () => , +})); + +const mockGetRowModel = vi.fn(); +const mockGetHeaderGroups = vi.fn(); +const mockGetColumn = vi.fn(); +const mockGetCanPreviousPage = vi.fn(() => false); +const mockGetCanNextPage = vi.fn(() => false); +const mockNextPage = vi.fn(); +const mockPreviousPage = vi.fn(); + +vi.mock('@tanstack/react-table', async () => { + const actual = await vi.importActual('@tanstack/react-table'); + return { + ...actual, + useReactTable: () => ({ + getRowModel: mockGetRowModel, + getHeaderGroups: mockGetHeaderGroups, + getColumn: mockGetColumn, + getCanPreviousPage: mockGetCanPreviousPage, + getCanNextPage: mockGetCanNextPage, + nextPage: mockNextPage, + previousPage: mockPreviousPage, + }), + flexRender: (def: unknown, ctx: unknown) => { + if (typeof def === 'function') { + return def(ctx); + } + if (ctx && typeof ctx === 'object' && 'getValue' in (ctx as Record)) { + const getValue = (ctx as { getValue: () => unknown }).getValue; + return String(getValue()); + } + return String(def ?? ''); + }, + getCoreRowModel: () => () => ({}), + getPaginationRowModel: () => () => ({}), + getSortedRowModel: () => () => ({}), + getFilteredRowModel: () => () => ({}), + }; +}); + +const columns: ColumnDef<{ id: string; name: string; email: string }>[] = [ + { accessorKey: 'name', header: 'Name' }, + { accessorKey: 'email', header: 'Email' }, +]; + +const mockData = [ + { id: '1', name: 'Alice', email: 'alice@test.com' }, + { id: '2', name: 'Bob', email: 'bob@test.com' }, +]; + +describe('DataTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without throwing', () => { + mockGetRowModel.mockReturnValue({ rows: [] }); + mockGetHeaderGroups.mockReturnValue([{ id: '1', headers: [] }]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders search input with default placeholder', () => { + mockGetRowModel.mockReturnValue({ rows: [] }); + mockGetHeaderGroups.mockReturnValue([{ id: '1', headers: [] }]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + render(); + expect(screen.getByPlaceholderText('Buscar por nome...')).toBeTruthy(); + }); + + it('renders custom search placeholder', () => { + mockGetRowModel.mockReturnValue({ rows: [] }); + mockGetHeaderGroups.mockReturnValue([{ id: '1', headers: [] }]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + render( + + ); + expect(screen.getByPlaceholderText('Custom search...')).toBeTruthy(); + }); + + it('shows empty state when no rows', () => { + mockGetRowModel.mockReturnValue({ rows: [] }); + mockGetHeaderGroups.mockReturnValue([{ id: '1', headers: [] }]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + render(); + expect(screen.getAllByText('Nenhum aluno encontrado.').length).toBeGreaterThanOrEqual(1); + }); + + it('renders table rows when data is present', () => { + mockGetRowModel.mockReturnValue({ + rows: mockData.map((item) => ({ + id: item.id, + original: item, + getIsSelected: () => false, + getVisibleCells: () => [ + { + id: `${item.id}-name`, + column: { id: 'name', columnDef: { cell: () => item.name } }, + getContext: () => ({ getValue: () => item.name }), + }, + { + id: `${item.id}-email`, + column: { id: 'email', columnDef: { cell: () => item.email } }, + getContext: () => ({ getValue: () => item.email }), + }, + ], + })), + }); + mockGetHeaderGroups.mockReturnValue([ + { + id: '1', + headers: [ + { + id: 'name', + column: { + getCanSort: () => true, + getToggleSortingHandler: () => {}, + columnDef: { header: () => 'Name' }, + }, + columnDef: { header: () => 'Name' }, + getContext: () => ({}), + isPlaceholder: false, + }, + { + id: 'email', + column: { + getCanSort: () => false, + getToggleSortingHandler: () => {}, + columnDef: { header: () => 'Email' }, + }, + columnDef: { header: () => 'Email' }, + getContext: () => ({}), + isPlaceholder: false, + }, + ], + }, + ]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + render(); + expect(screen.getByText('Alice')).toBeTruthy(); + expect(screen.getByText('Bob')).toBeTruthy(); + }); + + it('shows loading skeletons when isLoading is true', () => { + mockGetRowModel.mockReturnValue({ rows: [] }); + mockGetHeaderGroups.mockReturnValue([{ id: '1', headers: [] }]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + render(); + const skeletons = screen.getAllByTestId('skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders pagination buttons', () => { + mockGetRowModel.mockReturnValue({ rows: [] }); + mockGetHeaderGroups.mockReturnValue([{ id: '1', headers: [] }]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + render(); + expect(screen.getByText('Anterior')).toBeTruthy(); + expect(screen.getByText('Próximo')).toBeTruthy(); + }); + + it('pagination buttons are disabled when no pages', () => { + mockGetRowModel.mockReturnValue({ rows: [] }); + mockGetHeaderGroups.mockReturnValue([{ id: '1', headers: [] }]); + mockGetColumn.mockReturnValue({ getFilterValue: () => '' }); + + render(); + expect(screen.getByText('Anterior').closest('button')?.disabled).toBe(true); + expect(screen.getByText('Próximo').closest('button')?.disabled).toBe(true); + }); +}); diff --git a/src/components/dashboard/alunos/form-aluno.test.tsx b/src/components/dashboard/alunos/form-aluno.test.tsx new file mode 100644 index 00000000..d7ae1bef --- /dev/null +++ b/src/components/dashboard/alunos/form-aluno.test.tsx @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FormAluno } from './form-aluno'; +import type { Aluno } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +type MockChildren = { children?: ReactNode }; +type MockDialog = MockChildren & { open?: boolean }; + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open }: MockDialog) => + open ?
{children}
: null, + DialogContent: ({ children }: MockChildren) =>
{children}
, + DialogHeader: ({ children }: MockChildren) =>
{children}
, + DialogTitle: ({ children }: MockChildren) =>

{children}

, + DialogDescription: ({ children }: MockChildren) =>

{children}

, + DialogFooter: ({ children }: MockChildren) =>
{children}
, +})); + +vi.mock('@/components/ui/form', () => { + const Form = ({ children }: MockChildren) =>
{children}
; + const FormControl = ({ children }: MockChildren) =>
{children}
; + const FormField = ({ + render: renderProp, + ...rest + }: { + render: (props: { field: Record }) => ReactNode; + [key: string]: unknown; + }) => { + const restObj = rest as { field?: Record }; + const field = { value: '', onChange: vi.fn(), ...(restObj.field ?? {}) }; + return
{renderProp({ field })}
; + }; + const FormItem = ({ children }: MockChildren) =>
{children}
; + const FormLabel = ({ children }: MockChildren) => ; + const FormMessage = () => null; + return { Form, FormControl, FormField, FormItem, FormLabel, FormMessage }; +}); + +vi.mock('@/components/ui/select', () => ({ + Select: ({ + children, + onValueChange, + defaultValue, + }: { + children?: ReactNode; + onValueChange?: (v: string) => void; + defaultValue?: string; + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: MockChildren) => <>{children}, + SelectItem: ({ children, value }: { children?: ReactNode; value: string }) => ( + + ), +})); + +vi.mock('@/components/ui/input', () => ({ + Input: ({ + placeholder, + type, + ...props + }: { + placeholder?: string; + type?: string; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type, + variant, + }: { + children?: ReactNode; + onClick?: () => void; + type?: string; + variant?: string; + }) => ( + + ), +})); + +const mockAluno: Aluno = { + id: '1', + nomeCompleto: 'João Silva', + cpf: '123.456.789-00', + email: 'joao@test.com', + telefone: '(11) 99999-9999', + dataNascimento: '1990-01-01', + dataCadastro: '2023-01-01', + fotoUrl: '', + statusMatricula: 'INATIVA', + nivel: 1, + exp: 0, + streakDiasSeguidos: 0, + treinosNoMes: 0, + ultimoTreinoData: null, +}; + +describe('FormAluno', () => { + const mockOnSubmit = vi.fn(); + const mockOnOpenChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not render when isOpen is false', () => { + const { container } = render( + + ); + expect(container.querySelector('[data-testid="dialog"]')).toBeNull(); + }); + + it('renders when isOpen is true', () => { + render(); + expect(screen.getByText('Cadastrar Novo Aluno')).toBeTruthy(); + }); + + it('shows "Cadastrar" submit button for new aluno', () => { + render(); + expect(screen.getByText('Cadastrar')).toBeTruthy(); + }); + + it('shows "Editar Aluno" title when editing', () => { + render( + + ); + expect(screen.getByText('Editar Aluno')).toBeTruthy(); + }); + + it('shows "Salvar Alterações" button when editing', () => { + render( + + ); + expect(screen.getByText('Salvar Alterações')).toBeTruthy(); + }); + + it('calls onOpenChange(false) when cancel is clicked', () => { + render(); + fireEvent.click(screen.getByText('Cancelar')); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + it('renders all form fields', () => { + render(); + expect(screen.getByText('Nome Completo')).toBeTruthy(); + expect(screen.getByText('Email')).toBeTruthy(); + expect(screen.getByText('CPF')).toBeTruthy(); + expect(screen.getByText('Telefone')).toBeTruthy(); + expect(screen.getByText('Data de Nascimento')).toBeTruthy(); + }); + + it('shows status select when editing', () => { + render( + + ); + expect(screen.getByText('Status da Matrícula')).toBeTruthy(); + }); + + it('does not show status select for new aluno', () => { + render(); + expect(screen.queryByText('Status da Matrícula')).toBeNull(); + }); +}); diff --git a/src/components/dashboard/planos/form-plano.test.tsx b/src/components/dashboard/planos/form-plano.test.tsx new file mode 100644 index 00000000..d8637945 --- /dev/null +++ b/src/components/dashboard/planos/form-plano.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FormPlano } from './form-plano'; +import type { Plano } from '@/lib/definitions'; +import type { ReactNode } from 'react'; + +type MockChildren = { children?: ReactNode }; +type MockDialog = MockChildren & { open?: boolean }; + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open }: MockDialog) => + open ?
{children}
: null, + DialogContent: ({ children }: MockChildren) =>
{children}
, + DialogHeader: ({ children }: MockChildren) =>
{children}
, + DialogTitle: ({ children }: MockChildren) =>

{children}

, + DialogDescription: ({ children }: MockChildren) =>

{children}

, + DialogFooter: ({ children }: MockChildren) =>
{children}
, +})); + +vi.mock('@/components/ui/form', () => { + const Form = ({ children }: MockChildren) =>
{children}
; + const FormControl = ({ children }: MockChildren) =>
{children}
; + const FormField = ({ + render: renderProp, + }: { + render: (props: { field: Record }) => ReactNode; + }) => { + const field = { value: '', onChange: vi.fn() }; + return
{renderProp({ field })}
; + }; + const FormItem = ({ children }: MockChildren) =>
{children}
; + const FormLabel = ({ children }: MockChildren) => ; + const FormMessage = () => null; + return { Form, FormControl, FormField, FormItem, FormLabel, FormMessage }; +}); + +vi.mock('@/components/ui/input', () => ({ + Input: ({ + placeholder, + type, + ...props + }: { + placeholder?: string; + type?: string; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type, + variant, + }: { + children?: ReactNode; + onClick?: () => void; + type?: string; + variant?: string; + }) => ( + + ), +})); + +const mockPlano: Plano = { + id: 'plano-1', + nome: 'Mensal', + preco: 100, + duracaoDias: 30, +}; + +describe('FormPlano', () => { + const mockOnSubmit = vi.fn(); + const mockOnOpenChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not render when isOpen is false', () => { + const { container } = render( + + ); + expect(container.querySelector('[data-testid="dialog"]')).toBeNull(); + }); + + it('renders when isOpen is true', () => { + render(); + expect(screen.getByText('Novo Plano')).toBeTruthy(); + }); + + it('shows "Criar Plano" submit button for new plano', () => { + render(); + expect(screen.getByText('Criar Plano')).toBeTruthy(); + }); + + it('shows "Editar Plano" title when editing', () => { + render( + + ); + expect(screen.getByText('Editar Plano')).toBeTruthy(); + }); + + it('shows "Salvar Alterações" button when editing', () => { + render( + + ); + expect(screen.getByText('Salvar Alterações')).toBeTruthy(); + }); + + it('calls onOpenChange(false) when cancel is clicked', () => { + render(); + fireEvent.click(screen.getByText('Cancelar')); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + it('renders all form fields', () => { + render(); + expect(screen.getByText('Nome do Plano')).toBeTruthy(); + expect(screen.getByText('Preço (R$)')).toBeTruthy(); + expect(screen.getByText('Duração (dias)')).toBeTruthy(); + }); + + it('shows create description for new plano', () => { + render(); + expect(screen.getByText('Preencha os dados para criar um novo plano.')).toBeTruthy(); + }); + + it('shows edit description when editing', () => { + render( + + ); + expect(screen.getByText('Edite os dados do plano.')).toBeTruthy(); + }); +}); diff --git a/src/components/page-header.test.tsx b/src/components/page-header.test.tsx new file mode 100644 index 00000000..f0638f7d --- /dev/null +++ b/src/components/page-header.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { PageHeader } from './page-header'; + +vi.mock('@/lib/utils', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})); + +describe('PageHeader', () => { + it('renders title', () => { + render(); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Dashboard'); + }); + + it('renders description when provided', () => { + render(); + expect(screen.getByText('Gerencie seus alunos')).toBeTruthy(); + }); + + it('does not render description when not provided', () => { + render(); + expect(screen.queryByRole('paragraph')).toBeNull(); + }); + + it('renders actions when provided', () => { + render(Novo Plano} />); + expect(screen.getByText('Novo Plano')).toBeTruthy(); + }); + + it('does not render actions section when not provided', () => { + const { container } = render(); + expect(container.querySelector('.flex-shrink-0')).toBeNull(); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); +}); diff --git a/src/components/providers/auth-provider.test.tsx b/src/components/providers/auth-provider.test.tsx new file mode 100644 index 00000000..f35c758e --- /dev/null +++ b/src/components/providers/auth-provider.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { SupabaseAuthProvider, useAuth } from './auth-provider'; + +const mockGetUser = vi.fn(); +const mockOnAuthStateChange = vi.fn(); +const mockSignOut = vi.fn(); + +vi.mock('@/utils/supabase/client', () => ({ + createClient: () => ({ + auth: { + getUser: mockGetUser, + onAuthStateChange: mockOnAuthStateChange, + signOut: mockSignOut, + }, + }), +})); + +vi.mock('@sentry/nextjs', () => ({ + setUser: vi.fn(), +})); + +function TestConsumer() { + const { user, isUserLoading, userError, signOut } = useAuth(); + return ( +
+ {String(isUserLoading)} + {user ? user.email : 'no-user'} + {userError ? userError.message : 'no-error'} + +
+ ); +} + +describe('SupabaseAuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); + mockOnAuthStateChange.mockReturnValue({ + data: { subscription: { unsubscribe: vi.fn() } }, + }); + }); + + it('provides auth context to children', async () => { + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('loading')).toBeTruthy(); + }); + + it('starts with loading state', async () => { + mockGetUser.mockImplementation(() => new Promise(() => {})); + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('loading').textContent).toBe('true'); + }); + + it('resolves to no user when getUser returns null', async () => { + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('loading').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe('no-user'); + }); + + it('provides user when getUser returns one', async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: '1', email: 'test@test.com' } }, + error: null, + }); + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('user').textContent).toBe('test@test.com'); + }); + + it('calls signOut correctly', async () => { + mockSignOut.mockResolvedValue({ error: null }); + await act(async () => { + render( + + + + ); + }); + await act(async () => { + screen.getByText('Sign Out').click(); + }); + expect(mockSignOut).toHaveBeenCalled(); + }); + + it('registers onAuthStateChange listener', async () => { + await act(async () => { + render( + + + + ); + }); + expect(mockOnAuthStateChange).toHaveBeenCalled(); + }); +}); + +describe('useAuth', () => { + it('throws when used outside provider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + function BadConsumer() { + useAuth(); + return null; + } + expect(() => render()).toThrow( + 'useAuth must be used within a SupabaseAuthProvider.' + ); + spy.mockRestore(); + }); +}); diff --git a/src/components/providers/i18n-provider.test.tsx b/src/components/providers/i18n-provider.test.tsx new file mode 100644 index 00000000..813102bd --- /dev/null +++ b/src/components/providers/i18n-provider.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { I18nProvider, useI18n } from './i18n-provider'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + clear: () => { + store = {}; + }, + removeItem: (key: string) => { + delete store[key]; + }, + get length() { + return Object.keys(store).length; + }, + key: (index: number) => Object.keys(store)[index] ?? null, + }; +})(); + +vi.stubGlobal('localStorage', localStorageMock); + +function TestConsumer() { + const { language, setLanguage, t } = useI18n(); + return ( +
+ {language} + {t('common.welcome')} + {t('dashboard.streak', { count: 5 })} + {t('nonexistent.key')} + + +
+ ); +} + +describe('I18nProvider', () => { + beforeEach(() => { + localStorageMock.clear(); + }); + + it('defaults to Portuguese', async () => { + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('language').textContent).toBe('pt'); + }); + + it('translates common.welcome in Portuguese', async () => { + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('welcome').textContent).toBeTruthy(); + }); + + it('handles parameter interpolation', async () => { + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('with-param').textContent).toContain('5'); + }); + + it('returns path when key is missing', async () => { + await act(async () => { + render( + + + + ); + }); + expect(screen.getByTestId('missing').textContent).toBe('nonexistent.key'); + }); + + it('switches language on setLanguage call', async () => { + await act(async () => { + render( + + + + ); + }); + await act(async () => { + screen.getByText('Switch to EN').click(); + }); + expect(screen.getByTestId('language').textContent).toBe('en'); + }); + + it('persists language to localStorage', async () => { + await act(async () => { + render( + + + + ); + }); + await act(async () => { + screen.getByText('Switch to EN').click(); + }); + expect(localStorageMock.getItem('app-language')).toBe('en'); + }); +}); + +describe('useI18n', () => { + it('throws when used outside provider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + function BadConsumer() { + useI18n(); + return null; + } + expect(() => render()).toThrow('useI18n must be used within an I18nProvider'); + spy.mockRestore(); + }); +}); diff --git a/src/components/ui/accordion.test.tsx b/src/components/ui/accordion.test.tsx new file mode 100644 index 00000000..d2aa1897 --- /dev/null +++ b/src/components/ui/accordion.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-accordion', () => { + const Root = ({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) => ( +
+ {children} +
+ ); + Root.displayName = 'Accordion'; + + const Item = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Item.displayName = 'AccordionItem'; + + const Header = ({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) => ( +
+ {children} +
+ ); + Header.displayName = 'AccordionHeader'; + + const Trigger = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Trigger.displayName = 'AccordionTrigger'; + + const Content = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Content.displayName = 'AccordionContent'; + + return { Root, Item, Header, Trigger, Content }; +}); + +import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './accordion'; + +describe('Accordion components', () => { + it('Accordion renders children', () => { + render( + +
content
+
+ ); + expect(screen.getByTestId('accordion-root')).toBeTruthy(); + expect(screen.getByText('content')).toBeTruthy(); + }); + + it('AccordionItem renders', () => { + render(Item Content); + expect(screen.getByTestId('accordion-item')).toBeTruthy(); + expect(screen.getByText('Item Content')).toBeTruthy(); + }); + + it('AccordionTrigger renders with text and chevron', () => { + render(Click me); + expect(screen.getByTestId('accordion-trigger')).toBeTruthy(); + expect(screen.getByText('Click me')).toBeTruthy(); + }); + + it('AccordionContent renders children', () => { + render(Hidden Content); + expect(screen.getByTestId('accordion-content')).toBeTruthy(); + expect(screen.getByText('Hidden Content')).toBeTruthy(); + }); + + it('AccordionItem passes className', () => { + render( + + x + + ); + expect(screen.getByTestId('accordion-item').className).toContain('my-class'); + }); +}); diff --git a/src/components/ui/alert-dialog.test.tsx b/src/components/ui/alert-dialog.test.tsx new file mode 100644 index 00000000..af27d2a0 --- /dev/null +++ b/src/components/ui/alert-dialog.test.tsx @@ -0,0 +1,193 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-alert-dialog', () => { + const createPrimitive = (displayName: string) => { + const Component = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Component.displayName = displayName; + return Component; + }; + + const createButton = (displayName: string) => { + const Component = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Component.displayName = displayName; + return Component; + }; + + return { + Root: ({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) => ( +
+ {children} +
+ ), + Trigger: createButton('AlertDialogTrigger'), + Portal: ({ children }: { children: React.ReactNode }) => <>{children}, + Overlay: createPrimitive('AlertDialogOverlay'), + Content: createPrimitive('AlertDialogContent'), + Title: createPrimitive('AlertDialogTitle'), + Description: createPrimitive('AlertDialogDescription'), + Action: createButton('AlertDialogAction'), + Cancel: createButton('AlertDialogCancel'), + }; +}); + +vi.mock('@/components/ui/button', () => ({ + buttonVariants: (opts?: { variant?: string }) => `btn ${opts?.variant || 'default'}`, +})); + +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} from './alert-dialog'; + +describe('AlertDialog', () => { + it('renders without throwing', () => { + const { container } = render( + + Open + + + Are you sure? + This action cannot be undone. + + + Cancel + Continue + + + + ); + expect(container).toBeTruthy(); + }); +}); + +describe('AlertDialogTrigger', () => { + it('renders as a button', () => { + render( + + Open Dialog + + ); + const trigger = screen.getByText('Open Dialog'); + expect(trigger.tagName).toBe('BUTTON'); + }); + + it('handles click', () => { + const onClick = vi.fn(); + render( + + Open + + ); + fireEvent.click(screen.getByText('Open')); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('AlertDialogTitle', () => { + it('renders title text', () => { + render( + + + Warning + + + ); + expect(screen.getByText('Warning')).toBeTruthy(); + }); +}); + +describe('AlertDialogDescription', () => { + it('renders description text', () => { + render( + + + Some description + + + ); + expect(screen.getByText('Some description')).toBeTruthy(); + }); +}); + +describe('AlertDialogAction', () => { + it('renders and handles click', () => { + const onAction = vi.fn(); + render( + + + Confirm + + + ); + fireEvent.click(screen.getByText('Confirm')); + expect(onAction).toHaveBeenCalledTimes(1); + }); +}); + +describe('AlertDialogCancel', () => { + it('renders cancel button', () => { + render( + + + Go Back + + + ); + expect(screen.getByText('Go Back')).toBeTruthy(); + }); +}); + +describe('AlertDialogHeader', () => { + it('renders header with title and description', () => { + render( + + + + Confirm + Details here + + + + ); + expect(screen.getByText('Confirm')).toBeTruthy(); + expect(screen.getByText('Details here')).toBeTruthy(); + }); +}); + +describe('AlertDialogFooter', () => { + it('renders footer with action and cancel', () => { + render( + + + + Cancel + OK + + + + ); + expect(screen.getByText('Cancel')).toBeTruthy(); + expect(screen.getByText('OK')).toBeTruthy(); + }); +}); diff --git a/src/components/ui/alert.test.tsx b/src/components/ui/alert.test.tsx new file mode 100644 index 00000000..5fa9d7a9 --- /dev/null +++ b/src/components/ui/alert.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { Alert, AlertTitle, AlertDescription } from './alert'; + +describe('Alert components', () => { + it('renders Alert with role="alert"', () => { + render(Alert content); + const el = screen.getByTestId('alert'); + expect(el).toBeTruthy(); + expect(el.getAttribute('role')).toBe('alert'); + }); + + it('renders with default variant class', () => { + render(Content); + expect(screen.getByTestId('alert').className).toContain('bg-background'); + }); + + it('renders with destructive variant class', () => { + render( + + Error + + ); + expect(screen.getByTestId('alert').className).toContain('destructive'); + }); + + it('passes custom className', () => { + render( + + x + + ); + expect(screen.getByTestId('alert').className).toContain('extra-class'); + }); + + it('AlertTitle renders', () => { + render( + + Warning + + ); + expect(screen.getByText('Warning')).toBeTruthy(); + }); + + it('AlertDescription renders', () => { + render( + + Details here + + ); + expect(screen.getByText('Details here')).toBeTruthy(); + }); + + it('AlertTitle passes className', () => { + render( + + + T + + + ); + expect(screen.getByTestId('at').className).toContain('title-extra'); + }); + + it('AlertDescription passes className', () => { + render( + + + D + + + ); + expect(screen.getByTestId('ad').className).toContain('desc-extra'); + }); +}); diff --git a/src/components/ui/calendar.test.tsx b/src/components/ui/calendar.test.tsx new file mode 100644 index 00000000..6f0862f4 --- /dev/null +++ b/src/components/ui/calendar.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('react-day-picker', () => ({ + DayPicker: ({ onSelect, ...props }: Record) => ( +
+ + +
+ ), +})); + +vi.mock('@/components/ui/button', () => ({ + buttonVariants: (opts?: { variant?: string }) => `btn-variant-${opts?.variant || 'default'}`, +})); + +import { Calendar } from './calendar'; + +describe('Calendar', () => { + it('renders without throwing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders DayPicker', () => { + render(); + expect(screen.getByRole('button', { name: 'Jan 15' })).toBeTruthy(); + }); + + it('passes onSelect callback', () => { + const onSelect = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render(); + fireEvent.click(screen.getByText('Jan 15')); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it('renders with custom className', () => { + render(); + expect(screen.getByTestId('cal')).toHaveClass('my-calendar'); + }); + + it('passes showOutsideDays prop', () => { + render(); + expect(screen.getByRole('button', { name: 'Jan 15' })).toBeTruthy(); + }); +}); diff --git a/src/components/ui/carousel.test.tsx b/src/components/ui/carousel.test.tsx new file mode 100644 index 00000000..a89190da --- /dev/null +++ b/src/components/ui/carousel.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +const mockScrollPrev = vi.fn(); +const mockScrollNext = vi.fn(); + +vi.mock('embla-carousel-react', () => ({ + default: () => [ + vi.fn(), + { + canScrollPrev: () => false, + canScrollNext: () => true, + scrollPrev: mockScrollPrev, + scrollNext: mockScrollNext, + on: vi.fn(), + off: vi.fn(), + }, + ], +})); + +vi.mock('@/components/ui/button', () => { + const Button = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Button.displayName = 'Button'; + return { Button }; +}); + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} from './carousel'; + +describe('Carousel', () => { + it('renders without throwing', () => { + const { container } = render( + + + Slide 1 + Slide 2 + + + ); + expect(container).toBeTruthy(); + }); + + it('renders carousel items', () => { + render( + + + First + Second + + + ); + expect(screen.getByText('First')).toBeTruthy(); + expect(screen.getByText('Second')).toBeTruthy(); + }); + + it('renders prev/next buttons', () => { + render( + + + Slide + + + + + ); + expect(screen.getByText('Previous slide')).toBeTruthy(); + expect(screen.getByText('Next slide')).toBeTruthy(); + }); + + it('disables previous button when canScrollPrev is false', () => { + render( + + + Slide + + + + ); + expect(screen.getByText('Previous slide').closest('button')).toBeDisabled(); + }); + + it('enables next button when canScrollNext is true', () => { + render( + + + Slide + + + + ); + expect(screen.getByText('Next slide').closest('button')).not.toBeDisabled(); + }); + + it('sets aria-roledescription on carousel section', () => { + render( + + + Slide + + + ); + expect(screen.getByRole('region', { name: 'Carrossel de conteúdo' })).toBeTruthy(); + }); +}); diff --git a/src/components/ui/chart.test.tsx b/src/components/ui/chart.test.tsx new file mode 100644 index 00000000..eda95e22 --- /dev/null +++ b/src/components/ui/chart.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Tooltip: () =>
, + Legend: () =>
, +})); + +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, +} from './chart'; + +const sampleConfig = { + desktop: { label: 'Desktop', color: '#2563eb' }, + mobile: { label: 'Mobile', color: '#10b981' }, +}; + +describe('ChartContainer', () => { + it('renders without throwing', () => { + const { container } = render( + +
chart children
+
+ ); + expect(container).toBeTruthy(); + }); + + it('renders children inside ResponsiveContainer', () => { + render( + +
Inner chart
+
+ ); + expect(screen.getByText('Inner chart')).toBeTruthy(); + expect(screen.getByTestId('responsive-container')).toBeTruthy(); + }); + + it('applies data-chart attribute', () => { + render( + +
child
+
+ ); + expect(screen.getByTestId('chart')).toHaveAttribute('data-chart'); + }); + + it('injects CSS custom properties via style tag', () => { + const { container } = render( + +
child
+
+ ); + const styleEl = container.querySelector('style'); + expect(styleEl).toBeTruthy(); + expect(styleEl?.innerHTML).toContain('--color-desktop'); + expect(styleEl?.innerHTML).toContain('--color-mobile'); + }); +}); + +describe('ChartTooltip', () => { + it('renders recharts Tooltip', () => { + render(); + expect(screen.getByTestId('chart-tooltip')).toBeTruthy(); + }); +}); + +describe('ChartLegend', () => { + it('renders recharts Legend', () => { + render(); + expect(screen.getByTestId('chart-legend')).toBeTruthy(); + }); +}); + +describe('ChartTooltipContent', () => { + it('renders nothing when not active', () => { + const { container } = render( + + + + ); + expect(container.querySelector('[class*="grid min-w"]')).toBeNull(); + }); + + it('renders nothing when no payload', () => { + const { container } = render( + + + + ); + expect(container.querySelector('[class*="grid min-w"]')).toBeNull(); + }); + + it('renders tooltip with payload', () => { + render( + + + + ); + expect(screen.getByText('100')).toBeTruthy(); + }); +}); + +describe('ChartLegendContent', () => { + it('renders nothing with empty payload', () => { + const { container } = render( + + + + ); + expect(container.querySelector('[class*="flex items-center justify-center"]')).toBeNull(); + }); + + it('renders legend items from config', () => { + render( + + + + ); + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.getByText('Mobile')).toBeTruthy(); + }); +}); diff --git a/src/components/ui/checkbox.test.tsx b/src/components/ui/checkbox.test.tsx new file mode 100644 index 00000000..ba32aa81 --- /dev/null +++ b/src/components/ui/checkbox.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-checkbox', () => { + const Root = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<'button'> & { + checked?: boolean; + onCheckedChange?: (checked: boolean | 'indeterminate') => void; + } + >(({ children, checked: initialChecked, onCheckedChange, ...props }, ref) => { + const [checked, setChecked] = React.useState(initialChecked ?? false); + return ( + + ); + }); + Root.displayName = 'CheckboxRoot'; + + const Indicator = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Indicator.displayName = 'CheckboxIndicator'; + + return { Root, Indicator }; +}); + +import { Checkbox } from './checkbox'; + +describe('Checkbox', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('cb')).toBeTruthy(); + }); + + it('has checkbox role', () => { + render(); + expect(screen.getByRole('checkbox')).toBeTruthy(); + }); + + it('can be checked and unchecked', () => { + render(); + const cb = screen.getByTestId('cb'); + expect(cb.getAttribute('data-state')).toBe('unchecked'); + + fireEvent.click(cb); + expect(cb.getAttribute('data-state')).toBe('checked'); + }); + + it('passes custom className', () => { + render(); + expect(screen.getByTestId('cb').className).toContain('my-cb'); + }); +}); diff --git a/src/components/ui/circular-progress.test.tsx b/src/components/ui/circular-progress.test.tsx new file mode 100644 index 00000000..8368908d --- /dev/null +++ b/src/components/ui/circular-progress.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { CircularProgress } from './circular-progress'; + +describe('CircularProgress', () => { + it('renders without throwing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders SVG circles', () => { + const { container } = render(); + const circles = container.querySelectorAll('circle'); + expect(circles.length).toBe(2); + }); + + it('displays value when showValue is true', () => { + render(); + expect(screen.getByText('42%')).toBeTruthy(); + }); + + it('does not display value by default', () => { + const { container } = render(); + expect(container.textContent).not.toContain('42%'); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Progress')).toBeTruthy(); + }); + + it('renders sm size', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.className).toContain('w-16'); + }); + + it('renders md size by default', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.className).toContain('w-32'); + }); + + it('renders lg size', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.className).toContain('w-48'); + }); + + it('applies custom className', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.className).toContain('my-class'); + }); + + it('calculates strokeDashoffset correctly for 0%', () => { + const { container } = render(); + const progressCircle = container.querySelectorAll('circle')[1]; + const circumference = 2 * Math.PI * 40; + expect(progressCircle.getAttribute('stroke-dashoffset')).toBe(String(circumference)); + }); + + it('calculates strokeDashoffset correctly for 100%', () => { + const { container } = render(); + const progressCircle = container.querySelectorAll('circle')[1]; + expect(progressCircle.getAttribute('stroke-dashoffset')).toBe('0'); + }); + + it('uses cyan gradient by default', () => { + const { container } = render(); + const progressCircle = container.querySelectorAll('circle')[1]; + expect(progressCircle.getAttribute('stroke')).toBe('url(#cyan-gradient)'); + }); + + it('uses gold gradient when specified', () => { + const { container } = render(); + const progressCircle = container.querySelectorAll('circle')[1]; + expect(progressCircle.getAttribute('stroke')).toBe('url(#gold-gradient)'); + }); + + it('uses rose gradient when specified', () => { + const { container } = render(); + const progressCircle = container.querySelectorAll('circle')[1]; + expect(progressCircle.getAttribute('stroke')).toBe('url(#rose-gradient)'); + }); + + it('passes forward ref', () => { + const ref = { current: null }; + render(); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/src/components/ui/combobox.test.tsx b/src/components/ui/combobox.test.tsx new file mode 100644 index 00000000..d17351a6 --- /dev/null +++ b/src/components/ui/combobox.test.tsx @@ -0,0 +1,230 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@/components/ui/popover', () => { + const PopoverTrigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes + >(({ children, ...props }, ref) => ( + + )); + PopoverTrigger.displayName = 'PopoverTrigger'; + + const PopoverContent = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + PopoverContent.displayName = 'PopoverContent'; + + return { + Popover: ({ children, open }: { children?: React.ReactNode; open?: boolean }) => ( +
+ {children} +
+ ), + PopoverTrigger, + PopoverContent, + }; +}); + +vi.mock('@/components/ui/command', () => { + const Command = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Command.displayName = 'Command'; + + const CommandInput = React.forwardRef< + HTMLInputElement, + React.InputHTMLAttributes + >(({ ...props }, ref) => ); + CommandInput.displayName = 'CommandInput'; + + const CommandList = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + CommandList.displayName = 'CommandList'; + + const CommandEmpty = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + CommandEmpty.displayName = 'CommandEmpty'; + + const CommandGroup = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { heading?: string } + >(({ children, heading, ...props }, ref) => ( +
+ {children} +
+ )); + CommandGroup.displayName = 'CommandGroup'; + + const CommandItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + value?: string; + keywords?: string[]; + onSelect?: (value: string) => void; + } + >(({ children, value, onSelect, ...props }, ref) => ( +
onSelect?.(value ?? '')} + {...props} + > + {children} +
+ )); + CommandItem.displayName = 'CommandItem'; + + return { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem }; +}); + +vi.mock('@/components/ui/button', () => { + const Button = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { variant?: string } + >(({ children, variant, ...props }, ref) => ( + + )); + Button.displayName = 'Button'; + return { Button }; +}); + +vi.mock('lucide-react', () => ({ + Check: () => , + ChevronsUpDown: () => , +})); + +import { Combobox } from './combobox'; + +const mockOptions = [ + { + label: 'Group 1', + options: [ + { value: 'opt1', label: 'Option One' }, + { value: 'opt2', label: 'Option Two' }, + ], + }, +]; + +const mockFlatOptions = [ + { value: 'opt1', label: 'Option One' }, + { value: 'opt2', label: 'Option Two' }, +]; + +describe('Combobox', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('popover')).toBeTruthy(); + }); + + it('renders trigger button with placeholder when no value', () => { + render( + + ); + expect(screen.getByText('Pick something...')).toBeTruthy(); + expect(screen.getByRole('combobox')).toBeTruthy(); + }); + + it('shows default placeholder text', () => { + render(); + expect(screen.getByText('Select an option...')).toBeTruthy(); + }); + + it('renders chevrons up down icon', () => { + render(); + expect(screen.getByTestId('icon-chevrons-up-down')).toBeTruthy(); + }); + + it('shows selected label when value is provided', () => { + render( + + ); + const matches = screen.getAllByText('Option One'); + expect(matches.length).toBeGreaterThanOrEqual(1); + expect(screen.getByRole('combobox').textContent).toContain('Option One'); + }); + + it('has combobox role with aria-expanded', () => { + render(); + const combobox = screen.getByRole('combobox'); + expect(combobox.getAttribute('aria-expanded')).toBe('false'); + }); + + it('passes className to button', () => { + render( + + ); + expect(screen.getByRole('combobox').className).toContain('custom-combobox'); + }); + + it('renders command input inside popover', () => { + render( + + ); + expect(screen.getByTestId('command-input')).toBeTruthy(); + }); + + it('renders command groups with options', () => { + render(); + expect(screen.getByTestId('command-group')).toBeTruthy(); + expect(screen.getByText('Option One')).toBeTruthy(); + expect(screen.getByText('Option Two')).toBeTruthy(); + }); + + it('renders check icon for selected option', () => { + render( + + ); + const checks = screen.getAllByTestId('icon-check'); + expect(checks.length).toBeGreaterThan(0); + }); +}); diff --git a/src/components/ui/command.test.tsx b/src/components/ui/command.test.tsx new file mode 100644 index 00000000..dd1910ed --- /dev/null +++ b/src/components/ui/command.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('cmdk', () => { + const createPrimitive = (displayName: string) => { + const Component = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Component.displayName = displayName; + return Component; + }; + + const Input = React.forwardRef>( + ({ ...props }, ref) => + ); + Input.displayName = 'CommandInput'; + + return { + Command: Object.assign(createPrimitive('Command'), { + Input, + List: createPrimitive('CommandList'), + Empty: createPrimitive('CommandEmpty'), + Group: ({ + children, + heading, + ...props + }: React.ComponentPropsWithoutRef<'div'> & { heading?: string }) => ( +
+ {heading &&
{heading}
} + {children} +
+ ), + Item: ({ + children, + onSelect, + ...props + }: React.ComponentPropsWithoutRef<'div'> & { onSelect?: (e: Event) => void }) => ( +
onSelect?.(e)} {...props}> + {children} +
+ ), + Separator: createPrimitive('CommandSeparator'), + }), + }; +}); + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) => ( +
+ {children} +
+ ), + DialogContent: ({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) => ( +
+ {children} +
+ ), +})); + +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, + CommandDialog, +} from './command'; + +describe('Command', () => { + it('renders without throwing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('passes className and children', () => { + render(Content); + const el = screen.getByText('Content'); + expect(el.closest('[data-slot="Command"]')).toHaveClass('test-class'); + }); + + it('passes data attributes', () => { + render(); + expect(screen.getByTestId('cmd')).toBeTruthy(); + }); +}); + +describe('CommandInput', () => { + it('renders an input element', () => { + render(); + expect(screen.getByPlaceholderText('Search…')).toBeTruthy(); + }); + + it('passes value and onChange', () => { + const handleChange = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render(); + const input = screen.getByDisplayValue('test'); + fireEvent.change(input, { target: { value: 'new' } }); + expect(handleChange).toHaveBeenCalled(); + }); +}); + +describe('CommandList', () => { + it('renders children', () => { + render(List content); + expect(screen.getByText('List content')).toBeTruthy(); + }); +}); + +describe('CommandEmpty', () => { + it('renders empty state', () => { + render(No results); + expect(screen.getByText('No results')).toBeTruthy(); + }); +}); + +describe('CommandGroup', () => { + it('renders with heading', () => { + render( + + Item 1 + + ); + expect(screen.getByText('Suggestions')).toBeTruthy(); + expect(screen.getByText('Item 1')).toBeTruthy(); + }); +}); + +describe('CommandItem', () => { + it('renders and handles click', () => { + const handleClick = vi.fn(); + render(My Item); + const item = screen.getByText('My Item'); + fireEvent.click(item); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('CommandShortcut', () => { + it('renders shortcut text', () => { + render(⌘K); + expect(screen.getByText('⌘K')).toBeTruthy(); + }); +}); + +describe('CommandSeparator', () => { + it('renders a separator element', () => { + const { container } = render(); + expect(container.querySelector('[data-slot="CommandSeparator"]')).toBeTruthy(); + }); +}); + +describe('CommandDialog', () => { + it('renders dialog with command inside', () => { + render( + + + + + Result + + + + ); + expect(screen.getByPlaceholderText('Search…')).toBeTruthy(); + expect(screen.getByText('Result')).toBeTruthy(); + }); +}); diff --git a/src/components/ui/dashboard-skeletons.test.tsx b/src/components/ui/dashboard-skeletons.test.tsx new file mode 100644 index 00000000..08788262 --- /dev/null +++ b/src/components/ui/dashboard-skeletons.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@/components/ui/premium-skeleton', () => { + const PremiumSkeleton = ({ className, ...props }: React.HTMLAttributes) => ( +
+ ); + return { PremiumSkeleton }; +}); + +import { TableSkeleton, FinanceiroSkeleton } from './dashboard-skeletons'; + +describe('TableSkeleton', () => { + it('renders without crashing', () => { + render(); + expect(screen.getAllByTestId('premium-skeleton').length).toBeGreaterThan(0); + }); + + it('renders multiple skeleton rows', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-testid="premium-skeleton"]').length).toBeGreaterThan( + 10 + ); + }); +}); + +describe('FinanceiroSkeleton', () => { + it('renders without crashing', () => { + render(); + expect(screen.getAllByTestId('premium-skeleton').length).toBeGreaterThan(0); + }); + + it('renders 3 skeletons (title, subtitle, chart)', () => { + render(); + expect(screen.getAllByTestId('premium-skeleton')).toHaveLength(3); + }); +}); diff --git a/src/components/ui/dialog.test.tsx b/src/components/ui/dialog.test.tsx new file mode 100644 index 00000000..b883d2e3 --- /dev/null +++ b/src/components/ui/dialog.test.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-dialog', () => { + const Root = ({ + children, + open, + ...props + }: { + children?: React.ReactNode; + open?: boolean; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ); + + const Trigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes + >(({ children, ...props }, ref) => ( + + )); + Trigger.displayName = 'DialogTrigger'; + + const Portal = ({ children }: { children?: React.ReactNode }) => <>{children}; + + const Overlay = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Overlay.displayName = 'DialogOverlay'; + + const Content = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Content.displayName = 'DialogContent'; + + const Close = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Close.displayName = 'DialogClose'; + + const Title = React.forwardRef>( + ({ children, ...props }, ref) => ( +

+ {children} +

+ ) + ); + Title.displayName = 'DialogTitle'; + + const Description = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes + >(({ children, ...props }, ref) => ( +

+ {children} +

+ )); + Description.displayName = 'DialogDescription'; + + return { Root, Trigger, Portal, Overlay, Content, Close, Title, Description }; +}); + +vi.mock('lucide-react', () => ({ + X: () => , +})); + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogClose, +} from './dialog'; + +describe('Dialog', () => { + it('renders Dialog root without crashing', () => { + render( + +
Content
+
+ ); + expect(screen.getByTestId('dialog-root')).toBeTruthy(); + }); + + it('renders DialogTrigger', () => { + render( + + Open + + ); + expect(screen.getByTestId('dialog-trigger')).toBeTruthy(); + expect(screen.getByText('Open')).toBeTruthy(); + }); + + it('renders DialogContent with children', () => { + render( + + +

Dialog body

+
+
+ ); + expect(screen.getByTestId('dialog-content')).toBeTruthy(); + expect(screen.getByText('Dialog body')).toBeTruthy(); + }); + + it('renders DialogHeader with children', () => { + render( + + + + My Title + My Description + + + + ); + expect(screen.getByText('My Title')).toBeTruthy(); + expect(screen.getByText('My Description')).toBeTruthy(); + }); + + it('renders DialogFooter with children', () => { + render( + + + + + + + + + ); + expect(screen.getByText('Cancel')).toBeTruthy(); + expect(screen.getByText('Save')).toBeTruthy(); + }); + + it('renders DialogClose button', () => { + render( + + + Close me + + + ); + const closeButtons = screen.getAllByTestId('dialog-close'); + expect(closeButtons.length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('Close me')).toBeTruthy(); + }); + + it('passes className to DialogContent', () => { + render( + + +

Content

+
+
+ ); + expect(screen.getByTestId('dialog-content').className).toContain('custom-class'); + }); + + it('passes props to Dialog root', () => { + render( + +
Child
+
+ ); + expect(screen.getByTestId('dialog-root').getAttribute('data-state')).toBe('open'); + }); + + it('renders with data-state=closed when not open', () => { + render( + +
Child
+
+ ); + expect(screen.getByTestId('dialog-root').getAttribute('data-state')).toBe('closed'); + }); +}); diff --git a/src/components/ui/dropdown-menu.test.tsx b/src/components/ui/dropdown-menu.test.tsx new file mode 100644 index 00000000..7a08fdd3 --- /dev/null +++ b/src/components/ui/dropdown-menu.test.tsx @@ -0,0 +1,215 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-dropdown-menu', () => { + const createPrimitive = (displayName: string) => { + const Component = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Component.displayName = displayName; + return Component; + }; + + const createButton = (displayName: string) => { + const Component = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Component.displayName = displayName; + return Component; + }; + + return { + Root: ({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) => ( +
+ {children} +
+ ), + Trigger: createButton('DropdownMenuTrigger'), + Content: createPrimitive('DropdownMenuContent'), + Item: ({ + children, + onSelect, + ...props + }: React.ComponentPropsWithoutRef<'div'> & { onSelect?: (e: Event) => void }) => ( +
onSelect?.(e)} {...props}> + {children} +
+ ), + CheckboxItem: createPrimitive('DropdownMenuCheckboxItem'), + RadioItem: createPrimitive('DropdownMenuRadioItem'), + Label: createPrimitive('DropdownMenuLabel'), + Separator: createPrimitive('DropdownMenuSeparator'), + Group: createPrimitive('DropdownMenuGroup'), + Portal: ({ children }: { children: React.ReactNode }) => <>{children}, + Sub: createPrimitive('DropdownMenuSub'), + SubTrigger: createButton('DropdownMenuSubTrigger'), + SubContent: createPrimitive('DropdownMenuSubContent'), + RadioGroup: createPrimitive('DropdownMenuRadioGroup'), + ItemIndicator: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }; +}); + +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuRadioGroup, +} from './dropdown-menu'; + +function renderMenu(trigger = 'Open', items: React.ReactNode = null) { + return render( + + {trigger} + {items} + + ); +} + +describe('DropdownMenu', () => { + it('renders trigger button', () => { + renderMenu(); + expect(screen.getByText('Open')).toBeTruthy(); + }); + + it('renders menu items', () => { + renderMenu( + 'Open', + <> + Edit + Delete + + ); + expect(screen.getByText('Edit')).toBeTruthy(); + expect(screen.getByText('Delete')).toBeTruthy(); + }); +}); + +describe('DropdownMenuTrigger', () => { + it('renders as button', () => { + renderMenu(); + expect(screen.getByText('Open').tagName).toBe('BUTTON'); + }); + + it('handles onClick', () => { + const onClick = vi.fn(); + render( + + Toggle + + ); + fireEvent.click(screen.getByText('Toggle')); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('DropdownMenuItem', () => { + it('renders and handles click', () => { + const handleClick = vi.fn(); + renderMenu('Menu', Save); + fireEvent.click(screen.getByText('Save')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('DropdownMenuCheckboxItem', () => { + it('renders with checked state', () => { + renderMenu( + 'Menu', + Show sidebar + ); + expect(screen.getByText('Show sidebar')).toBeTruthy(); + }); +}); + +describe('DropdownMenuRadioGroup', () => { + it('renders radio items in group', () => { + renderMenu( + 'Menu', + + Option A + Option B + + ); + expect(screen.getByText('Option A')).toBeTruthy(); + expect(screen.getByText('Option B')).toBeTruthy(); + }); +}); + +describe('DropdownMenuLabel', () => { + it('renders label text', () => { + renderMenu('Menu', My Actions); + expect(screen.getByText('My Actions')).toBeTruthy(); + }); +}); + +describe('DropdownMenuSeparator', () => { + it('renders separator element', () => { + const { container } = renderMenu( + 'Menu', + <> + A + + B + + ); + expect(container.querySelector('[data-slot="DropdownMenuSeparator"]')).toBeTruthy(); + }); +}); + +describe('DropdownMenuShortcut', () => { + it('renders shortcut text', () => { + render(⌘Z); + expect(screen.getByText('⌘Z')).toBeTruthy(); + }); +}); + +describe('DropdownMenuGroup', () => { + it('renders grouped items', () => { + renderMenu( + 'Menu', + + Cut + Copy + + ); + expect(screen.getByText('Cut')).toBeTruthy(); + expect(screen.getByText('Copy')).toBeTruthy(); + }); +}); + +describe('DropdownMenuSub', () => { + it('renders sub menu with trigger and content', () => { + renderMenu( + 'Menu', + + More tools + + Sub item + + + ); + expect(screen.getByText('More tools')).toBeTruthy(); + expect(screen.getByText('Sub item')).toBeTruthy(); + }); +}); diff --git a/src/components/ui/form.test.tsx b/src/components/ui/form.test.tsx new file mode 100644 index 00000000..f2263a8e --- /dev/null +++ b/src/components/ui/form.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('react-hook-form', () => { + const FormProvider = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + + const Controller = ({ + name, + render: renderProp, + }: { + name: string; + render: (props: { field: Record }) => React.ReactNode; + }) => { + return ( +
+ {renderProp({ + field: { name, value: '', onChange: vi.fn(), onBlur: vi.fn() }, + })} +
+ ); + }; + + const useFormContext = () => ({ + getFieldState: (_name: string) => ({ error: undefined, isDirty: false, invalid: false }), + formState: { errors: {} as Record }, + }); + + return { FormProvider, Controller, useFormContext }; +}); + +vi.mock('@radix-ui/react-slot', () => { + const Slot = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { children?: React.ReactNode } + >(({ children, ...props }, ref) => ( +
+ {children} +
+ )); + Slot.displayName = 'Slot'; + return { Slot }; +}); + +vi.mock('@radix-ui/react-label', () => { + const Root = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Root.displayName = 'LabelRoot'; + return { Root }; +}); + +vi.mock('@/components/ui/label', () => { + const Label = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Label.displayName = 'Label'; + return { Label }; +}); + +import { Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from './form'; + +const MockedForm = Form as unknown as React.FC<{ children?: React.ReactNode }>; + +describe('Form', () => { + it('renders Form (FormProvider) wrapping children', () => { + render( + +
Form content
+
+ ); + expect(screen.getByTestId('form-provider')).toBeTruthy(); + expect(screen.getByText('Form content')).toBeTruthy(); + }); + + it('renders FormItem as a div', () => { + render( + + Item content + + ); + expect(screen.getByTestId('test-item')).toBeTruthy(); + expect(screen.getByText('Item content')).toBeTruthy(); + }); + + it('renders FormLabel as a label element', () => { + render(Username); + const label = screen.getByText('Username'); + expect(label.tagName).toBe('LABEL'); + }); + + it('renders FormControl wrapping a child element', () => { + render( + + + + ); + expect(screen.getByTestId('form-input')).toBeTruthy(); + }); + + it('renders FormDescription', () => { + render(This is a help text); + expect(screen.getByTestId('test-desc')).toBeTruthy(); + expect(screen.getByText('This is a help text')).toBeTruthy(); + }); + + it('renders FormMessage with no error returns null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders FormMessage with children text when no error', () => { + render(fallback text); + expect(screen.getByText('fallback text')).toBeTruthy(); + }); + + it('passes className to FormItem', () => { + render(); + expect(screen.getByTestId('item').className).toContain('custom-item'); + }); + + it('passes className to FormDescription', () => { + render( + + Text + + ); + expect(screen.getByTestId('desc').className).toContain('desc-custom'); + }); + + it('passes ref to FormItem', () => { + const ref = React.createRef(); + render(Content); + expect(ref.current).toBeTruthy(); + }); +}); diff --git a/src/components/ui/label.test.tsx b/src/components/ui/label.test.tsx new file mode 100644 index 00000000..8f7a02ef --- /dev/null +++ b/src/components/ui/label.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-label', () => { + const Root = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Root.displayName = 'Label'; + return { Root }; +}); + +import { Label } from './label'; + +describe('Label', () => { + it('renders label element', () => { + render(); + expect(screen.getByTestId('label').tagName).toBe('LABEL'); + expect(screen.getByText('Email')).toBeTruthy(); + }); + + it('passes custom className', () => { + render( + + ); + expect(screen.getByTestId('label').className).toContain('custom-label'); + }); + + it('has font-medium in default class', () => { + render(); + expect(screen.getByTestId('label').className).toContain('font-medium'); + }); +}); diff --git a/src/components/ui/menubar.test.tsx b/src/components/ui/menubar.test.tsx new file mode 100644 index 00000000..824466d9 --- /dev/null +++ b/src/components/ui/menubar.test.tsx @@ -0,0 +1,277 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-menubar', () => { + const createPrimitive = (displayName: string) => { + const Component = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Component.displayName = displayName; + return Component; + }; + + const createButton = (displayName: string) => { + const Component = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Component.displayName = displayName; + return Component; + }; + + return { + Root: createPrimitive('Menubar'), + Menu: createPrimitive('MenubarMenu'), + Trigger: createButton('MenubarTrigger'), + Content: createPrimitive('MenubarContent'), + CheckboxItem: createPrimitive('MenubarCheckboxItem'), + RadioItem: createPrimitive('MenubarRadioItem'), + Label: createPrimitive('MenubarLabel'), + Separator: createPrimitive('MenubarSeparator'), + Group: ({ + children, + heading, + ...props + }: React.ComponentPropsWithoutRef<'div'> & { heading?: string }) => ( +
+ {heading &&
{heading}
} + {children} +
+ ), + Item: ({ + children, + onSelect, + ...props + }: React.ComponentPropsWithoutRef<'div'> & { onSelect?: (e: Event) => void }) => ( +
onSelect?.(e)} {...props}> + {children} +
+ ), + Portal: ({ children }: { children: React.ReactNode }) => <>{children}, + RadioGroup: createPrimitive('MenubarRadioGroup'), + Sub: createPrimitive('MenubarSub'), + SubTrigger: createButton('MenubarSubTrigger'), + SubContent: createPrimitive('MenubarSubContent'), + ItemIndicator: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }; +}); + +import { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarShortcut, + MenubarGroup, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, +} from './menubar'; + +describe('Menubar', () => { + it('renders without throwing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('passes className', () => { + render(); + expect(screen.getByTestId('menubar')).toHaveClass('custom-class'); + }); + + it('renders children', () => { + render( + + Content + + ); + expect(screen.getByText('Content')).toBeTruthy(); + }); +}); + +describe('MenubarMenu', () => { + it('renders children inside a menu', () => { + render( + + + Menu item + + + ); + expect(screen.getByText('Menu item')).toBeTruthy(); + }); +}); + +describe('MenubarTrigger', () => { + it('renders as a button', () => { + render( + + + File + + + ); + const trigger = screen.getByText('File'); + expect(trigger.tagName).toBe('BUTTON'); + }); + + it('handles onClick', () => { + const handleClick = vi.fn(); + render( + + + File + + + ); + fireEvent.click(screen.getByText('File')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('MenubarContent', () => { + it('renders content with items', () => { + render( + + + File + + New + Open + + + + ); + expect(screen.getByText('New')).toBeTruthy(); + expect(screen.getByText('Open')).toBeTruthy(); + }); +}); + +describe('MenubarItem', () => { + it('renders and handles click', () => { + const handleClick = vi.fn(); + render( + + + Menu + + Copy + + + + ); + fireEvent.click(screen.getByText('Copy')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('MenubarSeparator', () => { + it('renders a separator', () => { + const { container } = render( + + + Menu + + A + + B + + + + ); + expect(container.querySelector('[data-slot="MenubarSeparator"]')).toBeTruthy(); + }); +}); + +describe('MenubarLabel', () => { + it('renders label text', () => { + render( + + + Menu + + Actions + + + + ); + expect(screen.getByText('Actions')).toBeTruthy(); + }); +}); + +describe('MenubarShortcut', () => { + it('renders shortcut text', () => { + render(⌘S); + expect(screen.getByText('⌘S')).toBeTruthy(); + }); +}); + +describe('MenubarGroup', () => { + it('renders grouped items', () => { + render( + + + Edit + + {/* @ts-expect-error - heading prop accepted by mock but not by real Radix type */} + + Cut + Copy + + + + + ); + expect(screen.getByText('Clipboard')).toBeTruthy(); + expect(screen.getByText('Cut')).toBeTruthy(); + }); +}); + +describe('MenubarCheckboxItem', () => { + it('renders checkbox item', () => { + render( + + + View + + Show Toolbar + + + + ); + expect(screen.getByText('Show Toolbar')).toBeTruthy(); + }); +}); + +describe('MenubarRadioGroup', () => { + it('renders radio group with items', () => { + render( + + + Layout + + + Grid + List + + + + + ); + expect(screen.getByText('Grid')).toBeTruthy(); + expect(screen.getByText('List')).toBeTruthy(); + }); +}); diff --git a/src/components/ui/popover.test.tsx b/src/components/ui/popover.test.tsx new file mode 100644 index 00000000..1af7127c --- /dev/null +++ b/src/components/ui/popover.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-popover', () => { + const Trigger = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Trigger.displayName = 'PopoverTrigger'; + + const Content = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Content.displayName = 'PopoverContent'; + + return { + Root: ({ children }: { children: React.ReactNode }) => <>{children}, + Trigger, + Portal: ({ children }: { children: React.ReactNode }) => <>{children}, + Content, + }; +}); + +import { Popover, PopoverTrigger, PopoverContent } from './popover'; + +describe('Popover', () => { + it('renders trigger and content', () => { + render( + + Open popover + Popover body + + ); + expect(screen.getByText('Open popover')).toBeTruthy(); + expect(screen.getByText('Popover body')).toBeTruthy(); + }); +}); + +describe('PopoverTrigger', () => { + it('renders as a button', () => { + render( + + Click me + + ); + expect(screen.getByText('Click me').tagName).toBe('BUTTON'); + }); + + it('handles onClick', () => { + const onClick = vi.fn(); + render( + + Toggle + + ); + fireEvent.click(screen.getByText('Toggle')); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('PopoverContent', () => { + it('renders content', () => { + render( + + Open + Content text + + ); + expect(screen.getByText('Content text')).toBeTruthy(); + }); + + it('passes custom className', () => { + render( + + Open + Body + + ); + expect(screen.getByText('Body')).toHaveClass('custom-popover'); + }); +}); diff --git a/src/components/ui/premium-skeleton.test.tsx b/src/components/ui/premium-skeleton.test.tsx new file mode 100644 index 00000000..8ccf53cc --- /dev/null +++ b/src/components/ui/premium-skeleton.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { PremiumSkeleton } from './premium-skeleton'; + +describe('PremiumSkeleton', () => { + it('renders a div', () => { + render(); + expect(screen.getByTestId('skeleton').tagName).toBe('DIV'); + }); + + it('has animate-pulse class', () => { + render(); + expect(screen.getByTestId('skeleton').className).toContain('animate-pulse'); + }); + + it('passes custom className', () => { + render(); + const el = screen.getByTestId('skeleton'); + expect(el.className).toContain('h-10'); + expect(el.className).toContain('w-50'); + }); + + it('passes extra props', () => { + render(); + expect(screen.getByTestId('skeleton').getAttribute('role')).toBe('img'); + }); +}); diff --git a/src/components/ui/progress.test.tsx b/src/components/ui/progress.test.tsx new file mode 100644 index 00000000..56d6f879 --- /dev/null +++ b/src/components/ui/progress.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-progress', () => { + const Root = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Root.displayName = 'ProgressRoot'; + + const Indicator = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Indicator.displayName = 'ProgressIndicator'; + + return { Root, Indicator }; +}); + +import { Progress } from './progress'; + +describe('Progress', () => { + it('renders with progressbar role', () => { + render(); + expect(screen.getByRole('progressbar')).toBeTruthy(); + }); + + it('renders indicator', () => { + render(); + expect(screen.getByTestId('indicator')).toBeTruthy(); + }); + + it('applies transform style based on value', () => { + render(); + const indicator = screen.getByTestId('indicator'); + expect(indicator.style.transform).toBe('translateX(-25%)'); + }); + + it('handles null value (defaults to 0)', () => { + render(); + const indicator = screen.getByTestId('indicator'); + expect(indicator.style.transform).toBe('translateX(-100%)'); + }); + + it('passes custom className', () => { + render(); + expect(screen.getByTestId('progress').className).toContain('my-progress'); + }); +}); diff --git a/src/components/ui/radio-group.test.tsx b/src/components/ui/radio-group.test.tsx new file mode 100644 index 00000000..097e5c7e --- /dev/null +++ b/src/components/ui/radio-group.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-radio-group', () => { + const Root = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { onValueChange?: (value: string) => void } + >(({ children, onValueChange, ...props }, ref) => ( +
+ {React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement( + child as React.ReactElement<{ onValueChange?: (v: string) => void }>, + { + onValueChange, + } + ); + } + return child; + })} +
+ )); + Root.displayName = 'RadioGroupRoot'; + + const Item = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<'button'> & { + value?: string; + onValueChange?: (v: string) => void; + } + >(({ children, value: itemValue, onValueChange: parentOnChange, ...props }, ref) => ( + + )); + Item.displayName = 'RadioGroupItem'; + + const Indicator = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Indicator.displayName = 'RadioGroupIndicator'; + + return { Root, Item, Indicator }; +}); + +import { RadioGroup, RadioGroupItem } from './radio-group'; + +describe('RadioGroup components', () => { + it('RadioGroup renders with radiogroup role', () => { + render( + + + + ); + expect(screen.getByTestId('rg').getAttribute('role')).toBe('radiogroup'); + }); + + it('RadioGroupItem renders with radio role', () => { + render( + + + + ); + expect(screen.getByTestId('ri').getAttribute('role')).toBe('radio'); + }); + + it('RadioGroup passes className', () => { + render( + + + + ); + expect(screen.getByTestId('rg').className).toContain('my-rg'); + }); + + it('RadioGroupItem passes className', () => { + render( + + + + ); + expect(screen.getByTestId('ri').className).toContain('my-ri'); + }); + + it('renders multiple items', () => { + render( + + + + + + ); + expect(screen.getAllByRole('radio')).toHaveLength(3); + }); +}); diff --git a/src/components/ui/scroll-area.test.tsx b/src/components/ui/scroll-area.test.tsx new file mode 100644 index 00000000..9641ab62 --- /dev/null +++ b/src/components/ui/scroll-area.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-scroll-area', () => { + const Root = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Root.displayName = 'ScrollAreaRoot'; + + const Viewport = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Viewport.displayName = 'ScrollAreaViewport'; + + const Scrollbar = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Scrollbar.displayName = 'ScrollAreaScrollbar'; + + const Thumb = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Thumb.displayName = 'ScrollAreaThumb'; + + return { + Root, + Viewport, + ScrollAreaScrollbar: Scrollbar, + ScrollAreaThumb: Thumb, + Corner: () =>
, + }; +}); + +import { ScrollArea, ScrollBar } from './scroll-area'; + +describe('ScrollArea', () => { + it('renders without throwing', () => { + const { container } = render( + +
Scrollable content
+
+ ); + expect(container).toBeTruthy(); + }); + + it('renders children', () => { + render( + +

Long text content that might overflow

+
+ ); + expect(screen.getByText('Long text content that might overflow')).toBeTruthy(); + }); + + it('passes custom className', () => { + render( + +
Content
+
+ ); + expect(screen.getByTestId('scroll')).toHaveClass('custom-scroll'); + }); + + it('includes scrollbar and corner', () => { + const { container } = render( + +
Content
+
+ ); + expect(container.querySelector('[data-slot="ScrollAreaScrollbar"]')).toBeTruthy(); + expect(container.querySelector('[data-slot="ScrollAreaCorner"]')).toBeTruthy(); + }); +}); + +describe('ScrollBar', () => { + it('renders with vertical orientation by default', () => { + const { container } = render( + +
Content
+ +
+ ); + expect(container.querySelector('[data-slot="ScrollAreaScrollbar"]')).toBeTruthy(); + }); + + it('renders with horizontal orientation', () => { + const { container } = render( + +
Content
+ +
+ ); + const scrollbar = container.querySelector('[data-slot="ScrollAreaScrollbar"]'); + expect(scrollbar).toBeTruthy(); + }); +}); diff --git a/src/components/ui/select.test.tsx b/src/components/ui/select.test.tsx new file mode 100644 index 00000000..b22ce7a6 --- /dev/null +++ b/src/components/ui/select.test.tsx @@ -0,0 +1,277 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-select', () => { + const Root = ({ + children, + value, + ...props + }: { + children?: React.ReactNode; + value?: string; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ); + + const Trigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes + >(({ children, ...props }, ref) => ( + + )); + Trigger.displayName = 'SelectTrigger'; + + const Value = ({ placeholder }: { placeholder?: string }) => ( + {placeholder} + ); + + const Content = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Content.displayName = 'SelectContent'; + + const Item = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { value: string } + >(({ children, value: itemValue, ...props }, ref) => ( +
+ {children} +
+ )); + Item.displayName = 'SelectItem'; + + const Group = ({ + children, + ...props + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ); + + const Label = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Label.displayName = 'SelectLabel'; + + const Separator = React.forwardRef>( + ({ ...props }, ref) => ( +
+ ) + ); + Separator.displayName = 'SelectSeparator'; + + const Icon = ({ children }: { children?: React.ReactNode; asChild?: boolean }) => <>{children}; + + const ItemIndicator = ({ children }: { children?: React.ReactNode }) => <>{children}; + + const ItemText = ({ children }: { children?: React.ReactNode }) => {children}; + + const Portal = ({ children }: { children?: React.ReactNode }) => <>{children}; + + const Viewport = ({ + children, + ...props + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ); + + const ScrollUpButton = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + ScrollUpButton.displayName = 'SelectScrollUpButton'; + + const ScrollDownButton = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + ScrollDownButton.displayName = 'SelectScrollDownButton'; + + return { + Root, + Trigger, + Value, + Content, + Item, + Group, + Label, + Separator, + Icon, + ItemIndicator, + ItemText, + Portal, + Viewport, + ScrollUpButton, + ScrollDownButton, + }; +}); + +vi.mock('lucide-react', () => ({ + Check: () => , + ChevronDown: () => , + ChevronUp: () => , +})); + +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, + SelectGroup, + SelectLabel, + SelectSeparator, +} from './select'; + +describe('Select', () => { + it('renders Select root without crashing', () => { + render( + + ); + expect(screen.getByTestId('select-root')).toBeTruthy(); + }); + + it('renders SelectTrigger', () => { + render( + + ); + expect(screen.getByTestId('select-trigger')).toBeTruthy(); + }); + + it('renders SelectValue with placeholder', () => { + render( + + ); + expect(screen.getByText('Select a fruit')).toBeTruthy(); + }); + + it('renders chevron icon inside trigger', () => { + render( + + ); + expect(screen.getByTestId('icon-chevron-down')).toBeTruthy(); + }); + + it('renders SelectContent', () => { + render( + + ); + expect(screen.getByTestId('select-content')).toBeTruthy(); + }); + + it('renders SelectItem with children', () => { + render( + + ); + expect(screen.getByText('Banana')).toBeTruthy(); + expect(screen.getByText('Cherry')).toBeTruthy(); + }); + + it('passes value prop to Select root', () => { + render( + + ); + expect(screen.getByTestId('select-root').getAttribute('data-value')).toBe('apple'); + }); + + it('renders SelectGroup', () => { + render( + + ); + expect(screen.getByTestId('select-group')).toBeTruthy(); + expect(screen.getByText('Fruits')).toBeTruthy(); + }); + + it('renders SelectSeparator', () => { + render( + + ); + expect(screen.getByTestId('select-separator')).toBeTruthy(); + }); + + it('passes className to SelectTrigger', () => { + render( + + ); + expect(screen.getByTestId('select-trigger').className).toContain('my-custom-trigger'); + }); +}); diff --git a/src/components/ui/separator.test.tsx b/src/components/ui/separator.test.tsx new file mode 100644 index 00000000..a74f5509 --- /dev/null +++ b/src/components/ui/separator.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-separator', () => { + const Root = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { + orientation?: 'horizontal' | 'vertical'; + decorative?: boolean; + } + >(({ orientation = 'horizontal', decorative = true, ...props }, ref) => ( +
+ )); + Root.displayName = 'Separator'; + return { Root }; +}); + +import { Separator } from './separator'; + +describe('Separator', () => { + it('renders horizontal separator by default', () => { + const { container } = render(); + const el = container.firstChild as HTMLElement; + expect(el).toBeTruthy(); + expect(el.getAttribute('role')).toBe('separator'); + expect(el.getAttribute('data-orientation')).toBe('horizontal'); + }); + + it('renders vertical separator', () => { + const { container } = render(); + const el = container.firstChild as HTMLElement; + expect(el.getAttribute('data-orientation')).toBe('vertical'); + }); + + it('passes decorative attribute', () => { + const { container } = render(); + const el = container.firstChild as HTMLElement; + expect(el.getAttribute('data-decorative')).toBeNull(); + }); + + it('passes custom className', () => { + const { container } = render(); + const el = container.firstChild as HTMLElement; + expect(el.className).toContain('my-separator'); + }); + + it('passes data attributes', () => { + render(); + expect(screen.getByTestId('sep')).toBeTruthy(); + }); +}); diff --git a/src/components/ui/sheet.test.tsx b/src/components/ui/sheet.test.tsx new file mode 100644 index 00000000..8f12056b --- /dev/null +++ b/src/components/ui/sheet.test.tsx @@ -0,0 +1,207 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-dialog', () => { + const Root = ({ + children, + open, + ...props + }: { + children?: React.ReactNode; + open?: boolean; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ); + + const Trigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes + >(({ children, ...props }, ref) => ( + + )); + Trigger.displayName = 'SheetTrigger'; + + const Portal = ({ children }: { children?: React.ReactNode }) => <>{children}; + + const Overlay = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Overlay.displayName = 'SheetOverlay'; + + const Content = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Content.displayName = 'SheetContent'; + + const Close = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Close.displayName = 'SheetClose'; + + const Title = React.forwardRef>( + ({ children, ...props }, ref) => ( +

+ {children} +

+ ) + ); + Title.displayName = 'SheetTitle'; + + const Description = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes + >(({ children, ...props }, ref) => ( +

+ {children} +

+ )); + Description.displayName = 'SheetDescription'; + + return { Root, Trigger, Portal, Overlay, Content, Close, Title, Description }; +}); + +vi.mock('lucide-react', () => ({ + X: () => , +})); + +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} from './sheet'; + +describe('Sheet', () => { + it('renders Sheet root without crashing', () => { + render( + +
Content
+
+ ); + expect(screen.getByTestId('sheet-root')).toBeTruthy(); + }); + + it('renders SheetTrigger', () => { + render( + + Open Sheet + + ); + expect(screen.getByTestId('sheet-trigger')).toBeTruthy(); + expect(screen.getByText('Open Sheet')).toBeTruthy(); + }); + + it('renders SheetContent with children', () => { + render( + + +

Sheet body

+
+
+ ); + expect(screen.getByTestId('sheet-content')).toBeTruthy(); + expect(screen.getByText('Sheet body')).toBeTruthy(); + }); + + it('renders close button inside SheetContent', () => { + render( + + +

Content

+
+
+ ); + expect(screen.getByTestId('sheet-close')).toBeTruthy(); + }); + + it('renders SheetHeader with children', () => { + render( + + + + Sheet Title + Sheet Description + + + + ); + expect(screen.getByText('Sheet Title')).toBeTruthy(); + expect(screen.getByText('Sheet Description')).toBeTruthy(); + }); + + it('renders SheetFooter with children', () => { + render( + + + + + + + + + ); + expect(screen.getByText('Action 1')).toBeTruthy(); + expect(screen.getByText('Action 2')).toBeTruthy(); + }); + + it('renders SheetContent with side variant applied via className', () => { + render( + + +

Left sheet

+
+
+ ); + const content = screen.getByTestId('sheet-content'); + expect(content.className).toContain('left'); + }); + + it('defaults to right side variant', () => { + render( + + +

Right sheet

+
+
+ ); + const content = screen.getByTestId('sheet-content'); + expect(content.className).toContain('right'); + }); + + it('passes open state to root', () => { + render( + +
Child
+
+ ); + expect(screen.getByTestId('sheet-root').getAttribute('data-state')).toBe('open'); + }); + + it('passes className to SheetContent', () => { + render( + + +

Content

+
+
+ ); + expect(screen.getByTestId('sheet-content').className).toContain('my-custom-class'); + }); +}); diff --git a/src/components/ui/sidebar.test.tsx b/src/components/ui/sidebar.test.tsx new file mode 100644 index 00000000..399c2a32 --- /dev/null +++ b/src/components/ui/sidebar.test.tsx @@ -0,0 +1,610 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@/hooks/use-mobile', () => ({ + useIsMobile: vi.fn(() => false), +})); + +vi.mock('lucide-react', () => ({ + PanelLeft: () => , +})); + +vi.mock('@radix-ui/react-tooltip', () => { + const Trigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes + >(({ children, ...props }, ref) => ( + + )); + Trigger.displayName = 'TooltipTrigger'; + + const Content = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Content.displayName = 'TooltipContent'; + + return { + Provider: ({ children }: { children?: React.ReactNode }) => <>{children}, + Root: ({ children }: { children?: React.ReactNode }) => <>{children}, + Trigger, + Content, + }; +}); + +vi.mock('@radix-ui/react-separator', () => { + const Root = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Root.displayName = 'Separator'; + return { Root }; +}); + +vi.mock('@radix-ui/react-slot', () => { + const Slot = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { children?: React.ReactNode } + >(({ children, ...props }, ref) => ( +
+ {children} +
+ )); + Slot.displayName = 'Slot'; + return { Slot }; +}); + +vi.mock('@/components/ui/sheet', () => { + const SheetContent = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + SheetContent.displayName = 'SheetContent'; + + const SheetTitle = React.forwardRef>( + ({ children, ...props }, ref) => ( +

+ {children} +

+ ) + ); + SheetTitle.displayName = 'SheetTitle'; + + return { + Sheet: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + SheetContent, + SheetTitle, + }; +}); + +import { + SidebarProvider, + Sidebar, + SidebarTrigger, + SidebarContent, + SidebarHeader, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarSeparator, + SidebarInset, + useSidebar, +} from './sidebar'; + +function TestConsumer() { + const { state, open, toggleSidebar } = useSidebar(); + return ( +
+ {state} + {String(open)} + +
+ ); +} + +describe('SidebarProvider', () => { + beforeEach(() => { + document.cookie = 'sidebar_state=; path=/; max-age=0'; + }); + + it('renders without crashing', () => { + render( + +
Content
+
+ ); + expect(screen.getByText('Content')).toBeTruthy(); + }); + + it('provides context via useSidebar', () => { + render( + + + + ); + expect(screen.getByTestId('state')).toBeTruthy(); + expect(screen.getByTestId('open')).toBeTruthy(); + }); + + it('defaults to expanded state', () => { + render( + + + + ); + expect(screen.getByTestId('state').textContent).toBe('expanded'); + expect(screen.getByTestId('open').textContent).toBe('true'); + }); + + it('accepts defaultOpen=false', () => { + render( + + + + ); + expect(screen.getByTestId('state').textContent).toBe('collapsed'); + expect(screen.getByTestId('open').textContent).toBe('false'); + }); + + it('toggles sidebar on button click', () => { + render( + + + + ); + expect(screen.getByTestId('state').textContent).toBe('expanded'); + fireEvent.click(screen.getByTestId('toggle')); + expect(screen.getByTestId('state').textContent).toBe('collapsed'); + fireEvent.click(screen.getByTestId('toggle')); + expect(screen.getByTestId('state').textContent).toBe('expanded'); + }); + + it('throws when useSidebar used outside provider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + function BadConsumer() { + useSidebar(); + return null; + } + expect(() => render()).toThrow( + 'useSidebar must be used within a SidebarProvider' + ); + consoleSpy.mockRestore(); + }); + + it('sets data-state on sidebar div', () => { + render( + + Nav + + ); + const sidebar = screen.getByText('Nav').closest('[data-state]'); + expect(sidebar?.getAttribute('data-state')).toBe('expanded'); + }); + + it('responds to controlled open prop', () => { + function Controlled() { + const [open, setOpen] = React.useState(false); + return ( + + {String(open)} + + + ); + } + render(); + expect(screen.getByTestId('open-val').textContent).toBe('false'); + fireEvent.click(screen.getByTestId('set-open')); + expect(screen.getByTestId('open-val').textContent).toBe('true'); + }); +}); + +describe('Sidebar structural components', () => { + it('renders SidebarTrigger', () => { + render( + + + + ); + expect(screen.getByTestId('panel-left-icon')).toBeTruthy(); + }); + + it('SidebarTrigger calls toggleSidebar on click', () => { + render( + + + + + ); + expect(screen.getByTestId('state').textContent).toBe('expanded'); + fireEvent.click(screen.getByRole('button', { name: /toggle sidebar/i })); + expect(screen.getByTestId('state').textContent).toBe('collapsed'); + }); + + it('renders SidebarHeader', () => { + render( + + + + Logo + + + + ); + expect(screen.getByTestId('header')).toBeTruthy(); + expect(screen.getByText('Logo')).toBeTruthy(); + }); + + it('renders SidebarFooter', () => { + render( + + + + Footer + + + + ); + expect(screen.getByTestId('footer')).toBeTruthy(); + expect(screen.getByText('Footer')).toBeTruthy(); + }); + + it('renders SidebarContent', () => { + render( + + + + Content + + + + ); + expect(screen.getByTestId('content')).toBeTruthy(); + expect(screen.getByText('Content')).toBeTruthy(); + }); + + it('renders SidebarGroup', () => { + render( + + + + Group content + + + + ); + expect(screen.getByTestId('group')).toBeTruthy(); + }); + + it('renders SidebarGroupLabel', () => { + render( + + + + + Navigation + + + + + ); + expect(screen.getByTestId('group-label')).toBeTruthy(); + expect(screen.getByText('Navigation')).toBeTruthy(); + }); + + it('renders SidebarGroupContent', () => { + render( + + + + + Items + + + + + ); + expect(screen.getByTestId('group-content')).toBeTruthy(); + }); + + it('renders SidebarMenu', () => { + render( + + + + Menu items + + + + ); + expect(screen.getByTestId('menu')).toBeTruthy(); + expect(screen.getByTestId('menu').tagName).toBe('UL'); + }); + + it('renders SidebarMenuItem', () => { + render( + + + + + Item + + + + + ); + expect(screen.getByTestId('menu-item')).toBeTruthy(); + expect(screen.getByTestId('menu-item').tagName).toBe('LI'); + }); + + it('renders SidebarMenuButton', () => { + render( + + + + + + Dashboard + + + + + + ); + expect(screen.getByTestId('menu-button')).toBeTruthy(); + expect(screen.getByText('Dashboard')).toBeTruthy(); + }); + + it('renders SidebarMenuButton with isActive', () => { + render( + + + + + + + Active Item + + + + + + + ); + expect(screen.getByTestId('active-btn').getAttribute('data-active')).toBe('true'); + }); + + it('renders SidebarMenuAction', () => { + render( + + + + + + Item + Action + + + + + + ); + expect(screen.getByTestId('menu-action')).toBeTruthy(); + }); + + it('renders SidebarMenuBadge', () => { + render( + + + + + + Messages + 5 + + + + + + ); + expect(screen.getByTestId('badge')).toBeTruthy(); + expect(screen.getByText('5')).toBeTruthy(); + }); + + it('renders SidebarMenuSub', () => { + render( + + + + + + + Sub item + + + + + + + ); + expect(screen.getByTestId('menu-sub')).toBeTruthy(); + expect(screen.getByTestId('menu-sub').tagName).toBe('UL'); + }); + + it('renders SidebarMenuSubItem', () => { + render( + + + + + + + Sub Item + + + + + + + ); + expect(screen.getByTestId('sub-item')).toBeTruthy(); + expect(screen.getByTestId('sub-item').tagName).toBe('LI'); + }); + + it('renders SidebarMenuSubButton', () => { + render( + + + + + + + + + Sub Button + + + + + + + + + ); + expect(screen.getByTestId('sub-button')).toBeTruthy(); + expect(screen.getByText('Sub Button')).toBeTruthy(); + }); + + it('renders SidebarSeparator', () => { + render( + + + + + + + + ); + expect(screen.getByTestId('separator')).toBeTruthy(); + expect(screen.getByTestId('separator').getAttribute('role')).toBe('separator'); + }); + + it('renders SidebarInset', () => { + render( + + + Main content + + + ); + expect(screen.getByTestId('inset')).toBeTruthy(); + expect(screen.getByTestId('inset').tagName).toBe('MAIN'); + expect(screen.getByText('Main content')).toBeTruthy(); + }); + + it('passes className to SidebarMenuButton', () => { + render( + + + + + + + Item + + + + + + + ); + expect(screen.getByTestId('custom-btn').className).toContain('custom-btn'); + }); + + it('passes data attributes to SidebarMenuSubButton', () => { + render( + + + + + + + + + Sub + + + + + + + + + ); + const subBtn = screen.getByText('Sub').closest('a'); + expect(subBtn?.getAttribute('data-size')).toBe('sm'); + expect(subBtn?.getAttribute('data-active')).toBe('true'); + }); + + it('Sidebar renders with side=right', () => { + render( + + + Right sidebar + + + ); + const sidebarRoot = screen.getByText('Right sidebar').closest('[data-side]'); + expect(sidebarRoot?.getAttribute('data-side')).toBe('right'); + }); + + it('Sidebar renders with collapsible=none', () => { + render( + + + Non-collapsible + + + ); + expect(screen.getByText('Non-collapsible')).toBeTruthy(); + }); + + it('SidebarMenuButton renders with tooltip string', () => { + render( + + + + + + Item with tooltip + + + + + + ); + expect(screen.getByText('Item with tooltip')).toBeTruthy(); + }); +}); diff --git a/src/components/ui/slider.test.tsx b/src/components/ui/slider.test.tsx new file mode 100644 index 00000000..3663fe15 --- /dev/null +++ b/src/components/ui/slider.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-slider', () => { + const Root = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Root.displayName = 'SliderRoot'; + + const Track = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Track.displayName = 'SliderTrack'; + + const Range = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Range.displayName = 'SliderRange'; + + const Thumb = React.forwardRef>( + ({ ...props }, ref) =>
+ ); + Thumb.displayName = 'SliderThumb'; + + return { Root, Track, Range, Thumb }; +}); + +import { Slider } from './slider'; + +describe('Slider', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('slider')).toBeTruthy(); + }); + + it('renders track', () => { + render(); + expect(screen.getByTestId('track')).toBeTruthy(); + }); + + it('renders range', () => { + render(); + expect(screen.getByTestId('range')).toBeTruthy(); + }); + + it('renders thumb', () => { + render(); + expect(screen.getByTestId('thumb')).toBeTruthy(); + }); + + it('passes custom className', () => { + render(); + expect(screen.getByTestId('slider').className).toContain('my-slider'); + }); +}); diff --git a/src/components/ui/switch.test.tsx b/src/components/ui/switch.test.tsx new file mode 100644 index 00000000..7a28616b --- /dev/null +++ b/src/components/ui/switch.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-switch', () => { + const Root = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<'button'> & { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + } + >(({ children, checked: initialChecked, onCheckedChange, ...props }, ref) => { + const [checked, setChecked] = React.useState(initialChecked ?? false); + return ( + + ); + }); + Root.displayName = 'SwitchRoot'; + + const Thumb = React.forwardRef>( + ({ ...props }, ref) => + ); + Thumb.displayName = 'SwitchThumb'; + + return { Root, Thumb }; +}); + +import { Switch } from './switch'; + +describe('Switch', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('sw')).toBeTruthy(); + }); + + it('has switch role', () => { + render(); + expect(screen.getByRole('switch')).toBeTruthy(); + }); + + it('toggles on click', () => { + render(); + const sw = screen.getByTestId('sw'); + expect(sw.getAttribute('data-state')).toBe('unchecked'); + + fireEvent.click(sw); + expect(sw.getAttribute('data-state')).toBe('checked'); + }); + + it('renders thumb', () => { + render(); + expect(screen.getByTestId('switch-thumb')).toBeTruthy(); + }); + + it('passes custom className', () => { + render(); + expect(screen.getByTestId('sw').className).toContain('custom-switch'); + }); +}); diff --git a/src/components/ui/table.test.tsx b/src/components/ui/table.test.tsx new file mode 100644 index 00000000..a3059f6f --- /dev/null +++ b/src/components/ui/table.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './table'; + +describe('Table components', () => { + it('Table renders a inside a wrapper div', () => { + const { container } = render( +
+ +
+ ); + expect(container.querySelector('table')).toBeTruthy(); + }); + + it('TableHeader renders ', () => { + render( + + + + +
+ ); + expect(screen.getByTestId('thead').tagName).toBe('THEAD'); + }); + + it('TableBody renders ', () => { + render( + + + + +
+ ); + expect(screen.getByTestId('tbody').tagName).toBe('TBODY'); + }); + + it('TableFooter renders ', () => { + render( + + + + +
+ ); + expect(screen.getByTestId('tfoot').tagName).toBe('TFOOT'); + }); + + it('TableHead renders ', () => { + render( + + + + Name + + +
+ ); + expect(screen.getByTestId('th').tagName).toBe('TH'); + expect(screen.getByText('Name')).toBeTruthy(); + }); + + it('TableRow renders ', () => { + render( + + + + +
+ ); + expect(screen.getByTestId('tr').tagName).toBe('TR'); + }); + + it('TableCell renders ', () => { + render( + + + + Cell + + +
+ ); + expect(screen.getByTestId('td').tagName).toBe('TD'); + expect(screen.getByText('Cell')).toBeTruthy(); + }); + + it('TableCaption renders ', () => { + render( + + Table info +
+ ); + expect(screen.getByTestId('caption').tagName).toBe('CAPTION'); + expect(screen.getByText('Table info')).toBeTruthy(); + }); + + it('Table passes custom className', () => { + const { container } = render( + + +
+ ); + expect(container.querySelector('table')!.className).toContain('custom-table'); + }); +}); diff --git a/src/components/ui/tabs.test.tsx b/src/components/ui/tabs.test.tsx new file mode 100644 index 00000000..49cc8c70 --- /dev/null +++ b/src/components/ui/tabs.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; + +vi.mock('@radix-ui/react-tabs', () => { + const Root = ({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) => ( +
+ {children} +
+ ); + Root.displayName = 'Tabs'; + + const List = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + List.displayName = 'TabsList'; + + const Trigger = React.forwardRef>( + ({ children, ...props }, ref) => ( + + ) + ); + Trigger.displayName = 'TabsTrigger'; + + const Content = React.forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ); + Content.displayName = 'TabsContent'; + + return { Root, List, Trigger, Content }; +}); + +import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'; + +describe('Tabs components', () => { + it('Tabs renders children', () => { + render( + + + Tab 1 + + Content 1 + + ); + expect(screen.getByTestId('tabs-root')).toBeTruthy(); + }); + + it('TabsList renders with role tablist', () => { + render( + + + A + + + ); + expect(screen.getByTestId('list').getAttribute('role')).toBe('tablist'); + }); + + it('TabsTrigger renders as tab button', () => { + render( + + + + My Tab + + + + ); + expect(screen.getByTestId('trigger').getAttribute('role')).toBe('tab'); + expect(screen.getByText('My Tab')).toBeTruthy(); + }); + + it('TabsContent renders panel', () => { + render( + + + Y + + + Panel Content + + + ); + expect(screen.getByTestId('panel').getAttribute('role')).toBe('tabpanel'); + expect(screen.getByText('Panel Content')).toBeTruthy(); + }); + + it('Tabs passes className', () => { + render( + +
+ + ); + expect(screen.getByTestId('tabs-root').className).toContain('tabs-custom'); + }); +}); diff --git a/src/components/ui/textarea.test.tsx b/src/components/ui/textarea.test.tsx new file mode 100644 index 00000000..c3865d64 --- /dev/null +++ b/src/components/ui/textarea.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { Textarea } from './textarea'; + +describe('Textarea', () => { + it('renders textarea element', () => { + render(