feat(ai): AI Workspace panel on every job application
Phase 5 frontend. A new "AI Workspace" tab in the job details dialog hosts the five suggestion modules (Job Analysis, Career Match, Cover Letter with tone, Interview Prep, Application Review) with a generate flow, a dependency-free markdown renderer for results, and a history sidebar (reuse / compare / copy / delete). Everything is suggestion-only — copy to keep; nothing auto-applies. - aiWorkspace.ts (types + API), components/AiWorkspacePanel.tsx, components/Markdown.tsx (no HTML injection surface — renders React nodes) - mounted as the last tab in JobDetailsDialog (index-safe, no reindexing) - 3 tests (generate flow, cover-letter mode, markdown); tsc + build clean Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import AiWorkspacePanel from "./components/AiWorkspacePanel";
|
||||
import Markdown from "./components/Markdown";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<AiWorkspacePanel jobId={7} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url.includes("/modules")) return Promise.resolve({ data: { modules: [], provider: "gemini" } } as any);
|
||||
return Promise.resolve({ data: [] } as any); // history
|
||||
});
|
||||
});
|
||||
|
||||
test("renders modules and generates a suggestion into history", async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { id: 1, module: "job-analysis", mode: null, title: "Job analysis", provider: "gemini", result: { text: "**Company**\nAcme" }, createdAtUtc: new Date().toISOString() },
|
||||
} as any);
|
||||
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Generate/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "job-analysis" })));
|
||||
expect(await screen.findByText("Acme")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cover letter sends the selected mode", async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { id: 2, module: "cover-letter", mode: "professional", title: "Cover letter · Professional", provider: "p", result: { text: "Dear team" }, createdAtUtc: new Date().toISOString() },
|
||||
} as any);
|
||||
|
||||
renderPanel();
|
||||
fireEvent.click(await screen.findByText("Cover Letter"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Generate/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", mode: "professional" })));
|
||||
});
|
||||
|
||||
test("Markdown renders headings, bold, and bullet lists", () => {
|
||||
render(<Markdown text={"# Title\n**Strengths**\n- one\n- two\nplain *word*"} />);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Strengths")).toBeInTheDocument();
|
||||
expect(screen.getByText("one")).toBeInTheDocument();
|
||||
expect(screen.getByText("two")).toBeInTheDocument();
|
||||
});
|
||||
Reference in New Issue
Block a user