// 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) } }