// stalled_commands_test.go — stalled/xrefs CLI 接線層測試(#7): // 注入假 StalledSource,驗證 Run→dispatch→flagset 完整路徑, // 不走網路(與 workflow_commands_test.go 同模式)。 package cli import ( "strings" "testing" "time" "gitea.alterminal.com/alterminal/teai/internal/gitea" "gitea.alterminal.com/alterminal/teai/internal/workflow" ) // stubStalledSource 以記憶體資料實作 workflow.StalledSource。 type stubStalledSource struct { username string orgs []string repos map[string][]workflow.Repo issues map[string]map[int64]workflow.Issue comments map[string][]workflow.Comment getErr error } func (s *stubStalledSource) CurrentUser() (string, error) { return s.username, nil } func (s *stubStalledSource) MyOrganizations() ([]string, error) { return s.orgs, nil } func (s *stubStalledSource) OrganizationRepositories(org string) ([]workflow.Repo, error) { return s.repos[org], nil } func (s *stubStalledSource) OpenIssues(owner, repo string) ([]workflow.Issue, error) { var out []workflow.Issue for _, i := range s.issues[owner+"/"+repo] { out = append(out, i) } return out, nil } func (s *stubStalledSource) OpenPulls(owner, repo string) ([]workflow.PullRequest, error) { return nil, nil } func (s *stubStalledSource) IssueComments(owner, repo string, number int64) ([]workflow.Comment, error) { return s.comments[owner+"/"+repo+"/"+itoa(number)], nil } func (s *stubStalledSource) TryGetIssue(owner, repo string, number int64) (workflow.Issue, bool, error) { i, ok := s.issues[owner+"/"+repo][number] return i, ok, nil } func (s *stubStalledSource) GetIssue(owner, repo string, number int64) (workflow.Issue, error) { if s.getErr != nil { return workflow.Issue{}, s.getErr } i, ok := s.issues[owner+"/"+repo][number] if !ok { return workflow.Issue{}, &gitea.ErrAPI{Op: "GET /repos/" + owner + "/" + repo + "/issues/" + itoa(number), StatusCode: 404} } return i, nil } func itoa(n int64) string { if n == 0 { return "0" } var b []byte for n > 0 { b = append([]byte{byte('0' + n%10)}, b...) n /= 10 } return string(b) } // newStubStalledEnv 注入假資料源並固定時刻。 func newStubStalledEnv(t *testing.T, src *stubStalledSource, frozen time.Time) { t.Helper() origSrc, origNow := newStalledSourceFunc, nowFunc newStalledSourceFunc = func(env *Env) (workflow.StalledSource, error) { return src, nil } nowFunc = func() time.Time { return frozen } t.Cleanup(func() { newStalledSourceFunc, nowFunc = origSrc, origNow }) } // isoAt 把相對分鐘轉成 ISO8601 字串(假資料用;錨點同 workflow 測試)。 func isoAt(minutes int) string { return workflow.FormatMoment(time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC).Add(time.Duration(minutes) * time.Minute)) } // newStubStalledData 建構 stalled 情境:agents#1 停滯 10 小時(no-assignee)。 func newStubStalledData() *stubStalledSource { return &stubStalledSource{ username: "ceo", orgs: []string{"alterminal"}, repos: map[string][]workflow.Repo{"alterminal": {{Name: "agents"}}}, issues: map[string]map[int64]workflow.Issue{ "alterminal/agents": { 1: {Number: 1, Title: "停滯項", State: "open", CreatedAt: isoAt(-600), UpdatedAt: isoAt(-600), Repository: &workflow.Repository{FullName: "alterminal/agents"}}, }, }, comments: map[string][]workflow.Comment{}, } } // TestWiringStalled 驗證 stalled 基本路徑:JSON 輸出含停滯欄位。 func TestWiringStalled(t *testing.T) { newStubStalledEnv(t, newStubStalledData(), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) stdout, _, code := run("stalled") if code != 0 { t.Fatalf("stalled 應回 0,得到 %d(stdout=%q)", code, stdout) } for _, want := range []string{`"type": "issue"`, `"reason": "no-assignee"`, `"stalled_hours": 10.0`} { if !strings.Contains(stdout, want) { t.Fatalf("stalled 輸出應含 %q,得到:\n%s", want, stdout) } } } // TestWiringStalledHoursFlag 驗證 --hours 覆寫門檻與非正數被攔。 func TestWiringStalledHoursFlag(t *testing.T) { freeze := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC) newStubStalledEnv(t, newStubStalledData(), freeze) // 門檻 20h → 10h 停滯未過門檻 → 空陣列。 stdout, _, code := run("stalled", "--hours", "20") if code != 0 { t.Fatalf("stalled --hours 20 應回 0,得到 %d", code) } if strings.TrimSpace(stdout) != "[]" { t.Fatalf("無停滯項應輸出 [],得到 %q", stdout) } // 非正數 → exit 2。 _, stderr, code := run("stalled", "--hours", "-1") if code != int(ExitUsage) { t.Fatalf("stalled --hours -1 應回 ExitUsage(%d),得到 %d(stderr=%q)", int(ExitUsage), code, stderr) } // 位置參數 → exit 2。 _, _, code = run("stalled", "extra") if code != int(ExitUsage) { t.Fatalf("stalled extra 應回 ExitUsage(%d),得到 %d", int(ExitUsage), code) } } // TestWiringStalledTable 驗證 table 輸出(README 範例)。 func TestWiringStalledTable(t *testing.T) { newStubStalledEnv(t, newStubStalledData(), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) stdout, _, code := run("stalled", "--output", "table") if code != 0 { t.Fatalf("stalled --output table 應回 0,得到 %d", code) } for _, want := range []string{"reason", "no-assignee", "停滯項"} { if !strings.Contains(stdout, want) { t.Fatalf("table 輸出應含 %q,得到:\n%s", want, stdout) } } } // TestWiringXrefs 驗證 xrefs 解析輸出(含 body/comment 出處)。 func TestWiringXrefs(t *testing.T) { src := newStubStalledData() src.issues["alterminal/agents"][1] = workflow.Issue{ Number: 1, Title: "父項", State: "open", Body: "子項:agents#2", Repository: &workflow.Repository{FullName: "alterminal/agents"}, } src.issues["alterminal/agents"][2] = workflow.Issue{ Number: 2, Title: "子項", State: "open", Repository: &workflow.Repository{FullName: "alterminal/agents"}, } src.comments["alterminal/agents/1"] = []workflow.Comment{ {Body: "看 agents#2", User: &workflow.User{UserName: "alex"}}, } newStubStalledEnv(t, src, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) stdout, _, code := run("xrefs", "alterminal/agents", "1") if code != 0 { t.Fatalf("xrefs 應回 0,得到 %d(stdout=%q)", code, stdout) } // Printer JSON 為單行緊湊格式(無縮排);命令清單類輸出一律如此。 for _, want := range []string{`"repo":"agents"`, `"number":2`, `"source":"body"`} { if !strings.Contains(stdout, want) { t.Fatalf("xrefs 輸出應含 %q,得到:\n%s", want, stdout) } } } // TestWiringXrefsEmpty 驗證無引用 → []。 func TestWiringXrefsEmpty(t *testing.T) { src := newStubStalledData() src.issues["alterminal/agents"][1] = workflow.Issue{ Number: 1, Title: "無引用", State: "open", Body: "沒有任何引用", Repository: &workflow.Repository{FullName: "alterminal/agents"}, } newStubStalledEnv(t, src, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) stdout, _, code := run("xrefs", "alterminal/agents", "1") if code != 0 { t.Fatalf("xrefs 應回 0,得到 %d", code) } if strings.TrimSpace(stdout) != "[]" { t.Fatalf("無引用應輸出 [],得到 %q", stdout) } } // TestWiringXrefsUsage 驗證參數不足/格式錯誤 → exit 2。 func TestWiringXrefsUsage(t *testing.T) { newStubStalledEnv(t, newStubStalledData(), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) if _, _, code := run("xrefs"); code != int(ExitUsage) { t.Fatalf("xrefs 無參數應回 ExitUsage,得到 %d", code) } if _, _, code := run("xrefs", "alterminal/agents"); code != int(ExitUsage) { t.Fatalf("xrefs 缺編號應回 ExitUsage,得到 %d", code) } if _, _, code := run("xrefs", "not-a-repo", "1"); code != int(ExitUsage) { t.Fatalf("xrefs repo 格式錯誤應回 ExitUsage,得到 %d", code) } if _, _, code := run("xrefs", "alterminal/agents", "abc"); code != int(ExitUsage) { t.Fatalf("xrefs 編號非數字應回 ExitUsage,得到 %d", code) } } // TestWiringXrefsNotFound 驗證 404 → exit 3(API 錯誤)。 func TestWiringXrefsNotFound(t *testing.T) { src := newStubStalledData() src.getErr = &gitea.ErrAPI{Op: "GET /repos/alterminal/agents/issues/99", StatusCode: 404} newStubStalledEnv(t, src, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) _, stderr, code := run("xrefs", "alterminal/agents", "99") if code != int(ExitAPI) { t.Fatalf("xrefs 404 應回 ExitAPI(%d),得到 %d(stderr=%q)", int(ExitAPI), code, stderr) } }