Files
jobtrackingapp/JobTrackerApi.Tests/CvAiReadinessTests.cs
T

391 lines
25 KiB
C#

using System.Text.Json;
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Tests.TestSupport;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CvAiReadinessTests
{
private const string Source = "Built and maintained internal web applications.";
private const string Paraphrase = "Developed and maintained internal web applications.";
private sealed class Fixture : IAsyncDisposable
{
public readonly SqliteConnection Connection = new("Data Source=:memory:");
public readonly Mock<ISummarizerService> Ai = new();
public readonly IDataProtectionProvider Protection = new EphemeralDataProtectionProvider();
public JobTrackerContext Db = null!;
public CareerProfileService Career = null!;
public CvVariantService Variants = null!;
public CvAiSuggestionService Suggestions = null!;
public CvVariant Cv = null!;
public StructuredCvProfile Profile = new() { Contact = new() { FullName = "Åse Synthetic", Headline = Source }, Summary = { Source } };
public async Task Open()
{
await Connection.OpenAsync();
var current = new Mock<ICurrentUserService>();
current.SetupGet(value => value.UserId).Returns("owner");
Db = new(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(Connection).Options, current.Object);
await Db.Database.EnsureCreatedAsync();
Career = new(Db);
await Career.SaveVersionAsync("owner", Profile, "manual", default);
Variants = new(Db, Career, new ThemedCvRenderer());
Cv = await Variants.CreateAsync("owner", "Synthetic CV", null, new CvVariantSettings(), default);
Suggestions = new(Db, Career, Variants, Ai.Object, Protection);
Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(Paraphrase));
Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string candidate, string proposal, string? context, CancellationToken ct) =>
new AiGenerationResult(Assessment(proposal, Source)));
}
public Task<CvAiProposal> Generate(string? context = null, string target = "section:summary") =>
Suggestions.GenerateAsync("owner", Cv.Id, 1, target, "improve", Source, context, "Rewrite supported facts only.", default);
public CvVariantController Controller(bool reviewAvailable = true)
{
var user = new ApplicationUser { Id = "owner" };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(value => value.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
return new(users.Object, Variants, Mock.Of<ICvPdfExporter>(), Ai.Object, suggestions: reviewAvailable ? Suggestions : null)
{ ControllerContext = new() { HttpContext = new DefaultHttpContext
{ User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "owner") }, "local")) } } };
}
public async Task AssertUnchanged()
{
Db.ChangeTracker.Clear();
var saved = await Db.CvVariants.SingleAsync();
Assert.Equal(1, saved.Version);
Assert.Null(CvVariantSettingsJson.Deserialize(saved.SettingsJson).Headline);
Assert.Empty(CvVariantSettingsJson.Deserialize(saved.SettingsJson).Sections);
Assert.Single(await Db.CvVariantVersions.ToListAsync());
Assert.Equal(Source, (await Career.LoadStructuredForOwnerAsync("owner", default)).Contact.Headline);
Assert.Single(await Db.CareerProfileVersions.ToListAsync());
}
public async ValueTask DisposeAsync() { await Db.DisposeAsync(); await Connection.DisposeAsync(); }
}
private static string Assessment(string proposal, string source, string verdict = "supported") => JsonSerializer.Serialize(new
{ verdict, coverageComplete = true, claims = new[] { new { text = proposal, verdict, sourceQuotes = new[] { source } } } });
[Theory]
[InlineData(Paraphrase)]
[InlineData("Maintained internal web applications.")]
public async Task Grounded_rewriting_is_applied_only_on_accept_and_uses_normal_history(string rewrite)
{
await using var fixture = new Fixture(); await fixture.Open();
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(rewrite));
var proposal = await fixture.Generate();
await fixture.AssertUnchanged(); // generation has no content/history mutation
var accepted = await fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, null, default);
Assert.Equal(2, accepted.Version);
Assert.Equal(rewrite, CvVariantSettingsJson.Deserialize(accepted.SettingsJson).Sections.Single().Items!.Single());
Assert.Equal("ai:improve", (await fixture.Db.CvVariantVersions.OrderBy(value => value.Version).LastAsync()).Source);
Assert.Equal(Source, (await fixture.Career.LoadStructuredForOwnerAsync("owner", default)).Summary.Single());
Assert.Equal(Source, (await fixture.Career.LoadStructuredForOwnerAsync("owner", default)).Contact.Headline);
var historical = await fixture.Db.CvVariantVersions.SingleAsync(value => value.Version == 1);
Assert.Empty(CvVariantSettingsJson.Deserialize(historical.SettingsJson).Sections);
var replay = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, null, default));
Assert.Equal(409, replay.Status);
}
[Theory]
[InlineData("Experienced with Kubernetes.")]
[InlineData("Senior engineer at Microsoft.")]
[InlineData("Completed a master's degree and Azure certification.")]
[InlineData("Managed 50 engineers at Microsoft and increased revenue 300%.")]
[InlineData("Built internal web applications and led international security audits.")]
[InlineData("Flytende fransk og sertifisert prosjektleder.")]
[InlineData("Worked at Microsoft from 2018 to 2025.")]
public async Task Fabricated_or_mixed_provider_output_cannot_obtain_an_applicable_proposal(string invented)
{
await using var fixture = new Fixture(); await fixture.Open();
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(invented));
fixture.Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(Assessment(invented, Source, "unsupported")));
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Generate());
Assert.Equal(422, error.Status);
Assert.Equal("cv_ai_grounding_unproven", error.Code);
await fixture.AssertUnchanged();
}
[Fact]
public async Task Job_requirements_are_context_not_candidate_evidence()
{
await using var fixture = new Fixture(); await fixture.Open();
const string requirement = "Kubernetes required";
const string invented = "Experienced with Kubernetes";
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(invented));
// Even a 'supported' verdict cannot cite the advert as candidate evidence.
fixture.Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), invented, requirement, It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(Assessment(invented, requirement)));
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Generate(requirement));
Assert.Equal(422, error.Status);
fixture.Ai.Verify(value => value.EvaluateCvRewriteAsync(It.Is<string>(source => !source.Contains("Kubernetes")), invented, requirement, It.IsAny<CancellationToken>()), Times.Once);
await fixture.AssertUnchanged();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Pending_proposal_cannot_overwrite_newer_CV_or_canonical_source(bool canonical)
{
await using var fixture = new Fixture(); await fixture.Open();
var proposal = await fixture.Generate();
if (canonical)
{
fixture.Profile.Summary = ["Newer canonical user content"];
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
}
else await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
new CvVariantSettings { Headline = "Newer CV user content" }, "manual", 1, default);
var historyCount = await fixture.Db.CvVariantVersions.CountAsync();
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, null, default));
Assert.Equal(409, error.Status);
Assert.Equal("cv_ai_source_changed", error.Code);
fixture.Db.ChangeTracker.Clear();
Assert.Equal(historyCount, await fixture.Db.CvVariantVersions.CountAsync());
var saved = CvVariantSettingsJson.Deserialize((await fixture.Db.CvVariants.SingleAsync()).SettingsJson);
Assert.Empty(saved.Sections);
if (canonical) Assert.Equal("Newer canonical user content", (await fixture.Career.LoadStructuredForOwnerAsync("owner", default)).Summary.Single());
else Assert.Equal("Newer CV user content", saved.Headline);
}
[Fact]
public async Task Source_change_during_generation_does_not_issue_a_stale_proposal()
{
await using var fixture = new Fixture(); await fixture.Open();
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns(async () =>
{
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null, new CvVariantSettings { Headline = "Newer" }, "manual", 1, default);
return new AiGenerationResult(Paraphrase);
});
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Generate());
Assert.Equal(409, error.Status);
Assert.Equal(2, await fixture.Db.CvVariantVersions.CountAsync());
}
[Fact]
public async Task Canonical_change_during_edited_proposal_review_is_checked_again_before_save()
{
await using var fixture = new Fixture(); await fixture.Open();
var proposal = await fixture.Generate();
const string shortened = "Maintained internal web applications.";
fixture.Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), shortened, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(async () =>
{
fixture.Profile.Summary = ["Newer canonical content"];
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
return new AiGenerationResult(Assessment(shortened, Source));
});
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, shortened, default));
Assert.Equal(409, error.Status);
fixture.Db.ChangeTracker.Clear();
Assert.Single(await fixture.Db.CvVariantVersions.ToListAsync());
Assert.Equal(1, (await fixture.Db.CvVariants.SingleAsync()).Version);
Assert.Equal("Newer canonical content", (await fixture.Career.LoadStructuredForOwnerAsync("owner", default)).Summary.Single());
}
[Fact]
public async Task Whitespace_consistency_check_does_not_block_supported_rewriting()
{
await using var fixture = new Fixture(); await fixture.Open();
var proposal = await fixture.Suggestions.GenerateAsync("owner", fixture.Cv.Id, 1, "section:summary", "improve", " " + Source + "\r\n\n", null, "Rewrite", default);
Assert.Equal(Source, proposal.Original);
await fixture.AssertUnchanged();
}
[Fact]
public async Task Whole_document_proposal_cannot_review_multiple_headline_items_then_drop_one_on_apply()
{
await using var fixture = new Fixture(); await fixture.Open();
const string invalid = "[{\"id\":\"headline\",\"items\":[\"First\",\"Second\"]},{\"id\":\"section:summary\",\"title\":\"Summary\",\"items\":[]}]";
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), AiGenerationOutputMode.CvBlocksJson, It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(invalid));
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.GenerateAsync("owner", fixture.Cv.Id, 1, null, "document-improve", null, null, "Rewrite", default));
Assert.Equal("cv_ai_proposal_invalid", error.Code);
fixture.Ai.Verify(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
await fixture.AssertUnchanged();
}
[Fact]
public async Task Edited_proposal_is_re_evaluated_and_unsupported_edit_has_no_mutation()
{
await using var fixture = new Fixture(); await fixture.Open();
var proposal = await fixture.Generate();
const string invented = "Invented job at Microsoft";
fixture.Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), invented, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(Assessment(invented, Source, "unsupported")));
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, invented, default));
Assert.Equal(422, error.Status);
await fixture.AssertUnchanged();
}
[Fact]
public async Task Tampered_token_cannot_bypass_grounding_or_select_another_target()
{
await using var fixture = new Fixture(); await fixture.Open();
var proposal = await fixture.Generate();
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, "forged-grounded-true-" + proposal.ProposalToken, null, default));
Assert.Equal(400, error.Status);
await fixture.AssertUnchanged();
}
[Fact]
public async Task Protected_proposal_is_bound_to_owner_and_intended_CV()
{
await using var fixture = new Fixture(); await fixture.Open();
var proposal = await fixture.Generate();
var ownerError = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("other-account", fixture.Cv.Id, proposal.ProposalToken, null, default));
var cvError = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id + 1, proposal.ProposalToken, null, default));
Assert.Equal(404, ownerError.Status); Assert.Equal(404, cvError.Status);
var generation = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.GenerateAsync("other-account", fixture.Cv.Id, 1, "section:summary", "improve", null, null, "Rewrite", default));
Assert.Equal(404, generation.Status);
await fixture.AssertUnchanged();
}
[Fact]
public async Task Evaluator_unavailable_fails_closed_and_provider_error_never_changes_content()
{
await using var fixture = new Fixture(); await fixture.Open();
fixture.Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((AiGenerationResult?)null);
Assert.Equal(422, (await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Generate())).Status);
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new AiGenerationException("provider_unavailable", "Synthetic outage", true));
await Assert.ThrowsAsync<AiGenerationException>(() => fixture.Generate());
await fixture.AssertUnchanged();
}
[Fact]
public async Task Outdated_unbound_API_request_fails_closed_before_provider_generation()
{
await using var fixture = new Fixture(); await fixture.Open();
var response = await fixture.Controller().AiAssist(new("improve", Source, null, null, "en"), default);
Assert.Equal(428, Assert.IsType<ObjectResult>(response.Result).StatusCode);
fixture.Ai.Verify(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
await fixture.AssertUnchanged();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Unavailable_review_service_blocks_generation_and_apply_without_side_effects(bool apply)
{
await using var fixture = new Fixture(); await fixture.Open();
var controller = fixture.Controller(reviewAvailable: false);
var response = apply
? Assert.IsType<ObjectResult>((await controller.ApplyAi(fixture.Cv.Id, new("unused-proposal", Paraphrase), default)).Result)
: Assert.IsType<ObjectResult>((await controller.AiAssist(new("improve", Source, null, null, "en", fixture.Cv.Id, 1, "section:summary"), default)).Result);
Assert.Equal(503, response.StatusCode);
Assert.Equal("cv_ai_unavailable", JsonSerializer.SerializeToElement(response.Value).GetProperty("code").GetString());
fixture.Ai.VerifyNoOtherCalls();
await fixture.AssertUnchanged();
}
[Fact]
public async Task Confirmed_provider_fabrication_reproduction_is_now_an_API_rejection()
{
await using var fixture = new Fixture(); await fixture.Open();
const string invented = "Managed 50 engineers at Microsoft and increased revenue 300%.";
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(invented));
// Numerics are rejected independently, even if the evaluator asserts support.
fixture.Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), invented, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiGenerationResult(Assessment(invented, Source)));
var response = await fixture.Controller().AiAssist(new("improve", Source, null, null, "en", fixture.Cv.Id, 1, "section:summary"), default);
Assert.Equal(422, Assert.IsType<ObjectResult>(response.Result).StatusCode);
await fixture.AssertUnchanged();
}
[Theory]
[InlineData("ai:improve")]
[InlineData(" AI:grammar ")]
public async Task Authored_save_cannot_forge_server_AI_provenance(string source)
{
await using var fixture = new Fixture(); await fixture.Open();
var response = await fixture.Controller().Save(fixture.Cv.Id,
new(null, new CvVariantSettings { Headline = "Forged approval" }, source, 1), default);
Assert.IsType<BadRequestObjectResult>(response.Result);
await fixture.AssertUnchanged();
}
[Fact]
public async Task Confirmed_stale_apply_reproduction_is_now_an_API_conflict()
{
await using var fixture = new Fixture(); await fixture.Open();
var proposal = await fixture.Generate();
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
new CvVariantSettings { Headline = "Newer user content" }, "manual", 1, default);
var response = await fixture.Controller().ApplyAi(fixture.Cv.Id, new(proposal.ProposalToken), default);
Assert.Equal(409, Assert.IsType<ObjectResult>(response.Result).StatusCode);
fixture.Db.ChangeTracker.Clear();
Assert.Equal("Newer user content", CvVariantSettingsJson.Deserialize((await fixture.Db.CvVariants.SingleAsync()).SettingsJson).Headline);
Assert.Equal(2, await fixture.Db.CvVariantVersions.CountAsync());
}
[Fact]
public async Task Omitted_content_remains_editable_without_becoming_document_evidence()
{
await using var fixture = new Fixture(); await fixture.Open();
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
new CvVariantSettings { Sections = { new() { Key = "summary", Hidden = true } } }, "manual", 1, default);
var proposal = await fixture.Suggestions.GenerateAsync("owner", fixture.Cv.Id, 2, "section:summary", "improve", Source, null, "Rewrite", default);
var saved = await fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, null, default);
var section = CvVariantSettingsJson.Deserialize(saved.SettingsJson).Sections.Single();
Assert.True(section.Hidden);
Assert.Equal(Paraphrase, section.Items!.Single());
Assert.Equal(Source, (await fixture.Career.LoadStructuredForOwnerAsync("owner", default)).Summary.Single());
}
[Fact]
public async Task Whole_document_accept_maps_known_blocks_only_and_preserves_the_rest_of_the_lens()
{
await using var fixture = new Fixture(); await fixture.Open();
fixture.Profile.Jobs = [new() { Id = "experience-key", Title = "Engineer", Company = "Nordlys", Bullets = { Source } }];
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null, new CvVariantSettings
{ AccentColor = "#123456", PageMarginMm = 22, Sections = { new() { Key = "experience" }, new() { Key = "summary" } } }, "manual", 1, default);
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), AiGenerationOutputMode.CvBlocksJson, It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string instruction, string text, AiGenerationOutputMode mode, int max, int min, CancellationToken ct) =>
new AiGenerationResult(text.Replace(Source, Paraphrase, StringComparison.Ordinal)));
fixture.Ai.Setup(value => value.EvaluateCvRewriteAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string candidate, string proposal, string? context, CancellationToken ct) =>
{
using var parsed = JsonDocument.Parse(proposal);
var values = parsed.RootElement.EnumerateArray().SelectMany(block => block.EnumerateObject()
.Where(property => property.Name != "id").SelectMany(property => property.Value.ValueKind == JsonValueKind.Array
? property.Value.EnumerateArray().Select(item => item.GetString()!) : new[] { property.Value.GetString()! }));
return new AiGenerationResult(JsonSerializer.Serialize(new { verdict = "supported", coverageComplete = true,
claims = values.Select(value => new { text = value, verdict = "supported", sourceQuotes = new[] { value == Paraphrase ? Source : value } }) }));
});
var proposal = await fixture.Suggestions.GenerateAsync("owner", fixture.Cv.Id, 2, null, "document-improve", null, null, "Rewrite", default);
var saved = await fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, null, default);
var settings = CvVariantSettingsJson.Deserialize(saved.SettingsJson);
Assert.Equal(3, saved.Version);
Assert.Equal(Paraphrase, settings.Headline);
Assert.Equal(Paraphrase, settings.Overrides["experience-key"].Bullets!.Single());
Assert.Equal("Engineer", settings.Overrides["experience-key"].Title);
Assert.Equal("Nordlys", settings.Overrides["experience-key"].Subtitle);
Assert.Equal(new[] { "experience", "summary" }, settings.Sections.Select(value => value.Key));
Assert.Equal("#123456", settings.AccentColor); Assert.Equal(22, settings.PageMarginMm);
Assert.Equal(Source, (await fixture.Career.LoadStructuredForOwnerAsync("owner", default)).Jobs.Single().Bullets.Single());
}
}