diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b88786..5ea779b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -214,6 +214,8 @@ export default function App() { setOptimizerSectionType(sectionType || 'work_history'); if (targetResume) { setSelectedResumeForJob(targetResume); + } else if (!selectedResumeForJob && resumes.length > 0) { + setSelectedResumeForJob(resumes[0]); } setIsOptimizerOpen(true); }; @@ -464,7 +466,7 @@ export default function App() { setIsAuthOpen(true); return; } - handleOpenOptimizer(kw); + handleOpenOptimizer(kw, selectedResumeForJob || resumes[0] || null); }} onOpenCoverLetter={() => { if (!currentUser) { @@ -596,17 +598,19 @@ export default function App() { }} /> - setIsOptimizerOpen(false)} - initialKeywords={optimizerKeywords} - initialSectionType={optimizerSectionType} - targetJobTitle={currentJob?.title || 'Software Engineer'} - selectedResume={selectedResumeForJob} - onMarkSkillsAdded={(skills) => handleAdoptSkills(skills, selectedResumeForJob?.id)} - onAiError={handleAiError} - /> + {isOptimizerOpen && ( + setIsOptimizerOpen(false)} + initialKeywords={optimizerKeywords} + initialSectionType={optimizerSectionType} + targetJobTitle={currentJob?.title || 'Software Engineer'} + selectedResume={selectedResumeForJob} + onMarkSkillsAdded={(skills) => handleAdoptSkills(skills, selectedResumeForJob?.id)} + onAiError={handleAiError} + /> + )} { + if (initialKeywords && initialKeywords.length > 0) { + return [...initialKeywords]; + } + if (initialKeyword) { + return [initialKeyword]; + } + return []; + }; + + const [keywords, setKeywords] = useState(getInitialKeywords); const [newSkillInput, setNewSkillInput] = useState(''); - const [sectionType, setSectionType] = useState('work_history'); // 'work_history' or 'project' + const [sectionType, setSectionType] = useState(initialSectionType || 'work_history'); const [existingBullet, setExistingBullet] = useState(''); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); @@ -37,23 +47,12 @@ export default function BulletOptimizerModal({ const [addedConfirmed, setAddedConfirmed] = useState(false); useEffect(() => { - let kwList = []; - if (initialKeywords && initialKeywords.length > 0) { - kwList = [...initialKeywords]; - } else if (initialKeyword) { - kwList = [initialKeyword]; + const initKws = getInitialKeywords(); + const initSec = initialSectionType || 'work_history'; + if (initKws.length > 0) { + handleGenerate(null, null, initKws, initSec); } - setKeywords(kwList); - setSectionType(initialSectionType || 'work_history'); - setResult(null); - setExistingBullet(''); - }, [initialKeyword, initialKeywords, initialSectionType, isOpen]); - - useEffect(() => { - if (isOpen && keywords.length > 0 && !result && !loading) { - handleGenerate(); - } - }, [isOpen, keywords]); + }, []); if (!isOpen) return null; @@ -70,9 +69,17 @@ export default function BulletOptimizerModal({ } }; - const handleGenerate = async (e, overrideBullet = null) => { + const handleGenerate = async ( + e = null, + overrideBullet = null, + targetKeywords = null, + targetSectionType = null + ) => { e?.preventDefault(); - if (keywords.length === 0) { + const effectiveKeywords = targetKeywords !== null ? targetKeywords : keywords; + const effectiveSectionType = targetSectionType !== null ? targetSectionType : sectionType; + + if (!effectiveKeywords || effectiveKeywords.length === 0) { setError('Please add at least one target skill to incorporate.'); return; } @@ -89,9 +96,9 @@ export default function BulletOptimizerModal({ const res = await optimizeBullet({ target_job_title: targetJobTitle || 'Software Engineer', - section_type: sectionType, - target_keyword: keywords.join(', '), - target_keywords: keywords, + section_type: effectiveSectionType, + target_keyword: effectiveKeywords.join(', '), + target_keywords: effectiveKeywords, existing_bullet: bulletToUse.trim(), evidence_context: evidenceContext, }); @@ -103,7 +110,7 @@ export default function BulletOptimizerModal({ } catch (err) { setError(err.message || 'Failed to generate optimized bullet points.'); if (onAiError && (err.canSwitchOffline || err.status === 502)) { - onAiError(err, () => handleGenerate(null, bulletToUse)); + onAiError(err, () => handleGenerate(null, bulletToUse, effectiveKeywords, effectiveSectionType)); } } finally { setLoading(false); @@ -113,7 +120,7 @@ export default function BulletOptimizerModal({ const handleSelectDifferentBullet = (bulletObj) => { setExistingBullet(bulletObj.bullet); setShowBulletPicker(false); - handleGenerate(null, bulletObj.bullet); + handleGenerate(null, bulletObj.bullet, keywords, sectionType); }; const handleCopy = (text, idx) => { @@ -140,7 +147,7 @@ export default function BulletOptimizerModal({

- BulletSkill 2.0 Resume Bullet Optimizer + BulletCraft Resume Optimizer

