159 lines
6.8 KiB
C#
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);
|
|
}
|
|
}
|