f0b9b222ff
Keep extraction heuristics out of manual save, version, and import paths so reviewed locations, URLs, dates, and languages round-trip unchanged.
222 lines
9.4 KiB
C#
222 lines
9.4 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, Norway and Remote across Europe";
|
|
current.Contact.Website = "https://example.test/portfolio/ada?view=full";
|
|
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, Norway and Remote across Europe", merged.Jobs[0].Location);
|
|
Assert.Equal("https://example.test/portfolio/ada?view=full", merged.Contact.Website);
|
|
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");
|
|
}
|
|
|
|
}
|