Files
notarius/internal/cli/run_test.go

936 lines
30 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"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/chunk/generic"
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/input/seriatim"
"gitea.maximumdirect.net/eric/notarius/internal/modules/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
)
func TestRunNoArgsWritesUsageToStdout(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run(nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("Run() code = %d, want 0", code)
}
if stdout.String() != usage {
t.Fatalf("stdout = %q, want %q", stdout.String(), usage)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestRunHelpArgsWriteUsageToStdout(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "help", args: []string{"help"}},
{name: "long help flag", args: []string{"--help"}},
{name: "short help flag", args: []string{"-h"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run(tt.args, &stdout, &stderr)
if code != 0 {
t.Fatalf("Run() code = %d, want 0", code)
}
if stdout.String() != usage {
t.Fatalf("stdout = %q, want %q", stdout.String(), usage)
}
if !strings.Contains(stdout.String(), "config validate") || !strings.Contains(stdout.String(), "pipelines list") {
t.Fatalf("usage does not mention new commands: %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
})
}
}
func TestRunUnknownCommandWritesErrorAndUsageToStderr(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run([]string{"extract"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("Run() code = %d, want 2", code)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
gotStderr := stderr.String()
if !strings.Contains(gotStderr, "notarius: unknown command \"extract\"") {
t.Fatalf("stderr = %q, want unknown command error", gotStderr)
}
if !strings.Contains(gotStderr, usage) {
t.Fatalf("stderr = %q, want usage", gotStderr)
}
}
func TestRunConfigValidateSuccessWithFakeCatalog(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes"))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{
Catalog: fakeCatalog(t),
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "is valid for pipeline") {
t.Fatalf("stdout = %q, want validation success", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestProductionCatalogIncludesDefaultModules(t *testing.T) {
catalog, err := productionCatalog()
if err != nil {
t.Fatalf("productionCatalog() error = %v, want nil", err)
}
tests := []struct {
name string
got func() (pipeline.ModuleSpec, bool)
want pipeline.ModuleSpec
}{
{
name: "seriatim input",
got: func() (pipeline.ModuleSpec, bool) { return catalog.Inputs.Spec(seriatim.Key) },
want: seriatim.ModuleSpec(),
},
{
name: "generic chunker",
got: func() (pipeline.ModuleSpec, bool) { return catalog.Chunkers.Spec(generic.Key) },
want: generic.ModuleSpec(),
},
{
name: "dnd spells extractor",
got: func() (pipeline.ModuleSpec, bool) { return catalog.Extractors.Spec(spells.Key) },
want: spells.ModuleSpec(),
},
{
name: "appendorder merger",
got: func() (pipeline.ModuleSpec, bool) { return catalog.Mergers.Spec(appendorder.Key) },
want: appendorder.ModuleSpec(),
},
{
name: "noop normalizer",
got: func() (pipeline.ModuleSpec, bool) { return catalog.Normalizers.Spec(noop.Key) },
want: noop.ModuleSpec(),
},
{
name: "json output",
got: func() (pipeline.ModuleSpec, bool) { return catalog.Outputs.Spec(jsonoutput.Key) },
want: jsonoutput.ModuleSpec(),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, ok := test.got()
if !ok {
t.Fatalf("module spec ok = false, want true")
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("module spec = %#v, want %#v", got, test.want)
}
})
}
}
func TestRunConfigValidateUsesProductionCatalogByDefault(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "is valid for pipeline") {
t.Fatalf("stdout = %q, want validation success", stdout.String())
}
}
func TestRunConfigValidateUnknownProductionModuleIncludesContext(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "missing/extract"))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
got := stderr.String()
for _, want := range []string{"dnd-session", "extract", "missing/extract", "not registered"} {
if !strings.Contains(got, want) {
t.Fatalf("stderr = %q, want substring %q", got, want)
}
}
}
func TestRunConfigValidateReportsParseErrors(t *testing.T) {
configPath := writeFile(t, "config.yml", "version: 2\n")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "unsupported config version") {
t.Fatalf("stderr = %q, want parse error", stderr.String())
}
}
func TestRunConfigValidatePipelineOnlySuccessAndInvalidLane(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes"))
t.Run("success", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "notes"}, &stdout, &stderr, Options{
Catalog: fakeCatalog(t),
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
})
t.Run("invalid lane", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "missing"}, &stdout, &stderr, Options{
Catalog: fakeCatalog(t),
})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "selected artifact lane") {
t.Fatalf("stderr = %q, want invalid lane error", stderr.String())
}
})
}
func TestRunConfigValidateOnlyWithoutPipelineFails(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "events"}, &stdout, &stderr, Options{})
if code != 2 {
t.Fatalf("RunWithOptions() code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "--only requires --pipeline") {
t.Fatalf("stderr = %q, want only/pipeline error", stderr.String())
}
}
func TestRunConfigValidateRejectsMalformedOnlyValues(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes"))
tests := []string{",", "notes,", ",notes", "events, ,notes"}
for _, only := range tests {
t.Run(only, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", only}, &stdout, &stderr, Options{
Catalog: fakeCatalog(t),
})
if code != 2 {
t.Fatalf("RunWithOptions() code = %d, want 2", code)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "--only") {
t.Fatalf("stderr = %q, want --only error", stderr.String())
}
})
}
}
func TestRunPipelinesListSortedTextOutput(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{
"zeta": {"events"},
"alpha": {"events"},
}))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, Options{})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if got, want := stdout.String(), "alpha\nzeta\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}
func TestRunPipelinesListStableJSONOutput(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{
"b": {"events"},
"a": {"events"},
}))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, Options{})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if got, want := stdout.String(), "{\"pipelines\":[\"a\",\"b\"]}\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}
func TestRunUsesNotariusConfigWhenConfigFlagAbsent(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, Options{
LookupEnv: mapLookup(map[string]string{"NOTARIUS_CONFIG": configPath}),
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if got, want := stdout.String(), "example\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}
func TestRunConfigValidateResolvesAPIKeyEnvThroughOptions(t *testing.T) {
configPath := writeTestConfig(t, `version: 1
llm_profiles:
default:
api_key_env: NOTARIUS_TEST_API_KEY
pipelines:
example:
input: fake/input
artifacts:
events:
extract: fake/extract
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{
LookupEnv: mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"}),
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
}
func TestRunMissingConfigPathProducesActionableError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", filepath.Join(t.TempDir(), "missing.yml")}, &stdout, &stderr, Options{})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "config file") || !strings.Contains(stderr.String(), "not available") {
t.Fatalf("stderr = %q, want actionable missing config error", stderr.String())
}
}
func TestRunRejectsAdHocStructuralFlags(t *testing.T) {
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
flags := []string{"--extractor", "--chunker", "--input", "--merge", "--normalize"}
for _, flagName := range flags {
t.Run(flagName, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, flagName, "value"}, &stdout, &stderr, Options{})
if code != 2 {
t.Fatalf("RunWithOptions() code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "flag provided but not defined") {
t.Fatalf("stderr = %q, want invalid flag error", stderr.String())
}
})
}
}
func TestRunInvalidFlagsExitTwo(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list", "--bogus"}, &stdout, &stderr, Options{})
if code != 2 {
t.Fatalf("RunWithOptions() code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "flag provided but not defined") {
t.Fatalf("stderr = %q, want invalid flag error", stderr.String())
}
}
func TestProductionLLMClientFactoryRejectsMissingProfile(t *testing.T) {
cfg := config.Default()
_, _, err := productionLLMClientFactory(context.Background(), cfg, "missing")
if err == nil {
t.Fatal("productionLLMClientFactory() error = nil, want error")
}
if !strings.Contains(err.Error(), "LLM profile") || !strings.Contains(err.Error(), "missing") {
t.Fatalf("error = %q, want missing profile context", err.Error())
}
}
func TestProductionLLMClientFactoryRejectsInvalidProfile(t *testing.T) {
tests := []struct {
name string
profile config.LLMProfile
want string
}{
{
name: "unsupported provider",
profile: config.LLMProfile{Provider: "other", BaseURL: "https://example.test", Model: "model"},
want: "not supported",
},
{
name: "missing base url",
profile: config.LLMProfile{Provider: "openai-compatible", Model: "model"},
want: "base URL",
},
{
name: "missing model",
profile: config.LLMProfile{Provider: "openai-compatible", BaseURL: "https://example.test"},
want: "model",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := config.Default()
cfg.LLMProfiles = map[string]config.LLMProfile{"default": test.profile}
_, _, err := productionLLMClientFactory(context.Background(), cfg, "default")
if err == nil {
t.Fatal("productionLLMClientFactory() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %q, want substring %q", err.Error(), test.want)
}
})
}
}
func TestProductionLLMClientFactoryReturnsScheduledClientAndManifestMetadata(t *testing.T) {
cfg := config.Default()
cfg.LLMProfiles = map[string]config.LLMProfile{
"default": {
Provider: "openai-compatible",
BaseURL: "https://example.test",
Model: "model-a",
MaxConcurrency: 2,
},
}
client, metadata, err := productionLLMClientFactory(context.Background(), cfg, "default")
if err != nil {
t.Fatalf("productionLLMClientFactory() error = %v, want nil", err)
}
if client == nil {
t.Fatal("client = nil, want scheduled client")
}
if len(metadata) != 1 {
t.Fatalf("len(metadata) = %d, want 1", len(metadata))
}
if metadata[0].ID != "default" || metadata[0].Provider != "openai-compatible" || metadata[0].Model != "model-a" {
t.Fatalf("metadata = %#v, want profile-safe model metadata", metadata)
}
}
func TestRunPipelineMissingPipelineID(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := writeSeriatimInput(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{})
if code != 2 {
t.Fatalf("RunWithOptions() code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "pipeline ID") {
t.Fatalf("stderr = %q, want missing pipeline ID error", stderr.String())
}
}
func TestRunPipelineMissingInputFlag(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath}, &stdout, &stderr, Options{})
if code != 2 {
t.Fatalf("RunWithOptions() code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "--input") {
t.Fatalf("stderr = %q, want missing input error", stderr.String())
}
}
func TestRunPipelineRejectsUnknownFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--extractor", "dnd/spells"}, &stdout, &stderr, Options{})
if code != 2 {
t.Fatalf("RunWithOptions() code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "flag provided but not defined") {
t.Fatalf("stderr = %q, want invalid flag error", stderr.String())
}
}
func TestRunPipelineUnknownPipeline(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := writeSeriatimInput(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "missing", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{
LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "pipeline \"missing\" is not configured") {
t.Fatalf("stderr = %q, want unknown pipeline error", stderr.String())
}
}
func TestRunPipelineUnknownOnlyLane(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := writeSeriatimInput(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "missing"}, &stdout, &stderr, Options{
LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "selected artifact lane") {
t.Fatalf("stderr = %q, want selected lane error", stderr.String())
}
}
func TestRunPipelineInvalidInputPath(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := filepath.Join(t.TempDir(), "missing.json")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{
LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "read input") {
t.Fatalf("stderr = %q, want input read error", stderr.String())
}
}
func TestRunPipelineSuccessUsesProductionRegistriesAndFakeLLM(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := writeSeriatimInput(t)
client := newFakeRunLLMClient(false)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{
LLMClientFactory: fakeLLMFactory(client, nil),
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if client.calls != 1 {
t.Fatalf("LLM calls = %d, want 1", client.calls)
}
for _, want := range []string{"dnd-session", "approved=1", "rejected=0"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("stdout = %q, want substring %q", stdout.String(), want)
}
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestRunPipelineOnlySelectsRequestedLane(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAMLForLanes("dnd-session", "spells", "rituals"))
inputPath := writeSeriatimInput(t)
client := newFakeRunLLMClient(false)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "spells"}, &stdout, &stderr, Options{
LLMClientFactory: fakeLLMFactory(client, nil),
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if client.calls != 1 {
t.Fatalf("LLM calls = %d, want only selected lane to run once", client.calls)
}
if !strings.Contains(stdout.String(), "approved=1") {
t.Fatalf("stdout = %q, want approved count", stdout.String())
}
}
func TestRunPipelineLLMFactoryFailure(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := writeSeriatimInput(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{
LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), errors.New("factory unavailable")),
})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "create LLM client") || !strings.Contains(stderr.String(), "factory unavailable") {
t.Fatalf("stderr = %q, want LLM factory error", stderr.String())
}
}
func TestRunPipelineValidationRejectionCompletesSuccessfully(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := writeSeriatimInput(t)
client := newFakeRunLLMClient(true)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{
LLMClientFactory: fakeLLMFactory(client, nil),
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "approved=0") || !strings.Contains(stdout.String(), "rejected=1") {
t.Fatalf("stdout = %q, want rejection counts", stdout.String())
}
}
func TestRunPipelineLLMProfileOverrideSelectsFactoryProfile(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAMLWithProfiles("dnd-session"))
inputPath := writeSeriatimInput(t)
client := newFakeRunLLMClient(false)
factory := &recordingLLMFactory{client: client}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--llm-profile", "runtime"}, &stdout, &stderr, Options{
LLMClientFactory: factory.build,
})
if code != 0 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
}
if got, want := strings.Join(factory.profileIDs, ","), "runtime"; got != want {
t.Fatalf("factory profile IDs = %q, want %q", got, want)
}
}
func writeTestConfig(t *testing.T, content string) string {
t.Helper()
return writeFile(t, "config.yml", content)
}
func writeFile(t *testing.T, name string, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
func testConfigYAML(pipelineID string, laneIDs ...string) string {
return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs})
}
func testConfigYAMLForPipelines(pipelines map[string][]string) string {
var b strings.Builder
b.WriteString("version: 1\n")
b.WriteString("pipelines:\n")
for pipelineID, laneIDs := range pipelines {
b.WriteString(" " + pipelineID + ":\n")
b.WriteString(" input: fake/input\n")
b.WriteString(" artifacts:\n")
for _, laneID := range laneIDs {
b.WriteString(" " + laneID + ":\n")
b.WriteString(" extract: fake/extract\n")
}
}
return b.String()
}
func mvpConfigYAML(pipelineID string, extractor string) string {
return `version: 1
pipelines:
` + pipelineID + `:
input: seriatim
artifacts:
spells:
extract: ` + extractor + `
`
}
func mvpConfigYAMLForLanes(pipelineID string, laneIDs ...string) string {
var b strings.Builder
b.WriteString("version: 1\n")
b.WriteString("pipelines:\n")
b.WriteString(" " + pipelineID + ":\n")
b.WriteString(" input: seriatim\n")
b.WriteString(" artifacts:\n")
for _, laneID := range laneIDs {
b.WriteString(" " + laneID + ":\n")
b.WriteString(" extract: dnd/spells\n")
}
return b.String()
}
func mvpConfigYAMLWithProfiles(pipelineID string) string {
return `version: 1
llm_profiles:
default:
provider: openai-compatible
runtime:
provider: openai-compatible
pipelines:
` + pipelineID + `:
input: seriatim
artifacts:
spells:
extract: dnd/spells
`
}
func writeSeriatimInput(t *testing.T) string {
t.Helper()
return writeFile(t, "source.json", `{
"metadata": {
"id": "session-alpha"
},
"segments": [
{
"id": "seg-001",
"start": 0,
"end": 1,
"speaker": "Aria",
"text": "Aria casts Cure Wounds."
}
]
}`)
}
type fakeRunLLMClient struct {
invalidSourceRef bool
calls int
}
func newFakeRunLLMClient(invalidSourceRef bool) *fakeRunLLMClient {
return &fakeRunLLMClient{invalidSourceRef: invalidSourceRef}
}
func (client *fakeRunLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.calls++
startUnitID := "seg-001"
if client.invalidSourceRef {
startUnitID = "missing-segment"
}
payload := map[string]any{
"spell_casts": []map[string]any{
{
"caster": "Aria",
"spell": "Cure Wounds",
"effect": "Heals a wounded ally.",
"narrative_description": "Aria casts Cure Wounds.",
"source_refs": []map[string]string{
{
"source_id": "session-alpha",
"start_unit_id": startUnitID,
"end_unit_id": "seg-001",
},
},
},
},
}
encoded, err := json.Marshal(payload)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(encoded, out); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: encoded}, nil
}
func fakeLLMFactory(client contracts.StructuredLLMClient, err error) LLMClientFactory {
return func(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
if err != nil {
return nil, nil, err
}
return client, []artifacts.LLMProfileManifest{{ID: strings.TrimSpace(profileID)}}, nil
}
}
type recordingLLMFactory struct {
client contracts.StructuredLLMClient
profileIDs []string
}
func (factory *recordingLLMFactory) build(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
factory.profileIDs = append(factory.profileIDs, strings.TrimSpace(profileID))
return factory.client, []artifacts.LLMProfileManifest{{ID: strings.TrimSpace(profileID)}}, nil
}
func fakeCatalog(t *testing.T) pipeline.ModuleCatalog {
t.Helper()
inputs := pipeline.NewInputAdapterRegistry()
chunkers := pipeline.NewChunkerRegistry()
extractors := pipeline.NewExtractorRegistry()
mergers := pipeline.NewMergerRegistry()
normalizers := pipeline.NewNormalizerRegistry()
validators := pipeline.NewValidatorRegistry()
outputs := pipeline.NewOutputEncoderRegistry()
mustRegisterInput(t, inputs, pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}})
mustRegisterChunker(t, chunkers, pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}})
mustRegisterExtractor(t, extractors, pipeline.ModuleSpec{Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}})
mustRegisterMerger(t, mergers, pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}})
mustRegisterNormalizer(t, normalizers, pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}})
mustRegisterOutput(t, outputs, pipeline.ModuleSpec{Key: "json", Stage: pipeline.StageOutput, Requires: []string{"normalized"}})
return pipeline.ModuleCatalog{
Inputs: inputs,
Chunkers: chunkers,
Extractors: extractors,
Mergers: mergers,
Normalizers: normalizers,
Validators: validators,
Outputs: outputs,
}
}
func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil {
t.Fatalf("register input: %v", err)
}
}
func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil {
t.Fatalf("register chunker: %v", err)
}
}
func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Extractor, error) { return nil, nil }); err != nil {
t.Fatalf("register extractor: %v", err)
}
}
func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Merger, error) { return nil, nil }); err != nil {
t.Fatalf("register merger: %v", err)
}
}
func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Normalizer, error) { return nil, nil }); err != nil {
t.Fatalf("register normalizer: %v", err)
}
}
func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil {
t.Fatalf("register output: %v", err)
}
}
func mapLookup(values map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
value, ok := values[key]
return value, ok
}
}