Files
Wiki/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs
T
claude-botandClaude Opus 5.5 af7ca24819
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
fix(encyclopedia): a planning reply that repeats a property is unusable
JsonObject builds its dictionary on first access and throws ArgumentException
on a repeated key, which escaped reply validation and failed the whole run
(assign reply with titleEn twice). Parsing now rejects duplicates up front, so
the reply counts as unusable like any other malformed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:11:52 +01:00

251 lines
10 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Wiki.Application.Encyclopedia;
using Wiki.Core.Model;
namespace Wiki.Application.Tests;
public class AssignNotesUseCaseTests
{
private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null);
private static AssignNotesUseCase UseCase(FakeTextModel model) =>
new(model, Settings, NullLogger<AssignNotesUseCase>.Instance);
private static PlannedArticle Article(string slug, params string[] sources) =>
new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug, sources);
private static ArticlePlan PlanWith(params PlannedArticle[] articles)
{
var plan = ArticlePlan.Empty();
foreach (var a in articles) plan.AddArticle(a);
return plan;
}
private static Document[] Batch(params string[] slugs) =>
slugs.Select(s => TestNotes.Note(s)).ToArray();
[Fact]
public void Prompt_RoomNotesAlsoFeedTheRoomArticle()
{
Assert.Contains("additionally feeds that room's article (category room)", Prompts.Assign);
Assert.Contains("\"EG Schlafzimmer\"", Prompts.Assign);
}
[Fact]
public async Task Assign_ExistingAndNew()
{
var heating = Article("heating", "old");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"Garten","titleEn":"Garden","category":"room","scope":"Der Garten."}],
"notes":[{"note":"a","articles":["heating","garden"]},{"note":"b","articles":["garden"]},{"note":"c","skip":true}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b", "c"), [heating], PlanWith(heating), CancellationToken.None);
Assert.False(outcome.Unusable);
var garden = Assert.Single(outcome.New);
Assert.Equal("garden", garden.Slug);
Assert.Equal(ArticleCategory.Room, garden.Category);
Assert.Equal(["a", "b"], garden.Sources);
Assert.Equal(["heating", "garden"], outcome.Assignments["a"]);
Assert.Empty(outcome.Assignments["c"]);
}
[Fact]
public async Task Assign_NoteOutsideBatch_Dropped()
{
var heating = Article("heating");
var model = new FakeTextModel().Reply("""{"new":[],"notes":[{"note":"a","articles":["heating"]},{"note":"zzz","articles":["heating"]}]}""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [heating], PlanWith(heating), CancellationToken.None);
Assert.Equal(["a"], outcome.Assignments.Keys);
}
[Fact]
public async Task Assign_ArticleOutsideCandidates_Dropped()
{
var heating = Article("heating");
var other = Article("other");
var model = new FakeTextModel().Reply("""{"new":[],"notes":[{"note":"a","articles":["heating","other"]},{"note":"b","articles":["other"]}]}""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], PlanWith(heating, other), CancellationToken.None);
Assert.Equal(["heating"], outcome.Assignments["a"]);
Assert.False(outcome.Assignments.ContainsKey("b"));
}
[Fact]
public async Task Assign_NewWithExistingNonCandidateSlug_MergedIntoExistingArticle()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden"));
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.False(outcome.Unusable);
Assert.Empty(outcome.New);
Assert.Equal(["garden"], outcome.Assignments["a"]);
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_NewWithRedirectedSlug_ReferencesTheFinalTarget()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden"), Article("yard"), Article("lawn"));
plan.Fold("lawn", "yard", isPublic: false);
plan.Fold("yard", "garden", isPublic: false);
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"lawn","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["lawn","heating"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.Empty(outcome.New);
Assert.Equal(["garden", "heating"], outcome.Assignments["a"]);
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_RedirectedSlugReferencedDirectly_ReferencesTheTarget()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("waermepumpe"));
plan.Fold("waermepumpe", "heating", isPublic: false);
var model = new FakeTextModel().Reply("""{"new":[],"notes":[{"note":"a","articles":["waermepumpe"]}]}""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [], plan, CancellationToken.None);
Assert.False(outcome.Unusable);
Assert.Equal(["heating"], outcome.Assignments["a"]);
}
[Fact]
public async Task Assign_NewWithRetiredSlug_DroppedAndItsNotesUnassigned()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden", "gone"));
plan.RemoveNote("gone");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.Empty(outcome.New);
Assert.False(outcome.Assignments.ContainsKey("a"));
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_UnreferencedNew_Dropped()
{
var heating = Article("heating");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [heating], PlanWith(heating), CancellationToken.None);
Assert.False(outcome.Unusable);
Assert.Empty(outcome.New);
}
[Fact]
public async Task Assign_Truncated_IsUnusable()
{
var model = new FakeTextModel().Reply("""{"new":[],"notes":[{"note":"a","skip":true}]}""", truncated: true);
var outcome = await UseCase(model).AssignAsync(Batch("a"), [], ArticlePlan.Empty(), CancellationToken.None);
Assert.True(outcome.Unusable);
Assert.Empty(outcome.Assignments);
}
[Fact]
public async Task Assign_DuplicatePropertyInReply_IsUnusable()
{
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","titleEn":"H","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [], ArticlePlan.Empty(), CancellationToken.None);
Assert.True(outcome.Unusable);
Assert.Empty(outcome.Assignments);
}
[Fact]
public async Task Assign_PromptIsBounded()
{
var articles = Enumerable.Range(0, 500).Select(i => Article($"article-{i}", "n")).ToArray();
var plan = PlanWith(articles);
var batch = Batch(Enumerable.Range(0, 20).Select(i => $"note-{i:00}").ToArray());
var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}""");
await UseCase(model).AssignAsync(batch, articles.Take(100).ToArray(), plan, CancellationToken.None);
var user = model.Calls[0].User;
Assert.Equal(20, user.Split('\n').Count(l => l.StartsWith("note-")));
Assert.Equal(20, user.Split('\n').Count(l => l.StartsWith("article-")));
Assert.DoesNotContain("article-20 ", user);
}
[Fact]
public async Task Assign_FirstParagraph_SkipsHeadingsAndBlankLines()
{
var note = TestNotes.Note("a") with { Body = "# T\n\n \nfirst line\ncontinued\n\nsecond para" };
var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}""");
await UseCase(model).AssignAsync([note], [], ArticlePlan.Empty(), CancellationToken.None);
var line = model.Calls[0].User.Split('\n').Single(l => l.StartsWith("a | "));
Assert.EndsWith(" | first line continued", line);
}
[Fact]
public async Task Assign_FirstParagraph_CutTo300()
{
var note = TestNotes.Note("a") with { Body = new string('y', 500) };
var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}""");
await UseCase(model).AssignAsync([note], [], ArticlePlan.Empty(), CancellationToken.None);
var line = model.Calls[0].User.Split('\n').Single(l => l.StartsWith("a | "));
Assert.EndsWith(" | " + new string('y', 300), line);
}
[Fact]
public async Task Assign_NewWithoutTitle_Dropped()
{
var heating = Article("heating");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden","heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [heating], PlanWith(heating), CancellationToken.None);
Assert.Empty(outcome.New);
Assert.Equal(["heating"], outcome.Assignments["a"]);
}
[Fact]
public async Task Assign_ModelUnavailable_Propagates()
{
var model = new FakeTextModel().Throw(new ModelUnavailableException("down"));
await Assert.ThrowsAsync<ModelUnavailableException>(() =>
UseCase(model).AssignAsync(Batch("a"), [], ArticlePlan.Empty(), CancellationToken.None));
}
}