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