picoclaw/pkg/evolution/apply_test.go

308 lines
10 KiB
Go

package evolution_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func TestApplier_CreateDraftWritesSkillFile(t *testing.T) {
workspace := t.TempDir()
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-1",
WorkspaceID: workspace,
SourceRecordID: "rule-1",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindCreate,
HumanSummary: "weather helper",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
Status: evolution.DraftStatusAccepted,
}
if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil {
t.Fatalf("ApplyDraft: %v", err)
}
skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "# Weather") {
t.Fatalf("unexpected content: %s", string(data))
}
}
func TestApplier_CreateDraftFailsWhenSkillAlreadyExists(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n"
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-create-existing",
WorkspaceID: workspace,
SourceRecordID: "rule-create-existing",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindCreate,
HumanSummary: "weather helper",
BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n",
}
err := applier.ApplyDraft(context.Background(), workspace, draft)
if err == nil {
t.Fatal("expected ApplyDraft to fail")
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("error = %v, want already exists", err)
}
got, readErr := os.ReadFile(skillPath)
if readErr != nil {
t.Fatalf("ReadFile: %v", readErr)
}
if string(got) != original {
t.Fatalf("skill content changed unexpectedly:\n%s", string(got))
}
}
func TestApplier_RollsBackOnInvalidSkillBody(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n"
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-2",
WorkspaceID: workspace,
SourceRecordID: "rule-2",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindReplace,
HumanSummary: "broken draft",
BodyOrPatch: "invalid-frontmatter",
Status: evolution.DraftStatusAccepted,
}
err := applier.ApplyDraft(context.Background(), workspace, draft)
if err == nil {
t.Fatal("expected ApplyDraft to fail")
}
got, readErr := os.ReadFile(skillPath)
if readErr != nil {
t.Fatalf("ReadFile: %v", readErr)
}
if string(got) != original {
t.Fatalf("skill content changed after rollback:\n%s", string(got))
}
}
func TestApplier_FailedNewSkillDoesNotLeaveEmptyDirectory(t *testing.T) {
workspace := t.TempDir()
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-invalid-new-skill",
WorkspaceID: workspace,
SourceRecordID: "rule-invalid-new-skill",
TargetSkillName: "calculate-100-via-theorems",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindCreate,
HumanSummary: "broken new skill",
BodyOrPatch: "invalid-frontmatter",
Status: evolution.DraftStatusAccepted,
}
err := applier.ApplyDraft(context.Background(), workspace, draft)
if err == nil {
t.Fatal("expected ApplyDraft to fail")
}
skillPath := filepath.Join(workspace, "skills", "calculate-100-via-theorems", "SKILL.md")
if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) {
t.Fatalf("expected no skill file, got err=%v", statErr)
}
skillDir := filepath.Dir(skillPath)
if _, statErr := os.Stat(skillDir); !os.IsNotExist(statErr) {
t.Fatalf("expected no leftover skill dir, got err=%v", statErr)
}
}
func TestApplier_ReplaceDraftFailsWhenSkillDoesNotExist(t *testing.T) {
workspace := t.TempDir()
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-replace-missing",
WorkspaceID: workspace,
SourceRecordID: "rule-replace-missing",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindReplace,
HumanSummary: "replace missing skill",
BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n",
}
err := applier.ApplyDraft(context.Background(), workspace, draft)
if err == nil {
t.Fatal("expected ApplyDraft to fail")
}
if !strings.Contains(err.Error(), "does not exist") {
t.Fatalf("error = %v, want does not exist", err)
}
skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md")
if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) {
t.Fatalf("expected no skill file, got err=%v", statErr)
}
}
func TestApplier_AppendDraftPreservesOriginalBody(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n"
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-append",
WorkspaceID: workspace,
SourceRecordID: "rule-append",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindAppend,
HumanSummary: "append draft",
BodyOrPatch: "\n## Learned Pattern\nPrefer native-name query first.\n",
}
if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil {
t.Fatalf("ApplyDraft: %v", err)
}
got, err := os.ReadFile(skillPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
content := string(got)
if !strings.Contains(content, "Use city names.") {
t.Fatalf("appended content lost original body:\n%s", content)
}
if !strings.Contains(content, "Prefer native-name query first.") {
t.Fatalf("appended content missing new body:\n%s", content)
}
}
func TestApplier_MergeDraftAddsMergedKnowledgeSection(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n"
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-merge",
WorkspaceID: workspace,
SourceRecordID: "rule-merge",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindMerge,
HumanSummary: "merge draft",
BodyOrPatch: "Prefer native-name query first.",
}
if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil {
t.Fatalf("ApplyDraft: %v", err)
}
got, err := os.ReadFile(skillPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
content := string(got)
if !strings.Contains(content, "Use city names.") {
t.Fatalf("merged content lost original body:\n%s", content)
}
if !strings.Contains(content, "## Merged Knowledge") {
t.Fatalf("merged content missing merged section:\n%s", content)
}
if !strings.Contains(content, "Prefer native-name query first.") {
t.Fatalf("merged content missing new knowledge:\n%s", content)
}
}
func TestApplier_RejectsInvalidSkillName(t *testing.T) {
workspace := t.TempDir()
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
for _, name := range []string{"../escape", "/tmp/escape"} {
err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{
ID: "draft-invalid-name",
WorkspaceID: workspace,
SourceRecordID: "rule-invalid-name",
TargetSkillName: name,
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindCreate,
HumanSummary: "bad name",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nbody\n",
})
if err == nil {
t.Fatalf("TargetSkillName %q expected error", name)
}
}
}