--ranges validated the position of "!" in the raw string and then returned the trimmed halves, so " !A1:B2" was accepted and produced a set_cell_range / clear_range operation carrying sheetId: "". Depending on how the server treats an empty sheetId, the whole batch_update fails, or — worse — the operation lands on the default worksheet instead of the one the user named, while the command reports success. Both halves must now be non-empty *after* trimming. batch-clear grew the same hole independently (it duplicated the split inline); it now shares splitSheetPrefixedRange, so the invariant holds by construction rather than by being repeated correctly in two places. batch-set-style --batch had the same gap at the JSON level: it only rejected sheetId == "", so " " passed. It now judges the trimmed value but still sends the raw one — sheetId may be a worksheet *name*, and names may legitimately carry leading or trailing spaces, so trimming on the user's behalf would target a different sheet. The --ranges form cannot express such a name anyway, which is what --batch is for. TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall covers all three entry points with calls == 0; TestBatchStyleSheetIDIsSentVerbatimNotTrimmed pins the no-normalisation half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
12 KiB
Go
377 lines
12 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 (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/spf13/cobra"
|
||
"github.com/spf13/pflag"
|
||
)
|
||
|
||
// 跨 range 累计单元格预算(对齐飞书 checkBatchStampBudget)。
|
||
//
|
||
// 每个 "S!A1:Z1000" 是 26×1000=26000 格:单独看都在 buildStyleCells 的
|
||
// 单区域上限(rows≤1000、rows×cols≤30000)之内,所以只有累计预算能拦住它们。
|
||
// 7 个 = 182000 通过,8 个 = 208000 越过 200000 上限。
|
||
const budgetTestRange = "A1:Z1000"
|
||
|
||
func budgetTestRanges(n int) []string {
|
||
out := make([]string, 0, n)
|
||
for i := 0; i < n; i++ {
|
||
out = append(out, fmt.Sprintf("Sheet%d!%s", i+1, budgetTestRange))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func newBatchStyleCmdWithStyle(t *testing.T) *cobra.Command {
|
||
t.Helper()
|
||
cmd := newRangeBatchSetStyleCmd()
|
||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||
t.Fatalf("set --bg-color: %v", err)
|
||
}
|
||
return cmd
|
||
}
|
||
|
||
func TestBatchStyleRangesRespectAggregateCellBudget(t *testing.T) {
|
||
// 7 个区域刚好在预算内:必须成功,且每个区域各产出一个 op。
|
||
within, err := json.Marshal(budgetTestRanges(7))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ops, err := buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(within))
|
||
if err != nil {
|
||
t.Fatalf("7 个区域(累计 182000 格)应在预算内: %v", err)
|
||
}
|
||
if len(ops) != 7 {
|
||
t.Fatalf("operations = %d, want 7", len(ops))
|
||
}
|
||
|
||
// 第 8 个把累计推到 208000,越过 200000 上限。
|
||
over, err := json.Marshal(budgetTestRanges(8))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, err = buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(over))
|
||
if err == nil {
|
||
t.Fatal("8 个区域(累计 208000 格)必须被累计预算拒绝")
|
||
}
|
||
for _, want := range []string{"--ranges[7]", "208000", "200000"} {
|
||
if !strings.Contains(err.Error(), want) {
|
||
t.Fatalf("error = %v, want contains %q", err, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBatchStyleFileRespectsAggregateCellBudgetAndItemCap(t *testing.T) {
|
||
writeBatch := func(t *testing.T, items []map[string]any) string {
|
||
t.Helper()
|
||
data, err := json.Marshal(items)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
path := filepath.Join(t.TempDir(), "styles.json")
|
||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||
t.Fatalf("write batch fixture: %v", err)
|
||
}
|
||
return path
|
||
}
|
||
item := func(i int) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": fmt.Sprintf("Sheet%d", i+1),
|
||
"range": budgetTestRange,
|
||
"fontWeight": "bold",
|
||
}
|
||
}
|
||
|
||
// 累计预算:8 条 × 26000 = 208000 > 200000。
|
||
overBudget := make([]map[string]any, 0, 8)
|
||
for i := 0; i < 8; i++ {
|
||
overBudget = append(overBudget, item(i))
|
||
}
|
||
_, err := buildBatchStyleOpsFromFile(writeBatch(t, overBudget))
|
||
if err == nil {
|
||
t.Fatal("--batch 累计 208000 格必须被拒绝")
|
||
}
|
||
for _, want := range []string{"第 8/8 条", "208000", "200000"} {
|
||
if !strings.Contains(err.Error(), want) {
|
||
t.Fatalf("error = %v, want contains %q", err, want)
|
||
}
|
||
}
|
||
|
||
// 条数上限与 --ranges 对齐:101 条应在展开任何矩阵之前就被拒。
|
||
tooMany := make([]map[string]any, 0, maxBatchStyleRanges+1)
|
||
for i := 0; i < maxBatchStyleRanges+1; i++ {
|
||
tooMany = append(tooMany, map[string]any{
|
||
"sheetId": "Sheet1", "range": "A1", "fontWeight": "bold",
|
||
})
|
||
}
|
||
_, err = buildBatchStyleOpsFromFile(writeBatch(t, tooMany))
|
||
if err == nil || !strings.Contains(err.Error(), "--batch 最多 100 条") {
|
||
t.Fatalf("error = %v, want --batch item cap", err)
|
||
}
|
||
|
||
// 预算内的正常配置仍然照常展开。
|
||
ops, err := buildBatchStyleOpsFromFile(writeBatch(t, []map[string]any{item(0), item(1)}))
|
||
if err != nil {
|
||
t.Fatalf("2 条应在预算内: %v", err)
|
||
}
|
||
if len(ops) != 2 {
|
||
t.Fatalf("operations = %d, want 2", len(ops))
|
||
}
|
||
}
|
||
|
||
func TestAddBatchStyleCellsAccumulatesAcrossCalls(t *testing.T) {
|
||
var total int64
|
||
if err := addBatchStyleCells(&total, 1000, 26); err != nil {
|
||
t.Fatalf("first range: %v", err)
|
||
}
|
||
if total != 26000 {
|
||
t.Fatalf("total = %d, want 26000", total)
|
||
}
|
||
// 单次就越界也必须拦住(累计器同时覆盖单区域巨量输入)。
|
||
var single int64
|
||
if err := addBatchStyleCells(&single, 1000, 1000); err == nil {
|
||
t.Fatal("1000x1000=1000000 必须超出累计预算")
|
||
}
|
||
}
|
||
|
||
// styleFlagNames 是手工维护的,必须与 bindStyleFlags 实际绑定的 flag 严格一致:
|
||
// 漏登记会让 --batch 的互斥检查放过一个静默失效的 flag。
|
||
func TestStyleFlagNamesMatchBoundFlags(t *testing.T) {
|
||
probe := &cobra.Command{Use: "probe"}
|
||
bindStyleFlags(probe)
|
||
|
||
bound := map[string]bool{}
|
||
probe.Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true })
|
||
|
||
listed := map[string]bool{}
|
||
for _, name := range styleFlagNames {
|
||
if listed[name] {
|
||
t.Fatalf("styleFlagNames 重复登记 %q", name)
|
||
}
|
||
listed[name] = true
|
||
if !bound[name] {
|
||
t.Errorf("styleFlagNames 登记了 %q,但 bindStyleFlags 没有绑定它", name)
|
||
}
|
||
}
|
||
for name := range bound {
|
||
if !listed[name] {
|
||
t.Errorf("bindStyleFlags 绑定了 %q,但 styleFlagNames 漏登记(--batch 互斥检查会放过它)", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBatchSetStyleRejectsStyleFlagsInBatchMode(t *testing.T) {
|
||
previousDeps := deps
|
||
t.Cleanup(func() { deps = previousDeps })
|
||
|
||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
|
||
t.Fatalf("write batch fixture: %v", err)
|
||
}
|
||
|
||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||
InitDeps(caller)
|
||
|
||
cmd := newRangeBatchSetStyleCmd()
|
||
cmd.SetOut(io.Discard)
|
||
cmd.SetErr(io.Discard)
|
||
cmd.SilenceErrors = true
|
||
cmd.SilenceUsage = true
|
||
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath, "--bg-color", "#FFF2CC", "--font-family", "Arial"})
|
||
err := cmd.Execute()
|
||
if err == nil {
|
||
t.Fatal("--batch 搭配命令行样式 flag 必须报错,而不是静默忽略")
|
||
}
|
||
for _, want := range []string{"--batch 模式下样式来自配置文件", "--bg-color", "--font-family"} {
|
||
if !strings.Contains(err.Error(), want) {
|
||
t.Fatalf("error = %v, want contains %q", err, want)
|
||
}
|
||
}
|
||
if caller.calls != 0 {
|
||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||
}
|
||
}
|
||
|
||
// buildBatchStyleOpsFromRanges 的每条前置校验都必须在组装 operations 之前拦住。
|
||
func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) {
|
||
tooMany := make([]string, 0, maxBatchStyleRanges+1)
|
||
for i := 0; i <= maxBatchStyleRanges; i++ {
|
||
tooMany = append(tooMany, `"Sheet1!A1"`)
|
||
}
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
ranges string
|
||
style bool
|
||
want string
|
||
}{
|
||
{name: "bad-json", ranges: `[`, style: true, want: "--ranges JSON 解析失败"},
|
||
{name: "empty-array", ranges: `[]`, style: true, want: "--ranges 不能为空数组"},
|
||
{
|
||
name: "over-range-cap",
|
||
ranges: "[" + strings.Join(tooMany, ",") + "]",
|
||
style: true,
|
||
want: "--ranges 最多 100 项",
|
||
},
|
||
{
|
||
name: "missing-sheet-prefix",
|
||
ranges: `["A1:B2"]`,
|
||
style: true,
|
||
want: "必须包含工作表前缀",
|
||
},
|
||
{
|
||
name: "blank-sheet-prefix",
|
||
ranges: `[" !A1:B2"]`,
|
||
style: true,
|
||
want: "必须包含工作表前缀",
|
||
},
|
||
{
|
||
name: "blank-range-after-prefix",
|
||
ranges: `["Sheet1! "]`,
|
||
style: true,
|
||
want: "必须包含工作表前缀",
|
||
},
|
||
{
|
||
name: "bad-range-address",
|
||
ranges: `["Sheet1!zz"]`,
|
||
style: true,
|
||
want: "解析失败",
|
||
},
|
||
{
|
||
name: "no-style-flag",
|
||
ranges: `["Sheet1!A1:B2"]`,
|
||
style: false,
|
||
want: "至少需要指定一个样式参数",
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
cmd := newRangeBatchSetStyleCmd()
|
||
if tc.style {
|
||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||
t.Fatalf("set --bg-color: %v", err)
|
||
}
|
||
}
|
||
_, err := buildBatchStyleOpsFromRanges(cmd, tc.ranges)
|
||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。
|
||
//
|
||
// 只按原始串里 ! 的位置判断," !A1:B2" 会拆出空工作表名,操作却照样带着
|
||
// sheetId:"" 提交:服务端要么让整批 batch_update 失败,要么落到默认工作表而不是
|
||
// 用户指定的那张表(后者更糟,因为命令报成功)。--batch 的纯空白 sheetId 同理,
|
||
// 它此前只挡 == ""。三条路径都必须在发请求之前失败。
|
||
func TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall(t *testing.T) {
|
||
previousDeps := deps
|
||
t.Cleanup(func() { deps = previousDeps })
|
||
|
||
batchDir := t.TempDir()
|
||
writeBatch := func(t *testing.T, name, body string) string {
|
||
t.Helper()
|
||
p := filepath.Join(batchDir, name)
|
||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||
t.Fatalf("write batch fixture: %v", err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
cmd func() *cobra.Command
|
||
args func(t *testing.T) []string
|
||
want string
|
||
}{
|
||
{
|
||
name: "set-style-ranges-blank-sheet",
|
||
cmd: newRangeBatchSetStyleCmd,
|
||
args: func(*testing.T) []string {
|
||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`, "--bg-color", "#FFF2CC"}
|
||
},
|
||
want: "必须包含工作表前缀",
|
||
},
|
||
{
|
||
name: "set-style-batch-blank-sheet-id",
|
||
cmd: newRangeBatchSetStyleCmd,
|
||
args: func(t *testing.T) []string {
|
||
p := writeBatch(t, "blank-sheet.json", `[{"sheetId":" ","range":"A1:B2","fontWeight":"bold"}]`)
|
||
return []string{"--node", "NODE_ID", "--batch", p}
|
||
},
|
||
want: "缺少 sheetId 或 range",
|
||
},
|
||
{
|
||
name: "set-style-batch-blank-range",
|
||
cmd: newRangeBatchSetStyleCmd,
|
||
args: func(t *testing.T) []string {
|
||
p := writeBatch(t, "blank-range.json", `[{"sheetId":"Sheet1","range":" ","fontWeight":"bold"}]`)
|
||
return []string{"--node", "NODE_ID", "--batch", p}
|
||
},
|
||
want: "缺少 sheetId 或 range",
|
||
},
|
||
{
|
||
name: "batch-clear-ranges-blank-sheet",
|
||
cmd: rangeBatchClearCoverageCommand,
|
||
args: func(*testing.T) []string {
|
||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`}
|
||
},
|
||
want: "必须包含工作表前缀",
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||
InitDeps(caller)
|
||
deps.Out.w = io.Discard
|
||
deps.Out.errW = io.Discard
|
||
|
||
cmd := tc.cmd()
|
||
cmd.SetArgs(tc.args(t))
|
||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||
err := cmd.Execute()
|
||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||
}
|
||
if caller.calls != 0 {
|
||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 判空看修剪后的值,但下发仍用原值:sheetId 可以是工作表**名**,名字允许带首尾
|
||
// 空格,替用户修剪会指向另一张表。--batch 是需要精确名字时的入口,必须原样透传。
|
||
func TestBatchStyleSheetIDIsSentVerbatimNotTrimmed(t *testing.T) {
|
||
p := filepath.Join(t.TempDir(), "padded.json")
|
||
if err := os.WriteFile(p, []byte(`[{"sheetId":" Sheet1 ","range":" A1:B2 ","fontWeight":"bold"}]`), 0o600); err != nil {
|
||
t.Fatalf("write batch fixture: %v", err)
|
||
}
|
||
|
||
ops, err := buildBatchStyleOpsFromFile(p)
|
||
if err != nil {
|
||
t.Fatalf("buildBatchStyleOpsFromFile = %v, want accepted", err)
|
||
}
|
||
if len(ops) != 1 {
|
||
t.Fatalf("operations = %d, want 1", len(ops))
|
||
}
|
||
op, _ := ops[0].(map[string]any)
|
||
input, _ := op["input"].(map[string]any)
|
||
if input["sheetId"] != " Sheet1 " {
|
||
t.Fatalf("sheetId = %#v, want %q (verbatim)", input["sheetId"], " Sheet1 ")
|
||
}
|
||
if input["rangeAddress"] != " A1:B2 " {
|
||
t.Fatalf("rangeAddress = %#v, want %q (verbatim)", input["rangeAddress"], " A1:B2 ")
|
||
}
|
||
}
|