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) { } } } }