Files
Wiki/test/Wiki.Application.Tests/ArticlePlanTests.cs
T
claude-botandClaude Opus 5.5 4706e49fd7 fix(encyclopedia): planning survives model errors, thin articles wait for fold
- A text-model HttpRequestException skips its assign batch (not remembered),
  split or fold article; only embedding/index failures end planning.
- Neighbour timeouts (TaskCanceledException, run not cancelled) count as
  service down.
- Thin articles with a nearby article and no keep at their inputs are not
  written until their fold decision; split children of the run are not
  folded.
- NearestAsync widens its query up to 8x the batch size, then fills from
  the open notes in slug order.
- Deleted articles' slugs (and redirects to them) stay reserved in
  _plan.json "retired"; a note left without articles is unassigned, not
  skipped.
- Fold records whether the folded article was public in both languages
  ("publicRedirects"); repointed redirects keep their flag.
- Assigned counts only notes assigned to an article.

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

314 lines
11 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("""
{"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("""{"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("""{"articles":[],"assignments":{},"redirects":{"x":"y"}}""")!;
var stray = ArticlePlanJson.Read("""{"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("""
{"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"}}
""")!;
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("""{"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"]}"""));
[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("""
{"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("""
{"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);
public int GetHashCode(PlannedArticle obj) => obj.Slug.GetHashCode();
}
}