package cli import ( "context" "encoding/json" "fmt" "reflect" "sort" "strings" "sync" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "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" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" ) const assembledSpellExtractorKey = "test/dnd/spell-casts" func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) { registries, resolved, extractor := assembledSpellPipeline(t, assembledSpellPipelineOptions{}) prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{}) if err != nil { t.Fatalf("Prepare() error = %v, want nil", 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) } chunkIndexes := extractor.chunkIndexesSnapshot() sort.Ints(chunkIndexes) if !reflect.DeepEqual(chunkIndexes, []int{0, 1}) { t.Fatalf("extractor chunk indexes = %#v, want two chunk-boundary calls", chunkIndexes) } if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { t.Fatalf("run output = %#v, want approved normalized output without rejections", output) } if output.NormalizeOutputs[0].NormalizerKey != spellnormalize.Key { t.Fatalf("normalized output module = %q, want %q", output.NormalizeOutputs[0].NormalizerKey, spellnormalize.Key) } var normalized dnd.SpellList if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil { t.Fatalf("decode normalized output: %v", err) } if len(normalized.SpellCasts) != 2 { t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts) } first, distinct := normalized.SpellCasts[0], normalized.SpellCasts[1] if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" { t.Fatalf("retained cast = %#v, want canonical spell with first occurrence caster", first) } if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) { t.Fatalf("retained refs = %#v, want sorted complete evidence", first.SourceRefs) } if distinct.Spell != "Cure Wounds" || distinct.Caster != "aria" || !reflect.DeepEqual(distinct.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) { t.Fatalf("distinct cast = %#v, want separate evidence event", distinct) } wantWarningReasons := []string{ spellnormalize.ReasonCodeSpellNameCanonicalized, spellnormalize.ReasonCodeSourceReferencesNormalized, spellnormalize.ReasonCodeDuplicateSpellCastCollapsed, "spell_not_near_source", } gotWarningReasons := make([]string, len(output.Warnings)) for index, warning := range output.Warnings { gotWarningReasons[index] = warning.ReasonCode } if !reflect.DeepEqual(gotWarningReasons, wantWarningReasons) { t.Fatalf("warnings = %#v, want deterministic normalize and validation warnings", output.Warnings) } if output.Warnings[2].Scope != "spell_casts[0]" || !strings.Contains(output.Warnings[2].Message, "retained input index 0") || !strings.Contains(output.Warnings[2].Message, "removed input indices [1]") { t.Fatalf("duplicate warning = %#v, want retained and removed merged indices", output.Warnings[2]) } warningsFile := decodeAssembledOutput[struct { Warnings []contracts.Warning `json:"warnings"` }](t, output.OutputFiles, "warnings.json") if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) { t.Fatalf("warnings file = %#v, run warnings = %#v, want manifest output path to preserve warnings", warningsFile.Warnings, output.Warnings) } manifest := decodeAssembledOutput[artifacts.RunManifest](t, output.OutputFiles, "manifest.json") if len(manifest.ArtifactLanes) != 1 || manifest.ArtifactLanes[0].Normalizer != spellnormalize.Key { t.Fatalf("manifest lanes = %#v, want assembled spell normalizer", manifest.ArtifactLanes) } normalizerMetadata, ok := manifest.ArtifactLanes[0].Metadata["normalizer"].(map[string]any) _, hasOverlayIDs := normalizerMetadata["catalog_overlay_ids"] if !ok || normalizerMetadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || !strings.HasPrefix(stringValue(normalizerMetadata["catalog_digest"]), "sha256:") || !hasOverlayIDs { t.Fatalf("normalizer manifest metadata = %#v, want base ID, digest, and overlay IDs", manifest.ArtifactLanes[0].Metadata) } } func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) { registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true}) var normalizeChain *pipeline.ResolvedValidatorChain for index := range resolved.ValidatorChains { chain := &resolved.ValidatorChains[index] if chain.Stage == pipeline.StageNormalize && chain.ModuleKey == spellnormalize.Key && chain.LaneID == "spells" { normalizeChain = chain break } } if normalizeChain == nil || len(normalizeChain.Validators) != 1 || normalizeChain.Validators[0].Binding.Module != "generic/always_accept" { t.Fatalf("normalize validator chain = %#v, want explicit always-accept override", normalizeChain) } prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{}) if err != nil { t.Fatalf("Prepare() error = %v, want nil", 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 || output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { t.Fatalf("Run() error = %v output = %#v, want approved override run", err, output) } for _, warning := range output.Warnings { if warning.ReasonCode == "spell_not_near_source" { t.Fatalf("warnings = %#v, want explicit validator override to replace default relatedness chain", output.Warnings) } } } func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning(t *testing.T) { registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true}) prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{}) if err != nil { t.Fatalf("Prepare() error = %v, want nil", 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 output.Manifest.ValidationStatus != "rejected" || len(output.NormalizeOutputs) != 0 || len(output.Rejected) != 1 { t.Fatalf("run output = %#v, want one rejected normalize candidate and no normalized output", output) } rejection := output.Rejected[0] if rejection.Stage != string(pipeline.StageNormalize) || rejection.LaneID != "spells" || rejection.ModuleKey != spellnormalize.Key || rejection.ValidatorName != "extract/dnd/spells/catalog" || rejection.ReasonCode != "unknown_spell" { t.Fatalf("rejection = %#v, want durable normalize catalog rejection", rejection) } rejectedFile := decodeAssembledOutput[struct { Rejected []contracts.RejectedOutput `json:"rejected"` }](t, output.OutputFiles, "rejected.json") if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) { t.Fatalf("rejected file = %#v, run rejections = %#v, want durable rejection diagnostic", rejectedFile.Rejected, output.Rejected) } for _, warning := range output.Warnings { if warning.ReasonCode == spellnormalize.ReasonCodeSpellNameUnresolved { t.Fatalf("warnings = %#v, want rejected-attempt warning to remain non-durable", output.Warnings) } } } func TestAssembledSpellPipelinePromotesUnknownSpellWarningWhenOverrideAccepts(t *testing.T) { registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true, unknownSpell: true}) prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{}) if err != nil { t.Fatalf("Prepare() error = %v, want nil", 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 output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { t.Fatalf("run output = %#v, want accepted unknown spell with explicit validator override", output) } var normalized dnd.SpellList if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil { t.Fatalf("decode normalized output: %v", err) } if len(normalized.SpellCasts) != 1 || normalized.SpellCasts[0].Spell != "Mysterious Burst" { t.Fatalf("normalized casts = %#v, want unresolved name preserved", normalized.SpellCasts) } if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" { t.Fatalf("warnings = %#v, want promoted scoped unresolved-name warning", output.Warnings) } warningsFile := decodeAssembledOutput[struct { Warnings []contracts.Warning `json:"warnings"` }](t, output.OutputFiles, "warnings.json") if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) { t.Fatalf("warnings file = %#v, run warnings = %#v, want durable unresolved-name warning", warningsFile.Warnings, output.Warnings) } } type assembledSpellPipelineOptions struct { normalizeValidatorOverride bool unknownSpell bool } func assembledSpellPipeline(t *testing.T, options assembledSpellPipelineOptions) (pipeline.Registries, pipeline.ResolvedPipeline, *assembledSpellExtractor) { t.Helper() components := productionTestComponents(t) extractor := &assembledSpellExtractor{unknownSpell: options.unknownSpell} if err := pipeline.RegisterExtractor[dnd.SpellList](components.registries.Extractors, pipeline.ModuleSpec{ Key: assembledSpellExtractorKey, Stage: pipeline.StageExtract, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.spell_casts"}, ArtifactKind: dnd.SpellListKind, }, func() (contracts.Extractor[dnd.SpellList], error) { return extractor, nil }); err != nil { t.Fatalf("register assembled extractor: %v", err) } normalize := pipeline.Binding(spellnormalize.Key) if options.normalizeValidatorOverride { normalize.Validators = pipeline.ValidatorOverride{ Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}, } } resolved, err := pipeline.ResolvePipeline(pipeline.PipelineProfile{ ID: "assembled-dnd-spells", Input: pipeline.Binding("seriatim"), Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}, Artifacts: map[string]pipeline.ArtifactLaneProfile{ "spells": {Extract: pipeline.Binding(assembledSpellExtractorKey), Normalize: normalize}, }, Output: pipeline.Binding("json"), }, pipeline.ResolveOptions{}, catalogFromRegistries(components.registries)) if err != nil { t.Fatalf("ResolvePipeline() error = %v, want nil", err) } return components.registries, resolved, extractor } type assembledSpellExtractor struct { mu sync.Mutex chunkIndexes []int unknownSpell bool } func (e *assembledSpellExtractor) Key() string { return assembledSpellExtractorKey } func (*assembledSpellExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (e *assembledSpellExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SpellList], error) { if err := ctx.Err(); err != nil { return contracts.TypedExtractionResult[dnd.SpellList]{}, err } if req.Chunk == nil || req.Source == nil { return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("assembled extractor requires source and chunk") } e.mu.Lock() e.chunkIndexes = append(e.chunkIndexes, req.Chunk.Index) e.mu.Unlock() refOne := source.SourceRef{SourceID: req.Source.ID, StartUnitID: 1, EndUnitID: 1} refTwo := source.SourceRef{SourceID: req.Source.ID, StartUnitID: 2, EndUnitID: 2} if e.unknownSpell { if req.Chunk.Index == 0 { return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{ Caster: "Aria", Spell: "Mysterious Burst", SourceRefs: []source.SourceRef{refOne}, }}}}, nil } return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, nil } switch req.Chunk.Index { case 0: return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{ Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{refTwo, refOne}, }}}}, nil case 1: return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{ {Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refOne, refTwo}}, {Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refTwo}}, }}}, nil default: return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index) } } func (e *assembledSpellExtractor) chunkIndexesSnapshot() []int { e.mu.Lock() defer e.mu.Unlock() return append([]int(nil), e.chunkIndexes...) } func decodeAssembledOutput[T any](t *testing.T, files []contracts.OutputFile, name string) T { t.Helper() for _, file := range files { if file.Name != name { continue } var value T if err := json.Unmarshal(file.Bytes, &value); err != nil { t.Fatalf("decode %s: %v", name, err) } return value } t.Fatalf("output files = %#v, want %q", files, name) return *new(T) }