Files
Wiki/test/Wiki.Application.Tests/ArticlePlanTests.cs
T
claude-botandClaude Opus 5.5 179bc3bcbc feat(encyclopedia): consolidate nearby articles, plan hierarchy, room articles
Article vectors (titleDe | titleEn | scope, embedded locally, cosine in
memory) feed the assign candidates alongside the note-neighbour articles
and form the groups of a new consolidate step after assign: one bounded
call per group of up to 8 nearby articles, at most 60 per run, returning
merges (fold mechanics, redirects incl. public ones) and parent links
(no cycles anywhere in the plan). Groups are remembered by fingerprint
in _state.json.

PlannedArticle gains a parent; split children get the overview. The write
prompt marks main and sub-articles among its link targets (children
capped at 20); the article page shows the main article and the visible
sub-articles from the plan. Parent changes do not make articles stale.

The assign prompt also sends room notes to the room article; the write
prompt writes room articles from the room's angle.

The plan file gets version 3: the v2 plan reads as no plan, its state is
dropped, and the next run bootstraps. Prompts.Version 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:36:59 +01:00

437 lines
16 KiB
C#

using Wiki.Application.Encyclopedia;
using Wiki.Core.Model;
namespace Wiki.Application.Tests;
public class ArticlePlanTests
{
private static PlannedArticle Article(string slug, params string[] sources) =>
new(slug, slug.ToUpperInvariant(), slug.ToUpperInvariant(), ArticleCategory.Topic, $"About {slug}.", sources);
[Fact]
public void AddArticle_TakenSlug_Throws()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a", "n1"));
plan.AddArticle(Article("b", "n2"));
plan.Fold("b", "a", isPublic: false);
Assert.Throws<InvalidOperationException>(() => plan.AddArticle(Article("a")));
Assert.Throws<InvalidOperationException>(() => plan.AddArticle(Article("b")));
}
[Fact]
public void Assign_AppendsSourcesInOrderWithoutDuplicates()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a"));
plan.AddArticle(Article("b"));
plan.Assign("n2", ["a", "b"]);
plan.Assign("n1", ["a"]);
plan.Assign("n1", ["a"]);
Assert.Equal(["n2", "n1"], plan.Find("a")!.Sources);
Assert.Equal(["n2"], plan.Find("b")!.Sources);
Assert.Equal(["a", "b"], plan.Assignments["n2"]);
}
[Fact]
public void Assign_Empty_RecordsSkip()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a"));
plan.Assign("meta", []);
Assert.True(plan.IsAssigned("meta"));
Assert.False(plan.IsAssigned("other"));
Assert.Empty(plan.Assignments["meta"]);
Assert.Empty(plan.Find("a")!.Sources);
}
[Fact]
public void RemoveNote_LastSource_DeletesArticleAndRedirectsToIt()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a", "n1"));
plan.AddArticle(Article("b", "n2"));
plan.Assign("n1", ["a"]);
plan.Assign("n2", ["b"]);
plan.Fold("a", "b", isPublic: false);
plan.Assign("n3", ["b"]);
Assert.Equal("b", plan.Redirects["a"]);
Assert.Empty(plan.RemoveNote("n3"));
Assert.Equal(["b"], plan.RemoveNote("n2").Concat(plan.RemoveNote("n1")));
Assert.Null(plan.Find("b"));
Assert.False(plan.Redirects.ContainsKey("a"));
Assert.False(plan.IsAssigned("n2"));
}
[Fact]
public void RemoveNote_DeletedArticleWasOnlyAssignment_NoteUnassignedNotSkipped()
{
// n3 is assigned to b without being among its sources
var plan = ArticlePlanJson.Read("""
{"version":3,"articles":[{"slug":"b","category":"topic","sources":["n2"]}],
"assignments":{"n2":["b"],"n3":["b"],"meta":[]}}
""")!;
Assert.Equal(["b"], plan.RemoveNote("n2"));
Assert.False(plan.IsAssigned("n3"));
Assert.True(plan.IsAssigned("meta"));
}
[Fact]
public void RemoveNote_DeletedSlugAndItsRedirectsStayReserved()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a", "n1"));
plan.AddArticle(Article("b", "n2"));
plan.Fold("a", "b", isPublic: true);
Assert.Equal(["b"], plan.RemoveNote("n2").Concat(plan.RemoveNote("n1")));
Assert.Empty(plan.Redirects);
Assert.Empty(plan.PublicRedirects);
Assert.Equal(["a", "b"], plan.Retired.Order(StringComparer.Ordinal));
Assert.True(plan.SlugTaken("a"));
Assert.True(plan.SlugTaken("b"));
Assert.Throws<InvalidOperationException>(() => plan.AddArticle(Article("b")));
}
[Fact]
public void Json_Retired_RoundTripsAndOldFormatReadsEmpty()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("gone", "n1"));
plan.AddArticle(Article("kept", "n2"));
plan.RemoveNote("n1");
var read = ArticlePlanJson.Read(ArticlePlanJson.Write(plan))!;
var old = ArticlePlanJson.Read("""{"version":3,"articles":[],"assignments":{},"redirects":{}}""")!;
Assert.Equal(["gone"], read.Retired);
Assert.True(read.SlugTaken("gone"));
Assert.Empty(old.Retired);
}
[Fact]
public void Fold_PublicFlagPerRedirect_KeptWhenRepointed()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("pub", "n1"));
plan.AddArticle(Article("priv", "n2"));
plan.AddArticle(Article("mid", "n3"));
plan.AddArticle(Article("end", "n4"));
plan.Fold("pub", "mid", isPublic: true);
plan.Fold("priv", "mid", isPublic: false);
plan.Fold("mid", "end", isPublic: false);
Assert.Equal("end", plan.Redirects["pub"]);
Assert.Equal("end", plan.Redirects["priv"]);
Assert.Equal(["pub"], plan.PublicRedirects);
}
[Fact]
public void Json_PublicRedirects_RoundTripAndOldFormatReadsNonePublic()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("pub", "n1"));
plan.AddArticle(Article("priv", "n2"));
plan.AddArticle(Article("end", "n3"));
plan.Fold("pub", "end", isPublic: true);
plan.Fold("priv", "end", isPublic: false);
var read = ArticlePlanJson.Read(ArticlePlanJson.Write(plan))!;
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);
Assert.Equal(["x"], stray.PublicRedirects);
}
[Fact]
public void Fold_MovesSourcesAndRedirects()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("thin"));
plan.AddArticle(Article("into"));
plan.AddArticle(Article("x"));
plan.Assign("n1", ["into"]);
plan.Assign("n2", ["thin"]);
plan.Assign("n3", ["thin", "into"]);
plan.Fold("x", "thin", isPublic: false);
plan.Fold("thin", "into", isPublic: false);
Assert.Null(plan.Find("thin"));
Assert.Equal(["n1", "n3", "n2"], plan.Find("into")!.Sources);
Assert.Equal(["into"], plan.Assignments["n2"]);
Assert.Equal(["into"], plan.Assignments["n3"]);
Assert.Equal("into", plan.Redirects["thin"]);
Assert.Equal("into", plan.Redirects["x"]);
}
[Fact]
public void Resolve_FollowsChain_StopsAtCycleAndFiveHops()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("end", "n"));
plan.AddArticle(Article("a1", "n"));
plan.Fold("a1", "end", isPublic: false);
Assert.Equal("end", plan.Resolve("end"));
Assert.Equal("end", plan.Resolve("a1"));
Assert.Null(plan.Resolve("a2"));
Assert.Null(plan.Resolve("unknown"));
var sixHops = ArticlePlanJson.Read("""
{"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("""
{"version":3,"articles":[],"assignments":{},"redirects":{"p":"q","q":"p"}}
""")!;
Assert.Null(cycle.Resolve("p"));
}
[Fact]
public void ReplaceArticle_KeepsAssignmentsConsistent()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a"));
plan.Assign("n1", ["a"]);
plan.Assign("n2", ["a"]);
plan.ReplaceArticle(Article("a", "n2", "n3") with { Scope = "New scope." });
Assert.Equal("New scope.", plan.Find("a")!.Scope);
Assert.False(plan.IsAssigned("n1"));
Assert.Equal(["a"], plan.Assignments["n3"]);
Assert.Equal(["a"], plan.Assignments["n2"]);
}
[Fact]
public void Json_RoundTrips()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(new PlannedArticle("heating", "Heizung", "Heating", ArticleCategory.System, "Heating system.", []));
plan.AddArticle(Article("old"));
plan.Assign("n1", ["heating"]);
plan.Assign("n2", ["heating", "old"]);
plan.Assign("meta", []);
plan.Fold("old", "heating", isPublic: false);
var json = ArticlePlanJson.Write(plan);
var read = ArticlePlanJson.Read(json);
Assert.Contains("\"assignments\"", json);
Assert.Contains("\"category\": \"system\"", json);
Assert.NotNull(read);
Assert.Equal(plan.Articles, read.Articles, new PlannedArticleComparer());
Assert.Equal(["meta", "n1", "n2"], read.Assignments.Keys);
Assert.Equal(["heating"], read.Assignments["n2"]);
Assert.Empty(read.Assignments["meta"]);
Assert.Equal("heating", read.Redirects["old"]);
}
[Fact]
public void Json_RedirectWithBadTargetSlug_Dropped()
{
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);
}
[Fact]
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<InvalidOperationException>(() => plan.SetParent("ghost", "a"));
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a"));
plan.AddArticle(Article("c", "n9"));
plan.AddArticle(Article("d", "n0"));
plan.Assign("n2", ["a"]);
plan.Assign("n1", ["a"]);
plan.Fold("d", "c", isPublic: false);
plan.AddArticle(Article("b", "n8"));
plan.Fold("b", "c", isPublic: false);
var json = ArticlePlanJson.Write(plan);
var maps = json[json.IndexOf("\"assignments\"", StringComparison.Ordinal)..];
Assert.True(maps.IndexOf("\"n1\"", StringComparison.Ordinal) < maps.IndexOf("\"n2\"", StringComparison.Ordinal));
Assert.True(maps.IndexOf("\"b\": \"c\"", StringComparison.Ordinal) < maps.IndexOf("\"d\": \"c\"", StringComparison.Ordinal));
Assert.Equal(json, ArticlePlanJson.Write(ArticlePlanJson.Read(json)!));
}
[Fact]
public void Json_Malformed_IsNull() => Assert.Null(ArticlePlanJson.Read("not json"));
[Fact]
public void Json_BadSlugOrCategory_ArticleDropped()
{
var plan = ArticlePlanJson.Read("""
{"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));
}
[Fact]
public void Json_AssignmentToUnknownArticle_Dropped()
{
var plan = ArticlePlanJson.Read("""
{"version":3,"articles":[{"slug":"a","category":"topic","sources":["n1"]}],
"assignments":{"n1":["a","ghost"],"n2":["ghost"],"n3":[]}}
""")!;
Assert.Equal(["a"], plan.Assignments["n1"]);
Assert.False(plan.IsAssigned("n2"));
Assert.True(plan.IsAssigned("n3"));
}
private sealed class PlannedArticleComparer : IEqualityComparer<PlannedArticle>
{
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.Parent == y.Parent;
public int GetHashCode(PlannedArticle obj) => obj.Slug.GetHashCode();
}
}