Merge pull request #1030 from zhengzhijiej-tech/fix/sheets-e2e-fixes-batch1

fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema (restore --colors/--highlight)
This commit is contained in:
zhengzhijiej-tech
2026-05-25 14:43:15 +08:00
committed by GitHub
15 changed files with 419 additions and 66 deletions

View File

@@ -132,8 +132,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
{
shortcut: "+range-sort",
sc: RangeSort,
args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"col":"B","order":"asc"}]`, "--has-header"},
subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"col":"B","order":"asc"}],"has-header":true}`,
args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"},
subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`,
},
{
shortcut: "+sheet-create",

View File

@@ -1867,7 +1867,7 @@
"name": "options",
"kind": "own",
"type": "string",
"required": "required",
"required": "xor",
"desc": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`; up to 500 items, each ≤100 chars, no commas",
"input": [
"file",
@@ -1879,7 +1879,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`",
"desc": "RGB hex pill colors for dropdown options (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); maps to server `data_validation.highlight_colors`. Length may be **shorter than** `--options` (server cycles remaining slots through a built-in 10-color palette) but **must not exceed** it. Only takes effect when `--highlight` is also set.",
"input": [
"file",
"stdin"
@@ -1897,7 +1897,14 @@
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Color-highlight options; default `false`"
"desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. When `true`, colors come from `--colors` in order; options beyond `--colors` length cycle through a built-in 10-color palette."
},
{
"name": "source-range",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same."
},
{
"name": "dry-run",
@@ -2795,7 +2802,7 @@
"name": "options",
"kind": "own",
"type": "string",
"required": "required",
"required": "xor",
"desc": "Options as a JSON array (e.g. `[\"opt1\",\"opt2\"]`)",
"input": [
"file",
@@ -2807,7 +2814,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Color array (same length as `--options`)",
"desc": "RGB hex pill colors for dropdown options (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); maps to server `data_validation.highlight_colors`. Length may be **shorter than** `--options` (remaining slots cycle through a built-in palette) but **must not exceed** it. Only takes effect when `--highlight` is also set.",
"input": [
"file",
"stdin"
@@ -2825,7 +2832,14 @@
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Color-highlight options"
"desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. Works with `--colors`; if `--colors` is omitted, all options cycle through a built-in 10-color palette."
},
{
"name": "source-range",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same."
},
{
"name": "dry-run",

View File

@@ -558,7 +558,7 @@
]
},
"items": {
"description": "列表选项",
"description": "列表选项type='list' 时必填)",
"type": "array",
"items": {
"type": "string"
@@ -603,6 +603,17 @@
"help_text": {
"description": "验证失败时显示的提示文本",
"type": "string"
},
"enable_highlight": {
"description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效,默认 false。",
"type": "boolean"
},
"highlight_colors": {
"description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"]),仅当 enable_highlight=true 时生效。按顺序对应type='list' 对应 itemstype='listFromRange' 按 range 内单元格行优先顺序,如 A1:A10 对应第 1-10 项A1:B2 顺序为 A1,B1,A2,B2。长度可以短于但不能长于未指定项及不提供该字段时按内置 10 色色板循环补色。",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
@@ -1759,10 +1770,6 @@
}
}
},
"required": [
"position",
"size"
],
"additionalProperties": {}
}
},
@@ -2789,10 +2796,6 @@
}
}
},
"required": [
"position",
"size"
],
"additionalProperties": {}
}
},
@@ -3568,7 +3571,7 @@
},
"+dropdown-set": {
"options": {
"description": "列表选项",
"description": "列表选项type='list' 时必填)",
"type": "array",
"items": {
"type": "string"
@@ -3577,7 +3580,7 @@
},
"+dropdown-update": {
"options": {
"description": "列表选项",
"description": "列表选项type='list' 时必填)",
"type": "array",
"items": {
"type": "string"

View File

@@ -398,7 +398,7 @@ func TestExecute_RangeSort(t *testing.T) {
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:D50",
"--has-header",
"--sort-keys", `[{"col":"B","order":"asc"}]`,
"--sort-keys", `[{"column":"B","ascending":true}]`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)

View File

@@ -332,7 +332,7 @@ var DropdownUpdate = common.Shortcut{
if _, err := validateDropdownRanges(runtime); err != nil {
return err
}
if _, err := validateDropdownOptionsColors(runtime); err != nil {
if _, err := validateDropdownSourceOrOptions(runtime); err != nil {
return err
}
return nil

View File

@@ -188,14 +188,16 @@ func TestCellsBatchClear_Guards(t *testing.T) {
// TestDropdownUpdate_BatchPayload verifies the multi-range dropdown
// update fans out into a single batch_update with one set_cell_range
// op per range.
// op per range. Also covers --colors / --highlight -> highlight_colors
// / enable_highlight propagation through dropdownBatchInput.
func TestDropdownUpdate_BatchPayload(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
"--options", `["a","b","c"]`,
"--multiple",
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
"--multiple", "--highlight",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
@@ -215,8 +217,19 @@ func TestDropdownUpdate_BatchPayload(t *testing.T) {
if dv == nil || dv["type"] != "list" {
t.Errorf("op[%d] missing data_validation list: %#v", i, cell)
}
if dv["multiple_values"] != true {
t.Errorf("op[%d] multiple_values = %v, want true", i, dv["multiple_values"])
items, _ := dv["items"].([]interface{})
if len(items) != 3 {
t.Errorf("op[%d] data_validation.items length = %d, want 3", i, len(items))
}
if dv["support_multiple_values"] != true {
t.Errorf("op[%d] support_multiple_values = %v, want true", i, dv["support_multiple_values"])
}
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 3 {
t.Errorf("op[%d] highlight_colors length = %d, want 3", i, len(colors))
}
if dv["enable_highlight"] != true {
t.Errorf("op[%d] enable_highlight = %v, want true", i, dv["enable_highlight"])
}
}
}

View File

@@ -598,6 +598,23 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str
if err != nil {
return nil, err
}
// transform_range.sort_conditions[i] requires both `column` (string)
// and `ascending` (bool); the server's own validation surfaces a
// terse "required property X is missing" with no per-item context.
// Pre-check here so the user sees which entry is malformed.
for i, raw := range keys {
item, ok := raw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--sort-keys[%d] must be an object {column, ascending}; got %T", i, raw)
}
col, _ := item["column"].(string)
if strings.TrimSpace(col) == "" {
return nil, common.FlagErrorf("--sort-keys[%d] missing required string field `column` (the column letter to sort by, e.g. \"C\")", i)
}
if _, ok := item["ascending"].(bool); !ok {
return nil, common.FlagErrorf("--sort-keys[%d] missing required bool field `ascending`", i)
}
}
input := map[string]interface{}{
"excel_id": token,
"operation": "sort",

View File

@@ -185,7 +185,7 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
{
name: "+range-sort multi-key with header",
sc: RangeSort,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]`},
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"column":"B","ascending":true},{"column":"D","ascending":false}]`},
toolName: "transform_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
@@ -194,8 +194,8 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
"range": "A1:E100",
"has_header": true,
"sort_conditions": []interface{}{
map[string]interface{}{"col": "B", "order": "asc"},
map[string]interface{}{"col": "D", "order": "desc"},
map[string]interface{}{"column": "B", "ascending": true},
map[string]interface{}{"column": "D", "ascending": false},
},
},
},
@@ -211,6 +211,41 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
}
}
// TestRangeSort_RejectsMalformedKeys verifies the pre-check that each
// --sort-keys entry has both `column` (string) and `ascending` (bool);
// previously the CLI passed any JSON through and the server bounced
// with a terse "required property X missing" that didn't name the bad
// entry.
func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
t.Parallel()
cases := []struct {
name string
keys string
want string
}{
{"missing column", `[{"ascending":true}]`, "missing required string field `column`"},
{"missing ascending", `[{"column":"B"}]`, "missing required bool field `ascending`"},
{"old vocab col/order", `[{"col":"B","order":"asc"}]`, "missing required string field `column`"},
{"non-object item", `["B"]`, "must be an object"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:E10", "--sort-keys", c.keys, "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.want) {
t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err)
}
})
}
}
func TestResize_TypeAndSizeGuards(t *testing.T) {
t.Parallel()
cases := []struct {

View File

@@ -215,8 +215,12 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
}
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
// dropdown configuration on a range. The range carries its own sheet prefix
// (e.g. "sheet1!A2:A100"), so no separate --sheet-id / --sheet-name is needed.
// dropdown configuration on a range. The CLI accepts the range in the
// sheet-prefixed form (e.g. "sheet1!A2:A100") for convenience; the
// prefix is split client-side into sheet_name + bare A1 because the
// get_cell_ranges tool wants sheet selector and ranges as separate
// fields (ranges with the "sheet!" prefix gets the empty-sheet_id
// rejection from the server).
var DropdownGet = common.Shortcut{
Service: "sheets",
Command: "+dropdown-get",
@@ -257,10 +261,15 @@ var DropdownGet = common.Shortcut{
}
func dropdownGetInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
return map[string]interface{}{
// Validate already enforced the "Sheet!range" prefix, so the
// split error path can't be reached here in practice.
sheetName, bareRange, _ := splitSheetPrefixedRange(strings.TrimSpace(runtime.Str("range")))
input := map[string]interface{}{
"excel_id": token,
"ranges": []string{strings.TrimSpace(runtime.Str("range"))},
"ranges": []string{bareRange},
"include_styles": false,
"value_render_option": "formatted_value",
}
sheetSelectorForToolInput(input, "", sheetName)
return input
}

View File

@@ -54,7 +54,8 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"ranges": []interface{}{"sheet1!A2:A100"},
"sheet_name": "sheet1",
"ranges": []interface{}{"A2:A100"},
"include_styles": false,
"value_render_option": "formatted_value",
},

View File

@@ -331,40 +331,85 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
// ─── shared dropdown helpers ──────────────────────────────────────────
// buildDropdownValidation packs --options / --colors / --multiple / --highlight
// into the data_validation block expected by set_cell_range.
// buildDropdownValidation packs --options or --source-range plus --colors /
// --multiple / --highlight into the data_validation block expected by
// set_cell_range. Field names follow the canonical
// set_cell_range.data_validation schema:
//
// --options -> {type: "list", items: <strings>}
// --source-range -> {type: "listFromRange", range: <A1+sheet prefix>}
// --multiple -> support_multiple_values (bool)
// --colors -> highlight_colors (string array, hex)
// --highlight -> enable_highlight (bool)
//
// --options and --source-range are XOR (caller must pass exactly one).
// --colors length may be shorter than the source size (options length or
// source-range cell count) — server cycles remaining slots through a
// built-in 10-color palette — but must not exceed it.
func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
options, err := requireJSONArray(runtime, "options")
sourceSize, dv, err := dropdownTypeAndItems(runtime)
if err != nil {
return nil, err
}
dv := map[string]interface{}{
"type": "list",
"values": options,
}
if runtime.Str("colors") != "" {
colors, err := requireJSONArray(runtime, "colors")
if err != nil {
return nil, err
}
if len(colors) != len(options) {
return nil, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options))
if len(colors) > sourceSize {
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
dv["colors"] = colors
dv["highlight_colors"] = colors
}
if runtime.Bool("multiple") {
dv["multiple_values"] = true
dv["support_multiple_values"] = true
}
if runtime.Bool("highlight") {
dv["highlight_options"] = true
dv["enable_highlight"] = true
}
return dv, nil
}
// validateDropdownOptionsColors validates --options is a JSON array and that
// --colors (when set) has matching length. Used by +dropdown-update Validate.
func validateDropdownOptionsColors(runtime flagView) (int, error) {
options, err := requireJSONArray(runtime, "options")
// dropdownTypeAndItems resolves the XOR between --options and --source-range
// and returns (sourceSize, partial dv with type+items|range set). sourceSize
// is the option count for `list` mode or the source-range cell count for
// `listFromRange` mode — used to validate --colors length.
func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) {
optsRaw := runtime.Str("options")
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
switch {
case optsRaw != "" && sourceRange != "":
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
case optsRaw == "" && sourceRange == "":
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
case optsRaw != "":
options, err := requireJSONArray(runtime, "options")
if err != nil {
return 0, nil, err
}
return len(options), map[string]interface{}{
"type": "list",
"items": options,
}, nil
default: // sourceRange != ""
rows, cols, err := rangeDimensions(sourceRange)
if err != nil {
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
}
return rows * cols, map[string]interface{}{
"type": "listFromRange",
"range": sourceRange,
}, nil
}
}
// validateDropdownSourceOrOptions runs the XOR + --colors length check at
// Validate time so +dropdown-update / +dropdown-delete can fail fast without
// reaching the body-build step. Returns the dropdown source size (options
// length for list mode, source-range cell count for listFromRange) so
// callers can size their cells matrix.
func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
sourceSize, _, err := dropdownTypeAndItems(runtime)
if err != nil {
return 0, err
}
@@ -373,11 +418,11 @@ func validateDropdownOptionsColors(runtime flagView) (int, error) {
if err != nil {
return 0, err
}
if len(colors) != len(options) {
return 0, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options))
if len(colors) > sourceSize {
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
}
return len(options), nil
return sourceSize, nil
}
// ─── range parsing helpers ────────────────────────────────────────────

