Enforce spell catalog validation in the D&D pipeline

This commit is contained in:
2026-07-20 19:30:17 +00:00
parent 4ff2c7795f
commit f08b407b72
8 changed files with 488 additions and 2 deletions

View File

@@ -296,6 +296,7 @@ production validators do not call the LLM and must not set `llm_profile`.
| `generic/valid_json` | deterministic | Rejects payloads that are not syntactically valid JSON. | | `generic/valid_json` | deterministic | Rejects payloads that are not syntactically valid JSON. |
| `generic/valid_json_schema` | deterministic | Rejects invalid JSON or JSON that does not conform to the module response schema. | | `generic/valid_json_schema` | deterministic | Rejects invalid JSON or JSON that does not conform to the module response schema. |
| `extract/dnd/spells/shape` | deterministic | Rejects malformed D&D spell-list artifacts. | | `extract/dnd/spells/shape` | deterministic | Rejects malformed D&D spell-list artifacts. |
| `extract/dnd/spells/catalog` | deterministic | Rejects spell-list artifacts containing names outside the effective SRD and overlay catalog. |
| `extract/dnd/spells/source_refs` | deterministic | Rejects missing or invalid D&D spell source references. | | `extract/dnd/spells/source_refs` | deterministic | Rejects missing or invalid D&D spell source references. |
| `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. | | `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. |
@@ -306,6 +307,7 @@ validators:
- generic/valid_json - generic/valid_json
- generic/valid_json_schema - generic/valid_json_schema
- extract/dnd/spells/shape - extract/dnd/spells/shape
- extract/dnd/spells/catalog
- extract/dnd/spells/source_refs - extract/dnd/spells/source_refs
- extract/dnd/spells/source_relatedness - extract/dnd/spells/source_relatedness
``` ```

View File

@@ -211,8 +211,12 @@ codec bytes according to its target context. Neither validator calls the LLM.
## D&D Spell Validators ## D&D Spell Validators
All three validators receive `dnd.SpellList` directly. The shape validator All four validators receive `dnd.SpellList` directly. The shape validator
rejects missing or empty spell fields and empty reference lists. The rejects missing or empty spell fields and empty reference lists. The catalog
validator defers when shape is invalid, then checks every non-empty spell name
against the immutable effective SRD and overlay catalog. It accepts normalized
canonical names and aliases without rewriting the artifact; unknown names
reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator applies generic source-reference validation to every source-reference validator applies generic source-reference validation to every
cited range. The relatedness validator warns when a case-insensitive spell name cited range. The relatedness validator warns when a case-insensitive spell name
is absent from all cited source text. is absent from all cited source text.

View File

