Files
Wiki/test/Wiki.Contracts/FileIndex/FileIndexUnitTests.cs
T
claude-botandClaude Sonnet 5 019f046925 feat(fileindex): file-backed vector index and document catalog adapters
FileVectorIndex persists one binary file per model under index/ with
atomic tmp+rename writes; FileDocumentCatalog persists a single
catalog.json, rebuilding the link graph via WikiLinks.Extract on every
upsert. Both start empty and report health Ok with a reset detail if
their file on disk is corrupt, rather than crashing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:32:43 +02:00

99 lines
3.7 KiB
C#

using Microsoft.Extensions.Options;
using Wiki.Adapters.FileIndex;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Contracts.FileIndex;
/// <summary>Behaviour specific to the file-backed adapters that the shared ports contracts don't exercise: atomicity and resilience to corrupt state on disk.</summary>
public sealed class FileIndexUnitTests : IDisposable
{
private readonly List<string> _directories = [];
private string NewDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "wiki-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
_directories.Add(directory);
return directory;
}
private static IOptions<FileIndexOptions> OptionsFor(string directory) => Options.Create(new FileIndexOptions { Directory = directory });
private static float[] Unit(params float[] v)
{
var n = MathF.Sqrt(v.Sum(x => x * x));
return v.Select(x => x / n).ToArray();
}
[Fact]
public async Task VectorIndexFlushLeavesNoTempFileBehind()
{
var directory = NewDirectory();
var index = new FileVectorIndex(OptionsFor(directory));
await index.UpsertAsync([new Embedding("m", "a", "h1", Unit(1, 0, 0))], CancellationToken.None);
await index.FlushAsync(CancellationToken.None);
var indexDirectory = Path.Combine(directory, "index");
Assert.Empty(Directory.GetFiles(indexDirectory, "*.tmp"));
Assert.NotEmpty(Directory.GetFiles(indexDirectory, "*.bin"));
}
[Fact]
public async Task VectorIndexCorruptFileStartsEmptyAndHealthMentionsReset()
{
var directory = NewDirectory();
var indexDirectory = Path.Combine(directory, "index");
Directory.CreateDirectory(indexDirectory);
await File.WriteAllBytesAsync(Path.Combine(indexDirectory, "broken.bin"), [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
var index = new FileVectorIndex(OptionsFor(directory));
var state = await index.DescribeAsync("test-model", CancellationToken.None);
Assert.Equal(0, state.Count);
var health = await index.CheckAsync(CancellationToken.None);
Assert.True(health.Ok);
Assert.NotNull(health.Detail);
Assert.Contains("reset", health.Detail, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CatalogFlushLeavesNoTempFileBehind()
{
var directory = NewDirectory();
var catalog = new FileDocumentCatalog(OptionsFor(directory));
await catalog.SetStateAsync(new CatalogState("abc", DateTimeOffset.UtcNow), CancellationToken.None);
await catalog.FlushAsync(CancellationToken.None);
Assert.Empty(Directory.GetFiles(directory, "*.tmp"));
Assert.True(File.Exists(Path.Combine(directory, "catalog.json")));
}
[Fact]
public async Task CatalogCorruptFileStartsEmptyAndHealthMentionsReset()
{
var directory = NewDirectory();
await File.WriteAllTextAsync(Path.Combine(directory, "catalog.json"), "{ not valid json");
var catalog = new FileDocumentCatalog(OptionsFor(directory));
var state = await catalog.GetStateAsync(CancellationToken.None);
Assert.Null(state.Commit);
Assert.Empty(await catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None));
var health = await catalog.CheckAsync(CancellationToken.None);
Assert.True(health.Ok);
Assert.NotNull(health.Detail);
Assert.Contains("reset", health.Detail, StringComparison.OrdinalIgnoreCase);
}
public void Dispose()
{
foreach (var directory in _directories)
{
try { Directory.Delete(directory, recursive: true); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
}