214 lines
9.4 KiB
Go
214 lines
9.4 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].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
|
|
}
|