209 lines
11 KiB
Go
209 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
|
)
|
|
|
|
func TestProductionCombatConfigurationResolvesTypedLane(t *testing.T) {
|
|
components := productionTestComponents(t)
|
|
cfg := productionCombatContractConfig()
|
|
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
|
}
|
|
if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule {
|
|
t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule)
|
|
}
|
|
if len(effective.ResolvedPipeline.Steps[0].ArtifactLanes) != 1 {
|
|
t.Fatalf("artifact lanes = %#v, want one combat lane", effective.ResolvedPipeline.Steps[0].ArtifactLanes)
|
|
}
|
|
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
|
|
if lane.ID != "combat" || lane.ArtifactKind != dnd.CombatTurnListKind || lane.Extract.Module != combatextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != combatnormalize.Key {
|
|
t.Fatalf("resolved combat lane = %#v, want typed production composition", lane)
|
|
}
|
|
|
|
catalog := catalogFromRegistries(components.registries)
|
|
extractSpec, ok := catalog.Extractors.Spec(combatextract.Key)
|
|
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.combat_turns"}) {
|
|
t.Fatalf("combat extractor spec = %#v, want source and artifact capabilities", extractSpec)
|
|
}
|
|
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(combatnormalize.Key, dnd.CombatTurnListKind)
|
|
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
|
|
t.Fatalf("combat normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
|
|
}
|
|
mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.CombatTurnListKind)
|
|
if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) {
|
|
t.Fatalf("combat merger spec = %#v, want merged capability", mergeSpec)
|
|
}
|
|
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
|
|
if !ok || codecSpec.Schema.ID != "notarius.dnd.combat_turns" || codecSpec.Schema.Version != "v1" {
|
|
t.Fatalf("combat codec spec = %#v, want compatible durable schema", codecSpec)
|
|
}
|
|
if !hasReferenceSlot(extractSpec.ReferenceSlots, "npc_registry") || !hasReferenceSlot(extractSpec.ReferenceSlots, "scene_descriptions") || !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npc_registry") {
|
|
t.Fatalf("combat reference slots = %#v / %#v, want extraction scene and NPC slots plus normalization NPC slot", extractSpec.ReferenceSlots, normalizeSpec.ReferenceSlots)
|
|
}
|
|
sceneSlot := referenceSlot(extractSpec.ReferenceSlots, "scene_descriptions")
|
|
if !sceneSlot.Required || !reflect.DeepEqual(sceneSlot.AcceptedMediaTypes, []string{"application/json"}) || !reflect.DeepEqual(sceneSlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) || sceneSlot.MaxBytes != 1048576 {
|
|
t.Fatalf("scene description slot = %#v, want required approved scene artifact", sceneSlot)
|
|
}
|
|
|
|
wantExtractChain := []pipeline.ModuleBinding{
|
|
pipeline.Binding("generic/valid_json"),
|
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
|
pipeline.Binding("generic/valid_json_schema"),
|
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
|
}
|
|
wantNormalizeChain := []pipeline.ModuleBinding{
|
|
pipeline.Binding("generic/valid_json"),
|
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
|
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
|
pipeline.Binding("generic/valid_json_schema"),
|
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
|
}
|
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
|
|
t.Fatalf("combat extract chain = %#v, want %#v", got, wantExtractChain)
|
|
}
|
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) {
|
|
t.Fatalf("combat normalize chain = %#v, want %#v", got, wantNormalizeChain)
|
|
}
|
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 {
|
|
t.Fatalf("combat merge chain = %#v, want empty", got)
|
|
}
|
|
|
|
bound, err := cfg.Resolve(config.ResolveInput{
|
|
PipelineID: "dnd-combat",
|
|
Catalog: catalog,
|
|
ReferenceOverrides: []pipeline.ReferenceBinding{
|
|
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npc_registry", Source: "npc-run/lanes/npc_registry.json", BindingSource: contracts.ReferenceBindingSourceCLI},
|
|
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npc_registry", Source: "npc-run/lanes/npc_registry.json", BindingSource: contracts.ReferenceBindingSourceCLI},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve(bound references) error = %v, want nil", err)
|
|
}
|
|
boundLane := bound.ResolvedPipeline.Steps[0].ArtifactLanes[0]
|
|
if len(boundLane.ExtractReferences.Bindings) != 2 || len(boundLane.NormalizeReferences.Bindings) != 1 || !hasReferenceBinding(boundLane.ExtractReferences.Bindings, "npc_registry") || !hasReferenceBinding(boundLane.ExtractReferences.Bindings, "scene_descriptions") || !hasReferenceBinding(boundLane.NormalizeReferences.Bindings, "npc_registry") {
|
|
t.Fatalf("bound combat references = %#v / %#v, want extraction scene and NPC bindings plus normalization NPC binding", boundLane.ExtractReferences, boundLane.NormalizeReferences)
|
|
}
|
|
}
|
|
|
|
func TestProductionCombatConfigurationRequiresSceneDescriptions(t *testing.T) {
|
|
components := productionTestComponents(t)
|
|
cfg := productionCombatContractConfig()
|
|
profile := cfg.Pipelines["dnd-combat"]
|
|
profile.References = nil
|
|
cfg.Pipelines["dnd-combat"] = profile
|
|
if _, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)}); err == nil || !strings.Contains(err.Error(), "scene_descriptions") || !strings.Contains(err.Error(), "required") {
|
|
t.Fatalf("Resolve() error = %v, want required scene reference failure", err)
|
|
}
|
|
}
|
|
|
|
func TestProductionCombatConfigurationRejectsLooseOptionsAndLaneValidators(t *testing.T) {
|
|
components := productionTestComponents(t)
|
|
resolve := func(mutate func(*pipeline.PipelineProfile)) error {
|
|
cfg := productionCombatContractConfig()
|
|
profile := cfg.Pipelines["dnd-combat"]
|
|
mutate(&profile)
|
|
cfg.Pipelines["dnd-combat"] = profile
|
|
_, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
|
return err
|
|
}
|
|
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
|
lane := profile.Artifacts["combat"]
|
|
lane.Extract.Options = map[string]any{"unexpected": true}
|
|
profile.Artifacts["combat"] = lane
|
|
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
|
t.Fatalf("unknown extractor option error = %v, want strict option rejection", err)
|
|
}
|
|
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
|
lane := profile.Artifacts["combat"]
|
|
lane.Normalize.Options = map[string]any{"unexpected": true}
|
|
profile.Artifacts["combat"] = lane
|
|
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
|
t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err)
|
|
}
|
|
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
|
lane := profile.Artifacts["combat"]
|
|
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}
|
|
profile.Artifacts["combat"] = lane
|
|
}); err == nil || !strings.Contains(err.Error(), "artifact lane level") {
|
|
t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestProductionCombatConfigurationResolvesTypedUnconditionalValidators(t *testing.T) {
|
|
components := productionTestComponents(t)
|
|
cfg := productionCombatContractConfig()
|
|
profile := cfg.Pipelines["dnd-combat"]
|
|
lane := profile.Artifacts["combat"]
|
|
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}}
|
|
lane.Normalize.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}}
|
|
profile.Artifacts["combat"] = lane
|
|
cfg.Pipelines["dnd-combat"] = profile
|
|
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v, want typed unconditional validators to resolve", err)
|
|
}
|
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}) {
|
|
t.Fatalf("extract override chain = %#v, want typed always-accept", got)
|
|
}
|
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}) {
|
|
t.Fatalf("normalize override chain = %#v, want typed always-reject", got)
|
|
}
|
|
}
|
|
|
|
func productionCombatContractConfig() config.Config {
|
|
cfg := config.Default()
|
|
cfg.Pipelines["dnd-combat"] = pipeline.PipelineProfile{
|
|
ID: "dnd-combat",
|
|
Input: pipeline.Binding("seriatim"),
|
|
Chunk: pipeline.Binding(pipeline.DefaultChunkModule),
|
|
References: map[string]pipeline.ReferenceSource{"scene_descriptions": pipeline.ExternalReference("scenes.json")},
|
|
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
|
"combat": {
|
|
Extract: pipeline.ModuleBinding{Module: combatextract.Key, Retries: 2},
|
|
Normalize: pipeline.Binding(combatnormalize.Key),
|
|
},
|
|
},
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func hasReferenceSlot(slots []contracts.ReferenceSlot, name string) bool {
|
|
for _, slot := range slots {
|
|
if slot.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasReferenceBinding(bindings []pipeline.ReferenceBinding, name string) bool {
|
|
for _, binding := range bindings {
|
|
if binding.SlotName == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func referenceSlot(slots []contracts.ReferenceSlot, name string) contracts.ReferenceSlot {
|
|
for _, slot := range slots {
|
|
if slot.Name == name {
|
|
return slot
|
|
}
|
|
}
|
|
return contracts.ReferenceSlot{}
|
|
}
|