Enforce spell catalog validation in the D&D pipeline
This commit is contained in:
113
internal/modules/dnd/validate/spells/catalog/validator.go
Normal file
113
internal/modules/dnd/validate/spells/catalog/validator.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/spells/catalog"
|
||||
ReasonCode = "unknown_spell"
|
||||
maxIssues = 20
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
|
||||
type Validator struct {
|
||||
catalog spellcatalog.EffectiveCatalog
|
||||
}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
||||
|
||||
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
|
||||
if len(references) > 1 {
|
||||
return nil, fmt.Errorf("spell catalog validator accepts at most one reference set")
|
||||
}
|
||||
var referenceSet contracts.ReferenceSet
|
||||
if len(references) == 1 {
|
||||
referenceSet = references[0]
|
||||
}
|
||||
effective, err := spellcatalog.ResolveEffectiveCatalog(referenceSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Validator{catalog: effective}, nil
|
||||
}
|
||||
|
||||
func (v *Validator) Name() string { return Key }
|
||||
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||
if err := spellshape.Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
unknown := make([]unknownSpell, 0)
|
||||
for index, spell := range req.Value.SpellCasts {
|
||||
name := strings.TrimSpace(spell.Spell)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := v.catalog.Lookup(name); !ok {
|
||||
unknown = append(unknown, unknownSpell{index: index, name: name})
|
||||
}
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(unknown), nil
|
||||
}
|
||||
|
||||
type unknownSpell struct {
|
||||
index int
|
||||
name string
|
||||
}
|
||||
|
||||
func rejection(unknown []unknownSpell) contracts.ValidationResult {
|
||||
displayed := unknown
|
||||
if len(displayed) > maxIssues {
|
||||
displayed = displayed[:maxIssues]
|
||||
}
|
||||
issues := make([]string, len(displayed))
|
||||
for index, item := range displayed {
|
||||
issues[index] = fmt.Sprintf("spell_casts[%d].spell %q", item.index, item.name)
|
||||
}
|
||||
message := fmt.Sprintf("unknown spell names: %s", strings.Join(issues, ", "))
|
||||
if omitted := len(unknown) - len(displayed); omitted > 0 {
|
||||
message += fmt.Sprintf("; %d additional issue(s) omitted", omitted)
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.SpellListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.SpellList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options, request.References)
|
||||
})
|
||||
}
|
||||
|
||||
func DecodeOptions(options map[string]any) (Options, error) {
|
||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||
return Options{}, err
|
||||
}
|
||||
return Options{}, nil
|
||||
}
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
176
internal/modules/dnd/validate/spells/catalog/validator_test.go
Normal file
176
internal/modules/dnd/validate/spells/catalog/validator_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
spellreference "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||
)
|
||||
|
||||
func TestValidatorApprovesCanonicalNormalizedAndOverlayAliasNames(t *testing.T) {
|
||||
validator, err := New(Options{}, overlayReferences())
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v, want nil", err)
|
||||
}
|
||||
value := spellList(" cure wounds ", "Emberfall Aegis")
|
||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, want approved", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsMultipleUnknownCastsInStableOrder(t *testing.T) {
|
||||
value := spellList("Unknown First", "Cure Wounds", "Unknown Second")
|
||||
validator, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v, want nil", err)
|
||||
}
|
||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("Validate() = %#v, want unknown-spell rejection", result)
|
||||
}
|
||||
if want := `spell_casts[0].spell "Unknown First", spell_casts[2].spell "Unknown Second"`; !strings.Contains(result.Message, want) {
|
||||
t.Fatalf("message = %q, want %q", result.Message, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorBoundsUnknownCastMessage(t *testing.T) {
|
||||
value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 22)}
|
||||
for index := range value.SpellCasts {
|
||||
value.SpellCasts[index] = validCast(fmt.Sprintf("Unknown Spell %02d", index))
|
||||
}
|
||||
validator, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v, want nil", err)
|
||||
}
|
||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("Validate() = %#v, want unknown-spell rejection", result)
|
||||
}
|
||||
if got := strings.Count(result.Message, "spell_casts["); got != maxIssues {
|
||||
t.Fatalf("message includes %d issues, want %d: %q", got, maxIssues, result.Message)
|
||||
}
|
||||
if !strings.Contains(result.Message, "2 additional issue(s) omitted") || strings.Contains(result.Message, "Unknown Spell 21") {
|
||||
t.Fatalf("message = %q, want bounded diagnostics", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersInvalidShape(t *testing.T) {
|
||||
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Unknown Spell"}}}
|
||||
validator, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v, want nil", err)
|
||||
}
|
||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved || result.ReasonCode != "" || result.Message != "" {
|
||||
t.Fatalf("Validate() = %#v, want approval without catalog diagnostics", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDoesNotMutateArtifactOrCatalog(t *testing.T) {
|
||||
value := spellList("Cure Wounds")
|
||||
before := value
|
||||
validator, err := New(Options{}, overlayReferences())
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v, want nil", err)
|
||||
}
|
||||
names := validator.catalog.CanonicalNames()
|
||||
names[0] = "mutated"
|
||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved || !reflect.DeepEqual(value, before) {
|
||||
t.Fatalf("Validate() = %#v and value = %#v, want approval without mutation", result, value)
|
||||
}
|
||||
if _, ok := validator.catalog.Lookup("Aegis of Emberfall"); !ok {
|
||||
t.Fatal("catalog lost overlay canonical name after mutating returned names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorUsesStrictEmptyOptions(t *testing.T) {
|
||||
if _, err := DecodeOptions(nil); err != nil {
|
||||
t.Fatalf("DecodeOptions(nil) error = %v, want nil", err)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() error = nil, want unknown option error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorSpecAndRegister(t *testing.T) {
|
||||
spec := Spec()
|
||||
if spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v, want deterministic catalog validator", spec)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
registered, ok := registry.Spec(Key)
|
||||
if !ok || registered != spec {
|
||||
t.Fatalf("registry.Spec(%q) = %#v, present = %t; want %#v", Key, registered, ok, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func validationRequest(value dnd.SpellList) contracts.TypedValidationRequest[dnd.SpellList] {
|
||||
return contracts.TypedValidationRequest[dnd.SpellList]{Value: value}
|
||||
}
|
||||
|
||||
func spellList(names ...string) dnd.SpellList {
|
||||
value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, len(names))}
|
||||
for index, name := range names {
|
||||
value.SpellCasts[index] = validCast(name)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validCast(name string) dnd.SpellCast {
|
||||
return dnd.SpellCast{
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
Effect: "heals an ally",
|
||||
NarrativeDescription: "Aria restores Borin.",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}
|
||||
}
|
||||
|
||||
func overlayReferences() contracts.ReferenceSet {
|
||||
content := []byte(`{
|
||||
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
|
||||
"catalogs": [{
|
||||
"id": "campaign-spells",
|
||||
"ruleset": "dnd-5e-2014",
|
||||
"source": {"title": "Campaign spell names"},
|
||||
"spells": [{"name": "Aegis of Emberfall", "aliases": ["Emberfall Aegis"]}]
|
||||
}]
|
||||
}`)
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
spellreference.SpellCatalogReferenceSlot: {
|
||||
Slot: contracts.ReferenceSlot{Name: spellreference.SpellCatalogReferenceSlot},
|
||||
Items: []contracts.ReferenceItem{{
|
||||
SlotName: spellreference.SpellCatalogReferenceSlot,
|
||||
MediaType: "application/json",
|
||||
Content: content,
|
||||
}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
Reference in New Issue
Block a user