diff --git a/code/frontend/src/tests/ApplyJobButton.tdd.test.tsx b/code/frontend/src/tests/ApplyJobButton.tdd.test.tsx new file mode 100644 index 0000000..05da3da --- /dev/null +++ b/code/frontend/src/tests/ApplyJobButton.tdd.test.tsx @@ -0,0 +1,98 @@ +// src/tests/ApplyJobButton.tdd.test.tsx +// Purpose: Verify ApplyJobButton’s UI states, user interactions, and API/localStorage behavior +// 80% AI-generated, 20% human refined + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import ApplyJobButton from '../components/jobsList/ApplyJobButton'; +import { applyJob } from '../api/savedAndApplied/savedAndApplied'; +import type { Job } from '../types/job'; + +// Mock the applyJob API +jest.mock('../api/savedAndApplied/savedAndApplied', () => ({ + applyJob: jest.fn(), +})); + +// Mock localStorage helpers without importing (avoids TS unused import error) +jest.mock('../components/jobsList/localStorageHelpers', () => ({ + isJobApplied: jest.fn(() => false), + toggleAppliedJobId: jest.fn(() => true), +})); + +describe('ApplyJobButton', () => { + // Mock window.open to avoid jsdom "Not implemented" error + beforeAll(() => { + window.open = jest.fn(); + }); + + const mockJob: Job = { + _id: '1', + title: 'Frontend Developer', + url: 'https://example.com/apply', + createdAt: '', + department: 'Engineering', + descriptionBreakdown: { + employmentType: 'Full-time', + keywords: [], + oneSentenceJobSummary: 'Develop front-end apps', + salaryRangeMaxYearly: 120000, + salaryRangeMinYearly: 80000, + skillRequirements: ['React', 'TypeScript'], + workModel: 'Remote', + }, + locationAddress: '123 Main St', + locationCoordinates: { lat: 0, lon: 0 }, + owner: { + _id: 'o1', + companyName: 'Tech Co', + benefits: { title: 'Benefits', benefits: [] }, + evaluatedSize: null, + funding: 'Series A', + isClaimed: true, + locationAddress: '123 Main St', + photo: '', + rating: '5', + slug: 'tech-co', + teamSize: 50, + }, + seniority: 'Mid', + skills_suggest: ['React', 'TypeScript'], + type: 'Job', + updatedAt: '', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders Apply button initially', () => { + render(); + expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument(); + }); + + it('handles successful apply flow', async () => { + (applyJob as jest.Mock).mockResolvedValue({ success: true }); + + render(); + const btn = screen.getByRole('button', { name: /apply/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(applyJob).toHaveBeenCalledWith(mockJob); + expect(screen.getByRole('button', { name: /applied/i })).toBeInTheDocument(); + expect(screen.getByText(/successfully applied/i)).toBeInTheDocument(); + }); + }); + + it('handles failed apply flow', async () => { + (applyJob as jest.Mock).mockRejectedValue(new Error('Network fail')); + + render(); + const btn = screen.getByRole('button', { name: /apply/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(applyJob).toHaveBeenCalled(); + expect(screen.getByText(/network fail/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/code/frontend/src/tests/SaveJobButton.tdd.test.tsx b/code/frontend/src/tests/SaveJobButton.tdd.test.tsx new file mode 100644 index 0000000..5fc49ee --- /dev/null +++ b/code/frontend/src/tests/SaveJobButton.tdd.test.tsx @@ -0,0 +1,97 @@ +// SaveJobButton.test.tsx +// Purpose: Verify SaveJobButton’s UI states and API/localStorage interactions +// 80% AI-generated, 20% human refined + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import SaveJobButton from '../components/jobsList/SaveJobButton'; +import { saveJob } from '../api/savedAndApplied/savedAndApplied'; +import { + isJobSaved, + toggleSavedJobId, +} from '../components/jobsList/localStorageHelpers'; + +jest.mock('../api/savedAndApplied/savedAndApplied', () => ({ + saveJob: jest.fn(), +})); + +jest.mock('../components/jobsList/localStorageHelpers', () => ({ + isJobSaved: jest.fn(), + toggleSavedJobId: jest.fn(), +})); + +describe('SaveJobButton', () => { + const job = { _id: 'abc123', title: 'Frontend Engineer' } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders Save Job initially when not saved', () => { + (isJobSaved as jest.Mock).mockReturnValue(false); + render(); + expect(screen.getByRole('button', { name: /save job/i })).toBeInTheDocument(); + }); + + it('renders Saved when job is already saved', () => { + (isJobSaved as jest.Mock).mockReturnValue(true); + render(); + expect(screen.getByRole('button', { name: /saved/i })).toBeInTheDocument(); + }); + + it('disables button when detailed=false', () => { + (isJobSaved as jest.Mock).mockReturnValue(false); + render(); + const btn = screen.getByRole('button'); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute('title', 'Open the detailed view to save this job'); + }); + + it('handles successful save flow', async () => { + (isJobSaved as jest.Mock).mockReturnValue(false); + (saveJob as jest.Mock).mockResolvedValue({ success: true }); + (toggleSavedJobId as jest.Mock).mockReturnValue(true); + + render(); + + const btn = screen.getByRole('button', { name: /save job/i }); + fireEvent.click(btn); + + // Should change to Saving… + expect(await screen.findByText(/saving/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(saveJob).toHaveBeenCalledWith(job); + expect(toggleSavedJobId).toHaveBeenCalledWith('abc123'); + expect(screen.getByText(/saved/i)).toBeInTheDocument(); + }); + }); + + it('handles failed save flow gracefully', async () => { + (isJobSaved as jest.Mock).mockReturnValue(false); + (saveJob as jest.Mock).mockRejectedValue(new Error('Network fail')); + + render(); + + const btn = screen.getByRole('button', { name: /save job/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(saveJob).toHaveBeenCalled(); + expect(screen.getByText(/network fail/i)).toBeInTheDocument(); + }); + }); + + it('unsaves when already saved and clicked again', async () => { + (isJobSaved as jest.Mock).mockReturnValue(true); + (toggleSavedJobId as jest.Mock).mockReturnValue(false); + + render(); + const btn = screen.getByRole('button', { name: /saved/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(toggleSavedJobId).toHaveBeenCalledWith('abc123'); + expect(screen.getByRole('button', { name: /save job/i })).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/code/frontend/src/tests/SavedJobApplyButton.tdd.test.tsx b/code/frontend/src/tests/SavedJobApplyButton.tdd.test.tsx new file mode 100644 index 0000000..7cbbc05 --- /dev/null +++ b/code/frontend/src/tests/SavedJobApplyButton.tdd.test.tsx @@ -0,0 +1,111 @@ +// src/tests/SavedJobApplyButton.tdd.test.tsx +// Purpose: Verify SavedJobApplyButton’s UI states, application flow, and API interactions +// 80% AI-generated, 20% human refined + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import SavedJobApplyButton from '../components/jobsList/SavedJobApplyButton'; +import { applyJob } from '../api/savedAndApplied/savedAndApplied'; +import { deleteSavedJob } from '../api/pages/myJobs'; +import type { SavedAppliedJob } from '../api/pages/myJobs'; + +jest.mock('../api/savedAndApplied/savedAndApplied', () => ({ + applyJob: jest.fn(), +})); + +jest.mock('../api/pages/myJobs', () => ({ + deleteSavedJob: jest.fn(), +})); + +describe('SavedJobApplyButton', () => { + const mockJob: SavedAppliedJob = { + id: 1, + title: 'Frontend Developer', + company: 'Tech Co', + description: 'Build awesome UI', + department: 'Engineering', + url: 'http://example.com/job/frontend', + location: '123 Tech St', + locationAddress: '123 Tech St', + locationCoordinates: { lat: 0, lon: 0 }, + salaryMin: 90000, + salaryMax: 120000, + employmentType: 'Full-time', + requirements: 'React,TypeScript', + type: 'Software', + seniority: 'Mid-level', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + benefits: 'Gym,Insurance', + // 修正类型 + postedBy: 123, + applicationDeadline: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7天后 timestamp + }; + + beforeAll(() => { + // mock window.open,避免 jsdom 报错 + window.open = jest.fn(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders Apply Now button initially', () => { + render(); + expect(screen.getByRole('button', { name: /Apply Now/i })).toBeInTheDocument(); + }); + + it('disables button if detailed is false', () => { + render(); + const btn = screen.getByRole('button', { name: /Apply Now/i }); + expect(btn).toBeDisabled(); + }); + + it('calls applyJob and deleteSavedJob on successful apply', async () => { + (applyJob as jest.Mock).mockResolvedValue({ success: true }); + (deleteSavedJob as jest.Mock).mockResolvedValue({ success: true }); + const onAppliedMock = jest.fn(); + + render( + + ); + const btn = screen.getByRole('button', { name: /Apply Now/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(applyJob).toHaveBeenCalled(); + expect(deleteSavedJob).toHaveBeenCalledWith(mockJob.id); + expect(onAppliedMock).toHaveBeenCalledWith(mockJob.id); + expect(screen.getByText(/Applied Successfully/i)).toBeInTheDocument(); + }); + }); + + it('shows error message when applyJob fails', async () => { + (applyJob as jest.Mock).mockRejectedValue(new Error('Network fail')); + render(); + const btn = screen.getByRole('button', { name: /Apply Now/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(applyJob).toHaveBeenCalled(); + expect(screen.getByText(/Try Again/i)).toBeInTheDocument(); + expect(screen.getByText(/Network fail/i)).toBeInTheDocument(); + }); + }); + + it('opens new tab when job has URL', async () => { + (applyJob as jest.Mock).mockResolvedValue({ success: true }); + + render(); + const btn = screen.getByRole('button', { name: /Apply Now/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(window.open).toHaveBeenCalledWith( + mockJob.url, + '_blank', + 'noopener,noreferrer' + ); + }); + }); +}); \ No newline at end of file diff --git a/code/frontend/src/tests/Type.tdd.test.tsx b/code/frontend/src/tests/Type.tdd.test.tsx new file mode 100644 index 0000000..d717ea6 --- /dev/null +++ b/code/frontend/src/tests/Type.tdd.test.tsx @@ -0,0 +1,112 @@ +// Type.tdd.test.tsx +// Verifies open/close behavior, dynamic type population, selection logic, and event dispatching. +// 80% AI-generated, 20% human refined + +import { render, screen, fireEvent, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Type from '../components/asideAndToggler/Type.tsx'; + +describe('Type component', () => { + beforeEach(() => { + // Clear listeners and any custom events between tests + jest.restoreAllMocks(); + }); + + it('renders button with default label', () => { + render(); + expect(screen.getByRole('button', { name: /type/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /type/i })).toHaveTextContent('Type'); + }); + + it('opens and closes dropdown panel when clicked', async () => { + render(); + const button = screen.getByRole('button', { name: /type/i }); + + await userEvent.click(button); + expect(await screen.findByRole('dialog', { name: /type filter/i })).toBeInTheDocument(); + + // Close via X button + await userEvent.click(screen.getByLabelText(/close/i)); + expect(screen.queryByRole('dialog', { name: /type filter/i })).not.toBeInTheDocument(); + }); + + it('closes when clicking outside', async () => { + render(); + const button = screen.getByRole('button', { name: /type/i }); + + await userEvent.click(button); + const panel = await screen.findByRole('dialog', { name: /type filter/i }); + expect(panel).toBeInTheDocument(); + + act(() => { + fireEvent.mouseDown(document.body); // simulate click outside + }); + expect(screen.queryByRole('dialog', { name: /type filter/i })).not.toBeInTheDocument(); + }); + + it('renders dynamic types from jobs:types event', async () => { + render(); + const button = screen.getByRole('button', { name: /type/i }); + + // Dispatch event before open — should sync value and list + act(() => { + const evt = new CustomEvent('jobs:types', { + detail: { types: ['Full-time', 'Internship'], selectedType: null }, + }); + window.dispatchEvent(evt); + }); + + await userEvent.click(button); + expect(await screen.findByRole('dialog', { name: /type filter/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /full-time/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /internship/i })).toBeInTheDocument(); + }); + + it('calls onChange and dispatches jobs:typeSelect event when choosing a type', async () => { + const mockOnChange = jest.fn(); + const spyDispatch = jest.spyOn(window, 'dispatchEvent'); + + render(); + const button = screen.getByRole('button', { name: /type/i }); + + act(() => { + const evt = new CustomEvent('jobs:types', { + detail: { types: ['Contract', 'Remote'], selectedType: null }, + }); + window.dispatchEvent(evt); + }); + + await userEvent.click(button); + const option = await screen.findByRole('option', { name: /contract/i }); + await userEvent.click(option); + + expect(mockOnChange).toHaveBeenCalledWith('Contract'); + expect(spyDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'jobs:typeSelect', + detail: expect.objectContaining({ value: 'Contract' }), + }) + ); + + // After choosing, panel should close + expect(screen.queryByRole('dialog', { name: /type filter/i })).not.toBeInTheDocument(); + }); + + it('choosing "Any type" calls onChange with null', async () => { + const mockOnChange = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /type/i })); + const anyOption = await screen.findByRole('option', { name: /any type/i }); + await userEvent.click(anyOption); + + expect(mockOnChange).toHaveBeenCalledWith(null); + }); + + it('shows "No types detected yet…" when there are no types', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /type/i })); + + expect(await screen.findByText(/no types detected yet/i)).toBeInTheDocument(); + }); +}); diff --git a/code/frontend/src/tests/themeToggler.tdd.test.tsx b/code/frontend/src/tests/themeToggler.tdd.test.tsx new file mode 100644 index 0000000..dc04b72 --- /dev/null +++ b/code/frontend/src/tests/themeToggler.tdd.test.tsx @@ -0,0 +1,55 @@ +// src/tests/themeToggler.tdd.test.tsx +// 80% AI-generated, 20% human refined + +import { render, screen, fireEvent } from '@testing-library/react'; +import ThemeToggler from '../components/asideAndToggler/themeToggler'; +import { ThemeProvider } from '../theme/ThemeContext'; + +describe('ThemeToggler', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders moon icon when initial theme is light', () => { + localStorage.setItem('appTheme', 'light'); + render( + + + + ); + const img = screen.getByAltText(/switch to dark mode/i); + expect(img).toBeInTheDocument(); + }); + + it('renders sun icon when initial theme is dark', () => { + localStorage.setItem('appTheme', 'dark'); // 模拟初始 dark + render( + + + + ); + const img = screen.getByAltText(/switch to light mode/i); + expect(img).toBeInTheDocument(); + }); + + it('toggles theme on click', () => { + localStorage.setItem('appTheme', 'light'); + render( + + + + ); + + const toggler = screen.getByLabelText(/toggle color theme/i); + + expect(screen.getByAltText(/switch to dark mode/i)).toBeInTheDocument(); + + fireEvent.click(toggler); + expect(screen.getByAltText(/switch to light mode/i)).toBeInTheDocument(); + expect(localStorage.getItem('appTheme')).toBe('dark'); + + fireEvent.click(toggler); + expect(screen.getByAltText(/switch to dark mode/i)).toBeInTheDocument(); + expect(localStorage.getItem('appTheme')).toBe('light'); + }); +}); \ No newline at end of file