using Morph.AiKit.Rag;
using Xunit;
namespace Morph.AiKit.Tests;
///
/// Canarios do (T14). A afirmacao central e uma so, e ela e
/// demonstrada contra um acervo, nao afirmada: ingerir a mesma coisa duas vezes nao
/// duplica nada, e mudar um campo atualiza em vez de acrescentar.
///
public sealed class DeltaIngestTests
{
/// Um documento de acervo: identidade estavel + conteudo que muda.
private sealed record Doc(string Slug, string Body);
private static string Key(Doc d) => d.Slug;
///
/// Hash de conteudo do teste. Nao precisa ser criptografico — precisa mudar quando o
/// conteudo muda, que e a unica propriedade que o usa.
///
private static string Hash(Doc d) => $"h:{d.Body.Length}:{d.Body.GetHashCode(StringComparison.Ordinal)}";
///
/// Um acervo minusculo que aplica o plano — e a unica forma de a idempotencia ser
/// medida no que ficou guardado, em vez de nos nomes das listas do plano.
///
///
/// Modela um acervo de verdade: Added vira INSERT numa lista de linhas,
/// Updated vira UPDATE da linha existente e Removed vira DELETE. O indice de
/// hashes e mantido a parte, como um indice de banco. Isso importa: se o acervo fizesse
/// upsert de tudo, uma re-ingestao que classificasse errado sairia invisivel — aqui ela
/// aparece como linha duplicada, que e o defeito real que esta task combate.
///
private sealed class Corpus
{
private readonly List _rows = [];
private readonly Dictionary _index = new(StringComparer.Ordinal);
/// Quantos embeddings foram pagos ao todo. Nao sobe numa ingestao no-op.
public int EmbeddingCalls { get; private set; }
/// As linhas guardadas, na ordem em que entraram.
public IReadOnlyList Rows => _rows;
/// O indice lookup-key para hash, que alimenta o proximo plano.
public IReadOnlyDictionary Hashes => _index;
public DeltaPlan Ingest(IEnumerable incoming)
{
var plan = DeltaIngest.Plan(incoming, Key, Hash, _index);
foreach (var doc in plan.Added)
{
_rows.Add(doc); // INSERT
_index[doc.Slug] = Hash(doc);
EmbeddingCalls++;
}
foreach (var doc in plan.Updated)
{
_rows[_rows.FindIndex(r => r.Slug == doc.Slug)] = doc; // UPDATE
_index[doc.Slug] = Hash(doc);
EmbeddingCalls++;
}
foreach (var slug in plan.Removed)
{
_rows.RemoveAll(r => r.Slug == slug); // DELETE
_index.Remove(slug);
}
return plan;
}
}
private static Doc[] ThreeDocs() =>
[
new("politica-troca", "Trocas em ate 7 dias."),
new("prazo-entrega", "Entrega em 3 dias uteis."),
new("garantia", "Garantia de 12 meses."),
];
// ---------------------------------------------------------------------------------
// doneCriteria: os quatro baldes
// ---------------------------------------------------------------------------------
///
/// Os quatro baldes numa unica passada: um item novo, um com hash mudado, um com hash
/// igual e uma chave que sumiu da origem.
///
[Fact]
public void TheFourBucketsAreSortedInASinglePass()
{
var existing = new Dictionary(StringComparer.Ordinal)
{
["prazo-entrega"] = Hash(new Doc("prazo-entrega", "Entrega em 5 dias uteis.")), // vai mudar
["garantia"] = Hash(new Doc("garantia", "Garantia de 12 meses.")), // igual
["pagina-morta"] = "h:qualquer", // sumiu
};
var plan = DeltaIngest.Plan(ThreeDocs(), Key, Hash, existing);
Assert.Equal(["politica-troca"], plan.Added.Select(d => d.Slug));
Assert.Equal(["prazo-entrega"], plan.Updated.Select(d => d.Slug));
Assert.Equal(["garantia"], plan.Unchanged.Select(d => d.Slug));
Assert.Equal(["pagina-morta"], plan.Removed);
Assert.False(plan.IsNoOp);
Assert.Equal(3, plan.WriteCount);
}
/// Acervo vazio: tudo e novo, nada e removido.
[Fact]
public void AnEmptyCorpusMakesEverythingNew()
{
var plan = DeltaIngest.Plan(ThreeDocs(), Key, Hash, new Dictionary());
Assert.Equal(3, plan.Added.Count);
Assert.Empty(plan.Updated);
Assert.Empty(plan.Unchanged);
Assert.Empty(plan.Removed);
}
///
/// Remessa vazia com acervo cheio: tudo sai em Removed. E o comportamento
/// correto e tambem o mais perigoso — e por isso o plano e um plano: quem decide se
/// aplica um Removed de 100% e o projeto, que sabe se a origem realmente esvaziou
/// ou se o crawler falhou.
///
[Fact]
public void AnEmptyShipmentAgainstAFullCorpusRemovesEverything()
{
var corpus = new Corpus();
corpus.Ingest(ThreeDocs());
var plan = DeltaIngest.Plan(Array.Empty(), Key, Hash, corpus.Hashes);
Assert.Equal(3, plan.Removed.Count);
Assert.Empty(plan.Added);
Assert.Empty(plan.Updated);
}
// ---------------------------------------------------------------------------------
// doneCriteria: IDEMPOTENCIA, medida no acervo
// ---------------------------------------------------------------------------------
///
/// A prova principal. Ingerir a mesma remessa duas vezes: na segunda o plano e
/// vazio nos tres baldes de escrita, o acervo tem exatamente os mesmos documentos e
/// nenhum embedding a mais foi pago.
///
[Fact]
public void IngestingTheSameShipmentTwiceLeavesTheCorpusUntouched()
{
var corpus = new Corpus();
var shipment = ThreeDocs();
var first = corpus.Ingest(shipment);
Assert.Equal(3, first.Added.Count);
Assert.Equal(3, corpus.EmbeddingCalls);
var before = corpus.Rows.OrderBy(d => d.Slug, StringComparer.Ordinal).ToArray();
var second = corpus.Ingest(shipment);
Assert.True(second.IsNoOp);
Assert.Equal(0, second.WriteCount);
Assert.Equal(3, second.Unchanged.Count);
// O acervo e o custo nao se mexeram: 3 documentos, os mesmos, 3 embeddings ao todo.
Assert.Equal(3, corpus.Rows.Count); // <- a linha que morre se a mesma coisa duplicar
Assert.Equal(before, corpus.Rows.OrderBy(d => d.Slug, StringComparer.Ordinal));
Assert.Equal(3, corpus.EmbeddingCalls);
// E uma terceira, para que "duas vezes" nao pareca coincidencia.
Assert.True(corpus.Ingest(shipment).IsNoOp);
Assert.Equal(3, corpus.EmbeddingCalls);
}
///
/// Mudar um campo atualiza, nao acrescenta. A contagem do acervo nao sobe, o slug
/// continua unico e o corpo e o novo — que e a diferenca entre uma ingestao delta e a
/// ingestao apaga-tudo que este tipo existe para substituir.
///
[Fact]
public void ChangingOneFieldUpdatesInPlaceInsteadOfAppending()
{
var corpus = new Corpus();
corpus.Ingest(ThreeDocs());
Doc[] revised =
[
new("politica-troca", "Trocas em ate 7 dias."),
new("prazo-entrega", "Entrega em 1 dia util."), // <- unico campo alterado
new("garantia", "Garantia de 12 meses."),
];
var plan = corpus.Ingest(revised);
Assert.Equal(["prazo-entrega"], plan.Updated.Select(d => d.Slug));
Assert.Empty(plan.Added);
Assert.Empty(plan.Removed);
Assert.Equal(2, plan.Unchanged.Count);
Assert.Equal(3, corpus.Rows.Count); // nao apendou
Assert.Single(corpus.Rows, d => d.Slug == "prazo-entrega"); // uma so linha
Assert.Equal("Entrega em 1 dia util.", corpus.Rows.Single(d => d.Slug == "prazo-entrega").Body);
Assert.Equal(4, corpus.EmbeddingCalls); // 3 iniciais + 1 mudanca
// E a re-ingestao da remessa revisada volta a ser no-op.
Assert.True(corpus.Ingest(revised).IsNoOp);
Assert.Equal(4, corpus.EmbeddingCalls);
}
///
/// A mesma afirmacao dita como contagem de linhas, e antes de qualquer outra
/// asserçao: re-ingerir nao insere uma segunda copia. E a linha que fica vermelha com
/// "6" no lugar de "3" quando a comparacao por lookup-key se quebra — a duplicacao
/// literal, e nao um nome de balde trocado.
///
[Fact]
public void ReIngestingDoesNotInsertASecondCopyOfTheSameDocument()
{
var corpus = new Corpus();
var shipment = ThreeDocs();
corpus.Ingest(shipment);
corpus.Ingest(shipment);
Assert.Equal(3, corpus.Rows.Count);
Assert.Equal(3, corpus.Rows.Select(d => d.Slug).Distinct(StringComparer.Ordinal).Count());
Assert.Equal(3, corpus.EmbeddingCalls);
}
///
/// Idempotencia sobrevive a reordenacao: a mesma remessa embaralhada continua sendo
/// no-op. A identidade e a chave, nunca a posicao na lista.
///
[Fact]
public void OrderDoesNotMakeAShipmentLookDifferent()
{
var corpus = new Corpus();
corpus.Ingest(ThreeDocs());
var shuffled = ThreeDocs().Reverse().ToArray();
Assert.True(corpus.Ingest(shuffled).IsNoOp);
Assert.Equal(3, corpus.EmbeddingCalls);
}
// ---------------------------------------------------------------------------------
// doneCriteria: chave duplicada falha de forma explicita
// ---------------------------------------------------------------------------------
///
/// Chave repetida derruba o plano dizendo a chave e as duas posicoes. Ficar com o
/// ultimo gravaria um dos dois ao acaso; deduplicar sozinho esconderia o defeito da
/// origem, que e onde ele tem conserto.
///
[Fact]
public void ADuplicateLookupKeyFailsNamingTheKeyAndBothPositions()
{
Doc[] shipment =
[
new("politica-troca", "primeira versao"),
new("prazo-entrega", "irrelevante"),
new("politica-troca", "segunda versao"),
];
var failure = Assert.Throws(
() => DeltaIngest.Plan(shipment, Key, Hash, new Dictionary()));
Assert.Equal("politica-troca", failure.LookupKey);
Assert.Equal(0, failure.FirstIndex);
Assert.Equal(2, failure.DuplicateIndex);
Assert.Contains("deixa de ser deterministico", failure.Message, StringComparison.Ordinal);
}
/// Chave em branco colapsaria itens distintos num so — e recusada.
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ABlankLookupKeyIsRefused(string blank)
{
var failure = Assert.Throws(
() => DeltaIngest.Plan([new Doc(blank, "corpo")], Key, Hash, new Dictionary()));
Assert.Contains("posicao 0", failure.Message, StringComparison.Ordinal);
}
///
/// Hash em branco e recusado: sem versao nao ha como separar "nao mudou" de "mudou", e
/// tratar a ausencia como "mudou" reintroduz o re-embedding de tudo — o defeito original.
///
[Fact]
public void ABlankContentHashIsRefusedInsteadOfMeaningChanged()
{
var failure = Assert.Throws(
() => DeltaIngest.Plan(ThreeDocs(), Key, _ => string.Empty, new Dictionary()));
Assert.Contains("politica-troca", failure.Message, StringComparison.Ordinal);
}
/// Argumento nulo e defeito de chamada, e falha na chamada.
[Fact]
public void TheRequiredArgumentsAreNotOptional()
{
var empty = new Dictionary();
Assert.Throws(() => DeltaIngest.Plan(null!, Key, Hash, empty));
Assert.Throws(() => DeltaIngest.Plan(ThreeDocs(), null!, Hash, empty));
Assert.Throws(() => DeltaIngest.Plan(ThreeDocs(), Key, null!, empty));
Assert.Throws(() => DeltaIngest.Plan(ThreeDocs(), Key, Hash, null!));
}
// ---------------------------------------------------------------------------------
// Identidade e versao sao perguntas diferentes
// ---------------------------------------------------------------------------------
///
/// A comparacao de chave e ordinal: Politica e politica sao dois
/// documentos. Deixar a cultura decidir faria o mesmo corpus produzir planos diferentes
/// conforme a maquina que roda a ingestao.
///
[Fact]
public void KeysAreComparedOrdinallySoThePlanDoesNotDependOnTheMachine()
{
var existing = new Dictionary(StringComparer.Ordinal)
{
["politica-troca"] = Hash(new Doc("politica-troca", "Trocas em ate 7 dias.")),
};
var plan = DeltaIngest.Plan(
[new Doc("Politica-Troca", "Trocas em ate 7 dias.")],
Key,
Hash,
existing);
Assert.Equal(["Politica-Troca"], plan.Added.Select(d => d.Slug));
Assert.Equal(["politica-troca"], plan.Removed);
}
///
/// O plano nao le o corpo do item: um documento cuja chave e hash nao mudaram e
/// Unchanged ainda que o objeto seja outra instancia. E o que permite ao acervo
/// reprocessar so o que mudou de verdade.
///
[Fact]
public void EqualityIsDecidedByKeyAndHashNotByInstance()
{
var corpus = new Corpus();
corpus.Ingest(ThreeDocs());
var reconstructed = ThreeDocs().Select(d => new Doc(d.Slug, d.Body)).ToArray();
Assert.True(corpus.Ingest(reconstructed).IsNoOp);
}
/// A remessa e enumerada uma unica vez — importa quando ela vem de um stream.
[Fact]
public void TheShipmentIsEnumeratedExactlyOnce()
{
var passes = 0;
IEnumerable Once()
{
passes++;
foreach (var doc in ThreeDocs())
{
yield return doc;
}
}
DeltaIngest.Plan(Once(), Key, Hash, new Dictionary());
Assert.Equal(1, passes);
}
}