diff --git a/doc/changes/output-table-determinism.md b/doc/changes/output-table-determinism.md new file mode 100644 index 0000000..01b39cf --- /dev/null +++ b/doc/changes/output-table-determinism.md @@ -0,0 +1,30 @@ +# 表格输出确定性排序与错误状态码展示 + +## 背景 + +`--format table` 的渲染依赖 Go map 的遍历顺序,而 Go 的 map 遍历是**随机的**: + +- `printMapTable`(单对象 KEY/VALUE 表)每次运行的行顺序都不一样; +- `collectKeys` 在补全非优先列时直接 `for k := range m`,导致 `printSliceTable`(列表表)优先列之后的**列顺序**也随机。 + +这会让同一条命令两次运行的表格输出不一致,难以肉眼对比、`diff`、截图或在脚本/测试中稳定断言。 + +此外,table 模式的错误输出只显示 `Error: `,**不显示状态码**,用户难以快速区分 404 / 422 / 500。 + +## 变更 + +- `collectKeys`:优先列(id/name/login/title/status/state/created_at/updated_at)之后的剩余列改为 `sort.Strings` 排序,列顺序稳定且可预期。 +- `printMapTable`:改为按 `collectKeys` 的顺序输出,行顺序确定,并与列表表的列顺序保持一致。 +- 错误输出:当存在错误码时显示 `Error []: `(无错误码时保持 `Error: `),便于快速识别状态码。 + +不影响 json / yaml 输出,也不改变成功数据的内容,仅稳定其呈现顺序与丰富错误提示。 + +## 测试 + +`internal/output/formatter_test.go` 新增: +- `collectKeys` 非优先列按字典序排序; +- 单对象表渲染 25 次输出完全一致(确定性); +- 列表表表头顺序 25 次渲染一致; +- 错误输出包含状态码 `Error [500]: ...`,无错误码时回退为 `Error: ...`。 + +`go build ./...`、`go vet ./...`、`go test ./...`、`gofmt -s` 全部通过。 diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dd0b59c..2fd778a 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -6,6 +6,7 @@ import ( "io" "os" "reflect" + "sort" "strings" "text/tabwriter" @@ -53,7 +54,11 @@ func printYAML(w io.Writer, envelope *Envelope) error { func printTable(w io.Writer, envelope *Envelope) error { if !envelope.OK { if envelope.Error != nil { - fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message) + if envelope.Error.Code != nil && fmt.Sprintf("%v", envelope.Error.Code) != "" { + fmt.Fprintf(w, "Error [%v]: %s\n", envelope.Error.Code, envelope.Error.Message) + } else { + fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message) + } if envelope.Error.Suggestion != "" { fmt.Fprintf(w, "Suggestion: %s\n", envelope.Error.Suggestion) } @@ -136,8 +141,10 @@ func printMapTable(w io.Writer, m map[string]interface{}) error { tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) fmt.Fprintln(tw, "KEY\tVALUE") fmt.Fprintln(tw, "---\t-----") - for k, v := range m { - fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v)) + // collectKeys yields a deterministic order (priority keys first, then the + // remaining keys sorted), so table output is stable across runs. + for _, k := range collectKeys(m) { + fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(m[k])) } return tw.Flush() } @@ -153,11 +160,16 @@ func collectKeys(m map[string]interface{}) []string { seen[k] = true } } + remaining := make([]string, 0, len(m)) for k := range m { if !seen[k] { - keys = append(keys, k) + remaining = append(remaining, k) } } + // Sort the non-priority keys so column/row order is deterministic instead of + // depending on Go's randomized map iteration order. + sort.Strings(remaining) + keys = append(keys, remaining...) return keys } diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index 80430b9..ea7999c 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -122,14 +122,85 @@ func TestPrintToTableError(t *testing.T) { t.Fatalf("PrintTo table error: %v", err) } out := buf.String() - if !strings.Contains(out, "Error: server error") { - t.Fatalf("expected error message, got: %s", out) + if !strings.Contains(out, "Error [500]: server error") { + t.Fatalf("expected error message with code, got: %s", out) } if !strings.Contains(out, "try again") { t.Fatalf("expected suggestion, got: %s", out) } } +func TestPrintToTableErrorWithoutCode(t *testing.T) { + var buf bytes.Buffer + env := ErrorEnvelope(nil, "plain error", "") + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo table error without code: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Error: plain error") { + t.Fatalf("expected plain error line, got: %s", out) + } + if strings.Contains(out, "Error [") { + t.Fatalf("did not expect a code prefix, got: %s", out) + } +} + +func TestCollectKeysSortsRemaining(t *testing.T) { + m := map[string]interface{}{ + "zebra": 1, "id": 1, "alpha": 1, "mango": 1, "name": 1, + } + keys := collectKeys(m) + // Priority keys first (id, name), then the rest in sorted order. + want := []string{"id", "name", "alpha", "mango", "zebra"} + if len(keys) != len(want) { + t.Fatalf("keys = %v, want %v", keys, want) + } + for i := range want { + if keys[i] != want[i] { + t.Fatalf("keys = %v, want %v", keys, want) + } + } +} + +func TestPrintToTableMapDeterministic(t *testing.T) { + m := map[string]interface{}{ + "zebra": "z", "id": float64(1), "alpha": "a", "name": "n", "mango": "m", + } + render := func() string { + var buf bytes.Buffer + if err := PrintTo(&buf, SuccessEnvelope(m, nil), "table"); err != nil { + t.Fatalf("render: %v", err) + } + return buf.String() + } + first := render() + for i := 0; i < 25; i++ { + if got := render(); got != first { + t.Fatalf("table output is not deterministic:\n%q\nvs\n%q", first, got) + } + } +} + +func TestPrintToTableSliceDeterministic(t *testing.T) { + items := []interface{}{ + map[string]interface{}{"id": float64(1), "zebra": "z", "alpha": "a", "name": "n"}, + map[string]interface{}{"id": float64(2), "zebra": "z2", "alpha": "a2", "name": "n2"}, + } + render := func() string { + var buf bytes.Buffer + if err := PrintTo(&buf, SuccessEnvelope(items, nil), "table"); err != nil { + t.Fatalf("render: %v", err) + } + return buf.String() + } + first := render() + for i := 0; i < 25; i++ { + if got := render(); got != first { + t.Fatalf("slice table header order is not deterministic:\n%q\nvs\n%q", first, got) + } + } +} + func TestPrintToTableErrorNoSuggestion(t *testing.T) { var buf bytes.Buffer env := ErrorEnvelope(500, "server error", "")