Files
larksuite--cli/shortcuts/sheets/lark_sheet_write_cells_test.go
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

587 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func TestWriteCellsShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}{
{
name: "+cells-set with --cells bare 2D array",
sc: CellsSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2",
"--cells", `[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]`,
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:B2",
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}, map[string]interface{}{"value": float64(2)}}, []interface{}{map[string]interface{}{"value": float64(3)}, map[string]interface{}{"value": float64(4)}}},
},
},
{
name: "+cells-set --allow-overwrite=false sends false explicitly",
sc: CellsSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1",
"--cells", `[[{"value":1}]]`,
"--allow-overwrite=false",
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1",
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}}},
"allow_overwrite": false,
},
},
{
name: "+cells-set --copy-to-range passes copy_to_range",
sc: CellsSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "H2",
"--cells", `[[{"formula":"=A2*B2"}]]`,
"--copy-to-range", "H2:H100",
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "H2",
"cells": []interface{}{[]interface{}{map[string]interface{}{"formula": "=A2*B2"}}},
"copy_to_range": "H2:H100",
},
},
{
name: "+csv-put inline csv",
sc: CsvPut,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--csv", "a,b,c\n1,2,3",
"--start-cell", "B3",
},
toolName: "set_range_from_csv",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"csv": "a,b,c\n1,2,3",
"start_cell": "B3",
},
},
{
name: "+dropdown-set fans out cells matrix",
sc: DropdownSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b"]`,
"--multiple",
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A2:A4",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestDropdownSet_CellsShape inspects the 3×1 matrix produced from
// --range A2:A4 to confirm the data_validation prototype is replicated.
// Also covers --colors / --highlight emitting the canonical
// `highlight_colors` / `enable_highlight` field names (not the legacy
// `colors` / `highlight_options`).
func TestDropdownSet_CellsShape(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4", "--options", `["a","b"]`, "--multiple",
"--colors", `["#FFE699","#bff7d9"]`, "--highlight",
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("cells rows = %d, want 3 (A2:A4)", len(cells))
}
for i, row := range cells {
r, _ := row.([]interface{})
if len(r) != 1 {
t.Errorf("row %d cols = %d, want 1", i, len(r))
}
cell, _ := r[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
if dv == nil {
t.Errorf("row %d cell missing data_validation: %#v", i, cell)
continue
}
if dv["type"] != "list" {
t.Errorf("row %d data_validation.type = %v, want list", i, dv["type"])
}
items, _ := dv["items"].([]interface{})
if len(items) != 2 || items[0] != "a" || items[1] != "b" {
t.Errorf("row %d data_validation.items = %#v, want [\"a\",\"b\"]", i, dv["items"])
}
if dv["support_multiple_values"] != true {
t.Errorf("row %d data_validation.support_multiple_values = %v, want true", i, dv["support_multiple_values"])
}
if _, hasLegacy := dv["multiple_values"]; hasLegacy {
t.Errorf("row %d data_validation should not emit legacy `multiple_values`", i)
}
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 2 || colors[0] != "#FFE699" || colors[1] != "#bff7d9" {
t.Errorf("row %d data_validation.highlight_colors = %#v, want [\"#FFE699\",\"#bff7d9\"]", i, dv["highlight_colors"])
}
if dv["enable_highlight"] != true {
t.Errorf("row %d data_validation.enable_highlight = %v, want true", i, dv["enable_highlight"])
}
if _, hasLegacy := dv["colors"]; hasLegacy {
t.Errorf("row %d data_validation should not emit legacy `colors`", i)
}
if _, hasLegacy := dv["highlight_options"]; hasLegacy {
t.Errorf("row %d data_validation should not emit legacy `highlight_options`", i)
}
}
}
// TestDropdownSet_HighlightTriState pins down the tri-state semantics of
// --highlight after the server flipped enable_highlight's default from false
// to true. The translator uses runtime.Changed() to tell "user did not pass
// the flag" apart from "user passed --highlight=false":
//
// - omitted → no enable_highlight key in body (server applies its
// new default = true)
// - --highlight → enable_highlight=true (presence-only cobra form)
// - --highlight=true → enable_highlight=true (explicit form)
// - --highlight=false → enable_highlight=false (the only way to opt out;
// the documented "plain dropdown" path)
func TestDropdownSet_HighlightTriState(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantKey bool
wantValue bool
}{
{
name: "omitted leaves enable_highlight off the body",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`},
wantKey: false,
},
{
name: "presence form (--highlight) stamps true",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight"},
wantKey: true,
wantValue: true,
},
{
name: "explicit --highlight=true stamps true",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=true"},
wantKey: true,
wantValue: true,
},
{
name: "explicit --highlight=false stamps false (the opt-out path)",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=false"},
wantKey: true,
wantValue: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, tc.args)
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
got, has := dv["enable_highlight"]
if has != tc.wantKey {
t.Fatalf("enable_highlight key present = %v, want %v (dv = %#v)", has, tc.wantKey, dv)
}
if tc.wantKey && got != tc.wantValue {
t.Errorf("enable_highlight = %v (%T), want %v", got, got, tc.wantValue)
}
})
}
}
// TestDropdownSet_ColorsLongerThanOptions checks the early Validate-time
// error when --colors length exceeds the dropdown source size (options
// length in list mode). Equal-or-shorter lengths are accepted (server
// cycles the rest through a built-in palette).
func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b"]`,
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
"--dry-run",
})
ve := requireValidation(t, err, "must not exceed dropdown source size")
if ve.Param != "--colors" {
t.Errorf("param = %q, want --colors", ve.Param)
}
}
// TestDropdownSet_ColorsShorterAccepted verifies the partial-colors case:
// fewer colors than options is legal — array is forwarded as-is and the
// server fills remaining slots from its default palette.
func TestDropdownSet_ColorsShorterAccepted(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b","c","d"]`,
"--colors", `["#FFE699","#bff7d9"]`,
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 2 {
t.Errorf("highlight_colors length = %d, want 2 (forwarded as-is)", len(colors))
}
}
// TestDropdownSet_ListFromRange verifies --source-range emits
// data_validation.type=listFromRange + data_validation.range, paired with
// --colors / --highlight propagating to highlight_colors / enable_highlight.
func TestDropdownSet_ListFromRange(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--source-range", "Sheet1!T1:T3",
"--colors", `["#cce8ff","#ffd6e7","#e6e6e6"]`,
"--highlight",
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
if dv["type"] != "listFromRange" {
t.Errorf("data_validation.type = %v, want listFromRange", dv["type"])
}
if dv["range"] != "Sheet1!T1:T3" {
t.Errorf("data_validation.range = %v, want Sheet1!T1:T3 (verbatim, server normalizes)", dv["range"])
}
if _, hasItems := dv["items"]; hasItems {
t.Errorf("listFromRange mode should not emit `items`: %#v", dv)
}
if dv["enable_highlight"] != true {
t.Errorf("data_validation.enable_highlight = %v, want true", dv["enable_highlight"])
}
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 3 {
t.Errorf("highlight_colors length = %d, want 3", len(colors))
}
}
// TestDropdownSet_ListFromRange_ColorsLongerThanCells rejects --colors
// longer than the source range cell count (T1:T3 has 3 cells, 4 colors
// must be refused).
func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--source-range", "Sheet1!T1:T3",
"--colors", `["#a","#b","#c","#d"]`,
"--highlight",
"--dry-run",
})
ve := requireValidation(t, err, "must not exceed dropdown source size")
if ve.Param != "--colors" {
t.Errorf("param = %q, want --colors", ve.Param)
}
}
// TestDropdownSet_XorBothSet rejects passing both --options and
// --source-range.
func TestDropdownSet_XorBothSet(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--options", `["a","b"]`,
"--source-range", "Sheet1!T1:T3",
"--dry-run",
})
requireValidation(t, err, "mutually exclusive")
}
// TestDropdownSet_XorNeitherSet rejects passing neither --options nor
// --source-range.
func TestDropdownSet_XorNeitherSet(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--dry-run",
})
requireValidation(t, err, "one of --options")
}
// TestCellsSetStyle_FlatFlags verifies that the 11 flat style flags +
// --border-styles compose into cell_styles + border_styles per cell.
func TestCellsSetStyle_FlatFlags(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsSetStyle, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B1",
"--font-weight", "bold",
"--background-color", "#ffff00",
"--horizontal-alignment", "center",
"--border-styles", `{"top":{"style":"solid"}}`,
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row, _ := cells[0].([]interface{})
cell, _ := row[0].(map[string]interface{})
style, _ := cell["cell_styles"].(map[string]interface{})
if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" || style["horizontal_alignment"] != "center" {
t.Errorf("cell_styles wrong: %#v", style)
}
if cell["border_styles"] == nil {
t.Fatalf("border_styles missing on cell: %#v", cell)
}
if _, leaked := style["border_styles"]; leaked {
t.Errorf("border_styles leaked into cell_styles: %#v", style)
}
}
func TestCellsSetStyle_RequiresAtLeastOneFlag(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSetStyle, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2", "--dry-run",
})
requireValidation(t, err, "at least one style flag")
}
func TestCellsSet_RequiresJSONArray(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", `{"foo":"bar"}`, "--dry-run",
})
// Schema validator fires first now: "--cells: expected type \"array\", got \"object\"".
// Either the schema phrasing or the legacy requireJSONArray phrasing is
// acceptable — both pin the same contract.
ve := requireValidation(t, err, "")
if !strings.Contains(ve.Message, `expected type "array"`) && !strings.Contains(ve.Message, "must be a JSON array") {
t.Errorf("expected array-type guard; got message=%q", ve.Message)
}
}
// TestCellsSet_RejectsUnsupportedMentionType pins the mention_type enum in
// data/flag-schemas.json (synced from the upstream tool schema): a rich_text
// mention whose mention_type is outside MENTION_FILE_TYPE (here 6 = cloud
// shared folder) is rejected by the schema validator at flag-parse time,
// before it can reach the server and blow up pb serialization
// ("mentionFileInfo.fileType: enum value expected").
func TestCellsSet_RejectsUnsupportedMentionType(t *testing.T) {
t.Parallel()
cells := `[[{"rich_text":[{"type":"mention","text":"x","mention_type":6,"mention_token":"t"}]}]]`
_, _, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells, "--dry-run",
})
ve := requireValidation(t, err, "mention_type")
if !strings.Contains(ve.Message, "not in enum") {
t.Errorf("expected enum guard; got message=%q", ve.Message)
}
}
// TestCellsSet_AllowsValidMentionTypes confirms the guard lets through a
// user @mention (mention_type 0) and a render-supported file type (22 = DOCX).
func TestCellsSet_AllowsValidMentionTypes(t *testing.T) {
t.Parallel()
for _, mt := range []int{0, 22} {
cells := fmt.Sprintf(`[[{"rich_text":[{"type":"mention","text":"x","mention_type":%d,"mention_token":"t"}]}]]`, mt)
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells, "--dry-run",
})
if err != nil {
t.Errorf("mention_type %d: unexpected error: stdout=%s stderr=%s err=%v", mt, stdout, stderr, err)
}
}
}
// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is
// rendered, including the parent_type=sheet_image upload metadata.
func TestCellsSetImage_DryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1",
"--image", "./README.md", // any existing-shaped path; dry-run skips stat
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls))
}
upload := calls[0].(map[string]interface{})
if upload["url"] != "/open-apis/drive/v1/medias/upload_all" {
t.Errorf("upload url = %v", upload["url"])
}
ubody, _ := upload["body"].(map[string]interface{})
if ubody["parent_type"] != "sheet_image" {
t.Errorf("parent_type = %v, want sheet_image", ubody["parent_type"])
}
if ubody["parent_node"] != testToken {
t.Errorf("parent_node = %v, want token", ubody["parent_node"])
}
embed := calls[1].(map[string]interface{})
body, _ := embed["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row, _ := cells[0].([]interface{})
cell, _ := row[0].(map[string]interface{})
rt, _ := cell["rich_text"].([]interface{})
if len(rt) != 1 {
t.Fatalf("rich_text len = %d, want 1", len(rt))
}
item, _ := rt[0].(map[string]interface{})
if item["type"] != "embed-image" {
t.Errorf("rich_text.type = %v, want embed-image", item["type"])
}
if item["image_token"] != "<file_token>" {
t.Errorf("image_token = %v, want <file_token>", item["image_token"])
}
if item["text"] != "" {
t.Errorf("text = %v, want empty string", item["text"])
}
if item["image_width"] != "<image_width>" {
t.Errorf("image_width = %v, want <image_width>", item["image_width"])
}
if item["image_height"] != "<image_height>" {
t.Errorf("image_height = %v, want <image_height>", item["image_height"])
}
}
// TestCellsSetImage_DryRunOfficeParentType confirms that an imported "office"
// spreadsheet (token prefixed with "fake_office_") uploads with
// parent_type=office_sheet_file instead of the native sheet_image, and that the
// preview's parent_node carries the same token.
func TestCellsSetImage_DryRunOfficeParentType(t *testing.T) {
t.Parallel()
const officeToken = "fake_office_abc123"
calls := parseDryRunAPI(t, CellsSetImage, []string{
"--spreadsheet-token", officeToken, "--sheet-id", testSheetID,
"--range", "A1",
"--image", "./README.md", // any existing-shaped path; dry-run skips stat
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls))
}
upload := calls[0].(map[string]interface{})
ubody, _ := upload["body"].(map[string]interface{})
if ubody["parent_type"] != officeSheetFileParentType {
t.Errorf("parent_type = %v, want %s", ubody["parent_type"], officeSheetFileParentType)
}
if ubody["parent_node"] != officeToken {
t.Errorf("parent_node = %v, want %s", ubody["parent_node"], officeToken)
}
}
func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2", "--image", "./foo.png", "--dry-run",
})
ve := requireValidation(t, err, "must be exactly one cell")
if ve.Param != "--range" {
t.Errorf("param = %q, want --range", ve.Param)
}
}
// TestCellsSetImage_DryRunRejectsUnsafePath guards that an unsafe --image path
// (e.g. an absolute path) is rejected during Validate, so --dry-run fails the
// same way as a real run instead of printing a misleading success preview.
func TestCellsSetImage_DryRunRejectsUnsafePath(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--image", "/etc/hosts", "--dry-run",
})
ve := requireValidation(t, err, "must be a relative path")
if ve.Param != "--image" {
t.Errorf("param = %q, want --image", ve.Param)
}
}
// TestRangeDimensions exercises the A1 parser's corner cases used by
// cells-set-style / dropdown-set / rows-resize / cols-resize.
func TestRangeDimensions(t *testing.T) {
t.Parallel()
cases := []struct {
in string
wantRows int
wantCols int
wantErr bool
}{
{"A1", 1, 1, false},
{"A1:B2", 2, 2, false},
{"sheet1!C3:E10", 8, 3, false},
{"A:C", 0, 0, true}, // whole column not supported
{"3:6", 0, 0, true}, // whole row not supported
{"B2:A1", 0, 0, true}, // end before start
{"", 0, 0, true},
}
var unusedSheet common.Shortcut = CellsSet // touch the common import
_ = unusedSheet
for _, c := range cases {
rows, cols, err := rangeDimensions(c.in)
if c.wantErr {
if err == nil {
t.Errorf("rangeDimensions(%q): want error, got rows=%d cols=%d", c.in, rows, cols)
}
continue
}
if err != nil {
t.Errorf("rangeDimensions(%q) unexpected error: %v", c.in, err)
}
if rows != c.wantRows || cols != c.wantCols {
t.Errorf("rangeDimensions(%q) = (%d,%d), want (%d,%d)", c.in, rows, cols, c.wantRows, c.wantCols)
}
}
}