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

185 lines
10 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Wiki.Core.Model;
using Wiki.Core.Parsing;
using Wiki.Core.Ports;
namespace Wiki.Application.Tests;
/// <summary>In-memory ports for use-case tests. Content hash = SHA-1 of the text, like a git blob.</summary>
public sealed class FakeStore : INoteStore
{
public Dictionary<string, string> Files { get; } = new();
public string Head { get; set; } = "c1";
public int Reads { get; private set; }
public string Name => "fake-store";
public Task<StoreHead> GetHeadAsync(CancellationToken ct) => Task.FromResult(new StoreHead(Head, null));
public Task<IReadOnlyList<DocumentRef>> ListAsync(string commit, IReadOnlySet<DocumentKind> kinds, CancellationToken ct)
{
var refs = new List<DocumentRef>();
foreach (var (path, text) in Files)
{
var c = DocumentParser.Classify(path);
if (c is null || !kinds.Contains(c.Value.Kind)) continue;
refs.Add(new DocumentRef(c.Value.Kind, c.Value.Slug, path, BlobSha(text)));
}
return Task.FromResult<IReadOnlyList<DocumentRef>>(refs);
}
public Task<string> ReadAsync(DocumentRef reference, string commit, CancellationToken ct)
{
Reads++;
return Task.FromResult(Files[reference.Path]);
}
public List<(string Message, string Path)> Created { get; } = [];
public Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct)
{
if (Files.ContainsKey(path)) throw new DocumentExistsException(path);
Files[path] = text;
Created.Add((message, path));
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(text)))));
}
public Dictionary<string, byte[]> Binaries { get; } = new();
public Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct)
{
if (Files.ContainsKey(path) || Binaries.ContainsKey(path)) throw new DocumentExistsException(path);
Binaries[path] = content;
Created.Add(($"{message}", path));
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(content))));
}
public List<(string Message, IReadOnlyList<FileChange> Changes)> Commits { get; } = [];
private int _commitCount = 1;
public Task<string> CommitAsync(IReadOnlyList<FileChange> changes, string message, CancellationToken ct)
{
foreach (var c in changes)
{
var exists = Files.TryGetValue(c.Path, out var current);
if (c.Op == FileChangeOp.Create ? exists : !exists || c.BlobSha != BlobSha(current!))
throw new StoreConflictException($"conflict on {c.Path}");
}
foreach (var c in changes)
{
if (c.Op == FileChangeOp.Delete) Files.Remove(c.Path);
else Files[c.Path] = c.Text!;
}
Commits.Add((message, changes));
Head = $"c{++_commitCount}";
return Task.FromResult(Head);
}
public Task<StoredText?> ReadTextAsync(string path, string commit, CancellationToken ct) =>
Task.FromResult(Files.TryGetValue(path, out var text) ? new StoredText(text, BlobSha(text)) : null);
public Task<byte[]> ReadBytesAsync(string path, string commit, CancellationToken ct) =>
Task.FromResult(Binaries.TryGetValue(path, out var bytes) ? bytes
: Files.TryGetValue(path, out var text) ? Encoding.UTF8.GetBytes(text)
: throw new FileNotFoundException(path));
private static string BlobSha(string text) => Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(text)));
public Uri? BrowseUrl(string path) => null;
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}
public sealed class FakeCatalog : IDocumentCatalog
{
private readonly Dictionary<(DocumentKind, string), Document> _docs = new();
private CatalogState _state = new(null, null);
public int Flushes { get; private set; }
public string Name => "fake-catalog";
public Task<CatalogState> GetStateAsync(CancellationToken ct) => Task.FromResult(_state);
public Task SetStateAsync(CatalogState state, CancellationToken ct) { _state = state; return Task.CompletedTask; }
public Task UpsertAsync(IReadOnlyList<Document> documents, CancellationToken ct) { foreach (var d in documents) _docs[(d.Kind, d.Slug)] = d; return Task.CompletedTask; }
public Task RemoveAsync(IReadOnlyList<DocumentRef> references, CancellationToken ct) { foreach (var r in references) _docs.Remove((r.Kind, r.Slug)); return Task.CompletedTask; }
public Task<Document?> GetAsync(DocumentKind kind, string slug, CancellationToken ct) => Task.FromResult(_docs.GetValueOrDefault((kind, slug)));
public Task<IReadOnlyList<DocumentRef>> ListAsync(DocumentKind kind, CancellationToken ct) => Task.FromResult<IReadOnlyList<DocumentRef>>(_docs.Values.Where(d => d.Kind == kind).Select(d => d.Ref).ToList());
public Task<IReadOnlyList<Document>> ListDocumentsAsync(DocumentKind kind, CancellationToken ct) => Task.FromResult<IReadOnlyList<Document>>(_docs.Values.Where(d => d.Kind == kind).ToList());
public Task<IReadOnlyList<Link>> BacklinksAsync(string slug, CancellationToken ct) => Task.FromResult<IReadOnlyList<Link>>(_docs.Values.Where(d => d.Kind == DocumentKind.Wiki).SelectMany(WikiLinks.Extract).Where(l => l.ToSlug == slug).ToList());
public Task<IReadOnlyList<Link>> OutgoingLinksAsync(string slug, CancellationToken ct) => Task.FromResult<IReadOnlyList<Link>>(_docs.TryGetValue((DocumentKind.Wiki, slug), out var d) ? WikiLinks.Extract(d) : []);
public Task<IReadOnlyDictionary<string, int>> TagCountsAsync(CancellationToken ct) => Task.FromResult<IReadOnlyDictionary<string, int>>(_docs.Values.Where(d => d.Kind == DocumentKind.Wiki).SelectMany(d => d.Tags).GroupBy(t => t).ToDictionary(g => g.Key, g => g.Count()));
public Task FlushAsync(CancellationToken ct) { Flushes++; return Task.CompletedTask; }
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}
public sealed class FakeIndex : IVectorIndex
{
public Dictionary<string, Dictionary<string, (string Hash, float[] Vector)>> Models { get; } = new();
public int Flushes { get; private set; }
public string Name => "fake-index";
private Dictionary<string, (string Hash, float[] Vector)> Of(string model) => Models.TryGetValue(model, out var m) ? m : Models[model] = new();
public Task<IndexState> DescribeAsync(string modelId, CancellationToken ct) => Task.FromResult(new IndexState(modelId, Of(modelId).ToDictionary(kv => kv.Key, kv => kv.Value.Hash)));
public Task UpsertAsync(IReadOnlyList<Embedding> embeddings, CancellationToken ct) { foreach (var e in embeddings) Of(e.ModelId)[e.Slug] = (e.ContentHash, e.Vector); return Task.CompletedTask; }
public Task RemoveAsync(string modelId, IReadOnlyList<string> slugs, CancellationToken ct) { foreach (var s in slugs) Of(modelId).Remove(s); return Task.CompletedTask; }
public Task<IReadOnlyList<RankedHit>> QueryAsync(string modelId, float[] query, int k, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<RankedHit>>(Of(modelId).Select(kv => new RankedHit(kv.Key, kv.Value.Vector.Zip(query, (a, b) => a * b).Sum())).OrderByDescending(h => h.Score).Take(k).ToList());
public Task FlushAsync(CancellationToken ct) { Flushes++; return Task.CompletedTask; }
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}
/// <summary>Deterministic 4-dimensional "embeddings": a hash of the text spread over the axes, unit length.</summary>
public sealed class FakeEmbeddings(string modelId) : IEmbeddingModel
{
public string ModelId { get; } = modelId;
public int Dimension => 4;
public List<IReadOnlyList<string>> Batches { get; } = [];
public string Name => "fake-embeddings:" + ModelId;
public Task<IReadOnlyList<float[]>> EmbedAsync(IReadOnlyList<string> texts, CancellationToken ct)
{
Batches.Add(texts);
var vectors = texts.Select(t =>
{
var h = SHA256.HashData(Encoding.UTF8.GetBytes(ModelId + t));
var v = new float[] { h[0] + 1, h[1] + 1, h[2] + 1, h[3] + 1 };
var n = MathF.Sqrt(v.Sum(x => x * x));
return v.Select(x => x / n).ToArray();
}).ToList();
return Task.FromResult<IReadOnlyList<float[]>>(vectors);
}
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}
/// <summary>Scripted text model: replies (or exceptions) are returned in the order they were queued; every call is recorded.</summary>
public sealed class FakeTextModel(string modelId = "fake-text") : ITextModel
{
private readonly Queue<object> _script = new();
public string ModelId { get; } = modelId;
public string Name => "fake-text:" + ModelId;
public List<(string System, string User, int MaxTokens)> Calls { get; } = [];
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
public FakeTextModel Reply(string text, bool truncated = false) =>
Enqueue(new TextReply(text, ModelId, 10, 10, truncated));
public FakeTextModel Throw(Exception exception) => Enqueue(exception);
private FakeTextModel Enqueue(object item) { _script.Enqueue(item); return this; }
public Task<TextReply> CompleteAsync(string system, string user, int maxTokens, CancellationToken ct)
{
Calls.Add((system, user, maxTokens));
if (_script.Count == 0) throw new InvalidOperationException("FakeTextModel: no scripted reply left");
return _script.Dequeue() switch
{
Exception e => Task.FromException<TextReply>(e),
TextReply r => Task.FromResult(r),
var other => throw new InvalidOperationException($"unexpected script item {other}"),
};
}
}