Incorporate target skills into {sectionType === 'project' ? 'Project' : 'Work History'} bullet points @@ -217,6 +224,7 @@ export default function BulletOptimizerModal({ onClick={() => { setSectionType('project'); setResult(null); + handleGenerate(null, null, keywords, 'project'); }} className={`flex items-center gap-1.5 py-1 px-3.5 rounded-full font-semibold transition-all ${ sectionType === 'project' @@ -232,6 +240,7 @@ export default function BulletOptimizerModal({ onClick={() => { setSectionType('work_history'); setResult(null); + handleGenerate(null, null, keywords, 'work_history'); }} className={`flex items-center gap-1.5 py-1 px-3.5 rounded-full font-semibold transition-all ${ sectionType === 'work_history' diff --git a/frontend/src/components/JobAnalyzer.jsx b/frontend/src/components/JobAnalyzer.jsx index 5dd2a6b..9a16ed5 100644 --- a/frontend/src/components/JobAnalyzer.jsx +++ b/frontend/src/components/JobAnalyzer.jsx @@ -319,11 +319,18 @@ export default function JobAnalyzer({ {/* Action Buttons */}

diff --git a/frontend/src/test/BulletOptimizerFlow.test.jsx b/frontend/src/test/BulletOptimizerFlow.test.jsx new file mode 100644 index 0000000..0f9d1f1 --- /dev/null +++ b/frontend/src/test/BulletOptimizerFlow.test.jsx @@ -0,0 +1,186 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import BulletOptimizerModal from '../components/BulletOptimizerModal' +import * as apiClient from '../api/client' + +vi.mock('../api/client', () => ({ + optimizeBullet: vi.fn(), +})) + +describe('BulletCraft Resume Optimizer Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const mockResponse = { + status: 'rewritten', + target_keyword: 'Docker', + target_keywords: ['Docker'], + claim_status: 'VERIFIED', + selected_bullet_index: 0, + target_project_name: 'Cloud Infrastructure', + original_bullet_to_replace: 'Deployed microservices to servers', + replacement_rationale: 'Incorporates Docker to highlight containerization skills.', + alternatives: [ + { + variant_name: 'Candidate A (ATS-focused)', + bullet: 'Containerized microservices with Docker to streamline deployments and enhance system scalability.', + what: 'Docker', + how: 'Containerized microservices', + result_or_reason: 'Streamline deployments and enhance scalability', + claim_status: 'VERIFIED', + requires_confirmation: false, + }, + ], + validation: { + past_tense: true, + one_sentence: true, + one_period_max: true, + what_how_result_present: true, + keyword_stuffing: false, + }, + } + + it('renders BulletCraft branding in the modal header', async () => { + apiClient.optimizeBullet.mockResolvedValue(mockResponse) + + render( + + ) + + expect(screen.getByText('BulletCraft Resume Optimizer')).toBeInTheDocument() + }) + + it('generates recommendations immediately for the selected skill on mount', async () => { + apiClient.optimizeBullet.mockResolvedValue(mockResponse) + + render( + + ) + + await waitFor(() => { + expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(1) + }) + + expect(apiClient.optimizeBullet).toHaveBeenCalledWith( + expect.objectContaining({ + target_keyword: 'Docker', + target_keywords: ['Docker'], + section_type: 'work_history', + target_job_title: 'DevOps Engineer', + }) + ) + }) + + it('passes selected resume content as evidence context to the optimizer', async () => { + apiClient.optimizeBullet.mockResolvedValue(mockResponse) + + const resume = { + id: 'res-1', + name: 'Senior Resume', + content: 'Experienced engineer with Docker and Kubernetes background.', + } + + render( + + ) + + await waitFor(() => { + expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(1) + }) + + expect(apiClient.optimizeBullet).toHaveBeenCalledWith( + expect.objectContaining({ + evidence_context: [resume.content], + }) + ) + }) + + it('re-generates with project section type when user toggles to Project Bullet', async () => { + apiClient.optimizeBullet.mockResolvedValue(mockResponse) + + render( + + ) + + await waitFor(() => { + expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(1) + }) + + const projectTabBtn = screen.getByRole('button', { name: /Project Bullet/i }) + fireEvent.click(projectTabBtn) + + await waitFor(() => { + expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(2) + }) + + expect(apiClient.optimizeBullet).toHaveBeenLastCalledWith( + expect.objectContaining({ + target_keywords: ['PostgreSQL'], + section_type: 'project', + }) + ) + }) + + it('properly targets sequential skills when opening skill A then skill B', async () => { + apiClient.optimizeBullet.mockResolvedValue(mockResponse) + + // First open for Python + const { unmount } = render( + + ) + + await waitFor(() => { + expect(apiClient.optimizeBullet).toHaveBeenCalledWith( + expect.objectContaining({ target_keywords: ['Python'] }) + ) + }) + + // Simulate modal close and unmount + unmount() + + // Second open for Kubernetes + render( + + ) + + await waitFor(() => { + expect(apiClient.optimizeBullet).toHaveBeenLastCalledWith( + expect.objectContaining({ target_keywords: ['Kubernetes'] }) + ) + }) + + expect(apiClient.optimizeBullet).toHaveBeenCalledTimes(2) + }) +})