Files
dingtalk-workspace-cli/internal/helpers/sheet_read_completion_test.go

203 lines
7.5 KiB
Go

package helpers
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
)
func executeSheetReadCompletionCommand(t *testing.T, caller *scriptedToolCaller, args ...string) (*bytes.Buffer, error) {
t.Helper()
installScriptedCaller(t, caller)
installSheetProductArgs(t)
stdout := &bytes.Buffer{}
deps.Out.w = stdout
cmd := newSheetCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
return stdout, cmd.Execute()
}
func decodeSheetReadOutput(t *testing.T, stdout *bytes.Buffer) map[string]any {
t.Helper()
var got map[string]any
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode sheet read output %q: %v", stdout.String(), err)
}
return got
}
func assertSheetReadCompletionMetadata(t *testing.T, got map[string]any) {
t.Helper()
if hasMore, ok := got["hasMore"].(bool); !ok || !hasMore {
t.Fatalf("hasMore = %#v, want true", got["hasMore"])
}
if got["resolvedRange"] != "A1:A30001" {
t.Fatalf("resolvedRange = %#v", got["resolvedRange"])
}
if got["returnedRange"] != "A1:A30000" {
t.Fatalf("returnedRange = %#v", got["returnedRange"])
}
reasons, ok := got["truncationReasons"].([]any)
if !ok || len(reasons) == 0 || reasons[0] != "max_cells" {
t.Fatalf("truncationReasons = %#v", got["truncationReasons"])
}
}
// csv-get intentionally projects the MCP response rather than inventing a
// client-side page model. Keep all completion metadata visible and make one
// request only, even when the server reports a partial success.
func TestSheetCSVGetPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) {
const response = `{"csv":"[row=1]1\n","colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells","max_chars"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: response}},
}
stdout, err := executeSheetReadCompletionCommand(t, caller,
"csv-get", "--node", "NODE")
if err != nil {
t.Fatalf("csv-get: %v", err)
}
if caller.calls != 1 || caller.tool != "get_range_as_csv" {
t.Fatalf("calls/tool = %d/%q, want one get_range_as_csv call", caller.calls, caller.tool)
}
if _, exists := caller.args["range"]; exists {
t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"])
}
if got := strings.TrimSpace(stdout.String()); got != response {
t.Fatalf("raw csv-get response changed:\n got: %s\nwant: %s", got, response)
}
got := decodeSheetReadOutput(t, stdout)
assertSheetReadCompletionMetadata(t, got)
reasons := got["truncationReasons"].([]any)
if len(reasons) != 2 || reasons[1] != "max_chars" {
t.Fatalf("truncationReasons = %#v, want max_cells and max_chars", reasons)
}
}
// range read parses the get_cell_infos envelope to remove empty per-cell
// schema shells. That cleanup must not discard new top-level completion fields.
func TestSheetRangeReadPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) {
const response = `{"cells":[[{"value":"1","dataValidation":{"type":null},"hyperlink":{"url":null}}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "json",
steps: []scriptedToolStep{{text: response}},
}
stdout, err := executeSheetReadCompletionCommand(t, caller,
"range", "read", "--node", "NODE")
if err != nil {
t.Fatalf("range read: %v", err)
}
if caller.calls != 1 || caller.tool != "get_cell_infos" {
t.Fatalf("calls/tool = %d/%q, want one get_cell_infos call", caller.calls, caller.tool)
}
if _, exists := caller.args["range"]; exists {
t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"])
}
got := decodeSheetReadOutput(t, stdout)
assertSheetReadCompletionMetadata(t, got)
cells := got["cells"].([]any)
row := cells[0].([]any)
cell := row[0].(map[string]any)
if _, exists := cell["dataValidation"]; exists {
t.Fatalf("empty dataValidation shell was not removed: %#v", cell)
}
if _, exists := cell["hyperlink"]; exists {
t.Fatalf("empty hyperlink shell was not removed: %#v", cell)
}
}
// sheet +read calls get_cell_infos through this direct passthrough instead of
// callMCPToolCellInfos. Lock that third read surface to the same raw envelope
// contract and to one request only.
func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T) {
const response = `{"cells":[[{"value":"1"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: response}},
}
installScriptedCaller(t, caller)
stdout := &bytes.Buffer{}
deps.Out.w = stdout
if err := CallMCPToolOnServer("sheet", "get_cell_infos", map[string]any{"nodeId": "NODE"}); err != nil {
t.Fatalf("direct get_cell_infos: %v", err)
}
if caller.calls != 1 || caller.server != "sheet" || caller.tool != "get_cell_infos" {
t.Fatalf("calls/server/tool = %d/%q/%q", caller.calls, caller.server, caller.tool)
}
if got := strings.TrimSpace(stdout.String()); got != response {
t.Fatalf("raw get_cell_infos response changed:\n got: %s\nwant: %s", got, response)
}
assertSheetReadCompletionMetadata(t, decodeSheetReadOutput(t, stdout))
}
// Large-CP failure is not a pageable success. Both the raw csv-get path and
// both get_cell_infos paths must retain the backend code and safe user message
// exactly, matching the generic get_all_sheets error-classification path.
func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}`
tests := []struct {
name string
args []string
tool string
direct bool
}{
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"},
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"},
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{
format: "json",
steps: []scriptedToolStep{{text: response}},
}
var stdout *bytes.Buffer
var err error
if test.direct {
installScriptedCaller(t, caller)
stdout = &bytes.Buffer{}
deps.Out.w = stdout
err = CallMCPToolOnServer("sheet", test.tool, map[string]any{"nodeId": "NODE"})
} else {
stdout, err = executeSheetReadCompletionCommand(t, caller, test.args...)
}
if err == nil {
t.Fatal("large-CP response was accepted as success")
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError {
t.Fatalf("error = %T %v, want MCP tool error", err, err)
}
if cliErr.Message != response {
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response)
}
for _, want := range []string{
"forbidden.document.sizeOverLimit",
"The workbook data is too large to process.",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not preserve %q", err, want)
}
}
if stdout.Len() != 0 {
t.Fatalf("failure wrote a success body: %q", stdout.String())
}
if caller.calls != 1 || caller.tool != test.tool {
t.Fatalf("calls/tool = %d/%q, want one %s call", caller.calls, caller.tool, test.tool)
}
})
}
}