#51 的三就緒詞漏掉「等候審核」形態:alterminal#2 作者留言「無待修改 項目…等候 @chenyunda218 審核…請直接合併」實質已交棒,仍誤判 author-idle,巡邏回頭催已無待辦的作者。 IsReviewHandoff 改複合訊號:@目前審核者 ∧(就緒語 ∨(完成語 ∧ 等候語))。就緒語補「請…審核」;等候語=等候/等待…審核、待審、 請…合併(間距容 @帳號);完成語=已完成/無待修改等。新增防護: 預告式(即將/稍後/之後/預計…、條件句「…後請審核」)與百分比進度 (完成 80%)一律不算——作者自述還沒到位不能解讀成已交棒;句讀阻斷 避免誤傷「合併後,我會回報」真交棒尾巴,「後續」除外。 gitea.py _is_review_handoff 同步(另 PR);測試案例兩側同集 (fox#60、alterminal#2 實例+17 個邊界)。
231 lines
10 KiB
Go
231 lines
10 KiB
Go
// judge_test.go — 驗證工作流判定語義(對照 gitea.py):
|
||
// 最後留言判定、作者/審核者跟進條件、assignee 交集與排序。
|
||
package workflow
|
||
|
||
import (
|
||
"reflect"
|
||
"testing"
|
||
)
|
||
|
||
// mkUser 構造 User。
|
||
func mkUser(name string) *User { return &User{UserName: name} }
|
||
|
||
func TestUserNameFallback(t *testing.T) {
|
||
if got := (&User{UserName: "a", Login: "b"}).Name(); got != "a" {
|
||
t.Errorf("username 優先,得到 %q", got)
|
||
}
|
||
if got := (&User{Login: "b"}).Name(); got != "b" {
|
||
t.Errorf("login 回退,得到 %q", got)
|
||
}
|
||
if got := mkUser("").Name(); got != "" {
|
||
t.Errorf("空帳號,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
func TestLastComment(t *testing.T) {
|
||
comments := []Comment{
|
||
{CreatedAt: "2026-09-09T01:00:00Z", User: mkUser("alex")},
|
||
{CreatedAt: "2026-09-09T03:00:00Z", User: mkUser("max")},
|
||
{CreatedAt: "2026-09-09T02:00:00Z", User: mkUser("ceo")},
|
||
}
|
||
last := LastComment(comments)
|
||
if last == nil || last.Author() != "max" {
|
||
t.Errorf("應依 created_at 排序取最後(03:00 max),得到 %+v", last)
|
||
}
|
||
if LastComment(nil) != nil {
|
||
t.Errorf("無留言應回 nil")
|
||
}
|
||
}
|
||
|
||
func TestNeedsAuthorFollowup(t *testing.T) {
|
||
pr := &PullRequest{User: mkUser("alex")}
|
||
cases := []struct {
|
||
name string
|
||
comments []Comment
|
||
want bool
|
||
}{
|
||
{"無留言", nil, false},
|
||
{"最後留言是作者", []Comment{{CreatedAt: "a", User: mkUser("alex")}}, false},
|
||
{"最後留言是他人", []Comment{
|
||
{CreatedAt: "a", User: mkUser("alex")},
|
||
{CreatedAt: "b", User: mkUser("ceo")},
|
||
}, true},
|
||
{"留言者未知", []Comment{{CreatedAt: "a"}}, false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := NeedsAuthorFollowup(pr, c.comments); got != c.want {
|
||
t.Errorf("%s:得到 %v,want %v", c.name, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNeedsReviewerFollowup(t *testing.T) {
|
||
pr := &PullRequest{
|
||
User: mkUser("max"),
|
||
RequestedReviewers: []User{*mkUser("alex"), *mkUser("queena")},
|
||
}
|
||
comments := []Comment{{CreatedAt: "a", User: mkUser("max")}}
|
||
if !NeedsReviewerFollowup(pr, comments, "alex") {
|
||
t.Errorf("審核者且最後留言非自己 → 應需跟進")
|
||
}
|
||
if NeedsReviewerFollowup(pr, comments, "max") {
|
||
t.Errorf("作者本人 → 不應列入審核清單")
|
||
}
|
||
if NeedsReviewerFollowup(pr, []Comment{{CreatedAt: "a", User: mkUser("alex")}}, "alex") {
|
||
t.Errorf("最後留言是自己 → 不應需跟進")
|
||
}
|
||
if NeedsReviewerFollowup(pr, comments, "ceo") {
|
||
t.Errorf("非審核者 → 不應需跟進")
|
||
}
|
||
// 尚無留言的 PR:審核者仍應被列入(含尚無留言)。
|
||
if !NeedsReviewerFollowup(pr, nil, "queena") {
|
||
t.Errorf("尚無留言 → 審核者應需跟進")
|
||
}
|
||
}
|
||
|
||
func TestAssigneeNames(t *testing.T) {
|
||
issue := &Issue{
|
||
Assignee: mkUser("alex"),
|
||
Assignees: []User{*mkUser("alex"), *mkUser("max")},
|
||
}
|
||
if got := AssigneeNames(issue); !reflect.DeepEqual(got, []string{"alex", "max"}) {
|
||
t.Errorf("assignee 應去重排序,得到 %v", got)
|
||
}
|
||
}
|
||
|
||
func TestAssigneeOverlapsEmptyMembers(t *testing.T) {
|
||
issue := &Issue{Assignee: mkUser("alex")}
|
||
if AssigneeOverlaps(issue, nil) {
|
||
t.Errorf("成員集為空 → 不應列入(同 gitea.py filter_issues_by_assignee)")
|
||
}
|
||
}
|
||
|
||
func TestLastCommentNotByAssignee(t *testing.T) {
|
||
issue := &Issue{Assignee: mkUser("alex")}
|
||
if !LastCommentNotByAssignee(issue, nil) {
|
||
t.Errorf("無留言 → 應視為需要處理")
|
||
}
|
||
if LastCommentNotByAssignee(issue, []Comment{{CreatedAt: "a", User: mkUser("alex")}}) {
|
||
t.Errorf("最後留言來自 assignee → 不應列入")
|
||
}
|
||
if !LastCommentNotByAssignee(issue, []Comment{{CreatedAt: "a", User: mkUser("ceo")}}) {
|
||
t.Errorf("最後留言來自他人 → 應列入")
|
||
}
|
||
}
|
||
|
||
func TestUpdatedKeyFallbackChain(t *testing.T) {
|
||
i := &Issue{UpdatedAt: "", Updated: "u", CreatedAt: "c"}
|
||
if got := i.updatedKey(); got != "u" {
|
||
t.Errorf("updated 回退鏈,得到 %q", got)
|
||
}
|
||
if got := (&Issue{}).updatedKey(); got != "9999" {
|
||
t.Errorf("全缺 → 9999,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
func TestFullNameFallbacks(t *testing.T) {
|
||
pr := &PullRequest{
|
||
HTMLURL: "https://gitea.alterminal.com/alterminal/teai/pulls/2",
|
||
Base: &PRRef{Repo: &Repository{FullName: "alterminal/teai"}},
|
||
}
|
||
if got := pr.FullName(); got != "alterminal/teai" {
|
||
t.Errorf("base.repo 優先,得到 %q", got)
|
||
}
|
||
pr2 := &PullRequest{HTMLURL: "https://gitea.alterminal.com/alterminal/teai/pulls/2"}
|
||
if got := pr2.FullName(); got != "alterminal/teai" {
|
||
t.Errorf("html_url 回退,得到 %q", got)
|
||
}
|
||
i := &Issue{Repository: &Repository{FullName: "alterminal/agents"}}
|
||
if got := i.FullName(); got != "alterminal/agents" {
|
||
t.Errorf("repository 欄位,得到 %q", got)
|
||
}
|
||
if got := (&Issue{}).FullName(); got != "" {
|
||
t.Errorf("全缺 → 空,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
// TestIsDeliveryReport(agents #47):交付報告=結構訊號 ∧ 交棒訊號。
|
||
// 案例與 gitea.py _is_delivery_report 的離線驗證同集(bear#52 實例)。
|
||
func TestIsDeliveryReport(t *testing.T) {
|
||
body52 := "## 完成定義\n- 逐項回報查核結果至此 issue;\n- 全數通過 → 回報 #39 並由 #39 assignee 關閉"
|
||
claim := "認領。我來執行四項驗收查核,完成後逐項回報結果至此。"
|
||
report := "## 全階段驗收查核結果(#52)\n**結論:四項全數通過。** 逐項如下…\n四項全過,符合「完成定義」。回報 #39:全數驗收通過,可由 @ceo 關閉 #39 與里程碑。"
|
||
reportFail := "## 查核結果\n結論:第 2 項不通過。已開立修復 issue #60,請 @ceo 確認分配。"
|
||
cases := []struct {
|
||
name string
|
||
issueBody string
|
||
comment string
|
||
want bool
|
||
}{
|
||
{"交付報告+回報#39+@ceo 關閉", body52, report, true},
|
||
{"失敗型交付(開修復 issue+請確認)", body52, reportFail, true},
|
||
{"認領留言(逐項+回報 但無交棒)", body52, claim, false},
|
||
{"純進度留言", body52, "進行中,已看完 P1,預計今晚交付。", false},
|
||
{"已完成但無交棒", body52, "已完成,程式碼都在分支上。", false},
|
||
{"無完成定義內文+逐項交棒", "一般 issue 內文", "逐項測試結果全數通過,請 @ceo 確認後關閉。", true},
|
||
{"無完成定義+僅交棒(無結構)", "一般 issue 內文", "請 @ceo 確認後關閉。", false},
|
||
{"空留言", body52, "", false},
|
||
}
|
||
for _, tc := range cases {
|
||
var last *Comment
|
||
if tc.comment != "" {
|
||
last = &Comment{Body: tc.comment}
|
||
}
|
||
if got := IsDeliveryReport(tc.issueBody, last); got != tc.want {
|
||
t.Errorf("%s:IsDeliveryReport = %v,want %v", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
if IsDeliveryReport(body52, nil) {
|
||
t.Error("nil 最後留言應為 false")
|
||
}
|
||
}
|
||
|
||
// TestIsReviewHandoff(agents #51、#53):審核交棒=@審核者帳號 ∧ 交棒訊號
|
||
// (就緒語 ∨(完成語 ∧ 等候語))。案例與 gitea.py _is_review_handoff 的
|
||
// 離線驗證同集(fox#60、alterminal#2 實例)。
|
||
func TestIsReviewHandoff(t *testing.T) {
|
||
// fox#60 作者(alex)最後留言結尾(2026-09-16 08:05)。
|
||
fox60 := "狀態回報(作者端自查,分支無需再變更):\n\n- 已重新核對分支與 main 的差異…\n- PR 分支 CI 已通過(run 209)。\n\n@chenyunda218 PR 已就緒,可審閱。@ceo 已知悉管理者巡邏的備註;審核合併後,我會在下一個 main 部署 run 跑通時回 #58 驗收。"
|
||
// alterminal#2 作者(alex)最後留言節錄(2026-10-04 03:06,#53 案例):
|
||
// 有 @審核者+完成回報+「等候審核」+「請直接合併」,但無 #51 三就緒詞。
|
||
al2 := "狀態確認:PR #2 內容已依 issue #1 完成(commit e1f2516、分支 feat/dockerfile),交付回報已於 2026-10-03 22:59 留在 #1,包含建置驗證與煙霧測試結果;目前 mergeable、無待修改項目。\n\n等候 @chenyunda218 審核:若內容符合預期請直接合併(合併後 #1 即可關閉);若有修改意見請在 PR 留言,我會在原分支 feat/dockerfile 接續更新。若本週仍無法排入審核,請回報,以便另安排審核者。"
|
||
cases := []struct {
|
||
name string
|
||
comment string
|
||
reviewers []string
|
||
want bool
|
||
}{
|
||
{"fox#60 實例(@審核者+已就緒,可審閱)", fox60, []string{"chenyunda218"}, true},
|
||
{"alterminal#2 實例(@審核者+無待修改+等候審核+請合併,#53)", al2, []string{"chenyunda218"}, true},
|
||
{"多位審核者命中其一", fox60, []string{"ceo", "chenyunda218"}, true},
|
||
{"前綴碰撞(@chenyunda218 ≠ chenyunda)", fox60, []string{"chenyunda"}, false},
|
||
{"就緒語但無 @(無點名對象)", fox60, []string{"dan"}, false},
|
||
{"進度回報(無就緒語)", "進行中:已改完兩步,CI 跑著,@chenyunda218 稍後再看。", []string{"chenyunda218"}, false},
|
||
{"預告式(即將就緒不算)", "@chenyunda218 快好了,即將整理完成。", []string{"chenyunda218"}, false},
|
||
{"預告式(之後再請審核不算)", "@chenyunda218 還有兩項要改,之後再請審核。", []string{"chenyunda218"}, false},
|
||
{"百分比進度(完成 80% 不算)", "@chenyunda218 進度:已完成 80%,剩下的明天補上,屆時再請合併。", []string{"chenyunda218"}, false},
|
||
{"仍在實作(有請合併但無完成自述)", "@chenyunda218 實作完成後請審核合併。", []string{"chenyunda218"}, false},
|
||
{"仍在實作(有等候審核但無完成自述)", "@chenyunda218 還在實作,等候審核語先記著:等待審核中。", []string{"chenyunda218"}, false},
|
||
{"等候審核+已完成(複合訊號命中)", "@chenyunda218 所有修改已完成,等待審核。", []string{"chenyunda218"}, true},
|
||
{"待審+無待辦(複合訊號命中)", "PR 無待辦,@chenyunda218 待審中。", []string{"chenyunda218"}, true},
|
||
{"請審核(點名就緒語命中)", "@chenyunda218 請審核。", []string{"chenyunda218"}, true},
|
||
{"請儘速審核(擴充就緒語命中)", "@chenyunda218 內容無待辦,請儘速審核。", []string{"chenyunda218"}, true},
|
||
{"預告式(預計完成後請合併不算)", "@chenyunda218 預計今晚完成,屆時請合併。", []string{"chenyunda218"}, false},
|
||
{"後續請審核(完成已發生,非條件句)", "@chenyunda218 所有修改已完成,後續請審核。", []string{"chenyunda218"}, true},
|
||
{"無審核者", fox60, nil, false},
|
||
{"空留言", "", []string{"chenyunda218"}, false},
|
||
}
|
||
for _, tc := range cases {
|
||
var last *Comment
|
||
if tc.comment != "" {
|
||
last = &Comment{Body: tc.comment}
|
||
}
|
||
if got := IsReviewHandoff(last, tc.reviewers); got != tc.want {
|
||
t.Errorf("%s:IsReviewHandoff = %v,want %v", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
if IsReviewHandoff(nil, []string{"chenyunda218"}) {
|
||
t.Error("nil 最後留言應為 false")
|
||
}
|
||
}
|