diff --git a/internal/modules/extract/dnd/spells/extractor.go b/internal/modules/extract/dnd/spells/extractor.go index f49eeb9..ea0f766 100644 --- a/internal/modules/extract/dnd/spells/extractor.go +++ b/internal/modules/extract/dnd/spells/extractor.go @@ -47,7 +47,10 @@ func (e *Extractor) SchemaVersion() string { } func (e *Extractor) Validators() []contracts.Validator { - return nil + return []contracts.Validator{ + ShapeValidator{}, + SourceRefValidator{}, + } } func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { diff --git a/internal/modules/extract/dnd/spells/validator.go b/internal/modules/extract/dnd/spells/validator.go new file mode 100644 index 0000000..4c760e6 --- /dev/null +++ b/internal/modules/extract/dnd/spells/validator.go @@ -0,0 +1,104 @@ +package spells + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "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" +) + +const ( + shapeValidatorName = "dnd/spells/shape" + sourceRefValidatorName = "dnd/spells/source_refs" + + reasonInvalidPayload = "invalid_payload" + reasonMissingRequiredField = "missing_required_field" + reasonMissingSourceRef = "missing_source_ref" + reasonInvalidSourceRef = "invalid_source_ref" +) + +var _ contracts.Validator = ShapeValidator{} +var _ contracts.Validator = SourceRefValidator{} + +type ShapeValidator struct{} + +func (validator ShapeValidator) Name() string { + return shapeValidatorName +} + +func (validator ShapeValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + decisions := make([]contracts.ValidationDecision, 0, len(req.Candidates)) + for _, candidate := range req.Candidates { + decisions = append(decisions, validateShape(candidate)) + } + return contracts.ValidationResult{ + ValidatorName: validator.Name(), + Decisions: decisions, + }, nil +} + +type SourceRefValidator struct{} + +func (validator SourceRefValidator) Name() string { + return sourceRefValidatorName +} + +func (validator SourceRefValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + if req.Source == nil { + return contracts.ValidationResult{}, fmt.Errorf("dnd spells source refs validator: source must not be nil") + } + + decisions := make([]contracts.ValidationDecision, 0, len(req.Candidates)) + for _, candidate := range req.Candidates { + decisions = append(decisions, validateSourceRefs(req.Source, candidate)) + } + return contracts.ValidationResult{ + ValidatorName: validator.Name(), + Decisions: decisions, + }, nil +} + +func validateShape(candidate artifacts.ArtifactCandidate) contracts.ValidationDecision { + var payload SpellCast + if err := json.Unmarshal(candidate.Payload, &payload); err != nil { + return validate.Rejected(candidate.Index, reasonInvalidPayload, fmt.Sprintf("invalid spell cast payload: %v", err)) + } + for _, field := range requiredSpellCastFields(payload) { + if strings.TrimSpace(field.value) == "" { + return validate.Rejected(candidate.Index, reasonMissingRequiredField, fmt.Sprintf("missing required field %q", field.name)) + } + } + return validate.Approved(candidate.Index) +} + +func validateSourceRefs(doc *source.SourceDocument, candidate artifacts.ArtifactCandidate) contracts.ValidationDecision { + if len(candidate.SourceRefs) == 0 { + return validate.Rejected(candidate.Index, reasonMissingSourceRef, "spell cast candidate must include at least one source ref") + } + for _, ref := range candidate.SourceRefs { + if err := source.ValidateRef(doc, ref); err != nil { + return validate.Rejected(candidate.Index, reasonInvalidSourceRef, err.Error()) + } + } + return validate.Approved(candidate.Index) +} + +func requiredSpellCastFields(payload SpellCast) []struct { + name string + value string +} { + return []struct { + name string + value string + }{ + {name: "caster", value: payload.Caster}, + {name: "spell", value: payload.Spell}, + {name: "effect", value: payload.Effect}, + {name: "narrative_description", value: payload.NarrativeDescription}, + } +} diff --git a/internal/modules/extract/dnd/spells/validator_test.go b/internal/modules/extract/dnd/spells/validator_test.go new file mode 100644 index 0000000..f5b7160 --- /dev/null +++ b/internal/modules/extract/dnd/spells/validator_test.go @@ -0,0 +1,271 @@ +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]) + } + } +}