View File

@@ -95,7 +95,7 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) {
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b"]`,
"--multiple", "--highlight",
"--multiple",
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
@@ -118,11 +118,15 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) {
// 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{})
@@ -143,9 +147,164 @@ func TestDropdownSet_CellsShape(t *testing.T) {
if dv["type"] != "list" {
t.Errorf("row %d data_validation.type = %v, want list", i, dv["type"])
}
if dv["multiple_values"] != true {
t.Errorf("row %d data_validation.multiple_values = %v, want true", i, dv["multiple_values"])
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_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()
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b"]`,
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
"--dry-run",
})
if err == nil {
t.Fatal("expected --colors length error, got nil")
}
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
t.Errorf("error message missing length-overflow hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// 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()
_, stderr, 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",
})
if err == nil {
t.Fatal("expected --colors length error, got nil")
}
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
t.Errorf("error message missing source-size hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestDropdownSet_XorBothSet rejects passing both --options and
// --source-range.
func TestDropdownSet_XorBothSet(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--options", `["a","b"]`,
"--source-range", "Sheet1!T1:T3",
"--dry-run",
})
if err == nil {
t.Fatal("expected XOR error, got nil")
}
if !strings.Contains(stderr, "mutually exclusive") && !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("error message missing XOR hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestDropdownSet_XorNeitherSet rejects passing neither --options nor
// --source-range.
func TestDropdownSet_XorNeitherSet(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--dry-run",
})
if err == nil {
t.Fatal("expected required-one error, got nil")
}
if !strings.Contains(stderr, "one of --options") && !strings.Contains(err.Error(), "one of --options") {
t.Errorf("error message missing required-one hint:\nerr=%v\nstderr=%s", err, stderr)
}
}

