- 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>
314 lines
11 KiB
C#
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();
|
|
}
|
|
}
|