package integration_test import ( "context" "os" "strings" "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" 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/generic/merge/appendorder" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript" ) func TestDNDSpellCapabilityFailures(t *testing.T) { tests := []struct { name string mutate func(pipeline.ModuleSpec, pipeline.ModuleSpec) (pipeline.ModuleSpec, pipeline.ModuleSpec) wantModule string wantCap string }{ { name: "spell extractor requires transcript source", mutate: func(input, extractor pipeline.ModuleSpec) (pipeline.ModuleSpec, pipeline.ModuleSpec) { input.Provides = withoutCapability(input.Provides, "source.transcript") return input, extractor }, wantModule: spells.Key, wantCap: "source.transcript", }, { name: "append-order merger requires spell casts", mutate: func(input, extractor pipeline.ModuleSpec) (pipeline.ModuleSpec, pipeline.ModuleSpec) { extractor.Provides = withoutCapability(extractor.Provides, "dnd.spell_casts") return input, extractor }, wantModule: pipeline.DefaultMergeModule, wantCap: "dnd.spell_casts", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { inputSpec, extractorSpec := tt.mutate(transcript.ModuleSpec(), spells.ModuleSpec()) _, err := pipeline.ResolvePipeline(dndCapabilityProfile(), pipeline.ResolveOptions{}, dndCapabilityCatalog(t, inputSpec, extractorSpec)) if err == nil || !strings.Contains(err.Error(), "missing capability") || !strings.Contains(err.Error(), tt.wantCap) || !strings.Contains(err.Error(), tt.wantModule) { t.Fatalf("ResolvePipeline() error = %v, want %s missing %s capability", err, tt.wantModule, tt.wantCap) } }) } } func dndCapabilityProfile() pipeline.PipelineProfile { return pipeline.PipelineProfile{ ID: "dnd-capability", Input: pipeline.Binding(transcript.Key), Chunk: pipeline.Binding("fake/chunk"), Artifacts: map[string]pipeline.ArtifactLaneProfile{ "spells": {Extract: pipeline.Binding(spells.Key)}, }, } } func dndCapabilityCatalog(t *testing.T, inputSpec, extractorSpec pipeline.ModuleSpec) pipeline.ModuleCatalog { t.Helper() inputs := pipeline.NewInputAdapterRegistry() if err := inputs.RegisterWithSpec(inputSpec, func() (contracts.InputAdapter, error) { return transcript.New(), nil }); err != nil { t.Fatalf("register capability input: %v", err) } chunkers := pipeline.NewChunkerRegistry() if err := chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "fake/chunk", Stage: pipeline.StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Provides: []string{"chunks"}}, func() (contracts.Chunker, error) { return dndSpellsChunker{}, nil }); err != nil { t.Fatalf("register capability chunker: %v", err) } extractors := pipeline.NewExtractorRegistry() extractorSpec.ArtifactKind = dnd.SpellListKind if err := pipeline.RegisterExtractor[dnd.SpellList](extractors, extractorSpec, func() (contracts.Extractor[dnd.SpellList], error) { return configExtractor{key: extractorSpec.Key}, nil }); err != nil { t.Fatalf("register capability extractor: %v", err) } codecs := pipeline.NewArtifactCodecRegistry() evidence := pipeline.NewArtifactEvidenceRegistry() if err := pipeline.RegisterArtifactCodec(codecs, spellcodec.New()); err != nil { t.Fatalf("register capability codec: %v", err) } mergers := pipeline.NewMergerRegistry() if err := pipeline.RegisterMerger[dnd.SpellList](mergers, pipeline.ModuleSpec{ Key: pipeline.DefaultMergeModule, Stage: pipeline.StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: dnd.SpellListKind, Requires: []string{"dnd.spell_casts"}, }, func() (contracts.Merger[dnd.SpellList], error) { return appendorder.NewTyped(appendSpellLists) }); err != nil { t.Fatalf("register capability merger: %v", err) } normalizers := pipeline.NewNormalizerRegistry() if err := pipeline.RegisterNormalizer[dnd.SpellList](normalizers, pipeline.ModuleSpec{Key: pipeline.DefaultNormalizeModule, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: dnd.SpellListKind}, func() (contracts.Normalizer[dnd.SpellList], error) { return noop.NewTyped[dnd.SpellList](), nil }); err != nil { t.Fatalf("register capability normalizer: %v", err) } outputs := pipeline.NewOutputEncoderRegistry() if err := outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: pipeline.DefaultOutputModule, Stage: pipeline.StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.OutputEncoder, error) { return dndSpellsOutput{}, nil }); err != nil { t.Fatalf("register capability output: %v", err) } return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, ArtifactCodecs: codecs, ArtifactEvidence: evidence, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: outputs, } } func loadDNDSpellsPipelineConfig(t *testing.T) config.Config { t.Helper() data, err := os.ReadFile("testdata/pipeline.yml") if err != nil { t.Fatalf("ReadFile(pipeline.yml) error = %v, want nil", err) } fileCfg, err := config.ParseFileConfigYAML(data) if err != nil { t.Fatalf("ParseFileConfigYAML() error = %v, want nil", err) } cfg := config.Default() if err := cfg.ApplyFileConfig(fileCfg); err != nil { t.Fatalf("ApplyFileConfig() error = %v, want nil", err) } return cfg } type dndSpellsCatalogSpecs struct { input pipeline.ModuleSpec chunk pipeline.ModuleSpec extractor pipeline.ModuleSpec } func dndSpellsTestCatalog(t *testing.T, specs dndSpellsCatalogSpecs) pipeline.ModuleCatalog { t.Helper() inputs := pipeline.NewInputAdapterRegistry() chunkers := pipeline.NewChunkerRegistry() extractors := pipeline.NewExtractorRegistry() codecs := pipeline.NewArtifactCodecRegistry() evidence := pipeline.NewArtifactEvidenceRegistry() mergers := pipeline.NewMergerRegistry() normalizers := pipeline.NewNormalizerRegistry() outputs := pipeline.NewOutputEncoderRegistry() if specs.input.Key == "" { if err := transcript.Register(inputs); err != nil { t.Fatalf("register seriatim input: %v", err) } } else if err := inputs.RegisterWithSpec(specs.input, func() (contracts.InputAdapter, error) { return transcript.New(), nil }); err != nil { t.Fatalf("register seriatim input override: %v", err) } chunkSpec := specs.chunk if chunkSpec.Key == "" { chunkSpec = dndSpellsChunkerSpec() } if err := chunkers.RegisterWithSpec(chunkSpec, func() (contracts.Chunker, error) { return dndSpellsChunker{}, nil }); err != nil { t.Fatalf("register chunker: %v", err) } codec := spellcodec.New() if err := pipeline.RegisterArtifactCodec(codecs, codec); err != nil { t.Fatalf("register dnd spells codec: %v", err) } if specs.extractor.Key == "" { if err := spells.Register(extractors); err != nil { t.Fatalf("register dnd spells extractor: %v", err) } } else { specs.extractor.ArtifactKind = dnd.SpellListKind if err := pipeline.RegisterExtractor[dnd.SpellList](extractors, specs.extractor, func() (contracts.Extractor[dnd.SpellList], error) { return configExtractor{key: specs.extractor.Key}, nil }); err != nil { t.Fatalf("register dnd spells extractor override: %v", err) } } if err := pipeline.RegisterMerger[dnd.SpellList](mergers, pipeline.ModuleSpec{ Key: pipeline.DefaultMergeModule, Stage: pipeline.StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: dnd.SpellListKind, Requires: []string{"dnd.spell_casts"}, }, func() (contracts.Merger[dnd.SpellList], error) { return appendorder.NewTyped(appendSpellLists) }); err != nil { t.Fatalf("register merger: %v", err) } if err := pipeline.RegisterNormalizer[dnd.SpellList](normalizers, pipeline.ModuleSpec{ Key: pipeline.DefaultNormalizeModule, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: dnd.SpellListKind, }, func() (contracts.Normalizer[dnd.SpellList], error) { return noop.NewTyped[dnd.SpellList](), nil }); err != nil { t.Fatalf("register normalizer: %v", err) } if err := outputs.RegisterWithSpec(pipeline.ModuleSpec{ Key: pipeline.DefaultOutputModule, Stage: pipeline.StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic, }, func() (contracts.OutputEncoder, error) { return dndSpellsOutput{}, nil }); err != nil { t.Fatalf("register output: %v", err) } return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, ArtifactCodecs: codecs, ArtifactEvidence: evidence, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: outputs, } } type configExtractor struct{ key string } func (extractor configExtractor) Key() string { return extractor.key } func (configExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (configExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SpellList], error) { return contracts.TypedExtractionResult[dnd.SpellList]{}, nil } func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) { combined := dnd.SpellList{SpellCasts: []dnd.SpellCast{}} for _, value := range values { combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...) } return combined, nil } func dndSpellsChunkerSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: "fake/chunk", Stage: pipeline.StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"source.transcript"}, Provides: []string{"chunks"}, } } type dndSpellsChunker struct{} func (dndSpellsChunker) Key() string { return "fake/chunk" } func (dndSpellsChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (dndSpellsChunker) Plan(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkPlanResult, error) { return contracts.ChunkPlanResult{ Plan: source.ChunkPlan{SourceDigest: req.Source.Digest, Ranges: []source.ChunkRange{{StartUnitID: req.Source.Units[0].ID, EndUnitID: req.Source.Units[len(req.Source.Units)-1].ID}}}, }, nil } type dndSpellsOutput struct{} func (dndSpellsOutput) Key() string { return pipeline.DefaultOutputModule } func (dndSpellsOutput) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { return contracts.OutputResult{ Files: []contracts.OutputFile{ {Name: "output.json", ContentType: "application/json", Bytes: []byte(`{"encoded":true}`)}, }, }, nil } func withoutCapability(capabilities []string, capability string) []string { filtered := make([]string, 0, len(capabilities)) for _, candidate := range capabilities { if candidate != capability { filtered = append(filtered, candidate) } } return filtered } var ( _ contracts.Chunker = dndSpellsChunker{} _ contracts.OutputEncoder = dndSpellsOutput{} )