app/tests/coach-integration.spec.ts

431 lines
No EOL
13 KiB
TypeScript

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';
import {
membersSample,
skillsCatalogSample,
problemsCatalogSample,
sampleSelections
} from '~/sample/skillsToOffersSamples';
// 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('loads sample data and generates offers automatically', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
// Trigger sample data loading
await wrapper.vm.loadSampleData();
await nextTick();
// Wait for debounced offer generation
await new Promise(resolve => setTimeout(resolve, 350));
// Should have loaded sample members
expect(wrapper.vm.members).toEqual(membersSample);
// Should have pre-selected skills and problems
expect(wrapper.vm.selectedSkills).toEqual(sampleSelections.selectedSkillsByMember);
expect(wrapper.vm.selectedProblems).toEqual(sampleSelections.selectedProblems);
// Should have generated offers
expect(wrapper.vm.offers).toBeDefined();
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
});
it('handles "Use these" action correctly', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
// Load sample data and generate offers
await wrapper.vm.loadSampleData();
await nextTick();
await new Promise(resolve => setTimeout(resolve, 350));
// Ensure we have offers
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
const initialOffers = wrapper.vm.offers!;
// Trigger "Use these" action
await wrapper.vm.useOffers();
// Should have added streams to plan store
expect(planStore.streams.length).toBe(initialOffers.length);
// Verify streams are properly converted
planStore.streams.forEach((stream: any, index: number) => {
const originalOffer = initialOffers[index];
expect(stream.id).toBe(`offer-${originalOffer.id}`);
expect(stream.name).toBe(originalOffer.name);
expect(stream.unitPrice).toBe(originalOffer.price.baseline);
expect(stream.payoutDelayDays).toBe(originalOffer.payoutDelayDays);
expect(stream.feePercent).toBe(3);
expect(stream.notes).toBe(originalOffer.whyThis.join('. '));
});
});
});
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);
});
});
});