View File

@@ -22,6 +22,8 @@
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。
## Shortcuts
| Shortcut | Risk | 分组 |
@@ -69,10 +71,11 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 |
| `--options` | string + File + Stdin复合 JSON | required | 选项 JSON 数组(如 `["opt1","opt2"]` |
| `--colors` | string + File + Stdin简单 JSON | optional | 颜色数组(与 `--options` 等长) |
| `--options` | string + File + Stdin复合 JSON | xor | 选项 JSON 数组(如 `["opt1","opt2"]` |
| `--colors` | string + File + Stdin简单 JSON | optional | 下拉选项的胶囊背景色RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项按内置色板补色)但**不能长于**。仅当 `--highlight` 也传时才生效。 |
| `--multiple` | bool | optional | 启用多选 |
| `--highlight` | bool | optional | 选项配色 |
| `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。需配合 `--colors` 使用——不传 `--colors` 时全部选项按内置 10 色色板循环。 |
| `--source-range` | string | xor | listFromRange 模式的下拉源 rangeA1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表type=list传本 flag 走 range 引用type=listFromRange`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 |
### `+dropdown-delete`
@@ -115,7 +118,7 @@ _单元格边框配置含 top/bottom/left/right 四个方向,每个方向
### `+dropdown-update` `--options`
_列表选项_
_列表选项type='list' 时必填)_
**数组项**(类型 string
- 标量string

View File

@@ -147,9 +147,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_
_创建/更新的图表属性_
**顶层字段**
- `position` (object) — 必填 { row: number, col: string }
- `position` (object?) — 必填 { row: number, col: string }
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
- `size` (object) — 必填 { width: number, height: number }
- `size` (object?) — 必填 { width: number, height: number }
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 }
## Examples

