Files
jobtrackingapp/JobTrackerApi.Tests/JobCvMatchServiceTests.cs
T
2026-08-09 18:47:21 +02:00

197 lines
8.6 KiB
C#

using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobCvMatchServiceTests
{
private readonly JobCvMatchService _service = new();
private static Dictionary<string, string> Sections(params (string Name, string Text)[] items)
=> items.ToDictionary(i => i.Name, i => i.Text, StringComparer.OrdinalIgnoreCase);
public static IEnumerable<object[]> QualityFixtures()
{
yield return new object[]
{
"Norwegian",
"Senior systemutvikler",
"Vi ser etter deg som har erfaring med C# og ASP.NET Core. Du vil designe skalerbare distribuerte systemer. Gode samarbeidsevner er en fordel.",
new[] { "C#", "ASP.NET Core", "designe skalerbare distribuerte systemer" },
new[] { "med", "til", "for", "som", "erfaring", "ser" },
};
yield return new object[]
{
"English",
"Cloud platform engineer",
"We need a candidate with experience in AWS and Terraform. You will lead incident response and operate distributed systems.",
new[] { "AWS", "Terraform", "incident response", "operate distributed systems" },
new[] { "the", "with", "experience", "candidate", "will" },
};
yield return new object[]
{
"Mixed Norwegian and English",
"DevOps-utvikler",
"Du vil jobbe med Node.js og CI/CD. Work closely with Azure DevOps and cross-functional product teams.",
new[] { "Node.js", "CI/CD", "Azure DevOps", "Collaboration" },
new[] { "og", "med", "with", "teams" },
};
yield return new object[]
{
"Short",
"Data developer",
"Python and SQL.",
new[] { "Python", "SQL" },
new[] { "and" },
};
yield return new object[]
{
"Noisy HTML",
"Frontend developer",
"<nav>Home Jobs Login</nav><script>trackingCookie('React')</script><main>Build accessible web applications with React and TypeScript.</main><footer>Cookie settings Privacy Terms</footer>",
new[] { "React", "TypeScript", "Build accessible web applications" },
new[] { "home", "jobs", "login", "cookie", "settings", "privacy", "terms", "trackingcookie" },
};
yield return new object[]
{
"Technology heavy",
"Platform developer",
"C++, C#, .NET, ASP.NET Core, Node.js, CI/CD, Azure DevOps and Kubernetes.",
new[] { "C++", "C#", ".NET", "ASP.NET Core", "Node.js", "CI/CD", "Azure DevOps", "Kubernetes" },
new[] { "and" },
};
yield return new object[]
{
"Repeated recruitment filler",
"Software engineer",
"Exciting opportunity for a passionate candidate. Great opportunity, strong experience required. We offer an exciting dynamic environment. Apply now. Build services using domain-driven design and Docker.",
new[] { "domain-driven design", "Docker" },
new[] { "exciting", "opportunity", "passionate", "candidate", "experience", "environment", "apply" },
};
}
[Theory]
[MemberData(nameof(QualityFixtures))]
public void Quality_fixtures_keep_useful_terms_and_suppress_noise(
string name,
string title,
string description,
string[] expected,
string[] excluded)
{
var result = _service.Evaluate(title, description, Sections(("Skills", "synthetic profile text")));
var terms = result.MatchedKeywords.Concat(result.MissingKeywords).ToList();
foreach (var term in expected)
Assert.True(terms.Contains(term, StringComparer.OrdinalIgnoreCase), $"{name}: expected '{term}' in [{string.Join(", ", terms)}]");
foreach (var term in excluded)
Assert.False(terms.Contains(term, StringComparer.OrdinalIgnoreCase), $"{name}: did not expect '{term}' in [{string.Join(", ", terms)}]");
}
[Fact]
public void Strong_overlap_scores_high_and_lists_matched_keywords()
{
var result = _service.Evaluate(
jobTitle: "Senior C# Backend Developer",
jobText: "We need a backend engineer with strong C#, .NET, SQL and Docker experience building REST APIs.",
cvSections: Sections(
("Skills", "C# .NET SQL Docker Kubernetes"),
("Experience", "Built REST APIs in C# and .NET with SQL Server and Docker.")));
Assert.True(result.Score >= 75, $"expected strong score, got {result.Score}");
Assert.Equal("Strong", result.Band);
Assert.Contains("C#", result.MatchedKeywords);
Assert.Contains(".NET", result.MatchedKeywords);
Assert.True(result.HasEnoughSignal);
}
[Fact]
public void No_overlap_scores_low_and_surfaces_missing_keywords()
{
var result = _service.Evaluate(
jobTitle: "Kubernetes Platform Engineer",
jobText: "Deep Kubernetes, AWS, and Docker platform experience required. Terraform and CI/CD pipelines.",
cvSections: Sections(
("Skills", "Graphic design, Adobe Photoshop, Illustrator, copywriting"),
("Experience", "Ran marketing campaigns and brand design work.")));
Assert.True(result.Score < 50, $"expected low score, got {result.Score}");
Assert.Equal("Low", result.Band);
Assert.Contains("Kubernetes", result.MissingKeywords);
Assert.Contains("AWS", result.MissingKeywords);
}
[Fact]
public void Is_deterministic_for_identical_inputs()
{
var a = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
var b = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
Assert.Equal(a.Score, b.Score);
Assert.Equal(a.MatchedKeywords, b.MatchedKeywords);
Assert.Equal(a.MissingKeywords, b.MissingKeywords);
}
[Fact]
public void Word_boundary_prevents_false_substring_matches()
{
// "go" (the language) must not match inside "goals"/"ago".
var result = _service.Evaluate(
jobTitle: "Go Developer",
jobText: "Go programming language, goroutines, concurrency.",
cvSections: Sections(("Experience", "Achieved company goals two years ago in a great environment.")));
Assert.DoesNotContain("go", result.MatchedKeywords, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Section_coverage_reports_where_matches_are_concentrated()
{
var result = _service.Evaluate(
jobTitle: "React Frontend Engineer",
jobText: "Build UIs with React, TypeScript and JavaScript. Strong testing culture.",
cvSections: Sections(
("Skills", "React TypeScript JavaScript"),
("Experience", "Wrote documentation and managed budgets.")));
var skills = Assert.Single(result.SectionCoverage, s => s.Section == "Skills");
var experience = Assert.Single(result.SectionCoverage, s => s.Section == "Experience");
Assert.True(skills.Matched > experience.Matched);
}
[Fact]
public void Empty_cv_reports_no_signal()
{
var result = _service.Evaluate("Anything", "Some role text with several words here.", Sections());
Assert.False(result.HasEnoughSignal);
Assert.Equal("Unknown", result.Band);
Assert.Equal(0, result.MatchedCount);
}
[Fact]
public void Curated_tag_matches_synonym_spelling_in_cv()
{
// Job posting says "Kubernetes"; CV only says "K8s" -- same skill, different spelling.
var result = _service.Evaluate(
jobTitle: "Platform Engineer",
jobText: "Deep Kubernetes experience required for our platform team.",
cvSections: Sections(("Skills", "K8s, Terraform, Helm")));
Assert.Contains("Kubernetes", result.MatchedKeywords);
Assert.DoesNotContain("Kubernetes", result.MissingKeywords);
}
[Fact]
public void Title_keywords_are_weighted_and_missing_ones_rank_first()
{
// The title term "kubernetes" is absent from the CV; it should lead the missing list
// because title terms carry the title bonus weight.
var result = _service.Evaluate(
jobTitle: "Kubernetes Specialist",
jobText: "Kubernetes orchestration. Some familiarity with logging and monitoring dashboards.",
cvSections: Sections(("Skills", "logging monitoring dashboards")));
Assert.Equal("Kubernetes", result.MissingKeywords.First());
}
}