fix(output): deterministic --format table ordering and show error status code

--format table 的渲染依赖 Go map 随机遍历顺序:printMapTable 的行顺序、
以及 collectKeys 补全非优先列后 printSliceTable 的列顺序,每次运行都可能不同,
导致同一命令两次输出不一致,难以对比/diff/脚本断言。

- collectKeys:优先列之后的剩余列改为 sort.Strings 排序,顺序稳定可预期。
- printMapTable:按 collectKeys 顺序输出,行序确定且与列表表列序一致。
- 错误输出:有错误码时显示 Error [<code>]: <message>,便于区分 404/422/500。

仅稳定 table 呈现顺序并丰富错误提示,不影响 json/yaml 输出与成功数据内容。
新增单测覆盖排序、25 次渲染确定性、错误码展示。
This commit is contained in:
luwanzhou 2026-06-15 12:59:23 +08:00
parent ef7a2c6ac1
commit 2f4b7674d6
3 changed files with 119 additions and 6 deletions

View File

@ -0,0 +1,30 @@
# 表格输出确定性排序与错误状态码展示
## 背景
`--format table` 的渲染依赖 Go map 的遍历顺序,而 Go 的 map 遍历是**随机的**
- `printMapTable`(单对象 KEY/VALUE 表)每次运行的行顺序都不一样;
- `collectKeys` 在补全非优先列时直接 `for k := range m`,导致 `printSliceTable`(列表表)优先列之后的**列顺序**也随机。
这会让同一条命令两次运行的表格输出不一致,难以肉眼对比、`diff`、截图或在脚本/测试中稳定断言。
此外table 模式的错误输出只显示 `Error: <message>`**不显示状态码**,用户难以快速区分 404 / 422 / 500。
## 变更
- `collectKeys`优先列id/name/login/title/status/state/created_at/updated_at之后的剩余列改为 `sort.Strings` 排序,列顺序稳定且可预期。
- `printMapTable`:改为按 `collectKeys` 的顺序输出,行顺序确定,并与列表表的列顺序保持一致。
- 错误输出:当存在错误码时显示 `Error [<code>]: <message>`(无错误码时保持 `Error: <message>`),便于快速识别状态码。
不影响 json / yaml 输出,也不改变成功数据的内容,仅稳定其呈现顺序与丰富错误提示。
## 测试
`internal/output/formatter_test.go` 新增:
- `collectKeys` 非优先列按字典序排序;
- 单对象表渲染 25 次输出完全一致(确定性);
- 列表表表头顺序 25 次渲染一致;
- 错误输出包含状态码 `Error [500]: ...`,无错误码时回退为 `Error: ...`
`go build ./...`、`go vet ./...`、`go test ./...`、`gofmt -s` 全部通过。

View File

@ -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
}

View File

@ -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", "")