using Microsoft.Extensions.Options;
using Wiki.Adapters.FileIndex;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Contracts.FileIndex;
/// Behaviour specific to the file-backed adapters that the shared ports contracts don't exercise: atomicity and resilience to corrupt state on disk.
public sealed class FileIndexUnitTests : IDisposable
{
private readonly List _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 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) { }
}
}
}