import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { nextTick } from 'vue'; // Import components and utilities import CoachSkillsToOffers from '~/pages/coach/skills-to-offers.vue'; import WizardRevenueStep from '~/components/WizardRevenueStep.vue'; import { useOfferSuggestor } from '~/composables/useOfferSuggestor'; import { usePlanStore } from '~/stores/plan'; import { offerToStream, offersToStreams } from '~/utils/offerToStream'; // Create inline test data to replace removed sample imports const membersSample = [ { id: "1", name: "Maya Chen", role: "Designer", hourly: 32, availableHrs: 20 }, { id: "2", name: "Alex Rodriguez", role: "Developer", hourly: 35, availableHrs: 30 }, { id: "3", name: "Jordan Kim", role: "Writer", hourly: 28, availableHrs: 15 } ]; const skillsCatalogSample = [ { id: "design", label: "UI/UX Design" }, { id: "writing", label: "Technical Writing" }, { id: "development", label: "Web Development" } ]; const problemsCatalogSample = [ { id: "unclear-pitch", label: "Unclear value proposition", examples: ["Need better messaging", "Confusing product pitch"] } ]; const sampleSelections = { selectedSkillsByMember: { "1": ["design"], "3": ["writing"] }, selectedProblems: ["unclear-pitch"] }; // Mock router vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })); // Mock stores const mockStreamsStore = { streams: [], upsertStream: vi.fn(), removeStream: vi.fn() }; vi.mock('~/stores/streams', () => ({ useStreamsStore: () => mockStreamsStore })); describe('Coach Integration Tests', () => { let pinia: any; let planStore: any; beforeEach(() => { pinia = createPinia(); setActivePinia(pinia); planStore = usePlanStore(); // Reset stores planStore.members = []; planStore.streams = []; mockStreamsStore.streams = []; // Clear mocks vi.clearAllMocks(); }); describe('Offer Generation with Sample Data', () => { it('generates "Pitch Polish (2 days)" offer for Design+Writing and "Unclear pitch"', () => { const { suggestOffers } = useOfferSuggestor(); // Setup input with sample data const input = { members: membersSample, selectedSkillsByMember: { [membersSample[0].id]: ['design'], // Maya: Design [membersSample[2].id]: ['writing'] // Jordan: Writing }, selectedProblems: ['unclear-pitch'] }; const catalogs = { skills: skillsCatalogSample, problems: problemsCatalogSample }; // Generate offers const offers = suggestOffers(input, catalogs); // Should generate at least one offer expect(offers.length).toBeGreaterThan(0); // Should include Pitch Polish offer const pitchPolishOffer = offers.find(offer => offer.name.includes('Pitch Polish') && offer.name.includes('2 days') ); expect(pitchPolishOffer).toBeDefined(); expect(pitchPolishOffer?.name).toBe('Pitch Polish (2 days)'); }); it('calculates baseline price using correct formula: sum(hours*hourly*1.25) * 1.10', () => { const { suggestOffers } = useOfferSuggestor(); const input = { members: membersSample, selectedSkillsByMember: { [membersSample[0].id]: ['design'], // Maya: 32€/h [membersSample[2].id]: ['writing'] // Jordan: 28€/h }, selectedProblems: ['unclear-pitch'] }; const catalogs = { skills: skillsCatalogSample, problems: problemsCatalogSample }; const offers = suggestOffers(input, catalogs); const pitchPolishOffer = offers.find(offer => offer.name.includes('Pitch Polish') ); expect(pitchPolishOffer).toBeDefined(); // Calculate expected price manually // Pitch Polish is 2 days = 16 hours total (8 hours per day) // Assume hours are distributed between Maya and Jordan let expectedCost = 0; for (const allocation of pitchPolishOffer!.hoursByMember) { const member = membersSample.find(m => m.id === allocation.memberId); if (member) { expectedCost += allocation.hours * member.hourly * 1.25; } } const expectedBaseline = Math.round(expectedCost * 1.10); // Allow for ±1 rounding difference expect(pitchPolishOffer!.price.baseline).toBeCloseTo(expectedBaseline, 0); expect(Math.abs(pitchPolishOffer!.price.baseline - expectedBaseline)).toBeLessThanOrEqual(1); }); it('generates offers that include required whyThis and riskNotes', () => { const { suggestOffers } = useOfferSuggestor(); const input = { members: membersSample, selectedSkillsByMember: sampleSelections.selectedSkillsByMember, selectedProblems: sampleSelections.selectedProblems }; const catalogs = { skills: skillsCatalogSample, problems: problemsCatalogSample }; const offers = suggestOffers(input, catalogs); expect(offers.length).toBeGreaterThan(0); offers.forEach(offer => { expect(offer.whyThis).toBeDefined(); expect(offer.whyThis.length).toBeGreaterThan(0); expect(offer.riskNotes).toBeDefined(); expect(offer.riskNotes.length).toBeGreaterThan(0); expect(offer.price.calcNote).toBeDefined(); expect(offer.price.calcNote.length).toBeGreaterThan(0); }); }); }); describe('Coach Page Integration', () => { it('starts with empty data by default', async () => { const wrapper = mount(CoachSkillsToOffers, { global: { plugins: [pinia] } }); // Should start with empty data expect(wrapper.vm.members).toEqual([]); expect(wrapper.vm.availableSkills).toEqual([]); expect(wrapper.vm.availableProblems).toEqual([]); expect(wrapper.vm.offers).toBeNull(); }); it('handles empty state gracefully with no offers generated', async () => { const wrapper = mount(CoachSkillsToOffers, { global: { plugins: [pinia] } }); // Wait for any potential async operations await nextTick(); await new Promise(resolve => setTimeout(resolve, 100)); // Should have no offers with empty data expect(wrapper.vm.offers).toBeNull(); expect(wrapper.vm.canRegenerate).toBe(false); }); }); describe('Stream Conversion and Validation', () => { it('converts offers to streams with correct structure', () => { const mockOffer = { id: 'test-offer', name: 'Test Offer (5 days)', scope: ['Test scope item'], hoursByMember: [ { memberId: membersSample[0].id, hours: 20 }, { memberId: membersSample[1].id, hours: 15 } ], price: { baseline: 2000, stretch: 2400, calcNote: 'Test calculation' }, payoutDelayDays: 30, whyThis: ['Test reason 1', 'Test reason 2'], riskNotes: ['Test risk'] }; const stream = offerToStream(mockOffer, membersSample); expect(stream.id).toBe('offer-test-offer'); expect(stream.name).toBe('Test Offer (5 days)'); expect(stream.unitPrice).toBe(2000); expect(stream.payoutDelayDays).toBe(30); expect(stream.feePercent).toBe(3); expect(stream.notes).toBe('Test reason 1. Test reason 2'); expect(stream.restrictions).toBe('General'); expect(stream.certainty).toBe('Probable'); }); it('handles batch conversion correctly', () => { const offers = [ { id: 'offer1', name: 'Workshop Offer', scope: [], hoursByMember: [{ memberId: membersSample[0].id, hours: 8 }], price: { baseline: 800, stretch: 960, calcNote: 'Workshop calc' }, payoutDelayDays: 14, whyThis: ['Quick workshop'], riskNotes: ['Time constraint'] }, { id: 'offer2', name: 'Sprint Offer', scope: [], hoursByMember: [{ memberId: membersSample[1].id, hours: 40 }], price: { baseline: 2000, stretch: 2400, calcNote: 'Sprint calc' }, payoutDelayDays: 45, whyThis: ['Full development'], riskNotes: ['Scope creep'] } ]; const streams = offersToStreams(offers, membersSample); expect(streams.length).toBe(2); expect(streams[0].name).toBe('Workshop Offer'); expect(streams[1].name).toBe('Sprint Offer'); expect(streams[0].unitsPerMonth).toBe(1); // Workshop expect(streams[1].unitsPerMonth).toBe(0); // Sprint }); }); describe('Wizard Integration', () => { it('displays coach streams with proper highlighting', () => { // Add coach streams to plan store planStore.streams = [ { id: 'offer-test', name: 'Test Coach Offer', unitPrice: 1500, unitsPerMonth: 0, // Needs setup payoutDelayDays: 30, feePercent: 3, notes: 'AI-suggested offer', targetMonthlyAmount: 0, category: 'services' } ]; const wrapper = mount(WizardRevenueStep, { global: { plugins: [pinia] } }); // Should show coach stream with special styling const coachStreamElements = wrapper.findAll('[data-testid="coach-stream"]'); expect(coachStreamElements.length).toBeGreaterThan(0); // Should show hint about setting units expect(wrapper.text()).toContain('Setting just 1 unit per offer'); }); it('allows proceeding to Review when unitsPerMonth is set to 1', () => { // Setup coach stream with units set planStore.streams = [ { id: 'offer-test', name: 'Test Coach Offer', unitPrice: 1500, unitsPerMonth: 1, payoutDelayDays: 30, feePercent: 3, targetMonthlyAmount: 1500, category: 'services' } ]; const wrapper = mount(WizardRevenueStep, { global: { plugins: [pinia] } }); // Should not show setup hint when units are configured expect(wrapper.text()).not.toContain('Setting just 1 unit per offer'); // Should have proper monthly amount calculated const stream = planStore.streams[0]; expect(stream.targetMonthlyAmount).toBe(1500); // 1500 * 1 }); it('calculates summary metrics correctly', () => { planStore.streams = [ { id: 'offer-1', targetMonthlyAmount: 3000, payoutDelayDays: 30 }, { id: 'offer-2', targetMonthlyAmount: 2000, payoutDelayDays: 45 } ]; const wrapper = mount(WizardRevenueStep, { global: { plugins: [pinia] } }); // Top source should be 60% (3000 out of 5000 total) expect(wrapper.vm.getTopSourcePercentage()).toBe(60); // Weighted payout should be 36 days ((3000*30 + 2000*45) / 5000) expect(wrapper.vm.getWeightedPayoutDelay()).toBe(36); }); }); describe('Error Handling and Edge Cases', () => { it('handles empty selections gracefully', () => { const { suggestOffers } = useOfferSuggestor(); const input = { members: membersSample, selectedSkillsByMember: {}, selectedProblems: [] }; const catalogs = { skills: skillsCatalogSample, problems: problemsCatalogSample }; const offers = suggestOffers(input, catalogs); // Should return empty array for invalid input expect(offers).toEqual([]); }); it('handles missing members gracefully', () => { const mockOffer = { id: 'test', name: 'Test', scope: [], hoursByMember: [{ memberId: 'nonexistent', hours: 10 }], price: { baseline: 1000, stretch: 1200, calcNote: 'Test' }, payoutDelayDays: 30, whyThis: ['Test'], riskNotes: ['Test'] }; expect(() => { offerToStream(mockOffer, membersSample); }).toThrow('Member not found: nonexistent'); }); it('handles zero amounts in calculations', () => { planStore.streams = [ { id: 'test-1', targetMonthlyAmount: 0, payoutDelayDays: 30 } ]; const wrapper = mount(WizardRevenueStep, { global: { plugins: [pinia] } }); expect(wrapper.vm.getTopSourcePercentage()).toBe(0); expect(wrapper.vm.getWeightedPayoutDelay()).toBe(0); }); }); });