Files
renamer/tests/integration/ai_apply_undo_test.go
Rogee 3867736858 feat: implement AI-assisted rename prompting feature
- Added data model for AI-assisted renaming including structures for prompts, responses, and policies.
- Created implementation plan detailing the integration of Google Genkit into the CLI for renaming tasks.
- Developed quickstart guide for setting up and using the new AI rename functionality.
- Documented research decisions regarding Genkit orchestration and prompt composition.
- Established tasks for phased implementation, including setup, foundational work, and user stories.
- Implemented contract tests to ensure AI rename policies and ledger metadata are correctly applied.
- Developed integration tests for validating AI rename flows, including preview, apply, and undo functionalities.
- Added tooling to pin Genkit dependency for consistent builds.
2025-11-03 18:08:14 +08:00

199 lines
5.2 KiB
Go

package integration
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
"github.com/rogeecn/renamer/internal/ai/genkit"
"github.com/rogeecn/renamer/internal/ai/plan"
"github.com/rogeecn/renamer/internal/ai/prompt"
"github.com/rogeecn/renamer/internal/listing"
)
func TestAIApplyAndUndoFlow(t *testing.T) {
initialWorkflow := stubWorkflow{
response: prompt.RenameResponse{
Items: []prompt.RenameItem{
{
Original: "draft_one.txt",
Proposed: "001_initial.txt",
Sequence: 1,
},
{
Original: "draft_two.txt",
Proposed: "002_initial.txt",
Sequence: 2,
},
},
Model: "test-model",
},
}
genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) {
return initialWorkflow, nil
})
t.Cleanup(genkit.ResetWorkflowFactory)
root := t.TempDir()
writeFile(t, filepath.Join(root, "draft_one.txt"))
writeFile(t, filepath.Join(root, "draft_two.txt"))
planPath := filepath.Join(root, "ai-plan.json")
preview := renamercmd.NewRootCommand()
var previewOut, previewErr bytes.Buffer
preview.SetOut(&previewOut)
preview.SetErr(&previewErr)
preview.SetArgs([]string{
"ai",
"--path", root,
"--dry-run",
"--export-plan", planPath,
})
if err := preview.Execute(); err != nil {
if previewOut.Len() > 0 {
t.Logf("preview stdout: %s", previewOut.String())
}
if previewErr.Len() > 0 {
t.Logf("preview stderr: %s", previewErr.String())
}
t.Fatalf("initial preview: %v", err)
}
data, err := os.ReadFile(planPath)
if err != nil {
t.Fatalf("read plan: %v", err)
}
var exported prompt.RenameResponse
if err := json.Unmarshal(data, &exported); err != nil {
t.Fatalf("unmarshal plan: %v", err)
}
if len(exported.Items) != 2 {
t.Fatalf("expected two plan items, got %d", len(exported.Items))
}
// Simulate operator edit.
exported.Items[0].Proposed = "001_final-one.txt"
exported.Items[1].Proposed = "002_final-two.txt"
exported.Items[0].Notes = "custom edit"
modified, err := json.MarshalIndent(exported, "", " ")
if err != nil {
t.Fatalf("marshal modified plan: %v", err)
}
if err := os.WriteFile(planPath, append(modified, '\n'), 0o644); err != nil {
t.Fatalf("write modified plan: %v", err)
}
req := &listing.ListingRequest{WorkingDir: root}
if err := req.Validate(); err != nil {
t.Fatalf("validate listing request: %v", err)
}
currentCandidates, err := plan.CollectCandidates(context.Background(), req)
if err != nil {
t.Fatalf("collect candidates: %v", err)
}
filtered := make([]plan.Candidate, 0, len(currentCandidates))
for _, cand := range currentCandidates {
if strings.EqualFold(cand.OriginalPath, filepath.Base(planPath)) {
continue
}
filtered = append(filtered, cand)
}
originals := make([]string, 0, len(filtered))
for _, cand := range filtered {
originals = append(originals, cand.OriginalPath)
}
validator := plan.NewValidator(originals, prompt.NamingPolicyConfig{Casing: "kebab"}, nil)
if _, err := validator.Validate(exported); err != nil {
t.Fatalf("pre-validation of edited plan: %v", err)
}
previewEdited := renamercmd.NewRootCommand()
var editedOut, editedErr bytes.Buffer
previewEdited.SetOut(&editedOut)
previewEdited.SetErr(&editedErr)
previewEdited.SetArgs([]string{
"ai",
"--path", root,
"--dry-run",
"--import-plan", planPath,
})
if err := previewEdited.Execute(); err != nil {
if editedOut.Len() > 0 {
t.Logf("edited stdout: %s", editedOut.String())
}
if editedErr.Len() > 0 {
t.Logf("edited stderr: %s", editedErr.String())
}
t.Fatalf("preview edited plan: %v", err)
}
if !strings.Contains(editedOut.String(), "001_final-one.txt") {
t.Fatalf("expected edited preview to show final name, got: %s", editedOut.String())
}
applyCmd := renamercmd.NewRootCommand()
var applyOut, applyErr bytes.Buffer
applyCmd.SetOut(&applyOut)
applyCmd.SetErr(&applyErr)
applyCmd.SetArgs([]string{
"ai",
"--path", root,
"--import-plan", planPath,
"--yes",
})
if err := applyCmd.Execute(); err != nil {
if applyOut.Len() > 0 {
t.Logf("apply stdout: %s", applyOut.String())
}
if applyErr.Len() > 0 {
t.Logf("apply stderr: %s", applyErr.String())
}
t.Fatalf("apply plan: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "001_final-one.txt")); err != nil {
t.Fatalf("expected renamed file: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "002_final-two.txt")); err != nil {
t.Fatalf("expected renamed file: %v", err)
}
undo := renamercmd.NewRootCommand()
var undoOut bytes.Buffer
undo.SetOut(&undoOut)
undo.SetErr(&undoOut)
undo.SetArgs([]string{"undo", "--path", root})
if err := undo.Execute(); err != nil {
t.Fatalf("undo command: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "draft_one.txt")); err != nil {
t.Fatalf("expected original file after undo: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "draft_two.txt")); err != nil {
t.Fatalf("expected original file after undo: %v", err)
}
}
func writeFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}