Files
dingtalk-workspace-cli/internal/output/emitter_phase_c_test.go

845 lines
34 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"bytes"
"encoding/json"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// phaseCListFixture 是三格式(table/csv/ndjson)共用的列表载荷:
// 3 条同构对象记录,配 meta.count 与 meta.pagination 以便同时断言
// 「人读摘要」与「分页诊断行」两条分发行为。
func phaseCListFixture() *Envelope {
env := NewSuccessEnvelope([]any{
map[string]any{"id": "a", "name": "alpha"},
map[string]any{"id": "b", "name": "beta"},
map[string]any{"id": "c", "name": "gamma"},
})
env.Meta = &Meta{
Count: NewCount(3),
Pagination: &Pagination{
EndpointExhausted: false,
Pages: 2,
Items: 60,
NextToken: "cursor_xyz",
},
}
return env
}
// --- B35:format 值归一化(trim/小写)——normalizeFormat 既有实现的补差断言 ---
func TestNormalizeFormatTrimAndCaseExhaustive(t *testing.T) {
// 六格式 × (原始小写 / 大写 / 混合大小写 + 前后空白)全部命中对应常量;
// 归一化规则 trim + 小写,与 ResolveFormat/ParseFormat 单一事实源。
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
for _, want := range formats {
base := string(want)
for _, raw := range []string{
base,
strings.ToUpper(base),
" " + strings.ToUpper(base[:1]) + base[1:] + "\t",
} {
if got := normalizeFormat(raw, FormatJSON); got != want {
t.Fatalf("normalizeFormat(%q) = %q, want %q", raw, got, want)
}
// ParseFormat 薄包装与 normalizeFormat 结果恒一致(单一事实源)。
if got := ParseFormat(raw, FormatJSON); got != want {
t.Fatalf("ParseFormat(%q) = %q, want %q", raw, got, want)
}
}
}
// 空串 = 未指定 → fallback;未知值 → fallback(B36 降级的归一化前提)。
if got := normalizeFormat("", FormatTable); got != FormatTable {
t.Fatalf("normalizeFormat empty = %q, want fallback table", got)
}
if got := normalizeFormat(" \t ", FormatCSV); got != FormatCSV {
t.Fatalf("normalizeFormat whitespace-only = %q, want fallback csv", got)
}
if got := normalizeFormat("yaml", FormatJSON); got != FormatJSON {
t.Fatalf("normalizeFormat unknown = %q, want fallback json", got)
}
}
// --- B36:未知 format 降级 + stderr warning(不崩不静默,AC-09)---
func TestResolveFormatWithWarningKnownValuesSilent(t *testing.T) {
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
for _, want := range formats {
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if err := cmd.Flags().Set("format", string(want)); err != nil {
t.Fatalf("set format: %v", err)
}
got, warning := resolveFormatWithWarning(cmd, FormatJSON)
if got != want {
t.Fatalf("resolveFormatWithWarning(%q) = %q, want %q", want, got, want)
}
if warning != "" {
t.Fatalf("known format %q must not warn, got %q", want, warning)
}
}
}
func TestResolveFormatWithWarningUnknownDegradesWithWarning(t *testing.T) {
for _, fallback := range []Format{FormatJSON, FormatTable} {
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if err := cmd.Flags().Set("format", "yaml"); err != nil {
t.Fatalf("set format: %v", err)
}
got, warning := resolveFormatWithWarning(cmd, fallback)
if got != fallback {
t.Fatalf("unknown format must degrade to fallback %q, got %q", fallback, got)
}
if warning == "" {
t.Fatal("unknown format must produce a warning (AC-09: no silent swallow)")
}
if !strings.Contains(warning, "yaml") || !strings.Contains(warning, string(fallback)) {
t.Fatalf("warning must name the unknown value and the fallback target: %q", warning)
}
}
}
func TestResolveFormatWithWarningEmptyAndMissingSilent(t *testing.T) {
// 空值 = 未指定(走 fallback 是正常默认路径),flag 缺席同:均不报警。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if got, warning := resolveFormatWithWarning(cmd, FormatRaw); got != FormatRaw || warning != "" {
t.Fatalf("empty format = (%q, %q), want (raw, silent)", got, warning)
}
cmd2 := &cobra.Command{Use: "t2"}
if got, warning := resolveFormatWithWarning(cmd2, FormatRaw); got != FormatRaw || warning != "" {
t.Fatalf("missing flag = (%q, %q), want (raw, silent)", got, warning)
}
if got, warning := resolveFormatWithWarning(nil, FormatJSON); got != FormatJSON || warning != "" {
t.Fatalf("nil cmd = (%q, %q), want (json, silent)", got, warning)
}
}
func TestResolveFormatKeepsWarningFreeReturnContract(t *testing.T) {
// ResolveFormat(既有导出 API)行为不变:只返回 format,未知值静默降级。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if err := cmd.Flags().Set("format", "bogus"); err != nil {
t.Fatalf("set format: %v", err)
}
if got := ResolveFormat(cmd, FormatJSON); got != FormatJSON {
t.Fatalf("ResolveFormat unknown = %q, want json fallback", got)
}
}
func TestWriteEnvelopeUnknownFormatDegradesToJSONWithStderrWarning(t *testing.T) {
// 端到端:未知 --format 不崩不静默——stdout 仍为完整 json 信封(降级
// 生效,数据不丢),stderr 恰有一条 warning 点名未知值与降级目标。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "yaml"); err != nil {
t.Fatalf("set format: %v", err)
}
env := NewSuccessEnvelope(map[string]any{"name": "DemoApp"})
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
t.Fatalf("WriteEnvelope with unknown format must not crash: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout must degrade to a full json envelope: %v\n%s", err, stdout.String())
}
if decoded["outcome"] != "success" {
t.Fatalf("degraded envelope outcome = %v, want success: %s", decoded["outcome"], stdout.String())
}
data, _ := decoded["data"].(map[string]any)
if data == nil || data["name"] != "DemoApp" {
t.Fatalf("degraded envelope lost data payload: %s", stdout.String())
}
if got := strings.Count(stderr.String(), "[WARN]"); got != 1 {
t.Fatalf("stderr must carry exactly one warning line, got %d: %s", got, stderr.String())
}
if !strings.Contains(stderr.String(), "yaml") || !strings.Contains(stderr.String(), "json") {
t.Fatalf("warning must name unknown value and fallback: %s", stderr.String())
}
}
func TestWriteEnvelopeKnownFormatLeavesStderrSilent(t *testing.T) {
// 已知 format 不产生 warning(stderr 零字节),防 B36 误伤正常路径。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "table"); err != nil {
t.Fatalf("set format: %v", err)
}
if err := WriteEnvelope(cmd, NewSuccessEnvelope(map[string]any{"name": "x"}), FormatJSON); err != nil {
t.Fatalf("WriteEnvelope: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("known format must not warn, stderr = %d bytes: %s", stderr.Len(), stderr.String())
}
}
// --- B37:信封渲染接统一 format 分发点(WS1 改动点4)---
func TestRenderEnvelopeSingleDispatchPointEmitterAndWriterAgree(t *testing.T) {
// 分发点唯一性:Emitter 路径与 WriteEnvelopeTo 路径对同一信封 + 同一
// format 逐字节一致——两条出口共用 renderEnvelope,不存在第二套渲染。
formats := []Format{FormatJSON, FormatTable, FormatPretty, FormatCSV, FormatNDJSON, FormatRaw}
for _, format := range formats {
env := phaseCListFixture()
var direct bytes.Buffer
if err := WriteEnvelopeTo(&direct, env, format, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(%s): %v", format, err)
}
var stdout, stderr bytes.Buffer
if err := NewEmitter(&stdout, &stderr, format, "", "").Emit(env); err != nil {
t.Fatalf("Emit(%s): %v", format, err)
}
if !bytes.Equal(direct.Bytes(), stdout.Bytes()) {
t.Fatalf("format %s: Emitter and WriteEnvelopeTo disagree:\n writer: %s\nemitter: %s",
format, direct.String(), stdout.String())
}
}
}
func TestRenderEnvelopeBufferFirstZeroLeakOnRenderFailure(t *testing.T) {
// §5.2 末条对整个分发点成立:渲染失败(不可序列化载荷 chan)
// 不向目标流泄漏任何字节——渲染段在写出前整体失败。
unrenderable := NewSuccessEnvelope(map[string]any{"bad": make(chan int)})
for _, format := range []Format{FormatJSON, FormatTable, FormatCSV, FormatNDJSON} {
target := &emitterFailingWriter{acceptN: 64}
err := WriteEnvelopeTo(target, unrenderable, format, "", "")
if err == nil {
t.Fatalf("format %s must surface render failure", format)
}
if len(target.accepted) != 0 {
t.Fatalf("format %s leaked %d bytes on render failure: %s", format, len(target.accepted), target.accepted)
}
}
}
func TestRenderEnvelopeFailureEnvelopeBypassesFormatAtDispatchPoint(t *testing.T) {
// 轮8裁决⑪在分发点内落地:failure 信封即使被调用方以 table/jq 配置
// 送入 renderEnvelope(WriteEnvelopeTo 层无流路由),也恒输出完整 JSON
// 信封——format/jq/fields 不得抹掉 §2.4 wire-stable 字段组。
var buf bytes.Buffer
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018, Retryable: true})
if err := WriteEnvelopeTo(&buf, env, FormatTable, "id", ".data"); err != nil {
t.Fatalf("WriteEnvelopeTo failure under table/jq: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("failure envelope must stay full JSON regardless of format: %v\n%s", err, buf.String())
}
if decoded["outcome"] != "failure" {
t.Fatalf("outcome = %v, want failure: %s", decoded["outcome"], buf.String())
}
errObj, ok := decoded["error"].(map[string]any)
if !ok {
t.Fatalf("failure envelope lost error detail: %s", buf.String())
}
for _, key := range []string{"type", "subtype", "code", "retryable"} {
if _, has := errObj[key]; !has {
t.Fatalf("wire-stable key %q lost under format/jq bypass: %s", key, buf.String())
}
}
}
// --- B38:-f json 输出完整信封(唯一 JSON 契约)---
func TestWriteEnvelopeJSONIsTheOnlyJSONContract(t *testing.T) {
// json 与空 format 等价:stdout 恰为一个完整信封 JSON 文档,
// ok/outcome/data/meta 外壳键齐备(唯一 JSON 契约,§5.2 首行)。
for _, format := range []Format{FormatJSON, ""} {
var buf bytes.Buffer
env := phaseCListFixture()
if err := WriteEnvelopeTo(&buf, env, format, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(%q): %v", format, err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("format %q stdout is not valid JSON: %v\n%s", format, err, buf.String())
}
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
}
if decoded["outcome"] != "success" {
t.Fatalf("outcome = %v, want success", decoded["outcome"])
}
for _, key := range []string{"data", "meta"} {
if _, has := decoded[key]; !has {
t.Fatalf("full envelope must carry %q: %s", key, buf.String())
}
}
}
}
func TestWriteEnvelopeJSONFieldsProjectionFiltersBusinessData(t *testing.T) {
// --fields 与 json 联动时只投影业务载荷,统一信封及分页元数据必须保留。
var buf bytes.Buffer
env := phaseCListFixture()
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "name", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
var decoded Envelope
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("fields=name output is not an envelope: %v\n%s", err, buf.String())
}
if !decoded.OK || decoded.Outcome != OutcomeSuccess || decoded.Meta == nil || decoded.Meta.Pagination == nil || decoded.Meta.Pagination.NextToken != "cursor_xyz" {
t.Fatalf("stable envelope metadata was lost: %+v", decoded)
}
items, ok := decoded.Data.([]any)
if !ok {
t.Fatalf("projected data type = %T", decoded.Data)
}
if len(items) != 3 {
t.Fatalf("projected items = %#v", items)
}
for _, item := range items {
row, ok := item.(map[string]any)
if !ok || len(row) != 1 || row["name"] == nil {
t.Fatalf("projected row = %#v", item)
}
}
// 投影不得改写原信封的 data。
data, ok := env.Data.([]any)
if !ok || len(data[0].(map[string]any)) <= 1 {
t.Fatalf("source envelope data mutated: %#v", env.Data)
}
// 未知 Format 常量的 json 兜底分支(归一化后理论不可达)遵循同一
// --fields 兼容语义:投影业务载荷而不是信封顶层键。
buf.Reset()
if err := WriteEnvelopeTo(&buf, NewSuccessEnvelope(map[string]any{"name": "DemoApp", "id": "1"}), Format("bogus"), "name", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(bogus format): %v", err)
}
if want := "{\n \"name\": \"DemoApp\"\n}\n"; buf.String() != want {
t.Fatalf("bogus format fields=name output = %q, want %q", buf.String(), want)
}
}
func TestJQOverridesFormatAndEvaluatesFullEnvelope(t *testing.T) {
// --jq 优先于 format(§5.2 矩阵行):-f table 下 jq 仍对**完整信封**
// 求值——.data 可取业务载荷、.ok/.outcome 可取框架字段。
for _, tc := range []struct {
jq string
want string
}{
{".ok", "true"},
{".outcome", `"success"`},
{".meta.pagination.next_token", `"cursor_xyz"`},
} {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", tc.jq); err != nil {
t.Fatalf("WriteEnvelopeTo jq=%q: %v", tc.jq, err)
}
if got := strings.TrimSpace(buf.String()); got != tc.want {
t.Fatalf("jq %q = %q, want %q (jq must win over -f table on the full envelope)", tc.jq, got, tc.want)
}
}
// .data 提取业务载荷:输出不再是信封(无 outcome 外壳键)。
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ".data"); err != nil {
t.Fatalf("WriteEnvelopeTo jq=.data: %v", err)
}
if strings.Contains(buf.String(), `"outcome"`) {
t.Fatalf("jq=.data output must not carry the envelope wrapper: %s", buf.String())
}
var rows []map[string]any
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
t.Fatalf("jq=.data must emit the bare data payload: %v\n%s", err, buf.String())
}
if len(rows) != 3 {
t.Fatalf("jq=.data rows = %d, want 3", len(rows))
}
}
// --- B39:-f table/pretty 输出业务数据 + 人读摘要(不输出信封外壳)---
func TestTableRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(table): %v", err)
}
out := buf.String()
// 数据行可见。
for _, want := range []string{"alpha", "beta", "gamma"} {
if !strings.Contains(out, want) {
t.Fatalf("table output missing data row %q:\n%s", want, out)
}
}
// 人读摘要:count 与分页摘要(wire 字段名 snake_case,两态显式——
// endpoint_exhausted:false 也输出,它是「可续跑」语义)。
if !strings.Contains(out, "count: 3") {
t.Fatalf("table output missing count summary:\n%s", out)
}
if !strings.Contains(out, "pagination: endpoint_exhausted: false, pages: 2, items: 60, next_token: cursor_xyz") {
t.Fatalf("table output missing pagination summary:\n%s", out)
}
// 不输出信封外壳键。
for _, forbidden := range []string{`"ok"`, `"outcome"`, `"endpoint_exhausted": `} {
if strings.Contains(out, forbidden) {
t.Fatalf("table output must not emit envelope wrapper (%q):\n%s", forbidden, out)
}
}
// stdout 不是 JSON 文档(人读视图)。
if strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Fatalf("table output should not be a JSON document:\n%s", out)
}
}
func TestTableWithoutMetaEmitsNoSummary(t *testing.T) {
// 无 meta 时不追加摘要行,不留空壳。
var buf bytes.Buffer
env := NewSuccessEnvelope([]any{map[string]any{"id": "a"}})
if err := WriteEnvelopeTo(&buf, env, FormatTable, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(table): %v", err)
}
out := buf.String()
if strings.Contains(out, "count:") || strings.Contains(out, "pagination:") {
t.Fatalf("no-meta table must not emit summary lines:\n%s", out)
}
if !strings.Contains(out, "a") {
t.Fatalf("table output missing data:\n%s", out)
}
}
func TestPrettyRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
forceNoColor(t)
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatPretty, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(pretty): %v", err)
}
out := buf.String()
for _, want := range []string{"alpha", "count: 3", "endpoint_exhausted: false"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
for _, forbidden := range []string{`"ok"`, `"outcome"`} {
if strings.Contains(out, forbidden) {
t.Fatalf("pretty output must not emit envelope wrapper (%q):\n%s", forbidden, out)
}
}
}
func TestTableWithFieldsProjectionNarrowsColumns(t *testing.T) {
// --fields 与 table 联动(成功通道):投影先作用于数据载荷再渲染。
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "id", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(table, fields=id): %v", err)
}
out := buf.String()
// id 列保留(值 a/b/c 可见),name 列与其值被投影掉。
for _, want := range []string{"id", "│ a", "│ b", "│ c"} {
if !strings.Contains(out, want) {
t.Fatalf("fields=id must keep id column/values (%q):\n%s", want, out)
}
}
if strings.Contains(out, "name") || strings.Contains(out, "alpha") {
t.Fatalf("fields=id must drop name column and its values:\n%s", out)
}
}
// --- B40:-f csv/ndjson 裸记录;分页元数据走 stderr 诊断行 ---
// newPhaseCCmd 构造带 --format flag 且 stdout/stderr 均可捕获的测试命令
// (诊断行断言必须走 cmd 出口路径:WriteEnvelopeTo 的 writer 级签名无
// stderr 出口,诊断行在该签名下按 io.Discard 处理)。
func newPhaseCCmd(t *testing.T, formatValue string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
cmd := &cobra.Command{Use: "t"}
cmd.Flags().StringP("format", "f", "", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", formatValue); err != nil {
t.Fatalf("set format: %v", err)
}
return cmd, &stdout, &stderr
}
func TestNDJSONEmitsBareRecordsWithoutEnvelope(t *testing.T) {
cmd, stdout, stderr := newPhaseCCmd(t, "ndjson")
env := NewSuccessEnvelope([]any{
map[string]any{"id": "a"},
map[string]any{"id": "b"},
map[string]any{"id": "c"},
})
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
t.Fatalf("WriteEnvelope(ndjson): %v", err)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 3 {
t.Fatalf("ndjson must emit one bare record per line, got %d:\n%s", len(lines), stdout.String())
}
for i, line := range lines {
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("ndjson line %d is not valid JSON: %v", i, err)
}
if _, has := rec["outcome"]; has {
t.Fatalf("ndjson line %d carries envelope wrapper: %s", i, line)
}
}
// 无 pagination 时 stderr 零诊断行。
if stderr.Len() != 0 {
t.Fatalf("no-pagination ndjson must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
}
}
func TestCSVEmitsBareRecordsWithoutEnvelope(t *testing.T) {
cmd, stdout, stderr := newPhaseCCmd(t, "csv")
env := NewSuccessEnvelope([]any{
map[string]any{"id": "a", "name": "alpha"},
map[string]any{"id": "b", "name": "beta"},
})
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
t.Fatalf("WriteEnvelope(csv): %v", err)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 3 { // header + 2 records
t.Fatalf("csv must emit header + one row per record, got %d lines:\n%s", len(lines), stdout.String())
}
if !strings.Contains(lines[0], "id") || !strings.Contains(lines[0], "name") {
t.Fatalf("csv header missing columns: %s", lines[0])
}
if strings.Contains(stdout.String(), `"outcome"`) || strings.Contains(stdout.String(), `"ok"`) {
t.Fatalf("csv must not emit envelope wrapper keys:\n%s", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("no-pagination csv must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
}
}
func TestCSVNDJSONPaginationMetadataGoesToStderrDiagnostic(t *testing.T) {
// 分页元数据不进裸记录流,走 stderr 诊断行(§5.2 csv/ndjson 行)。
for _, format := range []Format{FormatCSV, FormatNDJSON} {
cmd, stdout, stderr := newPhaseCCmd(t, string(format))
if err := WriteEnvelope(cmd, phaseCListFixture(), FormatJSON); err != nil {
t.Fatalf("WriteEnvelope(%s): %v", format, err)
}
diag := stderr.String()
if !strings.Contains(diag, "[pagination]") {
t.Fatalf("%s pagination diagnostic missing from stderr: %q", format, diag)
}
for _, want := range []string{"endpoint_exhausted: false", "next_token: cursor_xyz"} {
if !strings.Contains(diag, want) {
t.Fatalf("%s diagnostic missing %q: %q", format, want, diag)
}
}
if got := strings.Count(diag, "[pagination]"); got != 1 {
t.Fatalf("%s must emit exactly one pagination diagnostic line, got %d: %q", format, got, diag)
}
// stdout 保持纯裸记录:无诊断行、无信封键、无分页键。
out := stdout.String()
if strings.Contains(out, "[pagination]") || strings.Contains(out, "next_token") || strings.Contains(out, `"outcome"`) {
t.Fatalf("%s stdout polluted by diagnostics/envelope:\n%s", format, out)
}
}
}
func TestEmitterCSVNDJSONDiagnosticRoutedToEmitterStderr(t *testing.T) {
// Emitter 路径:诊断行落 Emitter 的 stderr writer(可注入),stdout
// 只承载裸记录;流路由纪律不变(success → stdout)。
for _, format := range []Format{FormatCSV, FormatNDJSON} {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, format, "", "")
if err := em.Emit(phaseCListFixture()); err != nil {
t.Fatalf("Emit(%s): %v", format, err)
}
if !strings.Contains(stderr.String(), "[pagination]") {
t.Fatalf("%s: emitter stderr must carry pagination diagnostic: %q", format, stderr.String())
}
if strings.Contains(stdout.String(), "[pagination]") {
t.Fatalf("%s: stdout must stay bare records:\n%s", format, stdout.String())
}
}
}
func TestPartialFailureCSVKeepsStderrZero(t *testing.T) {
// §2.3 特例优先:partial_failure 的 stderr 零输出约束压过 csv/ndjson
// 的诊断行通则——即便携带分页元数据也不写诊断行。
env := newPartialEnvelopeFixture()
env.Meta = &Meta{
Count: NewCount(3),
Pagination: &Pagination{EndpointExhausted: true},
}
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatCSV, "", "")
if err := em.Emit(env); err != nil {
t.Fatalf("Emit partial under csv: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("partial_failure must keep stderr zero (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
}
if stdout.Len() == 0 {
t.Fatal("partial_failure details must stay on stdout (§2.3)")
}
}
// --- B43:--json 语义 = --format json 简写(显式 --format 优先)---
func TestResolveFormatWithJSONShorthandMatrix(t *testing.T) {
// 优先级链穷举(契约规范 §5.2:「--json 自动注册为 --format json 简写
// (显式 --format 优先)」):显式 --format 非空值恒优先;--format 缺席
// 或为空时 --json=true 等价 --format json;两者皆无 → fallback。
newCmd := func(formatSet string, formatValue string, jsonSet bool, jsonValue bool) *cobra.Command {
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
cmd.Flags().Bool("json", false, "")
if formatSet != "" {
if err := cmd.Flags().Set("format", formatValue); err != nil {
t.Fatalf("set format: %v", err)
}
}
if jsonSet {
if err := cmd.Flags().Set("json", jsonFlagValue(jsonValue)); err != nil {
t.Fatalf("set json: %v", err)
}
}
return cmd
}
cases := []struct {
name string
cmd *cobra.Command
fallback Format
want Format
}{
{"json shorthand alone", newCmd("", "", true, true), FormatTable, FormatJSON},
{"explicit format wins over json shorthand", newCmd("set", "table", true, true), FormatJSON, FormatTable},
{"explicit raw wins over json shorthand", newCmd("set", "raw", true, true), FormatJSON, FormatRaw},
{"empty format does not shadow shorthand", newCmd("set", "", true, true), FormatTable, FormatJSON},
{"json=false is not shorthand", newCmd("", "", true, false), FormatTable, FormatTable},
{"neither flag", newCmd("", "", false, false), FormatTable, FormatTable},
{"unknown format not rescued by json shorthand", newCmd("set", "yaml", true, true), FormatJSON, FormatJSON},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ResolveFormatWithJSONShorthand(c.cmd, c.fallback); got != c.want {
t.Fatalf("ResolveFormatWithJSONShorthand = %q, want %q (fallback=%q)", got, c.want, c.fallback)
}
})
}
if got := ResolveFormatWithJSONShorthand(nil, FormatRaw); got != FormatRaw {
t.Fatalf("nil cmd = %q, want fallback raw", got)
}
}
// jsonFlagValue 把 bool 渲染成 pflag.Set 可接受的字符串。
func jsonFlagValue(v bool) string {
if v {
return "true"
}
return "false"
}
func TestResolveFormatWithJSONShorthandIgnoresStringTypedJSONFlag(t *testing.T) {
// 业务层存在同名 string flag(如 table create --json 载荷参数):
// GetBool 失败即跳过,不得被误判为简写。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("json", "", "base JSON payload")
if err := cmd.Flags().Set("json", `{"x":1}`); err != nil {
t.Fatalf("set json: %v", err)
}
if got := ResolveFormatWithJSONShorthand(cmd, FormatTable); got != FormatTable {
t.Fatalf("string-typed --json must not act as shorthand, got %q", got)
}
}
func TestResolveFormatWithJSONShorthandReadsInheritedAndPersistentFlags(t *testing.T) {
// --format 继承自 root persistent(与 ResolveFormat 同查找面);
// --json 挂在子命令本地亦生效。
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "", "")
child := &cobra.Command{Use: "list"}
child.Flags().Bool("json", false, "")
root.AddCommand(child)
if err := root.PersistentFlags().Set("format", "csv"); err != nil {
t.Fatalf("set format: %v", err)
}
if err := child.Flags().Set("json", "true"); err != nil {
t.Fatalf("set json: %v", err)
}
// 显式 format(继承)优先于 json 简写。
if got := ResolveFormatWithJSONShorthand(child, FormatJSON); got != FormatCSV {
t.Fatalf("inherited explicit format must win, got %q", got)
}
// 清空 format 后 json 简写生效。
if err := root.PersistentFlags().Set("format", ""); err != nil {
t.Fatalf("reset format: %v", err)
}
if got := ResolveFormatWithJSONShorthand(child, FormatTable); got != FormatJSON {
t.Fatalf("json shorthand = %q, want json", got)
}
}
// --- B209(轮10裁决⑭小批,随 Phase C 同轮收口):ExitCodeForEnvelope 边界
// 组合精确值断言 + 与 internal/errors.ExitCode 表同源锁定 ---
func TestExitCodeForEnvelopeBoundaryCombinationsExactValues(t *testing.T) {
// ① failure + Error 缺席(I3 非法的手工形态,构造器不产出)→ internal
// 兜底 exit==5,精确值固化(无明细可分支的失败不得假装可恢复)。
invalid := &Envelope{OK: false, Outcome: OutcomeFailure}
if err := invalid.Validate(); err == nil {
t.Fatal("failure without Error must be rejected by Validate (I3)")
}
if got := ExitCodeForEnvelope(invalid); got != 5 {
t.Fatalf("failure without Error = exit %d, want 5 (internal fallback)", got)
}
// ② failure + Error.Type 空串(wire-stable 字段缺失形态)→ 同样 internal 5。
emptyType := NewFailureEnvelope(&ErrorInfo{Message: "no type"})
if got := ExitCodeForEnvelope(emptyType); got != 5 {
t.Fatalf("failure with empty error.type = exit %d, want 5 (internal fallback)", got)
}
// ③ failure + Error.Type 未知值(不在码表类别内)→ internal 5。
unknown := NewFailureEnvelope(&ErrorInfo{Type: "mystery"})
if got := ExitCodeForEnvelope(unknown); got != 5 {
t.Fatalf("failure with unknown error.type = exit %d, want 5", got)
}
}
func TestExitCodeForEnvelopeSameSourceAsErrorsExitCode(t *testing.T) {
// B33 常量块与 internal/errors/errors.go ExitCode 表同源锁定:逐类别值
// 相等,防未来单边漂移(轮10裁决⑬/⑭)。PAT=4 边界注记:PAT 行为授权
// 专属码经 ExitCoder 接口路径分发,不出现在信封码表(信封 failure 的
// error.type 枚举不含 pat 类别),故不在本锁定表内。
cases := []struct {
category string
err error
}{
{"api", apperrors.NewAPI("x")},
{"auth", apperrors.NewAuth("x")},
{"validation", apperrors.NewValidation("x")},
{"discovery", apperrors.NewDiscovery("x")},
{"internal", apperrors.NewInternal("x")},
}
for _, c := range cases {
want := apperrors.ExitCode(c.err)
env := NewFailureEnvelope(&ErrorInfo{Type: c.category})
if got := ExitCodeForEnvelope(env); got != want {
t.Fatalf("category %q: ExitCodeForEnvelope=%d but errors.ExitCode=%d (code tables drifted)",
c.category, got, want)
}
}
// confirmation_required 子类与 validation 共享 rc=3(OQ-1 定案):
// runtime 形态 = NewValidation + WithReason(confirmation_required)。
confirmErr := apperrors.NewValidation("confirm", apperrors.WithReason("confirmation_required"))
confirmCode := apperrors.ExitCode(confirmErr)
confirmEnv := NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"})
if got := ExitCodeForEnvelope(confirmEnv); got != confirmCode {
t.Fatalf("confirmation subclass: ExitCodeForEnvelope=%d but runtime errors.ExitCode=%d", got, confirmCode)
}
if confirmCode != 3 {
t.Fatalf("confirmation/validation shared rc = %d, want 3 (OQ-1)", confirmCode)
}
}
// --- B35~B40 综合:六格式在 WriteEnvelopeTo/Emitter 路径的完整分发表 ---
func TestRawPassThroughEnvelopeDiffersFromJSONByteForByte(t *testing.T) {
// -f raw 对信封的透传:只出数据载荷本身(writeRaw 语义),与 json 的
// 完整信封逐字节不同——format flag 不是惰性的(对照附录 A.3:
// 历史上 --format 六值 stdout 逐字节相同即判 flag 惰性缺陷)。
env := NewSuccessEnvelope(map[string]any{"path": "/tmp/a.bin", "size": 42})
var rawBuf, jsonBuf bytes.Buffer
if err := WriteEnvelopeTo(&rawBuf, env, FormatRaw, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(raw): %v", err)
}
if err := WriteEnvelopeTo(&jsonBuf, env, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(json): %v", err)
}
if bytes.Equal(rawBuf.Bytes(), jsonBuf.Bytes()) {
t.Fatalf("-f raw must differ from -f json byte-for-byte (format flag must not be inert):\n%s", rawBuf.String())
}
// raw = 数据载荷透传:恰为 writeRaw(data) 的输出(紧凑单行 JSON +
// 终端净化),不含信封外壳键。
var direct bytes.Buffer
if err := Write(&direct, FormatRaw, env.Data); err != nil {
t.Fatalf("Write(raw, data): %v", err)
}
if !bytes.Equal(rawBuf.Bytes(), direct.Bytes()) {
t.Fatalf("-f raw output must be a verbatim pass-through of the data payload:\n got: %s\nwant: %s", rawBuf.String(), direct.String())
}
if strings.Contains(rawBuf.String(), `"outcome"`) || strings.Contains(rawBuf.String(), `"ok"`) {
t.Fatalf("-f raw must not emit the envelope wrapper: %s", rawBuf.String())
}
// 数据值可用:raw 输出可解码回业务载荷。
var decoded map[string]any
if err := json.Unmarshal(rawBuf.Bytes(), &decoded); err != nil {
t.Fatalf("raw output is not decodable JSON for structured payload: %v\n%s", err, rawBuf.String())
}
if decoded["path"] != "/tmp/a.bin" {
t.Fatalf("raw payload lost data: %s", rawBuf.String())
}
}
func TestSixFormatDispatchMatrixOnEnvelopePath(t *testing.T) {
// 六格式逐格式断言分发行为(§5.2 矩阵在信封出口的行为端):
// json = 完整信封;table/pretty = 数据 + 摘要;csv/ndjson = 裸记录;
// raw = 数据透传。全部格式 stdout 非空、均不产出「半个信封」。
cases := []struct {
format Format
wantJSON bool // stdout 是否恰为 JSON 文档(完整信封)
wantWrapper bool // 是否含信封外壳键(仅 json 允许)
}{
{FormatJSON, true, true},
{FormatTable, false, false},
{FormatPretty, false, false},
{FormatCSV, false, false},
{FormatNDJSON, false, false},
{FormatRaw, false, false},
}
for _, c := range cases {
t.Run(string(c.format), func(t *testing.T) {
forceNoColor(t)
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), c.format, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(%s): %v", c.format, err)
}
out := buf.String()
if len(out) == 0 {
t.Fatalf("format %s produced empty stdout", c.format)
}
hasWrapper := strings.Contains(out, `"outcome"`)
if hasWrapper != c.wantWrapper {
t.Fatalf("format %s wrapper presence = %v, want %v:\n%s", c.format, hasWrapper, c.wantWrapper, out)
}
if c.wantJSON {
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("format %s must emit one complete JSON envelope: %v\n%s", c.format, err, out)
}
if decoded["ok"] != true {
t.Fatalf("format %s envelope ok = %v, want true", c.format, decoded["ok"])
}
} else {
// 数据可见性:三格式与 raw 都能见到业务值。
if !strings.Contains(out, "alpha") {
t.Fatalf("format %s lost data payload:\n%s", c.format, out)
}
}
})
}
}