using Morph.AiKit.Media; using Xunit; namespace Morph.AiKit.Tests; /// /// Canarios da maquina de estados de . /// /// /// Tres afirmacoes carregam o standard ai-agents/media-pipeline.md inteiro, e cada /// uma e o inverso literal de um defeito caro: /// /// Publish falha fora de Approved — publicar sem revisao humana. /// Approved so existe por Approve(revisor) — "aprovado" sem quem. /// RejectedByPolicy nao e retryable — retry que vira fatura. /// /// public sealed class MediaJobStateTests { private static readonly DateTimeOffset T0 = new(2026, 9, 8, 12, 0, 0, TimeSpan.Zero); /// Uma marcacao valida, para os casos que nao estao medindo a marcacao. private static MediaDisclosure Label => MediaDisclosure.Create("Imagem gerada por IA", "legenda", T0); private static MediaJob Pending() => MediaJob.Create( new MediaRequest("catalogo", "um sofa verde", MediaPurpose.CatalogPhoto), new MediaIdempotencyKey("chave"), T0); private static MediaJob Generated() { var job = Pending().StartGenerating().Value; return job.MarkGenerated( new MediaHash("abc"), "ab/c/abc.png", new MediaProvenance("openai", "gpt-image-2", "um sofa verde", null, [], "-", null, 0.05m, T0, true)) .Value; } // --------------------------------------------------------------------------------- // 1. Publish so a partir de Approved // --------------------------------------------------------------------------------- /// /// O atalho que alguem sempre quer: publicar direto de Generated. Ele nao /// existe, e a mensagem diz por que. /// [Fact] public void PublishFailsFromGeneratedBecauseNobodyReviewedIt() { var result = Generated().Publish(Label, T0); Assert.False(result.IsSuccess); Assert.Equal(MediaErrorCode.InvalidTransition, result.Error!.Code); Assert.Contains("Approved", result.Error.Message, StringComparison.Ordinal); } /// Publish falha a partir de TODO estado que nao seja Approved — nao so de Generated. [Theory] [InlineData(MediaJobState.Pending)] [InlineData(MediaJobState.Generating)] [InlineData(MediaJobState.Generated)] [InlineData(MediaJobState.Rejected)] [InlineData(MediaJobState.RejectedByPolicy)] [InlineData(MediaJobState.Failed)] public void PublishFailsFromEveryStateThatIsNotApproved(MediaJobState state) { var job = JobIn(state); Assert.NotEqual(MediaJobState.Approved, job.State); Assert.False(job.Publish(Label, T0).IsSuccess); } /// E, com a revisao humana no lugar, publica. [Fact] public void PublishSucceedsFromApproved() { var approved = Generated().Approve(MediaReview.ApprovedBy("lucas", T0)).Value; var published = approved.Publish(Label, T0); Assert.True(published.IsSuccess); Assert.Equal(MediaJobState.Published, published.Value.State); Assert.Equal(T0, published.Value.PublishedAt); } // --------------------------------------------------------------------------------- // 2. Approved so por Approve(revisor) // --------------------------------------------------------------------------------- /// A aprovacao carrega quem e quando — sem isso e um campo booleano. [Fact] public void ApproveStampsTheReviewerAndTheTime() { var approved = Generated().Approve(MediaReview.ApprovedBy("lucas", T0, "cor confere")).Value; Assert.Equal(MediaJobState.Approved, approved.State); Assert.Equal("lucas", approved.Review!.Reviewer); Assert.Equal(T0, approved.Review.DecidedAt); Assert.Equal("cor confere", approved.Review.Note); } /// Revisor em branco nao carimba nada, e a recusa e na construcao da revisao. [Fact] public void AnAnonymousReviewIsRefusedAtConstruction() => Assert.Throws(() => MediaReview.ApprovedBy(" ", T0)); /// /// Aprovar com um veredito de REJEICAO seria registrar o oposto do que o humano /// decidiu. O tipo recusa. /// [Fact] public void ApproveRefusesAReviewThatSaysRejected() { var result = Generated().Approve(MediaReview.RejectedBy("lucas", T0)); Assert.False(result.IsSuccess); Assert.Equal(MediaErrorCode.InvalidTransition, result.Error!.Code); } /// Aprovar antes de existirem bytes nao e transicao. [Fact] public void ApproveFailsBeforeTheBytesExist() => Assert.False(Pending().Approve(MediaReview.ApprovedBy("lucas", T0)).IsSuccess); // --------------------------------------------------------------------------------- // 3. RejectedByPolicy nao e retryable; Failed e // --------------------------------------------------------------------------------- /// /// A distincao que vale dinheiro. Bloqueio de moderacao e resposta definitiva; erro /// tecnico e tentativa perdida. /// [Fact] public void PolicyBlockIsTerminalAndTechnicalFailureIsNot() { var blocked = Pending().StartGenerating().Value.RejectByPolicy("moderacao").Value; var failed = Pending().StartGenerating().Value.Fail("HTTP 503").Value; Assert.Equal(MediaJobState.RejectedByPolicy, blocked.State); Assert.False(blocked.IsRetryable); Assert.False(blocked.StartGenerating().IsSuccess); Assert.Equal(MediaJobState.Failed, failed.State); Assert.True(failed.IsRetryable); Assert.True(failed.StartGenerating().IsSuccess); } /// Um job em Generated carrega hash, caminho e proveniencia — os tres juntos. [Fact] public void GeneratedCarriesHashPathAndProvenanceTogether() { var job = Generated(); Assert.Equal(MediaJobState.Generated, job.State); Assert.Equal("abc", job.Hash!.Value.Value); Assert.Equal("ab/c/abc.png", job.RelativePath); Assert.Equal("gpt-image-2", job.Provenance!.ModelId); } /// Uma transicao recusada NAO muda o job: o registro e imutavel. [Fact] public void ARefusedTransitionLeavesTheJobUntouched() { var job = Generated(); _ = job.Publish(Label, T0); Assert.Equal(MediaJobState.Generated, job.State); Assert.Null(job.PublishedAt); } private static MediaJob JobIn(MediaJobState state) => state switch { MediaJobState.Pending => Pending(), MediaJobState.Generating => Pending().StartGenerating().Value, MediaJobState.Generated => Generated(), MediaJobState.Approved => Generated().Approve(MediaReview.ApprovedBy("lucas", T0)).Value, MediaJobState.Rejected => Generated().Reject(MediaReview.RejectedBy("lucas", T0)).Value, MediaJobState.RejectedByPolicy => Pending().StartGenerating().Value.RejectByPolicy("moderacao").Value, MediaJobState.Failed => Pending().StartGenerating().Value.Fail("HTTP 503").Value, _ => throw new ArgumentOutOfRangeException(nameof(state)), }; }