The fact sink was a second adapter over the same backend. INoteStore gains CreateAsync (DocumentExistsException on a taken path); the raw note's shape, slug and collision numbering live in DepositFactUseCase; GiteaRepository only moves text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
111 lines
6.8 KiB
C#
111 lines
6.8 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, Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(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 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));
|
|
}
|