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:
parent
ef7a2c6ac1
commit
2f4b7674d6
|
|
@ -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` 全部通过。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
Loading…
Reference in New Issue