Files
larksuite__cli/shortcuts/sheets/read_output_test.go
xiongyuanwen-byted be2a96f490 feat(sheets): harden error prescriptions, batch updates, and read workflows
Aggregate the sheets work from feat/lark-sheets-develop:

- Improve validation errors with schema hints, aggregated issues, enum guidance, and prescriptive flag/style-field messages.
- Harden +batch-update input contracts, key normalization, style vocabulary handling, and resource-budget checks.
- Add read offload and truncation handling for cells, csv, and table-get, with typed output-path errors and safer jq/output-path semantics.
- Correct freeze semantics by emitting full-state freeze/unfreeze operations and adding --rows/--cols for +dim-freeze.
- Improve +styles-put and shared --styles parsing for styles, merges, row/column sizing, freeze, and sheet-prefixed range validation.
- Fix dim-insert inherit-style mapping, table-get date/time handling, table-put style anchors, and CSV path-shaped input guards.
- Update lark-sheets skill docs, scripts, tests, and generated flag data.

Tested with:
- go test ./shortcuts/common ./shortcuts/sheets/...
- go test ./shortcuts/... ./internal/...
- python3 -m py_compile skills/lark-sheets/scripts/*.py
2026-08-07 11:21:12 +08:00

222 lines
8.5 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/httpmock"
)
// TestReadOutputPath_UnsafePathIsTypedValidation pins the error contract of
// the --output-path save seam: an escaping path must come back as a
// validation error with the path-validation cause preserved, not as
// internal/unknown from the raw FileIO.Save error.
func TestReadOutputPath_UnsafePathIsTypedValidation(t *testing.T) {
t.Parallel()
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"output": `{"values":[["x"]]}`},
},
}
_, err := runShortcutWithStubs(t, CellsGet, []string{
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1",
"--output-path", "../../outside.json", "--as", "user",
}, stub)
ve := requireValidation(t, err, "unsafe output path")
if ve.Param != "--output-path" {
t.Errorf("Param = %q, want --output-path", ve.Param)
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("ProblemOf = %#v, want validation/invalid_argument", p)
}
if ve.Cause == nil || !errors.Is(ve.Cause, fileio.ErrPathValidation) {
t.Errorf("Cause = %v, want the fileio.ErrPathValidation chain preserved", ve.Cause)
}
}
// TestReadResultTruncated_AllLevels pins the completeness classifier: a
// truncation marker at ANY level must be seen, or the --output-path receipt
// claims complete:true over a clipped file and an agent analyzes half the
// data believing it has all of it.
func TestReadResultTruncated_AllLevels(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
out interface{}
want bool
}{
{"top-level truncated", map[string]interface{}{"truncated": true}, true},
{"top-level has_more", map[string]interface{}{"has_more": true}, true},
{"ranges entry", map[string]interface{}{"ranges": []interface{}{map[string]interface{}{"truncated": true}}}, true},
{"sheets entry", map[string]interface{}{"sheets": []interface{}{map[string]interface{}{"truncated": true}}}, true},
{"nested ranges inside a sheet", map[string]interface{}{
"sheets": []interface{}{map[string]interface{}{
"ranges": []interface{}{map[string]interface{}{"truncated": true}},
}},
}, true},
{"clean payload", map[string]interface{}{"sheets": []interface{}{map[string]interface{}{"data": []interface{}{}}}}, false},
{"non-map payload", []interface{}{1, 2}, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := readResultTruncated(tc.out); got != tc.want {
t.Errorf("readResultTruncated = %v, want %v", got, tc.want)
}
})
}
}
// TestMaxCharsInput_ExplicitZero pins that asking for "no cap of my own" does
// not land on the SMALLEST cap. Omitting max_chars makes the read tool apply
// its own ~50000 fallback, so passing the request straight through would give
// --max-chars 0 a tighter limit than leaving the flag alone — the opposite of
// what it reads like, and silently.
func TestMaxCharsInput_ExplicitZero(t *testing.T) {
t.Parallel()
t.Run("resolves to whatever omitting the flag resolves to", func(t *testing.T) {
t.Parallel()
// Compared against the omitted call rather than against maxCharsFallback:
// asserting the constant equals itself would pass even after the flag's
// declared default moved in flag-defs.json and left the two out of step.
// The contract is "0 means no cap of my own", i.e. behave as if unset.
zero := cellsGetToolInput(t, []string{"--max-chars", "0"})
omitted := cellsGetToolInput(t, nil)
got, ok := zero["max_chars"]
if !ok {
t.Fatalf("max_chars must be sent, or the tool's ~50000 fallback binds: %#v", zero)
}
if want := omitted["max_chars"]; got != want {
t.Errorf("--max-chars 0 sent max_chars=%v, omitting it sent %v; they must agree", got, want)
}
})
t.Run("--output-path still raises it to the offload limit", func(t *testing.T) {
t.Parallel()
input := cellsGetToolInput(t, []string{"--max-chars", "0", "--output-path", "./o.json"})
if got := input["max_chars"]; got != float64(outputPathReadLimit) {
t.Errorf("max_chars = %v, want %d", got, outputPathReadLimit)
}
})
t.Run("a positive explicit cap still wins over --output-path", func(t *testing.T) {
t.Parallel()
input := cellsGetToolInput(t, []string{"--max-chars", "1234", "--output-path", "./o.json"})
if got := input["max_chars"]; got != float64(1234) {
t.Errorf("max_chars = %v, want 1234", got)
}
})
}
func cellsGetToolInput(t *testing.T, extra []string) map[string]interface{} {
t.Helper()
args := append([]string{"--url", testURL, "--sheet-name", "S1", "--range", "A1:B2"}, extra...)
return decodeToolInput(t, parseDryRunBody(t, CellsGet, args), "get_cell_ranges")
}
// TestEmitReadResult_ReceiptStatesCompleteness drives a real --output-path read
// end to end and checks the stdout receipt against the payload written to disk.
//
// The receipt is the ONLY completeness signal a caller gets on this path — the
// data went to a file, stdout carries just the summary — and the skill docs
// instruct agents to read `complete` before using the file. Nothing was pinning
// it: hard-coding complete:true passed the whole suite, which is exactly the
// failure that makes an agent analyze half a sheet believing it has all of it.
//
// Not parallel: t.Chdir scopes the relative --output-path to a temp dir.
func TestEmitReadResult_ReceiptStatesCompleteness(t *testing.T) {
cases := []struct {
name string
output string
wantComplete bool
}{
{
name: "clean read reports complete",
output: `{"ranges":[{"range":"A1:B2","values":[["x","y"]]}]}`,
wantComplete: true,
},
{
name: "per-range truncation flag reports incomplete",
output: `{"ranges":[{"range":"A1:B2","truncated":true,"values":[["x","y"]]}]}`,
wantComplete: false,
},
{
name: "top-level has_more reports incomplete",
output: `{"has_more":true,"ranges":[{"range":"A1:B2","values":[["x","y"]]}]}`,
wantComplete: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
// os.Chdir + Cleanup rather than t.Chdir: the latter is Go 1.24,
// and go.mod declares 1.23.0 (CI resolves its toolchain from it).
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"output": tc.output},
},
}
stdout, err := runShortcutWithStubs(t, CellsGet, []string{
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2",
"--output-path", "out.json", "--as", "user",
}, stub)
if err != nil {
t.Fatalf("read failed: %v", err)
}
receipt := decodeEnvelopeData(t, stdout)
if got := receipt["complete"]; got != tc.wantComplete {
t.Errorf("complete = %v, want %v (receipt=%v)", got, tc.wantComplete, receipt)
}
if !tc.wantComplete {
if receipt["truncated"] != true {
t.Errorf("an incomplete receipt must also set truncated:true, got %v", receipt)
}
if w, _ := receipt["truncation_warning"].(string); w == "" {
t.Error("an incomplete receipt must carry a truncation_warning telling the caller what to do")
}
} else if _, has := receipt["truncated"]; has {
t.Errorf("a complete receipt must not carry a truncation marker, got %v", receipt)
}
// The file must actually hold the payload, not the receipt.
written, readErr := os.ReadFile(filepath.Join(dir, "out.json"))
if readErr != nil {
t.Fatalf("output file not written: %v", readErr)
}
var payload map[string]interface{}
if err := json.Unmarshal(written, &payload); err != nil {
t.Fatalf("output file is not JSON: %v", err)
}
if _, has := payload["ranges"]; !has {
t.Errorf("file should hold the data payload, got %s", written)
}
if n, _ := receipt["bytes_written"].(float64); int(n) != len(written) {
t.Errorf("bytes_written = %v, file is %d bytes", receipt["bytes_written"], len(written))
}
})
}
}