Files
2026-06-23 05:02:15 +08:00

381 lines
11 KiB
Go

package issue_test
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"superwork-tui/internal/gitea"
"superwork-tui/internal/issue"
)
// fakeClient implements issue.GiteaClient using canned data.
type fakeClient struct {
user *gitea.User
issuesByFilter map[string][]gitea.Issue // key: filterKey+":"+filterVal
repoComments []gitea.Comment
issueComments map[int][]gitea.Comment
pullRequests map[int]*gitea.PullRequest
dependencies map[int][]gitea.Issue
posted []string // bodies posted via PostIssueComment
}
func (f *fakeClient) GetCurrentUser(ctx context.Context) (*gitea.User, error) {
return f.user, nil
}
func (f *fakeClient) ListIssuesByFilter(ctx context.Context, owner, repo, filterKey, filterVal string) ([]gitea.Issue, error) {
key := filterKey + ":" + filterVal
return f.issuesByFilter[key], nil
}
func (f *fakeClient) ListAllRepoComments(ctx context.Context, owner, repo string) ([]gitea.Comment, error) {
return f.repoComments, nil
}
func (f *fakeClient) GetIssue(ctx context.Context, owner, repo string, number int) (*gitea.Issue, error) {
for _, bucket := range f.issuesByFilter {
for i := range bucket {
if bucket[i].Number == number {
iss := bucket[i]
return &iss, nil
}
}
}
return nil, nil
}
func (f *fakeClient) GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error) {
pr, ok := f.pullRequests[number]
if !ok {
return nil, &gitea.APIError{Status: 404}
}
return pr, nil
}
func (f *fakeClient) ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error) {
return f.issueComments[number], nil
}
func (f *fakeClient) PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*gitea.Comment, error) {
f.posted = append(f.posted, body)
return &gitea.Comment{ID: 999, Body: body}, nil
}
func (f *fakeClient) GetDependencies(ctx context.Context, owner, repo string, number int) ([]gitea.Issue, error) {
return f.dependencies[number], nil
}
// --- extractStateJSON tests ---
func TestExtractStateJSON_TailScan(t *testing.T) {
// The last comment is a state blob; an intermediate ordinary comment must not shadow it.
comments := []gitea.Comment{
{Body: `{"column":"todo","branch":"feat/old"}`},
{Body: "just some text"},
{Body: `{"column":"in-progress","branch":"feat/new","pr":"42"}`},
{Body: "PR auto-link comment or review note"},
}
state := issue.ExtractStateJSON(comments)
// tail-first: last comment is plain text, skip; second-to-last is the state blob
if state["column"] != "in-progress" {
t.Errorf("column: got %v, want in-progress", state["column"])
}
if state["branch"] != "feat/new" {
t.Errorf("branch: got %v, want feat/new", state["branch"])
}
if state["pr"] != "42" {
t.Errorf("pr: got %v, want 42", state["pr"])
}
}
func TestExtractStateJSON_OrdinaryJSONSkipped(t *testing.T) {
// A JSON object without any known state fields must not be treated as a state blob.
comments := []gitea.Comment{
{Body: `{"column":"done","pr":"7"}`},
{Body: `{"foo":"bar","baz":123}`}, // unknown fields only
}
state := issue.ExtractStateJSON(comments)
// The unknown-fields JSON at the end must be skipped; pick the earlier state blob.
if state["column"] != "done" {
t.Errorf("column: got %v, want done", state["column"])
}
}
func TestExtractStateJSON_NoStateBlob(t *testing.T) {
comments := []gitea.Comment{
{Body: "just text"},
{Body: `{"foo":"bar"}`},
}
state := issue.ExtractStateJSON(comments)
if len(state) != 0 {
t.Errorf("expected empty map, got %v", state)
}
}
func TestExtractStateJSON_Empty(t *testing.T) {
state := issue.ExtractStateJSON(nil)
if len(state) != 0 {
t.Errorf("expected empty map, got %v", state)
}
}
func TestExtractStateJSON_Array_Skipped(t *testing.T) {
// JSON arrays are not state blobs.
comments := []gitea.Comment{
{Body: `{"column":"review"}`},
{Body: `[1,2,3]`},
}
state := issue.ExtractStateJSON(comments)
if state["column"] != "review" {
t.Errorf("column: got %v, want review", state["column"])
}
}
// --- isValidSpxFilePath tests ---
func TestIsValidSpxFilePath(t *testing.T) {
valid := []string{
"docs/superpowers/specs/my-feature.md",
"some/path/plan.md",
}
for _, v := range valid {
if !issue.IsValidSpxFilePath(v) {
t.Errorf("expected %q to be valid", v)
}
}
invalid := []string{
"",
"...",
"…",
"noSlash.md",
"has/slash/but.txt",
}
for _, s := range invalid {
if issue.IsValidSpxFilePath(s) {
t.Errorf("expected %q to be invalid", s)
}
}
}
// --- loader tests ---
func makeIssue(number int, state, htmlURL string) gitea.Issue {
return gitea.Issue{
ID: number,
Number: number,
Title: "Issue " + string(rune('0'+number)),
State: state,
HtmlURL: htmlURL,
}
}
func issueURL(number int) string {
return "https://gitea.example.com/owner/repo/issues/" + itoa(number)
}
func itoa(n int) string {
return strconv.Itoa(n)
}
func TestLoadIssues_TailScanChoosesLastStateBlob(t *testing.T) {
iss := makeIssue(1, "open", "https://gitea.example.com/owner/repo/issues/1")
client := &fakeClient{
user: &gitea.User{Login: "alice"},
issuesByFilter: map[string][]gitea.Issue{
"assigned_by:alice": {iss},
"created_by:alice": {},
},
repoComments: []gitea.Comment{
{Body: `{"column":"todo"}`, IssueURL: issueURL(1), CreatedAt: "2024-01-01T00:00:00Z"},
{Body: "ordinary text", IssueURL: issueURL(1), CreatedAt: "2024-01-02T00:00:00Z"},
{Body: `{"column":"in-progress","branch":"feat/1"}`, IssueURL: issueURL(1), CreatedAt: "2024-01-03T00:00:00Z"},
{Body: "PR auto-link", IssueURL: issueURL(1), CreatedAt: "2024-01-04T00:00:00Z"},
},
dependencies: map[int][]gitea.Issue{},
pullRequests: map[int]*gitea.PullRequest{},
}
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
if err != nil {
t.Fatalf("LoadIssues: %v", err)
}
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %d", len(issues))
}
got := issues[0]
if got.Column != issue.ColumnInProgress {
t.Errorf("column: got %v, want in-progress", got.Column)
}
if got.Branch != "feat/1" {
t.Errorf("branch: got %v, want feat/1", got.Branch)
}
}
func TestLoadIssues_MissingStateSeeded(t *testing.T) {
iss := makeIssue(2, "open", "https://gitea.example.com/owner/repo/issues/2")
client := &fakeClient{
user: &gitea.User{Login: "bob"},
issuesByFilter: map[string][]gitea.Issue{
"assigned_by:bob": {iss},
"created_by:bob": {},
},
repoComments: []gitea.Comment{}, // no comments → no state blob
dependencies: map[int][]gitea.Issue{},
pullRequests: map[int]*gitea.PullRequest{},
}
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
if err != nil {
t.Fatalf("LoadIssues: %v", err)
}
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %d", len(issues))
}
got := issues[0]
// open issue defaults to todo
if got.Column != issue.ColumnTodo {
t.Errorf("column: got %v, want todo", got.Column)
}
// A seed comment must have been posted
if len(client.posted) != 1 {
t.Errorf("expected 1 posted comment, got %d", len(client.posted))
}
}
func TestLoadIssues_DoneSkipsPRAndDeps(t *testing.T) {
iss := makeIssue(3, "closed", "https://gitea.example.com/owner/repo/issues/3")
client := &fakeClient{
user: &gitea.User{Login: "carol"},
issuesByFilter: map[string][]gitea.Issue{
"assigned_by:carol": {iss},
"created_by:carol": {},
},
repoComments: []gitea.Comment{
{Body: `{"column":"done","pr":"10"}`, IssueURL: issueURL(3), CreatedAt: "2024-01-01T00:00:00Z"},
},
dependencies: map[int][]gitea.Issue{
3: {{Number: 1}},
},
pullRequests: map[int]*gitea.PullRequest{
10: {Number: 10, Merged: true, MergedAt: "2024-02-01T00:00:00Z"},
},
}
prCallCount := 0
depsCallCount := 0
_ = prCallCount
_ = depsCallCount
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
if err != nil {
t.Fatalf("LoadIssues: %v", err)
}
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %d", len(issues))
}
got := issues[0]
if got.Column != issue.ColumnDone {
t.Errorf("column: got %v, want done", got.Column)
}
// done column: prerequisite must be 0 (skipped), prMerged is from state JSON
if got.Prerequisite != 0 {
t.Errorf("prerequisite: got %d, want 0 (done skips deps)", got.Prerequisite)
}
// PrMerged not live-fetched but state JSON had pr:"10"; prMerged not in state JSON → false
// The point: no live PR fetch happened (we can't easily assert that without a sentinel,
// but we assert the column and prerequisite are correct)
}
func TestLoadIssues_Prerequisite(t *testing.T) {
iss := makeIssue(5, "open", "https://gitea.example.com/owner/repo/issues/5")
client := &fakeClient{
user: &gitea.User{Login: "dave"},
issuesByFilter: map[string][]gitea.Issue{
"assigned_by:dave": {iss},
"created_by:dave": {},
},
repoComments: []gitea.Comment{
{Body: `{"column":"todo"}`, IssueURL: issueURL(5), CreatedAt: "2024-01-01T00:00:00Z"},
},
dependencies: map[int][]gitea.Issue{
5: {{Number: 3}, {Number: 4}}, // first dep is prerequisite
},
pullRequests: map[int]*gitea.PullRequest{},
}
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
if err != nil {
t.Fatalf("LoadIssues: %v", err)
}
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %d", len(issues))
}
if issues[0].Prerequisite != 3 {
t.Errorf("prerequisite: got %d, want 3", issues[0].Prerequisite)
}
}
func TestLoadIssues_WorktreeExists(t *testing.T) {
tmp := t.TempDir()
worktree := filepath.Join(tmp, "trees", "feat-5")
if err := os.MkdirAll(worktree, 0o755); err != nil {
t.Fatal(err)
}
relWorktree := "trees/feat-5"
iss := makeIssue(6, "open", "https://gitea.example.com/owner/repo/issues/6")
client := &fakeClient{
user: &gitea.User{Login: "eve"},
issuesByFilter: map[string][]gitea.Issue{
"assigned_by:eve": {iss},
"created_by:eve": {},
},
repoComments: []gitea.Comment{
{
Body: `{"column":"in-progress","worktreePath":"trees/feat-5"}`,
IssueURL: issueURL(6),
CreatedAt: "2024-01-01T00:00:00Z",
},
},
dependencies: map[int][]gitea.Issue{},
pullRequests: map[int]*gitea.PullRequest{},
}
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", tmp)
if err != nil {
t.Fatalf("LoadIssues: %v", err)
}
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %d", len(issues))
}
got := issues[0]
if got.WorktreePath != relWorktree {
t.Errorf("worktreePath: got %q, want %q", got.WorktreePath, relWorktree)
}
if !got.WorktreeExists {
t.Errorf("worktreeExists: got false, want true")
}
}
func TestLoadSingleIssue_NotFound(t *testing.T) {
client := &fakeClient{
user: &gitea.User{Login: "frank"},
issuesByFilter: map[string][]gitea.Issue{},
repoComments: nil,
issueComments: map[int][]gitea.Comment{},
dependencies: map[int][]gitea.Issue{},
pullRequests: map[int]*gitea.PullRequest{},
}
got, err := issue.LoadSingleIssue(context.Background(), client, "owner", "repo", 999, "")
if err != nil {
t.Fatalf("LoadSingleIssue: %v", err)
}
if got != nil {
t.Errorf("expected nil for missing issue, got %+v", got)
}
}