@(English ? "Main article" : "Hauptartikel"):
@parent.Title
+ }
+
@if (_infobox is not null)
{
@((MarkupString)_infobox)
@@ -48,6 +53,19 @@ else if (_article is not null && _rendered is not null)
}
@((MarkupString)_rendered.Html)
+
+ @if (_family?.Children.Count > 0)
+ {
+
diff --git a/src/Wiki.Host/Components/Shared/StatusPanel.razor b/src/Wiki.Host/Components/Shared/StatusPanel.razor
index a2bd405..4fd4d03 100644
--- a/src/Wiki.Host/Components/Shared/StatusPanel.razor
+++ b/src/Wiki.Host/Components/Shared/StatusPanel.razor
@@ -42,7 +42,7 @@
| Last run | @Status.LastEncyclopediaAttemptAt.Value.ToString("u") |
@if (Status.LastEncyclopediaRun is { } r)
{
- | Result | @r.Planned planned, @r.Assigned assigned, @r.Split split, @r.Folded folded, @r.Written written, @r.Deleted deleted, @r.Rejected rejected@(r.Skipped is null ? "" : $" (skipped: {r.Skipped})")@(r.StoppedByModel ? " (stopped: model unavailable)" : "") |
+ | Result | @r.Planned planned, @r.Assigned assigned, @r.Merged merged, @r.Parented parented, @r.Split split, @r.Folded folded, @r.Written written, @r.Deleted deleted, @r.Rejected rejected@(r.Skipped is null ? "" : $" (skipped: {r.Skipped})")@(r.StoppedByModel ? " (stopped: model unavailable)" : "") |
}
@if (Status.LastEncyclopediaError is not null)
{
diff --git a/src/Wiki.Host/Composition/WikiOptions.cs b/src/Wiki.Host/Composition/WikiOptions.cs
index 64c00b4..3bb8366 100644
--- a/src/Wiki.Host/Composition/WikiOptions.cs
+++ b/src/Wiki.Host/Composition/WikiOptions.cs
@@ -101,8 +101,12 @@ public sealed class EncyclopediaOptions
public int MaxSourcesPerArticle { get; set; } = 120;
public int FoldMinSources { get; set; } = 3;
public int FoldMinChars { get; set; } = 4000;
+ /// Articles per consolidation group, the seed included.
+ public int ConsolidateGroupSize { get; set; } = 8;
+ public int MaxConsolidationsPerRun { get; set; } = 60;
// thinking tokens count against max_tokens
public int AssignMaxTokens { get; set; } = 32000;
public int SplitMaxTokens { get; set; } = 32000;
public int FoldMaxTokens { get; set; } = 8000;
+ public int ConsolidateMaxTokens { get; set; } = 8000;
}
diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs
index b385f1b..2098d32 100644
--- a/src/Wiki.Host/Program.cs
+++ b/src/Wiki.Host/Program.cs
@@ -126,12 +126,16 @@ if (encyclopedia.Enabled)
MaxSourcesPerArticle: encyclopedia.MaxSourcesPerArticle,
FoldMinSources: encyclopedia.FoldMinSources,
FoldMinChars: encyclopedia.FoldMinChars,
+ ConsolidateGroupSize: encyclopedia.ConsolidateGroupSize,
+ MaxConsolidationsPerRun: encyclopedia.MaxConsolidationsPerRun,
AssignMaxTokens: encyclopedia.AssignMaxTokens,
SplitMaxTokens: encyclopedia.SplitMaxTokens,
- FoldMaxTokens: encyclopedia.FoldMaxTokens));
+ FoldMaxTokens: encyclopedia.FoldMaxTokens,
+ ConsolidateMaxTokens: encyclopedia.ConsolidateMaxTokens));
// ArticleNeighbours takes the active IEmbeddingModel: the index holds its vectors.
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/test/Wiki.Application.Tests/ArticleNeighboursTests.cs b/test/Wiki.Application.Tests/ArticleNeighboursTests.cs
index a7759e6..6b3cc5b 100644
--- a/test/Wiki.Application.Tests/ArticleNeighboursTests.cs
+++ b/test/Wiki.Application.Tests/ArticleNeighboursTests.cs
@@ -171,6 +171,88 @@ public class ArticleNeighboursTests
Assert.Equal(24, rig.Index.LastK);
}
+ /// Embeddings with a fixed vector per text; any other text gets the fake's hash vector.
+ private sealed class FixedEmbeddings(Dictionary vectors) : Wiki.Core.Ports.IEmbeddingModel
+ {
+ private readonly FakeEmbeddings _fallback = new(Model);
+ public List> Batches { get; } = [];
+ public string ModelId => Model;
+ public int Dimension => 4;
+ public string Name => "fixed";
+
+ public async Task> EmbedAsync(IReadOnlyList texts, CancellationToken ct)
+ {
+ Batches.Add(texts);
+ var fallback = await _fallback.EmbedAsync(texts, ct);
+ return texts.Select((t, i) => vectors.TryGetValue(t, out var v) ? v : fallback[i]).ToList();
+ }
+
+ public Task CheckAsync(CancellationToken ct) => _fallback.CheckAsync(ct);
+ }
+
+ private static PlannedArticle Titled(string slug, string scope = "scope") => new(slug, slug + " de", slug + " en", ArticleCategory.Topic, scope, ["n"]);
+
+ [Fact]
+ public async Task NearestArticles_OfNote_ByCosineOfArticleVectors_Capped()
+ {
+ var note = Note("n");
+ var near = Titled("near");
+ var nearer = Titled("nearer");
+ var far = Titled("far");
+ var embeddings = new FixedEmbeddings(new()
+ {
+ [EmbeddingText.For(note, 1500)] = [1, 0, 0, 0],
+ [ArticleNeighbours.ArticleText(near)] = [2, 1, 0, 0],
+ [ArticleNeighbours.ArticleText(nearer)] = [5, 1, 0, 0],
+ [ArticleNeighbours.ArticleText(far)] = [0, 0, 3, 0],
+ });
+ var plan = ArticlePlan.Empty();
+ foreach (var a in new[] { far, near, nearer }) plan.AddArticle(a);
+ var neighbours = new ArticleNeighbours(embeddings, new FakeIndex(), Settings with { CandidateArticles = 2 });
+
+ var nearest = await neighbours.NearestArticlesAsync(note, plan, default);
+
+ Assert.Equal(["nearer", "near"], nearest);
+ }
+
+ [Fact]
+ public async Task NearestArticles_OfArticle_ExcludesItself()
+ {
+ var self = Titled("self");
+ var other = Titled("other");
+ var third = Titled("third");
+ var embeddings = new FixedEmbeddings(new()
+ {
+ [ArticleNeighbours.ArticleText(self)] = [1, 0, 0, 0],
+ [ArticleNeighbours.ArticleText(other)] = [1, 1, 0, 0],
+ [ArticleNeighbours.ArticleText(third)] = [0, 1, 0, 0],
+ });
+ var plan = ArticlePlan.Empty();
+ foreach (var a in new[] { self, other, third }) plan.AddArticle(a);
+ var neighbours = new ArticleNeighbours(embeddings, new FakeIndex(), Settings);
+
+ var nearest = await neighbours.NearestArticlesAsync(self, plan, 5, default);
+
+ Assert.Equal(["other", "third"], nearest);
+ }
+
+ [Fact]
+ public async Task ArticleVectors_EmbeddedInBatchesOnce_AgainWhenTheTextChanges()
+ {
+ var embeddings = new FixedEmbeddings([]);
+ var plan = ArticlePlan.Empty();
+ foreach (var i in Enumerable.Range(0, 40)) plan.AddArticle(Titled($"a-{i:00}"));
+ var neighbours = new ArticleNeighbours(embeddings, new FakeIndex(), Settings);
+
+ await neighbours.NearestArticlesAsync(plan.Find("a-00")!, plan, 3, default);
+ await neighbours.NearestArticlesAsync(plan.Find("a-01")!, plan, 3, default);
+ plan.ReplaceArticle(plan.Find("a-05")! with { Scope = "new scope" });
+ await neighbours.NearestArticlesAsync(plan.Find("a-01")!, plan, 3, default);
+
+ Assert.Equal([32, 8, 1], embeddings.Batches.Select(b => b.Count));
+ Assert.Equal("a-05 de | a-05 en | new scope", Assert.Single(embeddings.Batches[^1]));
+ }
+
[Fact]
public async Task Vector_CachedPerContentHash()
{
diff --git a/test/Wiki.Application.Tests/ArticlePlanTests.cs b/test/Wiki.Application.Tests/ArticlePlanTests.cs
index 0f9a4c6..8240c27 100644
--- a/test/Wiki.Application.Tests/ArticlePlanTests.cs
+++ b/test/Wiki.Application.Tests/ArticlePlanTests.cs
@@ -75,7 +75,7 @@ public class ArticlePlanTests
{
// n3 is assigned to b without being among its sources
var plan = ArticlePlanJson.Read("""
- {"articles":[{"slug":"b","category":"topic","sources":["n2"]}],
+ {"version":3,"articles":[{"slug":"b","category":"topic","sources":["n2"]}],
"assignments":{"n2":["b"],"n3":["b"],"meta":[]}}
""")!;
@@ -112,7 +112,7 @@ public class ArticlePlanTests
plan.RemoveNote("n1");
var read = ArticlePlanJson.Read(ArticlePlanJson.Write(plan))!;
- var old = ArticlePlanJson.Read("""{"articles":[],"assignments":{},"redirects":{}}""")!;
+ var old = ArticlePlanJson.Read("""{"version":3,"articles":[],"assignments":{},"redirects":{}}""")!;
Assert.Equal(["gone"], read.Retired);
Assert.True(read.SlugTaken("gone"));
@@ -148,8 +148,8 @@ public class ArticlePlanTests
plan.Fold("priv", "end", isPublic: false);
var read = ArticlePlanJson.Read(ArticlePlanJson.Write(plan))!;
- var old = ArticlePlanJson.Read("""{"articles":[],"assignments":{},"redirects":{"x":"y"}}""")!;
- var stray = ArticlePlanJson.Read("""{"articles":[],"assignments":{},"redirects":{"x":"y"},"publicRedirects":["x","not-a-redirect"]}""")!;
+ var old = ArticlePlanJson.Read("""{"version":3,"articles":[],"assignments":{},"redirects":{"x":"y"}}""")!;
+ var stray = ArticlePlanJson.Read("""{"version":3,"articles":[],"assignments":{},"redirects":{"x":"y"},"publicRedirects":["x","not-a-redirect"]}""")!;
Assert.Equal(["pub"], read.PublicRedirects);
Assert.Empty(old.PublicRedirects);
@@ -192,14 +192,14 @@ public class ArticlePlanTests
Assert.Null(plan.Resolve("unknown"));
var sixHops = ArticlePlanJson.Read("""
- {"articles":[{"slug":"end","category":"topic"}],"assignments":{},
+ {"version":3,"articles":[{"slug":"end","category":"topic"}],"assignments":{},
"redirects":{"r1":"end","r2":"r1","r3":"r2","r4":"r3","r5":"r4","r6":"r5"}}
""")!;
Assert.Equal("end", sixHops.Resolve("r5"));
Assert.Null(sixHops.Resolve("r6"));
var cycle = ArticlePlanJson.Read("""
- {"articles":[],"assignments":{},"redirects":{"p":"q","q":"p"}}
+ {"version":3,"articles":[],"assignments":{},"redirects":{"p":"q","q":"p"}}
""")!;
Assert.Null(cycle.Resolve("p"));
}
@@ -247,7 +247,7 @@ public class ArticlePlanTests
[Fact]
public void Json_RedirectWithBadTargetSlug_Dropped()
{
- var read = ArticlePlanJson.Read("""{"articles":[],"assignments":{},"redirects":{"ok":"good-slug","bad":"../x","bad2":"A b"}}""");
+ var read = ArticlePlanJson.Read("""{"version":3,"articles":[],"assignments":{},"redirects":{"ok":"good-slug","bad":"../x","bad2":"A b"}}""");
Assert.NotNull(read);
Assert.Equal(["ok"], read.Redirects.Keys);
@@ -257,6 +257,129 @@ public class ArticlePlanTests
public void Json_OldFormat_IsNull() =>
Assert.Null(ArticlePlanJson.Read("""{"articles":[{"slug":"a","category":"topic","sources":["x"],"related":[]}],"coveredNotes":["x"]}"""));
+ [Theory]
+ [InlineData("""{"articles":[{"slug":"a","category":"topic","sources":["n1"]}],"assignments":{"n1":["a"]}}""")]
+ [InlineData("""{"version":2,"articles":[],"assignments":{}}""")]
+ [InlineData("""{"version":"3","articles":[],"assignments":{}}""")]
+ public void Json_EarlierFormatVersion_IsNull(string json) => Assert.Null(ArticlePlanJson.Read(json));
+
+ [Fact]
+ public void Json_Parent_RoundTrips()
+ {
+ var plan = ArticlePlan.Empty();
+ plan.AddArticle(Article("heating", "n1"));
+ plan.AddArticle(Article("heat-pump", "n2") with { Parent = "heating" });
+
+ var json = ArticlePlanJson.Write(plan);
+ var read = ArticlePlanJson.Read(json)!;
+
+ Assert.StartsWith("{\n \"version\": 3,", json);
+ Assert.Null(read.Find("heating")!.Parent);
+ Assert.Equal("heating", read.Find("heat-pump")!.Parent);
+ Assert.Equal(json, ArticlePlanJson.Write(read));
+ }
+
+ [Fact]
+ public void Json_ParentUnknownOrCyclic_Dropped()
+ {
+ var plan = ArticlePlanJson.Read("""
+ {"version":3,"articles":[
+ {"slug":"a","category":"topic","parent":"ghost"},
+ {"slug":"b","category":"topic","parent":"c"},
+ {"slug":"c","category":"topic","parent":"b"},
+ {"slug":"d","category":"topic","parent":"d"},
+ {"slug":"e","category":"topic","parent":"b"}],"assignments":{}}
+ """)!;
+
+ Assert.Null(plan.Find("a")!.Parent);
+ Assert.Null(plan.Find("d")!.Parent);
+ Assert.Equal("b", plan.Find("e")!.Parent);
+ // in file order: the link that would close the cycle is the one dropped
+ Assert.Equal("c", plan.Find("b")!.Parent);
+ Assert.Null(plan.Find("c")!.Parent);
+ }
+
+ [Fact]
+ public void SetParent_RefusesCyclesAndSelf()
+ {
+ var plan = ArticlePlan.Empty();
+ foreach (var s in new[] { "a", "b", "c" }) plan.AddArticle(Article(s));
+
+ Assert.True(plan.SetParent("b", "a"));
+ Assert.True(plan.SetParent("c", "b"));
+ Assert.False(plan.SetParent("a", "c"));
+ Assert.False(plan.SetParent("a", "a"));
+ Assert.True(plan.SetParent("c", "a"));
+ Assert.True(plan.SetParent("c", null));
+
+ Assert.Null(plan.Find("a")!.Parent);
+ Assert.Null(plan.Find("c")!.Parent);
+ Assert.Equal(["b"], plan.ChildrenOf("a").Select(c => c.Slug));
+ Assert.Throws(() => plan.SetParent("ghost", "a"));
+ Assert.Throws(() => plan.SetParent("a", "ghost"));
+ }
+
+ [Fact]
+ public void ReplaceArticle_KeepsParent()
+ {
+ var plan = ArticlePlan.Empty();
+ plan.AddArticle(Article("a"));
+ plan.AddArticle(Article("b") with { Parent = "a" });
+
+ plan.ReplaceArticle(Article("b", "n1"));
+
+ Assert.Equal("a", plan.Find("b")!.Parent);
+ }
+
+ [Fact]
+ public void AddArticle_UnknownParent_Throws()
+ {
+ var plan = ArticlePlan.Empty();
+
+ Assert.Throws(() => plan.AddArticle(Article("b") with { Parent = "ghost" }));
+ }
+
+ [Fact]
+ public void Fold_ChildrenMoveToTarget_TargetTakesFoldedParent()
+ {
+ var plan = ArticlePlan.Empty();
+ foreach (var s in new[] { "top", "thin", "into", "kid" }) plan.AddArticle(Article(s, "n-" + s));
+ plan.SetParent("thin", "top");
+ plan.SetParent("into", "thin");
+ plan.SetParent("kid", "thin");
+
+ plan.Fold("thin", "into", isPublic: false);
+
+ Assert.Equal("top", plan.Find("into")!.Parent);
+ Assert.Equal("into", plan.Find("kid")!.Parent);
+ }
+
+ [Fact]
+ public void Fold_ChildMoveWouldCycle_ChildLosesParent()
+ {
+ var plan = ArticlePlan.Empty();
+ foreach (var s in new[] { "thin", "kid", "into" }) plan.AddArticle(Article(s, "n-" + s));
+ plan.SetParent("kid", "thin");
+ plan.SetParent("into", "kid");
+
+ plan.Fold("thin", "into", isPublic: false);
+
+ Assert.Null(plan.Find("kid")!.Parent);
+ Assert.Equal("kid", plan.Find("into")!.Parent);
+ }
+
+ [Fact]
+ public void RemoveNote_DeletedParent_ChildrenLoseParent()
+ {
+ var plan = ArticlePlan.Empty();
+ plan.AddArticle(Article("a", "n1"));
+ plan.AddArticle(Article("b", "n2") with { Parent = "a" });
+
+ plan.RemoveNote("n1");
+
+ Assert.Null(plan.Find("b")!.Parent);
+ }
+
[Fact]
public void Json_Write_MapKeysOrdinal_SameForSameContent()
{
@@ -285,7 +408,7 @@ public class ArticlePlanTests
public void Json_BadSlugOrCategory_ArticleDropped()
{
var plan = ArticlePlanJson.Read("""
- {"articles":[{"slug":"Bad Slug","category":"topic"},{"slug":"a","category":"1"},{"slug":"b","category":"room"}],"assignments":{}}
+ {"version":3,"articles":[{"slug":"Bad Slug","category":"topic"},{"slug":"a","category":"1"},{"slug":"b","category":"room"}],"assignments":{}}
""");
Assert.Equal(["b"], plan!.Articles.Select(a => a.Slug));
}
@@ -294,7 +417,7 @@ public class ArticlePlanTests
public void Json_AssignmentToUnknownArticle_Dropped()
{
var plan = ArticlePlanJson.Read("""
- {"articles":[{"slug":"a","category":"topic","sources":["n1"]}],
+ {"version":3,"articles":[{"slug":"a","category":"topic","sources":["n1"]}],
"assignments":{"n1":["a","ghost"],"n2":["ghost"],"n3":[]}}
""")!;
@@ -307,7 +430,7 @@ public class ArticlePlanTests
{
public bool Equals(PlannedArticle? x, PlannedArticle? y) =>
x!.Slug == y!.Slug && x.TitleDe == y.TitleDe && x.TitleEn == y.TitleEn && x.Category == y.Category
- && x.Scope == y.Scope && x.Sources.SequenceEqual(y.Sources);
+ && x.Scope == y.Scope && x.Sources.SequenceEqual(y.Sources) && x.Parent == y.Parent;
public int GetHashCode(PlannedArticle obj) => obj.Slug.GetHashCode();
}
}
diff --git a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs
index ff153b0..5e18305 100644
--- a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs
+++ b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs
@@ -24,6 +24,13 @@ public class AssignNotesUseCaseTests
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()
{
diff --git a/test/Wiki.Application.Tests/ConsolidateArticlesUseCaseTests.cs b/test/Wiki.Application.Tests/ConsolidateArticlesUseCaseTests.cs
new file mode 100644
index 0000000..d718c55
--- /dev/null
+++ b/test/Wiki.Application.Tests/ConsolidateArticlesUseCaseTests.cs
@@ -0,0 +1,130 @@
+using System.Net;
+using Microsoft.Extensions.Logging.Abstractions;
+using Wiki.Application.Encyclopedia;
+using Wiki.Core.Model;
+
+namespace Wiki.Application.Tests;
+
+public class ConsolidateArticlesUseCaseTests
+{
+ private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null) { ConsolidateGroupSize = 4 };
+
+ private static ConsolidateArticlesUseCase UseCase(FakeTextModel model) =>
+ new(model, Settings, NullLogger.Instance);
+
+ private static PlannedArticle Article(string slug, string? parent = null, int sources = 1) =>
+ new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug,
+ Enumerable.Range(0, sources).Select(i => $"{slug}-n{i}").ToList(), parent);
+
+ private static readonly PlannedArticle[] Heating =
+ [Article("heating", sources: 2), Article("heat-pump", parent: "house"), Article("vitocal-heat-pump"), Article("radiators")];
+
+ [Fact]
+ public async Task Consolidate_MergesAndParents()
+ {
+ var model = new FakeTextModel().Reply("""
+ {"merges":[{"from":"vitocal-heat-pump","into":"heat-pump"}],"parents":[{"child":"heat-pump","parent":"heating"},{"child":"radiators","parent":"heating"}]}
+ """);
+
+ var outcome = await UseCase(model).ConsolidateAsync(Heating, CancellationToken.None);
+
+ Assert.NotNull(outcome);
+ Assert.Equal([new ArticleMerge("vitocal-heat-pump", "heat-pump")], outcome.Merges);
+ Assert.Equal([new ArticleParent("heat-pump", "heating"), new ArticleParent("radiators", "heating")], outcome.Parents);
+ var call = Assert.Single(model.Calls);
+ Assert.StartsWith("[consolidate]", call.System);
+ Assert.Equal(Settings.ConsolidateMaxTokens, call.MaxTokens);
+ Assert.Contains("heating | Titel heating | system | Scope heating | 2 | -\n", call.User);
+ Assert.Contains("heat-pump | Titel heat-pump | system | Scope heat-pump | 1 | house\n", call.User);
+ }
+
+ [Fact]
+ public async Task Consolidate_ShowsAtMostTheGroupSize()
+ {
+ var model = new FakeTextModel().Reply("""{"merges":[],"parents":[]}""");
+
+ await UseCase(model).ConsolidateAsync([.. Heating, Article("garden")], CancellationToken.None);
+
+ Assert.DoesNotContain("garden", model.Calls[0].User);
+ }
+
+ [Fact]
+ public async Task Consolidate_EntriesOutsideTheGroupOrOnThemselves_Dropped()
+ {
+ var model = new FakeTextModel().Reply("""
+ {"merges":[{"from":"heating","into":"heating"},{"from":"garden","into":"heating"},{"from":"heating","into":"garden"},{"from":"radiators"}],
+ "parents":[{"child":"radiators","parent":"radiators"},{"child":"garden","parent":"heating"},{"child":"radiators","parent":"garden"}]}
+ """);
+
+ var outcome = await UseCase(model).ConsolidateAsync(Heating, CancellationToken.None);
+
+ Assert.Empty(outcome!.Merges);
+ Assert.Empty(outcome.Parents);
+ }
+
+ [Fact]
+ public async Task Consolidate_MergedAwayArticleIsNeverATarget()
+ {
+ var model = new FakeTextModel().Reply("""
+ {"merges":[{"from":"vitocal-heat-pump","into":"heat-pump"},{"from":"heat-pump","into":"heating"},
+ {"from":"radiators","into":"vitocal-heat-pump"},{"from":"vitocal-heat-pump","into":"heating"}],"parents":[]}
+ """);
+
+ var outcome = await UseCase(model).ConsolidateAsync(Heating, CancellationToken.None);
+
+ Assert.Equal([new ArticleMerge("vitocal-heat-pump", "heat-pump")], outcome!.Merges);
+ }
+
+ [Fact]
+ public async Task Consolidate_ParentsFollowTheMergesAndAreUniquePerChild()
+ {
+ var model = new FakeTextModel().Reply("""
+ {"merges":[{"from":"vitocal-heat-pump","into":"heat-pump"}],
+ "parents":[{"child":"vitocal-heat-pump","parent":"heating"},{"child":"radiators","parent":"vitocal-heat-pump"},
+ {"child":"heat-pump","parent":"radiators"},{"child":"heat-pump","parent":"vitocal-heat-pump"}]}
+ """);
+
+ var outcome = await UseCase(model).ConsolidateAsync(Heating, CancellationToken.None);
+
+ Assert.Equal([new ArticleParent("heat-pump", "heating"), new ArticleParent("radiators", "heat-pump")], outcome!.Parents);
+ }
+
+ [Fact]
+ public async Task Consolidate_NothingToDo_IsUsable()
+ {
+ var model = new FakeTextModel().Reply("""{"merges":[]}""");
+
+ var outcome = await UseCase(model).ConsolidateAsync(Heating, CancellationToken.None);
+
+ Assert.NotNull(outcome);
+ Assert.Empty(outcome.Merges);
+ Assert.Empty(outcome.Parents);
+ }
+
+ [Theory]
+ [InlineData("not json", false)]
+ [InlineData("""{"keep":true}""", false)]
+ [InlineData("""{"merges":[],"parents":[]}""", true)]
+ public async Task Consolidate_UnusableReply_IsNull(string reply, bool truncated)
+ {
+ var model = new FakeTextModel().Reply(reply, truncated);
+
+ Assert.Null(await UseCase(model).ConsolidateAsync(Heating, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Consolidate_BadRequest_IsNull()
+ {
+ var model = new FakeTextModel().Throw(new HttpRequestException("invalid", null, HttpStatusCode.BadRequest));
+
+ Assert.Null(await UseCase(model).ConsolidateAsync(Heating, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Consolidate_OtherHttpError_Propagates()
+ {
+ var model = new FakeTextModel().Throw(new HttpRequestException("bad gateway", null, HttpStatusCode.BadGateway));
+
+ await Assert.ThrowsAsync(() => UseCase(model).ConsolidateAsync(Heating, CancellationToken.None));
+ }
+}
diff --git a/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs b/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs
index f2a4ab4..8009069 100644
--- a/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs
+++ b/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs
@@ -11,7 +11,7 @@ public class EncyclopediaCoordinatorTests
public Task RunAsync(CancellationToken ct) => body(ct);
}
- private static readonly ReconcileSummary Done = new(3, 0, 0, 0, 2, 0, 0, 0, false, null, "c1");
+ private static readonly ReconcileSummary Done = new(3, 0, 0, 0, 0, 0, 2, 0, 0, 0, false, null, "c1");
private static EncyclopediaCoordinator Coordinator(Func> body) =>
new(new ScriptedRun(body), new FakeTimeProvider(new DateTimeOffset(2026, 10, 24, 4, 0, 0, TimeSpan.Zero)),
diff --git a/test/Wiki.Application.Tests/EncyclopediaReconcilerTests.cs b/test/Wiki.Application.Tests/EncyclopediaReconcilerTests.cs
index 8d3ae3f..98c6748 100644
--- a/test/Wiki.Application.Tests/EncyclopediaReconcilerTests.cs
+++ b/test/Wiki.Application.Tests/EncyclopediaReconcilerTests.cs
@@ -16,8 +16,8 @@ public class EncyclopediaReconcilerTests
private const string StatePath = "articles/_state.json";
private static readonly DateTimeOffset Now = new(2026, 10, 24, 3, 0, 0, TimeSpan.Zero);
- /// Fold is off (no article has fewer than zero sources) unless a test turns it on.
- private static readonly EncyclopediaSettings Defaults = new(16000, 10, 300_000, [], null) { FoldMinSources = 0 };
+ /// Fold (no article has fewer than zero sources) and consolidation are off unless a test turns them on.
+ private static readonly EncyclopediaSettings Defaults = new(16000, 10, 300_000, [], null) { FoldMinSources = 0, MaxConsolidationsPerRun = 0 };
private static readonly (string Slug, string[] Notes)[] HeatingAndKitchen =
[("heating", ["boiler", "radiators"]), ("kitchen", ["kitchen"])];
@@ -58,6 +58,7 @@ public class EncyclopediaReconcilerTests
var s = settings ?? Defaults;
return new(_store, _catalog, new ArticleNeighbours(_embeddings, _index, s),
new AssignNotesUseCase(_model, s, NullLogger.Instance),
+ new ConsolidateArticlesUseCase(_model, s, NullLogger.Instance),
new SplitArticleUseCase(_model, s, NullLogger.Instance),
new FoldArticleUseCase(_model, s, NullLogger.Instance),
writer ?? _writer, s, _clock, NullLogger.Instance);
@@ -100,12 +101,12 @@ public class EncyclopediaReconcilerTests
var summary = await Reconciler().RunAsync(CancellationToken.None);
var commit = Assert.Single(_store.Commits);
- Assert.Equal("articles: 3 assigned, 0 split, 0 folded, 2 written, 0 deleted, 0 rejected", commit.Message);
+ Assert.Equal("articles: 3 assigned, 0 merged, 0 parented, 0 split, 0 folded, 2 written, 0 deleted, 0 rejected", commit.Message);
Assert.Equal(
[PlanPath, StatePath, "articles/de/heating.md", "articles/de/kitchen.md", "articles/en/heating.md", "articles/en/kitchen.md"],
commit.Changes.Select(c => c.Path).Order(StringComparer.Ordinal));
Assert.All(commit.Changes, c => Assert.Equal(FileChangeOp.Create, c.Op));
- Assert.Equal(new ReconcileSummary(2, 3, 0, 0, 2, 0, 0, 0, false, null, "c2"), summary);
+ Assert.Equal(new ReconcileSummary(2, 3, 0, 0, 0, 0, 2, 0, 0, 0, false, null, "c2"), summary);
Assert.Equal(AttemptOutcome.Written, State.Subjects["heating"].Outcome);
Assert.Equal(Now, State.Subjects["heating"].At);
Assert.Equal(["boiler", "radiators"], Plan.Find("heating")!.Sources);
@@ -237,7 +238,7 @@ public class EncyclopediaReconcilerTests
}
[Fact]
- public async Task Run_SplitChildrenAreLinksOnlyInTheSplittingRun()
+ public async Task Run_SplitChildrenAreSubArticles_LinkedInLaterRunsToo()
{
await SeedAsync("boiler", "radiators", "pump", "valve");
_model.Reply(AssignReply(("heating", ["boiler", "radiators", "pump", "valve"])));
@@ -259,7 +260,10 @@ public class EncyclopediaReconcilerTests
await Reconciler(settings).RunAsync(CancellationToken.None);
Assert.Equal("heating", _writer.Calls[^1]);
- Assert.Empty(_writer.Links["heating"]);
+ Assert.Equal(["heating-pump", "heating-radiators"], _writer.Links["heating"].Order(StringComparer.Ordinal));
+ Assert.Equal(["heating"], _writer.Links["heating-pump"]);
+ Assert.Equal("heating", Plan.Find("heating-pump")!.Parent);
+ Assert.Null(Plan.Find("heating")!.Parent);
}
[Fact]
@@ -507,7 +511,7 @@ public class EncyclopediaReconcilerTests
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal(1, summary.Rejected);
- Assert.Equal("articles: 0 assigned, 0 split, 0 folded, 0 written, 0 deleted, 1 rejected", _store.Commits[^1].Message);
+ Assert.Equal("articles: 0 assigned, 0 merged, 0 parented, 0 split, 0 folded, 0 written, 0 deleted, 1 rejected", _store.Commits[^1].Message);
Assert.Equal([StatePath], LastCommit.Select(c => c.Path));
Assert.Equal(de, _store.Files["articles/de/heating.md"]);
Assert.Equal(en, _store.Files["articles/en/heating.md"]);
@@ -946,6 +950,243 @@ public class EncyclopediaReconcilerTests
Assert.Equal(["heating"], _writer.Calls);
}
+ private static readonly EncyclopediaSettings Consolidating = Defaults with { MaxConsolidationsPerRun = 60 };
+
+ private static string Consolidation(object[]? merges = null, object[]? parents = null) =>
+ JsonSerializer.Serialize(new { merges = merges ?? [], parents = parents ?? [] });
+
+ private string[] Steps => _model.Calls.Select(c => c.System.Split('\n')[0]).ToArray();
+
+ [Fact]
+ public async Task Run_Consolidate_MergesAndSetsParents()
+ {
+ await SeedAsync("boiler", "pump", "radiators", "valve");
+ _model.Reply(AssignReply(("heat-pump", ["boiler"]), ("vitocal", ["pump"]), ("heating", ["radiators", "valve"])));
+ _model.Reply(Consolidation(
+ [new { from = "vitocal", into = "heat-pump" }],
+ [new { child = "heat-pump", parent = "heating" }, new { child = "vitocal", parent = "heating" }]));
+
+ var summary = await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ Assert.Equal(["[assign]", "[consolidate]"], Steps);
+ Assert.Equal((1, 1, 2), (summary.Merged, summary.Parented, summary.Planned));
+ Assert.StartsWith("articles: 4 assigned, 1 merged, 1 parented,", _store.Commits[^1].Message);
+ Assert.Equal("heat-pump", Plan.Redirects["vitocal"]);
+ Assert.Empty(Plan.PublicRedirects);
+ Assert.Equal(["boiler", "pump"], Plan.Find("heat-pump")!.Sources);
+ Assert.Equal(["heat-pump"], Plan.Assignments["pump"]);
+ Assert.Equal("heating", Plan.Find("heat-pump")!.Parent);
+ Assert.Contains("heating", _writer.Links["heat-pump"]);
+ Assert.Contains("heat-pump", _writer.Links["heating"]);
+ Assert.DoesNotContain("vitocal", _writer.Calls);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_GroupIsTheSeedAndItsNearestArticles()
+ {
+ await SeedAsync("boiler", "pump", "radiators");
+ _model.Reply(AssignReply(("heat-pump", ["boiler"]), ("vitocal", ["pump"]), ("heating", ["radiators"])));
+ _model.Reply(Consolidation());
+
+ await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ var user = _model.Calls[1].User;
+ Assert.StartsWith("Articles (slug | title | category | scope | source count | current parent):\nheat-pump | heat-pump de | topic | About heat-pump. | 1 | -\n", user);
+ Assert.Contains("vitocal | vitocal de |", user);
+ Assert.Contains("heating | heating de |", user);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_RememberedGroupNotAskedAgain()
+ {
+ await SeedAsync();
+ _model.Reply(AssignReply(HeatingAndKitchen));
+ _model.Reply(Consolidation());
+ await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+ await Sync();
+
+ var summary = await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ Assert.Equal(["[assign]", "[consolidate]"], Steps);
+ Assert.Single(State.ConsolidatedGroups);
+ Assert.Null(summary.Commit);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_ChangedGroupRememberedAsChanged()
+ {
+ await SeedAsync("boiler", "pump", "radiators");
+ _model.Reply(AssignReply(("heat-pump", ["boiler"]), ("vitocal", ["pump"]), ("heating", ["radiators"])));
+ _model.Reply(Consolidation([new { from = "vitocal", into = "heat-pump" }], [new { child = "heat-pump", parent = "heating" }]));
+ await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+ await Sync();
+
+ var summary = await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ Assert.Equal(2, _model.Calls.Count);
+ Assert.Single(State.ConsolidatedGroups);
+ Assert.Null(summary.Commit);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_UnusableReplyRememberedAsFailed()
+ {
+ await SeedAsync();
+ _model.Reply(AssignReply(HeatingAndKitchen));
+ _model.Reply("I would rather not.");
+ await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+ await Sync();
+
+ await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ Assert.Equal(["[assign]", "[consolidate]"], Steps);
+ Assert.Single(State.FailedGroups);
+ Assert.Empty(State.ConsolidatedGroups);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_HttpErrorSkipsOnlyThatGroup()
+ {
+ await SeedAsync("a1", "b1", "c1", "d1");
+ _model.Reply(AssignReply(("a", ["a1"]), ("b", ["b1"]), ("c", ["c1"]), ("d", ["d1"])));
+ _model.Throw(new HttpRequestException("bad gateway", null, HttpStatusCode.BadGateway));
+ foreach (var _ in Enumerable.Range(0, 3)) _model.Reply(Consolidation());
+
+ var summary = await Reconciler(Consolidating with { ConsolidateGroupSize = 2 }).RunAsync(CancellationToken.None);
+
+ Assert.True(Steps.Count(s => s == "[consolidate]") >= 2);
+ Assert.Empty(State.FailedGroups);
+ Assert.NotEmpty(State.ConsolidatedGroups);
+ Assert.Equal(4, summary.Written);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_CapRespected()
+ {
+ await SeedAsync("a1", "b1", "c1", "d1");
+ _model.Reply(AssignReply(("a", ["a1"]), ("b", ["b1"]), ("c", ["c1"]), ("d", ["d1"])));
+ _model.Reply(Consolidation());
+
+ await Reconciler(Consolidating with { ConsolidateGroupSize = 2, MaxConsolidationsPerRun = 1 }).RunAsync(CancellationToken.None);
+
+ Assert.Equal(["[assign]", "[consolidate]"], Steps);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_MergeOfPublicArticle_LeavesPublicRedirectAndDeletesFiles()
+ {
+ await FirstRunAsync();
+ _model.Reply(Consolidation([new { from = "kitchen", into = "heating" }]));
+
+ var summary = await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ Assert.Equal((1, 1), (summary.Merged, summary.Deleted));
+ Assert.Equal("heating", Plan.Redirects["kitchen"]);
+ Assert.Equal(["kitchen"], Plan.PublicRedirects);
+ Assert.DoesNotContain("articles/de/kitchen.md", _store.Files.Keys);
+ Assert.DoesNotContain("kitchen", State.Subjects.Keys);
+ Assert.Equal("heating", _writer.Calls[^1]);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_ParentLinkAloneRewritesNothing()
+ {
+ await FirstRunAsync();
+ _model.Reply(Consolidation(parents: [new { child = "kitchen", parent = "heating" }]));
+
+ var summary = await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ Assert.Equal(1, summary.Parented);
+ Assert.Equal(0, summary.Written);
+ Assert.Equal(2, _writer.Calls.Count);
+ Assert.Equal([PlanPath, StatePath], LastCommit.Select(c => c.Path).Order(StringComparer.Ordinal));
+ Assert.Equal("heating", Plan.Find("kitchen")!.Parent);
+ }
+
+ [Fact]
+ public async Task Run_Consolidate_ParentClosingACycle_Dropped()
+ {
+ await FirstRunAsync();
+ var plan = Plan;
+ plan.SetParent("heating", "kitchen");
+ _store.Files[PlanPath] = ArticlePlanJson.Write(plan);
+ await Sync();
+ _model.Reply(Consolidation(parents: [new { child = "kitchen", parent = "heating" }]));
+
+ var summary = await Reconciler(Consolidating).RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, summary.Parented);
+ Assert.Null(Plan.Find("kitchen")!.Parent);
+ Assert.Equal("kitchen", Plan.Find("heating")!.Parent);
+ }
+
+ [Fact]
+ public async Task Run_AssignCandidates_IncludeArticlesNearByVector()
+ {
+ var settings = Defaults with { NeighbourNotes = 0 };
+ await SeedAsync();
+ _model.Reply(AssignReply(HeatingAndKitchen));
+ await Reconciler(settings).RunAsync(CancellationToken.None);
+ await Sync();
+ _store.Files["wiki/oven.md"] = Note("oven");
+ await Sync();
+ _model.Reply(ExistingReply(("oven", "kitchen")));
+
+ await Reconciler(settings).RunAsync(CancellationToken.None);
+
+ // no note neighbours at all: both candidates come from the article vectors
+ Assert.Contains("\nheating | heating de |", _model.Calls[1].User);
+ Assert.Contains("\nkitchen | kitchen de |", _model.Calls[1].User);
+ }
+
+ [Fact]
+ public async Task Run_WriteLinksParentAndAtMost20Children()
+ {
+ var children = Numbered(25);
+ await SeedAsync(["hub-note", .. children]);
+ var plan = ArticlePlan.Empty();
+ plan.AddArticle(new PlannedArticle("hub", "Hub", "Hub", ArticleCategory.System, "The hub.", []));
+ plan.Assign("hub-note", ["hub"]);
+ foreach (var c in children)
+ {
+ plan.AddArticle(new PlannedArticle("a-" + c, c, c, ArticleCategory.Device, "One.", [], Parent: "hub"));
+ plan.Assign(c, ["a-" + c]);
+ }
+ _store.Files[PlanPath] = ArticlePlanJson.Write(plan);
+ await Sync();
+
+ await Reconciler(Defaults with { CandidateArticles = 0, MaxArticlesPerRun = 30 }).RunAsync(CancellationToken.None);
+
+ Assert.Equal(20, _writer.Links["hub"].Count);
+ Assert.Equal(["hub"], _writer.Links["a-note-25"]);
+ }
+
+ [Fact]
+ public async Task Run_PlanInOlderFormat_BootstrapsAndDropsItsState()
+ {
+ await SeedAsync();
+ _store.Files[PlanPath] = """
+ {"articles":[{"slug":"heizung","titleDe":"Heizung","titleEn":"Heating","category":"system","scope":"x","sources":["boiler"]}],
+ "assignments":{"boiler":["heizung"],"radiators":[],"kitchen":[]},"redirects":{}}
+ """;
+ _store.Files[StatePath] = EncyclopediaStateJson.Write(EncyclopediaState.Empty with
+ {
+ Subjects = new Dictionary { ["heizung"] = new("f", AttemptOutcome.Rejected, Now) },
+ KeptArticles = new Dictionary { ["heizung"] = "f" },
+ });
+ await Sync();
+ _model.Reply(AssignReply(HeatingAndKitchen));
+
+ var summary = await Reconciler().RunAsync(CancellationToken.None);
+
+ Assert.Equal(3, summary.Assigned);
+ Assert.Contains("boiler |", _model.Calls[0].User);
+ Assert.Null(Plan.Find("heizung"));
+ Assert.Equal(["heating", "kitchen"], State.Subjects.Keys);
+ Assert.Empty(State.KeptArticles);
+ Assert.StartsWith("{\n \"version\": 3,", _store.Files[PlanPath]);
+ }
+
/// Embeddings that fail like an unreachable service while , or with when set.
private sealed class SwitchableEmbeddings(IEmbeddingModel inner) : IEmbeddingModel
{
diff --git a/test/Wiki.Application.Tests/EncyclopediaSettingsTests.cs b/test/Wiki.Application.Tests/EncyclopediaSettingsTests.cs
index 78a553f..f58fa36 100644
--- a/test/Wiki.Application.Tests/EncyclopediaSettingsTests.cs
+++ b/test/Wiki.Application.Tests/EncyclopediaSettingsTests.cs
@@ -12,8 +12,18 @@ public class EncyclopediaSettingsTests
Assert.Equal(32000, s.AssignMaxTokens);
Assert.Equal(32000, s.SplitMaxTokens);
Assert.Equal(8000, s.FoldMaxTokens);
+ Assert.Equal(8000, s.ConsolidateMaxTokens);
}
[Fact]
- public void PromptsVersion_InvalidatesBatchesFailedAtTheOldCap() => Assert.Equal(7, Prompts.Version);
+ public void Defaults_ConsolidateABootstrapPlanInOneRun()
+ {
+ var s = new EncyclopediaSettings(16000, 10, 300_000, [], null);
+
+ Assert.Equal(8, s.ConsolidateGroupSize);
+ Assert.Equal(60, s.MaxConsolidationsPerRun);
+ }
+
+ [Fact]
+ public void PromptsVersion_InvalidatesMemoryOfThePreviousPlan() => Assert.Equal(8, Prompts.Version);
}
diff --git a/test/Wiki.Application.Tests/EncyclopediaStateTests.cs b/test/Wiki.Application.Tests/EncyclopediaStateTests.cs
index 11b4977..909d999 100644
--- a/test/Wiki.Application.Tests/EncyclopediaStateTests.cs
+++ b/test/Wiki.Application.Tests/EncyclopediaStateTests.cs
@@ -17,7 +17,9 @@ public class EncyclopediaStateTests
["sauna"] = new("def", AttemptOutcome.Rejected, At),
},
new Dictionary(),
- new Dictionary());
+ new Dictionary(),
+ new Dictionary(),
+ new Dictionary());
var read = EncyclopediaStateJson.Read(EncyclopediaStateJson.Write(state));
@@ -30,12 +32,42 @@ public class EncyclopediaStateTests
var state = new EncyclopediaState(
new Dictionary(),
new Dictionary { ["fp1"] = At },
- new Dictionary { ["heating"] = "fp2" });
+ new Dictionary { ["heating"] = "fp2" },
+ new Dictionary { ["g1"] = At },
+ new Dictionary { ["g2"] = At });
var read = EncyclopediaStateJson.Read(EncyclopediaStateJson.Write(state));
Assert.Equal(state.FailedBatches, read.FailedBatches);
Assert.Equal(state.KeptArticles, read.KeptArticles);
+ Assert.Equal(state.ConsolidatedGroups, read.ConsolidatedGroups);
+ Assert.Equal(state.FailedGroups, read.FailedGroups);
+ }
+
+ [Fact]
+ public void Read_WithoutGroups_ReadsEmpty()
+ {
+ var read = EncyclopediaStateJson.Read("""{"subjects":{},"failedBatches":{},"keptArticles":{}}""");
+ Assert.Empty(read.ConsolidatedGroups);
+ Assert.Empty(read.FailedGroups);
+ }
+
+ [Fact]
+ public void GroupFingerprint_MembersAndWhatTheModelJudges_NotSources()
+ {
+ var a = new PlannedArticle("a", "A", "A", ArticleCategory.System, "About a.", ["n1"]);
+ var b = new PlannedArticle("b", "B", "B", ArticleCategory.Device, "About b.", ["n2"], Parent: "a");
+
+ var baseline = EncyclopediaState.GroupFingerprint([a, b]);
+
+ Assert.Matches("^[0-9a-f]{64}$", baseline);
+ Assert.Equal(baseline, EncyclopediaState.GroupFingerprint([b, a]));
+ Assert.Equal(baseline, EncyclopediaState.GroupFingerprint([a, b with { Sources = ["n2", "n3"] }]));
+ Assert.NotEqual(baseline, EncyclopediaState.GroupFingerprint([a]));
+ Assert.NotEqual(baseline, EncyclopediaState.GroupFingerprint([a, b with { Parent = null }]));
+ Assert.NotEqual(baseline, EncyclopediaState.GroupFingerprint([a, b with { Scope = "Other." }]));
+ Assert.NotEqual(baseline, EncyclopediaState.GroupFingerprint([a, b with { TitleEn = "Bee" }]));
+ Assert.NotEqual(baseline, EncyclopediaState.GroupFingerprint([a, b with { Category = ArticleCategory.Room }]));
}
[Fact]
@@ -45,6 +77,7 @@ public class EncyclopediaStateTests
Assert.Empty(read.Subjects);
Assert.Empty(read.FailedBatches);
Assert.Empty(read.KeptArticles);
+ Assert.Empty(read.ConsolidatedGroups);
Assert.DoesNotContain("failedPlan", EncyclopediaStateJson.Write(read));
}
diff --git a/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs b/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs
index 6ea0ce3..0454141 100644
--- a/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs
+++ b/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs
@@ -62,6 +62,31 @@ public class WriteArticleUseCaseTests
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]
diff --git a/test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs b/test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs
index 094f00a..1a19d67 100644
--- a/test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs
+++ b/test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs
@@ -24,7 +24,7 @@ public sealed class EncyclopediaEndpointTests
{
CatalogCommits.Add((await catalog.GetStateAsync(ct)).Commit);
if (Failure is not null) throw Failure;
- return new ReconcileSummary(Planned, 0, 0, 0, 0, 0, 0, 0, StoppedByModel, null, null);
+ return new ReconcileSummary(Planned, 0, 0, 0, 0, 0, 0, 0, 0, 0, StoppedByModel, null, null);
}
}
diff --git a/test/Wiki.Host.Tests/HouseTests.cs b/test/Wiki.Host.Tests/HouseTests.cs
index 9ef63fa..062e753 100644
--- a/test/Wiki.Host.Tests/HouseTests.cs
+++ b/test/Wiki.Host.Tests/HouseTests.cs
@@ -44,9 +44,17 @@ public class HouseTests(WikiHostFactory factory) : IClassFixtureDas Haus", child);
+ Assert.DoesNotContain("Unterartikel", child);
+ Assert.Contains("Unterartikel
", parent);
+ Assert.Contains("Pfad", parent);
+ Assert.Contains("Wandschalter", parent);
+ Assert.True(parent.IndexOf("/house/de/x\"", StringComparison.Ordinal) < parent.IndexOf("/house/de/schalter\"", StringComparison.Ordinal));
+ Assert.DoesNotContain("network-internals", parent);
+ Assert.DoesNotContain("Netzwerk-Interna", parent);
+ Assert.DoesNotContain("heizung", parent);
+ Assert.DoesNotContain("geplant", parent);
+ Assert.DoesNotContain("Hauptartikel", parent);
+ }
+
+ [Fact]
+ public async Task Navigation_Anonymous_PrivateMainArticleInvisible()
+ {
+ await SeedAsync();
+
+ var html = Text(await Html(await Browser().GetAsync("/house/de/y")));
+
+ Assert.DoesNotContain("Hauptartikel", html);
+ Assert.DoesNotContain("network-internals", html);
+ Assert.DoesNotContain("Netzwerk-Interna", html);
+ }
+
+ [Fact]
+ public async Task Navigation_SignedIn_SeesPrivateRelatives()
+ {
+ await SeedAsync();
+ var browser = await SignedInAsync();
+
+ var parent = Text(await Html(await browser.GetAsync("/house/de/haus")));
+ var child = Text(await Html(await browser.GetAsync("/house/de/y")));
+
+ Assert.Contains("Netzwerk-Interna", parent);
+ Assert.Contains("Heizung", parent);
+ Assert.DoesNotContain("geplant", parent);
+ Assert.Contains("Hauptartikel: Netzwerk-Interna", child);
+ }
+
+ [Fact]
+ public async Task Navigation_English_Labels()
+ {
+ await SeedAsync();
+
+ var child = Text(await Html(await Browser().GetAsync("/house/en/schalter")));
+ var parent = Text(await Html(await Browser().GetAsync("/house/en/haus")));
+
+ Assert.Contains("Main article: The house", child);
+ Assert.Contains("Sub-articles
", parent);
+ Assert.Contains("Wall switches", parent);
+ }
+
[Fact]
public async Task FrontPage_Anonymous_ListsOnlyPublic()
{