1119 lines
38 KiB
Go
1119 lines
38 KiB
Go
// 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"
|
||
"fmt"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// canonicalOutcomes 是契约规范 §1/§2.5 的四值(顺序即 wire 顺序)。
|
||
var canonicalOutcomes = []Outcome{
|
||
OutcomeSuccess,
|
||
OutcomePending,
|
||
OutcomePartialFailure,
|
||
OutcomeFailure,
|
||
}
|
||
|
||
// --- B1:ParseOutcome 校验 ---
|
||
|
||
func TestParseOutcomeAcceptsCanonicalValues(t *testing.T) {
|
||
for _, want := range canonicalOutcomes {
|
||
got, err := ParseOutcome(string(want))
|
||
if err != nil {
|
||
t.Fatalf("ParseOutcome(%q) error = %v, want nil", want, err)
|
||
}
|
||
if got != want {
|
||
t.Fatalf("ParseOutcome(%q) = %q, want %q", want, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseOutcomeRejectsNonCanonical(t *testing.T) {
|
||
rejected := []string{
|
||
"", // 空串
|
||
"Success", // 首字母大写
|
||
"SUCCESS", // 全大写
|
||
"success ", // 尾部空白
|
||
" success", // 头部空白
|
||
"partial-failure", // 连字符变体
|
||
"partialfailure", // 缺下划线
|
||
"PARTIAL_FAILURE", // 大写变体
|
||
"Pending", // 大小写变体
|
||
"failure ", // 尾部空白
|
||
"ok", // 历史键名,非 outcome 值
|
||
"error", // 历史键名,非 outcome 值
|
||
"partial_failure\n", // 控制字符
|
||
}
|
||
for _, s := range rejected {
|
||
got, err := ParseOutcome(s)
|
||
if err == nil {
|
||
t.Fatalf("ParseOutcome(%q) = %q, want error", s, got)
|
||
}
|
||
if got != "" {
|
||
t.Fatalf("ParseOutcome(%q) returned %q on error, want zero value", s, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseOutcomeErrorMessageNamesAllFourValues(t *testing.T) {
|
||
_, err := ParseOutcome("Success")
|
||
if err == nil {
|
||
t.Fatal("ParseOutcome(\"Success\") error = nil, want non-nil")
|
||
}
|
||
msg := err.Error()
|
||
for _, want := range []string{`"Success"`, `"success"`, `"pending"`, `"partial_failure"`, `"failure"`} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Fatalf("error message %q missing %q", msg, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B2:Outcome.String() 与 JSON 序列化恒为四小写字符串 ---
|
||
|
||
func TestOutcomeStringIsCanonicalLowercase(t *testing.T) {
|
||
want := []string{"success", "pending", "partial_failure", "failure"}
|
||
for i, o := range canonicalOutcomes {
|
||
if got := o.String(); got != want[i] {
|
||
t.Fatalf("Outcome(%q).String() = %q, want %q", string(o), got, want[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestOutcomeJSONSerializesToExactLowercaseStrings(t *testing.T) {
|
||
want := []string{`"success"`, `"pending"`, `"partial_failure"`, `"failure"`}
|
||
for i, o := range canonicalOutcomes {
|
||
raw, err := json.Marshal(o)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(%q) error = %v", o, err)
|
||
}
|
||
if string(raw) != want[i] {
|
||
t.Fatalf("json.Marshal(%q) = %s, want %s", o, raw, want[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestOutcomeJSONRejectsInvalidValues(t *testing.T) {
|
||
for _, invalid := range []Outcome{"", "Success", "SUCCESS", "ok", "pending "} {
|
||
if _, err := json.Marshal(invalid); err == nil {
|
||
t.Fatalf("json.Marshal(%q) error = nil, want marshal-time rejection", string(invalid))
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B3:Envelope 结构体 json tag 对齐契约规范 §2.5 字段总表 ---
|
||
|
||
func TestEnvelopeJSONTagsMatchFieldTable(t *testing.T) {
|
||
// §2.5 字段总表:字段名 + 是否 omitempty(ok/outcome 恒序列化)。
|
||
want := []struct {
|
||
name string
|
||
tag string
|
||
omitted bool // true = 契约要求 omitempty
|
||
}{
|
||
{"OK", "ok", false},
|
||
{"Outcome", "outcome", false},
|
||
{"Identity", "identity", true},
|
||
{"DryRun", "dry_run", true},
|
||
{"Data", "data", true},
|
||
{"Meta", "meta", true},
|
||
{"Error", "error", true},
|
||
{"Notice", "_notice", true},
|
||
}
|
||
|
||
typ := reflect.TypeOf(Envelope{})
|
||
if typ.NumField() != len(want) {
|
||
t.Fatalf("Envelope has %d fields, want %d (§2.5 field table)", typ.NumField(), len(want))
|
||
}
|
||
for i, w := range want {
|
||
f := typ.Field(i)
|
||
if f.Name != w.name {
|
||
t.Fatalf("Envelope field %d = %q, want %q (§2.5 order)", i, f.Name, w.name)
|
||
}
|
||
tag := f.Tag.Get("json")
|
||
if tag != w.tag && tag != w.tag+",omitempty" {
|
||
t.Fatalf("Envelope.%s json tag = %q, want %q(,omitempty)", f.Name, tag, w.tag)
|
||
}
|
||
hasOmit := strings.HasSuffix(tag, ",omitempty")
|
||
if hasOmit != w.omitted {
|
||
t.Fatalf("Envelope.%s omitempty = %v, want %v (§2.5)", f.Name, hasOmit, w.omitted)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeFieldTypesMatchFieldTable(t *testing.T) {
|
||
typ := reflect.TypeOf(Envelope{})
|
||
byName := map[string]reflect.Type{
|
||
"OK": reflect.TypeOf(false),
|
||
"Outcome": reflect.TypeOf(OutcomeSuccess),
|
||
"Identity": reflect.TypeOf(""),
|
||
"DryRun": reflect.TypeOf(false),
|
||
"Meta": reflect.TypeOf((*Meta)(nil)),
|
||
"Error": reflect.TypeOf((*ErrorInfo)(nil)),
|
||
}
|
||
for name, want := range byName {
|
||
f, ok := typ.FieldByName(name)
|
||
if !ok {
|
||
t.Fatalf("Envelope missing field %q", name)
|
||
}
|
||
if f.Type != want {
|
||
t.Fatalf("Envelope.%s type = %v, want %v", name, f.Type, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B4:Meta 结构体与嵌套序列化 ---
|
||
|
||
func TestMetaJSONTagsMatchFieldTable(t *testing.T) {
|
||
want := []struct {
|
||
name string
|
||
tag string
|
||
}{
|
||
{"Count", "count,omitempty"},
|
||
{"Operation", "operation,omitempty"},
|
||
{"Pagination", "pagination,omitempty"},
|
||
}
|
||
typ := reflect.TypeOf(Meta{})
|
||
if typ.NumField() != len(want) {
|
||
t.Fatalf("Meta has %d fields, want %d (§2.5 meta.* rows)", typ.NumField(), len(want))
|
||
}
|
||
for i, w := range want {
|
||
f := typ.Field(i)
|
||
if f.Name != w.name {
|
||
t.Fatalf("Meta field %d = %q, want %q", i, f.Name, w.name)
|
||
}
|
||
if tag := f.Tag.Get("json"); tag != w.tag {
|
||
t.Fatalf("Meta.%s json tag = %q, want %q", f.Name, tag, w.tag)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeNestedMetaSerialization(t *testing.T) {
|
||
env := Envelope{
|
||
OK: true,
|
||
Outcome: OutcomeSuccess,
|
||
Meta: &Meta{
|
||
Count: NewCount(60),
|
||
Operation: &OperationInfo{
|
||
ID: "task_abc",
|
||
State: "processing",
|
||
NextCommand: "dws wiki task-result --task task_abc",
|
||
},
|
||
Pagination: &Pagination{
|
||
EndpointExhausted: false,
|
||
Pages: 3,
|
||
Items: 60,
|
||
NextToken: "cursor_xyz",
|
||
},
|
||
},
|
||
}
|
||
|
||
raw, err := json.Marshal(env)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(envelope) error = %v", err)
|
||
}
|
||
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
|
||
meta, ok := decoded["meta"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("meta missing or not an object in %s", raw)
|
||
}
|
||
if got, ok := meta["count"].(float64); !ok || got != 60 {
|
||
t.Fatalf("meta.count = %v, want 60", meta["count"])
|
||
}
|
||
op, ok := meta["operation"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("meta.operation missing or not an object in %s", raw)
|
||
}
|
||
if op["id"] != "task_abc" || op["state"] != "processing" ||
|
||
op["next_command"] != "dws wiki task-result --task task_abc" {
|
||
t.Fatalf("meta.operation = %v, want id/state/next_command preserved", op)
|
||
}
|
||
pg, ok := meta["pagination"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("meta.pagination missing or not an object in %s", raw)
|
||
}
|
||
if pg["endpoint_exhausted"] != false {
|
||
t.Fatalf("meta.pagination.endpoint_exhausted = %v, want false (explicit)", pg["endpoint_exhausted"])
|
||
}
|
||
if got, ok := pg["pages"].(float64); !ok || got != 3 {
|
||
t.Fatalf("meta.pagination.pages = %v, want 3", pg["pages"])
|
||
}
|
||
if pg["next_token"] != "cursor_xyz" {
|
||
t.Fatalf("meta.pagination.next_token = %v, want cursor_xyz", pg["next_token"])
|
||
}
|
||
}
|
||
|
||
// --- B7:OperationInfo json tag(§2.2,全部 omitempty)---
|
||
|
||
func TestOperationInfoJSONTagsAllOmitempty(t *testing.T) {
|
||
want := map[string]string{
|
||
"ID": "id,omitempty",
|
||
"State": "state,omitempty",
|
||
"TimedOut": "timed_out,omitempty",
|
||
"NextCommand": "next_command,omitempty",
|
||
}
|
||
typ := reflect.TypeOf(OperationInfo{})
|
||
if typ.NumField() != len(want) {
|
||
t.Fatalf("OperationInfo has %d fields, want %d (§2.2)", typ.NumField(), len(want))
|
||
}
|
||
for i := range typ.NumField() {
|
||
f := typ.Field(i)
|
||
w, ok := want[f.Name]
|
||
if !ok {
|
||
t.Fatalf("OperationInfo has unexpected field %q", f.Name)
|
||
}
|
||
if tag := f.Tag.Get("json"); tag != w {
|
||
t.Fatalf("OperationInfo.%s json tag = %q, want %q", f.Name, tag, w)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B8:Pagination json 字段(§3)---
|
||
|
||
func TestPaginationEndpointExhaustedAlwaysPresent(t *testing.T) {
|
||
// endpoint_exhausted 两态都必须显式出现(无 omitempty):
|
||
// false+next_token = 可续跑;true = 观察到服务端耗尽。
|
||
raw, err := json.Marshal(Pagination{EndpointExhausted: true})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(Pagination) error = %v", err)
|
||
}
|
||
if !strings.Contains(string(raw), `"endpoint_exhausted":true`) {
|
||
t.Fatalf("endpoint_exhausted:true must be explicit, got %s", raw)
|
||
}
|
||
raw, err = json.Marshal(Pagination{NextToken: "cursor_xyz"})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(Pagination) error = %v", err)
|
||
}
|
||
if !strings.Contains(string(raw), `"endpoint_exhausted":false`) {
|
||
t.Fatalf("endpoint_exhausted:false must be explicit, got %s", raw)
|
||
}
|
||
if !strings.Contains(string(raw), `"next_token":"cursor_xyz"`) {
|
||
t.Fatalf("next_token missing, got %s", raw)
|
||
}
|
||
}
|
||
|
||
func TestPaginationJSONTagsMatchContract(t *testing.T) {
|
||
want := []struct {
|
||
name string
|
||
tag string
|
||
}{
|
||
{"EndpointExhausted", "endpoint_exhausted"},
|
||
{"Pages", "pages,omitempty"},
|
||
{"Items", "items,omitempty"},
|
||
{"NextToken", "next_token,omitempty"},
|
||
}
|
||
typ := reflect.TypeOf(Pagination{})
|
||
if typ.NumField() != len(want) {
|
||
t.Fatalf("Pagination has %d fields, want %d (§3)", typ.NumField(), len(want))
|
||
}
|
||
for i, w := range want {
|
||
f := typ.Field(i)
|
||
if f.Name != w.name {
|
||
t.Fatalf("Pagination field %d = %q, want %q", i, f.Name, w.name)
|
||
}
|
||
if tag := f.Tag.Get("json"); tag != w.tag {
|
||
t.Fatalf("Pagination.%s json tag = %q, want %q", f.Name, tag, w.tag)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B9/B10:ErrorInfo 字段组序列化(§2.4)---
|
||
|
||
func TestErrorInfoWireStableFieldsSerialize(t *testing.T) {
|
||
retryAfter := int64(30)
|
||
info := ErrorInfo{
|
||
Type: "api",
|
||
Subtype: "rate_limit",
|
||
Code: 90018,
|
||
Retryable: true,
|
||
RetryAfterSeconds: &retryAfter,
|
||
}
|
||
raw, err := json.Marshal(info)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(ErrorInfo) error = %v", err)
|
||
}
|
||
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
if decoded["type"] != "api" || decoded["subtype"] != "rate_limit" {
|
||
t.Fatalf("type/subtype = %v/%v, want api/rate_limit", decoded["type"], decoded["subtype"])
|
||
}
|
||
if got, ok := decoded["code"].(float64); !ok || got != 90018 {
|
||
t.Fatalf("code = %v, want 90018", decoded["code"])
|
||
}
|
||
if decoded["retryable"] != true {
|
||
t.Fatalf("retryable = %v, want true present", decoded["retryable"])
|
||
}
|
||
if got, ok := decoded["retry_after_seconds"].(float64); !ok || got != 30 {
|
||
t.Fatalf("retry_after_seconds = %v, want 30", decoded["retry_after_seconds"])
|
||
}
|
||
}
|
||
|
||
func TestErrorInfoRetryableAbsentWhenFalse(t *testing.T) {
|
||
raw, err := json.Marshal(ErrorInfo{Type: "validation", Message: "bad flag"})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(ErrorInfo) error = %v", err)
|
||
}
|
||
s := string(raw)
|
||
if strings.Contains(s, "retryable") {
|
||
t.Fatalf("retryable:false must be omitted from wire, got %s", s)
|
||
}
|
||
}
|
||
|
||
func TestErrorInfoRetryAfterSecondsPreservesZero(t *testing.T) {
|
||
zero := int64(0)
|
||
raw, err := json.Marshal(ErrorInfo{Type: "api", Retryable: true, RetryAfterSeconds: &zero})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(ErrorInfo) error = %v", err)
|
||
}
|
||
if !strings.Contains(string(raw), `"retry_after_seconds":0`) {
|
||
t.Fatalf("retry_after_seconds:0 must serialize explicitly (0s is meaningful), got %s", raw)
|
||
}
|
||
|
||
absent, err := json.Marshal(ErrorInfo{Type: "api"})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(ErrorInfo) error = %v", err)
|
||
}
|
||
if strings.Contains(string(absent), "retry_after_seconds") {
|
||
t.Fatalf("nil retry_after_seconds must be omitted, got %s", absent)
|
||
}
|
||
}
|
||
|
||
func TestErrorInfoValidationFieldsSerialize(t *testing.T) {
|
||
info := ErrorInfo{
|
||
Type: "validation",
|
||
Subtype: "confirmation_required",
|
||
Message: "confirmation needed",
|
||
Hint: "re-run with --yes",
|
||
Param: "--user-id",
|
||
Params: []string{"--user-id", "--dept-id"},
|
||
Actions: []string{"dws dev app delete --app-id a1 --yes"},
|
||
}
|
||
raw, err := json.Marshal(info)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(ErrorInfo) error = %v", err)
|
||
}
|
||
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
if decoded["param"] != "--user-id" {
|
||
t.Fatalf("param = %v, want --user-id", decoded["param"])
|
||
}
|
||
params, ok := decoded["params"].([]any)
|
||
if !ok || len(params) != 2 {
|
||
t.Fatalf("params = %v, want 2 entries", decoded["params"])
|
||
}
|
||
actions, ok := decoded["actions"].([]any)
|
||
if !ok || len(actions) != 1 {
|
||
t.Fatalf("actions = %v, want 1 entry", decoded["actions"])
|
||
}
|
||
}
|
||
|
||
func TestMetaCountDistinguishesZeroFromAbsent(t *testing.T) {
|
||
withZero, err := json.Marshal(Envelope{OK: true, Outcome: OutcomeSuccess, Meta: &Meta{Count: NewCount(0)}})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(count:0) error = %v", err)
|
||
}
|
||
if !strings.Contains(string(withZero), `"count":0`) {
|
||
t.Fatalf("count:0 must serialize explicitly, got %s", withZero)
|
||
}
|
||
|
||
absent, err := json.Marshal(Envelope{OK: true, Outcome: OutcomeSuccess, Meta: &Meta{}})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(empty meta) error = %v", err)
|
||
}
|
||
if strings.Contains(string(absent), "count") {
|
||
t.Fatalf("unset count must be omitted, got %s", absent)
|
||
}
|
||
}
|
||
|
||
// --- B11:omitempty 语义——空值一律省略、不输出 null(契约规范 §2.5 末段)---
|
||
|
||
// marshalEnvelope 序列化信封并在失败时终止测试。
|
||
func marshalEnvelope(t *testing.T, env Envelope) []byte {
|
||
t.Helper()
|
||
raw, err := json.Marshal(env)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(envelope) error = %v", err)
|
||
}
|
||
return raw
|
||
}
|
||
|
||
func TestEnvelopeOmitEmptyNeverEmitsNull(t *testing.T) {
|
||
// 全零值信封:除恒序列化的 ok/outcome 外,所有可选字段缺席,
|
||
// wire 中不得出现任何 null(token 效率 + 对齐 lark)。
|
||
raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess})
|
||
if s := string(raw); s != `{"ok":true,"outcome":"success"}` {
|
||
t.Fatalf("empty-value envelope = %s, want exactly {\"ok\":true,\"outcome\":\"success\"}", s)
|
||
}
|
||
if strings.Contains(string(raw), "null") {
|
||
t.Fatalf("empty values must be omitted, never null: %s", raw)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeOmitEmptyPerFieldTable(t *testing.T) {
|
||
raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomePending})
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
for _, key := range []string{"identity", "dry_run", "data", "meta", "error", "_notice"} {
|
||
if v, present := decoded[key]; present {
|
||
t.Fatalf("empty %s must be omitted (§2.5 omitempty), got %v in %s", key, v, raw)
|
||
}
|
||
}
|
||
if strings.Contains(string(raw), "null") {
|
||
t.Fatalf("no null values allowed on empty fields: %s", raw)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeMetaOmitEmptyNeverEmitsNull(t *testing.T) {
|
||
// meta 非空但内部字段全部未设置:count/operation/pagination 一律缺席。
|
||
raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess, Meta: &Meta{}})
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
meta, ok := decoded["meta"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("meta missing in %s", raw)
|
||
}
|
||
if len(meta) != 0 {
|
||
t.Fatalf("empty meta must serialize as {} with no null entries, got %s", raw)
|
||
}
|
||
if strings.Contains(string(raw), "null") {
|
||
t.Fatalf("empty meta fields must be omitted, never null: %s", raw)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeEmptySliceDataIsPreservedNotOmitted(t *testing.T) {
|
||
// 空数组是合法业务载荷(空结果 data:[] + count:0,AC-06):
|
||
// omitempty 对非 nil 空切片不生效,必须原样输出 [],且不是 null。
|
||
raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess, Data: []any{}})
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
data, ok := decoded["data"].([]any)
|
||
if !ok {
|
||
t.Fatalf("data:[] must be present as an empty array, got %s", raw)
|
||
}
|
||
if len(data) != 0 {
|
||
t.Fatalf("data = %v, want empty array", decoded["data"])
|
||
}
|
||
if !strings.Contains(string(raw), `"data":[]`) {
|
||
t.Fatalf("empty-array data must serialize as [], got %s", raw)
|
||
}
|
||
}
|
||
|
||
// --- B12:Envelope JSON 顶层键集合 golden 断言(契约规范 §2 / §2.5)---
|
||
|
||
func envelopeTopLevelKeys(t *testing.T, raw []byte) []string {
|
||
t.Helper()
|
||
// json.Decoder 保序读入对象键,顺序即 Go 声明序(golden 键序前提)。
|
||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||
tok, err := dec.Token()
|
||
if err != nil || tok != json.Delim('{') {
|
||
t.Fatalf("envelope JSON must be an object, got %v (err %v) from %s", tok, err, raw)
|
||
}
|
||
var keys []string
|
||
for dec.More() {
|
||
keyTok, err := dec.Token()
|
||
if err != nil {
|
||
t.Fatalf("read key token from %s: %v", raw, err)
|
||
}
|
||
key, ok := keyTok.(string)
|
||
if !ok {
|
||
t.Fatalf("non-string key token %v in %s", keyTok, raw)
|
||
}
|
||
keys = append(keys, key)
|
||
var value json.RawMessage
|
||
if err := dec.Decode(&value); err != nil {
|
||
t.Fatalf("decode value for key %q in %s: %v", key, raw, err)
|
||
}
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func TestEnvelopeTopLevelKeySetGoldenFullEnvelope(t *testing.T) {
|
||
env := Envelope{
|
||
OK: true,
|
||
Outcome: OutcomeSuccess,
|
||
Identity: "user",
|
||
DryRun: true,
|
||
Data: map[string]any{"todoId": "t_123"},
|
||
Meta: &Meta{Count: NewCount(1)},
|
||
Error: nil,
|
||
Notice: map[string]any{},
|
||
}
|
||
raw := marshalEnvelope(t, env)
|
||
|
||
// golden 键集合(§2.5 字段总表);含 error 键的完整集合在失败信封测试断言。
|
||
want := []string{"ok", "outcome", "identity", "dry_run", "data", "meta", "_notice"}
|
||
got := envelopeTopLevelKeys(t, raw)
|
||
if !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("top-level keys = %v, want golden %v (declaration order, §2.5)", got, want)
|
||
}
|
||
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
if len(decoded) != len(want) {
|
||
t.Fatalf("decoded key count = %d, want %d", len(decoded), len(want))
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeTopLevelKeySetGoldenFailureEnvelope(t *testing.T) {
|
||
env := Envelope{
|
||
OK: false,
|
||
Outcome: OutcomeFailure,
|
||
Error: &ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018},
|
||
}
|
||
raw := marshalEnvelope(t, env)
|
||
want := []string{"ok", "outcome", "error"}
|
||
if got := envelopeTopLevelKeys(t, raw); !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("top-level keys = %v, want golden %v", got, want)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeTopLevelKeySetIsClosedWhitelist(t *testing.T) {
|
||
// 顶层键只允许 §2.5 字段总表白名单;任何非白名单键(含历史键名)都不得出现。
|
||
allowed := map[string]bool{
|
||
"ok": true, "outcome": true, "identity": true, "dry_run": true,
|
||
"data": true, "meta": true, "error": true, "_notice": true,
|
||
}
|
||
retryAfter := int64(30)
|
||
env := Envelope{
|
||
OK: false,
|
||
Outcome: OutcomeFailure,
|
||
Identity: "app",
|
||
DryRun: true,
|
||
Data: []any{},
|
||
Meta: &Meta{
|
||
Count: NewCount(0),
|
||
Operation: &OperationInfo{ID: "task_abc", State: "processing"},
|
||
Pagination: &Pagination{
|
||
EndpointExhausted: false,
|
||
NextToken: "cursor_xyz",
|
||
Pages: 3,
|
||
Items: 60,
|
||
},
|
||
},
|
||
Error: &ErrorInfo{
|
||
Type: "api", Subtype: "rate_limit", Code: 90018, Message: "too many requests",
|
||
Hint: "retry", Retryable: true, RetryAfterSeconds: &retryAfter, RequestID: "req_xxx",
|
||
Param: "--user-id", Params: []string{"--user-id"}, Actions: []string{"dws dev app list --yes"},
|
||
},
|
||
Notice: map[string]any{},
|
||
}
|
||
raw := marshalEnvelope(t, env)
|
||
for _, key := range envelopeTopLevelKeys(t, raw) {
|
||
if !allowed[key] {
|
||
t.Fatalf("unexpected top-level key %q outside §2.5 whitelist in %s", key, raw)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B13:ok 恒为 JSON bool(AC-02,禁止字符串布尔)---
|
||
|
||
func TestEnvelopeOKSerializesAsJSONBoolean(t *testing.T) {
|
||
for _, ok := range []bool{true, false} {
|
||
raw := marshalEnvelope(t, Envelope{OK: ok, Outcome: OutcomeSuccess})
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
got, present := decoded["ok"]
|
||
if !present {
|
||
t.Fatalf("ok must always be present (no omitempty), got %s", raw)
|
||
}
|
||
boolVal, isBool := got.(bool)
|
||
if !isBool {
|
||
t.Fatalf("ok = %v (%T), want JSON boolean (§2.5 禁止字符串布尔)", got, got)
|
||
}
|
||
if boolVal != ok {
|
||
t.Fatalf("ok = %v, want %v", boolVal, ok)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeOKWireBytesAreBooleanLiterals(t *testing.T) {
|
||
// wire 层断言:ok 只能以 true/false 字面量出现,绝不出现 "true"/"false"。
|
||
if raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess}); !strings.Contains(string(raw), `"ok":true`) {
|
||
t.Fatalf("ok:true must be a bare boolean, got %s", raw)
|
||
}
|
||
if raw := marshalEnvelope(t, Envelope{OK: false, Outcome: OutcomeFailure}); !strings.Contains(string(raw), `"ok":false`) {
|
||
t.Fatalf("ok:false must be a bare boolean, got %s", raw)
|
||
}
|
||
for _, banned := range []string{`"ok":"true"`, `"ok":"false"`} {
|
||
for _, ok := range []bool{true, false} {
|
||
if raw := marshalEnvelope(t, Envelope{OK: ok, Outcome: OutcomeSuccess}); strings.Contains(string(raw), banned) {
|
||
t.Fatalf("string boolean %s must never appear, got %s", banned, raw)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeOKFieldGoTypeIsBool(t *testing.T) {
|
||
// 类型层断言:Go 字段类型为 bool,字符串布尔在编译期即不可表达。
|
||
f, ok := reflect.TypeOf(Envelope{}).FieldByName("OK")
|
||
if !ok {
|
||
t.Fatal("Envelope missing field OK")
|
||
}
|
||
if f.Type.Kind() != reflect.Bool {
|
||
t.Fatalf("Envelope.OK kind = %v, want bool (AC-02)", f.Type.Kind())
|
||
}
|
||
if f.Tag.Get("json") != "ok" {
|
||
t.Fatalf("Envelope.OK json tag = %q, want \"ok\" (恒序列化,无 omitempty)", f.Tag.Get("json"))
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeDecoderRejectsStringBooleanOK(t *testing.T) {
|
||
// wire 守卫断言:若外部产出字符串布尔信封(契约禁止形态),
|
||
// 严格解码进 Envelope 必须失败——证明信封类型无法吞下该违规形态。
|
||
for _, wire := range []string{
|
||
`{"ok":"true","outcome":"success"}`,
|
||
`{"ok":"false","outcome":"failure"}`,
|
||
} {
|
||
var env Envelope
|
||
if err := json.Unmarshal([]byte(wire), &env); err == nil {
|
||
t.Fatalf("json.Unmarshal(%s) into Envelope error = nil, want strict rejection of string boolean", wire)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B14:ParseOutcome 非法值拒绝与错误信息(契约规范 §1)---
|
||
|
||
func TestParseOutcomeRejectsNonCanonicalWithDiagnosticMessage(t *testing.T) {
|
||
for _, invalid := range []string{"", "Success", "SUCCESS", "Pending", "PARTIAL_FAILURE", "success ", " partial_failure", "ok"} {
|
||
outcome, err := ParseOutcome(invalid)
|
||
if err == nil {
|
||
t.Fatalf("ParseOutcome(%q) = %q, want rejection (§1 仅四值)", invalid, outcome)
|
||
}
|
||
msg := err.Error()
|
||
// 错误信息必须回显非法输入(含空串),Agent/开发者可直接定位。
|
||
if !strings.Contains(msg, fmt.Sprintf("%q", invalid)) {
|
||
t.Fatalf("error %q must echo the invalid input %q", msg, invalid)
|
||
}
|
||
// 错误信息必须列全四个合法值,避免二次试错。
|
||
for _, canonical := range []string{`"success"`, `"pending"`, `"partial_failure"`, `"failure"`} {
|
||
if !strings.Contains(msg, canonical) {
|
||
t.Fatalf("error %q for input %q must list legal value %s", msg, invalid, canonical)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseOutcomeCanonicalValuesRoundTripWithNoError(t *testing.T) {
|
||
for _, canonical := range canonicalOutcomes {
|
||
got, err := ParseOutcome(canonical.String())
|
||
if err != nil {
|
||
t.Fatalf("ParseOutcome(%q) error = %v, want acceptance", canonical, err)
|
||
}
|
||
if got != canonical {
|
||
t.Fatalf("ParseOutcome(%q) = %q, want %q", canonical, got, canonical)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- B15:ErrorInfo.Retryable 仅 true 时出现(契约规范 §2.4;重试设计 §2.1)---
|
||
|
||
func TestErrorInfoRetryableAppearsOnlyWhenTrue(t *testing.T) {
|
||
// Retryable:false(零值)必须缺席:omitempty 语义,wire 上不出现 false。
|
||
raw, err := json.Marshal(ErrorInfo{Type: "api", Subtype: "not_found", Retryable: false})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(ErrorInfo) error = %v", err)
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
if v, present := decoded["retryable"]; present {
|
||
t.Fatalf("retryable:false must be absent from wire (§2.4), got %v in %s", v, raw)
|
||
}
|
||
if strings.Contains(string(raw), "retryable") {
|
||
t.Fatalf("retryable must be omitted when false, got %s", raw)
|
||
}
|
||
|
||
// Retryable:true 必须显式出现,且恰为布尔 true。
|
||
raw, err = json.Marshal(ErrorInfo{Type: "api", Subtype: "rate_limit", Retryable: true})
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal(ErrorInfo) error = %v", err)
|
||
}
|
||
if !strings.Contains(string(raw), `"retryable":true`) {
|
||
t.Fatalf("retryable:true must appear exactly, got %s", raw)
|
||
}
|
||
if strings.Contains(string(raw), `"retryable":false`) {
|
||
t.Fatalf("retryable:false must never serialize, got %s", raw)
|
||
}
|
||
}
|
||
|
||
func TestErrorInfoRetryableTagIsOmitempty(t *testing.T) {
|
||
f, ok := reflect.TypeOf(ErrorInfo{}).FieldByName("Retryable")
|
||
if !ok {
|
||
t.Fatal("ErrorInfo missing field Retryable")
|
||
}
|
||
if tag := f.Tag.Get("json"); tag != "retryable,omitempty" {
|
||
t.Fatalf("ErrorInfo.Retryable json tag = %q, want \"retryable,omitempty\" (§2.4)", tag)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeFailureEnvelopeOmitsAbsentRetryable(t *testing.T) {
|
||
// 完整失败信封视角:error 未标可重试时,整个 wire 不出现 retryable 键。
|
||
env := Envelope{
|
||
OK: false,
|
||
Outcome: OutcomeFailure,
|
||
Error: &ErrorInfo{Type: "validation", Message: "bad flag"},
|
||
}
|
||
raw := marshalEnvelope(t, env)
|
||
if strings.Contains(string(raw), "retryable") {
|
||
t.Fatalf("retryable must not leak into wire when false, got %s", raw)
|
||
}
|
||
}
|
||
|
||
// --- B16:data/error 互斥校验(契约规范 §1 不变量 I3)---
|
||
|
||
func TestEnvelopeDataErrorExclusivityRejectsBothPresent(t *testing.T) {
|
||
// error 非空时 data 必须缺席:同时出现即违反 I3 字段级推论。
|
||
env := &Envelope{
|
||
OK: false,
|
||
Outcome: OutcomeFailure,
|
||
Data: map[string]any{"id": "a"},
|
||
Error: &ErrorInfo{Type: "api", Code: 1},
|
||
}
|
||
if err := env.ValidateDataErrorExclusivity(); err == nil {
|
||
t.Fatal("ValidateDataErrorExclusivity() = nil, want I3 violation error for data+error")
|
||
} else if !strings.Contains(err.Error(), "I3") {
|
||
t.Fatalf("error %q must reference invariant I3", err.Error())
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeDataErrorExclusivityAcceptsFailureShape(t *testing.T) {
|
||
// §2.4 失败形态:error 非空、data 缺席,合法。
|
||
env := &Envelope{
|
||
OK: false,
|
||
Outcome: OutcomeFailure,
|
||
Error: &ErrorInfo{Type: "api", Subtype: "rate_limit"},
|
||
}
|
||
if err := env.ValidateDataErrorExclusivity(); err != nil {
|
||
t.Fatalf("ValidateDataErrorExclusivity() = %v, want nil for error-only envelope", err)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeDataErrorExclusivityAcceptsDataShapes(t *testing.T) {
|
||
cases := map[string]*Envelope{
|
||
"success with data": {OK: true, Outcome: OutcomeSuccess, Data: map[string]any{"todoId": "t_1"}},
|
||
"success empty-array data": {OK: true, Outcome: OutcomeSuccess, Data: []any{}},
|
||
"partial_failure channels": {OK: false, Outcome: OutcomePartialFailure, Data: map[string]any{"total": 3}},
|
||
"pending with operation": {OK: true, Outcome: OutcomePending, Meta: &Meta{Operation: &OperationInfo{ID: "task_abc"}}},
|
||
"nil envelope": nil,
|
||
}
|
||
for name, env := range cases {
|
||
if err := env.ValidateDataErrorExclusivity(); err != nil {
|
||
t.Fatalf("%s: ValidateDataErrorExclusivity() = %v, want nil", name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeDataErrorExclusivityEmptyErrorStillViolates(t *testing.T) {
|
||
// 只要 Error 指针非 nil 即视为 error 非空(零值 ErrorInfo 也算),
|
||
// 与 I3「error 字段非空 ⇔ outcome==failure」的指针语义一致。
|
||
env := &Envelope{OK: false, Outcome: OutcomeFailure, Data: []any{}, Error: &ErrorInfo{}}
|
||
if err := env.ValidateDataErrorExclusivity(); err == nil {
|
||
t.Fatal("ValidateDataErrorExclusivity() = nil, want error when Error pointer is non-nil alongside data")
|
||
}
|
||
}
|
||
|
||
// --- B17:_notice 系统级通知注入位(契约规范 §2.5)---
|
||
|
||
func TestEnvelopeNoticeInjectionSlotSerializesAsObject(t *testing.T) {
|
||
// _notice 是系统级通知注入位:设置时以 object 形态出现在 wire 上。
|
||
notice := map[string]any{"upgrade": map[string]any{"available": true}}
|
||
raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess, Notice: notice})
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
got, present := decoded["_notice"]
|
||
if !present {
|
||
t.Fatalf("_notice injection slot missing when Notice set: %s", raw)
|
||
}
|
||
obj, ok := got.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("_notice = %v (%T), want JSON object (§2.5 注入位)", got, got)
|
||
}
|
||
if _, ok := obj["upgrade"]; !ok {
|
||
t.Fatalf("_notice payload lost in %s", raw)
|
||
}
|
||
// 注入位是顶层最后一个键(§2.5 表行序,声明序=wire 序)。
|
||
keys := envelopeTopLevelKeys(t, raw)
|
||
if keys[len(keys)-1] != "_notice" {
|
||
t.Fatalf("top-level keys = %v, want _notice last (§2.5 field table order)", keys)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeNoticeOmittedWhenUnset(t *testing.T) {
|
||
// 未注入通知时 _notice 缺席(omitempty),绝不输出 null。
|
||
raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess})
|
||
if strings.Contains(string(raw), "_notice") {
|
||
t.Fatalf("unset _notice must be omitted, got %s", raw)
|
||
}
|
||
if strings.Contains(string(raw), "null") {
|
||
t.Fatalf("_notice must never serialize as null, got %s", raw)
|
||
}
|
||
}
|
||
|
||
// --- B18:Envelope.Validate() 聚合校验入口(契约规范 §1)---
|
||
|
||
func TestEnvelopeValidateAcceptsCanonicalShapes(t *testing.T) {
|
||
cases := map[string]*Envelope{
|
||
"success": NewSuccessEnvelope(map[string]any{"todoId": "t_123"}),
|
||
"success empty": NewSuccessEnvelope([]any{}),
|
||
"pending": NewPendingEnvelope(&OperationInfo{ID: "task_abc", State: "processing"}),
|
||
"pending no meta": NewPendingEnvelope(nil),
|
||
"failure": NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit"}),
|
||
"partial": NewPartialEnvelope(map[string]any{"total": 3}),
|
||
"nil envelope": nil,
|
||
}
|
||
for name, env := range cases {
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("%s: Validate() = %v, want nil", name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeValidateRejectsInvalidOutcome(t *testing.T) {
|
||
for _, invalid := range []Outcome{"", "Success", "ok"} {
|
||
env := &Envelope{OK: true, Outcome: invalid}
|
||
err := env.Validate()
|
||
if err == nil {
|
||
t.Fatalf("Validate() = nil for outcome %q, want rejection (§1 仅四值)", invalid)
|
||
}
|
||
if !strings.Contains(err.Error(), fmt.Sprintf("%q", string(invalid))) {
|
||
t.Fatalf("error %q must echo invalid outcome %q", err.Error(), invalid)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeValidateRejectsI1Violations(t *testing.T) {
|
||
cases := map[string]*Envelope{
|
||
"failure with ok=true": {OK: true, Outcome: OutcomeFailure},
|
||
"partial with ok=true": {OK: true, Outcome: OutcomePartialFailure},
|
||
"success with ok=false": {OK: false, Outcome: OutcomeSuccess, Data: map[string]any{}},
|
||
"pending with ok=false": {OK: false, Outcome: OutcomePending},
|
||
}
|
||
for name, env := range cases {
|
||
err := env.Validate()
|
||
if err == nil {
|
||
t.Fatalf("%s: Validate() = nil, want I1 violation", name)
|
||
}
|
||
if !strings.Contains(err.Error(), "I1") {
|
||
t.Fatalf("%s: error %q must reference invariant I1", name, err.Error())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeValidateRejectsI3Violations(t *testing.T) {
|
||
cases := map[string]*Envelope{
|
||
"failure without error": {OK: false, Outcome: OutcomeFailure},
|
||
"success with error": {OK: true, Outcome: OutcomeSuccess, Error: &ErrorInfo{Type: "api"}},
|
||
"pending with error": {OK: true, Outcome: OutcomePending, Error: &ErrorInfo{}},
|
||
"partial with error": {OK: false, Outcome: OutcomePartialFailure, Error: &ErrorInfo{Type: "api"}},
|
||
}
|
||
for name, env := range cases {
|
||
err := env.Validate()
|
||
if err == nil {
|
||
t.Fatalf("%s: Validate() = nil, want I3 violation", name)
|
||
}
|
||
if !strings.Contains(err.Error(), "I3") {
|
||
t.Fatalf("%s: error %q must reference invariant I3", name, err.Error())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeValidateAggregatesMultipleViolations(t *testing.T) {
|
||
// 聚合入口:一次报全,不因首个违反项提前返回。
|
||
// ok=true 配 failure(I1)+ data 与 error 并存(I3 互斥)+ error 配 failure 合法,
|
||
// 此处构造 I1 + I3 互斥两项同时违反。
|
||
env := &Envelope{
|
||
OK: true,
|
||
Outcome: OutcomeFailure,
|
||
Data: map[string]any{"id": "a"},
|
||
Error: &ErrorInfo{Type: "api"},
|
||
}
|
||
err := env.Validate()
|
||
if err == nil {
|
||
t.Fatal("Validate() = nil, want aggregated violations")
|
||
}
|
||
msg := err.Error()
|
||
if !strings.Contains(msg, "I1") {
|
||
t.Fatalf("aggregated error %q must include I1 violation", msg)
|
||
}
|
||
if !strings.Contains(msg, "I3") {
|
||
t.Fatalf("aggregated error %q must include I3 exclusivity violation", msg)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeValidateFailureConstructorRequiresErrorInfo(t *testing.T) {
|
||
// 构造器不做前置拦截(保持简单装配),但 nil error 的失败信封
|
||
// 无法通过 Validate():I3「error 非空 ⇔ failure」双向约束。
|
||
env := NewFailureEnvelope(nil)
|
||
if err := env.Validate(); err == nil {
|
||
t.Fatal("Validate() = nil for failure envelope without error, want I3 violation")
|
||
}
|
||
}
|
||
|
||
// --- B19/B20:四类信封构造函数(契约规范 §2.1/§2.2/§2.3/§2.4)---
|
||
|
||
func TestNewSuccessEnvelopeShape(t *testing.T) {
|
||
env := NewSuccessEnvelope(map[string]any{"todoId": "t_123"})
|
||
if !env.OK || env.Outcome != OutcomeSuccess {
|
||
t.Fatalf("NewSuccessEnvelope = ok:%v outcome:%q, want true/success (§2.1)", env.OK, env.Outcome)
|
||
}
|
||
if env.Error != nil || env.Meta != nil {
|
||
t.Fatalf("success envelope must not carry error/meta by default: %+v", env)
|
||
}
|
||
raw := marshalEnvelope(t, *env)
|
||
if s := string(raw); s != `{"ok":true,"outcome":"success","data":{"todoId":"t_123"}}` {
|
||
t.Fatalf("success envelope wire = %s, want ok/outcome/data only", s)
|
||
}
|
||
}
|
||
|
||
func TestNewPendingEnvelopeShape(t *testing.T) {
|
||
op := &OperationInfo{
|
||
ID: "task_abc",
|
||
State: "processing",
|
||
NextCommand: "dws wiki task-result --task task_abc",
|
||
}
|
||
env := NewPendingEnvelope(op)
|
||
if !env.OK || env.Outcome != OutcomePending {
|
||
t.Fatalf("NewPendingEnvelope = ok:%v outcome:%q, want true/pending (§2.2)", env.OK, env.Outcome)
|
||
}
|
||
if env.Meta == nil || env.Meta.Operation != op {
|
||
t.Fatalf("pending envelope must carry meta.operation: %+v", env.Meta)
|
||
}
|
||
raw := marshalEnvelope(t, *env)
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
|
||
}
|
||
meta, ok := decoded["meta"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("meta missing in %s", raw)
|
||
}
|
||
operation, ok := meta["operation"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("meta.operation missing in %s", raw)
|
||
}
|
||
if operation["next_command"] != "dws wiki task-result --task task_abc" {
|
||
t.Fatalf("next_command = %v, want preserved", operation["next_command"])
|
||
}
|
||
}
|
||
|
||
func TestNewPendingEnvelopeNilOperationOmitsMeta(t *testing.T) {
|
||
env := NewPendingEnvelope(nil)
|
||
if env.Meta != nil {
|
||
t.Fatalf("nil operation must leave Meta unset, got %+v", env.Meta)
|
||
}
|
||
raw := marshalEnvelope(t, *env)
|
||
if strings.Contains(string(raw), "meta") || strings.Contains(string(raw), "null") {
|
||
t.Fatalf("pending envelope without operation must omit meta and null: %s", raw)
|
||
}
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("Validate() = %v, want nil for bare pending envelope", err)
|
||
}
|
||
}
|
||
|
||
func TestNewFailureEnvelopeShape(t *testing.T) {
|
||
info := &ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018}
|
||
env := NewFailureEnvelope(info)
|
||
if env.OK || env.Outcome != OutcomeFailure {
|
||
t.Fatalf("NewFailureEnvelope = ok:%v outcome:%q, want false/failure (§2.4)", env.OK, env.Outcome)
|
||
}
|
||
if env.Error != info || env.Data != nil {
|
||
t.Fatalf("failure envelope must carry error only: %+v", env)
|
||
}
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("Validate() = %v, want nil for §2.4 shape", err)
|
||
}
|
||
raw := marshalEnvelope(t, *env)
|
||
if got := envelopeTopLevelKeys(t, raw); !reflect.DeepEqual(got, []string{"ok", "outcome", "error"}) {
|
||
t.Fatalf("failure envelope keys = %v, want [ok outcome error]", got)
|
||
}
|
||
}
|
||
|
||
func TestErrorInfoValidateRejectsUnknownWireType(t *testing.T) {
|
||
for _, errorType := range []string{"network", "confirmation", "mystery"} {
|
||
info := &ErrorInfo{Type: errorType, Message: "must use a governed type"}
|
||
if err := info.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported failure error.type") {
|
||
t.Fatalf("ErrorInfo.Validate(%q) = %v, want unsupported type error", errorType, err)
|
||
}
|
||
}
|
||
for _, errorType := range []string{"api", "auth", "validation", "permission", "discovery", "internal"} {
|
||
if err := (&ErrorInfo{Type: errorType}).Validate(); err != nil {
|
||
t.Fatalf("ErrorInfo.Validate(%q) = %v, want governed type accepted", errorType, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNewPartialEnvelopeShape(t *testing.T) {
|
||
data := map[string]any{
|
||
"total": 3,
|
||
"succeeded": []any{map[string]any{"id": "a"}},
|
||
"failed": []any{map[string]any{"id": "b"}},
|
||
"unknown": []any{map[string]any{"id": "c", "reason": "timeout after submit"}},
|
||
}
|
||
env := NewPartialEnvelope(data)
|
||
if env.OK || env.Outcome != OutcomePartialFailure {
|
||
t.Fatalf("NewPartialEnvelope = ok:%v outcome:%q, want false/partial_failure (§2.3)", env.OK, env.Outcome)
|
||
}
|
||
if env.Error != nil {
|
||
t.Fatalf("partial envelope must not carry top-level error (§2.3/I3): %+v", env)
|
||
}
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("Validate() = %v, want nil for §2.3 shape", err)
|
||
}
|
||
}
|
||
|
||
func TestEnvelopeConstructorsComputeOKFromOutcome(t *testing.T) {
|
||
// I1 框架计算:四类构造器的 OK 值与 outcome 映射恒一致,命令层不可写。
|
||
want := []struct {
|
||
env *Envelope
|
||
ok bool
|
||
}{
|
||
{NewSuccessEnvelope(nil), true},
|
||
{NewPendingEnvelope(nil), true},
|
||
{NewPartialEnvelope(nil), false},
|
||
{NewFailureEnvelope(&ErrorInfo{Type: "internal"}), false},
|
||
}
|
||
for i, c := range want {
|
||
if c.env.OK != c.ok {
|
||
t.Fatalf("constructor %d OK = %v, want %v (I1)", i, c.env.OK, c.ok)
|
||
}
|
||
}
|
||
}
|