ajhahn.de
← eeco
Go 157 lines
package cockpit

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// loadHandover reads the real shipped handover source (the
// internal/playbooks data file) and unmarshals it into a Playbook. Reading
// the file directly, rather than importing internal/playbooks, keeps the
// cockpit package free of the import cycle while still exercising the real
// source through the machinery.
func loadHandover(t *testing.T) Playbook {
	t.Helper()
	b, err := os.ReadFile(filepath.Join("..", "playbooks", "data", "handover.json"))
	if err != nil {
		t.Fatalf("read handover source: %v", err)
	}
	var pb Playbook
	if err := json.Unmarshal(b, &pb); err != nil {
		t.Fatalf("parse handover source: %v", err)
	}
	return pb
}

func TestClaudeRender_Structure(t *testing.T) {
	pb := loadHandover(t)
	out, err := claudeRenderer{}.Render(pb)
	if err != nil {
		t.Fatalf("Render: %v", err)
	}
	got := string(out)

	if !strings.HasPrefix(got, "---\n") {
		t.Errorf("output does not open with frontmatter fence:\n%s", got)
	}
	if !strings.HasSuffix(got, "\n") {
		t.Error("output does not end with a trailing newline")
	}
	for _, want := range []string{
		"name: handover\n",
		"description: " + pb.Description + "\n",
		"allowed-tools: ",
		"# Handover\n",
		"## Step 0 — ",
		"## Step 1 — ",
		"## Step 2 — ",
		"## Step 3 — ",
		"## Step 4 — ",
		"## Output\n",
	} {
		if !strings.Contains(got, want) {
			t.Errorf("rendered SKILL.md missing %q:\n%s", want, got)
		}
	}

	// The allowed-tools line carries the composed allowlist, single-line.
	allowLine := frontmatterValue(t, out, "allowed-tools")
	for _, want := range []string{
		"Read", "Write", "Grep", "Glob", "Agent", "Task", "AskUserQuestion",
		"Bash(git status:*)", "Bash(git stash list:*)", "Bash(date:*)", "Bash(head:*)",
	} {
		if !strings.Contains(allowLine, want) {
			t.Errorf("allowed-tools missing %q: %s", want, allowLine)
		}
	}

	// The safety warning is derived from Intent and must name every
	// forbidden phrase verbatim, so the prose is provably in sync with the
	// gate's denylist.
	for _, phrase := range pb.Intent.Forbidden {
		if !strings.Contains(got, phrase) {
			t.Errorf("safety warning missing forbidden phrase %q", phrase)
		}
	}
}

// frontmatterValue returns the value of a single-line frontmatter key.
func frontmatterValue(t *testing.T, content []byte, key string) string {
	t.Helper()
	for _, line := range strings.Split(string(content), "\n") {
		if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == key {
			return strings.TrimSpace(v)
		}
	}
	t.Fatalf("frontmatter key %q not found", key)
	return ""
}

func TestClaudeRender_Deterministic(t *testing.T) {
	pb := loadHandover(t)
	a, err := claudeRenderer{}.Render(pb)
	if err != nil {
		t.Fatal(err)
	}
	b, err := claudeRenderer{}.Render(pb)
	if err != nil {
		t.Fatal(err)
	}
	if string(a) != string(b) {
		t.Error("Render is not deterministic for the same Playbook")
	}
}

func TestClaudeRender_RejectsMultilineFrontmatter(t *testing.T) {
	pb := loadHandover(t)
	pb.Description = "line one\nline two"
	r := claudeRenderer{}
	if _, err := r.Render(pb); err == nil {
		t.Error("expected an error for a multi-line description")
	}
}

func TestComposeAllowedTools_OrderAndSpelling(t *testing.T) {
	pb := Playbook{
		Capabilities: []Capability{
			{Kind: "tool", Name: "Read"},
			{Kind: "bash", Verb: "git status", Scope: "*"},
			{Kind: "bash", Verb: "date"}, // default scope "*"
		},
	}
	got := composeAllowedTools(pb)
	want := []string{"Read", "Bash(git status:*)", "Bash(date:*)"}
	if len(got) != len(want) {
		t.Fatalf("got %v, want %v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("entry %d = %q, want %q", i, got[i], want[i])
		}
	}
}

func TestDeriveTitle(t *testing.T) {
	cases := map[string]string{
		"handover":  "Handover",
		"doc-drift": "Doc Drift",
		"bug_sweep": "Bug Sweep",
	}
	for in, want := range cases {
		if got := deriveTitle(in); got != want {
			t.Errorf("deriveTitle(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestClaudeRelPath(t *testing.T) {
	got := claudeRenderer{}.RelPath(Playbook{Name: "handover"})
	want := ".claude/skills/handover/SKILL.md"
	if got != want {
		t.Errorf("RelPath = %q, want %q", got, want)
	}
}