hugin/cli/internal/reconcile/reconcile_test.go
2026-10-27 08:33:21 +01:00

328 lines
10 KiB
Go

package reconcile
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"hugin/cli/internal/bundle"
"hugin/cli/internal/lock"
)
// Goldene Bundles, die die Server-Tests erzeugen (contract/examples/bundles).
const examples = "../../../contract/examples/bundles"
func load(t *testing.T, name string) *bundle.Bundle {
t.Helper()
b, err := bundle.Read(filepath.Join(examples, name))
if err != nil {
t.Fatal(err)
}
return b
}
func sync(t *testing.T, repo, name string, opt Options) *Plan {
t.Helper()
old, err := lock.Load(repo)
if err != nil {
t.Fatal(err)
}
plan, err := MakePlan(repo, load(t, name), old, opt)
if err != nil {
t.Fatal(err)
}
if !plan.Open() {
if err := plan.Run(repo); err != nil {
t.Fatal(err)
}
}
return plan
}
func read(t *testing.T, repo, rel string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(repo, rel))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func write(t *testing.T, repo, rel, content string) {
t.Helper()
abs := filepath.Join(repo, rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func exists(repo, rel string) bool {
_, err := os.Stat(filepath.Join(repo, rel))
return err == nil
}
func jsonValue(t *testing.T, repo, rel string) map[string]any {
t.Helper()
var value map[string]any
if err := json.Unmarshal([]byte(read(t, repo, rel)), &value); err != nil {
t.Fatalf("%s: %v", rel, err)
}
return value
}
func TestFirstInstallInEmptyRepo(t *testing.T) {
repo := t.TempDir()
plan := sync(t, repo, "first-install", Options{})
if len(plan.Findings) > 0 {
t.Fatalf("Befunde: %v", plan.Findings)
}
for _, rel := range []string{
".claude/skills/pub-munin-nutzen/SKILL.md",
".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
".hugin/rules/pub-hausregeln.md",
"CLAUDE.md", ".mcp.json", "opencode.json", "hugin.lock",
} {
if !exists(repo, rel) {
t.Errorf("%s fehlt", rel)
}
}
claudeMd := read(t, repo, "CLAUDE.md")
if !strings.Contains(claudeMd, "<!-- hugin:begin") || !strings.Contains(claudeMd, "@.hugin/rules/pub-hausregeln.md\n<!-- hugin:end -->") {
t.Errorf("CLAUDE.md ohne Block:\n%s", claudeMd)
}
mcp := jsonValue(t, repo, ".mcp.json")["mcpServers"].(map[string]any)["munin"].(map[string]any)
if mcp["type"] != "http" {
t.Errorf(".mcp.json: %v", mcp)
}
oc := jsonValue(t, repo, "opencode.json")
if oc["instructions"].([]any)[0] != ".hugin/rules/*.md" {
t.Errorf("opencode.json: %v", oc)
}
// Ein zweiter sync mit demselben Bundle ändert nichts
if plan := sync(t, repo, "first-install", Options{}); plan.Changed() {
t.Errorf("zweiter sync ändert etwas: schreiben %v, löschen %v", plan.Write, plan.Delete)
}
}
func TestUpdateCleansUp(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
plan := sync(t, repo, "update", Options{})
if len(plan.Findings) > 0 {
t.Fatalf("Befunde: %v", plan.Findings)
}
for _, remove := range []string{
".claude/skills/pub-munin-nutzen/referenz",
".hugin/rules/pub-hausregeln.md",
".hugin",
"CLAUDE.md", // von Hugin angelegt und jetzt leer
} {
if exists(repo, remove) {
t.Errorf("%s sollte entfernt sein", remove)
}
}
if !strings.Contains(read(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md"), "pub-munin-nutzen 1.5.0") {
t.Error("SKILL.md nicht aktualisiert")
}
oc := jsonValue(t, repo, "opencode.json")
if _, ok := oc["mcp"]; !ok {
t.Error("MCP-Eintrag fehlt")
}
if _, ok := oc["instructions"]; ok {
t.Errorf("leere instructions nicht entfernt: %v", oc["instructions"])
}
l, _ := lock.Load(repo)
if _, ok := l.Packages["pub-hausregeln"]; ok {
t.Error("pub-hausregeln noch in hugin.lock")
}
if l.Packages["pub-munin-nutzen"].Version != "1.5.0" {
t.Errorf("Version in hugin.lock: %v", l.Packages["pub-munin-nutzen"])
}
}
func TestTeamFilesArePreserved(t *testing.T) {
repo := t.TempDir()
write(t, repo, "CLAUDE.md", "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n")
write(t, repo, "opencode.json", "{\n // unser Modell\n \"model\": \"anthropic/claude-sonnet-5-5\",\n \"instructions\": [\"docs/stil.md\"]\n}\n")
sync(t, repo, "first-install", Options{})
claudeMd := read(t, repo, "CLAUDE.md")
if !strings.HasPrefix(claudeMd, "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n\r\n<!-- hugin:begin") {
t.Errorf("CLAUDE.md:\n%q", claudeMd)
}
if strings.Contains(strings.ReplaceAll(claudeMd, "\r\n", ""), "\n") {
t.Error("CLAUDE.md: Zeilenenden gemischt")
}
oc := read(t, repo, "opencode.json")
if !strings.Contains(oc, "// unser Modell") || !strings.Contains(oc, `"docs/stil.md"`) {
t.Errorf("opencode.json verändert:\n%s", oc)
}
sync(t, repo, "update", Options{})
if got := read(t, repo, "CLAUDE.md"); got != "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n" {
t.Errorf("CLAUDE.md nach Abbestellen:\n%q", got)
}
oc = read(t, repo, "opencode.json")
if !strings.Contains(oc, `"docs/stil.md"`) || strings.Contains(oc, ".hugin/rules") {
t.Errorf("opencode.json nach Abbestellen:\n%s", oc)
}
}
func TestManualChangeIsDetected(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
skill := ".claude/skills/pub-munin-nutzen/SKILL.md"
write(t, repo, skill, "von Hand\n")
plan := sync(t, repo, "update", Options{})
if !plan.Open() || len(plan.Findings) != 1 || plan.Findings[0].Kind != ManualChange || plan.Findings[0].Location != skill {
t.Fatalf("Befunde: %v", plan.Findings)
}
if read(t, repo, skill) != "von Hand\n" {
t.Error("Datei trotz Befund geändert")
}
if !exists(repo, ".hugin/rules/pub-hausregeln.md") {
t.Error("andere Dateien trotz Befund geändert")
}
sync(t, repo, "update", Options{Overwrite: true})
if !strings.Contains(read(t, repo, skill), "1.5.0") {
t.Error("--overwrite setzt nicht zurück")
}
}
func TestManualChangeInBlock(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
text := strings.Replace(read(t, repo, "CLAUDE.md"), "@.hugin", "@extra.md\n@.hugin", 1)
write(t, repo, "CLAUDE.md", text)
if plan := sync(t, repo, "first-install", Options{}); len(plan.Findings) != 1 || plan.Findings[0].Location != "CLAUDE.md#hugin" {
t.Fatalf("Befunde: %v", plan.Findings)
}
}
func TestForeignMcpServerIsConflict(t *testing.T) {
repo := t.TempDir()
write(t, repo, ".mcp.json", `{"mcpServers": {"munin": {"type": "http", "url": "https://anders"}}}`)
plan := sync(t, repo, "first-install", Options{})
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
t.Fatalf("Befunde: %v", plan.Findings)
}
if exists(repo, "hugin.lock") {
t.Error("trotz Konflikt geschrieben")
}
}
func TestForeignFileIsConflict(t *testing.T) {
repo := t.TempDir()
write(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md", "eigener Skill\n")
plan := sync(t, repo, "first-install", Options{})
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
t.Fatalf("Befunde: %v", plan.Findings)
}
}
func TestSymlinkInRepoIsRejected(t *testing.T) {
repo := t.TempDir()
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(repo, ".claude")); err != nil {
t.Fatal(err)
}
if _, err := MakePlan(repo, load(t, "first-install"), lock.New(), Options{}); err == nil {
t.Fatal("Symlink nicht erkannt")
}
}
func TestBlockSet(t *testing.T) {
tests := []struct{ name, text, content, expected string }{
{"leere Datei", "", "a\n", beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
{"anhängen", "x\n", "a\n", "x\n\n" + beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
{"replace", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\ny\n", "a\nb\n",
"x\n\n" + beginMarker("hugin") + "\na\nb\n" + endMarker("hugin") + "\ny\n"},
{"remove", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", "x\n"},
{"entfernen ohne Block", "x\n", "", "x\n"},
{"nur Block entfernen", beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setBlock(tt.text, "hugin", tt.content); got != tt.expected {
t.Errorf("\n got %q\nwant %q", got, tt.expected)
}
})
}
}
func TestCommandsAndAgents(t *testing.T) {
repo := t.TempDir()
if plan := sync(t, repo, "commands-and-agents", Options{}); len(plan.Findings) > 0 {
t.Fatalf("Befunde: %v", plan.Findings)
}
for _, rel := range []string{
".claude/commands/pub-release-notiz.md", ".opencode/commands/pub-release-notiz.md",
".claude/agents/pub-code-review.md", ".opencode/agents/pub-code-review.md",
} {
if !exists(repo, rel) {
t.Errorf("%s fehlt", rel)
}
}
if !strings.Contains(read(t, repo, ".opencode/commands/pub-release-notiz.md"), "Version $1, gedacht für $2") {
t.Error("Platzhalter für OpenCode nicht übersetzt")
}
}
func TestEmptyBundleCleansUpEverything(t *testing.T) {
repo := t.TempDir()
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
sync(t, repo, "first-install", Options{})
old, _ := lock.Load(repo)
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
if err != nil || len(plan.Findings) > 0 {
t.Fatalf("%v %v", err, plan.Findings)
}
if err := plan.Run(repo); err != nil {
t.Fatal(err)
}
for _, remove := range []string{".claude", ".hugin", "CLAUDE.md", ".mcp.json"} {
if exists(repo, remove) {
t.Errorf("%s ist noch da", remove)
}
}
if got := jsonValue(t, repo, "opencode.json"); len(got) != 1 || got["model"] != "x" {
t.Errorf("opencode.json: %v", got)
}
}
func TestNewOpencodeJsonHasSchemaAndIsCleanedUp(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
if got := jsonValue(t, repo, "opencode.json")["$schema"]; got != OpenCodeSchema {
t.Errorf("$schema in neuer opencode.json: %v", got)
}
// Von hugin angelegt und nur noch $schema übrig: wird wieder entfernt
old, _ := lock.Load(repo)
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
if err != nil || len(plan.Findings) > 0 {
t.Fatalf("%v %v", err, plan.Findings)
}
if err := plan.Run(repo); err != nil {
t.Fatal(err)
}
if exists(repo, "opencode.json") {
t.Errorf("opencode.json ist noch da:\n%s", read(t, repo, "opencode.json"))
}
}
func TestExistingOpencodeJsonGetsNoSchema(t *testing.T) {
repo := t.TempDir()
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
sync(t, repo, "first-install", Options{})
if _, ok := jsonValue(t, repo, "opencode.json")["$schema"]; ok {
t.Error("hugin schreibt $schema nur in Dateien, die es selbst anlegt")
}
}