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) configPath := repositoryPath("examples", "dnd-combat-turns.config.yml") cfg := loadMaintainedExample(t, configPath) 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, "npcs") || !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") { t.Fatalf("combat reference slots = %#v / %#v, want stage-local NPC slots", extractSpec.ReferenceSlots, normalizeSpec.ReferenceSlots) } wantExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), } wantNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("normalize/dnd/combat-turns/invariants"), pipeline.Binding("extract/dnd/combat-turns/source_refs"), 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: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI}, {Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.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) != 1 || len(boundLane.NormalizeReferences.Bindings) != 1 || boundLane.ExtractReferences.Bindings[0].SlotName != "npcs" || boundLane.NormalizeReferences.Bindings[0].SlotName != "npcs" { t.Fatalf("bound combat references = %#v / %#v, want one independent NPC binding per stage", boundLane.ExtractReferences, boundLane.NormalizeReferences) } } func TestProductionCombatConfigurationRejectsLooseOptionsAndLaneValidators(t *testing.T) { components := productionTestComponents(t) configPath := repositoryPath("examples", "dnd-combat-turns.config.yml") resolve := func(mutate func(*pipeline.PipelineProfile)) error { cfg := loadMaintainedExample(t, configPath) 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 := loadMaintainedExample(t, repositoryPath("examples", "dnd-combat-turns.config.yml")) 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 hasReferenceSlot(slots []contracts.ReferenceSlot, name string) bool { for _, slot := range slots { if slot.Name == name { return true } } return false }