Enforce spell catalog validation in the D&D pipeline
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
161
internal/cli/spell_catalog_retry_contract_test.go
Normal file
161
internal/cli/spell_catalog_retry_contract_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
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