Files
Wiki/test/Wiki.Application.Tests/SyncUseCaseTests.cs
T

159 lines
6.8 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Wiki.Application.Retrieval;
using Wiki.Application.Sync;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Application.Tests;
public class SyncUseCaseTests
{
private static string Note(string title, string body = "Body.", string tags = "[a]") =>
$"---\ntitle: {title}\ntype: wiki\ntags: {tags}\nsources: [x]\nupdated: 2026-09-01\n---\n\n# {title}\n\n{body}\n";
private readonly FakeStore _store = new();
private readonly FakeCatalog _catalog = new();
private readonly FakeIndex _index = new();
private readonly FakeEmbeddings _model = new("m1");
private SyncUseCase Sync(params IEmbeddingModel[] models) =>
new(_store, _catalog, _index, models.Length == 0 ? [_model] : models, TimeProvider.System, NullLogger<SyncUseCase>.Instance, new SyncOptions(EmbedChunk: 2));
[Fact]
public async Task InitialSyncEmbedsWikiOnlyAndCatalogsEverything()
{
_store.Files["wiki/a.md"] = Note("A");
_store.Files["wiki/b.md"] = Note("B");
_store.Files["wiki/c.md"] = Note("C");
_store.Files["raw/2026-09-23-r.md"] = "---\ntitle: R\n---\n\nraw\n";
_store.Files["output/2026-09-23-o.md"] = "---\ntitle: O\n---\n\nout\n";
_store.Files["wiki/_index.md"] = "index";
var summary = await Sync().RunAsync(false, CancellationToken.None);
Assert.False(summary.Skipped);
Assert.Equal(5, summary.Changed);
Assert.Equal(3, summary.Embedded);
Assert.Equal(3, summary.Notes);
Assert.Equal(3, (await _index.DescribeAsync("m1", CancellationToken.None)).Count);
Assert.Single(await _catalog.ListAsync(DocumentKind.Raw, CancellationToken.None));
Assert.Single(await _catalog.ListAsync(DocumentKind.Output, CancellationToken.None));
Assert.Equal("c1", (await _catalog.GetStateAsync(CancellationToken.None)).Commit);
Assert.All(_model.Batches, b => Assert.InRange(b.Count, 1, 2));
Assert.Equal(1, _index.Flushes);
Assert.Equal(1, _catalog.Flushes);
}
[Fact]
public async Task Sync_IndexesArticles_ButDoesNotEmbedThem()
{
_store.Files["wiki/a.md"] = Note("A");
_store.Files["articles/de/x.md"] = "---\ntitle: X\n---\n\nArtikel\n";
var summary = await Sync().RunAsync(false, CancellationToken.None);
Assert.Equal(1, summary.Embedded);
Assert.Single(await _catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None));
Assert.Equal("de/x", Assert.Single(await _catalog.ListAsync(DocumentKind.Article, CancellationToken.None)).Slug);
Assert.Equal(1, _model.Batches.Sum(b => b.Count));
Assert.Equal(["a"], (await _index.DescribeAsync("m1", CancellationToken.None)).ContentHashBySlug.Keys);
}
[Fact]
public async Task UnchangedHeadIsSkippedWithoutReads()
{
_store.Files["wiki/a.md"] = Note("A");
await Sync().RunAsync(false, CancellationToken.None);
var reads = _store.Reads;
var summary = await Sync().RunAsync(false, CancellationToken.None);
Assert.True(summary.Skipped);
Assert.Equal(reads, _store.Reads);
Assert.NotNull((await _catalog.GetStateAsync(CancellationToken.None)).SyncedAt);
}
[Fact]
public async Task ChangedDocumentIsReembeddedAndRemovedOneDropped()
{
_store.Files["wiki/a.md"] = Note("A");
_store.Files["wiki/b.md"] = Note("B");
await Sync().RunAsync(false, CancellationToken.None);
var before = (await _index.DescribeAsync("m1", CancellationToken.None)).ContentHashBySlug;
_store.Head = "c2";
_store.Files["wiki/a.md"] = Note("A", "Changed body.");
_store.Files.Remove("wiki/b.md");
var reads = _store.Reads;
var summary = await Sync().RunAsync(false, CancellationToken.None);
Assert.Equal(1, summary.Changed);
Assert.Equal(1, summary.Removed);
Assert.Equal(1, _store.Reads - reads);
var after = (await _index.DescribeAsync("m1", CancellationToken.None)).ContentHashBySlug;
Assert.Equal(["a"], after.Keys);
Assert.NotEqual(before["a"], after["a"]);
Assert.Null(await _catalog.GetAsync(DocumentKind.Wiki, "b", CancellationToken.None));
Assert.Contains("Changed body", (await _catalog.GetAsync(DocumentKind.Wiki, "a", CancellationToken.None))!.Body);
}
[Fact]
public async Task ForceResyncsAnUnchangedHead()
{
_store.Files["wiki/a.md"] = Note("A");
await Sync().RunAsync(false, CancellationToken.None);
var summary = await Sync().RunAsync(true, CancellationToken.None);
Assert.False(summary.Skipped);
Assert.Equal(0, summary.Changed);
}
[Fact]
public async Task ALaterRegisteredModelIsBackfilledFromTheCatalog()
{
_store.Files["wiki/a.md"] = Note("A");
_store.Files["wiki/b.md"] = Note("B");
await Sync().RunAsync(false, CancellationToken.None);
var second = new FakeEmbeddings("m2");
var summary = await Sync(_model, second).RunAsync(true, CancellationToken.None);
Assert.Equal(2, summary.Embedded);
Assert.Equal(2, (await _index.DescribeAsync("m2", CancellationToken.None)).Count);
Assert.Equal(2, (await _index.DescribeAsync("m1", CancellationToken.None)).Count);
}
[Fact]
public async Task RankReturnsTitlesFromTheCatalog()
{
_store.Files["wiki/udm-dns.md"] = Note("UDM intercepts DNS", "The UDM answers DNS.");
_store.Files["wiki/ceph-osd.md"] = Note("Ceph OSD", "OSD fsid.");
await Sync().RunAsync(false, CancellationToken.None);
var rank = new RankUseCase(_index, _catalog);
var udm = (await _catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!;
var rows = await rank.RankAsync(_model, Wiki.Core.Parsing.EmbeddingText.For(udm), 5, CancellationToken.None);
Assert.Equal(2, rows.Count);
Assert.Equal("udm-dns", rows[0].Slug);
Assert.Equal("UDM intercepts DNS", rows[0].Title);
Assert.InRange(rows[0].Score, 0.99f, 1.01f);
await Assert.ThrowsAsync<ArgumentException>(() => rank.RankAsync(_model, " ", 5, CancellationToken.None));
}
[Fact]
public async Task CoordinatorRecordsFailureAndRecovers()
{
_store.Files["wiki/a.md"] = Note("A");
var coordinator = new SyncCoordinator(Sync(), TimeProvider.System, NullLogger<SyncCoordinator>.Instance);
_store.Head = null!; // GetHeadAsync returns a null commit → the use case fails on Substring
Assert.Null(await coordinator.RunAsync(false, CancellationToken.None));
Assert.NotNull(coordinator.LastError);
Assert.False(coordinator.Syncing);
_store.Head = "c1";
Assert.NotNull(await coordinator.RunAsync(false, CancellationToken.None));
Assert.Null(coordinator.LastError);
}
}