feat(cv): extend templates and extraction
This commit is contained in:
@@ -210,6 +210,21 @@ public sealed class CvBuilderTests
|
||||
Assert.DoesNotContain("transform:scale", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multi_entry_sections_flow_between_entries_instead_of_leaving_a_blank_page()
|
||||
{
|
||||
var profile = Rich();
|
||||
profile.Jobs.Add(new StructuredCvJob { Id = "job3", Title = "Engineer III", Company = "Example", Bullets = { "Shipped reliable services." } });
|
||||
|
||||
var settings = new CvVariantSettings { ThemeId = "code" };
|
||||
var model = CvVariantResolver.Build(profile, settings, "F", null);
|
||||
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("code"), settings).Html;
|
||||
|
||||
Assert.Contains("section section-experience section-flow", html);
|
||||
Assert.Contains(".section-flow{break-inside:auto", html);
|
||||
Assert.DoesNotContain(":has(", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_band_and_sidebar_contact_text_own_their_contrasting_palette()
|
||||
{
|
||||
@@ -217,12 +232,11 @@ public sealed class CvBuilderTests
|
||||
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
|
||||
var modern = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html;
|
||||
var lightAccent = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern", AccentColor = "#f8fafc" }).Html;
|
||||
var technicalTheme = CvThemeCatalog.Resolve("technical");
|
||||
var technical = renderer.Render(model, technicalTheme, new CvVariantSettings { ThemeId = "technical" }).Html;
|
||||
var technical = renderer.Render(model, CvThemeCatalog.Resolve("technical"), new CvVariantSettings { ThemeId = "technical" }).Html;
|
||||
|
||||
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#fff;}", modern);
|
||||
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#000;}", lightAccent);
|
||||
Assert.Contains($".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{technicalTheme.SidebarInk};}}", technical);
|
||||
Assert.Contains(".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{color:var(--cv-accent-ink);}", technical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -272,8 +286,8 @@ public sealed class CvBuilderTests
|
||||
|
||||
Assert.Equal(13, settings.BaseFontSizePt);
|
||||
Assert.Contains("grid-template-columns:minmax(0,1fr) 70mm", html);
|
||||
Assert.Contains("color:#123456", html);
|
||||
Assert.Contains("background:#fafafa", html);
|
||||
Assert.Contains("--cv-ink:#123456", html);
|
||||
Assert.Contains("--cv-paper:#fafafa", html);
|
||||
Assert.Contains("line-height:1.55", html);
|
||||
Assert.Contains("font-size:13pt", html);
|
||||
Assert.Contains("class=\"skills-text\">C# · SQL</p>", html);
|
||||
@@ -437,12 +451,44 @@ public sealed class CvBuilderTests
|
||||
Assert.Equal("user-1", await svc.GetPublicOwnerAsync(v.PublicSlug, default));
|
||||
}
|
||||
[Fact]
|
||||
public void Premium_theme_policy_keeps_three_free_themes_available()
|
||||
public void Premium_theme_policy_keeps_free_themes_available()
|
||||
{
|
||||
Assert.Equal(3, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false)));
|
||||
Assert.Equal(4, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false)));
|
||||
Assert.Equal(5, CvThemeCatalog.Themes.Count(t => t.Premium));
|
||||
Assert.All(CvThemeCatalog.Themes, t => Assert.True(CvThemeCatalog.CanUse(t.Id, true)));
|
||||
Assert.False(CvThemeCatalog.CanUse("unknown", true));
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Code_theme_uses_registry_driven_numbered_sections_and_shared_accent_tokens()
|
||||
{
|
||||
var settings = new CvVariantSettings { ThemeId = "code", AccentColor = "#126b55" };
|
||||
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
|
||||
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("code"), settings).Html;
|
||||
|
||||
Assert.Contains("counter-reset:cv-section", html);
|
||||
Assert.Contains("--cv-accent-color:#126b55", html);
|
||||
Assert.Contains("Consolas", html);
|
||||
Assert.True(CvThemeCatalog.Resolve("code").AtsFriendly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_overrides_skill_groups_and_custom_paragraphs_share_the_render_model()
|
||||
{
|
||||
var settings = new CvVariantSettings
|
||||
{
|
||||
Sections = { new CvSectionSetting { Key = "summary", Items = new() { "Tailored summary" } } },
|
||||
SkillsStyle = "grouped",
|
||||
SkillGroups = new() { new CvSkillGroupSetting { Name = "Backend", Items = { "C#", ".NET" } } },
|
||||
CustomSections = { new CvCustomSectionSetting { Key = "decl", Title = "Declaration", ContentType = "paragraphs", Items = { "I confirm these details." } } },
|
||||
};
|
||||
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
|
||||
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html;
|
||||
|
||||
Assert.Equal(new[] { "Tailored summary" }, model.Sections.Single(section => section.Key == "summary").Bullets);
|
||||
Assert.Contains("class=\"skill-group-name\">Backend", html);
|
||||
Assert.Contains("class=\"paragraphs\"><p>I confirm these details.</p>", html);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,15 +18,16 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvCorpusHarnessTests
|
||||
{
|
||||
private static readonly string CorpusRoot = "/home/pi/cvs";
|
||||
private const string DefaultCorpusRoot = "/home/pi/cvs";
|
||||
|
||||
[Fact]
|
||||
public async Task Local_cv_corpus_harness_produces_repeatable_parse_report_when_available()
|
||||
{
|
||||
if (!Directory.Exists(CorpusRoot)) return;
|
||||
var corpusRoot = ResolveCorpusRoot();
|
||||
if (!Directory.Exists(corpusRoot)) return;
|
||||
|
||||
var ignoredPatterns = ResolveIgnoredPatterns();
|
||||
var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly)
|
||||
var files = Directory.EnumerateFiles(corpusRoot, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -143,7 +144,7 @@ public sealed class CvCorpusHarnessTests
|
||||
}
|
||||
|
||||
var summary = new CvBenchmarkSummary(
|
||||
CorpusRoot,
|
||||
corpusRoot,
|
||||
outputRoot,
|
||||
DateTimeOffset.UtcNow,
|
||||
entries.Count,
|
||||
@@ -202,6 +203,12 @@ public sealed class CvCorpusHarnessTests
|
||||
return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
|
||||
}
|
||||
|
||||
private static string ResolveCorpusRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_CORPUS_DIR");
|
||||
return string.IsNullOrWhiteSpace(configured) ? DefaultCorpusRoot : configured.Trim();
|
||||
}
|
||||
|
||||
private static string ResolveApprovedFixturesRoot(string outputRoot)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Reflection;
|
||||
using System.IO.Compression;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
@@ -137,10 +139,150 @@ public sealed class CvExtractionCoverageTests
|
||||
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 }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ public sealed class HumanLanguageCatalogTests
|
||||
Assert.Equal("Norwegian", HumanLanguageCatalog.NormalizeLanguageName(alias));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Norwegian_cv_terms_normalize_without_changing_the_canonical_storage_language()
|
||||
{
|
||||
Assert.Equal("English", HumanLanguageCatalog.NormalizeLanguageName("engelsk"));
|
||||
Assert.Equal("Native", HumanLanguageCatalog.ExtractLevel("morsmål"));
|
||||
Assert.Equal("Professional working proficiency", HumanLanguageCatalog.ExtractLevel("profesjonelt arbeidsnivå"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("C#")]
|
||||
[InlineData("Leadership")]
|
||||
|
||||
Reference in New Issue
Block a user