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