Files
notarius/internal/modules/extract/dnd/spells/validator_test.go

272 lines
8.9 KiB
Go

package spells
import (
"context"
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/validate"
)
func TestExtractorValidatorsReturnsExpectedChain(t *testing.T) {
validators := New().Validators()
if len(validators) != 2 {
t.Fatalf("len(Validators()) = %d, want 2", len(validators))
}
if validators[0].Name() != shapeValidatorName {
t.Fatalf("Validators()[0].Name() = %q, want %q", validators[0].Name(), shapeValidatorName)
}
if validators[1].Name() != sourceRefValidatorName {
t.Fatalf("Validators()[1].Name() = %q, want %q", validators[1].Name(), sourceRefValidatorName)
}
validators[0] = nil
again := New().Validators()
if len(again) != 2 || again[0] == nil || again[0].Name() != shapeValidatorName {
t.Fatalf("Validators() after caller mutation = %#v, want fresh validators", again)
}
}
func TestValidatorsApproveValidCandidate(t *testing.T) {
candidate := validSpellCandidate(7)
shapeResult, err := ShapeValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Candidates: []artifacts.ArtifactCandidate{candidate},
})
if err != nil {
t.Fatalf("ShapeValidator.Validate() error = %v, want nil", err)
}
assertSingleDecision(t, shapeResult, shapeValidatorName, 7, true, validate.ReasonApproved)
sourceRefResult, err := SourceRefValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Source: promptSourceDocument(),
Candidates: []artifacts.ArtifactCandidate{candidate},
})
if err != nil {
t.Fatalf("SourceRefValidator.Validate() error = %v, want nil", err)
}
assertSingleDecision(t, sourceRefResult, sourceRefValidatorName, 7, true, validate.ReasonApproved)
}
func TestShapeValidatorRejectsMalformedPayload(t *testing.T) {
candidate := validSpellCandidate(3)
candidate.Payload = json.RawMessage(`{"caster":`)
result, err := ShapeValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Candidates: []artifacts.ArtifactCandidate{candidate},
})
if err != nil {
t.Fatalf("ShapeValidator.Validate() error = %v, want nil", err)
}
assertSingleDecision(t, result, shapeValidatorName, 3, false, reasonInvalidPayload)
}
func TestShapeValidatorRejectsBlankRequiredFields(t *testing.T) {
tests := []struct {
name string
mutate func(*SpellCast)
}{
{name: "caster", mutate: func(payload *SpellCast) { payload.Caster = " \t" }},
{name: "spell", mutate: func(payload *SpellCast) { payload.Spell = "" }},
{name: "effect", mutate: func(payload *SpellCast) { payload.Effect = "\n" }},
{name: "narrative description", mutate: func(payload *SpellCast) { payload.NarrativeDescription = " " }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := validSpellPayload()
tt.mutate(&payload)
candidate := validSpellCandidate(5)
candidate.Payload = mustSpellPayload(t, payload)
result, err := ShapeValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Candidates: []artifacts.ArtifactCandidate{candidate},
})
if err != nil {
t.Fatalf("ShapeValidator.Validate() error = %v, want nil", err)
}
assertSingleDecision(t, result, shapeValidatorName, 5, false, reasonMissingRequiredField)
})
}
}
func TestShapeValidatorDoesNotRequireSourceDocument(t *testing.T) {
result, err := ShapeValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Candidates: []artifacts.ArtifactCandidate{validSpellCandidate(11)},
})
if err != nil {
t.Fatalf("ShapeValidator.Validate() error = %v, want nil", err)
}
assertSingleDecision(t, result, shapeValidatorName, 11, true, validate.ReasonApproved)
}
func TestSourceRefValidatorRejectsMissingRefs(t *testing.T) {
candidate := validSpellCandidate(13)
candidate.SourceRefs = nil
result, err := SourceRefValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Source: promptSourceDocument(),
Candidates: []artifacts.ArtifactCandidate{candidate},
})
if err != nil {
t.Fatalf("SourceRefValidator.Validate() error = %v, want nil", err)
}
assertSingleDecision(t, result, sourceRefValidatorName, 13, false, reasonMissingSourceRef)
}
func TestSourceRefValidatorRejectsInvalidRefs(t *testing.T) {
tests := []struct {
name string
ref source.SourceRef
want string
}{
{
name: "unknown source id",
ref: source.SourceRef{SourceID: "session-beta", StartUnitID: "seg-001", EndUnitID: "seg-002"},
want: "does not match",
},
{
name: "unknown start unit",
ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: "seg-999", EndUnitID: "seg-002"},
want: "start_unit_id",
},
{
name: "unknown end unit",
ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: "seg-001", EndUnitID: "seg-999"},
want: "end_unit_id",
},
{
name: "reversed unit range",
ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: "seg-002", EndUnitID: "seg-001"},
want: "appears after",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
candidate := validSpellCandidate(17)
candidate.SourceRefs = []source.SourceRef{tt.ref}
result, err := SourceRefValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Source: promptSourceDocument(),
Candidates: []artifacts.ArtifactCandidate{candidate},
})
if err != nil {
t.Fatalf("SourceRefValidator.Validate() error = %v, want nil", err)
}
assertSingleDecision(t, result, sourceRefValidatorName, 17, false, reasonInvalidSourceRef)
if !strings.Contains(result.Decisions[0].Message, tt.want) {
t.Fatalf("Message = %q, want substring %q", result.Decisions[0].Message, tt.want)
}
})
}
}
func TestSourceRefValidatorRequiresSourceDocument(t *testing.T) {
_, err := SourceRefValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Candidates: []artifacts.ArtifactCandidate{validSpellCandidate(19)},
})
if err == nil {
t.Fatal("SourceRefValidator.Validate() error = nil, want source error")
}
if !strings.Contains(err.Error(), "dnd spells") || !strings.Contains(err.Error(), "source") {
t.Fatalf("SourceRefValidator.Validate() error = %q, want source context", err.Error())
}
}
func TestValidatorsPreserveCandidateIndexes(t *testing.T) {
candidates := []artifacts.ArtifactCandidate{
validSpellCandidate(23),
validSpellCandidate(29),
}
shapeResult, err := ShapeValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Candidates: candidates,
})
if err != nil {
t.Fatalf("ShapeValidator.Validate() error = %v, want nil", err)
}
assertDecisionIndexes(t, shapeResult.Decisions, []int{23, 29})
sourceRefResult, err := SourceRefValidator{}.Validate(context.Background(), contracts.ValidationRequest{
Source: promptSourceDocument(),
Candidates: candidates,
})
if err != nil {
t.Fatalf("SourceRefValidator.Validate() error = %v, want nil", err)
}
assertDecisionIndexes(t, sourceRefResult.Decisions, []int{23, 29})
}
func validSpellCandidate(index int) artifacts.ArtifactCandidate {
return artifacts.ArtifactCandidate{
Index: index,
Payload: spellPayload(validSpellPayload()),
SourceRefs: []source.SourceRef{
{SourceID: "session-alpha", StartUnitID: "seg-001", EndUnitID: "seg-002"},
},
}
}
func validSpellPayload() SpellCast {
return SpellCast{
Caster: "Aria",
Spell: "Cure Wounds",
Effect: "Heals an injured ally.",
NarrativeDescription: "Aria restores the fighter after the fight.",
}
}
func mustSpellPayload(t *testing.T, payload SpellCast) json.RawMessage {
t.Helper()
return spellPayload(payload)
}
func spellPayload(payload SpellCast) json.RawMessage {
encoded, err := json.Marshal(payload)
if err != nil {
panic(err)
}
return encoded
}
func assertSingleDecision(t *testing.T, result contracts.ValidationResult, wantName string, wantIndex int, wantApproved bool, wantReason string) {
t.Helper()
if result.ValidatorName != wantName {
t.Fatalf("ValidatorName = %q, want %q", result.ValidatorName, wantName)
}
if len(result.Decisions) != 1 {
t.Fatalf("len(Decisions) = %d, want 1", len(result.Decisions))
}
decision := result.Decisions[0]
if decision.CandidateIndex != wantIndex {
t.Fatalf("CandidateIndex = %d, want %d", decision.CandidateIndex, wantIndex)
}
if decision.Approved != wantApproved {
t.Fatalf("Approved = %t, want %t", decision.Approved, wantApproved)
}
if decision.ReasonCode != wantReason {
t.Fatalf("ReasonCode = %q, want %q", decision.ReasonCode, wantReason)
}
}
func assertDecisionIndexes(t *testing.T, decisions []contracts.ValidationDecision, want []int) {
t.Helper()
if len(decisions) != len(want) {
t.Fatalf("len(Decisions) = %d, want %d", len(decisions), len(want))
}
for i := range want {
if decisions[i].CandidateIndex != want[i] {
t.Fatalf("Decisions[%d].CandidateIndex = %d, want %d", i, decisions[i].CandidateIndex, want[i])
}
}
}