package prompt import ( "context" "errors" "reflect" "testing" "text/template" "gitea.maximumdirect.net/eric/promptkit/internal/domain" ) func TestAnalyzeRequirementsCollectsDeclaredAndLiteralInputs(t *testing.T) { description := "retained metadata" definition := &domain.PromptDefinition{ID: "capture", Version: "v1"} snapshot := RequirementSnapshot{ Inputs: []InputMetadata{ {Name: "optional", ContentType: stringPointerForTest("text/plain"), Description: &description}, {Name: "unused", Required: true}, {Name: "transcript", Required: true}, }, Templates: CompiledTemplates{Messages: []*template.Template{ compileRequirementTemplate(t, "message", `{{input "transcript"}} {{input "extra"}} {{input "extra"}}`), }}, } result, err := AnalyzeRequirements(context.Background(), definition, snapshot, nil, false) if err != nil { t.Fatal(err) } if result.PromptID != "capture" || result.PromptVersion != "v1" || !result.Complete || len(result.Issues) != 0 { t.Fatalf("unexpected result identity or completeness: %+v", result) } wantNames := []string{"extra", "optional", "transcript", "unused"} if got := requirementInputNames(result.Inputs); !reflect.DeepEqual(got, wantNames) { t.Fatalf("input names = %v, want %v", got, wantNames) } if got := result.Inputs[0]; got.Kind != domain.RequirementRequired || got.Declared || got.ContentType != nil || got.Description != nil { t.Fatalf("undeclared literal input was not required independently: %+v", got) } if got := result.Inputs[1]; got.Kind != domain.RequirementOptional || !got.Declared || got.ContentType == nil || *got.ContentType != "text/plain" || got.Description == nil || *got.Description != description || got.Explanation == "" { t.Fatalf("optional declaration metadata was not retained: %+v", got) } if result.Inputs[2].Kind != domain.RequirementRequired || !result.Inputs[2].Declared || result.Inputs[3].Kind != domain.RequirementRequired { t.Fatalf("required declarations or literal use were lost: %+v", result.Inputs) } *result.Inputs[1].Description = "caller mutation" if *snapshot.Inputs[0].Description != description { t.Fatal("result metadata pointer aliases the captured snapshot") } } func TestAnalyzeRequirementsDistinguishesDirectAndOptionalCallerKeys(t *testing.T) { result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{ compileRequirementTemplate(t, "message", `{{.missing}} {{$.present}} {{index . "lookup"}}`), }}, }, map[string]string{"present": ""}, false) if err != nil { t.Fatal(err) } if !result.Complete || len(result.Issues) != 0 { t.Fatalf("plain root and literal lookup syntax should be complete: %+v", result) } wantNames := []string{"lookup", "missing", "present"} if got := requirementVariableNames(result.Variables); !reflect.DeepEqual(got, wantNames) { t.Fatalf("variable names = %v, want %v", got, wantNames) } if result.Variables[0].Kind != domain.RequirementOptional || result.Variables[0].Supplied || result.Variables[0].Explanation == "" { t.Errorf("literal index lookup should be optional and absent: %+v", result.Variables[0]) } if result.Variables[1].Kind != domain.RequirementRequired || result.Variables[1].Supplied { t.Errorf("missing direct variable should be required but not supplied: %+v", result.Variables[1]) } if result.Variables[2].Kind != domain.RequirementRequired || !result.Variables[2].Supplied { t.Errorf("empty supplied direct variable was not detected by presence: %+v", result.Variables[2]) } } func TestAnalyzeRequirementsSeparatesRootAndLocalTemplateValues(t *testing.T) { result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{ compileRequirementTemplate(t, "message", `{{$local := "local"}}{{with .context}}{{$local}} {{.child}} {{$.root}}{{end}}`), }}, }, nil, false) if err != nil { t.Fatal(err) } wantNames := []string{"context", "root"} if got := requirementVariableNames(result.Variables); !reflect.DeepEqual(got, wantNames) { t.Fatalf("root variable names = %v, want %v", got, wantNames) } if result.Variables[0].Kind != domain.RequirementRequired || result.Variables[1].Kind != domain.RequirementConditional || result.Complete { t.Fatalf("with scope and unresolved branch should be conservative: %+v", result) } if len(result.Issues) != 1 || result.Issues[0].VariableName != "root" { t.Fatalf("conditional root use should have one associated limitation: %+v", result.Issues) } } func TestAnalyzeRequirementsMarksBranchAndUnsupportedScopeReferences(t *testing.T) { result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{ compileRequirementTemplate(t, "message", `{{if .enabled}}{{input "maybe"}}{{end}}{{range .items}}{{input "inside"}} {{.local}} {{$.root}}{{end}}`), }}, }, nil, false) if err != nil { t.Fatal(err) } wantInputs := []string{"inside", "maybe"} wantVars := []string{"enabled", "items", "root"} if got := requirementInputNames(result.Inputs); !reflect.DeepEqual(got, wantInputs) { t.Fatalf("input names = %v, want %v", got, wantInputs) } if got := requirementVariableNames(result.Variables); !reflect.DeepEqual(got, wantVars) { t.Fatalf("variable names = %v, want %v", got, wantVars) } if result.Complete || len(result.Issues) < 3 { t.Fatalf("branch/range limitations were not reported: %+v", result) } for _, input := range result.Inputs { if input.Kind != domain.RequirementConditional { t.Errorf("input %q should be conservative conditional: %+v", input.Name, input) } } if result.Variables[0].Kind != domain.RequirementRequired || result.Variables[1].Kind != domain.RequirementRequired || result.Variables[2].Kind != domain.RequirementConditional { t.Errorf("root and loop-scope variables were misclassified: %+v", result.Variables) } } func TestAnalyzeRequirementsExcludesUnusedDefinitionsAndBypassedSession(t *testing.T) { message := compileRequirementTemplate(t, "message", `{{define "unused"}}{{input "unused"}}{{end}}main`) session := compileRequirementTemplate(t, "session", `{{input "session_only"}}`) result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{message}, Session: session}, }, nil, true) if err != nil { t.Fatal(err) } if !result.Complete || len(result.Inputs) != 0 { t.Fatalf("unused definitions or bypassed session were included: %+v", result) } result, err = AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{message}, Session: session}, }, nil, false) if err != nil { t.Fatal(err) } if len(result.Inputs) != 1 || result.Inputs[0].Name != "session_only" || result.Inputs[0].Kind != domain.RequirementRequired { t.Fatalf("applicable session template was not visited: %+v", result) } } func TestAnalyzeRequirementsScansInvokedDefinitionsConservatively(t *testing.T) { message := compileRequirementTemplate(t, "message", `{{define "unused"}}{{input "unused"}}{{end}}{{define "called"}}{{input "called"}} {{.local}}{{end}}main {{template "called" .}}`) result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{message}}, }, nil, false) if err != nil { t.Fatal(err) } if result.Complete || len(result.Inputs) != 1 || result.Inputs[0].Name != "called" || result.Inputs[0].Kind != domain.RequirementConditional || len(result.Variables) != 0 { t.Fatalf("called definition was not scanned conservatively: %+v", result) } if len(result.Issues) < 1 || result.Issues[0].Code != domain.RequirementIssueUnsupportedConstruct { t.Fatalf("named-template limitation was not reported: %+v", result.Issues) } } func TestAnalyzeRequirementsSupportsEmptyTemplatesAndCancellation(t *testing.T) { result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{compileRequirementTemplate(t, "message", "plain text")}}, }, nil, false) if err != nil || !result.Complete || len(result.Inputs) != 0 || len(result.Variables) != 0 { t.Fatalf("input-free template should have a complete empty inventory: %+v, %v", result, err) } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := AnalyzeRequirements(ctx, &domain.PromptDefinition{}, RequirementSnapshot{}, nil, false); !errors.Is(err, context.Canceled) { t.Fatalf("canceled context returned %v, want context.Canceled", err) } } func compileRequirementTemplate(t *testing.T, name, source string) *template.Template { t.Helper() compiled, err := CompileTemplate(name, source) if err != nil { t.Fatalf("compile template: %v", err) } return compiled } func requirementInputNames(inputs []domain.InputRequirement) []string { names := make([]string, len(inputs)) for index, input := range inputs { names[index] = input.Name } return names } func requirementVariableNames(variables []domain.VariableRequirement) []string { names := make([]string, len(variables)) for index, variable := range variables { names[index] = variable.Name } return names } func stringPointerForTest(value string) *string { return &value }