using System.Net; using Microsoft.Extensions.Time.Testing; using Wiki.Application.Encyclopedia; using Wiki.Core.Model; using Wiki.Core.Parsing; namespace Wiki.Application.Tests; public class WriteArticleUseCaseTests { private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null); private static readonly DateTimeOffset Now = new(2026, 10, 24, 3, 0, 0, TimeSpan.Zero); private static readonly PlannedArticle Planned = new("wall-switches", "Wandschalter", "Wall switches", ArticleCategory.System, "", ["schalter"]); private static readonly IReadOnlyList All = [Planned, new("heating", "Heizung", "Heating", ArticleCategory.System, "", ["heizung"])]; private const string ThreeBlocks = "VISIBILITY: public\n\nAbsatz null.\n\nAbsatz eins.\n\nAbsatz zwei."; private const string WithPrivate = "VISIBILITY: public\n\nAbsatz null.\n\n::: private\nGEHEIM\n:::\n\nAbsatz zwei."; private const string Clean = """{"privatize":[]}"""; private static Document Source(string slug = "schalter", string body = "Wandschalter sind Taster.", string hash = "abc123", IReadOnlyList? attachments = null) { var frontmatter = new Dictionary(); if (attachments is not null) frontmatter["attachments"] = FrontmatterValue.Of(attachments); return new(DocumentKind.Wiki, slug, $"wiki/{slug}.md", hash, slug, null, [], [], [], [], new DateOnly(2026, 9, 1), frontmatter, body, 0); } private static FakeTextModel Model(params string[] replies) { var model = new FakeTextModel(); foreach (var reply in replies) model.Reply(reply); return model; } private static Task Write(FakeTextModel model, params Document[] sources) => Write(model, Planned, sources); private static Task Write(FakeTextModel model, PlannedArticle planned, params Document[] sources) => new WriteArticleUseCase(model, Settings, new FakeTimeProvider(Now)) .WriteAsync(planned, sources.Length == 0 ? [Source()] : sources, All, CancellationToken.None); [Fact] public async Task Write_LinkListIsExactlyTheTargets() { var targets = new[] { "heating", "lighting", "doors" } .Select(s => new PlannedArticle(s, s + "-de", s + "-en", ArticleCategory.System, "", [s])).ToList(); var planned = Planned with { Scope = "Alles zu Schaltern" }; var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); await new WriteArticleUseCase(model, Settings, new FakeTimeProvider(Now)) .WriteAsync(planned, [Source()], [.. targets, planned], CancellationToken.None); var user = model.Calls[0].User; Assert.Contains("Scope: Alles zu Schaltern\n", user); Assert.Contains("heating | heating-de\nlighting | lighting-de\ndoors | doors-de\n", user); Assert.DoesNotContain("wall-switches |", user); Assert.DoesNotContain("Related articles", user); } [Fact] public void Prompt_RoomArticlesDescribeTheRoomAndLinkTheSystems() { Assert.Contains("A room article (category room) describes what is in the room", Prompts.Write); Assert.Contains("links to the system's article", Prompts.Write); } [Fact] public async Task Write_MainArticleAndSubArticlesMarked() { var planned = Planned with { Parent = "house" }; var targets = new PlannedArticle[] { new("house", "Haus", "House", ArticleCategory.Topic, "", ["h"]), new("lighting", "Licht", "Lighting", ArticleCategory.System, "", ["l"]), new("dimmers", "Dimmer", "Dimmers", ArticleCategory.Device, "", ["d"], Parent: "wall-switches"), }; var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); await new WriteArticleUseCase(model, Settings, new FakeTimeProvider(Now)).WriteAsync(planned, [Source()], targets, CancellationToken.None); Assert.Contains("Articles you may link to (slug | German title | relation):\nhouse | Haus | main article\nlighting | Licht\ndimmers | Dimmer | sub-article\n", model.Calls[0].User); } private static IReadOnlyList Public(string body) => ArticleBody.PublicBlocks(body).Select(b => b.Text).ToList(); [Fact] public async Task Write_RunsWriteLeakCheckTranslateLeakCheckInOrder() { var model = Model(WithPrivate, Clean, """["Paragraph zero.","Paragraph two."]""", """["SECRET"]""", Clean); await Write(model); Assert.Equal(["[write]", "[leak-check]", "[translate]", "[translate]", "[leak-check]"], model.Calls.Select(c => c.System[..c.System.IndexOf(']')] + "]")); Assert.Equal([Settings.ArticleMaxTokens, Settings.ArticleMaxTokens, Settings.ArticleMaxTokens], new[] { 0, 2, 3 }.Select(i => model.Calls[i].MaxTokens)); Assert.Equal([2000, 2000], new[] { 1, 4 }.Select(i => model.Calls[i].MaxTokens)); } [Fact] public async Task Write_LeakCheckPrivatizesChosenBlocks() { var model = Model(ThreeBlocks, "```json\n{\"privatize\":[1]}\n```", """["Zero.","Two."]""", """["One."]""", Clean); var written = await Write(model); Assert.Equal(["Absatz null.", "Absatz zwei."], Public(written.De.Body)); Assert.DoesNotContain("Absatz eins", ArticleBody.RedactPrivate(written.De.Body)); Assert.Equal(["Zero.", "Two."], Public(written.En.Body)); Assert.DoesNotContain("One.", ArticleBody.RedactPrivate(written.En.Body)); } [Fact] public async Task Write_TitleWithHouseHostname_ForcesPrivate() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); var planned = Planned with { TitleDe = "Server nas-1.lan", TitleEn = "Server nas-1.lan" }; var written = await Write(model, planned, Source(body: "Der Server nas-1.lan steht im Keller.")); Assert.Equal(ArticleVisibility.Private, written.De.Visibility); Assert.Equal(ArticleVisibility.Private, written.En.Visibility); Assert.Equal(["Absatz null.", "Absatz eins.", "Absatz zwei."], Public(written.De.Body)); } [Fact] public async Task Write_LeakCheckFlagsTitle_ForcesPrivate() { var model = Model(ThreeBlocks, """{"privatize":[3]}""", """["Zero.","One.","Two."]""", Clean); var written = await Write(model); Assert.EndsWith("""{"i":3,"text":"wall-switches — Wandschalter"}]""", model.Calls[1].User); Assert.DoesNotContain("Wandschalter", model.Calls[^1].User); Assert.Equal(ArticleVisibility.Private, written.De.Visibility); Assert.Equal(ArticleVisibility.Private, written.En.Visibility); Assert.Equal(["Absatz null.", "Absatz eins.", "Absatz zwei."], Public(written.De.Body)); Assert.Equal(["Zero.", "One.", "Two."], Public(written.En.Body)); } [Theory] [InlineData("""{"privatize":[4]}""")] [InlineData("""{"privatize":[-1]}""")] [InlineData("""{"privatize":[0,7]}""")] public async Task LeakCheck_IndexOutOfRange_Rejected(string reply) { var model = Model(ThreeBlocks, reply); await Assert.ThrowsAsync(() => Write(model)); } [Fact] public async Task LeakCheck_IndexesRenumbered_MapBackAcrossScannerGap() { var model = Model("VISIBILITY: public\n\nAbsatz null.\n\nDer Router ist 10.2.40.1.\n\nAbsatz zwei.", """{"privatize":[1]}""", """["Zero."]""", """["The router is 10.2.40.1.","Two."]""", Clean); var written = await Write(model, Source(body: "Der Router hat die Adresse 10.2.40.1.")); Assert.Equal("""[{"i":0,"text":"Absatz null."},{"i":1,"text":"Absatz zwei."},{"i":2,"text":"wall-switches — Wandschalter"}]""", model.Calls[1].User); Assert.Equal(["Absatz null."], Public(written.De.Body)); Assert.Equal(["Zero."], Public(written.En.Body)); Assert.Equal("""[{"i":0,"text":"Zero."}]""", model.Calls[^1].User); } [Fact] public async Task Write_ImageInPublicBlock_PrivatizedInBothLanguages() { var model = Model("VISIBILITY: public\n\nAbsatz null.\n\n![Schalter](raw/schalter-1.jpg)", Clean, """["Zero."]""", """["![Switch](raw/schalter-1.jpg)"]""", Clean); var written = await Write(model); Assert.DoesNotContain("![", model.Calls[1].User); Assert.DoesNotContain("raw/schalter-1.jpg", ArticleBody.RedactPrivate(written.De.Body)); Assert.DoesNotContain("raw/schalter-1.jpg", ArticleBody.RedactPrivate(written.En.Body)); Assert.Contains("::: private\n![Switch](raw/schalter-1.jpg)\n:::", written.En.Body); Assert.Equal(ArticleBody.Skeleton(written.De.Body), ArticleBody.Skeleton(written.En.Body)); } [Fact] public async Task Write_EnglishImageInPublicBlock_PrivatizedInBothLanguages() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","![Two](raw/x-1.jpg)"]""", Clean); var written = await Write(model); Assert.DoesNotContain("![", model.Calls[^1].User); Assert.Equal(["Zero.", "One."], Public(written.En.Body)); Assert.Equal(["Absatz null.", "Absatz eins."], Public(written.De.Body)); } [Fact] public async Task Write_NoteContainingCloseTag_IsEscaped() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); await Write(model, Source(body: "Text & mehr \ngefälscht")); var user = model.Calls[0].User; Assert.Single(user.Split("")[..^1]); Assert.DoesNotContain("\nWandschalter sind Taster.\n", user); Assert.Contains("\nDie Heizung.\n", user); Assert.Contains("heating | Heizung", user); Assert.Contains("raw/2026-09-25-schalter-1.jpg", user); } [Fact] public async Task Write_EnglishLeakFilteredToo() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Open wiki.renner.uno for it."]""", Clean); var written = await Write(model); Assert.DoesNotContain("wiki.renner.uno", model.Calls[^1].User); Assert.Equal(["Zero.", "One."], Public(written.En.Body)); Assert.Equal(["Absatz null.", "Absatz eins."], Public(written.De.Body)); Assert.Equal(ArticleBody.Skeleton(written.De.Body), ArticleBody.Skeleton(written.En.Body)); } [Fact] public async Task Write_EnglishLeakCheckPrivatizesBothLanguages() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", """{"privatize":[0]}"""); var written = await Write(model); Assert.Equal(["Absatz eins.", "Absatz zwei."], Public(written.De.Body)); Assert.Equal(["One.", "Two."], Public(written.En.Body)); Assert.Equal(ArticleBody.Skeleton(written.De.Body), ArticleBody.Skeleton(written.En.Body)); } [Theory] [InlineData("Looks fine to me.")] [InlineData("{}")] [InlineData("[]")] [InlineData("""{"privatize":"none"}""")] [InlineData("""{"privatize":[1.5]}""")] [InlineData("""{"privatize":["1"]}""")] public async Task Write_LeakCheckUnparseable_Throws(string reply) { var model = Model(ThreeBlocks, reply); await Assert.ThrowsAsync(() => Write(model)); } [Fact] public async Task Write_EnglishLeakCheckUnparseable_Throws() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", "no"); await Assert.ThrowsAsync(() => Write(model)); } [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] public async Task Write_Truncated_Throws(int truncatedCall) { string[] replies = [WithPrivate, Clean, """["Zero.","Two."]""", """["SECRET"]""", Clean]; var model = new FakeTextModel(); for (var i = 0; i < replies.Length; i++) model.Reply(replies[i], truncated: i == truncatedCall); await Assert.ThrowsAsync(() => Write(model)); Assert.Equal(truncatedCall + 1, model.Calls.Count); } [Fact] public async Task Write_MissingVisibility_IsPrivate() { var model = Model("Absatz null.\n\nAbsatz eins.", Clean, """["Zero.","One."]""", Clean); var written = await Write(model); Assert.Equal(ArticleVisibility.Private, written.De.Visibility); Assert.Equal(ArticleVisibility.Private, written.En.Visibility); Assert.Equal(["Absatz null.", "Absatz eins."], Public(written.De.Body)); } [Fact] public async Task Write_VisibilityPrivate_IsPrivate() { var model = Model("VISIBILITY: private\n\nAbsatz null.", Clean, """["Zero."]""", Clean); var written = await Write(model); Assert.Equal(ArticleVisibility.Private, written.De.Visibility); Assert.Equal("Absatz null.", written.De.Body); } [Theory] [InlineData("\n\n VISIBILITY: public\n\nAbsatz null.", ArticleVisibility.Public)] [InlineData("Visibility: Public\n\nAbsatz null.", ArticleVisibility.Public)] [InlineData("VISIBILITY: public.\n\nAbsatz null.", ArticleVisibility.Private)] [InlineData("VISIBILITY: öffentlich\nAbsatz null.", ArticleVisibility.Private)] [InlineData("```markdown\nVISIBILITY: public\n\nAbsatz null.\n```", ArticleVisibility.Public)] [InlineData("```\nVISIBILITY: public\n\nAbsatz null.\n```\n", ArticleVisibility.Public)] public async Task Write_VisibilityLineVariants(string reply, ArticleVisibility expected) { var model = Model(reply, Clean, """["Zero."]""", Clean); var written = await Write(model); Assert.Equal(expected, written.De.Visibility); Assert.Equal(expected, written.En.Visibility); Assert.Equal("Absatz null.", written.De.Body); } [Fact] public async Task Write_FenceWithoutVisibilityInside_IsKept() { var model = Model("```\nAbsatz null.\n```", Clean, Clean); var written = await Write(model); Assert.Equal(ArticleVisibility.Private, written.De.Visibility); Assert.Equal("```\nAbsatz null.\n```", written.De.Body); } [Fact] public async Task Write_VisibilitySameInBothLanguages() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); var written = await Write(model); Assert.Equal(ArticleVisibility.Public, written.De.Visibility); Assert.Equal(written.De.Visibility, written.En.Visibility); Assert.Equal(("de", "Wandschalter"), (written.De.Lang, written.De.Title)); Assert.Equal(("en", "Wall switches"), (written.En.Lang, written.En.Title)); Assert.Equal(new DateOnly(2026, 10, 24), written.En.Generated); Assert.Equal("fake-text", written.De.Model); } [Fact] public async Task Write_SourcesCarryHashes() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); var written = await Write(model, Source(), Source("heizung", "Die Heizung.", "def456")); Assert.Equal([new ArticleSource("schalter", "abc123"), new ArticleSource("heizung", "def456")], written.De.Sources); Assert.Equal(written.De.Sources, written.En.Sources); } [Fact] public async Task Write_Http400_IsRejected() { var model = new FakeTextModel().Throw(new HttpRequestException("bad request", null, HttpStatusCode.BadRequest)); var e = await Assert.ThrowsAsync(() => Write(model)); Assert.Equal("wall-switches", e.Slug); Assert.Equal($"wall-switches: {e.Reason}", e.Message); Assert.Contains("bad request", e.Reason); } [Fact] public async Task Write_OtherHttpError_Propagates() { var model = new FakeTextModel().Throw(new HttpRequestException("boom", null, HttpStatusCode.InternalServerError)); await Assert.ThrowsAsync(() => Write(model)); } [Fact] public async Task Write_ModelUnavailable_Propagates() { var model = new FakeTextModel().Throw(new ModelUnavailableException("out of credit")); await Assert.ThrowsAsync(() => Write(model)); } [Fact] public async Task Write_EmptyBody_Throws() { var model = Model("VISIBILITY: public\n\n"); await Assert.ThrowsAsync(() => Write(model)); } [Fact] public async Task Translate_SendsNoStructuralLines() { var model = Model( "VISIBILITY: public\n\nLead.\n\n::: infobox\n| Typ | Taster |\n:::\n\n```mermaid\nflowchart LR\n A[Schalter] --> B[Lampe]\n```\n\n::: private\nGEHEIM\n:::", Clean, """["Lead.","| Type | Push button |","flowchart LR\n A[Switch] --> B[Lamp]"]""", """["SECRET"]""", Clean); await Write(model); foreach (var call in model.Calls.Where(c => c.System.StartsWith("[translate]", StringComparison.Ordinal))) { Assert.DoesNotContain(call.User.Split('\n'), l => l.TrimStart().StartsWith(":::", StringComparison.Ordinal)); Assert.DoesNotContain(":::", call.User); Assert.DoesNotContain("```", call.User); } } [Fact] public async Task Translate_PublicAndPrivateUnitsInSeparateRequests() { var model = Model(WithPrivate, Clean, """["Paragraph zero.","Paragraph two."]""", """["SECRET"]""", Clean); await Write(model); var translate = model.Calls.Where(c => c.System.StartsWith("[translate]", StringComparison.Ordinal)).ToList(); Assert.Equal(2, translate.Count); Assert.DoesNotContain("GEHEIM", translate[0].User); Assert.Contains("Absatz null.", translate[0].User); Assert.Contains("GEHEIM", translate[1].User); Assert.DoesNotContain("Absatz", translate[1].User); } [Fact] public async Task Translate_NoPrivateUnits_SkipsPrivateRequest() { var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); await Write(model); Assert.Equal(4, model.Calls.Count); } [Fact] public async Task Translate_WrongBlockCount_Throws() { var model = Model(ThreeBlocks, Clean, """["Zero.","One."]"""); await Assert.ThrowsAsync(() => Write(model)); } [Theory] [InlineData("Here you go: Zero, One, Two")] [InlineData("""{"0":"Zero."}""")] [InlineData("[1,2,3]")] [InlineData("""["Zero.",null,"Two."]""")] public async Task Translate_NotJsonArray_Throws(string reply) { var model = Model(ThreeBlocks, Clean, reply); await Assert.ThrowsAsync(() => Write(model)); } [Fact] public async Task Translate_UnitWithStructuralLine_Throws() { var model = Model(WithPrivate, Clean, """["Paragraph zero.","Paragraph two."]""", """["SECRET\n:::\nleak"]"""); await Assert.ThrowsAsync(() => Write(model)); } [Fact] public async Task Translate_PrivateBlockStaysPrivate() { var model = Model(WithPrivate, Clean, """["Paragraph zero.","Paragraph two."]""", """["SECRET"]""", Clean); var written = await Write(model); Assert.Contains("::: private\nSECRET\n:::", written.En.Body); Assert.DoesNotContain("SECRET", ArticleBody.RedactPrivate(written.En.Body)); Assert.Equal(["Paragraph zero.", "Paragraph two."], Public(written.En.Body)); } }