Files
jobtrackingapp/JobTrackerApi.Tests/CvExtractionCoverageTests.cs
T
2026-08-27 15:27:48 +02:00

289 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Reflection;
using System.IO.Compression;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace JobTrackerApi.Tests;
// Phase 2.1-b — CV extraction coverage. The structured model and StructuredCvProfileJson.FromSections
// already map Projects, Certifications and Languages headings into the profile; the gap was upstream
// (the AI /cv/normalize prompt never emitted those headings, so the sections were dropped). These
// tests lock the C# side so that once the normalized markdown carries # Projects / # Certifications /
// # Languages, they reach the structured profile — and so a future change can't silently regress it.
//
// Content shapes mirror what the (fixed) normalizer produces for the benchmark CV
// (Connor Babbington): a Projects section, and languages stated as "Name: Level".
public sealed class CvExtractionCoverageTests
{
private static StructuredCvSection Section(string name, string content) =>
new() { Name = name, Content = content };
[Fact]
public void FromSections_maps_a_Projects_heading_into_structured_projects()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Projects",
"JobTrack\nFull-stack job-application tracker (React, ASP.NET Core, SQLite, Docker).\n\n" +
"InboxIntel\nGmail analytics and safe bulk-cleanup tool in .NET 8 with PostgreSQL."),
});
Assert.Equal(2, profile.Projects.Count);
Assert.Contains(profile.Projects, p => p.Name == "JobTrack");
Assert.Contains(profile.Projects, p => p.Name == "InboxIntel");
}
[Fact]
public void FromSections_maps_a_Certifications_heading_into_structured_certifications()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Certifications",
"Extended Diploma NVQ Level 3 in ICT\n\nAZ-900 Azure Fundamentals"),
});
Assert.NotEmpty(profile.Certifications);
Assert.Contains(profile.Certifications, c => (c.Name ?? "").Contains("NVQ", System.StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void FromSections_maps_a_Languages_heading_with_levels()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Languages", "English: Native\nNorwegian: B1"),
});
Assert.Equal(2, profile.Languages.Count);
Assert.Contains(profile.Languages, l => l.Name == "English" && (l.Level ?? "").Contains("Native"));
Assert.Contains(profile.Languages, l => l.Name == "Norwegian" && (l.Level ?? "").Contains("B1"));
}
// The benchmark CV has all four rich sections; confirm they coexist without one clobbering another.
[Fact]
public void FromSections_populates_projects_certifications_and_languages_together()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Skills", "C#\n.NET\nDocker"),
Section("Projects", "JobTrack\nJob-application tracker."),
Section("Certifications", "NVQ Level 3 in ICT"),
Section("Languages", "English: Native\nNorwegian: B1"),
});
Assert.NotEmpty(profile.Skills);
Assert.Single(profile.Projects);
Assert.NotEmpty(profile.Certifications);
Assert.Equal(2, profile.Languages.Count);
}
[Fact]
public void Normalized_markdown_strips_skill_group_labels()
{
var profile = InvokeProfileBuilder("""
# Skills
Development: C#
DevOps & Infrastructure: Docker
Practices: CI/CD
""");
Assert.Equal(new[] { "C#", "CI/CD", "Docker" }, profile.Skills);
Assert.DoesNotContain(profile.Skills, skill => skill.StartsWith("Development", System.StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Normalized_markdown_separates_glued_dates_from_job_title()
{
var profile = InvokeProfileBuilder("""
# Work Experience
20152023System Developer
Warwickshire County Council, UK
- Built APIs
""");
var job = Assert.Single(profile.Jobs);
Assert.Equal("2015", job.Start);
Assert.Equal("2023", job.End);
Assert.Contains("System Developer", job.Title ?? string.Empty);
}
[Fact]
public void Extraction_repairs_common_utf8_as_latin1_mojibake()
{
var method = typeof(ProfileCvController).GetMethod("RepairKnownMojibake", BindingFlags.NonPublic | BindingFlags.Static)!;
var repaired = Assert.IsType<string>(method.Invoke(null, new object[] { "Tønsberg 2015–2023" }));
Assert.Equal("Tønsberg 20152023", repaired);
}
[Fact]
public void Earlier_part_time_roles_become_separate_experience_entries()
{
var profile = InvokeProfileBuilder("""
# Work Experience
System Developer
Warwickshire County Council
20152023
- Built APIs
Earlier roles (part-time)
- Sales Assistant Royal Vapes | 20132015
- Labourer The Hodcarrier | 20142016
- Lifeguard Nuffield Health | 20122014
""");
Assert.Contains(profile.Jobs, job => job.Title == "Sales Assistant" && job.Company == "Royal Vapes");
Assert.Contains(profile.Jobs, job => job.Title == "Labourer" && job.Company == "The Hodcarrier");
Assert.Contains(profile.Jobs, job => job.Title == "Lifeguard" && job.Company == "Nuffield Health");
Assert.Equal(4, profile.Jobs.Count);
}
[Fact]
public void Numbered_editorial_headings_are_canonicalized_without_personal_fixture_data()
{
var profile = InvokeHeuristicBuilder("""
01 PROFESSIONAL SUMMARY
Backend engineer focused on reliable services and clear delivery.
02 CORE COMPETENCIES
C#
Docker
03 PROFESSIONAL EXPERIENCE
Software Engineer
Example Council
2020Present
- Built maintainable APIs
""");
Assert.NotEmpty(profile.Summary);
Assert.Contains("C#", profile.Skills);
Assert.Contains(profile.Jobs, job => job.Title == "Software Engineer");
}
[Fact]
public void Unknown_markdown_sections_are_preserved_for_import_review()
{
var profile = InvokeHeuristicBuilder("""
# Professional Summary
Engineer with broad delivery experience.
# Community Leadership
Mentored early-career developers
Organised a monthly meetup
""");
var custom = Assert.Single(profile.OtherSections, section => section.Title == "Community Leadership");
Assert.Contains(custom.Items, item => item.Contains("Mentored", System.StringComparison.Ordinal));
}
[Fact]
public void Contact_detection_uses_the_location_segment_and_rejects_section_headings()
{
var profile = InvokeHeuristicBuilder("""
Alex Example
Platform Engineer
Tønsberg, Norway · +47 412 34 567 · alex.example@example.test
portfolio.example.dev · linkedin.com/in/alex-example
Core Competencies
Backend Development · System Design
""");
Assert.Equal("Tønsberg, Norway", profile.Contact.Location);
Assert.Equal("portfolio.example.dev", profile.Contact.Website);
}
[Fact]
public void Norwegian_section_headings_are_canonicalized()
{
var profile = InvokeHeuristicBuilder("""
Sammendrag
Utvikler med erfaring fra robuste tjenester.
Teknisk kompetanse
C#
Docker
Arbeidserfaring
Systemutvikler
Eksempelkommune
2020Present
- Bygget pålitelige tjenester
Utdanning
Bachelor i informatikk
Eksempelhøgskolen
20172020
Språk
Norsk: B2
English: Native
""");
Assert.NotEmpty(profile.Summary);
Assert.Contains("C#", profile.Skills);
Assert.NotEmpty(profile.Jobs);
Assert.NotEmpty(profile.Education);
Assert.Equal(2, profile.Languages.Count);
}
[Fact]
public void Norwegian_inline_language_pairs_keep_their_own_proficiency()
{
var profile = InvokeHeuristicBuilder("""
Språk
Engelsk morsmål Norsk A2/B1, under aktiv utvikling mot B2
""");
Assert.Contains(profile.Languages, language => language.Name == "English" && language.Level == "Native");
Assert.Contains(profile.Languages, language => language.Name == "Norwegian" && language.Level == "A2/B1");
}
[Fact]
public async Task Docx_fallback_preserves_paragraph_and_table_boundaries()
{
await using var package = new MemoryStream();
using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry("word/document.xml");
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync("""
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
<w:p><w:r><w:t>Technical Skills</w:t></w:r></w:p>
<w:tbl>
<w:tr><w:tc><w:p><w:r><w:t>Backend</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>C#, .NET</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>DevOps</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Docker, Linux</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:pPr><w:pStyle w:val="CVRole" /></w:pPr><w:r><w:t>Example Engineer</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="CVBullet" /></w:pPr><w:r><w:t>Built reliable services</w:t></w:r></w:p>
</w:body></w:document>
""");
}
package.Position = 0;
var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx");
var method = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx" }));
var extracted = await task;
Assert.Contains("Technical Skills\n", extracted);
Assert.Contains("Backend | C#, .NET", extracted);
Assert.Contains("DevOps | Docker, Linux", extracted);
Assert.Contains("\n\nExample Engineer\n- Built reliable services", extracted);
}
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
{
var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", BindingFlags.NonPublic | BindingFlags.Static)!;
return Assert.IsType<StructuredCvProfile>(method.Invoke(null, new object[] { markdown }));
}
private static StructuredCvProfile InvokeHeuristicBuilder(string text)
{
var method = typeof(ProfileCvController).GetMethod("BuildHeuristicStructuredCv", BindingFlags.NonPublic | BindingFlags.Static)!;
return Assert.IsType<StructuredCvProfile>(method.Invoke(null, new object[] { text, text }));
}
}