Files
jobtrackingapp/JobTrackerApi.Tests/CvProfileDiffServiceTests.cs
T
cesnimda 56fed05d70
CI and Deploy / test (push) Successful in 2m42s
CI and Deploy / deploy (push) Failing after 9s
feat: complete phase 2 UX improvements
2026-07-30 21:35:52 +02:00

220 lines
9.1 KiB
C#

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