Add D&D spells validators
This commit is contained in:
@@ -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) {
|
||||
|
||||
104
internal/modules/extract/dnd/spells/validator.go
Normal file
104
internal/modules/extract/dnd/spells/validator.go
Normal file
@@ -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},
|
||||
}
|
||||
}
|
||||
271
internal/modules/extract/dnd/spells/validator_test.go
Normal file
271
internal/modules/extract/dnd/spells/validator_test.go
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user