289 lines
11 KiB
C#
289 lines
11 KiB
C#
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
|
||
2015–2023System 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 2015–2023", repaired);
|
||
}
|
||
|
||
[Fact]
|
||
public void Earlier_part_time_roles_become_separate_experience_entries()
|
||
{
|
||
var profile = InvokeProfileBuilder("""
|
||
# Work Experience
|
||
System Developer
|
||
Warwickshire County Council
|
||
2015–2023
|
||
- Built APIs
|
||
|
||
Earlier roles (part-time)
|
||
- Sales Assistant — Royal Vapes | 2013–2015
|
||
- Labourer — The Hodcarrier | 2014–2016
|
||
- Lifeguard — Nuffield Health | 2012–2014
|
||
""");
|
||
|
||
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
|
||
2020–Present
|
||
- 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
|
||
2020–Present
|
||
- Bygget pålitelige tjenester
|
||
|
||
Utdanning
|
||
Bachelor i informatikk
|
||
Eksempelhøgskolen
|
||
2017–2020
|
||
|
||
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 }));
|
||
}
|
||
|
||
}
|