423 lines
No EOL
13 KiB
TypeScript
423 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';
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
}); |