// milestones_test.go — MilestoneOverview 的單元測試(#31)。 // // 資料源注入假實作(離線可測);鍵序、null 表示、排序與 overdue // 判定對照 gitea.py list_milestone_overview 的 Python 語義。 package workflow import ( "encoding/json" "strings" "testing" "time" ) // fakeMilestoneSource 以記憶體資料實作 MilestoneSource。 type fakeMilestoneSource struct { orgs []string repos map[string][]Repo milestones map[string][]Milestone openIssues map[string][]Issue // key: owner/repo?milestone= } func (f *fakeMilestoneSource) MyOrganizations() ([]string, error) { return f.orgs, nil } func (f *fakeMilestoneSource) OrganizationRepositories(org string) ([]Repo, error) { return f.repos[org], nil } func (f *fakeMilestoneSource) OpenMilestones(owner, repo string) ([]Milestone, error) { return f.milestones[owner+"/"+repo], nil } func (f *fakeMilestoneSource) MilestoneOpenIssues(owner, repo, title string) ([]Issue, error) { return f.openIssues[owner+"/"+repo+"?milestone="+title], nil } func msPtr(s string) *string { return &s } func idPtr(i int64) *int64 { return &i } // newMilestoneFixture 建構情境:alterminal 兩倉庫、三里程碑 // (teai 有期限將至、agents 有逾期與無期限)。 func newMilestoneFixture() *fakeMilestoneSource { return &fakeMilestoneSource{ orgs: []string{"alterminal"}, repos: map[string][]Repo{"alterminal": { {Name: "agents", HasPullRequests: boolPtr(true)}, {Name: "teai", HasPullRequests: boolPtr(true)}, {Name: "empty-repo", Empty: true}, // 空倉庫應跳過 }}, milestones: map[string][]Milestone{ "alterminal/agents": { {ID: idPtr(7), Title: "MVP", State: "open", DueOn: msPtr("2026-09-09T00:00:00Z"), UpdatedAt: "2026-09-01T00:00:00Z", OpenIssues: 3, ClosedIssues: 1}, {ID: idPtr(8), Title: "v1", State: "open", DueOn: nil, UpdatedAt: "2026-09-02T00:00:00Z", OpenIssues: 1, ClosedIssues: 0}, }, "alterminal/teai": { {ID: idPtr(3), Title: "CLI", State: "open", DueOn: msPtr("2026-09-12T00:00:00Z"), UpdatedAt: "2026-09-05T00:00:00Z", OpenIssues: 2, ClosedIssues: 5}, }, }, openIssues: map[string][]Issue{ // agents MVP:一個未分派 issue、一個未分派 PR、一個已分派 issue。 "alterminal/agents?milestone=MVP": { {Number: 31, Title: "總覽命令", State: "open", UpdatedAt: "2026-09-10T01:00:00Z", Repository: &Repository{FullName: "alterminal/agents"}, HTMLURL: "https://gitea.alterminal.com/alterminal/agents/issues/31"}, {Number: 32, Title: "文件", State: "open", UpdatedAt: "2026-09-08T00:00:00Z", Repository: &Repository{FullName: "alterminal/agents"}, HTMLURL: "https://gitea.alterminal.com/alterminal/agents/pulls/32", PullRequest: &pullMarker{}, User: &User{UserName: "alex"}}, {Number: 33, Title: "已分派", State: "open", UpdatedAt: "2026-09-07T00:00:00Z", Repository: &Repository{FullName: "alterminal/agents"}, HTMLURL: "https://gitea.alterminal.com/alterminal/agents/issues/33", Assignee: &User{UserName: "queena"}}, }, // agents v1(無期限):條目比里程碑本身新 → updated_at 取條目。 "alterminal/agents?milestone=v1": { {Number: 40, Title: "無人理", State: "open", UpdatedAt: "2026-09-09T23:30:00Z", Repository: &Repository{FullName: "alterminal/agents"}, HTMLURL: "https://gitea.alterminal.com/alterminal/agents/issues/40"}, }, }, } } // TestMilestoneOverviewSortAndOverdue 驗證排序(期限近者在前、無期限在後) // 與 overdue/due_hours 計算。 func TestMilestoneOverviewSortAndOverdue(t *testing.T) { now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC) entries, err := MilestoneOverview(newMilestoneFixture(), "https://gitea.alterminal.com", now) if err != nil { t.Fatalf("MilestoneOverview: %v", err) } if len(entries) != 3 { t.Fatalf("應有 3 筆(空倉庫跳過),得到 %d", len(entries)) } // 排序:MVP(2026-09-09,已過期)→ CLI(2026-09-12)→ v1(無期限)。 wantOrder := []string{"alterminal/agents#MVP", "alterminal/teai#CLI", "alterminal/agents#v1"} for i, want := range wantOrder { got := entries[i].Repo + "#" + entries[i].Title if got != want { t.Errorf("第 %d 筆應為 %s,得到 %s", i, want, got) } } // MVP:due 2026-09-09T00:00Z、now 09-10T08:00Z → 32 小時、逾期。 if !entries[0].Overdue { t.Errorf("MVP 應逾期") } if entries[0].DueHours == nil || *entries[0].DueHours != 32.0 { t.Errorf("MVP due_hours 應 32.0,得到 %v", entries[0].DueHours) } // CLI:未到期。 if entries[1].Overdue { t.Errorf("CLI 不應逾期") } // v1:無期限 → null。 if entries[2].DueOn != nil || entries[2].DueHours != nil || entries[2].Overdue { t.Errorf("v1 無期限應為 null/false,得到 %+v", entries[2]) } } // TestMilestoneOverviewUnassigned 驗證未分派條目摘要(issue 無 author、 // pull 帶 author)與 updated_at 取最大值。 func TestMilestoneOverviewUnassigned(t *testing.T) { now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC) entries, err := MilestoneOverview(newMilestoneFixture(), "https://gitea.alterminal.com", now) if err != nil { t.Fatalf("MilestoneOverview: %v", err) } var mvp *MilestoneEntry for i := range entries { if entries[i].Title == "MVP" { mvp = &entries[i] } } if mvp == nil { t.Fatalf("找不到 MVP") } // 未分派:#31(issue)、#32(PR, author=alex);#33 已分派不列。 if len(mvp.Unassigned) != 2 { t.Fatalf("MVP 應有 2 筆未分派,得到 %d:%+v", len(mvp.Unassigned), mvp.Unassigned) } if mvp.Unassigned[0].Type != "issue" || mvp.Unassigned[0].Number != 31 { t.Errorf("第一筆未分派應為 issue #31,得到 %+v", mvp.Unassigned[0]) } if mvp.Unassigned[1].Type != "pull" || mvp.Unassigned[1].Number != 32 || mvp.Unassigned[1].Author != "alex" { t.Errorf("第二筆未分派應為 pull #32(author=alex),得到 %+v", mvp.Unassigned[1]) } // updated_at:max(里程碑 09-01, #31 09-10T01:00Z) → 後者。 if mvp.UpdatedAt == nil || *mvp.UpdatedAt != "2026-09-10T01:00:00+00:00" { t.Errorf("MVP updated_at 應取條目最大值 2026-09-10T01:00:00+00:00,得到 %v", mvp.UpdatedAt) } // url 組法。 if mvp.URL != "https://gitea.alterminal.com/alterminal/agents/milestone/7" { t.Errorf("MVP url 應為 milestone/7,得到 %s", mvp.URL) } } // TestMilestoneOverviewJSONKeys 驗證 JSON 鍵集與鍵序與 gitea.py 一致, // 以及 null/陣列空表示(unassigned 空 → [])。 func TestMilestoneOverviewJSONKeys(t *testing.T) { now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC) entries, err := MilestoneOverview(newMilestoneFixture(), "", now) if err != nil { t.Fatalf("MilestoneOverview: %v", err) } // v1:無期限、無未分派條目 → due_on/due_hours/updated_at 的 null 表示、 // unassigned=[]、webBase 空 → url=""。 var v1 *MilestoneEntry for i := range entries { if entries[i].Title == "v1" { v1 = &entries[i] } } if v1 == nil { t.Fatalf("找不到 v1") } // 手寫期望 JSON(縮排 2、鍵序照 gitea.py dict 實字序)。 b, err := json.Marshal(v1) if err != nil { t.Fatalf("Marshal: %v", err) } // 以逐鍵比對為準(MarshalIndent 因自訂 MarshalJSON 不縮排內層)。 var obj map[string]json.RawMessage if err := json.Unmarshal(b, &obj); err != nil { t.Fatalf("Unmarshal: %v", err) } wantKeys := []string{"repo", "id", "title", "url", "open_issues", "closed_issues", "due_on", "due_hours", "overdue", "unassigned", "updated_at"} // 鍵序:json.Unmarshal 到 map 會失序;改檢查原始字串的鍵出現順序。 pos := -1 for _, k := range wantKeys { i := strings.Index(string(b), `"`+k+`":`) if i < 0 { t.Fatalf("輸出缺少鍵 %q:%s", k, b) } if i < pos { t.Fatalf("鍵 %q 順序錯誤:%s", k, b) } pos = i } for _, kv := range []struct { key string want string }{ {"repo", `"alterminal/agents"`}, {"due_on", `null`}, {"due_hours", `null`}, {"overdue", `false`}, {"url", `""`}, } { if got := string(obj[kv.key]); got != kv.want { t.Errorf("%s 應為 %s,得到 %s", kv.key, kv.want, got) } } if !strings.Contains(string(b), `"number":40`) { t.Errorf("未分派條目應含 number 40:%s", b) } // pull 條目的 author 鍵:有作者才輸出(無則不該出現 "author")。 if strings.Contains(string(obj["unassigned"]), `"author"`) { t.Errorf("issue 條目不應有 author 鍵:%s", obj["unassigned"]) } } // TestMilestoneOverviewDueHoursRounding 驗證 due_hours 捨入到一位小數, // 且 overdue 以未捨入值判定(0.04h → 0.0 但 overdue=true)。 func TestMilestoneOverviewDueHoursRounding(t *testing.T) { src := &fakeMilestoneSource{ orgs: []string{"alterminal"}, repos: map[string][]Repo{"alterminal": {{Name: "r"}}}, milestones: map[string][]Milestone{ "alterminal/r": { {ID: idPtr(1), Title: "edge", State: "open", DueOn: msPtr("2026-09-10T07:57:36Z"), UpdatedAt: "2026-09-10T07:00:00Z"}, }, }, openIssues: map[string][]Issue{}, } now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC) entries, err := MilestoneOverview(src, "", now) if err != nil { t.Fatalf("MilestoneOverview: %v", err) } if len(entries) != 1 { t.Fatalf("應有 1 筆,得到 %d", len(entries)) } // 144 秒 = 0.04 小時 → round1 → 0.0;未捨入 > 0 → overdue=true。 b, err := json.Marshal(entries[0]) if err != nil { t.Fatalf("Marshal: %v", err) } if !strings.Contains(string(b), `"due_hours":0.0`) { t.Errorf("due_hours 應捨入為 0.0:%s", b) } if !strings.Contains(string(b), `"overdue":true`) { t.Errorf("overdue 應以未捨入值判定為 true:%s", b) } }