View File

@@ -120,6 +120,59 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl
**判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。
## Dropdown 选项 + 配色(`+dropdown-set` / `+dropdown-update`
### 选项怎么来:`--options` 与 `--source-range` 二选一
| flag | 选项来源 | 适用场景 |
|---|---|---|
| `--options '["a","b","c"]'` | 写在命令里的固定列表 | 选项集是常量、不需要事后维护 |
| `--source-range 'Sheet1!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 |
两个 flag **必须传一个、且只能传一个**——同时传或都不传CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `Sheet1!T1:T3`),可以指同 sheet 也可以指其它 sheet`Refs!A1:A10`)。
### 配色:`--colors` 与 `--highlight`(与选项模式无关)
- `--highlight`:开下拉选项的胶囊背景色总开关;不传默认 `false`、无配色。
- `--colors '["#hex","#hex",...]'`:每个选项对应一种胶囊背景色;只在 `--highlight` 也传时生效。
长度规则:
- `--colors` 长度**可以短于**选项数list 模式短于 `--options` 长度listFromRange 模式短于 `--source-range` 的单元格数),未指定的选项按内置 10 色色板循环补色;
- `--colors` 长度**不能长于**选项数——CLI 端 Validate 阶段就会拦截,错误信息形如 `--colors length (4) must not exceed dropdown source size (3)`
排列组合:
- 想要纯文本下拉、无配色 → 都不传 `--highlight` / `--colors`
- 想要每个选项指定确切颜色 → 同时传 `--highlight``--colors`
- 想要所有选项都有颜色但不指定具体色 → 只传 `--highlight`,颜色按内置色板循环。
### 最小用例
**`--options` 模式**4 个选项配 3 个颜色——前 3 个指定色,第 4 个按内置色板补):
```
lark-cli sheets +dropdown-set \
--url https://... --sheet-id <id> \
--range A2:A100 \
--options '["待开始","进行中","已完成","已取消"]' \
--colors '["#bff7d9","#FFE699","#bacefd"]' \
--highlight
```
**`--source-range` 模式**(先在 Sheet1!T1:T3 维护「男/女/保密」三行,再让 B2:B21 引用它):
```
lark-cli sheets +dropdown-set \
--url https://... --sheet-id <id> \
--range B2:B21 \
--source-range 'Sheet1!T1:T3' \
--colors '["#cce8ff","#ffd6e7","#e6e6e6"]' \
--highlight
```
> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet。注意一个坑回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`**必须自己重新补上 sheet 前缀**,否则会被拒。
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
## 工具选择
本 skill 提供以下 CLI shortcut按数据来源 + 内容形态选:
@@ -198,10 +251,11 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 目标范围A1 格式,如 `A2:A100` |
| `--options` | string + File + Stdin复合 JSON | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 |
| `--colors` | string + File + Stdin简单 JSON | optional | RGB hex 颜色数组`["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 |
| `--options` | string + File + Stdin复合 JSON | xor | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 |
| `--colors` | string + File + Stdin简单 JSON | optional | 下拉选项的胶囊背景色,RGB hex 数组`["#1FB6C1","#F006C2"]`。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项 server 按内置 10 色色板循环补色),但**不能长于**。仅当 `--highlight` 也传时才生效;单独传本 flag 不显示高亮色。 |
| `--multiple` | bool | optional | 启用多选;默认 `false` |
| `--highlight` | bool | optional | 选项配色显示;默认 `false` |
| `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。不传或为 `false` 时所有选项无背景色;为 `true` 时按 `--colors` 顺序上色,未在 `--colors` 中提供的选项使用内置 10 色色板循环补色。 |
| `--source-range` | string | xor | listFromRange 模式的下拉源 rangeA1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表type=list传本 flag 走 range 引用type=listFromRange`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 |
### `+csv-put`
@@ -228,7 +282,7 @@ _公共四件套 · 系统:`--dry-run`_
- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top { top?: object, bottom?: object, left?: object, right?: object }
- `rich_text` (array<object>?) — 富文本内容 each: { type: enum, text: string, style?: object, link?: string, mention_token?: string, …共 17 项 }
- `multiple_values` (array<object>?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string }
- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array<string>, range?: string, operator?: enum, values?: array<oneOf>, …共 7 项 }
- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array<string>, range?: string, operator?: enum, values?: array<oneOf>, …共 9 项 }
### `+cells-set-style` `--border-styles`
@@ -242,7 +296,7 @@ _单元格边框配置含 top/bottom/left/right 四个方向,每个方向
### `+dropdown-set` `--options`
_列表选项_
_列表选项type='list' 时必填)_
**数组项**(类型 string
- 标量string