package cli import ( "bytes" "context" "encoding/json" "io/fs" "path/filepath" "reflect" "sort" "strings" "sync" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" frameworkllm "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" 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" spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness" validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json" validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema" ) func TestProductionCompatibilitySnapshot(t *testing.T) { normalizedOptions, err := normalizeOptions(Options{}) if err != nil { t.Fatalf("normalizeOptions() error = %v, want nil", err) } if normalizedOptions.Catalog.Inputs != normalizedOptions.Registries.Inputs || normalizedOptions.Catalog.Chunkers != normalizedOptions.Registries.Chunkers || normalizedOptions.Catalog.Extractors != normalizedOptions.Registries.Extractors || normalizedOptions.Catalog.Mergers != normalizedOptions.Registries.Mergers || normalizedOptions.Catalog.Normalizers != normalizedOptions.Registries.Normalizers || normalizedOptions.Catalog.Validators != normalizedOptions.Registries.Validators || normalizedOptions.Catalog.ValidatorChains != normalizedOptions.Registries.ValidatorChains || normalizedOptions.Catalog.Outputs != normalizedOptions.Registries.Outputs { t.Fatal("production catalog and execution registries do not share one composition") } if normalizedOptions.LLMClientFactory == nil { t.Fatal("production LLM client factory is nil") } registries, err := productionRegistries() if err != nil { t.Fatalf("productionRegistries() error = %v, want nil", err) } keySnapshots := []struct { name string got []string want []string }{ {name: "inputs", got: registries.Inputs.RegisteredKeys(), want: []string{"seriatim"}}, {name: "chunkers", got: registries.Chunkers.RegisteredKeys(), want: []string{"dnd/scenes", "generic"}}, {name: "extractors", got: registries.Extractors.RegisteredKeys(), want: []string{"dnd/spells"}}, {name: "mergers", got: registries.Mergers.RegisteredKeys(), want: []string{"appendorder"}}, {name: "normalizers", got: registries.Normalizers.RegisteredKeys(), want: []string{"noop"}}, {name: "validators", got: registries.Validators.RegisteredKeys(), want: []string{ "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness", "generic/always_accept", "generic/always_reject", "generic/valid_json", "generic/valid_json_schema", }}, {name: "outputs", got: registries.Outputs.RegisteredKeys(), want: []string{"json"}}, } for _, snapshot := range keySnapshots { t.Run(snapshot.name, func(t *testing.T) { if !reflect.DeepEqual(snapshot.got, snapshot.want) { t.Fatalf("registered keys = %#v, want compatibility snapshot %#v", snapshot.got, snapshot.want) } }) } wantChain := []pipeline.ModuleBinding{ pipeline.Binding(validjson.Key), pipeline.Binding(validjsonschema.Key), pipeline.Binding(spellshape.Key), pipeline.Binding(spellsourcerefs.Key), pipeline.Binding(spellrelatedness.Key), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("spell validator chain = %#v, want compatibility snapshot %#v", got, wantChain) } assets, err := productionPromptAssets() if err != nil { t.Fatalf("productionPromptAssets() error = %v, want nil", err) } assertAssetNames(t, assets.PromptFS, []string{ "dnd.scenes/dnd.scenes.yaml", "dnd.scenes/instructions.md", "dnd.scenes/sharedassets/common-dnd-references.md", "dnd.scenes/sharedassets/common-dnd-system.md", "dnd.scenes/sharedassets/common-dnd-transcript.md", "dnd.scenes/task.md", "dnd.spells/dnd.spells.yaml", "dnd.spells/instructions.md", "dnd.spells/sharedassets/common-dnd-references.md", "dnd.spells/sharedassets/common-dnd-system.md", "dnd.spells/sharedassets/common-dnd-transcript.md", "dnd.spells/task.md", }) assertAssetNames(t, assets.SchemaFS, []string{ "dnd_scenes.v1.json", "dnd_spells_llm.v1.json", }) identitySnapshot := map[string]map[string]any{ "scenes": sceneManifestMetadata(t), "spells": spellManifestMetadata(t), } for name, metadata := range identitySnapshot { for _, key := range []string{"prompt_id", "prompt_version", "prompt_sha256", "response_schema_key", "response_schema_id", "response_schema_name", "response_schema_version", "response_schema_sha256"} { if value, ok := metadata[key].(string); !ok || value == "" { t.Fatalf("%s metadata[%q] = %#v, want non-empty identity", name, key, metadata[key]) } } } if got := []any{ identitySnapshot["scenes"]["prompt_id"], identitySnapshot["scenes"]["prompt_version"], identitySnapshot["scenes"]["response_schema_key"], identitySnapshot["scenes"]["response_schema_id"], identitySnapshot["scenes"]["response_schema_name"], identitySnapshot["scenes"]["response_schema_version"], }; !reflect.DeepEqual(got, []any{"dnd.scenes", "v1", "dnd_scenes", "notarius.dnd.scenes", "notarius_dnd_scenes_v1", "v1"}) { t.Fatalf("scene identities = %#v, want compatibility snapshot", got) } if got := []any{ identitySnapshot["spells"]["prompt_id"], identitySnapshot["spells"]["prompt_version"], identitySnapshot["spells"]["response_schema_key"], identitySnapshot["spells"]["response_schema_id"], identitySnapshot["spells"]["response_schema_name"], identitySnapshot["spells"]["response_schema_version"], }; !reflect.DeepEqual(got, []any{"dnd.spells", "v1", "dnd_spells", "notarius.dnd.spells", "notarius_dnd_spells_v1", "v1"}) { t.Fatalf("spell identities = %#v, want compatibility snapshot", got) } fileConfig, err := config.LoadFileConfig(fixturePath(t, "examples/dnd-spells.config.yml")) if err != nil { t.Fatalf("LoadFileConfig() error = %v, want nil", err) } cfg := config.Default() if err := cfg.ApplyFileConfig(fileConfig); err != nil { t.Fatalf("ApplyFileConfig() error = %v, want nil", err) } effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(registries)}) if err != nil { t.Fatalf("Resolve() error = %v, want nil", err) } resolved := effective.ResolvedPipeline if resolved.Input.Module != "seriatim" || resolved.Chunk.Module != "generic" || resolved.Output.Module != "json" || len(resolved.ArtifactLanes) != 1 { t.Fatalf("resolved example = %#v, want maintained production topology", resolved) } lane := resolved.ArtifactLanes[0] if lane.ID != "spells" || lane.Extract.Module != "dnd/spells" || lane.Merge.Module != "appendorder" || lane.Normalize.Module != "noop" { t.Fatalf("resolved lane = %#v, want maintained spell lane", lane) } if got := resolvedValidatorKeys(resolved.ValidatorChains, pipeline.StageExtract, "spells", spells.Key); !reflect.DeepEqual(got, []string{ "generic/valid_json", "generic/valid_json_schema", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness", }) { t.Fatalf("resolved validator keys = %#v, want compatibility snapshot", got) } productionFileConfig, err := config.LoadFileConfig(fixturePath(t, "examples/dnd-spells-production.config.yml")) if err != nil { t.Fatalf("LoadFileConfig(production example) error = %v, want nil", err) } productionConfig := config.Default() if err := productionConfig.ApplyFileConfig(productionFileConfig); err != nil { t.Fatalf("ApplyFileConfig(production example) error = %v, want nil", err) } productionEffective, err := productionConfig.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(registries)}) if err != nil { t.Fatalf("Resolve(production example) error = %v, want nil", err) } productionResolved := productionEffective.ResolvedPipeline if productionConfig.Concurrency.TotalLLM != 1 || productionConfig.Concurrency.StageWorkers["extract"] != 1 || !reflect.DeepEqual(productionResolved.Chunk.Options, map[string]any{"max_units": 50}) { t.Fatalf("production example concurrency/options = %#v/%#v, want compatibility snapshot", productionConfig.Concurrency, productionResolved.Chunk.Options) } bindings := productionResolved.ArtifactLanes[0].ExtractReferences.Bindings if len(bindings) != 2 || bindings[0].SlotName != "glossary" || bindings[0].Source != "./dnd-spells-glossary.txt" || bindings[1].SlotName != "party" || bindings[1].Source != "./dnd-spells-roster.txt" { t.Fatalf("production example reference bindings = %#v, want maintained glossary and party bindings", bindings) } } func TestMaintainedSeriatimToDNDCompatibilityBundle(t *testing.T) { tests := []struct { name string client contracts.StructuredLLMClient wantStatus string wantLaneFile bool wantRejectedCount int }{ {name: "approved", client: newFakeRunLLMClient(false), wantStatus: "approved", wantLaneFile: true}, {name: "validator rejection is nonfatal", client: newFakeRunLLMClient(true), wantStatus: "rejected", wantRejectedCount: 1}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { outputDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "dnd-session", "--config", fixturePath(t, "examples/dnd-spells.config.yml"), "--input", fixturePath(t, "examples/seriatim-minimal-transcript.json"), "--output-dir", outputDir, "--diagnostics-dir", t.TempDir(), }, &stdout, &stderr, Options{LLMClientFactory: fakeLLMFactory(test.client, nil)}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } runDir := onlyChildDir(t, outputDir) wantFiles := []string{"index.json", "manifest.json", "rejected.json", "warnings.json"} if test.wantLaneFile { wantFiles = append(wantFiles, "lanes/spells.json") } sort.Strings(wantFiles) if got := relativeFileNames(t, runDir); !reflect.DeepEqual(got, wantFiles) { t.Fatalf("durable files = %#v, want compatibility snapshot %#v", got, wantFiles) } var manifest artifacts.RunManifest readJSONFile(t, filepath.Join(runDir, "manifest.json"), &manifest) if manifest.PipelineID != "dnd-session" || manifest.InputModule != "seriatim" || manifest.Chunker != "generic" || manifest.OutputEncoder != "json" { t.Fatalf("manifest module provenance = %#v, want maintained production modules", manifest) } if len(manifest.ArtifactLanes) != 1 || manifest.ArtifactLanes[0].ID != "spells" { t.Fatalf("artifact lanes = %#v, want one spells lane", manifest.ArtifactLanes) } laneManifest := manifest.ArtifactLanes[0] if laneManifest.Extractor != "dnd/spells" || laneManifest.Merger != "appendorder" || laneManifest.Normalizer != "noop" { t.Fatalf("manifest lane module provenance = %#v, want maintained production modules", laneManifest) } if len(manifest.Extractors) != 0 || manifest.Merger != "" || manifest.Normalizer != "" { t.Fatalf("legacy top-level lane summaries = %#v/%q/%q, want empty compatibility snapshot", manifest.Extractors, manifest.Merger, manifest.Normalizer) } if manifest.ValidationStatus != test.wantStatus || len(manifest.RejectedOutputs) != test.wantRejectedCount { t.Fatalf("manifest outcome = status %q rejected %#v, want %q/%d", manifest.ValidationStatus, manifest.RejectedOutputs, test.wantStatus, test.wantRejectedCount) } if !reflect.DeepEqual(manifest.SourceDigests, []string{"sha256:1c98d94ae632fb10a2b56f684cd4fb1019cedb1a629e57dc0977cf4a54135be0"}) { t.Fatalf("source digests = %#v, want maintained fixture provenance", manifest.SourceDigests) } if got := manifestValidatorKeys(manifestValidatorChain(t, manifest, pipeline.StageExtract, "spells", spells.Key)); !reflect.DeepEqual(got, []string{ "generic/valid_json", "generic/valid_json_schema", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness", }) { t.Fatalf("manifest validator chain = %#v, want compatibility snapshot", got) } var index struct { ManifestFile string `json:"manifest_file"` OutputFiles []struct { LaneID string `json:"lane_id"` MediaType string `json:"media_type"` File string `json:"file"` ModuleKey string `json:"module_key"` SchemaID string `json:"schema_id"` SchemaName string `json:"schema_name"` SchemaVersion string `json:"schema_version"` } `json:"output_files"` RejectedFile string `json:"rejected_file"` WarningsFile string `json:"warnings_file"` } readJSONFile(t, filepath.Join(runDir, "index.json"), &index) if index.ManifestFile != "manifest.json" || index.RejectedFile != "rejected.json" || index.WarningsFile != "warnings.json" { t.Fatalf("output index fixed files = %#v, want compatibility snapshot", index) } if test.wantLaneFile { if len(index.OutputFiles) != 1 { t.Fatalf("output index entries = %#v, want one", index.OutputFiles) } wantOutput := struct { LaneID, MediaType, File, ModuleKey, SchemaID, SchemaName, SchemaVersion string }{"spells", "application/json", "lanes/spells.json", "noop", "notarius.dnd.spells", "notarius_dnd_spells_v1", "v1"} gotOutput := index.OutputFiles[0] got := struct { LaneID, MediaType, File, ModuleKey, SchemaID, SchemaName, SchemaVersion string }{gotOutput.LaneID, gotOutput.MediaType, gotOutput.File, gotOutput.ModuleKey, gotOutput.SchemaID, gotOutput.SchemaName, gotOutput.SchemaVersion} if got != wantOutput { t.Fatalf("output index entries = %#v, want compatibility snapshot %#v", index.OutputFiles, wantOutput) } assertJSONEqual(t, readFile(t, filepath.Join(runDir, "lanes/spells.json")), []byte(`{ "spell_casts": [{ "caster": "Aria", "spell": "Cure Wounds", "effect": "Heals a wounded ally.", "narrative_description": "Aria casts Cure Wounds.", "source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 1}] }] }`)) } else if len(index.OutputFiles) != 0 { t.Fatalf("output index entries = %#v, want none for rejected lane", index.OutputFiles) } var warnings struct { Warnings []contracts.Warning `json:"warnings"` } readJSONFile(t, filepath.Join(runDir, "warnings.json"), &warnings) if len(warnings.Warnings) != 0 { t.Fatalf("warnings = %#v, want empty compatibility snapshot", warnings.Warnings) } var rejected struct { Rejected []contracts.RejectedOutput `json:"rejected"` } readJSONFile(t, filepath.Join(runDir, "rejected.json"), &rejected) if len(rejected.Rejected) != test.wantRejectedCount { t.Fatalf("rejected outputs = %#v, want %d", rejected.Rejected, test.wantRejectedCount) } if test.wantRejectedCount == 1 { got := rejected.Rejected[0] if got.Stage != "extract" || got.LaneID != "spells" || got.ModuleKey != "dnd/spells" || got.ChunkID != "chunk-000001" || got.ChunkIndex != 0 || got.ValidatorName != "extract/dnd/spells/source_refs" || got.ReasonCode != "invalid_source_refs" || got.AttemptCount != 1 { t.Fatalf("rejection = %#v, want maintained nonfatal validator outcome", got) } } }) } } func TestProductionLLMCallersShareScheduledClient(t *testing.T) { underlying := newBlockingProductionLLMClient() scheduler, err := frameworkllm.NewScheduler(1) if err != nil { t.Fatalf("NewScheduler() error = %v, want nil", err) } client := frameworkllm.NewScheduledClient(underlying, scheduler) doc := &source.SourceDocument{ ID: "session-alpha", Kind: "transcript", Format: "application/json", Digest: "sha256:source", Units: []source.SourceUnit{ {ID: 1, Kind: "segment", Text: "Aria casts Cure Wounds.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}, {ID: 2, Kind: "segment", Text: "The spell takes effect.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}, }, } chunk := source.Chunk{ ID: "session-alpha:chunk:0", SourceID: doc.ID, Index: 0, Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, Content: []byte(`{"scene":"Aria casts Cure Wounds."}`), MediaType: "application/json", Units: append([]source.SourceUnit(nil), doc.Units...), } var started sync.WaitGroup started.Add(2) errs := make(chan error, 2) go func() { started.Done() chunker, err := scenes.New(client, scenes.Options{}) if err == nil { _, err = chunker.Plan(context.Background(), contracts.ChunkRequest{Source: doc}) } errs <- err }() go func() { started.Done() extractor, err := spells.New(client, spells.Options{}) if err == nil { _, err = extractor.Extract(context.Background(), contracts.TypedExtractionRequest{Source: doc, Chunk: &chunk}) } errs <- err }() started.Wait() for i := 0; i < 2; i++ { <-underlying.entered underlying.release <- struct{}{} } for i := 0; i < 2; i++ { if err := <-errs; err != nil { t.Fatalf("production LLM caller error = %v, want nil", err) } } if underlying.maxActive != 1 { t.Fatalf("maximum concurrent provider calls = %d, want total_llm limit 1", underlying.maxActive) } sort.Strings(underlying.stageNames) if !reflect.DeepEqual(underlying.stageNames, []string{"dnd/scenes", "dnd/spells"}) { t.Fatalf("scheduled stage names = %#v, want both production LLM callers", underlying.stageNames) } } func TestProductionBundlePreservesLaneAndChunkOrder(t *testing.T) { configPath := writeTestConfig(t, `version: 2 pipelines: dnd-session: input: seriatim chunk: module: generic options: max_units: 1 artifacts: zeta: extract: dnd/spells alpha: extract: dnd/spells `) outputDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "dnd-session", "--config", configPath, "--input", fixturePath(t, "examples/seriatim-minimal-transcript.json"), "--output-dir", outputDir, "--diagnostics-dir", t.TempDir(), }, &stdout, &stderr, Options{LLMClientFactory: fakeLLMFactory(orderingProductionLLMClient{}, nil)}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } runDir := onlyChildDir(t, outputDir) var index struct { OutputFiles []struct { LaneID string `json:"lane_id"` } `json:"output_files"` } readJSONFile(t, filepath.Join(runDir, "index.json"), &index) if len(index.OutputFiles) != 2 { t.Fatalf("output index entries = %#v, want two lanes", index.OutputFiles) } if got := []string{index.OutputFiles[0].LaneID, index.OutputFiles[1].LaneID}; !reflect.DeepEqual(got, []string{"alpha", "zeta"}) { t.Fatalf("output lane order = %#v, want resolved lane order", got) } for _, laneID := range []string{"alpha", "zeta"} { var payload struct { SpellCasts []struct { Spell string `json:"spell"` SourceRefs []source.SourceRef `json:"source_refs"` } `json:"spell_casts"` } readJSONFile(t, filepath.Join(runDir, "lanes", laneID+".json"), &payload) if len(payload.SpellCasts) != 2 { t.Fatalf("lane %q spell casts = %#v, want one per source chunk", laneID, payload.SpellCasts) } got := []any{ payload.SpellCasts[0].Spell, payload.SpellCasts[0].SourceRefs[0].StartUnitID, payload.SpellCasts[1].Spell, payload.SpellCasts[1].SourceRefs[0].StartUnitID, } if !reflect.DeepEqual(got, []any{"Cure Wounds", 1, "Shield", 2}) { t.Fatalf("lane %q chunk handoff order = %#v, want source chunk order", laneID, got) } } } type blockingProductionLLMClient struct { mu sync.Mutex active int maxActive int stageNames []string entered chan struct{} release chan struct{} } type orderingProductionLLMClient struct{} func (orderingProductionLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { material := req.Inputs["transcript"] unitID := 1 spellName := "Cure Wounds" if strings.Contains(string(material.Content), "Shield") { unitID = 2 spellName = "Shield" } payload, err := json.Marshal(map[string]any{ "spell_casts": []map[string]any{{ "caster": "Aria", "spell": spellName, "effect": "Fixture effect.", "narrative_description": "Fixture spell cast.", "source_refs": []map[string]any{{ "start_unit_id": unitID, "end_unit_id": unitID, }}, }}, }) if err != nil { return contracts.StructuredCompletionResponse{}, err } if err := json.Unmarshal(payload, out); err != nil { return contracts.StructuredCompletionResponse{}, err } return contracts.StructuredCompletionResponse{Content: payload}, nil } func newBlockingProductionLLMClient() *blockingProductionLLMClient { return &blockingProductionLLMClient{entered: make(chan struct{}, 2), release: make(chan struct{}, 2)} } func (client *blockingProductionLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { client.mu.Lock() client.active++ if client.active > client.maxActive { client.maxActive = client.active } client.stageNames = append(client.stageNames, req.StageName) client.mu.Unlock() client.entered <- struct{}{} select { case <-ctx.Done(): return contracts.StructuredCompletionResponse{}, ctx.Err() case <-client.release: } client.mu.Lock() client.active-- client.mu.Unlock() var payload []byte switch req.StageName { case scenes.Key: payload = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2,"short_title":"Spell","primary_mode":"Narrative","main_participants":["Aria"],"summary":"Aria casts a spell.","boundary_note":"Complete source.","boundary_confidence":"High"}],"boundary_caveats":[]}`) case spells.Key: payload = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Healing","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`) } if err := json.Unmarshal(payload, out); err != nil { return contracts.StructuredCompletionResponse{}, err } return contracts.StructuredCompletionResponse{Content: payload}, nil } func sceneManifestMetadata(t *testing.T) map[string]any { t.Helper() chunker, err := scenes.New(orderingProductionLLMClient{}, scenes.Options{}) if err != nil { t.Fatalf("construct scene chunker: %v", err) } return chunker.ManifestMetadata() } func spellManifestMetadata(t *testing.T) map[string]any { t.Helper() extractor, err := spells.New(orderingProductionLLMClient{}, spells.Options{}) if err != nil { t.Fatalf("construct spell extractor: %v", err) } return extractor.ManifestMetadata() } func assertAssetNames(t *testing.T, getFS func() (fs.FS, error), want []string) { t.Helper() fSys, err := getFS() if err != nil { t.Fatalf("asset filesystem error = %v, want nil", err) } var got []string if err := fs.WalkDir(fSys, ".", func(path string, entry fs.DirEntry, err error) error { if err == nil && !entry.IsDir() { got = append(got, path) } return err }); err != nil { t.Fatalf("walk assets: %v", err) } if !reflect.DeepEqual(got, want) { t.Fatalf("asset names = %#v, want compatibility snapshot %#v", got, want) } } func resolvedValidatorKeys(chains []pipeline.ResolvedValidatorChain, stage pipeline.ModuleStage, laneID, moduleKey string) []string { for _, chain := range chains { if chain.Stage == stage && chain.LaneID == laneID && chain.ModuleKey == moduleKey { keys := make([]string, 0, len(chain.Validators)) for _, validator := range chain.Validators { keys = append(keys, validator.Binding.Module) } return keys } } return nil } func relativeFileNames(t *testing.T, root string) []string { t.Helper() var names []string if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { if err != nil || entry.IsDir() { return err } rel, err := filepath.Rel(root, path) if err != nil { return err } names = append(names, filepath.ToSlash(rel)) return nil }); err != nil { t.Fatalf("walk durable output: %v", err) } sort.Strings(names) return names } func assertJSONEqual(t *testing.T, got, want []byte) { t.Helper() var gotValue any var wantValue any if err := json.Unmarshal(got, &gotValue); err != nil { t.Fatalf("unmarshal actual JSON: %v", err) } if err := json.Unmarshal(want, &wantValue); err != nil { t.Fatalf("unmarshal expected JSON: %v", err) } if !reflect.DeepEqual(gotValue, wantValue) { t.Fatalf("JSON = %#v, want compatibility snapshot %#v", gotValue, wantValue) } }