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 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) if len(sourceRefResult.Warnings) != 0 { t.Fatalf("warnings = %#v, want none", sourceRefResult.Warnings) } } 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: 1, EndUnitID: 2}, want: "does not match", }, { name: "unknown start unit", ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 999, EndUnitID: 2}, want: "start_unit_id", }, { name: "unknown end unit", ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 999}, want: "end_unit_id", }, { name: "reversed unit range", ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 1}, 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 TestSourceRefValidatorWarnsWhenSpellNameIsNotInCitedSource(t *testing.T) { candidate := validSpellCandidate(31) payload := validSpellPayload() payload.Spell = "Shield" candidate.Payload = mustSpellPayload(t, payload) 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, 31, true, validate.ReasonApproved) if len(result.Warnings) != 1 { t.Fatalf("warnings = %#v, want one relatedness warning", result.Warnings) } warning := result.Warnings[0] if warning.ReasonCode != reasonSpellNotNearSource || !strings.Contains(warning.Message, "Shield") { t.Fatalf("warning = %#v, want spell relatedness warning", warning) } } 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: 1, EndUnitID: 2}, }, } } 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]) } } }