Files
jobtrackingapp/JobTrackerApi.Tests/CvImportMergeScenarioTests.cs
T

228 lines
13 KiB
C#

using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public class CvImportMergeScenarioTests
{
[Fact]
public void Explicit_new_record_order_is_not_replaced_by_random_client_identifier_order()
{
var blocks = new[] { new CvImportBlock("b1", "Engineer First AS", "paragraph", 1), new CvImportBlock("b2", "Lead Second AS", "paragraph", 1) };
var document = new CvImportDocument("Engineer First AS Lead Second AS", false, "application/pdf", 1, 35, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic");
var mappings = new[]
{
new CvImportRecordMapping("z-first", "jobs", null, new[] { new CvImportMappedField("title", "Engineer", "b1"), new CvImportMappedField("company", "First AS", "b1") }),
new CvImportRecordMapping("a-second", "jobs", null, new[] { new CvImportMappedField("title", "Lead", "b2"), new CvImportMappedField("company", "Second AS", "b2") }),
};
var result = CvImportMapping.Apply(new(), mappings, document, 1, 2, new string('a', 64));
Assert.Equal(new[] { "First AS", "Second AS" }, result.Jobs.Select(job => job.Company));
}
private static StructuredCvJob Job(string company = "Example Labs", string? start = "2020", string? end = "2022") => new()
{
Title = "Engineer", Company = company, Start = start, End = end,
};
private static StructuredCvProfile Profile(params StructuredCvJob[] jobs) => new() { Jobs = jobs.ToList() };
private static CvImportDecision[] Decide(StructuredCvProfile current, StructuredCvProfile incoming, string action) =>
CvDocumentReview.Build(current, incoming).SelectMany(g => g.Fields).Select(f => new CvImportDecision(f.Id, action)).ToArray();
private static CvImportDocument Source(string text) => new(text, false, "application/pdf", 1, text.Length, "scenario.pdf",
new[] { new CvImportBlock("source", text, "paragraph", 1) }, Array.Empty<string>(), "docling");
private static CvImportMappedField Field(string name, string value) => new(name, value, "source");
private static StructuredCvProfile Map(StructuredCvProfile current, string text, params CvImportRecordMapping[] mappings) =>
CvImportMapping.Apply(current, mappings, Source(text), 10, 20, "scenario-hash");
[Fact]
public void Reimporting_the_same_complete_extracted_record_twice_reuses_its_existing_identity()
{
var saved = Job();
saved.Id = "stable-job";
saved.Location = "Saved location";
saved.Bullets.Add("User-only achievement");
var current = Profile(saved);
var incomingJob = Job();
incomingJob.Bullets.Add("Imported achievement");
var incoming = Profile(incomingJob);
incoming.Metadata.Fields["jobs.0.bullets.0"] = new() { SourceBlockId = "original-occurrence", SourceExtractionRunId = 10 };
var first = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
var second = CvDocumentReview.Apply(first, incoming, Decide(first, incoming, "use"));
var retained = Assert.Single(second.Jobs);
Assert.Equal("stable-job", retained.Id);
Assert.Equal("Saved location", retained.Location);
Assert.Equal(new[] { "User-only achievement", "Imported achievement" }, retained.Bullets);
Assert.Equal("original-occurrence", second.Metadata.Fields["jobs.0.bullets.1"].SourceBlockId);
Assert.Single(current.Jobs[0].Bullets);
}
[Theory]
[InlineData("2020", "2023")]
[InlineData("2020", "2021")]
[InlineData("2021", "2024")]
[InlineData("2020-01", "2022")]
public void Revised_older_overlapping_or_differently_precise_dates_remain_distinct_until_reviewed(string start, string end)
{
var current = Profile(Job());
var incoming = Profile(Job(start: start, end: end));
Assert.Equal("New record", Assert.Single(CvDocumentReview.Build(current, incoming)).MatchDescription);
var rejected = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "keep"));
Assert.Single(rejected.Jobs);
Assert.Equal("2022", rejected.Jobs[0].End);
var approved = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
Assert.Equal(2, approved.Jobs.Count);
Assert.Equal("2020", approved.Jobs[0].Start);
Assert.Equal("2022", approved.Jobs[0].End);
Assert.Equal(start, approved.Jobs[1].Start);
Assert.Equal(end, approved.Jobs[1].End);
Assert.Null(approved.Jobs[1].StartDate);
}
[Theory]
[InlineData("Example Lab")]
[InlineData("Example Labs AS")]
[InlineData("Example Laboratories")]
public void Similar_but_distinct_employers_are_never_fuzzy_merged(string company)
{
var current = Profile(Job());
var incoming = Profile(Job(company));
Assert.Equal("New record", Assert.Single(CvDocumentReview.Build(current, incoming)).MatchDescription);
var approved = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
Assert.Equal(new[] { "Example Labs", company }, approved.Jobs.Select(j => j.Company));
}
[Fact]
public void Incomplete_identity_is_new_until_the_user_explicitly_targets_a_current_record()
{
var current = Profile(Job());
current.Jobs[0].Id = "saved";
current.Jobs[0].Bullets.Add("Saved achievement");
var partial = Profile(Job(end: null));
partial.Jobs[0].Bullets.Add("New achievement");
var group = Assert.Single(CvDocumentReview.Build(current, partial));
Assert.Equal("New record", group.MatchDescription);
Assert.All(group.Fields, f => Assert.Null(f.CurrentValue));
var rejected = CvDocumentReview.Apply(current, partial, Decide(current, partial, "keep"));
var updated = Map(rejected, "New achievement", new CvImportRecordMapping("explicit", "jobs", 0, new[] { Field("bullets", "New achievement") }));
var retained = Assert.Single(updated.Jobs);
Assert.Equal("saved", retained.Id);
Assert.Equal("2022", retained.End);
Assert.Equal(new[] { "Saved achievement", "New achievement" }, retained.Bullets);
}
[Fact]
public void Correcting_an_unmatched_extracted_identity_does_not_silently_retarget_a_saved_record()
{
var current = Profile(Job());
current.Jobs[0].Id = "saved";
var incoming = Profile(Job("Example Typo"));
var decisions = Decide(current, incoming, "use").Select(d => d.Id == "jobs.0.company" ? d with { Action = "correct", Value = "Example Labs" } : d).ToArray();
var result = CvDocumentReview.Apply(current, incoming, decisions);
Assert.Equal(2, result.Jobs.Count);
Assert.Equal("saved", result.Jobs[0].Id);
Assert.Null(result.Jobs[1].Id);
Assert.All(result.Jobs, j => Assert.Equal("Example Labs", j.Company));
}
[Fact]
public void Correcting_a_matched_identity_keeps_the_original_target_even_when_another_record_has_the_corrected_name()
{
var current = Profile(Job("First Labs"), Job("Second Labs"));
current.Jobs[0].Id = "first";
current.Jobs[1].Id = "second";
var incoming = Profile(Job("First Labs"));
var result = CvDocumentReview.Apply(current, incoming, new[] { new CvImportDecision("jobs.0.company", "correct", "Second Labs") });
Assert.Equal(2, result.Jobs.Count);
Assert.Equal("first", result.Jobs[0].Id);
Assert.Equal("Second Labs", result.Jobs[0].Company);
Assert.Equal("second", result.Jobs[1].Id);
Assert.Equal("Second Labs", result.Jobs[1].Company);
}
[Fact]
public void Explicit_target_adds_education_details_without_replacing_absent_identity_dates_or_evidence()
{
var current = new StructuredCvProfile { Education = { new() { Id = "degree", Qualification = "BSc", Institution = "Saved University", Location = "Oslo", Start = "2018", End = "2021", Details = { "Saved detail" } } } };
current.Metadata.Fields["education.0.details.0"] = new() { SourceBlockId = "saved-source", SourceExtractionRunId = 3 };
var result = Map(current, "Database specialisation", new CvImportRecordMapping("detail", "education", 0, new[] { Field("details", "Database specialisation") }));
var education = Assert.Single(result.Education);
Assert.Equal("degree", education.Id);
Assert.Equal("BSc", education.Qualification);
Assert.Equal("Saved University", education.Institution);
Assert.Equal("Oslo", education.Location);
Assert.Equal("2018", education.Start);
Assert.Equal("2021", education.End);
Assert.Equal(new[] { "Saved detail", "Database specialisation" }, education.Details);
Assert.Equal("saved-source", result.Metadata.Fields["education.0.details.0"].SourceBlockId);
Assert.Equal(20, result.Metadata.Fields["education.0.details.1"].SourceExtractionRunId);
}
[Fact]
public void Rejected_extracted_choices_are_not_saved_as_a_side_effect_of_authoring_a_missing_record()
{
var current = new StructuredCvProfile { Contact = new() { FullName = "Saved name", GitHub = "https://github.com/saved" }, Skills = { "Saved skill" } };
current.Metadata.Fields["contact.fullName"] = new() { SourceExtractionRunId = 2 };
var incoming = Profile(Job("Rejected employer"));
incoming.Contact.FullName = "Rejected name";
incoming.Skills.Add("Rejected skill");
var kept = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "keep"));
var result = Map(kept, "Mapped Engineer | Reviewed employer", new CvImportRecordMapping("only-approved", "jobs", null,
new[] { Field("title", "Mapped Engineer"), Field("company", "Reviewed employer") }));
var job = Assert.Single(result.Jobs);
Assert.Equal("Reviewed employer", job.Company);
Assert.Equal("Saved name", result.Contact.FullName);
Assert.Equal("https://github.com/saved", result.Contact.GitHub);
Assert.Equal(new[] { "Saved skill" }, result.Skills);
Assert.Equal(2, result.Metadata.Fields["contact.fullName"].SourceExtractionRunId);
Assert.DoesNotContain(result.Metadata.Fields.Values, m => m.SourceSnippet is "Rejected name" or "Rejected skill");
}
[Fact]
public void A_target_that_disappeared_from_the_current_snapshot_is_rejected_without_mutation()
{
var current = Profile(Job("First Labs"), Job("Second Labs"));
var mapping = new CvImportRecordMapping("explicit", "jobs", 1, new[] { Field("bullets", "New achievement") });
CvImportMapping.ValidateTargets(current, new[] { mapping });
var afterRemoval = Profile(Job("First Labs"));
Assert.Throws<ArgumentException>(() => CvImportMapping.ValidateTargets(afterRemoval, new[] { mapping }));
Assert.Throws<ArgumentException>(() => Map(afterRemoval, "New achievement", mapping));
Assert.Empty(afterRemoval.Jobs[0].Bullets);
}
[Fact]
public void Repeated_explicit_target_list_approval_is_additive_and_keeps_the_first_evidence()
{
var current = Profile(Job());
var mapping = new CvImportRecordMapping("explicit", "jobs", 0, new[] { Field("bullets", "New achievement") });
var first = Map(current, "New achievement", mapping);
var second = CvImportMapping.Apply(first, new[] { mapping }, Source("New achievement"), 99, 99, "later-hash");
Assert.Single(second.Jobs);
Assert.Equal(new[] { "New achievement" }, second.Jobs[0].Bullets);
Assert.Equal(20, second.Metadata.Fields["jobs.0.bullets.0"].SourceExtractionRunId);
}
[Fact]
public void Duplicate_client_ids_and_conflicting_scalar_mappings_fail_without_changing_the_input()
{
var current = Profile(Job());
var first = new CvImportRecordMapping("same", "jobs", 0, new[] { Field("title", "First title") });
var second = new CvImportRecordMapping("other", "jobs", 0, new[] { Field("title", "Second title") });
Assert.Throws<ArgumentException>(() => Map(current, "First title Second title", first, first));
Assert.Throws<ArgumentException>(() => Map(current, "First title Second title", first, second));
Assert.Equal("Engineer", current.Jobs[0].Title);
Assert.Empty(current.Metadata.Fields);
}
[Fact]
public void Null_mapping_target_is_a_deliberate_new_record_even_when_its_identity_already_exists()
{
var current = Profile(Job());
current.Jobs[0].Id = "saved";
var result = Map(current, "Engineer Example Labs", new CvImportRecordMapping("deliberately-new", "jobs", null,
new[] { Field("title", "Engineer"), Field("company", "Example Labs") }));
Assert.Equal(2, result.Jobs.Count);
Assert.Equal("saved", result.Jobs[0].Id);
Assert.Null(result.Jobs[1].Id);
Assert.Null(result.Jobs[1].Start);
Assert.Null(result.Jobs[1].End);
}
}