Refactor: enforce canonical messages/output YAML, normalize content_file, and expand fixture-based validation tests

This commit is contained in:
2026-05-05 10:35:39 -05:00
parent 7fffdaede3
commit f6692dd4bb
27 changed files with 416 additions and 249 deletions

View File

@@ -3,104 +3,143 @@ package promptdef
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestFilesystemRepository_GetPromptDefinition(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "promptdef_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
testDataDir := "testdata"
files, err := os.ReadDir(testDataDir)
if err != nil {
t.Fatalf("failed to read testdata: %v", err)
}
for _, f := range files {
src := filepath.Join(testDataDir, f.Name())
dst := filepath.Join(tmpDir, f.Name())
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, data, 0644); err != nil {
t.Fatal(err)
}
tmpDir := t.TempDir()
if err := copyTree("testdata", tmpDir); err != nil {
t.Fatalf("failed to copy testdata: %v", err)
}
repo := NewFilesystemRepository(tmpDir)
ctx := context.Background()
t.Run("valid prompt definition", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "test-profile", "")
t.Run("valid inline prompt", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "valid-inline", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p == nil || p.ID != "test-profile" {
t.Errorf("expected prompt definition test-profile, got %v", p)
if p.ID != "valid-inline" {
t.Fatalf("unexpected id: %q", p.ID)
}
if p.Version != "1.0.0" {
t.Fatalf("expected version 1.0.0, got %q", p.Version)
}
if len(p.Inputs) != 2 || p.Inputs[0].Name != "transcript" || p.Inputs[1].Name != "glossary" {
t.Fatalf("unexpected inputs: %#v", p.Inputs)
}
if len(p.Templates) != 2 {
t.Fatalf("expected 2 templates, got %d", len(p.Templates))
}
if p.Templates[0].Role != "system" || p.Templates[1].Role != "user" {
t.Fatalf("unexpected template roles: %#v", p.Templates)
t.Fatalf("unexpected version: %q", p.Version)
}
if p.OutputFormat != domain.FormatMarkdown {
t.Fatalf("expected output format markdown, got %q", p.OutputFormat)
t.Fatalf("unexpected output format: %q", p.OutputFormat)
}
if p.Validation.ValidationMode != domain.ValidationBasic {
t.Fatalf("expected validation mode basic, got %q", p.Validation.ValidationMode)
t.Fatalf("unexpected validation mode: %q", p.Validation.ValidationMode)
}
if p.DefaultProfile != "test-exec" {
t.Fatalf("expected default profile test-exec, got %q", p.DefaultProfile)
if len(p.Templates) != 2 {
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
}
})
t.Run("invalid YAML", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "invalid_yaml", "")
if !errors.Is(err, ErrInvalidYAML) {
t.Errorf("expected ErrInvalidYAML, got %v", err)
t.Run("valid file-backed prompt", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "valid-file-backed", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(p.Templates) != 2 {
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
}
if !strings.Contains(p.Templates[1].Content, "{{input \"transcript\"}}") {
t.Fatalf("expected content_file template body to be loaded, got %q", p.Templates[1].Content)
}
if p.Templates[1].ContentFile == "" {
t.Fatal("expected ContentFile source metadata to be preserved")
}
if !filepath.IsAbs(p.Templates[1].ContentFile) {
t.Fatalf("expected resolved content_file path to be absolute, got %q", p.Templates[1].ContentFile)
}
})
t.Run("missing ID", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "missing-id", "")
t.Run("prompt with default_profile", func(t *testing.T) {
p, err := repo.GetPromptDefinition(ctx, "with-default-profile", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if p.DefaultProfile != "local-default" {
t.Fatalf("unexpected default profile: %q", p.DefaultProfile)
}
})
t.Run("version lookup", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "valid-inline", "9.9.9")
if !errors.Is(err, ErrPromptDefinitionNotFound) {
t.Errorf("expected ErrPromptDefinitionNotFound for profile with missing ID, got %v", err)
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
}
})
t.Run("no templates", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "no-templates", "")
if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Errorf("expected ErrInvalidPromptDefinition for profile with no templates, got %v", err)
}
})
cases := []struct {
name string
id string
targetErr error
errSubstrs []string
}{
{name: "invalid YAML", id: "invalid_yaml", targetErr: ErrInvalidYAML},
{name: "missing id", id: "missing_id", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"id is required"}},
{name: "no messages", id: "no_messages", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"at least one message is required"}},
{name: "both content and content_file", id: "both_content_and_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
{name: "neither content nor content_file", id: "neither_content_nor_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
{name: "missing content_file", id: "missing_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"failed to read content_file"}},
{name: "duplicate input names", id: "duplicate_input_names", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"duplicate input name"}},
{name: "invalid validation mode", id: "invalid_validation_mode", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"invalid validation mode"}},
{name: "json_schema without schema_path", id: "json_schema_without_schema_path", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"schema_path"}},
}
t.Run("json schema mode missing schema path", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "json-schema-missing-path", "")
if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Errorf("expected ErrInvalidPromptDefinition for json_schema profile without schema_path, got %v", err)
}
})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, tc.id, "")
if !errors.Is(err, tc.targetErr) {
t.Fatalf("expected %v, got %v", tc.targetErr, err)
}
for _, sub := range tc.errSubstrs {
if !strings.Contains(err.Error(), sub) {
t.Fatalf("expected error to contain %q, got %v", sub, err)
}
}
})
}
t.Run("prompt definition not found", func(t *testing.T) {
_, err := repo.GetPromptDefinition(ctx, "unknown", "")
_, err := repo.GetPromptDefinition(ctx, "does-not-exist", "")
if !errors.Is(err, ErrPromptDefinitionNotFound) {
t.Errorf("expected ErrPromptDefinitionNotFound, got %v", err)
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
}
})
}
func copyTree(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
}