// milestones_commands_test.go — 跨倉庫里程碑總覽的 CLI 接線層測試(#31): // 注入假 MilestoneSource,驗證 Run→dispatch→輸出完整路徑,不走網路 // (與 stalled_commands_test.go 同模式)。 package cli import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "gitea.alterminal.com/alterminal/teai/internal/workflow" ) // stubMilestoneSource 以記憶體資料實作 workflow.MilestoneSource。 type stubMilestoneSource struct { orgs []string repos map[string][]workflow.Repo milestones map[string][]workflow.Milestone openIssues map[string][]workflow.Issue // key: owner/repo?milestone= } func (s *stubMilestoneSource) MyOrganizations() ([]string, error) { return s.orgs, nil } func (s *stubMilestoneSource) OrganizationRepositories(org string) ([]workflow.Repo, error) { return s.repos[org], nil } func (s *stubMilestoneSource) OpenMilestones(owner, repo string) ([]workflow.Milestone, error) { return s.milestones[owner+"/"+repo], nil } func (s *stubMilestoneSource) MilestoneOpenIssues(owner, repo, title string) ([]workflow.Issue, error) { return s.openIssues[owner+"/"+repo+"?milestone="+title], nil } // newStubMilestoneEnv 注入假資料源並固定時刻。 func newStubMilestoneEnv(t *testing.T, src *stubMilestoneSource, frozen time.Time) { t.Helper() origSrc, origNow := newMilestoneSourceFunc, milestonesNowFunc newMilestoneSourceFunc = func(env *Env) (workflow.MilestoneSource, error) { return src, nil } milestonesNowFunc = func() time.Time { return frozen } t.Cleanup(func() { newMilestoneSourceFunc, milestonesNowFunc = origSrc, origNow }) } // stubPullIssue 以 JSON 建構帶 pull_request 標記的條目(pullMarker 未匯出, // 測試從套件外以 API 回應的形狀建構,走 Issue.UnmarshalJSON 同一路徑)。 func stubPullIssue(t *testing.T) workflow.Issue { t.Helper() var issue workflow.Issue err := json.Unmarshal([]byte(`{ "number": 32, "title": "文件", "state": "open", "updated_at": "2026-09-08T00:00:00Z", "html_url": "https://gitea.alterminal.com/alterminal/agents/pulls/32", "user": {"username": "alex"}, "pull_request": {} }`), &issue) if err != nil { t.Fatalf("建構 stub PR 條目失敗:%v", err) } return issue } // idPtr/msPtr 是測試用指標 helper(workflow 套件內同名函式未匯出)。 func idPtr(i int64) *int64 { return &i } func msPtr(s string) *string { return &s } // newStubMilestoneData 建構總覽情境:兩倉庫各一里程碑、一個未分派 PR。 func newStubMilestoneData(t *testing.T) *stubMilestoneSource { due := "2026-09-09T00:00:00Z" return &stubMilestoneSource{ orgs: []string{"alterminal"}, repos: map[string][]workflow.Repo{"alterminal": { {Name: "agents"}, {Name: "teai"}, }}, milestones: map[string][]workflow.Milestone{ "alterminal/agents": { {ID: idPtr(7), Title: "MVP", State: "open", DueOn: &due, UpdatedAt: "2026-09-01T00: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: 1, ClosedIssues: 2}, }, }, openIssues: map[string][]workflow.Issue{ "alterminal/agents?milestone=MVP": {stubPullIssue(t)}, }, } } // TestWiringMilestonesOverview 驗證 teai milestones 輸出跨倉庫總覽 // (JSON 縮排 2、鍵序與欄位齊 gitea.py)。 func TestWiringMilestonesOverview(t *testing.T) { newStubMilestoneEnv(t, newStubMilestoneData(t), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) stdout, _, code := run("milestones") if code != 0 { t.Fatalf("milestones 應回 0,得到 %d(stdout=%q)", code, stdout) } for _, want := range []string{ `"repo": "alterminal/agents"`, `"title": "MVP"`, `"overdue": true`, `"type": "pull"`, `"author": "alex"`, } { if !strings.Contains(stdout, want) { t.Fatalf("milestones 輸出應含 %q,得到:\n%s", want, stdout) } } // 排序:agents MVP(09-09)在 teai CLI(09-12)前。 if strings.Index(stdout, `"title": "MVP"`) > strings.Index(stdout, `"title": "CLI"`) { t.Fatalf("期限近者應在前:\n%s", stdout) } } // TestWiringMilestonesEmpty 驗證無里程碑 → [](#26 慣例)。 func TestWiringMilestonesEmpty(t *testing.T) { newStubMilestoneEnv(t, &stubMilestoneSource{ orgs: []string{"alterminal"}, repos: map[string][]workflow.Repo{"alterminal": {{Name: "agents"}}}, milestones: map[string][]workflow.Milestone{}, openIssues: map[string][]workflow.Issue{}, }, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) stdout, _, code := run("milestones") if code != 0 { t.Fatalf("milestones 空清單應回 0,得到 %d", code) } if strings.TrimSpace(stdout) != "[]" { t.Fatalf("無里程碑應輸出 [],得到 %q", stdout) } } // TestWiringMilestonesTable 驗證 table 輸出(人工抽查用)。 func TestWiringMilestonesTable(t *testing.T) { newStubMilestoneEnv(t, newStubMilestoneData(t), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) stdout, _, code := run("milestones", "--output", "table") if code != 0 { t.Fatalf("milestones --output table 應回 0,得到 %d", code) } for _, want := range []string{"repo", "overdue", "MVP", "alterminal/agents"} { if !strings.Contains(stdout, want) { t.Fatalf("table 輸出應含 %q,得到:\n%s", want, stdout) } } } // TestWiringMilestonesRepoFlagCompat 驗證 teai milestones --repo a/b // 維持現行單倉庫 list 語義(#31:帶 --repo 時不變)。 func TestWiringMilestonesRepoFlagCompat(t *testing.T) { var gotPath, gotQuery string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotQuery = r.URL.Path, r.URL.RawQuery w.Write([]byte(`[{"title":"v1","state":"open","description":"","due_on":""}]`)) })) t.Cleanup(srv.Close) stdout, _, code := run("--url", srv.URL, "milestones", "--repo", "a/b") if code != 0 { t.Fatalf("milestones --repo a/b 應回 0,得到 %d", code) } if gotPath != "/api/v1/repos/a/b/milestones" { t.Fatalf("應呼叫 /repos/a/b/milestones,得到 %s", gotPath) } if !strings.Contains(gotQuery, "state=open") { t.Fatalf("查詢應含 state=open,得到 %q", gotQuery) } if !strings.Contains(stdout, `"title":"v1"`) { t.Fatalf("輸出應含 title v1,得到:%s", stdout) } } // TestWiringMilestonesUsage 驗證未知子命令與位置參數 → exit 2。 func TestWiringMilestonesUsage(t *testing.T) { newStubMilestoneEnv(t, newStubMilestoneData(t), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) if _, _, code := run("milestones", "bogus"); code != int(ExitUsage) { t.Fatalf("milestones bogus 應回 ExitUsage,得到 %d", code) } if _, _, code := run("milestones", "extra", "arg"); code != int(ExitUsage) { t.Fatalf("milestones 位置參數應回 ExitUsage,得到 %d", code) } // milestones list 仍照舊要求 --repo。 if _, _, code := run("milestones", "list"); code != int(ExitUsage) { t.Fatalf("milestones list 缺 --repo 應回 ExitUsage,得到 %d", code) } }