Files
superwork/tui/internal/tui/open_actions_test.go
T
2026-06-23 05:02:15 +08:00

524 lines
14 KiB
Go

package tui
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"os/exec"
tea "charm.land/bubbletea/v2"
"superwork-tui/internal/issue"
)
// ── helpers ──────────────────────────────────────────────────────────────────
// captureEditorCall replaces editorExecFn for the duration of the test,
// recording the command path and first arg (file path) passed to it.
type editorCapture struct {
bin string
path string
}
func withEditorCapture(t *testing.T) *editorCapture {
t.Helper()
cap := &editorCapture{}
orig := editorExecFn
editorExecFn = func(c *exec.Cmd, fn func(error) tea.Msg) tea.Cmd {
cap.bin = c.Path
if len(c.Args) > 1 {
cap.path = c.Args[1]
}
// Return a no-op Cmd that immediately delivers success.
return func() tea.Msg { return fn(nil) }
}
t.Cleanup(func() { editorExecFn = orig })
return cap
}
// captureXdgOpen replaces xdgOpenFn for the duration of the test.
type xdgCapture struct {
url string
err error // if non-nil, xdgOpenFn returns this error
}
func withXdgCapture(t *testing.T, retErr error) *xdgCapture {
t.Helper()
cap := &xdgCapture{err: retErr}
orig := xdgOpenFn
xdgOpenFn = func(url string) error {
cap.url = url
return cap.err
}
t.Cleanup(func() { xdgOpenFn = orig })
return cap
}
// captureWorktreeWindow replaces worktreeWindowFn for the duration of the test.
type worktreeCapture struct {
winName string
path string
}
func withWorktreeCapture(t *testing.T) *worktreeCapture {
t.Helper()
cap := &worktreeCapture{}
orig := worktreeWindowFn
worktreeWindowFn = func(sm *SessionManager, winName, path string) error {
cap.winName = winName
cap.path = path
return nil
}
t.Cleanup(func() { worktreeWindowFn = orig })
return cap
}
// ── editor/open-file tests ────────────────────────────────────────────────────
func TestCandidateFiles_NoneSet(t *testing.T) {
root := t.TempDir()
iss := issue.Issue{Number: 1}
got := candidateFiles(iss, root)
if len(got) != 0 {
t.Errorf("expected 0 candidates, got %v", got)
}
}
func TestCandidateFiles_OnlyExistingReturned(t *testing.T) {
root := t.TempDir()
spec := filepath.Join(root, "spec.md")
if err := os.WriteFile(spec, []byte("spec"), 0o644); err != nil {
t.Fatal(err)
}
// planFile does not exist on disk.
iss := issue.Issue{
SpecFile: "spec.md",
PlanFile: "missing_plan.md",
}
got := candidateFiles(iss, root)
if len(got) != 1 {
t.Fatalf("expected 1 candidate, got %v", got)
}
if got[0] != spec {
t.Errorf("candidate = %q, want %q", got[0], spec)
}
}
func TestCandidateFiles_Order(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"spec.md", "plan.md", "diff.diff"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
iss := issue.Issue{
SpecFile: "spec.md",
PlanFile: "plan.md",
PrDiffFile: "diff.diff",
}
got := candidateFiles(iss, root)
want := []string{
filepath.Join(root, "spec.md"),
filepath.Join(root, "plan.md"),
filepath.Join(root, "diff.diff"),
}
for i, w := range want {
if i >= len(got) || got[i] != w {
t.Errorf("candidates[%d] = %q, want %q", i, got[i], w)
}
}
}
func TestOpenEditorCmd_UsesEditorEnv(t *testing.T) {
cap := withEditorCapture(t)
t.Setenv("EDITOR", "nano")
root := t.TempDir()
absFile := filepath.Join(root, "spec.md")
if err := os.WriteFile(absFile, []byte("spec"), 0o644); err != nil {
t.Fatal(err)
}
cmd := openEditorCmd(absFile)
msg := cmd()
if _, ok := msg.(openFileMsg); !ok {
t.Fatalf("expected openFileMsg, got %T", msg)
}
if !strings.HasSuffix(cap.bin, "nano") {
t.Errorf("editor bin = %q, want suffix 'nano'", cap.bin)
}
if cap.path != absFile {
t.Errorf("editor path = %q, want %q", cap.path, absFile)
}
}
func TestOpenEditorCmd_FallsBackToVi(t *testing.T) {
cap := withEditorCapture(t)
t.Setenv("EDITOR", "") // unset
absFile := filepath.Join(t.TempDir(), "plan.md")
if err := os.WriteFile(absFile, []byte("plan"), 0o644); err != nil {
t.Fatal(err)
}
openEditorCmd(absFile)()
if !strings.HasSuffix(cap.bin, "vi") {
t.Errorf("editor bin = %q, want suffix 'vi'", cap.bin)
}
}
// TestDetailKey_o_NoFiles verifies that pressing 'o' on an issue with no files
// sets a statusMsg and returns no suspend cmd.
func TestDetailKey_o_NoFiles(t *testing.T) {
cap := withEditorCapture(t)
_ = cap // not expected to be called
root := t.TempDir()
origRoot := overrideWorkspaceRoot(root)
defer origRoot()
iss := issue.Issue{Number: 5} // no SpecFile/PlanFile/PrDiffFile
m := Model{state: stateDetail, detailIss: &iss}
m2, cmd := m.Update(buildKeyMsg("o"))
if cmd != nil {
t.Error("expected nil cmd when no files exist")
}
m2model := m2.(Model)
if m2model.statusMsg == "" {
t.Error("expected statusMsg to be set for no-file guard")
}
}
// TestDetailKey_o_SingleFile verifies that pressing 'o' with exactly one
// candidate opens the file directly without entering the picker.
func TestDetailKey_o_SingleFile(t *testing.T) {
cap := withEditorCapture(t)
root := t.TempDir()
origRoot := overrideWorkspaceRoot(root)
defer origRoot()
specPath := filepath.Join(root, "spec.md")
if err := os.WriteFile(specPath, []byte("spec"), 0o644); err != nil {
t.Fatal(err)
}
iss := issue.Issue{Number: 5, SpecFile: "spec.md"}
m := Model{state: stateDetail, detailIss: &iss}
m2, cmd := m.Update(buildKeyMsg("o"))
if cmd == nil {
t.Fatal("expected a cmd for single-file open")
}
cmd() // execute to populate cap
if cap.path != specPath {
t.Errorf("editor path = %q, want %q", cap.path, specPath)
}
m2model := m2.(Model)
if m2model.state == stateFilePicker {
t.Error("must not enter file picker for single candidate")
}
}
// TestDetailKey_o_MultiFile_EntersPicker verifies that pressing 'o' with >1
// candidates enters stateFilePicker without opening any editor yet.
func TestDetailKey_o_MultiFile_EntersPicker(t *testing.T) {
cap := withEditorCapture(t)
_ = cap
root := t.TempDir()
origRoot := overrideWorkspaceRoot(root)
defer origRoot()
for _, name := range []string{"spec.md", "plan.md"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
iss := issue.Issue{Number: 5, SpecFile: "spec.md", PlanFile: "plan.md"}
m := Model{state: stateDetail, detailIss: &iss}
m2, cmd := m.Update(buildKeyMsg("o"))
if cmd != nil {
t.Error("entering picker must not issue a cmd")
}
m2model := m2.(Model)
if m2model.state != stateFilePicker {
t.Errorf("state = %v, want stateFilePicker", m2model.state)
}
if len(m2model.filePickerFiles) != 2 {
t.Errorf("filePickerFiles len = %d, want 2", len(m2model.filePickerFiles))
}
}
// TestDetailKey_o_MultiFile_SelectPlan verifies that navigating down then
// pressing enter in the file picker opens the plan file (index 1), not spec.
func TestDetailKey_o_MultiFile_SelectPlan(t *testing.T) {
cap := withEditorCapture(t)
root := t.TempDir()
origRoot := overrideWorkspaceRoot(root)
defer origRoot()
specPath := filepath.Join(root, "spec.md")
planPath := filepath.Join(root, "plan.md")
diffPath := filepath.Join(root, "diff.diff")
for _, p := range []string{specPath, planPath, diffPath} {
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
iss := issue.Issue{Number: 5, SpecFile: "spec.md", PlanFile: "plan.md", PrDiffFile: "diff.diff"}
m := Model{state: stateDetail, detailIss: &iss}
// Press 'o' → enter picker
m2, _ := m.Update(buildKeyMsg("o"))
m2model := m2.(Model)
if m2model.state != stateFilePicker {
t.Fatalf("expected stateFilePicker, got %v", m2model.state)
}
// Press 'j' → move cursor to plan (index 1)
m3, _ := m2model.Update(buildKeyMsg("j"))
m3model := m3.(Model)
if m3model.filePickerCursor != 1 {
t.Errorf("cursor = %d, want 1", m3model.filePickerCursor)
}
// Press enter → opens plan file
m4, cmd := m3model.Update(buildKeyMsg("enter"))
if cmd == nil {
t.Fatal("expected a cmd from enter in file picker")
}
cmd() // populate cap
if cap.path != planPath {
t.Errorf("editor path = %q, want %q (plan)", cap.path, planPath)
}
m4model := m4.(Model)
if m4model.state == stateFilePicker {
t.Error("state must leave stateFilePicker after enter")
}
}
// TestDetailKey_o_MultiFile_EscCancels verifies that esc in the file picker
// returns to the previous state without opening any file.
func TestDetailKey_o_MultiFile_EscCancels(t *testing.T) {
cap := withEditorCapture(t)
_ = cap
root := t.TempDir()
origRoot := overrideWorkspaceRoot(root)
defer origRoot()
for _, name := range []string{"spec.md", "plan.md"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
iss := issue.Issue{Number: 5, SpecFile: "spec.md", PlanFile: "plan.md"}
m := Model{state: stateDetail, detailIss: &iss}
// Enter picker
m2, _ := m.Update(buildKeyMsg("o"))
m2model := m2.(Model)
// Press esc
m3, cmd := m2model.Update(buildKeyMsg("esc"))
if cmd != nil {
t.Error("esc must not issue a cmd")
}
m3model := m3.(Model)
if m3model.state == stateFilePicker {
t.Error("esc must leave stateFilePicker")
}
if cap.path != "" {
t.Error("editor must not be called on esc")
}
}
// ── pr/open tests ─────────────────────────────────────────────────────────────
func TestPrNumberFor_Empty(t *testing.T) {
iss := issue.Issue{Number: 3, PR: ""}
_, err := prNumberFor(iss)
if err == nil {
t.Error("expected error for empty PR")
}
}
func TestPrNumberFor_Valid(t *testing.T) {
iss := issue.Issue{Number: 3, PR: "7"}
n, err := prNumberFor(iss)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 7 {
t.Errorf("prNumber = %d, want 7", n)
}
}
func TestOpenPRCmd_XdgOpenCalledWithURL(t *testing.T) {
cap := withXdgCapture(t, nil)
wantURL := "https://gitea.example.com/org/repo/pulls/42"
origFn := resolvePRURLFn
resolvePRURLFn = func(iss issue.Issue) (string, error) {
return wantURL, nil
}
defer func() { resolvePRURLFn = origFn }()
iss := issue.Issue{Number: 5, PR: "42"}
cmd := openPRCmd(iss)
msg := cmd()
result, ok := msg.(openPRMsg)
if !ok {
t.Fatalf("expected openPRMsg, got %T", msg)
}
if result.err != nil {
t.Fatalf("unexpected error: %v", result.err)
}
if cap.url != wantURL {
t.Errorf("xdg-open url = %q, want %q", cap.url, wantURL)
}
}
func TestOpenPRCmd_NoURL_Guard(t *testing.T) {
_ = withXdgCapture(t, nil)
origFn := resolvePRURLFn
resolvePRURLFn = func(iss issue.Issue) (string, error) {
return "", errors.New("issue #5 无关联 PR")
}
defer func() { resolvePRURLFn = origFn }()
iss := issue.Issue{Number: 5, PR: ""}
cmd := openPRCmd(iss)
msg := cmd()
result, ok := msg.(openPRMsg)
if !ok {
t.Fatalf("expected openPRMsg, got %T", msg)
}
if result.err == nil {
t.Error("expected error for missing PR")
}
}
// TestDetailKey_ShiftO_NoPR verifies that 'O' on an issue without a PR sets
// a statusMsg without calling xdg-open.
func TestDetailKey_ShiftO_NoPR(t *testing.T) {
cap := withXdgCapture(t, nil)
_ = cap
iss := issue.Issue{Number: 6, PR: ""}
m := Model{state: stateDetail, detailIss: &iss}
m2, _ := m.Update(buildKeyMsg("O"))
m2model := m2.(Model)
if m2model.statusMsg == "" {
t.Error("expected statusMsg for no-PR guard")
}
if cap.url != "" {
t.Error("xdg-open must not be called when no PR")
}
}
// ── worktree/open tests ───────────────────────────────────────────────────────
func TestOpenWorktreeCmd_MissingPath(t *testing.T) {
cap := withWorktreeCapture(t)
_ = cap
iss := issue.Issue{Number: 10, WorktreePath: ""}
sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil })
cmd := openWorktreeCmd(iss, sm)
msg := cmd()
result, ok := msg.(openWorktreeMsg)
if !ok {
t.Fatalf("expected openWorktreeMsg, got %T", msg)
}
if result.err == nil {
t.Error("expected error for missing WorktreePath")
}
}
func TestOpenWorktreeCmd_PathNotExist(t *testing.T) {
cap := withWorktreeCapture(t)
_ = cap
root := t.TempDir()
origRoot := overrideWorkspaceRoot(root)
defer origRoot()
iss := issue.Issue{Number: 10, WorktreePath: "nonexistent/worktree"}
sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil })
cmd := openWorktreeCmd(iss, sm)
msg := cmd()
result, ok := msg.(openWorktreeMsg)
if !ok {
t.Fatalf("expected openWorktreeMsg, got %T", msg)
}
if result.err == nil {
t.Error("expected error for non-existent worktree path")
}
if cap.winName != "" {
t.Error("tmux window must not be opened when path doesn't exist")
}
}
func TestOpenWorktreeCmd_Success(t *testing.T) {
cap := withWorktreeCapture(t)
root := t.TempDir()
origRoot := overrideWorkspaceRoot(root)
defer origRoot()
wtPath := filepath.Join(root, ".claude/worktrees/abc123")
if err := os.MkdirAll(wtPath, 0o755); err != nil {
t.Fatal(err)
}
iss := issue.Issue{Number: 42, WorktreePath: ".claude/worktrees/abc123"}
sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil })
cmd := openWorktreeCmd(iss, sm)
msg := cmd()
result, ok := msg.(openWorktreeMsg)
if !ok {
t.Fatalf("expected openWorktreeMsg, got %T", msg)
}
if result.err != nil {
t.Fatalf("unexpected error: %v", result.err)
}
if cap.winName != "42-wt" {
t.Errorf("winName = %q, want %q", cap.winName, "42-wt")
}
if cap.path != wtPath {
t.Errorf("path = %q, want %q", cap.path, wtPath)
}
}
// TestDetailKey_W_NoWorktree verifies 'w' on an issue with no WorktreePath sets statusMsg.
func TestDetailKey_W_NoWorktree(t *testing.T) {
cap := withWorktreeCapture(t)
_ = cap
iss := issue.Issue{Number: 7, WorktreePath: ""}
sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil })
m := Model{state: stateDetail, detailIss: &iss, sessionMgr: sm}
m2, _ := m.Update(buildKeyMsg("w"))
m2model := m2.(Model)
if m2model.statusMsg == "" {
t.Error("expected statusMsg for no-worktree guard")
}
if cap.winName != "" {
t.Error("tmux must not be called when no worktree")
}
}
// buildKeyMsg creates a tea.KeyMsg for use in Update tests.
func buildKeyMsg(key string) tea.KeyMsg {
return keyMsg(key)
}