open-code-review/cmd/opencodereview/session_cmd_test.go
Soner 1b193db358
Some checks are pending
CI / cross-compile (arm64, linux) (push) Waiting to run
CI / cross-compile (arm64, windows) (push) Waiting to run
CI / cross-compile (arm64, darwin) (push) Waiting to run
CI / test (push) Waiting to run
CI / cross-compile (amd64, darwin) (push) Waiting to run
CI / cross-compile (amd64, windows) (push) Waiting to run
Deploy Pages / build (push) Waiting to run
Deploy Pages / deploy (push) Blocked by required conditions
feat(cli): add 'ocr session comments' to display saved review comments (#505) (#646)
Adds a new 'ocr session comments <session-id>' subcommand that prints the
review comments persisted in a session, rendered in the same style as
'ocr review' terminal output (path, line range, severity badge, suggestion
diff). Supports --json for machine-readable output and --severity/--category
comma-separated filters.

Comments are read from the review_item_done / review_item_reused checkpoint
records via a new session.LoadComments, mirroring resume replay semantics:
a later checkpoint for the same fingerprint supersedes the earlier one and a
subsequent failure drops it.

Also adds shell tab completion for session ids (session show, session
comments, and review --resume) and for the --severity/--category values.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:49:48 +08:00

347 lines
10 KiB
Go

package main
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/alibaba/open-code-review/internal/model"
"github.com/alibaba/open-code-review/internal/session"
)
func TestRunSessionList_TextIncludesSessionID(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionListCompat([]string{"--repo", repoDir}); err != nil {
t.Fatalf("runSessionList: %v", err)
}
})
if !strings.Contains(got, sh.SessionID) {
t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got)
}
if !strings.Contains(got, "abc123") {
t.Errorf("expected list output to contain commit range, got %q", got)
}
if !strings.Contains(got, "SESSION ID") {
t.Errorf("expected header, got %q", got)
}
}
func TestRunSessionList_JSON(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionListCompat([]string{"--repo", repoDir, "--json"}); err != nil {
t.Fatalf("runSessionList: %v", err)
}
})
var decoded []session.Summary
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, got)
}
if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID {
t.Fatalf("decoded = %+v", decoded)
}
}
func TestRunSessionList_EmptyRepo(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
got := captureStdout(t, func() {
if err := runSessionListCompat([]string{"--repo", repoDir}); err != nil {
t.Fatalf("runSessionList: %v", err)
}
})
if !strings.Contains(got, "No sessions found") {
t.Errorf("expected empty message, got %q", got)
}
}
func TestRunSessionShow_Text(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom")
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionShowCompat([]string{"--repo", repoDir, sh.SessionID}); err != nil {
t.Fatalf("runSessionShow: %v", err)
}
})
for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} {
if !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, got %q", want, got)
}
}
}
func TestRunSessionShow_JSON(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionShowCompat([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
t.Fatalf("runSessionShow: %v", err)
}
})
var payload struct {
Summary *session.Summary `json:"summary"`
Items []session.ItemDetail `json:"items"`
}
if err := json.Unmarshal([]byte(got), &payload); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, got)
}
if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID {
t.Fatalf("summary mismatch: %+v", payload.Summary)
}
if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" {
t.Fatalf("items = %+v", payload.Items)
}
}
func TestRunSessionComments_TextRendersLikeReview(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{
{Path: "a.go", Content: "possible nil deref", StartLine: 3, EndLine: 5, Severity: "high", Category: "bug"},
})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
for _, want := range []string{"a.go:3-5", "[bug · high]", "possible nil deref"} {
if !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, got %q", want, got)
}
}
}
func TestRunSessionComments_SeverityFilter(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{
{Path: "a.go", Content: "keep me", Severity: "high"},
{Path: "a.go", Content: "drop me", Severity: "low"},
})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--severity", "HIGH", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if !strings.Contains(got, "keep me") || strings.Contains(got, "drop me") {
t.Errorf("severity filter not applied, got %q", got)
}
got = captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--severity", "critical", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if !strings.Contains(got, "No comments match the given filters") {
t.Errorf("expected filter-miss message, got %q", got)
}
}
func TestRunSessionComments_JSON(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{
{Path: "a.go", Content: "note", Severity: "medium", Category: "style"},
})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
var decoded []model.LlmComment
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, got)
}
if len(decoded) != 1 || decoded[0].Content != "note" || decoded[0].Severity != "medium" {
t.Fatalf("decoded = %+v", decoded)
}
}
func TestRunSessionComments_JSONEmptyIsArray(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if strings.TrimSpace(got) != "[]" {
t.Errorf("expected empty JSON array, got %q", got)
}
}
func TestRunSessionComments_NoCommentsMessage(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if !strings.Contains(got, "No comments recorded in session") {
t.Errorf("expected no-comments message, got %q", got)
}
}
func TestRunSessionShow_MissingID(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
err := runSessionShowCompat([]string{})
if err == nil {
t.Fatal("expected error for missing session id")
}
}
func TestTruncateUnicode(t *testing.T) {
got := truncate("错误原因:超过限制", 6)
if !strings.HasSuffix(got, "…") {
t.Fatalf("expected ellipsis suffix, got %q", got)
}
if !strings.Contains(got, "错误") {
t.Fatalf("expected valid truncated unicode text, got %q", got)
}
}
func TestRunSession_UnknownSubcommand(t *testing.T) {
err := runSession([]string{"bogus"})
if err == nil {
t.Fatal("expected error for unknown sub-command")
}
}
func TestSessionDisplayUsesManifestStatusAndCoverage(t *testing.T) {
summary := session.Summary{
SessionID: "run-1",
SelectedFiles: 4,
CompletedFiles: 1,
ReusedFiles: 1,
FailedFiles: 1,
WaivedFiles: 1,
RunManifest: &session.RunManifest{
TerminalState: session.StatePartial,
},
}
if got := describeStatus(summary); got != "partial" {
t.Fatalf("status = %q", got)
}
if got := describeFiles(summary); !strings.Contains(got, "4") || !strings.Contains(got, "failed 1") || !strings.Contains(got, "waived 1") {
t.Fatalf("files = %q", got)
}
got := captureStdout(t, func() { printSessionDetail(os.Stdout, &summary, nil) })
if !strings.Contains(got, "4 selected = 1 completed + 1 reused + 1 failed + 1 waived") {
t.Fatalf("detail = %q", got)
}
}
func TestSessionDisplayUsesUnknownForInvalidManifestStatus(t *testing.T) {
for _, state := range []session.TerminalState{"", "bogus"} {
summary := session.Summary{
RunManifest: &session.RunManifest{TerminalState: state},
}
if got := describeStatus(summary); got != "unknown" {
t.Errorf("terminal state %q displayed as %q, want unknown", state, got)
}
}
}
func TestSessionDisplayDoesNotInferLegacyComplete(t *testing.T) {
summary := session.Summary{CompletedFiles: 2, Legacy: true}
if got := describeStatus(summary); got != "legacy" {
t.Fatalf("status = %q", got)
}
summary.Aborted = true
if got := describeStatus(summary); got != "aborted" {
t.Fatalf("status = %q", got)
}
}