Both capabilities were added as flags on an existing leaf, and in both cases the leaf's published interface stopped describing what the command did: - `sheet create --values/--sheets/--styles` orchestrates create → probe → resolve default worksheet → write → read back → optional styles, yet the leaf still published `interface_mode: mcp` + `create_workspace_sheet`. - `sheet export --export-format csv` reads `get_range_as_csv` and never invokes `submit_export_job`, yet the leaf published `submit_export_job`. Each moves to its own command, declaring the interface it actually uses: `sheet create-with-data` is `composite` with a reviewed reason and no `interface_ref`; `sheet export-csv` is `mcp` + `get_range_as_csv`. Both are pinned in the interface-disposition contract test. `sheet create` and `sheet export` are restored byte-for-byte to main, so the compatibility gates see two `command_added` additions instead of four locked-field changes. The split also removes a user-visible trap: `--range` without `--export-format csv` used to be silently discarded and the whole workbook exported; the cross-format flags no longer exist, pinned by TestSheetExportAndExportCsvFlagsDoNotLeak. Drops the 8 now-stale mapping-ledger exclusions that covered the flags on `sheet.create_workspace_sheet` / `sheet.submit_export_job`, shrinking this branch's exclusion surface. Skill references and CHANGELOG follow the split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
13 KiB
Go
354 lines
13 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.
|
||
|
||
package helpers
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/spf13/pflag"
|
||
)
|
||
|
||
func executeSheetExportCsv(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||
t.Helper()
|
||
installScriptedCaller(t, caller)
|
||
oldArgs := os.Args
|
||
os.Args = []string{"dws", "sheet"}
|
||
t.Cleanup(func() { os.Args = oldArgs })
|
||
cmd := newSheetExportCsvCmd()
|
||
for index := 0; index < len(args); index += 2 {
|
||
if err := cmd.Flags().Set(args[index], args[index+1]); err != nil {
|
||
t.Fatalf("set %s: %v", args[index], err)
|
||
}
|
||
}
|
||
return runSheetExportCsv(cmd, nil)
|
||
}
|
||
|
||
// export-csv 是独立叶子,不能和 xlsx 的 sheet export 共用一套 flag:混在一条命令里
|
||
// 时,自动化漏写 --export-format csv 会让 --range 被静默丢掉、导出整篇工作簿而仍报
|
||
// 成功。这里钉住两条命令的 flag 面互不渗透。
|
||
func TestSheetExportAndExportCsvFlagsDoNotLeak(t *testing.T) {
|
||
xlsxOnly := []string{}
|
||
newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { xlsxOnly = append(xlsxOnly, f.Name) })
|
||
sort.Strings(xlsxOnly)
|
||
if want := []string{"node", "output"}; !reflect.DeepEqual(xlsxOnly, want) {
|
||
t.Errorf("sheet export flags = %v, want %v(csv 专属 flag 不能回到 xlsx 命令上)", xlsxOnly, want)
|
||
}
|
||
|
||
csvFlags := map[string]bool{}
|
||
newSheetExportCsvCmd().Flags().VisitAll(func(f *pflag.Flag) { csvFlags[f.Name] = true })
|
||
for _, name := range []string{"node", "output", "sheet-id", "range", "value-render-option", "allow-truncated"} {
|
||
if !csvFlags[name] {
|
||
t.Errorf("sheet export-csv 缺少 flag --%s", name)
|
||
}
|
||
}
|
||
// 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。
|
||
if csvFlags["export-format"] {
|
||
t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)")
|
||
}
|
||
}
|
||
|
||
func TestSheetExportCsvRequiresNode(t *testing.T) {
|
||
caller := &scriptedToolCaller{}
|
||
err := executeSheetExportCsv(t, caller)
|
||
if err == nil || !strings.Contains(err.Error(), "--node is required") {
|
||
t.Fatalf("err = %v, want required node", err)
|
||
}
|
||
if caller.calls != 0 {
|
||
t.Fatalf("calls = %d, want 0", caller.calls)
|
||
}
|
||
}
|
||
|
||
// --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。
|
||
func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) {
|
||
caller := &scriptedToolCaller{}
|
||
err := executeSheetExportCsv(t, caller, "node", "NODE", "value-render-option", "pretty")
|
||
if err == nil ||
|
||
!strings.Contains(err.Error(), "--value-render-option 必须为 formatted_value / raw_value / formula") {
|
||
t.Fatalf("err = %v, want enum rejection", err)
|
||
}
|
||
if caller.calls != 0 {
|
||
t.Fatalf("calls = %d, want 0(校验必须在发请求之前)", caller.calls)
|
||
}
|
||
|
||
for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} {
|
||
ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||
if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil {
|
||
t.Fatalf("value-render-option=%q 应被接受: %v", option, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) {
|
||
caller := &scriptedToolCaller{dry: true}
|
||
if err := executeSheetExportCsv(t, caller,
|
||
"node", "NODE", "sheet-id", "SHEET_1", "output", "out.csv"); err != nil {
|
||
t.Fatalf("csv dry run: %v", err)
|
||
}
|
||
if caller.calls != 0 {
|
||
t.Fatalf("dry run 不应发请求, calls = %d", caller.calls)
|
||
}
|
||
}
|
||
|
||
func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) {
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||
}}
|
||
// 截断默认失败,所以要显式放行才能走到落盘/输出。
|
||
if err := executeSheetExportCsv(t, caller,
|
||
"node", "NODE", "range", "A1:Z1000", "sheet-id", "SHEET_1",
|
||
"value-render-option", "raw_value", "allow-truncated", "true"); err != nil {
|
||
t.Fatalf("csv export: %v", err)
|
||
}
|
||
for _, kv := range [][2]string{
|
||
{"range", "A1:Z1000"},
|
||
{"sheetId", "SHEET_1"},
|
||
{"valueRenderOption", "raw_value"},
|
||
} {
|
||
if got := caller.args[kv[0]]; got != kv[1] {
|
||
t.Errorf("%s 未透传: %#v", kv[0], got)
|
||
}
|
||
}
|
||
// csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。
|
||
if caller.args["annotateRowNumbers"] != false {
|
||
t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"])
|
||
}
|
||
}
|
||
|
||
// 截断必须 fail-closed:默认报错、不写文件、不报“导出完成”。否则自动化调用方会把
|
||
// 不完整文件当成完整导出,且已存在的目标文件会被截断数据覆盖。
|
||
func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) {
|
||
dir := t.TempDir()
|
||
out := filepath.Join(dir, "out.csv")
|
||
if err := os.WriteFile(out, []byte("PREEXISTING\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||
}}
|
||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||
if err == nil {
|
||
t.Fatal("截断时未放行应报错,而非静默成功")
|
||
}
|
||
for _, want := range []string{"截断", "已中止导出", "--allow-truncated"} {
|
||
if !strings.Contains(err.Error(), want) {
|
||
t.Errorf("错误信息缺少 %q: %v", want, err)
|
||
}
|
||
}
|
||
// 关键:既有文件必须保持原样,不能被截断数据覆盖。
|
||
body, readErr := os.ReadFile(out)
|
||
if readErr != nil {
|
||
t.Fatalf("read output: %v", readErr)
|
||
}
|
||
if string(body) != "PREEXISTING\n" {
|
||
t.Fatalf("目标文件被截断数据覆盖了: %q", string(body))
|
||
}
|
||
}
|
||
|
||
// 显式放行后允许落盘,但成功信息必须点明数据不完整。
|
||
func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) {
|
||
dir := t.TempDir()
|
||
out := filepath.Join(dir, "out.csv")
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||
}}
|
||
if err := executeSheetExportCsv(t, caller,
|
||
"node", "NODE", "output", out, "allow-truncated", "true"); err != nil {
|
||
t.Fatalf("allow-truncated 应允许导出: %v", err)
|
||
}
|
||
body, err := os.ReadFile(out)
|
||
if err != nil {
|
||
t.Fatalf("read output: %v", err)
|
||
}
|
||
if string(body) != "a,b\n" {
|
||
t.Fatalf("csv 内容 = %q", string(body))
|
||
}
|
||
}
|
||
|
||
// 未截断时不受影响:不需要 --allow-truncated,成功信息也不提不完整。
|
||
func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) {
|
||
dir := t.TempDir()
|
||
out := filepath.Join(dir, "out.csv")
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n","hasMore":false}`}}}
|
||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil {
|
||
t.Fatalf("完整读取不应要求放行: %v", err)
|
||
}
|
||
body, err := os.ReadFile(out)
|
||
if err != nil || string(body) != "a,b\n" {
|
||
t.Fatalf("csv 内容 = %q, err = %v", string(body), err)
|
||
}
|
||
}
|
||
|
||
func TestSheetExportCsvWritesIntoDirectory(t *testing.T) {
|
||
dir := t.TempDir()
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", dir); err != nil {
|
||
t.Fatalf("csv export: %v", err)
|
||
}
|
||
body, err := os.ReadFile(filepath.Join(dir, "sheet-export.csv"))
|
||
if err != nil {
|
||
t.Fatalf("read exported csv: %v", err)
|
||
}
|
||
if string(body) != "a,b\n" {
|
||
t.Fatalf("csv 内容 = %q", string(body))
|
||
}
|
||
}
|
||
|
||
// 落盘失败必须保住已有文件:os.WriteFile 会先截断目标,写到一半失败(磁盘满、
|
||
// 配额、I/O 错误)用户的原文件就没了。走 AtomicWrite 后失败只丢临时文件。
|
||
func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) {
|
||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||
const original = "原有重要数据\n1,2,3\n"
|
||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 模拟"临时文件写完、替换目标时失败",这是 os.WriteFile 会毁掉原文件的时刻。
|
||
previousRename := atomicRename
|
||
t.Cleanup(func() { atomicRename = previousRename })
|
||
atomicRename = func(string, string) error { return errors.New("no space left on device") }
|
||
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||
t.Fatalf("err = %v, want write failure", err)
|
||
}
|
||
|
||
body, readErr := os.ReadFile(out)
|
||
if readErr != nil {
|
||
t.Fatalf("read output: %v", readErr)
|
||
}
|
||
if string(body) != original {
|
||
t.Fatalf("写入失败后原文件被破坏为 %q,应保持不变", string(body))
|
||
}
|
||
|
||
// 临时文件不能留在目录里冒充导出产物。
|
||
entries, readDirErr := os.ReadDir(filepath.Dir(out))
|
||
if readDirErr != nil {
|
||
t.Fatalf("read dir: %v", readDirErr)
|
||
}
|
||
if len(entries) != 1 || entries[0].Name() != filepath.Base(out) {
|
||
names := make([]string, 0, len(entries))
|
||
for _, entry := range entries {
|
||
names = append(names, entry.Name())
|
||
}
|
||
t.Fatalf("目录残留临时文件: %v", names)
|
||
}
|
||
}
|
||
|
||
func TestSheetExportCsvReportsWriteFailure(t *testing.T) {
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||
missing := filepath.Join(t.TempDir(), "no-such-dir", "out.csv")
|
||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing)
|
||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||
t.Fatalf("err = %v, want write failure", err)
|
||
}
|
||
}
|
||
|
||
func TestSheetExportCsvReportsReadFailure(t *testing.T) {
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}}
|
||
err := executeSheetExportCsv(t, caller, "node", "NODE")
|
||
if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") {
|
||
t.Fatalf("err = %v, want read failure", err)
|
||
}
|
||
}
|
||
|
||
func TestParseGetRangeAsCsvResult(t *testing.T) {
|
||
// 兼容裸响应与 result 包装两种形状
|
||
for _, body := range []string{
|
||
`{"csv":"a,b\n","hasMore":true}`,
|
||
`{"result":{"csv":"a,b\n","hasMore":true}}`,
|
||
} {
|
||
csv, hasMore, err := parseGetRangeAsCsvResult(body)
|
||
if err != nil || csv != "a,b\n" || !hasMore {
|
||
t.Fatalf("parseGetRangeAsCsvResult(%s) = (%q,%v,%v)", body, csv, hasMore, err)
|
||
}
|
||
}
|
||
// 字段存在但为空串是合法的(真的空区域)
|
||
csv, hasMore, err := parseGetRangeAsCsvResult(`{"csv":""}`)
|
||
if err != nil || csv != "" || hasMore {
|
||
t.Fatalf(`csv:"" = (%q,%v,%v), want ("",false,nil)`, csv, hasMore, err)
|
||
}
|
||
}
|
||
|
||
// csv 字段缺失或类型不对必须报错。此前会被当成空表,配合 --output 会用
|
||
// 0 字节覆盖已有文件并打印"导出完成",属于静默数据丢失。
|
||
func TestParseGetRangeAsCsvResultRejectsMissingOrBadCsvField(t *testing.T) {
|
||
for _, tc := range []struct{ body, want string }{
|
||
{"not json", "解析 get_range_as_csv 响应失败"},
|
||
{`{"message":"something odd"}`, "缺少 csv 字段"},
|
||
{`{"csv":123}`, "csv 字段不是字符串"},
|
||
{`{"csv":null}`, "csv 字段不是字符串"},
|
||
{`{"result":"not-an-object"}`, "result 不是对象"},
|
||
{`{"result":{"message":"odd"}}`, "缺少 csv 字段"},
|
||
} {
|
||
if _, _, err := parseGetRangeAsCsvResult(tc.body); err == nil ||
|
||
!strings.Contains(err.Error(), tc.want) {
|
||
t.Errorf("parseGetRangeAsCsvResult(%s) err = %v, want contains %q", tc.body, err, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 端到端:响应缺 csv 字段时,绝不能覆盖 --output 指向的已有文件。
|
||
func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) {
|
||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||
const original = "原有重要数据\n1,2,3\n"
|
||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}}
|
||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||
if err == nil || !strings.Contains(err.Error(), "缺少 csv 字段") {
|
||
t.Fatalf("err = %v, want missing csv field", err)
|
||
}
|
||
body, readErr := os.ReadFile(out)
|
||
if readErr != nil {
|
||
t.Fatal(readErr)
|
||
}
|
||
if string(body) != original {
|
||
t.Fatalf("已有文件被改写为 %q,原内容应保持不变", string(body))
|
||
}
|
||
}
|
||
|
||
// CSV 正文走 stdout,截断警告必须只走 stderr:否则管道/重定向拿到的文件里会
|
||
// 混入 [WARN] 文本,不再是合法 RFC4180 CSV。而大表恰恰最容易触发这个分支。
|
||
func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) {
|
||
previousDeps := deps
|
||
t.Cleanup(func() { deps = previousDeps })
|
||
|
||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\nc,d\n","hasMore":true}`}}}
|
||
installScriptedCaller(t, caller)
|
||
installSheetProductArgs(t)
|
||
var stdout, stderr bytes.Buffer
|
||
deps.Out.w, deps.Out.errW = &stdout, &stderr
|
||
|
||
cmd := newSheetExportCsvCmd()
|
||
// 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。
|
||
for _, kv := range [][2]string{{"node", "NODE"}, {"allow-truncated", "true"}} {
|
||
if err := cmd.Flags().Set(kv[0], kv[1]); err != nil {
|
||
t.Fatalf("set --%s: %v", kv[0], err)
|
||
}
|
||
}
|
||
if err := runSheetExportCsv(cmd, nil); err != nil {
|
||
t.Fatalf("csv export: %v", err)
|
||
}
|
||
|
||
if got := stdout.String(); got != "a,b\nc,d\n" {
|
||
t.Fatalf("stdout = %q, want 纯 CSV 正文", got)
|
||
}
|
||
if strings.Contains(stdout.String(), "WARN") || strings.Contains(stdout.String(), "截断") {
|
||
t.Fatalf("stdout 混入了警告文本: %q", stdout.String())
|
||
}
|
||
if !strings.Contains(stderr.String(), "CSV 已被截断") {
|
||
t.Fatalf("stderr = %q, want 截断提示", stderr.String())
|
||
}
|
||
}
|