using Microsoft.Extensions.Logging.Abstractions; using Wiki.Application.Encyclopedia; using Wiki.Core.Model; namespace Wiki.Application.Tests; public class SplitArticleUseCaseTests { private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null) { MaxSourcesPerArticle = 4 }; private static SplitArticleUseCase UseCase(FakeTextModel model) => new(model, Settings, NullLogger.Instance); private static PlannedArticle Article(string slug, params string[] sources) => new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug, sources); private static Dictionary Notes(params string[] slugs) => slugs.ToDictionary(s => s, s => TestNotes.Note(s)); private static ArticlePlan PlanWith(params PlannedArticle[] articles) { var plan = ArticlePlan.Empty(); foreach (var a in articles) plan.AddArticle(a); return plan; } private const string Valid = """ {"overview":{"scope":"Allgemein.","sources":["a"]}, "children":[{"slug":"x","titleDe":"X","titleEn":"X","category":"device","scope":"sx","sources":["b"]}, {"slug":"y","titleDe":"Y","titleEn":"Y","category":"topic","scope":"sy","sources":["c","d"]}]} """; [Fact] public async Task Split_Valid() { var article = Article("heating", "a", "b", "c", "d"); var model = new FakeTextModel().Reply(Valid); var outcome = await UseCase(model).SplitAsync(article, Notes("a", "b", "c", "d"), PlanWith(article), CancellationToken.None); Assert.NotNull(outcome); Assert.Equal("heating", outcome.Overview.Slug); Assert.Equal("Titel heating", outcome.Overview.TitleDe); Assert.Equal(ArticleCategory.System, outcome.Overview.Category); Assert.Equal("Allgemein.", outcome.Overview.Scope); Assert.Equal(["a"], outcome.Overview.Sources); Assert.Equal(["x", "y"], outcome.Children.Select(c => c.Slug)); Assert.Equal(ArticleCategory.Device, outcome.Children[0].Category); Assert.Equal(["c", "d"], outcome.Children[1].Sources); Assert.Equal(Settings.SplitMaxTokens, model.Calls[0].MaxTokens); Assert.StartsWith("[split]", model.Calls[0].System); } [Fact] public async Task Split_LostSource_IsNull() { var article = Article("heating", "a", "b", "c", "d"); var model = new FakeTextModel().Reply(""" {"overview":{"scope":"s","sources":["a"]}, "children":[{"slug":"x","titleDe":"X","titleEn":"X","category":"device","scope":"sx","sources":["b"]}, {"slug":"y","titleDe":"Y","titleEn":"Y","category":"topic","scope":"sy","sources":["c"]}]} """); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b", "c", "d"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_OneChild_IsNull() { var article = Article("heating", "a", "b"); var model = new FakeTextModel().Reply(""" {"overview":{"scope":"s","sources":["a"]}, "children":[{"slug":"x","titleDe":"X","titleEn":"X","category":"device","scope":"sx","sources":["b"]}]} """); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_SevenChildren_IsNull() { var article = Article("heating", "a", "b"); var children = string.Join(",", Enumerable.Range(0, 7).Select(i => $$"""{"slug":"c{{i}}","titleDe":"X","titleEn":"X","category":"device","scope":"s","sources":["b"]}""")); var model = new FakeTextModel().Reply($$"""{"overview":{"scope":"s","sources":["a"]},"children":[{{children}}]}"""); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_ChildSlugTaken_IsNull() { var article = Article("heating", "a", "b", "c", "d"); var model = new FakeTextModel().Reply(Valid); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b", "c", "d"), PlanWith(article, Article("x", "z")), CancellationToken.None)); } [Fact] public async Task Split_ChildSlugDuplicate_IsNull() { var article = Article("heating", "a", "b"); var model = new FakeTextModel().Reply(""" {"overview":{"scope":"s","sources":["a"]}, "children":[{"slug":"x","titleDe":"X","titleEn":"X","category":"device","scope":"sx","sources":["b"]}, {"slug":"x","titleDe":"Y","titleEn":"Y","category":"topic","scope":"sy","sources":["b"]}]} """); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_ChildWithoutSources_IsNull() { var article = Article("heating", "a", "b"); var model = new FakeTextModel().Reply(""" {"overview":{"scope":"s","sources":["a","b"]}, "children":[{"slug":"x","titleDe":"X","titleEn":"X","category":"device","scope":"sx","sources":["b"]}, {"slug":"y","titleDe":"Y","titleEn":"Y","category":"topic","scope":"sy","sources":[]}]} """); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_SourceOutsideInput_IsNull() { var article = Article("heating", "a", "b"); var model = new FakeTextModel().Reply(""" {"overview":{"scope":"s","sources":["a"]}, "children":[{"slug":"x","titleDe":"X","titleEn":"X","category":"device","scope":"sx","sources":["b","ghost"]}, {"slug":"y","titleDe":"Y","titleEn":"Y","category":"topic","scope":"sy","sources":["b"]}]} """); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_Truncated_IsNull() { var article = Article("heating", "a", "b", "c", "d"); var model = new FakeTextModel().Reply(Valid, truncated: true); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b", "c", "d"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_NotJson_IsNull() { var article = Article("heating", "a", "b"); var model = new FakeTextModel().Reply("sorry"); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_BadRequest_IsNull() { var article = Article("heating", "a", "b"); var model = new FakeTextModel().Throw(new HttpRequestException("bad", null, System.Net.HttpStatusCode.BadRequest)); Assert.Null(await UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_ModelUnavailable_Propagates() { var article = Article("heating", "a", "b"); var model = new FakeTextModel().Throw(new ModelUnavailableException("down")); await Assert.ThrowsAsync(() => UseCase(model).SplitAsync(article, Notes("a", "b"), PlanWith(article), CancellationToken.None)); } [Fact] public async Task Split_InputCappedAtMaxSources_RestStaysWithOverview() { var article = Article("heating", "a", "b", "c", "d", "e", "f"); var model = new FakeTextModel().Reply(Valid); var outcome = await UseCase(model).SplitAsync(article, Notes("a", "b", "c", "d", "e", "f"), PlanWith(article), CancellationToken.None); Assert.NotNull(outcome); Assert.Equal(["a", "e", "f"], outcome.Overview.Sources); var prompt = model.Calls[0].User; Assert.Contains("\nd | ", prompt); Assert.DoesNotContain("\ne | ", prompt); Assert.DoesNotContain("\nf | ", prompt); } [Fact] public async Task Split_PromptFlattensTitles() { var article = Article("heating", "a", "b", "c", "d"); var notes = Notes("a", "b", "c", "d"); notes["a"] = TestNotes.Note("a", title: "one | two\nthree"); var model = new FakeTextModel().Reply(Valid); await UseCase(model).SplitAsync(article, notes, PlanWith(article), CancellationToken.None); Assert.Contains("\na | one two three | ", model.Calls[0].User); } [Fact] public void NeedsSplit_ByCharsAndByCount() { var s = new EncyclopediaSettings(16000, 10, 1000, [], null) { MaxSourcesPerArticle = 3 }; var notes = new Dictionary { ["a"] = TestNotes.Note("a", 600), ["b"] = TestNotes.Note("b", 600), ["c"] = TestNotes.Note("c", 100), ["d"] = TestNotes.Note("d", 1), }; Assert.True(SplitArticleUseCase.NeedsSplit(Article("x", "a", "b"), notes, s)); Assert.False(SplitArticleUseCase.NeedsSplit(Article("x", "a", "c"), notes, s)); Assert.False(SplitArticleUseCase.NeedsSplit(Article("x", "a", "ghost"), notes, s)); Assert.True(SplitArticleUseCase.NeedsSplit(Article("x", "c", "d", "c2", "d2"), notes, s)); Assert.False(SplitArticleUseCase.NeedsSplit(Article("x", "c", "d", "ghost"), notes, s)); } }