524 lines
14 KiB
Go
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)
|
|
}
|