422 lines
16 KiB
Go
422 lines
16 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
|
||
|
||
// Phase H:operation / pagination 装配助手(B147~B154;契约规范 §2.2/§3)。
|
||
// 落盘策略:轮8裁决⑩新文件——不编辑 envelope_test.go/emitter_test.go。
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// --- B150:operation.state 取值表测试 ---
|
||
|
||
func TestOperationStateValueTable(t *testing.T) {
|
||
// state 取值表(§2.2):规范三值 processing/completed/failed 是 Agent
|
||
// 必须能分支的最小集;开放枚举容忍业务特化阶段(如 importing、queued)。
|
||
// 表驱动:每个规范值装配合法、wire 原样、Validate 通过。
|
||
normative := []struct {
|
||
state string
|
||
// timed_out 共存合法性由 ValidateTimeoutState 把守,这里只表 state 装配。
|
||
}{
|
||
{OperationStateProcessing},
|
||
{OperationStateCompleted},
|
||
{OperationStateFailed},
|
||
}
|
||
if len(normative) != 3 {
|
||
t.Fatalf("operation.state normative table must cover exactly 3 values (§2.2), got %d", len(normative))
|
||
}
|
||
for _, tc := range normative {
|
||
t.Run("state="+tc.state, func(t *testing.T) {
|
||
op := &OperationInfo{ID: "t_1", State: tc.state, NextCommand: "dws op get t_1"}
|
||
env, err := NewPendingEnvelopeForPolling(op)
|
||
if err != nil {
|
||
t.Fatalf("NewPendingEnvelopeForPolling(state=%q): %v", tc.state, err)
|
||
}
|
||
raw, err := json.Marshal(env)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal: %v", err)
|
||
}
|
||
var decoded struct {
|
||
Meta struct {
|
||
Operation struct {
|
||
State string `json:"state"`
|
||
} `json:"operation"`
|
||
} `json:"meta"`
|
||
}
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("decode: %v\n%s", err, raw)
|
||
}
|
||
if decoded.Meta.Operation.State != tc.state {
|
||
t.Fatalf("wire state = %q, want %q: %s", decoded.Meta.Operation.State, tc.state, raw)
|
||
}
|
||
})
|
||
}
|
||
// 开放枚举:业务特化阶段不在规范表内但可装配(框架不解释语义,§9)。
|
||
op := &OperationInfo{ID: "t_2", State: "importing", NextCommand: "dws op get t_2"}
|
||
if _, err := NewPendingEnvelopeForPolling(op); err != nil {
|
||
t.Fatalf("specialized state must remain assemblable (open enum, §2.2): %v", err)
|
||
}
|
||
}
|
||
|
||
// --- B148:next_command 非空可执行断言 ---
|
||
|
||
func TestPollingPendingEnvelopeRequiresNonEmptyNextCommand(t *testing.T) {
|
||
// 轮询形态的恢复方式只有 next_command 一条出口(§2.2 规则2):
|
||
// 非空、无前后空白、单行——缺任一项即装配失败。
|
||
cases := []struct {
|
||
name string
|
||
op *OperationInfo
|
||
}{
|
||
{"nil operation", nil},
|
||
{"empty next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing}},
|
||
{"whitespace next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " "}},
|
||
{"leading space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " dws op get t_1"}},
|
||
{"trailing space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1 "}},
|
||
{"multi-line", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\ndws op get t_1"}},
|
||
{"carriage return", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\r"}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env, err := NewPendingEnvelopeForPolling(tc.op)
|
||
if err == nil {
|
||
t.Fatalf("NewPendingEnvelopeForPolling(%+v) must fail (§2.2 rule 2), got %+v", tc.op, env)
|
||
}
|
||
})
|
||
}
|
||
// 合法形态:可执行单行命令装配通过。
|
||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||
if err != nil {
|
||
t.Fatalf("legal next_command rejected: %v", err)
|
||
}
|
||
if env.Meta == nil || env.Meta.Operation == nil || env.Meta.Operation.NextCommand != "dws op get t_1" {
|
||
t.Fatalf("assembled envelope lost next_command: %+v", env)
|
||
}
|
||
// ValidateNextCommand 对空串不拦截(裸 pending 信封允许无 operation 明细),
|
||
// 只对非空值做形态校验——严格性由装配助手把守。
|
||
if err := (&OperationInfo{}).ValidateNextCommand(); err != nil {
|
||
t.Fatalf("empty next_command must not be rejected by shape validator alone: %v", err)
|
||
}
|
||
}
|
||
|
||
// --- B149:timed_out:true 时 state 保留断言(§2.2 防伪装)---
|
||
|
||
func TestTimedOutKeepsRealState(t *testing.T) {
|
||
// §2.2 规则1(lark wiki_node_delete 教训):轮询超时必须保持 state 真实值
|
||
// 并置 timed_out:true;超时操作不得宣称完成态。
|
||
cases := []struct {
|
||
name string
|
||
op *OperationInfo
|
||
wantErr bool
|
||
}{
|
||
{"timed_out with real processing state",
|
||
&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"}, false},
|
||
{"timed_out with specialized real state",
|
||
&OperationInfo{ID: "t_1", State: "importing", TimedOut: true, NextCommand: "dws op get t_1"}, false},
|
||
{"timed_out with empty state",
|
||
&OperationInfo{ID: "t_1", TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||
{"timed_out claiming completed",
|
||
&OperationInfo{ID: "t_1", State: OperationStateCompleted, TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||
{"timed_out claiming success (case-insensitive)",
|
||
&OperationInfo{ID: "t_1", State: "SUCCESS", TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||
{"no timeout keeps completed legal",
|
||
&OperationInfo{ID: "t_1", State: OperationStateCompleted, NextCommand: "dws op get t_1"}, false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
err := tc.op.ValidateTimeoutState()
|
||
if tc.wantErr && err == nil {
|
||
t.Fatalf("ValidateTimeoutState(%+v) must reject (§2.2 anti-spoof)", tc.op)
|
||
}
|
||
if !tc.wantErr && err != nil {
|
||
t.Fatalf("ValidateTimeoutState(%+v) must accept: %v", tc.op, err)
|
||
}
|
||
})
|
||
}
|
||
// wire 层:timed_out:true 与真实 state 同现。
|
||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"})
|
||
if err != nil {
|
||
t.Fatalf("assembling timed_out envelope: %v", err)
|
||
}
|
||
raw, err := json.Marshal(env)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal: %v", err)
|
||
}
|
||
var decoded struct {
|
||
Meta struct {
|
||
Operation struct {
|
||
State string `json:"state"`
|
||
TimedOut bool `json:"timed_out"`
|
||
} `json:"operation"`
|
||
} `json:"meta"`
|
||
}
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("decode: %v\n%s", err, raw)
|
||
}
|
||
if decoded.Meta.Operation.State != OperationStateProcessing || !decoded.Meta.Operation.TimedOut {
|
||
t.Fatalf("wire must keep real state + timed_out:true (§2.2), got %+v: %s", decoded.Meta.Operation, raw)
|
||
}
|
||
}
|
||
|
||
// --- B147:NewPendingEnvelope 装配助手增强 / 轮询形态 ---
|
||
|
||
func TestPendingEnvelopeAssemblyAssistants(t *testing.T) {
|
||
// 宽松构造器(B19)保持构造宽松:无 op / 空 next_command 均可构造。
|
||
if env := NewPendingEnvelope(nil); env.Meta != nil || !env.OK || env.Outcome != OutcomePending {
|
||
t.Fatalf("NewPendingEnvelope(nil) = %+v, want bare pending", env)
|
||
}
|
||
if env := NewPendingEnvelope(&OperationInfo{ID: "t_1"}); env.Meta == nil || env.Meta.Operation.ID != "t_1" {
|
||
t.Fatalf("NewPendingEnvelope lost operation: %+v", env)
|
||
}
|
||
// 严格装配助手(B147):轮询形态必须携带 op + 可执行 next_command。
|
||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||
if err != nil {
|
||
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
|
||
}
|
||
// I1 一致性:pending → ok:true。
|
||
if !env.OK || env.Outcome != OutcomePending {
|
||
t.Fatalf("polling pending envelope = ok:%v outcome:%q, want true/pending (I1)", env.OK, env.Outcome)
|
||
}
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("Validate: %v", err)
|
||
}
|
||
// 出口面:pending 落 stdout、exit 0。
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
if stdout.Len() == 0 || stderr.Len() != 0 {
|
||
t.Fatalf("pending must route stdout (stdout=%d stderr=%d)", stdout.Len(), stderr.Len())
|
||
}
|
||
if code := ExitCodeForEnvelope(env); code != 0 {
|
||
t.Fatalf("pending exit code = %d, want 0 (I2)", code)
|
||
}
|
||
}
|
||
|
||
// --- B151:import_flow 字段映射断言 ---
|
||
|
||
func TestImportFlowOperationFieldMapping(t *testing.T) {
|
||
// 契约 §2.2 的 import_flow 场景映射:业务侧 task 终态 → operation 字段。
|
||
// 框架只断言映射后的 wire 形态(§9 语义归装配侧):
|
||
// task_id → operation.id
|
||
// status → operation.state
|
||
// timeout → operation.timed_out(超时时 true,且 state 保留真实值)
|
||
// 轮询命令 → operation.next_command
|
||
cases := []struct {
|
||
name string
|
||
taskID string
|
||
status string
|
||
timedOut bool
|
||
nextCmd string
|
||
wantTimed bool
|
||
wantState string
|
||
wantNext string
|
||
}{
|
||
{"running task", "task_01", "processing", false, "dws op get task_01", false, "processing", "dws op get task_01"},
|
||
{"timed-out task keeps real state", "task_02", "processing", true, "dws op get task_02", true, "processing", "dws op get task_02"},
|
||
{"completed task", "task_03", "completed", false, "dws op get task_03", false, "completed", "dws op get task_03"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env, err := NewPendingEnvelopeForPolling(&OperationInfo{
|
||
ID: tc.taskID,
|
||
State: tc.status,
|
||
TimedOut: tc.timedOut,
|
||
NextCommand: tc.nextCmd,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
|
||
}
|
||
raw, err := json.Marshal(env)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal: %v", err)
|
||
}
|
||
var decoded struct {
|
||
Meta struct {
|
||
Operation struct {
|
||
ID string `json:"id"`
|
||
State string `json:"state"`
|
||
TimedOut bool `json:"timed_out"`
|
||
NextCommand string `json:"next_command"`
|
||
} `json:"operation"`
|
||
} `json:"meta"`
|
||
}
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("decode: %v\n%s", err, raw)
|
||
}
|
||
op := decoded.Meta.Operation
|
||
if op.ID != tc.taskID {
|
||
t.Fatalf("operation.id = %q, want task_id %q: %s", op.ID, tc.taskID, raw)
|
||
}
|
||
if op.State != tc.wantState {
|
||
t.Fatalf("operation.state = %q, want %q: %s", op.State, tc.wantState, raw)
|
||
}
|
||
if op.TimedOut != tc.wantTimed {
|
||
t.Fatalf("operation.timed_out = %v, want %v: %s", op.TimedOut, tc.wantTimed, raw)
|
||
}
|
||
if op.NextCommand != tc.wantNext {
|
||
t.Fatalf("operation.next_command = %q, want %q: %s", op.NextCommand, tc.wantNext, raw)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// --- B152:NewPagination 装配助手 ---
|
||
|
||
func TestNewPaginationTwoStateCompleteness(t *testing.T) {
|
||
// §3 两态完备:resumable(携带 next_token)与 exhausted(无 token)是
|
||
// 仅有的两个状态;不设 stop_reason。装配助手强制两态互斥完备。
|
||
cases := []struct {
|
||
name string
|
||
exhausted bool
|
||
token string
|
||
wantErr bool
|
||
}{
|
||
{"resumable with token", false, "tok_2", false},
|
||
{"exhausted without token", true, "", false},
|
||
{"resumable missing token", false, "", true},
|
||
{"exhausted carrying token", true, "tok_2", true},
|
||
{"resumable whitespace-only token", false, " ", true},
|
||
{"exhausted whitespace token accepted as empty", true, " ", false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
pg, err := NewPagination(tc.exhausted, tc.token)
|
||
if tc.wantErr {
|
||
if err == nil {
|
||
t.Fatalf("NewPagination(exhausted=%v, token=%q) must fail (§3)", tc.exhausted, tc.token)
|
||
}
|
||
return
|
||
}
|
||
if err != nil {
|
||
t.Fatalf("NewPagination(exhausted=%v, token=%q): %v", tc.exhausted, tc.token, err)
|
||
}
|
||
if pg.EndpointExhausted != tc.exhausted {
|
||
t.Fatalf("endpoint_exhausted = %v, want %v", pg.EndpointExhausted, tc.exhausted)
|
||
}
|
||
wantToken := strings.TrimSpace(tc.token)
|
||
if pg.NextToken != wantToken {
|
||
t.Fatalf("next_token = %q, want %q", pg.NextToken, wantToken)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// --- B153:Pagination pages/items 字段 ---
|
||
|
||
func TestPaginationPagesItemsFields(t *testing.T) {
|
||
// pages/items 为信息性计数(omitempty):设置后 wire 携带,未设置则缺席。
|
||
pg, err := NewPagination(false, "tok_2")
|
||
if err != nil {
|
||
t.Fatalf("NewPagination: %v", err)
|
||
}
|
||
pages, items := 2, 50
|
||
pg.Pages = pages
|
||
pg.Items = items
|
||
raw, err := json.Marshal(pg)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal: %v", err)
|
||
}
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
t.Fatalf("decode: %v\n%s", err, raw)
|
||
}
|
||
if got, ok := decoded["pages"].(float64); !ok || got != 2 {
|
||
t.Fatalf("pages = %v, want 2: %s", decoded["pages"], raw)
|
||
}
|
||
if got, ok := decoded["items"].(float64); !ok || got != 50 {
|
||
t.Fatalf("items = %v, want 50: %s", decoded["items"], raw)
|
||
}
|
||
if decoded["next_token"] != "tok_2" || decoded["endpoint_exhausted"] != false {
|
||
t.Fatalf("pagination wire lost core fields: %s", raw)
|
||
}
|
||
// 未设置时 omitempty 缺席。
|
||
bare, err := NewPagination(true, "")
|
||
if err != nil {
|
||
t.Fatalf("NewPagination(exhausted): %v", err)
|
||
}
|
||
raw, err = json.Marshal(bare)
|
||
if err != nil {
|
||
t.Fatalf("json.Marshal: %v", err)
|
||
}
|
||
var bareDecoded map[string]any
|
||
if err := json.Unmarshal(raw, &bareDecoded); err != nil {
|
||
t.Fatalf("decode: %v\n%s", err, raw)
|
||
}
|
||
if _, present := bareDecoded["pages"]; present {
|
||
t.Fatalf("unset pages must be omitted: %s", raw)
|
||
}
|
||
if _, present := bareDecoded["items"]; present {
|
||
t.Fatalf("unset items must be omitted: %s", raw)
|
||
}
|
||
}
|
||
|
||
// --- B154:空结果 data:[] + count:0 + endpoint_exhausted 断言 ---
|
||
|
||
func TestEmptyResultSetWireAssertion(t *testing.T) {
|
||
// AC-06/§3:空结果合法形态 = data:[] + meta.count:0 +
|
||
// pagination.endpoint_exhausted:true。三要素齐备且 wire 原样。
|
||
pg, err := NewPagination(true, "")
|
||
if err != nil {
|
||
t.Fatalf("NewPagination: %v", err)
|
||
}
|
||
env := NewSuccessEnvelope([]any{})
|
||
env.Meta = &Meta{Count: NewCount(0), Pagination: pg}
|
||
if err := env.Validate(); err != nil {
|
||
t.Fatalf("Validate: %v", err)
|
||
}
|
||
var stdout, stderr bytes.Buffer
|
||
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||
if err := em.Emit(env); err != nil {
|
||
t.Fatalf("Emit: %v", err)
|
||
}
|
||
var decoded struct {
|
||
OK bool `json:"ok"`
|
||
Outcome string `json:"outcome"`
|
||
Data []any `json:"data"`
|
||
Meta struct {
|
||
Count *int `json:"count"`
|
||
Pagination struct {
|
||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||
} `json:"pagination"`
|
||
} `json:"meta"`
|
||
}
|
||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||
t.Fatalf("stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||
}
|
||
if !decoded.OK || decoded.Outcome != "success" {
|
||
t.Fatalf("empty result = ok:%v outcome:%q, want true/success", decoded.OK, decoded.Outcome)
|
||
}
|
||
if decoded.Data == nil || len(decoded.Data) != 0 {
|
||
t.Fatalf("data must be [] (not null, not missing): %s", stdout.String())
|
||
}
|
||
if decoded.Meta.Count == nil || *decoded.Meta.Count != 0 {
|
||
t.Fatalf("meta.count = %v, want 0: %s", decoded.Meta.Count, stdout.String())
|
||
}
|
||
if !decoded.Meta.Pagination.EndpointExhausted {
|
||
t.Fatalf("empty result must mark endpoint_exhausted:true (§3): %s", stdout.String())
|
||
}
|
||
// raw 字符串层面:data 必须是 [] 而非 null。
|
||
if !strings.Contains(stdout.String(), `"data": []`) && !strings.Contains(stdout.String(), `"data":[]`) {
|
||
t.Fatalf("data must serialize as [] on the wire: %s", stdout.String())
|
||
}
|
||
if strings.Contains(stdout.String(), `"data": null`) || strings.Contains(stdout.String(), `"data":null`) {
|
||
t.Fatalf("data must never be null (§2.5): %s", stdout.String())
|
||
}
|
||
}
|