Files
promptkit/internal/prompt/requirements_test.go

405 lines
20 KiB
Go

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].Code != domain.RequirementIssueMissingVariable || result.Issues[0].VariableName != "context" {
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) < 2 {
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 TestAnalyzeRequirementsClassifiesKnownConditions(t *testing.T) {
tests := []struct {
name string
condition string
vars map[string]string
wantActive string
wantSkipped string
}{
{name: "string false is truthy", condition: `.flag`, vars: map[string]string{"flag": "false"}, wantActive: "active", wantSkipped: "inactive"},
{name: "empty direct value is false", condition: `.flag`, vars: map[string]string{"flag": ""}, wantActive: "inactive", wantSkipped: "active"},
{name: "simple equality", condition: `eq .mode "active"`, vars: map[string]string{"mode": "active"}, wantActive: "active", wantSkipped: "inactive"},
{name: "simple inequality", condition: `ne .mode "active"`, vars: map[string]string{"mode": "other"}, wantActive: "active", wantSkipped: "inactive"},
{name: "not direct value", condition: `not .flag`, vars: map[string]string{"flag": ""}, wantActive: "active", wantSkipped: "inactive"},
{name: "literal true", condition: `true`, wantActive: "active", wantSkipped: "inactive"},
{name: "literal false", condition: `false`, wantActive: "inactive", wantSkipped: "active"},
{name: "missing index is empty", condition: `index . "toggle"`, wantActive: "inactive", wantSkipped: "active"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{
Role: "user", Content: `{{if ` + test.condition + `}}{{input "active"}}{{else}}{{input "inactive"}}{{end}}`,
}}}
snapshot := RequirementSnapshot{Templates: CompiledTemplates{Messages: []*template.Template{
compileRequirementTemplate(t, "message", definition.Templates[0].Content),
}}}
result, err := AnalyzeRequirements(context.Background(), definition, snapshot, test.vars, false)
if err != nil {
t.Fatal(err)
}
if !result.Complete || len(result.Issues) != 0 {
t.Fatalf("known condition should produce a complete result: %+v", result)
}
inputs := make(map[string]domain.InputRequirement, len(result.Inputs))
for _, input := range result.Inputs {
inputs[input.Name] = input
}
if inputs[test.wantActive].Kind != domain.RequirementRequired || inputs[test.wantSkipped].Kind != domain.RequirementOptional || inputs[test.wantSkipped].Explanation == "" {
t.Fatalf("selected/skipped inputs were misclassified: %+v", result.Inputs)
}
if test.condition == `index . "toggle"` {
if len(result.Variables) != 1 || result.Variables[0].Name != "toggle" || result.Variables[0].Kind != domain.RequirementOptional || result.Variables[0].Supplied {
t.Fatalf("missing index key was not retained as optional: %+v", result.Variables)
}
}
artifacts := map[string]*domain.Artifact{
"active": {Name: "active", Body: []byte("active")},
"inactive": {Name: "inactive", Body: []byte("inactive")},
}
rendered, err := NewGoRenderer().Render(context.Background(), definition, artifacts, test.vars)
if err != nil {
t.Fatal(err)
}
selected := "active"
if inputs[selected].Kind != domain.RequirementRequired {
selected = "inactive"
}
if len(rendered.Messages) != 1 || rendered.Messages[0].Content != selected {
t.Fatalf("analysis and renderer disagree: result=%+v rendered=%+v", result, rendered)
}
})
}
}
func TestAnalyzeRequirementsHandlesWithDotAndNestedReachability(t *testing.T) {
definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{
Role: "user", Content: `{{with .context}}{{input "inside"}} {{.child}} {{$.root}}{{else}}{{input "empty"}}{{end}}`,
}}}
snapshot := RequirementSnapshot{Templates: CompiledTemplates{Messages: []*template.Template{
compileRequirementTemplate(t, "message", definition.Templates[0].Content),
}}}
result, err := AnalyzeRequirements(context.Background(), definition, snapshot, map[string]string{"context": "value", "root": "root"}, false)
if err != nil {
t.Fatal(err)
}
if !result.Complete || len(result.Inputs) != 2 || len(result.Variables) != 2 {
t.Fatalf("known with condition should have a complete inventory: %+v", result)
}
if result.Inputs[0].Name != "empty" || result.Inputs[0].Kind != domain.RequirementOptional ||
result.Inputs[1].Name != "inside" || result.Inputs[1].Kind != domain.RequirementRequired {
t.Fatalf("with body and else reachability is incorrect: %+v", result.Inputs)
}
if result.Variables[0].Name != "context" || result.Variables[0].Kind != domain.RequirementRequired ||
result.Variables[1].Name != "root" || result.Variables[1].Kind != domain.RequirementRequired {
t.Fatalf("with dot was confused with the root caller map: %+v", result.Variables)
}
nested := compileRequirementTemplate(t, "nested", `{{if .outer}}{{if .inner}}{{input "inner"}}{{else}}{{input "inner_else"}}{{end}}{{else}}{{input "outer_else"}}{{end}}`)
nestedResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{nested}},
}, map[string]string{"outer": "yes", "inner": "yes"}, false)
if err != nil {
t.Fatal(err)
}
if !nestedResult.Complete || nestedResult.Inputs[0].Name != "inner" || nestedResult.Inputs[0].Kind != domain.RequirementRequired ||
nestedResult.Inputs[1].Name != "inner_else" || nestedResult.Inputs[1].Kind != domain.RequirementOptional ||
nestedResult.Inputs[2].Name != "outer_else" || nestedResult.Inputs[2].Kind != domain.RequirementOptional {
t.Fatalf("known nested conditions lost selected or inactive reachability: %+v", nestedResult)
}
}
func TestAnalyzeRequirementsReportsUnresolvedDecisionAndContentConditions(t *testing.T) {
missing := compileRequirementTemplate(t, "missing", `{{if eq .mode "active"}}{{input "yes"}}{{else}}{{input "no"}}{{end}}`)
missingResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{missing}},
}, nil, false)
if err != nil {
t.Fatal(err)
}
if missingResult.Complete || len(missingResult.Issues) != 1 || missingResult.Issues[0].Code != domain.RequirementIssueMissingVariable ||
missingResult.Issues[0].VariableName != "mode" || missingResult.Inputs[0].Kind != domain.RequirementConditional ||
missingResult.Inputs[1].Kind != domain.RequirementConditional {
t.Fatalf("missing decision value did not produce conditional dependencies: %+v", missingResult)
}
content := compileRequirementTemplate(t, "content", `{{if input "selector"}}{{input "yes"}}{{else}}{{input "no"}}{{end}}`)
contentResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{content}},
}, nil, false)
if err != nil {
t.Fatal(err)
}
contentInputs := make(map[string]domain.InputRequirement, len(contentResult.Inputs))
for _, input := range contentResult.Inputs {
contentInputs[input.Name] = input
}
if contentResult.Complete || len(contentResult.Issues) != 1 || contentResult.Issues[0].Code != domain.RequirementIssueInputContent ||
contentResult.Issues[0].InputName != "selector" || contentInputs["selector"].Kind != domain.RequirementRequired ||
contentInputs["yes"].Kind != domain.RequirementConditional || contentInputs["no"].Kind != domain.RequirementConditional {
t.Fatalf("content-dependent condition did not preserve known dependencies: %+v", contentResult)
}
textOnly := compileRequirementTemplate(t, "text-only", `{{if .missing}}plain text{{else}}also plain text{{end}}`)
textResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{textOnly}},
}, nil, false)
if err != nil || !textResult.Complete || len(textResult.Issues) != 0 {
t.Fatalf("unresolved text-only branch should not affect completeness: %+v, %v", textResult, err)
}
}
func TestAnalyzeRequirementsRetainsInactiveEntriesAndPromotesActiveUses(t *testing.T) {
description := "declared for a reason"
definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{
Role: "user", Content: `{{if false}}{{input "shared"}} {{.shared_var}} {{input "declared"}}{{input .dynamic}}{{and .left .right}}{{end}}{{input "shared"}} {{.shared_var}}`,
}}}
snapshot := RequirementSnapshot{
Inputs: []InputMetadata{{Name: "declared", Required: true, Description: &description}},
Templates: CompiledTemplates{Messages: []*template.Template{
compileRequirementTemplate(t, "message", definition.Templates[0].Content),
}},
}
result, err := AnalyzeRequirements(context.Background(), definition, snapshot, map[string]string{"shared_var": "present"}, false)
if err != nil {
t.Fatal(err)
}
if !result.Complete || len(result.Issues) != 0 {
t.Fatalf("skipped unsupported expressions introduced uncertainty: %+v", result)
}
inputs := make(map[string]domain.InputRequirement, len(result.Inputs))
for _, input := range result.Inputs {
inputs[input.Name] = input
}
if len(inputs) != 2 || inputs["shared"].Kind != domain.RequirementRequired || inputs["shared"].Explanation != "" ||
inputs["declared"].Kind != domain.RequirementRequired || inputs["declared"].Description == nil || *inputs["declared"].Description != description {
t.Fatalf("active or declared requirements did not override inactivity: %+v", result.Inputs)
}
variables := make(map[string]domain.VariableRequirement, len(result.Variables))
for _, variable := range result.Variables {
variables[variable.Name] = variable
}
for _, name := range []string{"dynamic", "left", "right", "shared_var"} {
if _, ok := variables[name]; !ok {
t.Errorf("inactive caller key %q was not inventoried", name)
}
}
if variables["shared_var"].Kind != domain.RequirementRequired || variables["shared_var"].Explanation != "" ||
variables["dynamic"].Kind != domain.RequirementOptional || variables["dynamic"].Explanation == "" {
t.Fatalf("inactive caller metadata was not promoted or explained: %+v", result.Variables)
}
allOptional := compileRequirementTemplate(t, "optional", `{{index . "lookup"}}`)
optionalResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Inputs: []InputMetadata{{Name: "optional"}}, Templates: CompiledTemplates{Messages: []*template.Template{allOptional}},
}, nil, false)
if err != nil || !optionalResult.Complete || len(optionalResult.Issues) != 0 || optionalResult.Inputs[0].Kind != domain.RequirementOptional ||
optionalResult.Variables[0].Kind != domain.RequirementOptional {
t.Fatalf("all-optional inventory should be complete without artifacts: %+v, %v", optionalResult, err)
}
}
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
}