From 0b72911e34dec6da21182f919ecbde3a7ff44882 Mon Sep 17 00:00:00 2001 From: Sourav Chandhok <114119117+souravC01@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:16:37 -0400 Subject: [PATCH] fix(optimizer): resolve skill selection race condition and rebrand to BulletCraft Fix the stale closure race condition in BulletOptimizerModal by eliminating conflicting dual effects and passing explicit target keywords to handleGenerate. Conditionally mount the modal with a dynamic key in App.jsx to ensure a clean state on every skill selection, ensure candidate resume context is supplied on Job Analyzer clicks, and rebrand the feature header and buttons to BulletCraft. Add automated Vitest coverage for sequential skill selection and evidence grounding. --- frontend/src/App.jsx | 28 +-- .../src/components/BulletOptimizerModal.jsx | 61 +++--- frontend/src/components/JobAnalyzer.jsx | 11 +- frontend/src/components/SettingsModal.jsx | 2 +- .../src/test/BulletOptimizerFlow.test.jsx | 186 ++++++++++++++++++ 5 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 frontend/src/test/BulletOptimizerFlow.test.jsx 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) + }) +})