Files
teai/internal/workflow/scan_test.go
2026-09-14 16:13:02 +08:00

392 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// scan_test.go — 以假 client 驗證 Scanner 的命令層語義(對照 gitea.py):
// next 三層拾取與「最久未更新優先」、mine/pulls 過濾、members --has-work。
package workflow
import (
"errors"
"fmt"
"reflect"
"testing"
)
// fakeClient 以記憶體資料實作 Client。
type fakeClient struct {
currentUser string
orgs []Org
teams map[string][]Team // org → teams
teamMembers map[int64][]string // teamID → 帳號
repos map[string][]Repo // org → repos
issues map[string][]Issue // "owner/repo" → issues(open,不含 PR)
pulls map[string][]PullRequest // "owner/repo" → open PR
comments map[string][]Comment // "owner/repo#number" → 留言
failOn map[string]error // 端點 → 錯誤(測失敗傳播)
}
func (f *fakeClient) CurrentUser() (string, error) {
if err := f.failOn["user"]; err != nil {
return "", err
}
return f.currentUser, nil
}
func (f *fakeClient) ListOrgs() ([]string, error) {
if err := f.failOn["orgs"]; err != nil {
return nil, err
}
out := make([]string, 0, len(f.orgs))
for _, o := range f.orgs {
out = append(out, o.Account())
}
return out, nil
}
func (f *fakeClient) ListTeams(org string) ([]Team, error) {
if err := f.failOn["teams"]; err != nil {
return nil, err
}
return f.teams[org], nil
}
func (f *fakeClient) ListTeamMembers(teamID int64) ([]string, error) {
if err := f.failOn["members"]; err != nil {
return nil, err
}
return f.teamMembers[teamID], nil
}
func (f *fakeClient) ListOrgRepos(org string) ([]Repo, error) {
if err := f.failOn["repos"]; err != nil {
return nil, err
}
return f.repos[org], nil
}
func (f *fakeClient) ListIssues(owner, repo string) ([]Issue, error) {
if err := f.failOn["issues"]; err != nil {
return nil, err
}
return f.issues[owner+"/"+repo], nil
}
func (f *fakeClient) ListPulls(owner, repo string) ([]PullRequest, error) {
if err := f.failOn["pulls"]; err != nil {
return nil, err
}
return f.pulls[owner+"/"+repo], nil
}
func (f *fakeClient) ListComments(owner, repo string, number int64) ([]Comment, error) {
if err := f.failOn["comments"]; err != nil {
return nil, err
}
return f.comments[fmt.Sprintf("%s/%s#%d", owner, repo, number)], nil
}
// newTestScanner 構造測試情境:一個組織、一個 Agents 團隊、兩個倉庫。
func newTestScanner() (*Scanner, *fakeClient) {
fc := &fakeClient{
currentUser: "alex",
orgs: []Org{{UserName: "alterminal"}},
teams: map[string][]Team{"alterminal": {{ID: 1, Name: "Agents"}}},
teamMembers: map[int64][]string{1: {"alex", "max", "queena"}},
repos: map[string][]Repo{"alterminal": {{Name: "teai"}, {Name: "agents"}}},
issues: map[string][]Issue{},
pulls: map[string][]PullRequest{},
comments: map[string][]Comment{},
failOn: map[string]error{},
}
return &Scanner{Client: fc}, fc
}
func TestNextLayerPriority(t *testing.T) {
s, fc := newTestScanner()
// 三層各有工作:author PR、reviewer PR、issue。
fc.pulls["alterminal/teai"] = []PullRequest{
{
Number: 10, Title: "我的 PR", UpdatedAt: "2026-09-09T00:00:00Z",
User: mkUser("alex"),
},
{
Number: 11, Title: "待我審核的 PR", UpdatedAt: "2026-09-09T01:00:00Z",
User: mkUser("max"),
RequestedReviewers: []User{*mkUser("alex")},
},
}
// author PR #10 有他人留言 → 需跟進。
fc.comments["alterminal/teai#10"] = []Comment{
{CreatedAt: "2026-09-09T00:10:00Z", User: mkUser("ceo")},
}
// reviewer PR #11 尚無留言 → 審核者仍應被列入。
fc.issues["alterminal/agents"] = []Issue{
{Number: 30, Title: "分派給 alex", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
}
work, err := s.Next()
if err != nil {
t.Fatal(err)
}
if work.Type != "pull" || work.Role != "author" || work.Number != 10 {
t.Errorf("第一層應選 author PR #10,得到 %+v", work)
}
}
func TestNextLayerFallbackToReviewer(t *testing.T) {
s, fc := newTestScanner()
fc.pulls["alterminal/teai"] = []PullRequest{
{
Number: 11, Title: "待審核", UpdatedAt: "2026-09-09T01:00:00Z",
User: mkUser("max"),
RequestedReviewers: []User{*mkUser("alex")},
},
}
fc.issues["alterminal/agents"] = []Issue{
{Number: 30, Title: "分派給 alex", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
}
work, err := s.Next()
if err != nil {
t.Fatal(err)
}
if work.Type != "pull" || work.Role != "reviewer" || work.Number != 11 {
t.Errorf("第二層應選 reviewer PR #11,得到 %+v", work)
}
}
func TestNextLayerFallbackToIssue(t *testing.T) {
s, fc := newTestScanner()
fc.issues["alterminal/agents"] = []Issue{
{Number: 30, Title: "分派給 alex", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
}
work, err := s.Next()
if err != nil {
t.Fatal(err)
}
if work.Type != "issue" || work.Number != 30 {
t.Errorf("第三層應選 issue #30,得到 %+v", work)
}
}
// TestNextPicksIssueInEmptyRepo 回歸測試(#43):空倉庫(empty=true)中
// 分派給目前帳號的 open issue 應被第三層拾取——gitea.py 的
// for_all_repositories 掃 issue 不跳過空倉庫(「初始化項目」型工作)。
func TestNextPicksIssueInEmptyRepo(t *testing.T) {
s, fc := newTestScanner()
fc.repos["alterminal"] = []Repo{
{Name: "teai"},
{Name: "colony-tick-demo", Empty: true},
}
fc.issues["alterminal/colony-tick-demo"] = []Issue{
{
Number: 1, Title: "初始化項目", UpdatedAt: "2026-09-08T00:00:00Z",
Assignees: []User{*mkUser("alex")},
Repository: &Repository{FullName: "alterminal/colony-tick-demo"},
HTMLURL: "https://gitea.alterminal.com/alterminal/colony-tick-demo/issues/1",
},
}
work, err := s.Next()
if err != nil {
t.Fatal(err)
}
if work == nil || work.Type != "issue" || work.Number != 1 || work.Repo != "alterminal/colony-tick-demo" {
t.Errorf("應拾取空倉庫中的 issue #1,得到 %+v", work)
}
}
// TestMineIncludesEmptyRepoIssues 回歸測試(#43):mine 同樣不因倉庫為空而漏列。
func TestMineIncludesEmptyRepoIssues(t *testing.T) {
s, fc := newTestScanner()
fc.repos["alterminal"] = []Repo{
{Name: "teai"},
{Name: "colony-tick-demo", Empty: true},
}
fc.issues["alterminal/colony-tick-demo"] = []Issue{
{Number: 1, Title: "初始化項目", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
}
issues, err := s.Mine()
if err != nil {
t.Fatal(err)
}
if len(issues) != 1 || issues[0].Num() != 1 {
t.Errorf("mine 應列出空倉庫中的 issue #1,得到 %d 件", len(issues))
}
}
func TestNextNone(t *testing.T) {
s, _ := newTestScanner()
work, err := s.Next()
if err != nil {
t.Fatal(err)
}
if work != nil {
t.Errorf("無工作應回 nil,得到 %+v", work)
}
}
func TestNextOldestFirst(t *testing.T) {
s, fc := newTestScanner()
fc.issues["alterminal/agents"] = []Issue{
{Number: 31, Title: "較新", UpdatedAt: "2026-09-09T00:00:00Z", Assignees: []User{*mkUser("alex")}},
{Number: 30, Title: "較舊", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
}
work, err := s.Next()
if err != nil {
t.Fatal(err)
}
if work.Number != 30 {
t.Errorf("應選最久未更新的 #30,得到 %+v", work)
}
}
func TestMineSkipsLastCommentByAssignee(t *testing.T) {
s, fc := newTestScanner()
fc.issues["alterminal/agents"] = []Issue{
{Number: 30, Title: "已在處理", UpdatedAt: "a", Assignees: []User{*mkUser("alex")}},
}
fc.comments["alterminal/agents#30"] = []Comment{
{CreatedAt: "b", User: mkUser("alex")},
}
issues, err := s.Mine()
if err != nil {
t.Fatal(err)
}
if len(issues) != 0 {
t.Errorf("最後留言來自 assignee → 應略過,得到 %d 件", len(issues))
}
}
func TestPullsMineReviewersExclusive(t *testing.T) {
s, _ := newTestScanner()
if _, err := s.Pulls(true, true); !errors.Is(err, errIncompatibleFlags) {
t.Errorf("--mine 與 --reviewer 併用應回 errIncompatibleFlags,得到 %v", err)
}
}
func TestMembersHasWork(t *testing.T) {
s, fc := newTestScanner()
// alex 有待審核 PR;max 無任何工作;queena 有分派 issue。
fc.pulls["alterminal/teai"] = []PullRequest{
{
Number: 11, UpdatedAt: "a", User: mkUser("max"),
RequestedReviewers: []User{*mkUser("alex")},
},
}
fc.issues["alterminal/agents"] = []Issue{
{Number: 30, UpdatedAt: "b", Assignees: []User{*mkUser("queena")}},
}
got, err := s.Members(true)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, []string{"alex", "queena"}) {
t.Errorf("應只有 alex 與 queena 有工作,得到 %v", got)
}
}
func TestErrorPropagation(t *testing.T) {
s, fc := newTestScanner()
fc.failOn["orgs"] = errors.New("boom")
if _, err := s.Next(); err == nil {
t.Errorf("API 錯誤應向上傳播,不應吞掉")
}
}
func TestRoleWorker(t *testing.T) {
s, _ := newTestScanner()
got, err := s.Role()
if err != nil {
t.Fatal(err)
}
if got != RoleWorker {
t.Errorf("Agents 團隊成員應為 worker,得到 %q", got)
}
}
func TestRoleSupervisor(t *testing.T) {
s, fc := newTestScanner()
fc.teams["alterminal"] = []Team{
{ID: 1, Name: "Agents"},
{ID: 2, Name: "Supervisor"},
}
fc.teamMembers[2] = []string{"alex"}
got, err := s.Role()
if err != nil {
t.Fatal(err)
}
if got != RoleSupervisor {
t.Errorf("Supervisor 團隊成員應為 supervisor,得到 %q", got)
}
}
func TestRoleSupervisorTakesPrecedence(t *testing.T) {
s, fc := newTestScanner()
// alex 同時在 Agents(newTestScanner 預設)與 Supervisor。
fc.teams["alterminal"] = []Team{
{ID: 1, Name: "Agents"},
{ID: 2, Name: "Supervisor"},
}
fc.teamMembers[2] = []string{"alex", "ceo"}
got, err := s.Role()
if err != nil {
t.Fatal(err)
}
if got != RoleSupervisor {
t.Errorf("同時屬於兩者時應為 supervisor,得到 %q", got)
}
}
func TestRoleNone(t *testing.T) {
s, fc := newTestScanner()
fc.currentUser = "outsider"
got, err := s.Role()
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("不在 Supervisor/Agents 應回空字串,得到 %q", got)
}
}
func TestRoleWorkerWhenSupervisorExists(t *testing.T) {
s, fc := newTestScanner()
fc.teams["alterminal"] = []Team{
{ID: 1, Name: "Agents"},
{ID: 2, Name: "Supervisor"},
}
fc.teamMembers[2] = []string{"ceo"}
got, err := s.Role()
if err != nil {
t.Fatal(err)
}
if got != RoleWorker {
t.Errorf("只在 Agents、不在 Supervisor 應為 worker,得到 %q", got)
}
}
func TestRoleErrorPropagation(t *testing.T) {
s, fc := newTestScanner()
fc.failOn["teams"] = errors.New("boom")
if _, err := s.Role(); err == nil {
t.Errorf("ListTeams 錯誤應向上傳播")
}
delete(fc.failOn, "teams")
fc.failOn["members"] = errors.New("boom")
if _, err := s.Role(); err == nil {
t.Errorf("ListTeamMembers 錯誤應向上傳播")
}
}
func TestPullsDefault(t *testing.T) {
s, fc := newTestScanner()
fc.pulls["alterminal/teai"] = []PullRequest{
{Number: 10, UpdatedAt: "a", User: mkUser("alex")},
}
fc.comments["alterminal/teai#10"] = []Comment{
{CreatedAt: "b", User: mkUser("ceo")},
}
items, err := s.Pulls(false, false)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Repo != "teai" {
t.Errorf("應列出待跟進 PR,得到 %+v", items)
}
}