feat: complete phase 2 UX improvements
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Reflection;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -74,4 +76,71 @@ public sealed class CvExtractionCoverageTests
|
||||
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);
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Phase 2.1-a — the diff engine. Pure comparison; applies nothing. These pin the conservative policy:
|
||||
// clear matches only, updates only on non-empty differing values, no deletions, dedup, and the
|
||||
// High/Medium/Low confidence markers that drive the review screen.
|
||||
public sealed class CvProfileDiffServiceTests
|
||||
{
|
||||
private static readonly CvProfileDiffService Svc = new();
|
||||
|
||||
private static CvCategoryDiff Cat(CvImportDiff d, string name) => d.Categories.First(c => c.Category == name);
|
||||
|
||||
private static StructuredCvJob Job(string title, string company, string? start = null, string? end = null, params string[] bullets)
|
||||
=> new() { Title = title, Company = company, Start = start, End = end, Bullets = bullets.ToList() };
|
||||
|
||||
[Fact]
|
||||
public void Empty_current_makes_everything_an_addition()
|
||||
{
|
||||
var current = new StructuredCvProfile();
|
||||
var extracted = new StructuredCvProfile
|
||||
{
|
||||
Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built full-stack apps.") },
|
||||
Projects = { new StructuredCvProject { Name = "JobTrack", Bullets = { "Job tracker." } } },
|
||||
Skills = { "C#", ".NET", "Docker" },
|
||||
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" }, new StructuredCvLanguage { Name = "Norwegian", Level = "B1" } },
|
||||
};
|
||||
|
||||
var diff = Svc.Diff(current, extracted);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Single(Cat(diff, "Experience").Added);
|
||||
Assert.Single(Cat(diff, "Projects").Added);
|
||||
Assert.Equal(3, Cat(diff, "Skills").Added.Count);
|
||||
Assert.Equal(2, Cat(diff, "Languages").Added.Count);
|
||||
Assert.Empty(Cat(diff, "Experience").Updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_matched_job_with_no_new_information_is_unchanged_not_duplicated()
|
||||
{
|
||||
var job = Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built apps.");
|
||||
var current = new StructuredCvProfile { Jobs = { job } };
|
||||
var extracted = new StructuredCvProfile { Jobs = { Job("system developer", "WARWICKSHIRE COUNTY COUNCIL", "2015", "2023", "Built apps.") } };
|
||||
|
||||
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||
|
||||
Assert.Empty(exp.Added); // same company+title -> not a new entry
|
||||
Assert.Empty(exp.Updated); // same dates + bullets -> nothing to update
|
||||
Assert.Equal(1, exp.UnchangedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_matched_job_with_new_dates_is_an_update_not_an_add()
|
||||
{
|
||||
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
|
||||
var extracted = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023") } };
|
||||
|
||||
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||
|
||||
Assert.Empty(exp.Added);
|
||||
Assert.Single(exp.Updated);
|
||||
Assert.Contains(exp.Updated[0].FieldChanges, f => f.Field == "Dates");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_genuinely_new_job_is_an_addition()
|
||||
{
|
||||
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
|
||||
var extracted = new StructuredCvProfile
|
||||
{
|
||||
Jobs = { Job("System Developer", "Warwickshire County Council"), Job("Bartender", "The Hodcarrier", "2016", "2018") },
|
||||
};
|
||||
|
||||
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||
|
||||
Assert.Single(exp.Added);
|
||||
Assert.Equal("Bartender — The Hodcarrier", exp.Added[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skills_dedup_case_insensitively_and_only_new_ones_are_added()
|
||||
{
|
||||
var current = new StructuredCvProfile { Skills = { "C#", "SQL" } };
|
||||
var extracted = new StructuredCvProfile { Skills = { "c#", ".NET", "sql", "Docker", "docker" } };
|
||||
|
||||
var skills = Cat(Svc.Diff(current, extracted), "Skills");
|
||||
|
||||
Assert.Equal(2, skills.Added.Count); // .NET and Docker only, deduped
|
||||
Assert.Contains(skills.Added, s => s.Label == ".NET");
|
||||
Assert.Contains(skills.Added, s => s.Label == "Docker");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_language_level_change_is_an_update_a_new_language_is_an_add()
|
||||
{
|
||||
var current = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } } };
|
||||
var extracted = new StructuredCvProfile
|
||||
{
|
||||
Languages =
|
||||
{
|
||||
new StructuredCvLanguage { Name = "English", Level = "Native" },
|
||||
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
|
||||
},
|
||||
};
|
||||
|
||||
var langs = Cat(Svc.Diff(current, extracted), "Languages");
|
||||
Assert.Single(langs.Added);
|
||||
Assert.Equal("Norwegian (B1)", langs.Added[0].Label);
|
||||
Assert.Equal(1, langs.UnchangedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_language_without_a_level_is_low_confidence()
|
||||
{
|
||||
var extracted = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "French" } } };
|
||||
var langs = Cat(Svc.Diff(new StructuredCvProfile(), extracted), "Languages");
|
||||
Assert.Equal("Low", langs.Added[0].Confidence);
|
||||
Assert.Equal(1, diffLow(langs));
|
||||
}
|
||||
|
||||
private static int diffLow(CvCategoryDiff c) => c.LowConfidenceCount;
|
||||
|
||||
[Fact]
|
||||
public void Extraction_never_proposes_blanking_an_existing_value_and_never_deletes()
|
||||
{
|
||||
// current has a rich job; extraction found the same job but with an empty location and no bullets.
|
||||
var current = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022", "Did things.") } };
|
||||
current.Jobs[0].Location = "Oslo";
|
||||
var extracted = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022") } }; // no location, no bullets
|
||||
|
||||
var exp = Cat(Svc.Diff(current, extracted), "Experience");
|
||||
|
||||
Assert.Empty(exp.Added);
|
||||
Assert.Empty(exp.Updated); // empty extracted fields never overwrite
|
||||
Assert.Equal(1, exp.UnchangedCount);
|
||||
|
||||
// And a job the extraction did NOT mention simply doesn't appear in the diff (never deleted).
|
||||
var extracted2 = new StructuredCvProfile();
|
||||
Assert.False(Svc.Diff(current, extracted2).HasChanges);
|
||||
}
|
||||
[Fact]
|
||||
public void Merge_preserves_existing_items_and_adds_new_information()
|
||||
{
|
||||
var current = new StructuredCvProfile
|
||||
{
|
||||
Jobs = { Job("Developer", "Acme", "2020", "2022", "Curated bullet") },
|
||||
Skills = { "C#" },
|
||||
};
|
||||
current.Jobs[0].Id = "keep-me";
|
||||
current.Jobs[0].Location = "Oslo";
|
||||
var extracted = new StructuredCvProfile
|
||||
{
|
||||
Jobs =
|
||||
{
|
||||
Job("developer", "ACME", "2020", "2024", "Curated bullet", "New extracted bullet"),
|
||||
Job("Engineer", "New Co", "2024", "Present", "Built systems"),
|
||||
},
|
||||
Skills = { "c#", "Docker" },
|
||||
};
|
||||
|
||||
var merged = Svc.Merge(current, extracted);
|
||||
|
||||
Assert.Equal(2, merged.Jobs.Count);
|
||||
Assert.Equal("keep-me", merged.Jobs[0].Id);
|
||||
Assert.Equal("Oslo", merged.Jobs[0].Location);
|
||||
Assert.Equal("2024", merged.Jobs[0].End);
|
||||
Assert.Equal(new[] { "Curated bullet", "New extracted bullet" }, merged.Jobs[0].Bullets);
|
||||
Assert.Equal(new[] { "C#", "Docker" }, merged.Skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_never_blanks_or_deletes_existing_data()
|
||||
{
|
||||
var current = new StructuredCvProfile
|
||||
{
|
||||
Contact = new StructuredCvContact { FullName = "Demo User", Email = "demo@example.com" },
|
||||
Jobs = { Job("Developer", "Acme", "2020", "2024", "Keep this") },
|
||||
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } },
|
||||
};
|
||||
var extracted = new StructuredCvProfile
|
||||
{
|
||||
Contact = new StructuredCvContact { FullName = "Demo User" },
|
||||
Languages = { new StructuredCvLanguage { Name = "English" } },
|
||||
};
|
||||
|
||||
var merged = Svc.Merge(current, extracted);
|
||||
|
||||
Assert.Equal("demo@example.com", merged.Contact.Email);
|
||||
Assert.Single(merged.Jobs);
|
||||
Assert.Equal("Native", merged.Languages[0].Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_requires_explicit_acceptance_for_each_low_confidence_change()
|
||||
{
|
||||
var extracted = new StructuredCvProfile
|
||||
{
|
||||
Languages =
|
||||
{
|
||||
new StructuredCvLanguage { Name = "French" },
|
||||
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
|
||||
},
|
||||
};
|
||||
var diff = Svc.Diff(new StructuredCvProfile(), extracted);
|
||||
var french = Cat(diff, "Languages").Added.Single(x => x.Label == "French");
|
||||
|
||||
Assert.Equal("Languages|french", french.Id);
|
||||
var defaultMerge = Svc.Merge(new StructuredCvProfile(), extracted);
|
||||
var confirmedMerge = Svc.Merge(new StructuredCvProfile(), extracted, new HashSet<string> { french.Id });
|
||||
|
||||
Assert.DoesNotContain(defaultMerge.Languages, x => x.Name == "French");
|
||||
Assert.Contains(defaultMerge.Languages, x => x.Name == "Norwegian");
|
||||
Assert.Contains(confirmedMerge.Languages, x => x.Name == "French");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public sealed class ProfileCvControllerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_stores_cv_artifact_and_extraction_run_metadata()
|
||||
public async Task Upload_waits_for_review_then_accept_merges_and_applies()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var userManager = CreateUserManager();
|
||||
@@ -81,21 +81,55 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var artifact = await db.CvUploadArtifacts.SingleAsync();
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
Assert.Equal("user-1", artifact.OwnerUserId);
|
||||
Assert.Equal("resume.md", artifact.OriginalFileName);
|
||||
Assert.True(System.IO.File.Exists(artifact.StoragePath));
|
||||
Assert.Equal("applied", run.Status);
|
||||
Assert.Equal("pending_review", run.Status);
|
||||
Assert.Equal("upload", run.Trigger);
|
||||
Assert.Equal(artifact.Id, run.ArtifactId);
|
||||
Assert.Null(user.ProfileCvStructureJson);
|
||||
Assert.Null(user.CurrentCvExtractionRunId);
|
||||
|
||||
Assert.IsType<OkObjectResult>(await controller.GetRunDiff(run.Id));
|
||||
Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id));
|
||||
|
||||
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
Assert.Equal("applied", run.Status);
|
||||
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
|
||||
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
|
||||
Assert.Equal(1, user.CurrentCvProfileVersion);
|
||||
Assert.Equal(run.Id, parsed.Metadata.AppliedExtractionRunId);
|
||||
Assert.True(parsed.Metadata.ProfileVersion >= 1);
|
||||
Assert.Contains(parsed.Metadata.Fields.Keys, key => key == "contact.fullName" || key == "summary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardRun_leaves_profile_unchanged()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvStructureJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "C#" } }) };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
await using var db = CreateDb();
|
||||
db.CvExtractionRuns.Add(new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
Trigger = "upload",
|
||||
Status = "pending_review",
|
||||
ParserVersion = "test",
|
||||
NormalizerVersion = "test",
|
||||
LlmPromptVersion = "test",
|
||||
StructuredProfileJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "Docker" } }),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
|
||||
Assert.IsType<NoContentResult>(await controller.DiscardRun(run.Id));
|
||||
|
||||
Assert.Equal("discarded", run.Status);
|
||||
Assert.Equal(new[] { "C#" }, StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRuns_returns_latest_extraction_runs()
|
||||
{
|
||||
@@ -186,10 +220,10 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
Assert.Equal("reprocess", run.Trigger);
|
||||
Assert.Equal("applied", run.Status);
|
||||
Assert.Equal(2, user.CurrentCvProfileVersion);
|
||||
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
|
||||
Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText);
|
||||
Assert.Equal("pending_review", run.Status);
|
||||
Assert.Equal(1, user.CurrentCvProfileVersion);
|
||||
Assert.Null(user.CurrentCvExtractionRunId);
|
||||
Assert.Null(user.ProfileCvText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -268,9 +302,10 @@ public sealed class ProfileCvControllerTests
|
||||
var result = await controller.Upload(file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.Equal(reconstructed, user.ProfileCvText);
|
||||
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||
Assert.Equal(reconstructed, savedRun.NormalizedText);
|
||||
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||
Assert.Single(structured.Summary);
|
||||
Assert.Single(structured.Jobs);
|
||||
@@ -326,10 +361,11 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||
Assert.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("# Skills", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Warwickshire County Council", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -366,7 +402,8 @@ public sealed class ProfileCvControllerTests
|
||||
var result = await controller.Upload(file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||
Assert.Equal("connor.babbington@cesnimda.co.uk", structured.Contact.Email);
|
||||
Assert.Equal("+47 41 33 44 70", structured.Contact.Phone);
|
||||
@@ -965,8 +1002,10 @@ public sealed class ProfileCvControllerTests
|
||||
var result = await controller.Upload(file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.Contains("Built APIs", user.ProfileCvText);
|
||||
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
Assert.Contains("Built APIs", run.NormalizedText);
|
||||
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(run.StructuredProfileJson).Contact.FullName);
|
||||
Assert.Equal("pending_review", run.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user