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