@@ -38,6 +38,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop"}) assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop"})
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"}) assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{ assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/spells/catalog",
"extract/dnd/spells/shape", "extract/dnd/spells/shape",
"extract/dnd/spells/source_refs", "extract/dnd/spells/source_refs",
"extract/dnd/spells/source_relatedness", "extract/dnd/spells/source_relatedness",
@@ -54,6 +55,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/shape"), pipeline.Binding("extract/dnd/spells/shape"),
pipeline.Binding("extract/dnd/spells/catalog"),
pipeline.Binding("extract/dnd/spells/source_refs"), pipeline.Binding("extract/dnd/spells/source_refs"),
pipeline.Binding("extract/dnd/spells/source_relatedness"), pipeline.Binding("extract/dnd/spells/source_relatedness"),
} }
@@ -124,6 +126,29 @@ func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
} }
} }
func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) {
components := productionTestComponents(t)
configPath := repositoryPath("examples", "dnd-spells-production.config.yml")
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve production spell configuration: %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: configPath,
WorkingDir: filepath.Dir(configPath),
})
if err != nil {
t.Fatalf("materialize production spell references: %v", err)
}
items := materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items
if len(items) != 1 || items[0].MediaType != "application/json" || len(items[0].Content) == 0 {
t.Fatalf("materialized spell catalog items = %#v, want one JSON item", items)
}
if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("prepare production spell pipeline from materialized catalog: %v", err)
}
}
func TestProductionLLMClientFactoriesBuildOfflineRuntime(t *testing.T) { func TestProductionLLMClientFactoriesBuildOfflineRuntime(t *testing.T) {
components := productionTestComponents(t) components := productionTestComponents(t)
factories := []struct { factories := []struct {

View File

@@ -0,0 +1,161 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
)
func TestProductionSpellCatalogValidationRetries(t *testing.T) {
const retries = 2
tests := []struct {
name string
responses []string
wantCalls int
wantRejected bool
wantSpell string
wantWarningCode string
}{
{
name: "unknown spell remains rejected after exhaustion",
responses: []string{
productionSpellResponse("Unknown Spell"),
productionSpellResponse("Unknown Spell"),
productionSpellResponse("Unknown Spell"),
},
wantCalls: retries + 1,
wantRejected: true,
},
{
name: "overlay spell becomes valid on retry",
responses: []string{
productionSpellResponse("Unknown Spell"),
productionSpellResponse("Aegis of Emberfall"),
},
wantCalls: 2,
wantSpell: "Aegis of Emberfall",
wantWarningCode: "spell_not_near_source",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
components := productionTestComponents(t)
configPath := repositoryPath("examples", "dnd-spells-production.config.yml")
cfg := loadMaintainedExample(t, configPath)
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("resolve production configuration: %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: configPath,
WorkingDir: repositoryPath("examples"),
})
if err != nil {
t.Fatalf("materialize production references: %v", err)
}
materialized.ArtifactLanes[0].Extract.Retries = retries
llmClient := &catalogRetryLLMClient{responses: tt.responses}
prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
t.Fatalf("prepare production pipeline: %v", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if calls := llmClient.CallCount(); calls > retries+1 || calls != tt.wantCalls {
t.Fatalf("LLM calls = %d, want %d and no more than %d", calls, tt.wantCalls, retries+1)
}
if tt.wantRejected {
if len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
t.Fatalf("rejected = %#v normalized = %#v, want one nonfatal rejection and no merge output", output.Rejected, output.NormalizeOutputs)
}
rejection := output.Rejected[0]
if rejection.ReasonCode != "unknown_spell" || rejection.AttemptCount != retries+1 {
t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection)
}
if len(output.Warnings) != 0 {
t.Fatalf("warnings = %#v, want no warnings from rejected attempts", output.Warnings)
}
return
}
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
t.Fatalf("rejected = %#v normalized = %#v, want only accepted output", output.Rejected, output.NormalizeOutputs)
}
var value dnd.SpellList
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &value); err != nil {
t.Fatalf("decode normalized spell list: %v", err)
}
if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell {
t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value)
}
if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != tt.wantWarningCode {
t.Fatalf("warnings = %#v, want only accepted-attempt warning", output.Warnings)
}
})
}
}
type catalogRetryLLMClient struct {
mu sync.Mutex
responses []string
calls int
}
func (client *catalogRetryLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if req.PromptID != spells.PromptID {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
}
client.mu.Lock()
index := client.calls
client.calls++
client.mu.Unlock()
if index >= len(client.responses) {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake response %d", index)
}
content := []byte(client.responses[index])
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
}
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
}
func (client *catalogRetryLLMClient) CallCount() int {
client.mu.Lock()
defer client.mu.Unlock()
return client.calls
}
func productionSpellResponse(name string) string {
content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria",
Spell: name,
Effect: "The spell takes effect.",
NarrativeDescription: "Aria casts the spell.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
}}})
if err != nil {
panic(err)
}
return string(content)
}

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs" spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness" spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
@@ -39,6 +40,7 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
}}, }},
{name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, {name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }},
{name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }}, {name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }},
{name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }},
{name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }}, {name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }},
{name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }}, {name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }},
{name: "spell-list always accept validator", register: func() error { {name: "spell-list always accept validator", register: func() error {
@@ -62,6 +64,7 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key), pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellshape.Key), pipeline.Binding(spellshape.Key),
pipeline.Binding(spellcatalog.Key),
pipeline.Binding(spellsourcerefs.Key), pipeline.Binding(spellsourcerefs.Key),
pipeline.Binding(spellrelatedness.Key), pipeline.Binding(spellrelatedness.Key),
}, },

View File

@@ -24,6 +24,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"}) assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind}) assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind})
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/spells/catalog",
"extract/dnd/spells/shape", "extract/dnd/spells/shape",
"extract/dnd/spells/source_refs", "extract/dnd/spells/source_refs",
"extract/dnd/spells/source_relatedness", "extract/dnd/spells/source_relatedness",
@@ -34,6 +35,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/shape"), pipeline.Binding("extract/dnd/spells/shape"),
pipeline.Binding("extract/dnd/spells/catalog"),
pipeline.Binding("extract/dnd/spells/source_refs"), pipeline.Binding("extract/dnd/spells/source_refs"),
pipeline.Binding("extract/dnd/spells/source_relatedness"), pipeline.Binding("extract/dnd/spells/source_relatedness"),
} }

View 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 }

View 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,
}},
},
}}
}