Files
Nexus/packages/staffing/src/__tests__/skill-matcher.test.ts
T

141 lines
4.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
computeAvailabilityScore,
computeCostScore,
computeSkillScore,
computeUtilizationScore,
rankResources,
} from "../skill-matcher.js";
const mockSkills = [
{ skill: "TypeScript", proficiency: 5 as const, yearsExperience: 5 },
{ skill: "React", proficiency: 4 as const, yearsExperience: 3 },
{ skill: "Node.js", proficiency: 3 as const, yearsExperience: 2 },
];
describe("computeSkillScore", () => {
it("returns 100 for no required skills", () => {
const result = computeSkillScore(mockSkills, []);
expect(result.score).toBe(100);
});
it("scores correctly for full match with max proficiency", () => {
const result = computeSkillScore(mockSkills, ["TypeScript"]);
// TypeScript proficiency 5/5 = 1.0, required portion = 70%, preferred = 30% (none)
expect(result.score).toBeGreaterThan(90);
expect(result.matchedRequired).toContain("TypeScript");
expect(result.missing).toHaveLength(0);
});
it("scores lower for missing required skills", () => {
const result = computeSkillScore(mockSkills, ["Python"]);
expect(result.score).toBeLessThan(50);
expect(result.missing).toContain("Python");
});
it("matches skills case-insensitively", () => {
const result = computeSkillScore(mockSkills, ["typescript"]);
expect(result.matchedRequired).toHaveLength(1);
});
it("accounts for preferred skills: matched preferred yields high score", () => {
const withPref = computeSkillScore(mockSkills, ["TypeScript"], ["React"]);
const withoutPref = computeSkillScore(mockSkills, ["TypeScript"], []);
// Both should score high; preferred matching is reflected in breakdown
expect(withPref.score).toBeGreaterThanOrEqual(80);
expect(withoutPref.score).toBe(100);
expect(withPref.matchedPreferred).toContain("React");
});
});
describe("computeAvailabilityScore", () => {
it("returns 100 for no conflicts", () => {
expect(computeAvailabilityScore(false)).toBe(100);
});
it("reduces score by 10 per conflict day", () => {
expect(computeAvailabilityScore(true, 5)).toBe(50);
});
it("floors at 0", () => {
expect(computeAvailabilityScore(true, 15)).toBe(0);
});
});
describe("computeCostScore", () => {
it("returns 50 for no budget defined", () => {
expect(computeCostScore(10000)).toBe(50);
});
it("returns 100 when resource cost is at budget", () => {
expect(computeCostScore(10000, 10000)).toBe(100);
});
it("returns 100 when resource cost is below budget", () => {
expect(computeCostScore(8000, 10000)).toBe(100);
});
it("penalizes resources above budget", () => {
const score = computeCostScore(15000, 10000); // 1.5x budget
expect(score).toBeLessThan(100);
expect(score).toBeGreaterThanOrEqual(0);
});
});
describe("computeUtilizationScore", () => {
it("returns 100 for well below target (20+ gap)", () => {
expect(computeUtilizationScore(50, 80)).toBe(100);
});
it("returns 0 for severely overallocated", () => {
expect(computeUtilizationScore(110, 80)).toBe(0);
});
});
describe("rankResources", () => {
const resources = [
{
id: "r1",
displayName: "Alice",
eid: "EMP-001",
skills: mockSkills,
lcrCents: 8000,
chargeabilityTarget: 80,
currentUtilizationPercent: 50,
hasAvailabilityConflicts: false,
},
{
id: "r2",
displayName: "Bob",
eid: "EMP-002",
skills: [{ skill: "Python", proficiency: 4 as const }],
lcrCents: 12000,
chargeabilityTarget: 80,
currentUtilizationPercent: 90,
hasAvailabilityConflicts: true,
conflictDays: ["2025-01-06"],
},
];
it("returns suggestions sorted by score descending", () => {
const results = rankResources({
requiredSkills: ["TypeScript"],
resources,
});
expect(results[0]?.score).toBeGreaterThanOrEqual(results[1]?.score ?? 0);
});
it("Alice ranks higher than Bob for TypeScript requirement", () => {
const results = rankResources({
requiredSkills: ["TypeScript"],
resources,
});
expect(results[0]?.resourceId).toBe("r1");
});
it("returns all resources", () => {
const results = rankResources({ requiredSkills: [], resources });
expect(results).toHaveLength(2);
});
});