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 { 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"); } }