Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
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
|
|
*/
|
|
});
|