7898dd939f
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 9m33s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 9m55s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 10m32s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 11m0s
- Add gopkg.in/yaml.v3 dependency (approved in CONVENTIONS.md) - Update parsePersona to detect format by file extension - Support both .yaml and .yml extensions (case-insensitive) - Convert built-in personas to YAML format - Add comprehensive tests for YAML parsing - Update README with YAML examples and documentation YAML provides cleaner multi-line strings via literal block scalars and supports comments, making persona definitions more readable. JSON remains supported for backwards compatibility. Closes #57
452 lines
10 KiB
Go
452 lines
10 KiB
Go
package review
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadBuiltinPersona(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
personaName string
|
|
wantErr bool
|
|
wantDisplay string
|
|
}{
|
|
{
|
|
name: "security persona",
|
|
personaName: "security",
|
|
wantErr: false,
|
|
wantDisplay: "Security Specialist",
|
|
},
|
|
{
|
|
name: "architect persona",
|
|
personaName: "architect",
|
|
wantErr: false,
|
|
wantDisplay: "Software Architect",
|
|
},
|
|
{
|
|
name: "docs persona",
|
|
personaName: "docs",
|
|
wantErr: false,
|
|
wantDisplay: "Documentation Reviewer",
|
|
},
|
|
{
|
|
name: "unknown persona",
|
|
personaName: "nonexistent",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p, err := LoadBuiltinPersona(tt.personaName)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if p.Name != tt.personaName {
|
|
t.Errorf("Name = %q, want %q", p.Name, tt.personaName)
|
|
}
|
|
if p.DisplayName != tt.wantDisplay {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, tt.wantDisplay)
|
|
}
|
|
if p.Identity == "" {
|
|
t.Error("Identity should not be empty")
|
|
}
|
|
if len(p.Focus) == 0 {
|
|
t.Error("Focus should not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBuiltinPersonas(t *testing.T) {
|
|
names := ListBuiltinPersonas()
|
|
if len(names) == 0 {
|
|
t.Fatal("expected at least one built-in persona")
|
|
}
|
|
|
|
// Check for expected personas
|
|
expected := map[string]bool{"security": false, "architect": false, "docs": false}
|
|
for _, name := range names {
|
|
if _, ok := expected[name]; ok {
|
|
expected[name] = true
|
|
}
|
|
}
|
|
for name, found := range expected {
|
|
if !found {
|
|
t.Errorf("expected built-in persona %q not found", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromYAMLFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.yaml")
|
|
|
|
content := `# Test persona
|
|
name: test
|
|
display_name: Test Persona
|
|
identity: |
|
|
You are a test persona.
|
|
Multi-line identity works.
|
|
focus:
|
|
- testing
|
|
- validation
|
|
ignore:
|
|
- nothing
|
|
severity:
|
|
major: Big problems
|
|
minor: Small problems
|
|
nit: Tiny problems
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test Persona" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
|
}
|
|
if len(p.Focus) != 2 {
|
|
t.Errorf("Focus len = %d, want 2", len(p.Focus))
|
|
}
|
|
if !strings.Contains(p.Identity, "Multi-line") {
|
|
t.Error("Identity should contain multi-line content")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromYMLFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.yml")
|
|
|
|
content := `name: test
|
|
display_name: Test YML
|
|
identity: Test identity
|
|
focus:
|
|
- testing
|
|
ignore: []
|
|
severity:
|
|
major: Big
|
|
minor: Small
|
|
nit: Tiny
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test YML" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test YML")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromJSONFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.json")
|
|
|
|
content := `{
|
|
"name": "test",
|
|
"display_name": "Test Persona",
|
|
"identity": "You are a test persona.\nMulti-line identity works.",
|
|
"focus": ["testing", "validation"],
|
|
"ignore": ["nothing"],
|
|
"severity": {
|
|
"major": "Big problems",
|
|
"minor": "Small problems",
|
|
"nit": "Tiny problems"
|
|
}
|
|
}`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test Persona" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
|
}
|
|
if len(p.Focus) != 2 {
|
|
t.Errorf("Focus len = %d, want 2", len(p.Focus))
|
|
}
|
|
if !strings.Contains(p.Identity, "Multi-line") {
|
|
t.Error("Identity should contain multi-line content")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
ext string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing name yaml",
|
|
content: "identity: test\n",
|
|
ext: ".yaml",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "missing identity yaml",
|
|
content: "name: test\n",
|
|
ext: ".yaml",
|
|
wantErr: "identity is required",
|
|
},
|
|
{
|
|
name: "missing name json",
|
|
content: `{"identity": "test"}`,
|
|
ext: ".json",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "missing identity json",
|
|
content: `{"name": "test"}`,
|
|
ext: ".json",
|
|
wantErr: "identity is required",
|
|
},
|
|
{
|
|
name: "display_name defaults to name",
|
|
content: "name: test\nidentity: test identity\n",
|
|
ext: ".yaml",
|
|
// No error expected - should succeed
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test"+tt.ext)
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Errorf("expected error containing %q, got nil", tt.wantErr)
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Check display_name defaulting
|
|
if p.DisplayName == "" {
|
|
t.Error("DisplayName should default to Name")
|
|
}
|
|
if p.DisplayName != p.Name {
|
|
t.Errorf("DisplayName should default to Name, got %q", p.DisplayName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFileNotFound(t *testing.T) {
|
|
_, err := LoadPersona("/nonexistent/path/persona.yaml")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaInvalidYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "invalid.yaml")
|
|
if err := os.WriteFile(path, []byte("not valid yaml:\n - [broken"), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for invalid YAML")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaInvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "invalid.json")
|
|
if err := os.WriteFile(path, []byte("not valid json {"), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaCaseInsensitiveExtension(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ext string
|
|
}{
|
|
{"lowercase yaml", ".yaml"},
|
|
{"uppercase YAML", ".YAML"},
|
|
{"mixed case Yaml", ".Yaml"},
|
|
{"lowercase yml", ".yml"},
|
|
{"uppercase YML", ".YML"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test"+tt.ext)
|
|
content := "name: test\nidentity: test identity\n"
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed for extension %s: %v", tt.ext, err)
|
|
}
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCapitalizeFirst(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"hello", "Hello"},
|
|
{"Hello", "Hello"},
|
|
{"HELLO", "HELLO"},
|
|
{"a", "A"},
|
|
{"", ""},
|
|
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
|
|
{"über", "Über"}, // German umlaut
|
|
{"élève", "Élève"}, // French accent
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := CapitalizeFirst(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("CapitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBuiltinPersonasReturnsEmptySlice(t *testing.T) {
|
|
// ListBuiltinPersonas should return an empty slice (not nil) on error.
|
|
// We can't easily test the error case, but we can verify the success case
|
|
// returns a proper slice.
|
|
names := ListBuiltinPersonas()
|
|
if names == nil {
|
|
t.Error("ListBuiltinPersonas should return empty slice, not nil")
|
|
}
|
|
}
|
|
|
|
func TestYAMLMultilineStrings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "multiline.yaml")
|
|
|
|
// Test literal block scalar (|) which preserves newlines
|
|
content := `name: multiline
|
|
display_name: Multiline Test
|
|
identity: |
|
|
First line.
|
|
Second line.
|
|
Third line.
|
|
focus:
|
|
- item one
|
|
ignore: []
|
|
severity:
|
|
major: Major issue
|
|
minor: Minor issue
|
|
nit: Nit
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
// Literal block scalar preserves newlines
|
|
if !strings.Contains(p.Identity, "\n") {
|
|
t.Error("Identity should contain newlines from literal block scalar")
|
|
}
|
|
if !strings.Contains(p.Identity, "Second line") {
|
|
t.Error("Identity should contain 'Second line'")
|
|
}
|
|
}
|
|
|
|
func TestYAMLComments(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "comments.yaml")
|
|
|
|
content := `# This is a comment
|
|
name: commented # inline comment
|
|
display_name: Commented Persona
|
|
# Another comment
|
|
identity: Test identity
|
|
focus:
|
|
- item # comment after item
|
|
ignore: []
|
|
severity:
|
|
major: Major
|
|
minor: Minor
|
|
nit: Nit
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
// Comments should be ignored
|
|
if p.Name != "commented" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "commented")
|
|
}
|
|
if p.Focus[0] != "item" {
|
|
t.Errorf("Focus[0] = %q, want %q", p.Focus[0], "item")
|
|
}
|
|
}
|