Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills
This commit is contained in:
commit
30202e2b81
29 files changed
+1104
-162
No files matched your search
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
+63
-22
@@ -529,12 +529,22 @@ jobs:
|
||||
# shards at full-suite scope; release-scripts is included because its
|
||||
# dedicated job only runs at full-suite or release-sensitive scope, and
|
||||
# dropping it here would stop testing test/scripts changes entirely.
|
||||
# internal/app is carried by one shard per bounded partition rather than a
|
||||
# single app shard: the partitions used to run end to end inside one job,
|
||||
# where the Schema partition alone owned most of the wall clock. The
|
||||
# app-<partition> names are pinned to the helper's partition set by
|
||||
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
|
||||
# its job silently.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
@@ -576,9 +586,16 @@ jobs:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the impacted-package query uses the base shard name and
|
||||
# the partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
|
||||
@@ -618,14 +635,19 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$TEST_SHARD" = "release-scripts" ]; then
|
||||
# Mirror the dedicated release-contract job: these suites shell out
|
||||
# to archive tooling and are not race-instrumented there.
|
||||
@@ -646,14 +668,21 @@ jobs:
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# app runs several independently bounded processes; cli/smoke need headroom
|
||||
# beyond go test -timeout for setup + assembly.
|
||||
# internal/app is split across one shard per bounded partition so the
|
||||
# partitions run concurrently and each releases its framework registries
|
||||
# when the process exits; cli/smoke need headroom beyond go test -timeout for
|
||||
# setup + assembly. The app-<partition> names are pinned to the helper's
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
@@ -679,18 +708,30 @@ jobs:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the package query uses the base shard name and the
|
||||
# partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$package_shard")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
|
||||
@@ -6,6 +6,37 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.2] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
|
||||
|
||||
## [1.0.59-beta.1] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59-beta.1"
|
||||
version "1.0.59-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
|
||||
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
|
||||
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -60,6 +60,11 @@ func translateBatchOp(op map[string]any) (map[string]any, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported toolName %q: must be a CLI command name (e.g. \"range clear\", \"range update\", \"merge-cells\"). Run 'dws sheet batch-update --help' for the full list", toolName)
|
||||
}
|
||||
if toolName == "set-dropdown" {
|
||||
if err := validateBatchSetDropdownInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"toolName": mapping.mcpTool,
|
||||
@@ -67,6 +72,33 @@ func translateBatchOp(op map[string]any) (map[string]any, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateBatchSetDropdownInput(input map[string]any) error {
|
||||
if _, exists := input["source-colors"]; exists {
|
||||
return fmt.Errorf("set-dropdown: 顶层 source-colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持")
|
||||
}
|
||||
if _, exists := input["colors"]; exists {
|
||||
return fmt.Errorf("set-dropdown: 顶层 colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持")
|
||||
}
|
||||
|
||||
options, hasOptions := input["options"]
|
||||
hasOptions = hasOptions && options != nil
|
||||
sourceRange := batchStr(input, "source-range")
|
||||
sourceSheetID := batchStr(input, "source-sheet-id")
|
||||
hasSourceRange := sourceRange != ""
|
||||
if hasOptions == hasSourceRange {
|
||||
return fmt.Errorf("set-dropdown: options 与 source-range 必须且只能指定一个")
|
||||
}
|
||||
if hasSourceRange != (sourceSheetID != "") {
|
||||
return fmt.Errorf("set-dropdown: source-range 与 source-sheet-id 必须同时指定")
|
||||
}
|
||||
if hasSourceRange {
|
||||
if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil {
|
||||
return fmt.Errorf("set-dropdown: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── BuildXxxArgs: CLI flag → MCP param 转换函数 ──────────────────────────────────
|
||||
// 每个函数接收 CLI flag 名(kebab-case)的 map,输出 MCP 参数名(camelCase)的 map。
|
||||
// 目前集中放在此文件;后续拆分命令文件时可移到各命令所在文件。
|
||||
@@ -215,7 +247,15 @@ func BuildSetDropdownArgs(input map[string]any) map[string]any {
|
||||
args := map[string]any{
|
||||
"sheetId": batchStr(input, "sheet-id"),
|
||||
"range": batchStr(input, "range"),
|
||||
"options": input["options"],
|
||||
}
|
||||
if options, ok := input["options"]; ok && options != nil {
|
||||
args["options"] = options
|
||||
}
|
||||
if sourceRange := batchStr(input, "source-range"); sourceRange != "" {
|
||||
args["sourceRange"] = map[string]any{
|
||||
"sheetId": batchStr(input, "source-sheet-id"),
|
||||
"a1Notation": sourceRange,
|
||||
}
|
||||
}
|
||||
if v, ok := input["multi-select"]; ok {
|
||||
args["enableMultiSelect"] = v
|
||||
@@ -417,6 +457,8 @@ func newBatchUpdateCmd() *cobra.Command {
|
||||
|
||||
toolName 使用 CLI 命令名(与原子命令一致),input 的键用 CLI flag 名去掉 --。
|
||||
CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
|
||||
source-range 的语义按 toolName 隔离:set-dropdown 中表示下拉候选项来源,
|
||||
range fill/copy-to/move-to 中表示待填充、复制或移动的数据源区域。
|
||||
|
||||
支持的 CLI 命令名:
|
||||
range clear / range update / merge-cells / unmerge-cells / update-dimension
|
||||
@@ -425,6 +467,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
|
||||
set-dropdown / delete-dropdown / csv-put / delete-float-image
|
||||
其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析,
|
||||
前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。
|
||||
set-dropdown 不接受顶层 colors/source-colors;Inline 颜色应写在 options[].color,
|
||||
SourceRange 颜色写入暂不支持。
|
||||
|
||||
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
|
||||
dws sheet group-dimension 命令。
|
||||
|
||||
@@ -23,7 +23,15 @@ func TestCrossPlatformCoverageSheetBatchOperationTranslationCoversEveryMapping(t
|
||||
"group-state": "fold",
|
||||
}
|
||||
for name, mapping := range batchOpDispatch {
|
||||
got, err := translateBatchOp(map[string]any{"toolName": name, "input": input})
|
||||
opInput := input
|
||||
if name == "set-dropdown" {
|
||||
opInput = make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
opInput[key] = value
|
||||
}
|
||||
delete(opInput, "source-range")
|
||||
}
|
||||
got, err := translateBatchOp(map[string]any{"toolName": name, "input": opInput})
|
||||
if err != nil {
|
||||
t.Errorf("translateBatchOp(%q): %v", name, err)
|
||||
continue
|
||||
|
||||
@@ -388,7 +388,10 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
csv 带 [row=N] 行号前缀的 CSV 文本
|
||||
colIndices 列字母映射数组(定位列用 colIndices[j],禁止手数逗号)
|
||||
rowIndices 行号映射数组
|
||||
hasMore 是否因 maxChars 截断
|
||||
hasMore 目标范围是否还有未返回数据
|
||||
truncationReasons 部分返回原因:max_cells / max_chars
|
||||
resolvedRange 未传 --range 时底层解析出的完整目标范围
|
||||
returnedRange 本次实际完整返回的范围
|
||||
|
||||
取值模式(--value-render-option):
|
||||
formatted_value 格式化后的展示值(默认),如 ¥1,000.00、2025-06-01
|
||||
@@ -398,9 +401,14 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
与 range read 的区别:
|
||||
- CSV 格式 token 消耗约为 JSON 的 1/3
|
||||
- 支持选择取值模式
|
||||
- 自动防爆(max-chars 截断 + has_more 标志)
|
||||
- 自动防爆(30,000 单元格 / max-chars 上限 + hasMore 标志)
|
||||
- [row=N] 前缀防止行号计算错误
|
||||
|
||||
当 hasMore=true 时本命令不会自动续读;请结合目标范围和
|
||||
returnedRange 从下一行显式传 --range。max_cells 不能通过增大
|
||||
--max-chars 解决。收到 forbidden.document.sizeOverLimit 表示工作簿整体
|
||||
无法装载,应创建更小副本或拆分工作簿,缩小 --range 不能解决。
|
||||
|
||||
注意:csv-get 不返回合并单元格结构。查看合并范围请使用
|
||||
dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。`,
|
||||
Example: ` dws sheet csv-get --node NODE_ID
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -18,6 +19,23 @@ func sizeTypeEnumHint(dimension string) string {
|
||||
return "pixel / standard"
|
||||
}
|
||||
|
||||
func validateDropdownSourceRangeInput(sourceSheetID, sourceRange string) error {
|
||||
if strings.TrimSpace(sourceSheetID) == "" {
|
||||
return fmt.Errorf("--source-sheet-id 不能为空")
|
||||
}
|
||||
sourceRange = strings.TrimSpace(sourceRange)
|
||||
if sourceRange == "" {
|
||||
return fmt.Errorf("--source-range 不能为空")
|
||||
}
|
||||
if strings.Contains(sourceRange, "!") {
|
||||
return fmt.Errorf("--source-range 不能包含工作表前缀;请通过 --source-sheet-id 指定来源工作表")
|
||||
}
|
||||
if strings.HasPrefix(sourceRange, "=") || strings.Contains(sourceRange, ",") {
|
||||
return fmt.Errorf("--source-range 必须是单一连续区域,不能是公式、表达式或多区域引用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension,
|
||||
// merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown).
|
||||
func newDimensionCmds() []*cobra.Command {
|
||||
@@ -775,15 +793,24 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
Short: "设置下拉列表",
|
||||
Long: `在钉钉表格的指定单元格范围内设置下拉列表。
|
||||
|
||||
设置后,用户可以在这些单元格中从预定义的选项列表中选择值。
|
||||
支持自定义每个选项的颜色和是否允许多选。
|
||||
Inline 模式通过 --options 提供静态选项,支持选项颜色。
|
||||
SourceRange 模式通过 --source-sheet-id + --source-range 引用同一工作簿内的来源区域,
|
||||
支持跨工作表、整行和整列引用;读取配置时不会展开来源区域的当前值或颜色。
|
||||
两种模式都支持 --multi-select,并且 --options 与 --source-range 必须且只能指定一个。
|
||||
如果目标范围已存在下拉列表,会被新的配置覆盖。
|
||||
|
||||
nodeId 支持传入文档链接 URL 或文档 ID(dentryUuid),系统自动识别。
|
||||
sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获取。
|
||||
|
||||
--options 为 JSON 数组,每个元素包含 value(必填)和 color(可选)。
|
||||
选项值不能包含英文逗号。`,
|
||||
选项值不能包含英文逗号。
|
||||
|
||||
--source-range 只接受不含工作表前缀的 A1 区域,例如 T1:T3、T:T 或 1:3;
|
||||
来源工作表由 --source-sheet-id 单独指定。SourceRange 颜色写入暂不支持。
|
||||
|
||||
已验证行为:工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 valid;
|
||||
已验证的 move-dimension 场景会使引用变为 invalid。列删除、删除整个来源区域或来源工作表等未覆盖场景不能预设结果;
|
||||
结构操作后应先回读 sourceRangeStatus,只有 invalid 时才重新选择来源并写入。`,
|
||||
Example: ` # 设置单选下拉列表
|
||||
dws sheet set-dropdown --node NODE_ID --sheet-id SHEET_ID --range "A2:A100" \
|
||||
--options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]'
|
||||
@@ -793,32 +820,63 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
|
||||
--multi-select
|
||||
|
||||
# 引用同一工作簿内另一工作表的区域作为候选项来源
|
||||
dws sheet set-dropdown --node NODE_ID --sheet-id TARGET_SHEET_ID --range "C2:C100" \
|
||||
--source-sheet-id SOURCE_SHEET_ID --source-range "T1:T3"
|
||||
|
||||
# 使用文档链接 URL
|
||||
dws sheet set-dropdown --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" \
|
||||
--sheet-id SHEET_ID --range "C1:C10" --options '[{"value":"是"},{"value":"否"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
optionsStr := mustGetFlag(cmd, "options")
|
||||
var options []map[string]any
|
||||
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
|
||||
return fmt.Errorf("--options JSON 解析失败: %w", err)
|
||||
sourceSheetID := mustGetFlag(cmd, "source-sheet-id")
|
||||
sourceRange := mustGetFlag(cmd, "source-range")
|
||||
hasOptions := optionsStr != ""
|
||||
hasSourceRange := sourceRange != ""
|
||||
if hasOptions == hasSourceRange {
|
||||
return fmt.Errorf("--options 与 --source-range 必须且只能指定一个")
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return fmt.Errorf("--options 至少包含 1 个选项")
|
||||
if hasSourceRange && sourceSheetID == "" {
|
||||
return fmt.Errorf("使用 --source-range 时必须同时指定 --source-sheet-id")
|
||||
}
|
||||
for i, opt := range options {
|
||||
val, ok := opt["value"].(string)
|
||||
if !ok || val == "" {
|
||||
return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i)
|
||||
}
|
||||
if strings.Contains(val, ",") {
|
||||
return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val)
|
||||
}
|
||||
if !hasSourceRange && sourceSheetID != "" {
|
||||
return fmt.Errorf("使用 --source-sheet-id 时必须同时指定 --source-range")
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"range": mustGetFlag(cmd, "range"),
|
||||
"options": options,
|
||||
}
|
||||
if hasOptions {
|
||||
var options []map[string]any
|
||||
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
|
||||
return fmt.Errorf("--options JSON 解析失败: %w", err)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return fmt.Errorf("--options 至少包含 1 个选项")
|
||||
}
|
||||
for i, opt := range options {
|
||||
val, ok := opt["value"].(string)
|
||||
if !ok || val == "" {
|
||||
return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i)
|
||||
}
|
||||
if strings.Contains(val, ",") {
|
||||
return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val)
|
||||
}
|
||||
}
|
||||
toolArgs["options"] = options
|
||||
} else {
|
||||
if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sourceRange"] = map[string]any{
|
||||
"sheetId": sourceSheetID,
|
||||
"a1Notation": sourceRange,
|
||||
}
|
||||
}
|
||||
if multiSelect, _ := cmd.Flags().GetBool("multi-select"); multiSelect {
|
||||
toolArgs["enableMultiSelect"] = true
|
||||
@@ -839,36 +897,49 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
CLIPath: "sheet set-dropdown",
|
||||
PrimaryCLIPath: "sheet set-dropdown",
|
||||
},
|
||||
Description: "为指定范围设置下拉列表(可多选、可带颜色)。",
|
||||
Description: "为指定范围设置 Inline 或 SourceRange 下拉列表(可多选;颜色仅 Inline 支持)。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_dropdown_lists"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定范围设置下拉列表(可多选、可带颜色)。",
|
||||
AgentSummary: "为指定范围设置 Inline 或 SourceRange 下拉列表;SourceRange 可跨同一工作簿内的工作表。",
|
||||
UseWhen: []string{"需要给单元格配置可选值下拉约束时"},
|
||||
AvoidWhen: []string{"查看已有下拉用 get-dropdown;移除下拉用 delete-dropdown"},
|
||||
Examples: []string{"dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'"},
|
||||
Examples: []string{
|
||||
"dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'",
|
||||
"dws sheet set-dropdown --node <NODE_ID> --sheet-id <TARGET_SHEET_ID> --range \"B2:B100\" --source-sheet-id <SOURCE_SHEET_ID> --source-range \"T1:T3\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "multi-select", Property: "enableMultiSelect"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "source-sheet-id", Property: "sourceRange.sheetId", Required: boolPtr(false), RequiredWhen: "--source-range is provided"},
|
||||
{Name: "source-range", Property: "sourceRange.a1Notation", Required: boolPtr(false), RequiredWhen: "exactly one of --options or --source-range must be provided"},
|
||||
},
|
||||
},
|
||||
})
|
||||
setDropdownCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
setDropdownCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
setDropdownCmd.Flags().String("range", "", "目标单元格范围,A1 表示法,如 A2:A100 (必填)")
|
||||
setDropdownCmd.Flags().String("options", "", `下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'`)
|
||||
setDropdownCmd.Flags().String("options", "", `Inline 下拉选项 JSON 数组,与 --source-range 二选一,如 '[{"value":"选项1","color":"#ff0000"}]'`)
|
||||
setDropdownCmd.Flags().String("source-sheet-id", "", "SourceRange 来源工作表 ID;使用 --source-range 时必填")
|
||||
setDropdownCmd.Flags().String("source-range", "", "SourceRange 来源区域,与 --options 二选一;不含工作表前缀,如 T1:T3、T:T 或 1:3")
|
||||
setDropdownCmd.Flags().Bool("multi-select", false, "是否允许多选(默认单选)")
|
||||
cli.AnnotateRuntimeFlagFormat(setDropdownCmd, "source-range", "a1-range")
|
||||
setDropdownCmd.MarkFlagsOneRequired("options", "source-range")
|
||||
setDropdownCmd.MarkFlagsMutuallyExclusive("options", "source-range")
|
||||
setDropdownCmd.MarkFlagsRequiredTogether("source-sheet-id", "source-range")
|
||||
|
||||
getDropdownCmd := &cobra.Command{
|
||||
Use: "get-dropdown",
|
||||
Short: "获取下拉列表配置",
|
||||
Long: `查询钉钉表格指定范围内的下拉列表配置。
|
||||
|
||||
返回范围内所有单元格的下拉列表配置信息,包括选项值和颜色。
|
||||
Inline 配置返回 conditionValues 和 options;SourceRange 配置返回
|
||||
sourceType/sourceRangeStatus/enableMultiSelect,且仅在 sourceRangeStatus="valid" 时返回
|
||||
sourceRange。invalid 结果仍保留配置组,但省略 sourceRange;两种状态都不展开来源区域的候选值或颜色。
|
||||
如果范围内存在多个不同的下拉列表配置,会分别返回每组配置及其覆盖的单元格列表。
|
||||
如果范围内没有设置下拉列表,返回空。
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSetDropdownSourceRangeCommand(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executeDimensionCoverage(t, "set-dropdown",
|
||||
"--node", "node-1",
|
||||
"--sheet-id", "target-sheet",
|
||||
"--range", "B2:B100",
|
||||
"--source-sheet-id", "source-sheet",
|
||||
"--source-range", "$t$1:$t$3",
|
||||
"--multi-select",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("set SourceRange dropdown: %v", err)
|
||||
}
|
||||
if caller.tool != "set_dropdown_lists" {
|
||||
t.Fatalf("tool = %q, want set_dropdown_lists", caller.tool)
|
||||
}
|
||||
if _, exists := caller.args["options"]; exists {
|
||||
t.Fatalf("SourceRange request must omit options: %#v", caller.args)
|
||||
}
|
||||
source, ok := caller.args["sourceRange"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("sourceRange = %#v", caller.args["sourceRange"])
|
||||
}
|
||||
if source["sheetId"] != "source-sheet" || source["a1Notation"] != "$t$1:$t$3" {
|
||||
t.Fatalf("sourceRange = %#v", source)
|
||||
}
|
||||
if caller.args["enableMultiSelect"] != true {
|
||||
t.Fatalf("enableMultiSelect = %#v", caller.args["enableMultiSelect"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSetDropdownInlineCommandStillOmitsSourceRange(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executeDimensionCoverage(t, "set-dropdown",
|
||||
"--node", "node-1",
|
||||
"--sheet-id", "sheet-1",
|
||||
"--range", "A1:A3",
|
||||
"--options", `[{"value":"one","color":"#ff0000"}]`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("set inline dropdown: %v", err)
|
||||
}
|
||||
if _, exists := caller.args["sourceRange"]; exists {
|
||||
t.Fatalf("inline request must omit sourceRange: %#v", caller.args)
|
||||
}
|
||||
options, ok := caller.args["options"].([]map[string]any)
|
||||
if !ok || len(options) != 1 || options[0]["value"] != "one" {
|
||||
t.Fatalf("options = %#v", caller.args["options"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSetDropdownModeConstraints(t *testing.T) {
|
||||
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "neither", want: "one of the flags"},
|
||||
{name: "both", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source", "--source-range", "A1:A3"}, want: "none of the others"},
|
||||
{name: "source sheet without range", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source"}, want: "must all be set"},
|
||||
{name: "range without source sheet", args: []string{"--source-range", "A1:A3"}, want: "must all be set"},
|
||||
{name: "sheet prefix", args: []string{"--source-sheet-id", "source", "--source-range", "Sheet2!A1:A3"}, want: "不能包含工作表前缀"},
|
||||
{name: "formula", args: []string{"--source-sheet-id", "source", "--source-range", "=A1:A3"}, want: "必须是单一连续区域"},
|
||||
{name: "multi region", args: []string{"--source-sheet-id", "source", "--source-range", "A1:A3,C1:C3"}, want: "必须是单一连续区域"},
|
||||
{name: "blank source range", args: []string{"--source-sheet-id", "source", "--source-range", " \t "}, want: "--source-range 不能为空"},
|
||||
{name: "blank source sheet", args: []string{"--source-sheet-id", " \t ", "--source-range", "A1:A3"}, want: "--source-sheet-id 不能为空"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, base...), tc.args...)
|
||||
err := executeDimensionCoverage(t, "set-dropdown", args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, validation must fail before request", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSetDropdownRunEValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options string
|
||||
source string
|
||||
rangeID string
|
||||
want string
|
||||
}{
|
||||
{name: "neither mode", want: "必须且只能指定一个"},
|
||||
{name: "source range without sheet", rangeID: "A1:A3", want: "必须同时指定 --source-sheet-id"},
|
||||
{name: "source sheet without range", options: `[{"value":"one"}]`, source: "source", want: "必须同时指定 --source-range"},
|
||||
{name: "invalid options JSON", options: "{", want: "JSON 解析失败"},
|
||||
{name: "missing option value", options: `[{}]`, want: "value 为空"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := dimensionCoverageCommand(t, "set-dropdown")
|
||||
for name, value := range map[string]string{
|
||||
"node": "node", "sheet-id": "sheet", "range": "A1",
|
||||
"options": tc.options, "source-sheet-id": tc.source, "source-range": tc.rangeID,
|
||||
} {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSetDropdownPreservesSchemaConstraints(t *testing.T) {
|
||||
cmd := dimensionCoverageCommand(t, "set-dropdown")
|
||||
if constraints := runtimeannotate.CommandConstraints(cmd); !runtimeannotate.ConstraintsEmpty(constraints) {
|
||||
t.Fatalf("constraints = %#v, want no new schema-level constraints", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBatchSetDropdownSourceRange(t *testing.T) {
|
||||
got, err := translateBatchOp(map[string]any{
|
||||
"toolName": "set-dropdown",
|
||||
"input": map[string]any{
|
||||
"sheet-id": "target-sheet",
|
||||
"range": "B2:B100",
|
||||
"source-sheet-id": "source-sheet",
|
||||
"source-range": "T:T",
|
||||
"multi-select": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("translate SourceRange batch op: %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]any)
|
||||
if _, exists := input["options"]; exists {
|
||||
t.Fatalf("SourceRange batch input must omit options: %#v", input)
|
||||
}
|
||||
source := input["sourceRange"].(map[string]any)
|
||||
if source["sheetId"] != "source-sheet" || source["a1Notation"] != "T:T" {
|
||||
t.Fatalf("sourceRange = %#v", source)
|
||||
}
|
||||
|
||||
invalid := []map[string]any{
|
||||
{},
|
||||
{"options": []any{map[string]any{"value": "one"}}, "source-sheet-id": "source", "source-range": "A1:A3"},
|
||||
{"source-range": "A1:A3"},
|
||||
{"source-sheet-id": "source", "source-range": "A1:A3", "colors": []any{"#fff"}},
|
||||
{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}},
|
||||
{"source-sheet-id": "source", "source-range": "Sheet2!A1:A3"},
|
||||
}
|
||||
for _, value := range invalid {
|
||||
if _, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": value}); err == nil {
|
||||
t.Errorf("invalid batch SourceRange input %#v returned nil", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBatchSetDropdownValidationGuidance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "inline top-level colors",
|
||||
input: map[string]any{"options": []any{map[string]any{"value": "one"}}, "colors": []any{"#fff"}},
|
||||
want: "Inline 颜色请写入 options[].color",
|
||||
},
|
||||
{
|
||||
name: "source top-level colors",
|
||||
input: map[string]any{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}},
|
||||
want: "SourceRange 颜色写入暂不支持",
|
||||
},
|
||||
{
|
||||
name: "blank source range",
|
||||
input: map[string]any{"source-sheet-id": "source", "source-range": " \t "},
|
||||
want: "--source-range 不能为空",
|
||||
},
|
||||
{
|
||||
name: "blank source sheet",
|
||||
input: map[string]any{"source-sheet-id": " \t ", "source-range": "A1:A3"},
|
||||
want: "--source-sheet-id 不能为空",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": tc.input})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDropdownHelpDescribesDynamicAndInvalidContracts(t *testing.T) {
|
||||
setDropdown := dimensionCoverageCommand(t, "set-dropdown")
|
||||
if !strings.Contains(setDropdown.Long, "会自动调整引用并保持 valid") ||
|
||||
!strings.Contains(setDropdown.Long, "只有 invalid 时才重新选择来源并写入") {
|
||||
t.Fatalf("set-dropdown help does not describe verified structural behavior:\n%s", setDropdown.Long)
|
||||
}
|
||||
getDropdown := dimensionCoverageCommand(t, "get-dropdown")
|
||||
if !strings.Contains(getDropdown.Long, `仅在 sourceRangeStatus="valid" 时返回`) ||
|
||||
!strings.Contains(getDropdown.Long, "invalid 结果仍保留配置组,但省略 sourceRange") {
|
||||
t.Fatalf("get-dropdown help does not describe conditional sourceRange readback:\n%s", getDropdown.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateSourceRangeDataValidation(t *testing.T) {
|
||||
valid := []any{
|
||||
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}},
|
||||
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "missing-sheet", "a1Notation": "not-validated-locally"}, "enableMultiSelect": true},
|
||||
}
|
||||
for _, value := range valid {
|
||||
if err := validateDataValidation(value, "dv"); err != nil {
|
||||
t.Errorf("valid SourceRange data validation %#v: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []any{
|
||||
map[string]any{"type": "dropdown", "options": []any{map[string]any{"value": "one"}}, "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}},
|
||||
map[string]any{"type": "dropdown", "sourceRange": "T1:T3"},
|
||||
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"a1Notation": "T1:T3"}},
|
||||
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source"}},
|
||||
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3", "colors": []any{"#fff"}}},
|
||||
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}, "enableMultiSelect": "yes"},
|
||||
}
|
||||
for _, value := range invalid {
|
||||
if err := validateDataValidation(value, "dv"); err == nil {
|
||||
t.Errorf("invalid SourceRange data validation %#v returned nil", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,16 @@ func newRangeCmd() *cobra.Command {
|
||||
value 单元格值(内容由 --value-render-option 决定)
|
||||
dataValidation 数据验证配置(下拉列表/复选框),无则为 null
|
||||
hyperlink 单元格级超链接(path/sheet/range),无则省略
|
||||
顶层还会返回 rowIndices / colIndices 与完成度字段:
|
||||
hasMore 目标范围是否还有未返回数据
|
||||
truncationReasons 部分返回原因(max_cells)
|
||||
resolvedRange 未传 --range 时底层解析出的完整目标范围
|
||||
returnedRange 本次实际完整返回的范围
|
||||
|
||||
单次最多返回 30,000 个单元格。hasMore=true 是部分成功,本命令
|
||||
不会自动续读;请结合目标范围和 returnedRange 从下一行显式传
|
||||
--range。收到 forbidden.document.sizeOverLimit 则表示工作簿整体无法装载,
|
||||
应创建更小副本或拆分工作簿,缩小 --range 不能解决。
|
||||
|
||||
注意:range read/get 不返回合并单元格结构。查看合并范围请使用
|
||||
dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。
|
||||
@@ -157,7 +167,12 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
2) {"dataValidation":{"type":"none"}} → 显式清除该单元格 DV
|
||||
3) {"dataValidation":{"type":"dropdown",...}} → 写新 dropdown(覆盖)
|
||||
{"dataValidation":{"type":"checkbox",...}} → 写新 checkbox(覆盖)
|
||||
dropdown: {"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}}
|
||||
Inline dropdown:
|
||||
{"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}}
|
||||
SourceRange dropdown(同一工作簿内可跨工作表;不展开来源 values,不支持 colors):
|
||||
{"dataValidation":{"type":"dropdown","sourceRange":{"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"},"enableMultiSelect":false}}
|
||||
dropdown 的 options 与 sourceRange 必须且只能传一个。SourceRange 支持普通区域、整行和整列;
|
||||
a1Notation 不带工作表前缀,来源工作表单独写在 sheetId。
|
||||
checkbox: {"dataValidation":{"type":"checkbox","checked":true}}
|
||||
可与 text/richText 共存,也可单独使用(如 {dataValidation:{type:"none"}} 仅清除 DV 不写值)
|
||||
|
||||
@@ -169,6 +184,8 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
- 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles
|
||||
- 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并
|
||||
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式
|
||||
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;move-dimension 及其他未覆盖的删除/移动场景后先回读 sourceRangeStatus,仅 invalid 时重新选源写入
|
||||
- 同一 cell 的 value/style 已写入但 SourceRange 校验失败时,服务端可返回 success=true,并通过 message 明确下拉未创建;必须检查 message,必要时重新读取确认
|
||||
- 清空整片区域请用 dws sheet range clear`,
|
||||
Example: ` # 写入文本
|
||||
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -239,22 +239,48 @@ func validateDataValidation(dvRaw any, path string) error {
|
||||
}
|
||||
switch dvType {
|
||||
case "dropdown":
|
||||
optionsRaw, ok := dv["options"]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: type=dropdown 必须包含 options 数组", path)
|
||||
optionsRaw, hasOptions := dv["options"]
|
||||
sourceRangeRaw, hasSourceRange := dv["sourceRange"]
|
||||
hasOptions = hasOptions && optionsRaw != nil
|
||||
hasSourceRange = hasSourceRange && sourceRangeRaw != nil
|
||||
if hasOptions == hasSourceRange {
|
||||
return fmt.Errorf("%s: type=dropdown 的 options 与 sourceRange 必须且只能包含一个", path)
|
||||
}
|
||||
options, ok := optionsRaw.([]any)
|
||||
if !ok || len(options) == 0 {
|
||||
return fmt.Errorf("%s.options 必须为非空数组", path)
|
||||
}
|
||||
for i, opt := range options {
|
||||
optMap, ok := opt.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i)
|
||||
if hasOptions {
|
||||
options, ok := optionsRaw.([]any)
|
||||
if !ok || len(options) == 0 {
|
||||
return fmt.Errorf("%s.options 必须为非空数组", path)
|
||||
}
|
||||
val, ok := optMap["value"].(string)
|
||||
if !ok || val == "" {
|
||||
return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i)
|
||||
for i, opt := range options {
|
||||
optMap, ok := opt.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i)
|
||||
}
|
||||
val, ok := optMap["value"].(string)
|
||||
if !ok || val == "" {
|
||||
return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sourceRange, ok := sourceRangeRaw.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.sourceRange 必须为 object", path)
|
||||
}
|
||||
if _, exists := sourceRange["colors"]; exists {
|
||||
return fmt.Errorf("%s.sourceRange.colors 暂不支持", path)
|
||||
}
|
||||
sheetID, ok := sourceRange["sheetId"].(string)
|
||||
if !ok || strings.TrimSpace(sheetID) == "" {
|
||||
return fmt.Errorf("%s.sourceRange.sheetId 必须为非空字符串", path)
|
||||
}
|
||||
a1Notation, ok := sourceRange["a1Notation"].(string)
|
||||
if !ok || strings.TrimSpace(a1Notation) == "" {
|
||||
return fmt.Errorf("%s.sourceRange.a1Notation 必须为非空字符串", path)
|
||||
}
|
||||
}
|
||||
if multi, exists := dv["enableMultiSelect"]; exists {
|
||||
if _, ok := multi.(bool); !ok {
|
||||
return fmt.Errorf("%s.enableMultiSelect 必须为 boolean", path)
|
||||
}
|
||||
}
|
||||
case "checkbox":
|
||||
|
||||
@@ -6,16 +6,35 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
"usage: $0 verify <app-package>" \
|
||||
" $0 run <app-package>" >&2
|
||||
" $0 run <app-package> [partition]" \
|
||||
" $0 list-partitions" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ "$#" -eq 2 ] || usage
|
||||
mode="$1"
|
||||
app_package="$2"
|
||||
# Single source of truth for the partition set. CI runs one job per partition and
|
||||
# pins its shard names to this list, so a name that appears here without a
|
||||
# dispatch entry below fails closed rather than silently skipping tests.
|
||||
APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz'
|
||||
|
||||
mode="${1:-}"
|
||||
partition=""
|
||||
case "$mode" in
|
||||
verify|run) ;;
|
||||
list-partitions)
|
||||
[ "$#" -eq 1 ] || usage
|
||||
for name in $APP_PARTITIONS; do
|
||||
printf '%s\n' "$name"
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
verify)
|
||||
[ "$#" -eq 2 ] || usage
|
||||
app_package="$2"
|
||||
;;
|
||||
run)
|
||||
[ "$#" -eq 2 ] || [ "$#" -eq 3 ] || usage
|
||||
app_package="$2"
|
||||
partition="${3:-}"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
@@ -70,21 +89,49 @@ if [ "$unmatched_count" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for partition in \
|
||||
# The loop variable is deliberately not named "partition": that name holds the
|
||||
# partition requested on the command line, and run mode still executes this
|
||||
# discovery pass before dispatching.
|
||||
classified=''
|
||||
for spec in \
|
||||
"schema:$schema_count" \
|
||||
"a-b:$ab_count" \
|
||||
"c:$c_count" \
|
||||
"d-r:$dr_count" \
|
||||
"s-z-example-fuzz:$sz_count"
|
||||
do
|
||||
name="${partition%%:*}"
|
||||
count="${partition#*:}"
|
||||
name="${spec%%:*}"
|
||||
count="${spec#*:}"
|
||||
classified="$classified $name"
|
||||
if [ "$count" -eq 0 ]; then
|
||||
printf 'app race partition %s is empty\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# The counters above and APP_PARTITIONS must describe the same set in both
|
||||
# directions. A counted partition missing from APP_PARTITIONS would never be
|
||||
# dispatched by any CI job, and a dispatchable partition with no counter would
|
||||
# escape the exact-coverage check above.
|
||||
for name in $APP_PARTITIONS; do
|
||||
case " $classified " in
|
||||
*" $name "*) ;;
|
||||
*)
|
||||
printf 'app partition %s has no coverage counter\n' "$name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
for name in $classified; do
|
||||
case " $APP_PARTITIONS " in
|
||||
*" $name "*) ;;
|
||||
*)
|
||||
printf 'counted partition %s is not a dispatchable app partition\n' "$name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
total_count="$(wc -l < "$tests" | tr -d ' ')"
|
||||
assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count))
|
||||
if [ "$assigned_count" -ne "$total_count" ]; then
|
||||
@@ -101,26 +148,73 @@ fi
|
||||
|
||||
run_partition() {
|
||||
name="$1"
|
||||
run_pattern="$2"
|
||||
skip_pattern="${3:-}"
|
||||
instrumentation="$2"
|
||||
run_pattern="$3"
|
||||
skip_pattern="${4:-}"
|
||||
|
||||
printf 'running internal/app race partition %s\n' "$name"
|
||||
# Fail closed on an unrecognized mode: a typo must not silently drop race
|
||||
# instrumentation from a partition that is supposed to carry it.
|
||||
case "$instrumentation" in
|
||||
race|no-race) ;;
|
||||
*)
|
||||
printf 'unknown instrumentation %s for app partition %s\n' \
|
||||
"$instrumentation" "$name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'running internal/app %s partition %s\n' "$instrumentation" "$name"
|
||||
set -- -v -count=1 -timeout=15m -run "$run_pattern"
|
||||
if [ -n "$skip_pattern" ]; then
|
||||
go test -v -race -count=1 -timeout=15m \
|
||||
-run "$run_pattern" -skip "$skip_pattern" "$app_package"
|
||||
else
|
||||
go test -v -race -count=1 -timeout=15m \
|
||||
-run "$run_pattern" "$app_package"
|
||||
set -- "$@" -skip "$skip_pattern"
|
||||
fi
|
||||
if [ "$instrumentation" = race ]; then
|
||||
set -- -race "$@"
|
||||
fi
|
||||
go test "$@" "$app_package"
|
||||
}
|
||||
|
||||
# Schema assembly has the largest transient memory footprint. Run those tests
|
||||
# in a fresh process, then keep each remaining name range in its own process so
|
||||
# command trees retained by process-global registries are released between
|
||||
# partitions. The complementary run/skip patterns preserve the full test set.
|
||||
#
|
||||
# The schema partition runs uninstrumented. Its tests assert structural
|
||||
# Schema-to-Cobra contracts over a single goroutine: none of them call
|
||||
# t.Parallel or start a goroutine, so the race detector has no concurrent access
|
||||
# to observe here. The process-global lazy metadata that does need race coverage
|
||||
# (schema_source_root's atomic.Value, the parameter-binding lazy loaders) is
|
||||
# exercised by internal/cli's concurrent tests, which stay instrumented. The
|
||||
# instrumentation is not free on this partition: its shared sync.Once Catalog
|
||||
# build is allocation-heavy, and -race made the partition roughly 11x slower
|
||||
# (26s -> 291s locally, 357s in CI) without being able to report anything.
|
||||
schema_pattern='^Test.*Schema'
|
||||
run_partition schema "$schema_pattern"
|
||||
run_partition a-b '^Test[A-B]' "$schema_pattern"
|
||||
run_partition c '^TestC' "$schema_pattern"
|
||||
run_partition d-r '^Test[D-R]' "$schema_pattern"
|
||||
run_partition s-z-example-fuzz '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
|
||||
|
||||
# Dispatch table for the partition set declared in APP_PARTITIONS. CI passes one
|
||||
# partition per job so they run concurrently; running without a partition keeps
|
||||
# the original end-to-end behaviour for local use and for any caller that wants
|
||||
# the whole package in one invocation.
|
||||
run_named_partition() {
|
||||
case "$1" in
|
||||
schema) run_partition schema no-race "$schema_pattern" ;;
|
||||
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
|
||||
c) run_partition c race '^TestC' "$schema_pattern" ;;
|
||||
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
|
||||
s-z-example-fuzz)
|
||||
run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
|
||||
;;
|
||||
*)
|
||||
printf 'unknown app partition: %s\n' "$1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ -n "$partition" ]; then
|
||||
run_named_partition "$partition"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for name in $APP_PARTITIONS; do
|
||||
run_named_partition "$name"
|
||||
done
|
||||
@@ -83,7 +83,8 @@ Example:
|
||||
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
|
||||
{"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}},
|
||||
{"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}
|
||||
{"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}},
|
||||
{"toolName":"set-dropdown","input":{"sheet-id":"Sheet1","range":"C2:C100","source-sheet-id":"SourceSheet","source-range":"T1:T3"}}
|
||||
]'
|
||||
dws sheet batch-update --node <NODE_ID> --continue-on-error --operations '[...]'
|
||||
Flags:
|
||||
@@ -101,6 +102,9 @@ Notes:
|
||||
- operations 最多 20 条
|
||||
- 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁)
|
||||
- `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本
|
||||
- `set-dropdown` 的 `input` 中,Inline 使用 `options`;SourceRange 使用 `source-sheet-id` + `source-range`,两种模式必须且只能选一个。顶层 `colors` / `source-colors` 会被拒绝;Inline 颜色写在 `options[].color`,SourceRange 颜色写入暂不支持
|
||||
- `set-dropdown` SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入
|
||||
- `source-range` 按 `toolName` 解释:`set-dropdown` 中是下拉候选项来源;`range fill` / `range copy-to` / `range move-to` 中是待填充、复制或移动的数据源区域
|
||||
- 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽
|
||||
- `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension`
|
||||
- `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put`
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉":
|
||||
- 设置下拉列表 → `set-dropdown`
|
||||
- 设置多选下拉 → `set-dropdown --multi-select`
|
||||
- 引用单元格区域作为候选项 → `set-dropdown --source-sheet-id ... --source-range ...`
|
||||
|
||||
用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项":
|
||||
- 获取下拉列表配置 → `get-dropdown`
|
||||
@@ -29,18 +30,25 @@ Example:
|
||||
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B50" \
|
||||
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
|
||||
--multi-select
|
||||
|
||||
# 引用同一工作簿内另一工作表的区域作为候选项来源
|
||||
dws sheet set-dropdown --node <NODE_ID> --sheet-id <TARGET_SHEET_ID> --range "C2:C100" \
|
||||
--source-sheet-id <SOURCE_SHEET_ID> --source-range "T1:T3"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标单元格范围,A1 表示法,如 A2:A100 (必填)
|
||||
--options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'
|
||||
--multi-select 是否允许多选(默认单选)
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标单元格范围,A1 表示法,如 A2:A100 (必填)
|
||||
--options string Inline 下拉选项 JSON 数组,与 --source-range 二选一
|
||||
--source-sheet-id string SourceRange 来源工作表 ID,与 --source-range 同时指定
|
||||
--source-range string SourceRange 来源区域,与 --options 二选一;不带工作表前缀
|
||||
--multi-select 是否允许多选(默认单选)
|
||||
```
|
||||
|
||||
在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。
|
||||
- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。
|
||||
在指定单元格范围内设置下拉列表。Inline 模式直接存储选项;SourceRange 模式引用同一工作簿内的来源区域,可跨工作表,并支持普通区域、整行和整列。
|
||||
- **用途**:为单元格配置静态选项或区域来源下拉,两种模式都支持多选;颜色仅 Inline 支持。
|
||||
- **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。
|
||||
- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。
|
||||
- **注意**:`--options` 与 `--source-range` 必须且只能指定一个。`--source-range` 只写 `T1:T3`、`T:T`、`1:3` 这类 A1 区域,来源工作表通过 `--source-sheet-id` 单独指定;不接受工作表前缀、公式或多区域。SourceRange 颜色写入暂不支持。
|
||||
- **结构操作行为**:已验证的工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 `valid`;已验证的 `move-dimension` 场景会使其变为 `invalid`。列删除、删除整个来源区域或来源工作表等场景未覆盖,不能预设结果;结构操作后先回读 `sourceRangeStatus`,仅在 `invalid` 时重新选择来源并写入。
|
||||
|
||||
### 获取下拉列表配置
|
||||
```
|
||||
@@ -55,10 +63,10 @@ Flags:
|
||||
--range string 查询范围,A1 表示法,如 A1:A100 (必填)
|
||||
```
|
||||
|
||||
查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。
|
||||
查询指定范围内的下拉列表配置信息。
|
||||
- **用途**:查看单元格已设置的下拉列表选项和配置。
|
||||
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
|
||||
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。
|
||||
- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
|
||||
|
||||
### 删除下拉列表
|
||||
```
|
||||
@@ -81,14 +89,15 @@ Flags:
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 |
|
||||
| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 |
|
||||
| `set-dropdown` | `range` 实际设置范围、`enableMultiSelect` 是否多选;仅 Inline 模式返回 `optionCount` | 确认下拉列表设置成功 |
|
||||
| `get-dropdown` | `hasDropdown`、`dataValidations`;按 `sourceType` 区分 Inline 与 SourceRange | 查看已有下拉配置 |
|
||||
| `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
|
||||
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
|
||||
- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉
|
||||
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持
|
||||
- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
|
||||
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
| 读取目的 | 推荐命令 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
|
||||
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 30,000 单元格和 maxChars 防爆 |
|
||||
| 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 |
|
||||
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
|
||||
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
|
||||
@@ -52,7 +52,10 @@ Flags:
|
||||
- `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算
|
||||
- `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号
|
||||
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
|
||||
- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取
|
||||
- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完
|
||||
- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现
|
||||
- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围
|
||||
- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标
|
||||
|
||||
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
|
||||
|
||||
@@ -63,10 +66,15 @@ Flags:
|
||||
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
|
||||
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
|
||||
|
||||
**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批:
|
||||
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
|
||||
- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` ……
|
||||
- 单次建议 ≤5000 单元格
|
||||
**大表分批读取**:`csv-get` 不会自动发起后续请求。当 `hasMore=true` 时:
|
||||
- 显式传了 `--range` 时,该范围是完整目标;未传时,以 `resolvedRange` 为完整目标
|
||||
- 根据 `returnedRange` 的结束行,从下一行开始显式构造新的 `--range`;例如目标是 `A1:A30001`、本次返回 `A1:A30000`,则继续读 `A30001:A30001`
|
||||
- `max_cells` 不能通过增大 `--max-chars` 解决;`max_chars` 且没有 `returnedRange` 时,增大 `--max-chars` 或缩小列范围
|
||||
- 上述完成度协议覆盖未传 `--range` 和显式有限矩形范围;`A:A`、`1:1` 等非有限范围可能不返回完成度字段,建议先用 `sheet info` 的 `nonEmptyRange` 换算成有限范围再读
|
||||
- 即使未命中硬上限,单次仍建议控制在 5000 单元格以内,减少超时和 token 消耗
|
||||
|
||||
**工作簿过大失败与部分成功不同**:收到顶层
|
||||
`errorCode=forbidden.document.sizeOverLimit` 和对应 `errorMessage` 时,表示工作簿整体无法装载。应停止读取并提示创建更小副本或拆分工作簿;缩小 `--range` 不能解决这类错误。不要把它当成 `hasMore=true` 的可续读结果。
|
||||
|
||||
### 以 table/dataframe 协议读取结构化数据
|
||||
```
|
||||
@@ -130,6 +138,8 @@ Flags:
|
||||
```
|
||||
|
||||
**返回字段说明**:
|
||||
- `rowIndices` / `colIndices` — 本次返回的真实行号与列字母映射
|
||||
- `hasMore` / `truncationReasons` / `resolvedRange` / `returnedRange` — 语义与 `csv-get` 相同;`range read` 的 `truncationReasons` 主要为 `max_cells`
|
||||
- `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下:
|
||||
|
||||
| 字段 | 类型 | 是否必有 | 说明 |
|
||||
@@ -146,10 +156,16 @@ Flags:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 |
|
||||
| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 |
|
||||
| `dropdown` Inline | `sourceType: "inline"` | 静态选项模式 |
|
||||
| `dropdown` Inline | `options: [{value: string, color?: string}]` | 下拉选项列表 |
|
||||
| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 |
|
||||
| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 |
|
||||
| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 |
|
||||
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` |
|
||||
| `checkbox` | `checked: boolean` | 当前勾选状态 |
|
||||
|
||||
SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。
|
||||
|
||||
**hyperlink 结构**:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
@@ -216,7 +232,7 @@ Flags:
|
||||
|
||||
**公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。
|
||||
|
||||
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略:
|
||||
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分批读取策略,每次都必须检查 `hasMore`:
|
||||
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
|
||||
- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` ……
|
||||
- 避免不传 `--range` 直接读取整个大工作表
|
||||
@@ -232,7 +248,7 @@ dws sheet list --node <NODE_ID> --format json
|
||||
# 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等)
|
||||
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 3. 读取全部数据
|
||||
# 3. 读取数据(只有 hasMore=false 才代表目标范围已完整返回)
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 4. 读取指定区域
|
||||
@@ -251,6 +267,8 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --f
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
|
||||
- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据
|
||||
- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`)
|
||||
- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读
|
||||
- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复
|
||||
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
|
||||
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
|
||||
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
|
||||
|
||||
@@ -386,9 +386,14 @@ Flags:
|
||||
**dropdown(下拉列表)**:
|
||||
```json
|
||||
{ "type": "text", "text": "High", "dataValidation": { "type": "dropdown", "options": [{"value":"High","color":"#00ff00"},{"value":"Low","color":"#ff0000"}], "enableMultiSelect": false } }
|
||||
{ "dataValidation": { "type": "dropdown", "sourceRange": {"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"}, "enableMultiSelect": false } }
|
||||
```
|
||||
- `options`:必填,`[{value, color?}]` 数组
|
||||
- `options` 与 `sourceRange` 必须且只能提供一个
|
||||
- Inline:`options` 为非空 `[{value, color?}]` 数组
|
||||
- SourceRange:`sourceRange` 为 `{sheetId,a1Notation}`;可引用同一工作簿内其他工作表,支持普通区域、整行和整列。`a1Notation` 不带工作表前缀,不接受公式或多区域;颜色写入暂不支持
|
||||
- `enableMultiSelect`:可选,是否多选,默认 false
|
||||
- SourceRange 在已验证的工作表重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读 `sourceRangeStatus`,仅 `invalid` 时重新选源写入
|
||||
- 复合 cell 写入中,如果值或样式已成功落盘但 SourceRange 校验失败,服务端可返回 `success:true` 并通过 `message` 说明下拉未创建;必须检查 `message` 并按需回读
|
||||
|
||||
**checkbox(复选框)**:
|
||||
```json
|
||||
|
||||
@@ -83,7 +83,8 @@ Example:
|
||||
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
|
||||
{"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}},
|
||||
{"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}
|
||||
{"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}},
|
||||
{"toolName":"set-dropdown","input":{"sheet-id":"Sheet1","range":"C2:C100","source-sheet-id":"SourceSheet","source-range":"T1:T3"}}
|
||||
]'
|
||||
dws sheet batch-update --node <NODE_ID> --continue-on-error --operations '[...]'
|
||||
Flags:
|
||||
@@ -101,6 +102,9 @@ Notes:
|
||||
- operations 最多 20 条
|
||||
- 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁)
|
||||
- `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本
|
||||
- `set-dropdown` 的 `input` 中,Inline 使用 `options`;SourceRange 使用 `source-sheet-id` + `source-range`,两种模式必须且只能选一个。顶层 `colors` / `source-colors` 会被拒绝;Inline 颜色写在 `options[].color`,SourceRange 颜色写入暂不支持
|
||||
- `set-dropdown` SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入
|
||||
- `source-range` 按 `toolName` 解释:`set-dropdown` 中是下拉候选项来源;`range fill` / `range copy-to` / `range move-to` 中是待填充、复制或移动的数据源区域
|
||||
- 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽
|
||||
- `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension`
|
||||
- `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put`
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉":
|
||||
- 设置下拉列表 → `set-dropdown`
|
||||
- 设置多选下拉 → `set-dropdown --multi-select`
|
||||
- 引用单元格区域作为候选项 → `set-dropdown --source-sheet-id ... --source-range ...`
|
||||
|
||||
用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项":
|
||||
- 获取下拉列表配置 → `get-dropdown`
|
||||
@@ -29,18 +30,25 @@ Example:
|
||||
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B50" \
|
||||
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
|
||||
--multi-select
|
||||
|
||||
# 引用同一工作簿内另一工作表的区域作为候选项来源
|
||||
dws sheet set-dropdown --node <NODE_ID> --sheet-id <TARGET_SHEET_ID> --range "C2:C100" \
|
||||
--source-sheet-id <SOURCE_SHEET_ID> --source-range "T1:T3"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标单元格范围,A1 表示法,如 A2:A100 (必填)
|
||||
--options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'
|
||||
--multi-select 是否允许多选(默认单选)
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 目标单元格范围,A1 表示法,如 A2:A100 (必填)
|
||||
--options string Inline 下拉选项 JSON 数组,与 --source-range 二选一
|
||||
--source-sheet-id string SourceRange 来源工作表 ID,与 --source-range 同时指定
|
||||
--source-range string SourceRange 来源区域,与 --options 二选一;不带工作表前缀
|
||||
--multi-select 是否允许多选(默认单选)
|
||||
```
|
||||
|
||||
在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。
|
||||
- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。
|
||||
在指定单元格范围内设置下拉列表。Inline 模式直接存储选项;SourceRange 模式引用同一工作簿内的来源区域,可跨工作表,并支持普通区域、整行和整列。
|
||||
- **用途**:为单元格配置静态选项或区域来源下拉,两种模式都支持多选;颜色仅 Inline 支持。
|
||||
- **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。
|
||||
- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。
|
||||
- **注意**:`--options` 与 `--source-range` 必须且只能指定一个。`--source-range` 只写 `T1:T3`、`T:T`、`1:3` 这类 A1 区域,来源工作表通过 `--source-sheet-id` 单独指定;不接受工作表前缀、公式或多区域。SourceRange 颜色写入暂不支持。
|
||||
- **结构操作行为**:已验证的工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 `valid`;已验证的 `move-dimension` 场景会使其变为 `invalid`。列删除、删除整个来源区域或来源工作表等场景未覆盖,不能预设结果;结构操作后先回读 `sourceRangeStatus`,仅在 `invalid` 时重新选择来源并写入。
|
||||
|
||||
### 获取下拉列表配置
|
||||
```
|
||||
@@ -55,10 +63,10 @@ Flags:
|
||||
--range string 查询范围,A1 表示法,如 A1:A100 (必填)
|
||||
```
|
||||
|
||||
查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。
|
||||
查询指定范围内的下拉列表配置信息。
|
||||
- **用途**:查看单元格已设置的下拉列表选项和配置。
|
||||
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
|
||||
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。
|
||||
- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
|
||||
|
||||
### 删除下拉列表
|
||||
```
|
||||
@@ -81,14 +89,15 @@ Flags:
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 |
|
||||
| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 |
|
||||
| `set-dropdown` | `range` 实际设置范围、`enableMultiSelect` 是否多选;仅 Inline 模式返回 `optionCount` | 确认下拉列表设置成功 |
|
||||
| `get-dropdown` | `hasDropdown`、`dataValidations`;按 `sourceType` 区分 Inline 与 SourceRange | 查看已有下拉配置 |
|
||||
| `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
|
||||
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
|
||||
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
|
||||
- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉
|
||||
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持
|
||||
- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
|
||||
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
| 读取目的 | 推荐命令 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
|
||||
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 30,000 单元格和 maxChars 防爆 |
|
||||
| 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 |
|
||||
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
|
||||
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
|
||||
@@ -52,7 +52,10 @@ Flags:
|
||||
- `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算
|
||||
- `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号
|
||||
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
|
||||
- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取
|
||||
- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完
|
||||
- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现
|
||||
- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围
|
||||
- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标
|
||||
|
||||
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
|
||||
|
||||
@@ -63,10 +66,15 @@ Flags:
|
||||
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
|
||||
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
|
||||
|
||||
**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批:
|
||||
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
|
||||
- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` ……
|
||||
- 单次建议 ≤5000 单元格
|
||||
**大表分批读取**:`csv-get` 不会自动发起后续请求。当 `hasMore=true` 时:
|
||||
- 显式传了 `--range` 时,该范围是完整目标;未传时,以 `resolvedRange` 为完整目标
|
||||
- 根据 `returnedRange` 的结束行,从下一行开始显式构造新的 `--range`;例如目标是 `A1:A30001`、本次返回 `A1:A30000`,则继续读 `A30001:A30001`
|
||||
- `max_cells` 不能通过增大 `--max-chars` 解决;`max_chars` 且没有 `returnedRange` 时,增大 `--max-chars` 或缩小列范围
|
||||
- 上述完成度协议覆盖未传 `--range` 和显式有限矩形范围;`A:A`、`1:1` 等非有限范围可能不返回完成度字段,建议先用 `sheet info` 的 `nonEmptyRange` 换算成有限范围再读
|
||||
- 即使未命中硬上限,单次仍建议控制在 5000 单元格以内,减少超时和 token 消耗
|
||||
|
||||
**工作簿过大失败与部分成功不同**:收到顶层
|
||||
`errorCode=forbidden.document.sizeOverLimit` 和对应 `errorMessage` 时,表示工作簿整体无法装载。应停止读取并提示创建更小副本或拆分工作簿;缩小 `--range` 不能解决这类错误。不要把它当成 `hasMore=true` 的可续读结果。
|
||||
|
||||
### 以 table/dataframe 协议读取结构化数据
|
||||
```
|
||||
@@ -130,6 +138,8 @@ Flags:
|
||||
```
|
||||
|
||||
**返回字段说明**:
|
||||
- `rowIndices` / `colIndices` — 本次返回的真实行号与列字母映射
|
||||
- `hasMore` / `truncationReasons` / `resolvedRange` / `returnedRange` — 语义与 `csv-get` 相同;`range read` 的 `truncationReasons` 主要为 `max_cells`
|
||||
- `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下:
|
||||
|
||||
| 字段 | 类型 | 是否必有 | 说明 |
|
||||
@@ -146,10 +156,16 @@ Flags:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 |
|
||||
| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 |
|
||||
| `dropdown` Inline | `sourceType: "inline"` | 静态选项模式 |
|
||||
| `dropdown` Inline | `options: [{value: string, color?: string}]` | 下拉选项列表 |
|
||||
| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 |
|
||||
| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 |
|
||||
| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 |
|
||||
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` |
|
||||
| `checkbox` | `checked: boolean` | 当前勾选状态 |
|
||||
|
||||
SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。
|
||||
|
||||
**hyperlink 结构**:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
@@ -216,7 +232,7 @@ Flags:
|
||||
|
||||
**公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。
|
||||
|
||||
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略:
|
||||
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分批读取策略,每次都必须检查 `hasMore`:
|
||||
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
|
||||
- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` ……
|
||||
- 避免不传 `--range` 直接读取整个大工作表
|
||||
@@ -232,7 +248,7 @@ dws sheet list --node <NODE_ID> --format json
|
||||
# 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等)
|
||||
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 3. 读取全部数据
|
||||
# 3. 读取数据(只有 hasMore=false 才代表目标范围已完整返回)
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 4. 读取指定区域
|
||||
@@ -251,6 +267,8 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --f
|
||||
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
|
||||
- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据
|
||||
- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`)
|
||||
- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读
|
||||
- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复
|
||||
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
|
||||
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
|
||||
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
|
||||
|
||||
@@ -386,9 +386,14 @@ Flags:
|
||||
**dropdown(下拉列表)**:
|
||||
```json
|
||||
{ "type": "text", "text": "High", "dataValidation": { "type": "dropdown", "options": [{"value":"High","color":"#00ff00"},{"value":"Low","color":"#ff0000"}], "enableMultiSelect": false } }
|
||||
{ "dataValidation": { "type": "dropdown", "sourceRange": {"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"}, "enableMultiSelect": false } }
|
||||
```
|
||||
- `options`:必填,`[{value, color?}]` 数组
|
||||
- `options` 与 `sourceRange` 必须且只能提供一个
|
||||
- Inline:`options` 为非空 `[{value, color?}]` 数组
|
||||
- SourceRange:`sourceRange` 为 `{sheetId,a1Notation}`;可引用同一工作簿内其他工作表,支持普通区域、整行和整列。`a1Notation` 不带工作表前缀,不接受公式或多区域;颜色写入暂不支持
|
||||
- `enableMultiSelect`:可选,是否多选,默认 false
|
||||
- SourceRange 在已验证的工作表重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读 `sourceRangeStatus`,仅 `invalid` 时重新选源写入
|
||||
- 复合 cell 写入中,如果值或样式已成功落盘但 SourceRange 校验失败,服务端可返回 `success:true` 并通过 `message` 说明下拉未创建;必须检查 `message` 并按需回读
|
||||
|
||||
**checkbox(复选框)**:
|
||||
```json
|
||||
|
||||
@@ -1157,7 +1157,7 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
`test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`,
|
||||
`test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`,
|
||||
`echo "TEST_HEAD_REF=$(git rev-parse HEAD)"`,
|
||||
`list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"`,
|
||||
`list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"`,
|
||||
"needs.lint.outputs.full_suite != 'true'",
|
||||
`name: "Test (race: ${{ matrix.shard }})"`,
|
||||
"name: Test (workflow and release contracts)",
|
||||
@@ -1219,16 +1219,20 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
}
|
||||
// The focused path fans the impacted set across the same shards as test-race
|
||||
// and runs each shard the way test-race runs it, so no single job carries
|
||||
// internal/app together with its reverse dependencies. internal/app keeps its
|
||||
// package-level headroom through the process-isolating helper instead of one
|
||||
// long -timeout, which is strictly stronger: every process releases the
|
||||
// framework registries it populated. release-scripts is asserted because its
|
||||
// dedicated job only runs at full-suite or release-sensitive scope, so losing
|
||||
// it here would silently stop testing test/scripts changes.
|
||||
// internal/app together with its reverse dependencies. internal/app is split
|
||||
// further into one shard per bounded partition, which keeps its package-level
|
||||
// headroom through the process-isolating helper instead of one long -timeout
|
||||
// and is strictly stronger than a single app job: every partition process
|
||||
// releases the framework registries it populated, and the partitions run
|
||||
// concurrently rather than end to end. Each partition shard still selects the
|
||||
// same single internal/app package, so the impacted-package query maps the
|
||||
// shard name back to app. release-scripts is asserted because its dedicated
|
||||
// job only runs at full-suite or release-sensitive scope, so losing it here
|
||||
// would silently stop testing test/scripts changes.
|
||||
for _, want := range []string{
|
||||
`if [ "$TEST_SHARD" = "app" ]; then`,
|
||||
`app-*) package_shard=app ;;`,
|
||||
`test "${#packages[@]}" -eq 1`,
|
||||
`./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`,
|
||||
`./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"`,
|
||||
`if [ "$TEST_SHARD" = "release-scripts" ]; then`,
|
||||
`go test -v -count=1 -timeout=10m "${packages[@]}"`,
|
||||
"timeout_budget=12m",
|
||||
@@ -1249,14 +1253,16 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
t.Fatal("Code Admission workflow missing race test job boundaries")
|
||||
}
|
||||
raceJob := admission[raceStart:raceEnd]
|
||||
// The app shard uses independently bounded test processes so process-global
|
||||
// command registries are released before the Schema assembly peak. Other full
|
||||
// race shards retain the dynamic package timeout: default/floor 12m, with
|
||||
// cli/smoke raised to 15m on slower hosted runners.
|
||||
// internal/app is carried by one shard per bounded partition, so process-global
|
||||
// command registries are released with each partition process and the Schema
|
||||
// assembly peak no longer sits in front of the other partitions. Each
|
||||
// partition shard resolves back to the same single internal/app package.
|
||||
// Other full race shards retain the dynamic package timeout: default/floor
|
||||
// 12m, with cli/smoke raised to 15m on slower hosted runners.
|
||||
for _, want := range []string{
|
||||
`if [ "$TEST_SHARD" = "app" ]; then`,
|
||||
`app-*) package_shard=app ;;`,
|
||||
`test "${#packages[@]}" -eq 1`,
|
||||
`./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`,
|
||||
`./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"`,
|
||||
"timeout_budget=12m",
|
||||
`if [ "$TEST_SHARD" = "cli" ] ||`,
|
||||
`[ "$TEST_SHARD" = "smoke" ]; then`,
|
||||
|
||||
@@ -99,6 +99,74 @@ func TestCIAppRacePartitionsCoverTopLevelTestsExactlyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCIAppRacePartitionMatrixMatchesHelper pins the workflow's app partition
|
||||
// shards to the partition set the helper actually runs. The partitions are
|
||||
// separate CI jobs now, so the helper's own "covered exactly once" check can no
|
||||
// longer prove the whole package ran: a partition the helper knows about but no
|
||||
// matrix shard dispatches would silently stop running while every job stays
|
||||
// green. Both directions are asserted so a stale matrix shard fails too.
|
||||
func TestCIAppRacePartitionMatrixMatchesHelper(t *testing.T) {
|
||||
root := testPackagePlanRoot(t)
|
||||
script := filepath.Join(root, "scripts", "ci", "run-app-race-tests.sh")
|
||||
cmd := exec.Command("sh", script, "list-partitions")
|
||||
cmd.Dir = root
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s list-partitions failed: %v\n%s", script, err, output)
|
||||
}
|
||||
partitions := strings.Fields(string(output))
|
||||
if len(partitions) == 0 {
|
||||
t.Fatalf("list-partitions returned no partitions: %q", output)
|
||||
}
|
||||
|
||||
workflow, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read ci.yml: %v", err)
|
||||
}
|
||||
admission := string(workflow)
|
||||
|
||||
for _, job := range []struct {
|
||||
name string
|
||||
startMark string
|
||||
endMark string
|
||||
}{
|
||||
{"test-focused", "\n test-focused:\n", "\n test-race:\n"},
|
||||
{"test-race", "\n test-race:\n", "\n test-release-scripts:\n"},
|
||||
} {
|
||||
start := strings.Index(admission, job.startMark)
|
||||
end := strings.Index(admission, job.endMark)
|
||||
if start < 0 || end <= start {
|
||||
t.Fatalf("ci.yml is missing %s job boundaries", job.name)
|
||||
}
|
||||
body := admission[start:end]
|
||||
|
||||
for _, partition := range partitions {
|
||||
want := "- app-" + partition
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s matrix is missing shard %q for a partition the helper runs", job.name, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
shard := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(shard, "- app-") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(shard, "- app-")
|
||||
matched := false
|
||||
for _, partition := range partitions {
|
||||
if partition == name {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Errorf("%s matrix shard %q has no matching helper partition", job.name, shard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) {
|
||||
root := testPackagePlanRoot(t)
|
||||
fakeBin := t.TempDir()
|
||||
|
||||
Reference in new issue
Block a user