Consolidation runs passes until one changes nothing; a changing reply ends the pass and the next starts from the first seed, so the last pass's groups are the ones the next run forms. Memory is pruned to that pass. A stable neighbourhood after a merge is no longer asked again the next night. Assign treats a redirected slug, proposed as new or referenced directly, as its live target instead of dropping the note. A plan reset deletes article files whose slug the new plan lacks. The sub-articles heading has no id, so it cannot collide with a body heading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
9.5 KiB
C#
237 lines
9.5 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_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));
|
|
}
|
|
}
|