Adds ability to override embedded role templates at town or rig level: - Town: <townRoot>/templates/roles/<role>.md.tmpl - Rig: <rigPath>/templates/roles/<role>.md.tmpl Rig-level overrides take precedence over town-level. This enables customizing polecat (or other role) behavior per-rig without modifying gastown source, following the same 3-tier override pattern as role configs. New APIs: - NewWithOverrides(townRoot, rigPath string) - loads templates with overrides - HasRoleOverride(role string) bool - check if role has override - RoleOverrideCount() int - count of loaded overrides Implements sc-6ghhn Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
460 lines
12 KiB
Go
460 lines
12 KiB
Go
package templates
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNew(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if tmpl == nil {
|
|
t.Fatal("New() returned nil")
|
|
}
|
|
}
|
|
|
|
func TestRenderRole_Mayor(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
data := RoleData{
|
|
Role: "mayor",
|
|
TownRoot: "/test/town",
|
|
TownName: "town",
|
|
WorkDir: "/test/town",
|
|
DefaultBranch: "main",
|
|
MayorSession: "gt-town-mayor",
|
|
DeaconSession: "gt-town-deacon",
|
|
}
|
|
|
|
output, err := tmpl.RenderRole("mayor", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderRole() error = %v", err)
|
|
}
|
|
|
|
// Check for key content
|
|
if !strings.Contains(output, "Mayor Context") {
|
|
t.Error("output missing 'Mayor Context'")
|
|
}
|
|
if !strings.Contains(output, "/test/town") {
|
|
t.Error("output missing town root")
|
|
}
|
|
if !strings.Contains(output, "global coordinator") {
|
|
t.Error("output missing role description")
|
|
}
|
|
}
|
|
|
|
func TestRenderRole_Polecat(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
data := RoleData{
|
|
Role: "polecat",
|
|
RigName: "myrig",
|
|
TownRoot: "/test/town",
|
|
TownName: "town",
|
|
WorkDir: "/test/town/myrig/polecats/TestCat",
|
|
DefaultBranch: "main",
|
|
Polecat: "TestCat",
|
|
MayorSession: "gt-town-mayor",
|
|
DeaconSession: "gt-town-deacon",
|
|
}
|
|
|
|
output, err := tmpl.RenderRole("polecat", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderRole() error = %v", err)
|
|
}
|
|
|
|
// Check for key content
|
|
if !strings.Contains(output, "Polecat Context") {
|
|
t.Error("output missing 'Polecat Context'")
|
|
}
|
|
if !strings.Contains(output, "TestCat") {
|
|
t.Error("output missing polecat name")
|
|
}
|
|
if !strings.Contains(output, "myrig") {
|
|
t.Error("output missing rig name")
|
|
}
|
|
}
|
|
|
|
func TestRenderRole_Deacon(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
data := RoleData{
|
|
Role: "deacon",
|
|
TownRoot: "/test/town",
|
|
TownName: "town",
|
|
WorkDir: "/test/town",
|
|
DefaultBranch: "main",
|
|
MayorSession: "gt-town-mayor",
|
|
DeaconSession: "gt-town-deacon",
|
|
}
|
|
|
|
output, err := tmpl.RenderRole("deacon", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderRole() error = %v", err)
|
|
}
|
|
|
|
// Check for key content
|
|
if !strings.Contains(output, "Deacon Context") {
|
|
t.Error("output missing 'Deacon Context'")
|
|
}
|
|
if !strings.Contains(output, "/test/town") {
|
|
t.Error("output missing town root")
|
|
}
|
|
if !strings.Contains(output, "Patrol Executor") {
|
|
t.Error("output missing role description")
|
|
}
|
|
if !strings.Contains(output, "Startup Protocol: Propulsion") {
|
|
t.Error("output missing startup protocol section")
|
|
}
|
|
if !strings.Contains(output, "mol-deacon-patrol") {
|
|
t.Error("output missing patrol molecule reference")
|
|
}
|
|
}
|
|
|
|
func TestRenderRole_Refinery_DefaultBranch(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
// Test with custom default branch (e.g., "develop")
|
|
data := RoleData{
|
|
Role: "refinery",
|
|
RigName: "myrig",
|
|
TownRoot: "/test/town",
|
|
TownName: "town",
|
|
WorkDir: "/test/town/myrig/refinery/rig",
|
|
DefaultBranch: "develop",
|
|
MayorSession: "gt-town-mayor",
|
|
DeaconSession: "gt-town-deacon",
|
|
}
|
|
|
|
output, err := tmpl.RenderRole("refinery", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderRole() error = %v", err)
|
|
}
|
|
|
|
// Check that the custom default branch is used in git commands
|
|
if !strings.Contains(output, "origin/develop") {
|
|
t.Error("output missing 'origin/develop' - DefaultBranch not being used for rebase")
|
|
}
|
|
if !strings.Contains(output, "git checkout develop") {
|
|
t.Error("output missing 'git checkout develop' - DefaultBranch not being used for checkout")
|
|
}
|
|
if !strings.Contains(output, "git push origin develop") {
|
|
t.Error("output missing 'git push origin develop' - DefaultBranch not being used for push")
|
|
}
|
|
|
|
// Verify it does NOT contain hardcoded "main" in git commands
|
|
// (main may appear in other contexts like "main branch" descriptions, so we check specific patterns)
|
|
if strings.Contains(output, "git rebase origin/main") {
|
|
t.Error("output still contains hardcoded 'git rebase origin/main' - should use DefaultBranch")
|
|
}
|
|
if strings.Contains(output, "git checkout main") {
|
|
t.Error("output still contains hardcoded 'git checkout main' - should use DefaultBranch")
|
|
}
|
|
if strings.Contains(output, "git push origin main") {
|
|
t.Error("output still contains hardcoded 'git push origin main' - should use DefaultBranch")
|
|
}
|
|
}
|
|
|
|
func TestRenderMessage_Spawn(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
data := SpawnData{
|
|
Issue: "gt-123",
|
|
Title: "Test Issue",
|
|
Priority: 1,
|
|
Description: "Test description",
|
|
Branch: "feature/test",
|
|
RigName: "myrig",
|
|
Polecat: "TestCat",
|
|
}
|
|
|
|
output, err := tmpl.RenderMessage("spawn", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderMessage() error = %v", err)
|
|
}
|
|
|
|
// Check for key content
|
|
if !strings.Contains(output, "gt-123") {
|
|
t.Error("output missing issue ID")
|
|
}
|
|
if !strings.Contains(output, "Test Issue") {
|
|
t.Error("output missing issue title")
|
|
}
|
|
}
|
|
|
|
func TestRenderMessage_Nudge(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
data := NudgeData{
|
|
Polecat: "TestCat",
|
|
Reason: "No progress for 30 minutes",
|
|
NudgeCount: 2,
|
|
MaxNudges: 3,
|
|
Issue: "gt-123",
|
|
Status: "in_progress",
|
|
}
|
|
|
|
output, err := tmpl.RenderMessage("nudge", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderMessage() error = %v", err)
|
|
}
|
|
|
|
// Check for key content
|
|
if !strings.Contains(output, "TestCat") {
|
|
t.Error("output missing polecat name")
|
|
}
|
|
if !strings.Contains(output, "2/3") {
|
|
t.Error("output missing nudge count")
|
|
}
|
|
}
|
|
|
|
func TestRoleNames(t *testing.T) {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
names := tmpl.RoleNames()
|
|
expected := []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"}
|
|
|
|
if len(names) != len(expected) {
|
|
t.Errorf("RoleNames() = %v, want %v", names, expected)
|
|
}
|
|
|
|
for i, name := range names {
|
|
if name != expected[i] {
|
|
t.Errorf("RoleNames()[%d] = %q, want %q", i, name, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetAllRoleTemplates(t *testing.T) {
|
|
templates, err := GetAllRoleTemplates()
|
|
if err != nil {
|
|
t.Fatalf("GetAllRoleTemplates() error = %v", err)
|
|
}
|
|
|
|
if len(templates) == 0 {
|
|
t.Fatal("GetAllRoleTemplates() returned empty map")
|
|
}
|
|
|
|
expectedFiles := []string{
|
|
"deacon.md.tmpl",
|
|
"witness.md.tmpl",
|
|
"refinery.md.tmpl",
|
|
"mayor.md.tmpl",
|
|
"polecat.md.tmpl",
|
|
"crew.md.tmpl",
|
|
}
|
|
|
|
for _, file := range expectedFiles {
|
|
content, ok := templates[file]
|
|
if !ok {
|
|
t.Errorf("GetAllRoleTemplates() missing %s", file)
|
|
continue
|
|
}
|
|
if len(content) == 0 {
|
|
t.Errorf("GetAllRoleTemplates()[%s] has empty content", file)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetAllRoleTemplates_ContentValidity(t *testing.T) {
|
|
templates, err := GetAllRoleTemplates()
|
|
if err != nil {
|
|
t.Fatalf("GetAllRoleTemplates() error = %v", err)
|
|
}
|
|
|
|
for name, content := range templates {
|
|
if !strings.HasSuffix(name, ".md.tmpl") {
|
|
t.Errorf("unexpected file %s (should end with .md.tmpl)", name)
|
|
}
|
|
contentStr := string(content)
|
|
if !strings.Contains(contentStr, "Context") {
|
|
t.Errorf("%s doesn't contain 'Context' - may not be a valid role template", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewWithOverrides_NoOverrides(t *testing.T) {
|
|
// When no override paths exist, should work like New()
|
|
tmpl, err := NewWithOverrides("/nonexistent/town", "/nonexistent/rig")
|
|
if err != nil {
|
|
t.Fatalf("NewWithOverrides() error = %v", err)
|
|
}
|
|
if tmpl == nil {
|
|
t.Fatal("NewWithOverrides() returned nil")
|
|
}
|
|
|
|
// Should have no overrides
|
|
if tmpl.RoleOverrideCount() != 0 {
|
|
t.Errorf("RoleOverrideCount() = %d, want 0", tmpl.RoleOverrideCount())
|
|
}
|
|
|
|
// Should still render embedded templates
|
|
data := RoleData{
|
|
Role: "polecat",
|
|
RigName: "myrig",
|
|
Polecat: "TestCat",
|
|
}
|
|
output, err := tmpl.RenderRole("polecat", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderRole() error = %v", err)
|
|
}
|
|
if !strings.Contains(output, "Polecat Context") {
|
|
t.Error("embedded template not rendered correctly")
|
|
}
|
|
}
|
|
|
|
func TestNewWithOverrides_WithOverride(t *testing.T) {
|
|
// Create a temp directory with an override template
|
|
tempDir := t.TempDir()
|
|
overrideDir := tempDir + "/templates/roles"
|
|
if err := os.MkdirAll(overrideDir, 0755); err != nil {
|
|
t.Fatalf("failed to create override dir: %v", err)
|
|
}
|
|
|
|
// Write a simple override template
|
|
overrideContent := `# Custom Polecat Context
|
|
You are polecat {{ .Polecat }} with custom instructions.
|
|
Rig: {{ .RigName }}
|
|
`
|
|
if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(overrideContent), 0644); err != nil {
|
|
t.Fatalf("failed to write override template: %v", err)
|
|
}
|
|
|
|
// Create Templates with the override
|
|
tmpl, err := NewWithOverrides("", tempDir)
|
|
if err != nil {
|
|
t.Fatalf("NewWithOverrides() error = %v", err)
|
|
}
|
|
|
|
// Should have one override
|
|
if tmpl.RoleOverrideCount() != 1 {
|
|
t.Errorf("RoleOverrideCount() = %d, want 1", tmpl.RoleOverrideCount())
|
|
}
|
|
|
|
// Should report polecat as having override
|
|
if !tmpl.HasRoleOverride("polecat") {
|
|
t.Error("HasRoleOverride('polecat') = false, want true")
|
|
}
|
|
if tmpl.HasRoleOverride("mayor") {
|
|
t.Error("HasRoleOverride('mayor') = true, want false")
|
|
}
|
|
|
|
// Render should use override
|
|
data := RoleData{
|
|
Role: "polecat",
|
|
RigName: "myrig",
|
|
Polecat: "TestCat",
|
|
}
|
|
output, err := tmpl.RenderRole("polecat", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderRole() error = %v", err)
|
|
}
|
|
|
|
// Should contain custom content, not embedded
|
|
if !strings.Contains(output, "Custom Polecat Context") {
|
|
t.Error("override template not used - missing 'Custom Polecat Context'")
|
|
}
|
|
if strings.Contains(output, "Idle Polecat Heresy") {
|
|
t.Error("embedded template used instead of override")
|
|
}
|
|
if !strings.Contains(output, "TestCat") {
|
|
t.Error("template variable not expanded")
|
|
}
|
|
}
|
|
|
|
func TestNewWithOverrides_RigOverridesTown(t *testing.T) {
|
|
// Create temp dirs for both town and rig overrides
|
|
townDir := t.TempDir()
|
|
rigDir := t.TempDir()
|
|
|
|
townOverrideDir := townDir + "/templates/roles"
|
|
rigOverrideDir := rigDir + "/templates/roles"
|
|
|
|
if err := os.MkdirAll(townOverrideDir, 0755); err != nil {
|
|
t.Fatalf("failed to create town override dir: %v", err)
|
|
}
|
|
if err := os.MkdirAll(rigOverrideDir, 0755); err != nil {
|
|
t.Fatalf("failed to create rig override dir: %v", err)
|
|
}
|
|
|
|
// Write town override
|
|
townContent := "# Town Polecat\nTown override: {{ .Polecat }}"
|
|
if err := os.WriteFile(townOverrideDir+"/polecat.md.tmpl", []byte(townContent), 0644); err != nil {
|
|
t.Fatalf("failed to write town override: %v", err)
|
|
}
|
|
|
|
// Write rig override (should win)
|
|
rigContent := "# Rig Polecat\nRig override: {{ .Polecat }}"
|
|
if err := os.WriteFile(rigOverrideDir+"/polecat.md.tmpl", []byte(rigContent), 0644); err != nil {
|
|
t.Fatalf("failed to write rig override: %v", err)
|
|
}
|
|
|
|
// Create Templates with both overrides
|
|
tmpl, err := NewWithOverrides(townDir, rigDir)
|
|
if err != nil {
|
|
t.Fatalf("NewWithOverrides() error = %v", err)
|
|
}
|
|
|
|
// Render should use rig override (higher precedence)
|
|
data := RoleData{Polecat: "TestCat"}
|
|
output, err := tmpl.RenderRole("polecat", data)
|
|
if err != nil {
|
|
t.Fatalf("RenderRole() error = %v", err)
|
|
}
|
|
|
|
if !strings.Contains(output, "Rig Polecat") {
|
|
t.Error("rig override not used")
|
|
}
|
|
if strings.Contains(output, "Town Polecat") {
|
|
t.Error("town override used instead of rig override")
|
|
}
|
|
}
|
|
|
|
func TestNewWithOverrides_InvalidTemplate(t *testing.T) {
|
|
// Create a temp directory with an invalid template
|
|
tempDir := t.TempDir()
|
|
overrideDir := tempDir + "/templates/roles"
|
|
if err := os.MkdirAll(overrideDir, 0755); err != nil {
|
|
t.Fatalf("failed to create override dir: %v", err)
|
|
}
|
|
|
|
// Write an invalid template (unclosed action)
|
|
invalidContent := "# Invalid Template\n{{ .Polecat"
|
|
if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(invalidContent), 0644); err != nil {
|
|
t.Fatalf("failed to write invalid template: %v", err)
|
|
}
|
|
|
|
// Should return error for invalid template
|
|
_, err := NewWithOverrides("", tempDir)
|
|
if err == nil {
|
|
t.Error("NewWithOverrides() should fail with invalid template")
|
|
}
|
|
if !strings.Contains(err.Error(), "parsing override template") {
|
|
t.Errorf("error should mention parsing override template: %v", err)
|
|
}
|
|
}
|