orchestrator tests
This commit is contained in:
parent
a4f52b923a
commit
84987f5921
164
orchestrator/src/client/pages/OrchestratorPage.test.tsx
Normal file
164
orchestrator/src/client/pages/OrchestratorPage.test.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import { OrchestratorPage } from "./OrchestratorPage";
|
||||||
|
import type { Job } from "../../shared/types";
|
||||||
|
|
||||||
|
const jobFixture: Job = {
|
||||||
|
id: "job-1",
|
||||||
|
source: "linkedin",
|
||||||
|
sourceJobId: null,
|
||||||
|
jobUrlDirect: null,
|
||||||
|
datePosted: null,
|
||||||
|
title: "Backend Engineer",
|
||||||
|
employer: "Acme",
|
||||||
|
employerUrl: null,
|
||||||
|
jobUrl: "https://example.com/job",
|
||||||
|
applicationLink: null,
|
||||||
|
disciplines: null,
|
||||||
|
deadline: null,
|
||||||
|
salary: null,
|
||||||
|
location: "London",
|
||||||
|
degreeRequired: null,
|
||||||
|
starting: null,
|
||||||
|
jobDescription: "Build APIs",
|
||||||
|
status: "ready",
|
||||||
|
suitabilityScore: 90,
|
||||||
|
suitabilityReason: null,
|
||||||
|
tailoredSummary: null,
|
||||||
|
tailoredHeadline: null,
|
||||||
|
tailoredSkills: null,
|
||||||
|
selectedProjectIds: null,
|
||||||
|
pdfPath: null,
|
||||||
|
notionPageId: null,
|
||||||
|
jobType: null,
|
||||||
|
salarySource: null,
|
||||||
|
salaryInterval: null,
|
||||||
|
salaryMinAmount: null,
|
||||||
|
salaryMaxAmount: null,
|
||||||
|
salaryCurrency: null,
|
||||||
|
isRemote: null,
|
||||||
|
jobLevel: null,
|
||||||
|
jobFunction: null,
|
||||||
|
listingType: null,
|
||||||
|
emails: null,
|
||||||
|
companyIndustry: null,
|
||||||
|
companyLogo: null,
|
||||||
|
companyUrlDirect: null,
|
||||||
|
companyAddresses: null,
|
||||||
|
companyNumEmployees: null,
|
||||||
|
companyRevenue: null,
|
||||||
|
companyDescription: null,
|
||||||
|
skills: null,
|
||||||
|
experienceRange: null,
|
||||||
|
companyRating: null,
|
||||||
|
companyReviewsCount: null,
|
||||||
|
vacancyCount: null,
|
||||||
|
workFromHomeType: null,
|
||||||
|
discoveredAt: "2025-01-01T00:00:00Z",
|
||||||
|
processedAt: null,
|
||||||
|
appliedAt: null,
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMatchMedia = (matches: boolean) =>
|
||||||
|
vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./orchestrator/useOrchestratorData", () => ({
|
||||||
|
useOrchestratorData: () => ({
|
||||||
|
jobs: [jobFixture],
|
||||||
|
stats: {
|
||||||
|
discovered: 0,
|
||||||
|
processing: 0,
|
||||||
|
ready: 1,
|
||||||
|
applied: 0,
|
||||||
|
skipped: 0,
|
||||||
|
expired: 0,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isPipelineRunning: false,
|
||||||
|
setIsPipelineRunning: vi.fn(),
|
||||||
|
loadJobs: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./orchestrator/usePipelineSources", () => ({
|
||||||
|
usePipelineSources: () => ({
|
||||||
|
pipelineSources: ["linkedin"],
|
||||||
|
setPipelineSources: vi.fn(),
|
||||||
|
toggleSource: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./orchestrator/OrchestratorHeader", () => ({
|
||||||
|
OrchestratorHeader: () => <div data-testid="header" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./orchestrator/OrchestratorSummary", () => ({
|
||||||
|
OrchestratorSummary: () => <div data-testid="summary" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||||
|
OrchestratorFilters: () => <div data-testid="filters" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./orchestrator/JobDetailPanel", () => ({
|
||||||
|
JobDetailPanel: () => <div data-testid="detail-panel" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./orchestrator/JobListPanel", () => ({
|
||||||
|
JobListPanel: ({ onSelectJob, selectedJobId }: { onSelectJob: (id: string) => void; selectedJobId: string | null }) => (
|
||||||
|
<div>
|
||||||
|
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
|
||||||
|
<button type="button" onClick={() => onSelectJob("job-1")}>Select job</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../components", () => ({
|
||||||
|
ManualImportSheet: () => <div data-testid="manual-import" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OrchestratorPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the detail drawer on mobile when a job is selected", () => {
|
||||||
|
window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<OrchestratorPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /select job/i }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the detail panel inline on desktop", () => {
|
||||||
|
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<OrchestratorPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,274 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { JobDetailPanel } from "./JobDetailPanel";
|
||||||
|
import type { Job } from "../../../shared/types";
|
||||||
|
import * as api from "../../api";
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
|
return {
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||||
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
}) => (
|
||||||
|
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuSeparator: () => <div role="separator" />,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../components", () => ({
|
||||||
|
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
||||||
|
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/ReadyPanel", () => ({
|
||||||
|
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
|
||||||
|
<div>
|
||||||
|
<div data-testid="ready-panel" />
|
||||||
|
<button type="button" onClick={() => onEditDescription?.()}>
|
||||||
|
Edit description
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/TailoringEditor", () => ({
|
||||||
|
TailoringEditor: () => <div data-testid="tailoring-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@client/lib/jobCopy", () => ({
|
||||||
|
copyTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||||
|
formatJobForWebhook: vi.fn(() => "payload"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
updateJob: vi.fn(),
|
||||||
|
processJob: vi.fn(),
|
||||||
|
generateJobPdf: vi.fn(),
|
||||||
|
markAsApplied: vi.fn(),
|
||||||
|
skipJob: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
message: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||||
|
id: "job-1",
|
||||||
|
source: "linkedin",
|
||||||
|
sourceJobId: null,
|
||||||
|
jobUrlDirect: null,
|
||||||
|
datePosted: null,
|
||||||
|
title: "Backend Engineer",
|
||||||
|
employer: "Acme",
|
||||||
|
employerUrl: null,
|
||||||
|
jobUrl: "https://example.com/job",
|
||||||
|
applicationLink: "https://example.com/apply",
|
||||||
|
disciplines: null,
|
||||||
|
deadline: "2025-02-01",
|
||||||
|
salary: "GBP 50k",
|
||||||
|
location: "London",
|
||||||
|
degreeRequired: null,
|
||||||
|
starting: null,
|
||||||
|
jobDescription: "Build APIs",
|
||||||
|
status: "ready",
|
||||||
|
suitabilityScore: 82,
|
||||||
|
suitabilityReason: "Strong fit",
|
||||||
|
tailoredSummary: null,
|
||||||
|
tailoredHeadline: null,
|
||||||
|
tailoredSkills: null,
|
||||||
|
selectedProjectIds: null,
|
||||||
|
pdfPath: null,
|
||||||
|
notionPageId: null,
|
||||||
|
jobType: null,
|
||||||
|
salarySource: null,
|
||||||
|
salaryInterval: null,
|
||||||
|
salaryMinAmount: null,
|
||||||
|
salaryMaxAmount: null,
|
||||||
|
salaryCurrency: null,
|
||||||
|
isRemote: null,
|
||||||
|
jobLevel: null,
|
||||||
|
jobFunction: null,
|
||||||
|
listingType: null,
|
||||||
|
emails: null,
|
||||||
|
companyIndustry: null,
|
||||||
|
companyLogo: null,
|
||||||
|
companyUrlDirect: null,
|
||||||
|
companyAddresses: null,
|
||||||
|
companyNumEmployees: null,
|
||||||
|
companyRevenue: null,
|
||||||
|
companyDescription: null,
|
||||||
|
skills: null,
|
||||||
|
experienceRange: null,
|
||||||
|
companyRating: null,
|
||||||
|
companyReviewsCount: null,
|
||||||
|
vacancyCount: null,
|
||||||
|
workFromHomeType: null,
|
||||||
|
discoveredAt: "2025-01-01T00:00:00Z",
|
||||||
|
processedAt: null,
|
||||||
|
appliedAt: null,
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-02T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JobDetailPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the discovered panel when active tab is discovered", () => {
|
||||||
|
const job = createJob({ id: "job-99", status: "discovered" });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobDetailPanel
|
||||||
|
activeTab="discovered"
|
||||||
|
activeJobs={[job]}
|
||||||
|
selectedJob={job}
|
||||||
|
onSelectJobId={vi.fn()}
|
||||||
|
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onSetActiveTab={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wires ready panel edit actions back to the page", () => {
|
||||||
|
const onSetActiveTab = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobDetailPanel
|
||||||
|
activeTab="ready"
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJob={createJob({ status: "ready" })}
|
||||||
|
onSelectJobId={vi.fn()}
|
||||||
|
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onSetActiveTab={onSetActiveTab}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
|
||||||
|
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty state when no job is selected", () => {
|
||||||
|
render(
|
||||||
|
<JobDetailPanel
|
||||||
|
activeTab="all"
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJob={null}
|
||||||
|
onSelectJobId={vi.fn()}
|
||||||
|
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onSetActiveTab={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("No job selected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a stripped description preview for html content", () => {
|
||||||
|
render(
|
||||||
|
<JobDetailPanel
|
||||||
|
activeTab="all"
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJob={createJob({ jobDescription: "<p>Hello <strong>world</strong></p>" })}
|
||||||
|
onSelectJobId={vi.fn()}
|
||||||
|
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onSetActiveTab={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves an edited description", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobDetailPanel
|
||||||
|
activeTab="all"
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJob={createJob({ jobDescription: "Original" })}
|
||||||
|
onSelectJobId={vi.fn()}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onSetActiveTab={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /^edit$/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Enter job description..."), {
|
||||||
|
target: { value: "Updated description" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" })
|
||||||
|
);
|
||||||
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a job as applied from the action button", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobDetailPanel
|
||||||
|
activeTab="all"
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJob={createJob({ status: "ready" })}
|
||||||
|
onSelectJobId={vi.fn()}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onSetActiveTab={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /applied/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.markAsApplied).toHaveBeenCalledWith("job-1"));
|
||||||
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a job from the menu", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(api.skipJob).mockResolvedValue(undefined as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobDetailPanel
|
||||||
|
activeTab="all"
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJob={createJob({ status: "ready" })}
|
||||||
|
onSelectJobId={vi.fn()}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onSetActiveTab={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", { name: /more actions/i }));
|
||||||
|
const skipItem = await screen.findByRole("menuitem", { name: /skip job/i });
|
||||||
|
fireEvent.click(skipItem);
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.skipJob).toHaveBeenCalledWith("job-1"));
|
||||||
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
140
orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
Normal file
140
orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { JobListPanel } from "./JobListPanel";
|
||||||
|
import type { Job } from "../../../shared/types";
|
||||||
|
|
||||||
|
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||||
|
id: "job-1",
|
||||||
|
source: "linkedin",
|
||||||
|
sourceJobId: null,
|
||||||
|
jobUrlDirect: null,
|
||||||
|
datePosted: null,
|
||||||
|
title: "Backend Engineer",
|
||||||
|
employer: "Acme",
|
||||||
|
employerUrl: null,
|
||||||
|
jobUrl: "https://example.com/job",
|
||||||
|
applicationLink: null,
|
||||||
|
disciplines: null,
|
||||||
|
deadline: null,
|
||||||
|
salary: null,
|
||||||
|
location: "London",
|
||||||
|
degreeRequired: null,
|
||||||
|
starting: null,
|
||||||
|
jobDescription: "Build APIs",
|
||||||
|
status: "ready",
|
||||||
|
suitabilityScore: 72,
|
||||||
|
suitabilityReason: null,
|
||||||
|
tailoredSummary: null,
|
||||||
|
tailoredHeadline: null,
|
||||||
|
tailoredSkills: null,
|
||||||
|
selectedProjectIds: null,
|
||||||
|
pdfPath: null,
|
||||||
|
notionPageId: null,
|
||||||
|
jobType: null,
|
||||||
|
salarySource: null,
|
||||||
|
salaryInterval: null,
|
||||||
|
salaryMinAmount: null,
|
||||||
|
salaryMaxAmount: null,
|
||||||
|
salaryCurrency: null,
|
||||||
|
isRemote: null,
|
||||||
|
jobLevel: null,
|
||||||
|
jobFunction: null,
|
||||||
|
listingType: null,
|
||||||
|
emails: null,
|
||||||
|
companyIndustry: null,
|
||||||
|
companyLogo: null,
|
||||||
|
companyUrlDirect: null,
|
||||||
|
companyAddresses: null,
|
||||||
|
companyNumEmployees: null,
|
||||||
|
companyRevenue: null,
|
||||||
|
companyDescription: null,
|
||||||
|
skills: null,
|
||||||
|
experienceRange: null,
|
||||||
|
companyRating: null,
|
||||||
|
companyReviewsCount: null,
|
||||||
|
vacancyCount: null,
|
||||||
|
workFromHomeType: null,
|
||||||
|
discoveredAt: "2025-01-01T00:00:00Z",
|
||||||
|
processedAt: null,
|
||||||
|
appliedAt: null,
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-02T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JobListPanel", () => {
|
||||||
|
it("shows a loading state when fetching jobs", () => {
|
||||||
|
render(
|
||||||
|
<JobListPanel
|
||||||
|
isLoading
|
||||||
|
jobs={[]}
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJobId={null}
|
||||||
|
activeTab="ready"
|
||||||
|
searchQuery=""
|
||||||
|
onSelectJob={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the tab empty state copy when no jobs exist", () => {
|
||||||
|
render(
|
||||||
|
<JobListPanel
|
||||||
|
isLoading={false}
|
||||||
|
jobs={[]}
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJobId={null}
|
||||||
|
activeTab="ready"
|
||||||
|
searchQuery=""
|
||||||
|
onSelectJob={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("No jobs found")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Run the pipeline to discover and process new jobs.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the query-specific empty state when searching", () => {
|
||||||
|
render(
|
||||||
|
<JobListPanel
|
||||||
|
isLoading={false}
|
||||||
|
jobs={[]}
|
||||||
|
activeJobs={[]}
|
||||||
|
selectedJobId={null}
|
||||||
|
activeTab="ready"
|
||||||
|
searchQuery="iOS"
|
||||||
|
onSelectJob={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders jobs and notifies when a job is selected", () => {
|
||||||
|
const onSelectJob = vi.fn();
|
||||||
|
const jobs = [
|
||||||
|
createJob({ id: "job-1", title: "Backend Engineer" }),
|
||||||
|
createJob({ id: "job-2", title: "Frontend Engineer", employer: "Globex" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobListPanel
|
||||||
|
isLoading={false}
|
||||||
|
jobs={jobs}
|
||||||
|
activeJobs={jobs}
|
||||||
|
selectedJobId="job-1"
|
||||||
|
activeTab="ready"
|
||||||
|
searchQuery=""
|
||||||
|
onSelectJob={onSelectJob}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /Backend Engineer/i })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
|
||||||
|
expect(onSelectJob).toHaveBeenCalledWith("job-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { OrchestratorFilters } from "./OrchestratorFilters";
|
||||||
|
import type { FilterTab, JobSort } from "./constants";
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
|
const React = require("react") as typeof import("react");
|
||||||
|
const RadioGroupContext = React.createContext<((value: string) => void) | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||||
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
}) => (
|
||||||
|
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuSeparator: () => <div role="separator" />,
|
||||||
|
DropdownMenuRadioGroup: ({
|
||||||
|
children,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}) => (
|
||||||
|
<RadioGroupContext.Provider value={onValueChange ?? null}>
|
||||||
|
<div role="radiogroup">{children}</div>
|
||||||
|
</RadioGroupContext.Provider>
|
||||||
|
),
|
||||||
|
DropdownMenuRadioItem: ({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
value: string;
|
||||||
|
}) => {
|
||||||
|
const onValueChange = React.useContext(RadioGroupContext);
|
||||||
|
return (
|
||||||
|
<button type="button" role="menuitemradio" onClick={() => onValueChange?.(value)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderFilters = (overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>) => {
|
||||||
|
const props = {
|
||||||
|
activeTab: "ready" as FilterTab,
|
||||||
|
onTabChange: vi.fn(),
|
||||||
|
counts: {
|
||||||
|
ready: 2,
|
||||||
|
discovered: 1,
|
||||||
|
applied: 3,
|
||||||
|
all: 6,
|
||||||
|
},
|
||||||
|
searchQuery: "",
|
||||||
|
onSearchQueryChange: vi.fn(),
|
||||||
|
sourceFilter: "all" as const,
|
||||||
|
onSourceFilterChange: vi.fn(),
|
||||||
|
sort: { key: "score", direction: "desc" } as JobSort,
|
||||||
|
onSortChange: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
props,
|
||||||
|
...render(<OrchestratorFilters {...props} />),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("OrchestratorFilters", () => {
|
||||||
|
it("notifies when tabs and search are updated", () => {
|
||||||
|
const { props } = renderFilters();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
|
||||||
|
expect(props.onTabChange).toHaveBeenCalledWith("applied");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Design" } });
|
||||||
|
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates source and sort selections", async () => {
|
||||||
|
const { props } = renderFilters();
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("menuitemradio", { name: /LinkedIn/i }));
|
||||||
|
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
|
||||||
|
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,3 +3,13 @@
|
|||||||
// expect(element).toHaveTextContent(/react/i)
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = ResizeObserver;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user