import { describe, it, expect } from 'vitest'; import fc from 'fast-check'; import { selectRelevant } from './relevance-selection'; import { generateTandemData } from './tandem-generator'; import type { Experience, Skill, Project, Person, Tandem } from '../schemas/types'; // --- Arbitraries --- const kebabCaseId = fc .array( fc.stringMatching(/^[a-z0-9]{1,8}$/), { minLength: 1, maxLength: 4 } ) .map(parts => parts.join('-')); const isoDate = fc .record({ year: fc.integer({ min: 1990, max: 2030 }), month: fc.integer({ min: 1, max: 12 }), }) .map(({ year, month }) => `${year}-${String(month).padStart(2, '0')}`); const skillCategory = fc.constantFrom( 'programming-language' as const, 'framework' as const, 'methodology' as const, 'soft-skill' as const, 'domain' as const ); const skillLevel = fc.constantFrom( 'beginner' as const, 'intermediate' as const, 'advanced' as const, 'expert' as const ); /** Generates a Skill entity */ const skillArb = fc.record({ id: kebabCaseId, created: isoDate, modified: isoDate, name: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0), category: skillCategory, level: skillLevel, }).map(f => ({ id: f.id, type: 'skill' as const, created: f.created, modified: f.modified, name: f.name, category: f.category, level: f.level, })); /** Generates an Experience entity */ const experienceArb = fc.record({ id: kebabCaseId, created: isoDate, modified: isoDate, title: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), organization: kebabCaseId, start: isoDate, description: fc.string({ minLength: 0, maxLength: 50 }), skillsUsed: fc.array(fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0), { minLength: 0, maxLength: 5 }), }).map(f => ({ id: f.id, type: 'experience' as const, created: f.created, modified: f.modified, title: f.title, organization: f.organization, start: f.start, end: null, description: f.description, skillsUsed: f.skillsUsed, })); /** Generates a Project entity */ const projectArb = fc.record({ id: kebabCaseId, created: isoDate, modified: isoDate, name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), organization: kebabCaseId, start: isoDate, description: fc.string({ minLength: 0, maxLength: 50 }), skillsUsed: fc.array(fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0), { minLength: 0, maxLength: 5 }), }).map(f => ({ id: f.id, type: 'project' as const, created: f.created, modified: f.modified, name: f.name, organization: f.organization, start: f.start, description: f.description, skillsUsed: f.skillsUsed, })); /** Generates a Person entity */ const personArb = fc.record({ id: kebabCaseId, created: isoDate, modified: isoDate, firstName: fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0), lastName: fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0), summary: fc.string({ minLength: 0, maxLength: 50 }), }).map(f => ({ id: f.id, type: 'person' as const, created: f.created, modified: f.modified, name: { first: f.firstName, last: f.lastName, display: `${f.firstName} ${f.lastName}` }, summary: f.summary || undefined, skills: [] as string[], experiences: [] as string[], })); /** Generates a Tandem entity referencing two partner IDs */ const tandemArb = (partner1Id: string, partner2Id: string) => fc.record({ id: kebabCaseId, created: isoDate, modified: isoDate, sharedVision: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), complementarySkills: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), collaborationModel: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), combinedNarrative: fc.string({ minLength: 0, maxLength: 50 }), jointCompetencies: fc.array(fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0), { minLength: 0, maxLength: 3 }), }).map(f => ({ id: f.id, type: 'tandem' as const, created: f.created, modified: f.modified, partners: [partner1Id, partner2Id] as [string, string], sharedVision: f.sharedVision, complementarySkills: f.complementarySkills, collaborationModel: f.collaborationModel, combinedNarrative: f.combinedNarrative || undefined, jointCompetencies: f.jointCompetencies.length > 0 ? f.jointCompetencies : undefined, })); // --- Property 9: CV relevance selection ordering --- describe('Property 9: CV relevance selection ordering', () => { it('matching items have score > 0 and appear before score-0 items in the output', () => { fc.assert( fc.property( fc.array(fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0), { minLength: 1, maxLength: 5 }), fc.array(experienceArb, { minLength: 0, maxLength: 5 }), fc.array(skillArb, { minLength: 0, maxLength: 5 }), fc.array(projectArb, { minLength: 0, maxLength: 5 }), (requirements, experiences, skills, projects) => { const results = selectRelevant(requirements, experiences, skills, projects); // All items with matchedRequirements.length > 0 must have score > 0 for (const r of results) { if (r.matchedRequirements.length > 0) { expect(r.score).toBeGreaterThan(0); } } // Items are sorted: all score > 0 items appear before score === 0 items let seenZero = false; for (const r of results) { if (r.score === 0) { seenZero = true; } else if (seenZero) { // A non-zero score item appeared after a zero-score item — ordering violated expect(seenZero && r.score > 0).toBe(false); } } // Results are sorted by score descending for (let i = 1; i < results.length; i++) { expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score); } } ), { numRuns: 100 } ); }); /** * Validates: Requirements 5.1, 5.2 */ }); // --- Property 10: Tandem application document set (exactly 3 docs) --- describe('Property 10: Tandem application document set', () => { it('generateTandemData produces data with exactly 2 partner profiles referencing both partners', () => { fc.assert( fc.property( personArb, personArb, fc.array(experienceArb, { minLength: 0, maxLength: 3 }), fc.array(experienceArb, { minLength: 0, maxLength: 3 }), fc.array(skillArb, { minLength: 0, maxLength: 3 }), fc.array(skillArb, { minLength: 0, maxLength: 3 }), (partner1, partner2, exp1, exp2, skills1, skills2) => { // Ensure distinct partner IDs const p1 = { ...partner1, id: partner1.id + '-p1' }; const p2 = { ...partner2, id: partner2.id + '-p2' }; const tandem: Tandem = { id: 'tandem-test', type: 'tandem', created: '2025-01-01', modified: '2025-01-01', partners: [p1.id, p2.id], sharedVision: 'Shared vision text', complementarySkills: 'Complementary skills text', collaborationModel: 'Collaboration model text', }; const result = generateTandemData( tandem, p1 as Person, p2 as Person, exp1, exp2, skills1, skills2 ); // Exactly 2 partner profiles in the output expect(result.partnerProfiles).toHaveLength(2); // The tandem CV references both partners expect(result.partners).toContain(p1.id); expect(result.partners).toContain(p2.id); // Each partner profile corresponds to one of the partners const profileIds = result.partnerProfiles.map(p => p.personId); expect(profileIds).toContain(p1.id); expect(profileIds).toContain(p2.id); // The tandem data contains shared information from both profiles expect(result.sharedVision).toBeTruthy(); expect(result.complementarySkills).toBeTruthy(); expect(result.collaborationModel).toBeTruthy(); } ), { numRuns: 100 } ); }); /** * Validates: Requirements 5.3, 5.4 */ }); // --- Property 11: CV output validity and traceability --- describe('Property 11: CV output validity and traceability', () => { it('all data in TandemDocumentData traces back to input entities', () => { fc.assert( fc.property( personArb, personArb, fc.array(experienceArb, { minLength: 1, maxLength: 4 }), fc.array(experienceArb, { minLength: 1, maxLength: 4 }), fc.array(skillArb, { minLength: 1, maxLength: 4 }), fc.array(skillArb, { minLength: 1, maxLength: 4 }), (partner1, partner2, exp1, exp2, skills1, skills2) => { const p1 = { ...partner1, id: partner1.id + '-p1' }; const p2 = { ...partner2, id: partner2.id + '-p2' }; const tandem: Tandem = { id: 'tandem-trace', type: 'tandem', created: '2025-01-01', modified: '2025-01-01', partners: [p1.id, p2.id], sharedVision: 'Vision', complementarySkills: 'Skills complement', collaborationModel: 'Model', combinedNarrative: 'Narrative', jointCompetencies: ['competency-1', 'competency-2'], }; const result = generateTandemData( tandem, p1 as Person, p2 as Person, exp1, exp2, skills1, skills2 ); // All skill names in partner profiles must come from input skills const allInputSkillNames = [...skills1, ...skills2].map(s => s.name); for (const profile of result.partnerProfiles) { for (const skillName of profile.topSkills) { expect(allInputSkillNames).toContain(skillName); } } // All experience titles in partner profiles must come from input experiences const allInputExpTitles = [...exp1, ...exp2].map(e => e.title); for (const profile of result.partnerProfiles) { for (const exp of profile.topExperiences) { expect(allInputExpTitles).toContain(exp.title); } } // All organization references in experiences must come from input experiences const allInputOrgs = [...exp1, ...exp2].map(e => e.organization); for (const profile of result.partnerProfiles) { for (const exp of profile.topExperiences) { expect(allInputOrgs).toContain(exp.organization); } } // Display names must come from input persons const inputDisplayNames = [p1.name.display, p2.name.display]; for (const profile of result.partnerProfiles) { expect(inputDisplayNames).toContain(profile.displayName); } // Tandem-level data must trace back to the tandem entity expect(result.sharedVision).toBe(tandem.sharedVision); expect(result.complementarySkills).toBe(tandem.complementarySkills); expect(result.collaborationModel).toBe(tandem.collaborationModel); expect(result.combinedNarrative).toBe(tandem.combinedNarrative); expect(result.jointCompetencies).toEqual(tandem.jointCompetencies); } ), { numRuns: 100 } ); }); /** * Validates: Requirements 5.5, 5.6 */ });