diff --git a/job-tracker-ui/src/ai-section-assistant.test.tsx b/job-tracker-ui/src/ai-section-assistant.test.tsx new file mode 100644 index 0000000..102b64c --- /dev/null +++ b/job-tracker-ui/src/ai-section-assistant.test.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { api } from "./api"; +import AiSectionAssistant from "./components/cv/AiSectionAssistant"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { ToastProvider } from "./toast"; + +jest.mock("./accountPlan", () => ({ useAccountPlan: () => ({ canUseAi: true }) })); +jest.mock("./api", () => ({ + api: { + get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (_error: unknown, fallback?: string) => fallback ?? "Request failed.", +})); + +const mockedApi = api as jest.Mocked; + +function renderAssistant(onApply = jest.fn()) { + render( + + + + + , + ); + return onApply; +} + +beforeEach(() => { + window.localStorage.removeItem("uiLanguage"); + jest.clearAllMocks(); +}); + +afterEach(() => window.localStorage.removeItem("uiLanguage")); + +test("section AI uses the CV document language and requires explicit apply", async () => { + mockedApi.post.mockResolvedValue({ data: { original: "Built reliable APIs", result: "Utviklet driftssikre API-er" } } as never); + const onApply = renderAssistant(); + + fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); + expect(screen.getByLabelText("Output language")).toHaveTextContent("Norsk bokmål"); + fireEvent.click(screen.getByRole("button", { name: "Suggest" })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/cv/ai/assist", expect.objectContaining({ + action: "improve", + language: "nb-NO", + text: "Built reliable APIs", + }))); + expect(onApply).not.toHaveBeenCalled(); + expect(await screen.findByDisplayValue("Utviklet driftssikre API-er")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(onApply).toHaveBeenCalledWith("Utviklet driftssikre API-er"); +}); + +test("section AI controls render in Bokmål without changing the document language", () => { + window.localStorage.setItem("uiLanguage", "nb"); + renderAssistant(); + + fireEvent.click(screen.getByRole("button", { name: "AI-forslag" })); + expect(screen.getByLabelText("AI-handling")).toBeInTheDocument(); + expect(screen.getByLabelText("Språk for resultat")).toHaveTextContent("Norsk bokmål"); + expect(screen.getByRole("button", { name: "Foreslå" })).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx b/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx index d93fcb5..ff38a87 100644 --- a/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx +++ b/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material"; @@ -8,23 +8,30 @@ import { useAccountPlan } from "../../accountPlan"; import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder"; import { useToast } from "../../toast"; import ProFeatureNotice from "../ProFeatureNotice"; +import { useI18n } from "../../i18n/I18nProvider"; type Props = { sectionName: string; text: string; context?: string; + documentLanguage?: string | null; onApply: (suggestion: string) => void; }; const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]); -export default function AiSectionAssistant({ sectionName, text, context, onApply }: Props) { +export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, onApply }: Props) { + const { t } = useI18n(); const { toast } = useToast(); const { canUseAi } = useAccountPlan(); const [open, setOpen] = useState(false); const [action, setAction] = useState("improve"); const [suggestion, setSuggestion] = useState(""); const [busy, setBusy] = useState(false); + const normalizedLanguage = documentLanguage === "no" || documentLanguage === "nb" ? "nb-NO" : documentLanguage ?? "en"; + const [targetLanguage, setTargetLanguage] = useState(normalizedLanguage); + + useEffect(() => setTargetLanguage(normalizedLanguage), [normalizedLanguage]); const run = async () => { if (!text.trim() || !canUseAi) return; @@ -33,11 +40,12 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply const response = await cvBuilderApi.aiAssist({ action, text, + language: targetLanguage, context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"), }); setSuggestion(response.result); } catch (error) { - toast(getApiErrorMessage(error, "AI suggestion failed."), "error"); + toast(getApiErrorMessage(error, t("cvAiFailed")), "error"); } finally { setBusy(false); } @@ -46,7 +54,7 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply if (!open) { return ( ); } @@ -54,10 +62,10 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply if (!canUseAi) { return ( - - AI only proposes wording; it never changes CV content without your approval. + + {t("cvAiProBody")} - + ); } @@ -66,25 +74,39 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply - setAction(event.target.value)} sx={{ minWidth: 190 }}> - {AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => {item.label})} + setAction(event.target.value)} sx={{ minWidth: 190 }}> + {AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => {actionLabel(item.key, item.label, t)})} - - + setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}> + English + Norsk bokmål + + + {suggestion && <> - Current + {t("cvAiCurrent")} {text} - setSuggestion(event.target.value)} /> - Check every fact before applying this suggestion. + setSuggestion(event.target.value)} /> + {t("cvAiCheckFacts")} - - + + } ); } + +type Translate = ReturnType["t"]; + +function actionLabel(key: string, fallback: string, t: Translate) { + const labels: Record = { + improve: t("cvAiImprove"), professional: t("cvAiProfessional"), shorten: t("cvAiShorten"), + grammar: t("cvAiGrammar"), impact: t("cvAiImpact"), bullets: t("cvAiBullets"), + }; + return labels[key] ?? fallback; +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index dd98348..5ceda24 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -406,6 +406,27 @@ export const translations = { cvDashboardChooseTemplate: "Choose a template", cvDashboardCreating: "Creating…", cvDashboardCreateCv: "Create CV", + cvAiFailed: "AI suggestion failed.", + cvAiSuggestions: "AI suggestions", + cvAiProTitle: "Refine this section with Pro.", + cvAiProBody: "AI only proposes wording; it never changes CV content without your approval.", + cvAiClose: "Close", + cvAiAction: "AI action", + cvAiLanguage: "Output language", + cvAiWorking: "Working…", + cvAiSuggest: "Suggest", + cvAiCurrent: "Current", + cvAiSuggested: "Suggested — edit before applying", + cvAiCheckFacts: "Check every fact before applying this suggestion.", + cvAiApplied: "Suggestion applied to {section}.", + cvAiApply: "Apply", + cvAiReject: "Reject", + cvAiImprove: "Improve writing", + cvAiProfessional: "Make more professional", + cvAiShorten: "Make shorter", + cvAiGrammar: "Fix grammar", + cvAiImpact: "Add measurable impact", + cvAiBullets: "Suggest bullet points", richTextBold: "Bold", richTextBoldPlaceholder: "bold text", richTextItalic: "Italic", @@ -2011,6 +2032,27 @@ export const translations = { cvDashboardChooseTemplate: "Velg en mal", cvDashboardCreating: "Oppretter…", cvDashboardCreateCv: "Opprett CV", + cvAiFailed: "AI-forslaget mislyktes.", + cvAiSuggestions: "AI-forslag", + cvAiProTitle: "Forbedre denne seksjonen med Pro.", + cvAiProBody: "AI foreslår bare formuleringer og endrer aldri CV-innhold uten godkjenning fra deg.", + cvAiClose: "Lukk", + cvAiAction: "AI-handling", + cvAiLanguage: "Språk for resultat", + cvAiWorking: "Arbeider…", + cvAiSuggest: "Foreslå", + cvAiCurrent: "Nåværende", + cvAiSuggested: "Forslag — rediger før du bruker det", + cvAiCheckFacts: "Kontroller alle fakta før du bruker dette forslaget.", + cvAiApplied: "Forslaget er brukt i {section}.", + cvAiApply: "Bruk", + cvAiReject: "Avvis", + cvAiImprove: "Forbedre teksten", + cvAiProfessional: "Gjør mer profesjonell", + cvAiShorten: "Gjør kortere", + cvAiGrammar: "Rett grammatikk", + cvAiImpact: "Legg til målbar effekt", + cvAiBullets: "Foreslå punkter", richTextBold: "Fet", richTextBoldPlaceholder: "fet tekst", richTextItalic: "Kursiv", diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 416ed9c..c2be152 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -780,7 +780,7 @@ function SectionRow({ : onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />} - onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} /> + onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} /> ) : outlineSection?.kind === "entries" ? ( @@ -788,7 +788,7 @@ function SectionRow({ onPatch({ items: value.split("\n") })} /> {row.items && } - onPatch({ items: value.split("\n") })} /> + onPatch({ items: value.split("\n") })} /> )} @@ -859,7 +859,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: { setOverride(key, { bullets: v.split("\n") })} /> - setOverride(key, { bullets: value.split("\n") })} /> + setOverride(key, { bullets: value.split("\n") })} /> {ov.bullets && ( )}