308 lines
10 KiB
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)
|
|
}
|
|
}
|
|
}
|