mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
e79d49e7e4
* feat(sheets): support font_family in cell styles (#1549) Add a font_family field to cell_styles so a cell's font name can be set and read back through every style entry point: - +cells-set (--cells JSON) and +cells-set-style / +cells-batch-set-style gain a font_family field / --font-family flat flag - +workbook-create / +table-put --styles accept font_family in cell_styles - +cells-get returns font_family helpers.go buildCellStyleFromFlags reads the --font-family flag; lark_sheet_workbook.go allows font_family in the --styles cell_styles whitelist; data/ + skills/ are synced from sheet-skill-spec. * docs(sheets): inline editing rules into SKILL.md and clarify flag descriptions - Move cross-cutting editing rules and execution notes into the root SKILL.md and drop the now-redundant core-operations reference - Clarify flag descriptions: offset must be explicit inside +batch-update, range prefixes written bare (no quotes), chart requires a dim index, untyped --values lose date/number types, ungroup level semantics - Sync the corresponding reference docs * feat(sheets): add --type bitable to +sheet-create for creating bitable sub-sheets (#1520) * perf(sheets): cap fan-out cell-matrix materialization to prevent OOM (#1578) * perf(sheets): cap fan-out cell-matrix materialization to prevent OOM The +cells-set-style / +dropdown-set / +cells-batch-set-style / +dropdown-update shortcuts expand a single A1 range into a rows×cols matrix of per-cell maps client-side (the backing set_cell_range tool takes an explicit cells matrix). rangeDimensions() had no upper bound, so a tiny input like "A1:Z100000" balloons into ~2.6M heap maps (~900MB, doubled again by json.Marshal) and can OOM the process before the request is even sent. Add a 50000-cell safety cap (checkStampMatrixBudget) gating every fan-out materialization point, matching the documented but never-wired --max-cells default. Oversized ranges now fail fast with a clear validation error instead of allocating. Also preallocate the per-op slices now that the range count is known up front. Adds benchmarks + a boundary test as regression guards. * perf(sheets): cap table-put/batch fan-out materialization (siblings of the cell-matrix cap) The single-range fan-out cap (maxStampMatrixCells) left three sibling ingress paths uncapped, each able to materialize an unbounded matrix or op set in memory before the request leaves: - +table-put / +workbook-create --sheets/--values: buildSheetMatrix builds the whole rows×cols matrix before slicing it into per-write batches; tablePutMaxCellsPerWrite only bounds the batch size, not the total input. Add tablePayload.checkCellBudget (1M-cell guardrail), enforced in validate() and in buildValuesPayload (the --values path bypasses validate()). - batch fan-out (+cells-batch-set-style / +dropdown-update): per-range checkStampMatrixBudget can't stop many ranges from summing past the cap. Add an aggregate cell budget (checkBatchStampBudget) and a shared maxBatchRanges (100) count cap in validateDropdownRanges — covering all fan-out commands and replacing the now-redundant +dropdown-delete count check. - +batch-update: cap --operations at maxBatchOperations (100) in translateBatchOperations. Adds boundary regression tests for each cap. go vet + gofmt clean; full shortcuts/sheets + backward suites green. * test(sheets): measure table-put matrix materialization cost Add BenchmarkBuildSheetMatrix_* and TestTablePutMatrixPeakMemory mirroring the fan-out probes. Confirms the +table-put/+workbook-create ingress has the same OOM profile as the single-range stamp: 2.6M cells → ~917 MB / 5.3M allocs (+875 MB resident heap) materialized before the first write — now rejected up front by checkCellBudget. * feat(pivot): lark-sheets pivot reference 补 +pivot-list info 说明与落点覆盖校验 +pivot-list 返回 info(page_range/content_range/error_state 等): 1) 判断目标单元格在透视表内(改配置 +pivot-update)还是区域外(改值 +cells-set); 2) 透视表展开后会覆盖已有数据,落点强烈优先默认自动新建子表; 3) 创建后用 info.error_state / content_range 校验有没有覆盖/冲突。 * feat(sheets): add +formula-verify shortcut for verify_formula tool Wraps the new verify_formula read tool in a CLI shortcut so AI agents can run write-then-zero-error verification end-to-end: lark-cli sheets +formula-verify --url <url> Scans formulas + cell error states across one or more sub-sheets and returns a JSON status report (success / errors_found / partial). Aggregates all 7 Excel error categories (#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A) plus compile failures into one envelope; the tool always reports every error in the scan window — callers needing a subset filter the returned error_summary client-side. The internal scan cap is hidden from callers; when it trips the response sets has_more=true and includes a warning_message asking the caller to narrow --range / split --sheet-id and continue. Flags follow the lark-sheets convention: - --url / --spreadsheet-token (XOR public) - --sheet-id / --sheet-name (repeat or comma-separate; mutually exclusive) - --range (repeatable A1) - --max-locations (default 20) - --exit-on-error (CI gate: status='errors_found' → exit 2 with failed_precondition) Generated artifacts (skills/lark-sheets/{SKILL.md, references/ lark-sheets-formula-verify.md}, shortcuts/sheets/data/flag-defs.json, shortcuts/sheets/flag_defs_gen.go) are mirrored from sheet-skill-spec generated/ via 'npm run sync:cli'. shortcuts.go registers FormulaVerify alongside the other lark_sheet_formula_verify skill shortcuts so +formula-verify is discoverable from 'lark-cli sheets --help'. Tests cover the dry-run wire shape (excel_id + sheet_ids/sheet_names/ ranges/max_locations packing), the read scope (invoke_read URL), the mutually-exclusive selector validation, the non-positive --max-locations guard, and the --exit-on-error status matrix (success/partial/errors_found/unknown). * feat(sheets): add +history-list / +history-revert / +history-revert-status shortcuts BE-1 + BE-2 (larksuite/cli lark-sheets) for spec sheet-history-revert. Three thin callTool wrappers over facade-agg history tools, following the existing sheets Validate/DryRun/Execute + --url/--spreadsheet-token(/--token) locator convention: - +history-list (read, history_list): passes the tool output through verbatim; facade-agg already does the minor_histories/4-field/RFC3339 transform. - +history-revert (write, history_revert): --history-version-id required, enforced at Validate stage with a typed *errs.ValidationError (no request on missing); returns the async receipt. - +history-revert-status (read, history_revert_status): polls in-progress / success / failure. Flags declared inline (not via *_gen.go) — flag_defs_gen.go / data/flag-defs.json are synced from sheet-skill-spec (BE-3) and must not be hand-edited. Notes: - history_revert / history_revert_status depend on facade-agg's downstream RPC wiring, a DEFERRED follow-up; the tools return a "not wired yet" guard today. These CLI wrappers are correct and go live when the backend follow-up lands. +history-list is fully functional now. - TestFlagDefsGen_MatchesJSON fails on baseline (pre-existing BE-3 gen/json drift); resolves once BE-3 sync:cli regenerates flag defs for these shortcuts. Validation: go build ./shortcuts/sheets/... PASS; new tests (TestHistoryShortcuts_DryRun, TestHistoryRevert_MissingVersionID) PASS. Spec source: active@2acd94a24ac3f835357a274a02344f78435bcc1c39ad0d695ce587f0cbddfb21 * chore(sheets): sync lark_sheet_history skill + flag defs from sheet-skill-spec (BE-3) Synced artifacts for the history shortcuts from ee/sheet-skill-spec (SSOT), landed surgically (history-only) to avoid regressing this branch's newer skills/lark-sheets content: - skills/lark-sheets/references/lark-sheets-history.md (new, mirrored). - skills/lark-sheets/SKILL.md: + Lark Sheet History references-table row only. - shortcuts/sheets/data/flag-defs.json: + 3 history shortcuts (additive; no existing entries touched). - shortcuts/sheets/flag_defs_gen.go: regenerated via go generate ./shortcuts/sheets/... (this also resolves the pre-existing flag-defs/gen drift — TestFlagDefsGen_MatchesJSON now passes). NOT a full mirror: the rest of skills/lark-sheets/ + flag-schemas.json on this branch (feat/lark-sheets-develop) are NEWER than the sheet-skill-spec worktree's canonical (e.g. /wiki/ URL support, schema_version 3). A wholesale sync:cli would have reverted them, so only the history delta is taken here. Full re-sync should happen once sheet-skill-spec canonical is realigned with this branch. Validation: go generate clean; go test ./shortcuts/sheets/ (TestFlagDefsGen_MatchesJSON, TestHistory*) PASS. Spec source: active@2acd94a24ac3f835357a274a02344f78435bcc1c39ad0d695ce587f0cbddfb21 * fix(sheets): +history-revert-status keys on --transaction-id, not version id BE-2 gap surfaced by PPE E2E: +history-revert-status sent history_version_id, but the facade-agg history_revert_status tool keys on transaction_id (the async receipt returned by +history-revert), so it returned "[40400] transaction_id is required". Give the status shortcut its own --transaction-id flag + input (excel_id + transaction_id); revert keeps --history-version-id. Tests updated. * fix(sheets): align history flag-defs with inline shortcuts (green TestFlagsFor) TestFlagsFor_EveryRegisteredCommandHasDefs was RED: generated flag-defs drifted from the hand-written history shortcuts. - +history-revert-status: flag-defs had --history-version-id; the BE-2 fix switched the shortcut to --transaction-id. Updated the entry to transaction-id. - +history-revert / -status --history-version-id were marked required="required", but the inline flags are cobra-optional (requiredness enforced in Validate). Set required="optional" to match. Regenerated flag_defs_gen.go. NOTE: canonical source is sheet-skill-spec (BE-3); apply the same change upstream or the next sync:cli will regress this. * chore(sheets): sync lark-sheets-history reference from spec (BE-2 transaction-id) Mirror the upstream BE-2 fix in canonical-spec/references/lark_sheet_history/ cli-reference.md: +history-revert-status now uses --transaction-id (taken from the async receipt returned by +history-revert), and +history-revert's --history-version-id flips required→optional (Validate enforces requiredness at runtime). This file is the only history-only delta from the upstream sheet-skill-spec sync; the rest of skills/lark-sheets/ stays on the cli's newer baseline (/wiki/ URL support, +cells-set-image / +float-image-create, etc.) to match commit 8ae516db's history-only mirror policy. Spec source companion change: feat/sheet-history-revert in ee/sheet-skill-spec, canonical-spec/{tool-shortcut-map.json,references/ lark_sheet_history/cli-reference.md}. * feat(sheets): +history-list --end-version for backward pagination Spec follow-up sheet-history-revert: thread the history_list pagination contract through the +history-list shortcut. - shortcuts/sheets/lark_sheet_history_list.go: + --end-version (int, optional). Mapped to the tool input's `end_version` only when explicitly set (so the server treats absence as "first page / latest"), via runtime.Changed / runtime.Int (matches the +formula-verify --max-locations precedent). + Tip: pass next_end_version from the response on the next call; capture exits the pagination loop when the server omits the field. - shortcuts/sheets/lark_sheet_history_test.go: + dry-run case asserting --end-version 12345 lands as input.end_version=12345 (post-JSON unmarshal float64). - skills/lark-sheets/references/lark-sheets-history.md: synced from ee/sheet-skill-spec (commit 39c6b61). Adds the "倒序分页" caveat row + --end-version flag + pagination Examples line. Drops the internal MajorHistory.Version implementation detail per spec follow-up. - shortcuts/sheets/data/flag-defs.json: synced from spec (+history-list +--end-version int optional). - shortcuts/sheets/flag_defs_gen.go: regenerated via `go generate ./shortcuts/sheets/...`. Companion changes: - ee/sheet-skill-spec MR !37: spec-tables + tool-schemas pagination contract (commits 09e8604, 39c6b61). - ee/sheet-facade-agg MR !1028: history_list tool plumbs end_version, emits next_end_version + has_more (omitted at earliest page), defaults PageSize=20 to datarpc. Validation: - go build ./shortcuts/sheets/... PASS - go test ./shortcuts/sheets/... PASS (sheets + backward) - TestHistoryShortcuts_DryRun (5 cases incl. new --end-version case): PASS - TestHistoryRevert_MissingRequiredFlag: PASS - TestFlagsFor_EveryRegisteredCommandHasDefs: PASS - TestFlagDefsGen_MatchesJSON: PASS * fix(sheets): make +history-revert --history-version-id cobra-required + revert max-cells default drift Two issues surfaced during MR !37 review: 1) +history-revert --history-version-id requiredness was set as "optional" in the spec table (BE-2 fix dc5fe0ea) so cobra wouldn't block before Validate. Per upstream review the flag should be required-by-cobra so the user gets the standard "required flag(s)" gate immediately and the runtime contract matches the JSON shape. - shortcuts/sheets/lark_sheet_history_revert.go: historyVersionIDFlag now sets Required: true. Validate keeps a trim/empty-string guard so '--history-version-id ""' still fails as a typed *errs.ValidationError (cobra accepts empty strings as "set"). - shortcuts/sheets/data/flag-defs.json: +history-revert --history-version-id required: optional -> required. - shortcuts/sheets/flag_defs_gen.go: regenerated. - shortcuts/sheets/lark_sheet_history_test.go: TestHistoryRevert_MissingRequiredFlag split into per-shortcut subtests; +history-revert asserts cobra's "required flag(s)" contract (raw err — the test rig calls cmd.Execute directly so it doesn't see the cmd dispatcher's typed envelope wrap); +history-revert-status keeps the typed *errs.ValidationError contract (its --transaction-id stays cobra-optional + Validate-enforced). 2) max-cells safety cap was accidentally rewritten from 200000 to 50000 by the last sync from sheet-skill-spec (the spec canonical side fell out of date — fixed separately on the spec MR follow-up). Restore desc: "Safety cap; default 200000" / default: "200000" so +cells-get / +csv-get keep the documented cap. Validation: - go test ./shortcuts/sheets/... PASS - TestHistoryRevert_MissingRequiredFlag (both subtests) PASS - TestHistoryShortcuts_DryRun (incl. +history-list pagination case) PASS - TestFlagsFor_EveryRegisteredCommandHasDefs PASS - TestFlagDefsGen_MatchesJSON PASS * fix(sheets): make +history-revert-status --transaction-id cobra-required (match +history-revert) Companion to commit 6ca35b06: same gating model now applies to both history receipts. - shortcuts/sheets/lark_sheet_history_revert.go: transactionIDFlag.Required=true. Validate keeps a trim/empty-string guard for '--transaction-id ""'. - shortcuts/sheets/data/flag-defs.json: +history-revert-status --transaction-id required: optional -> required (synced from sheet-skill-spec @9ca814d). - shortcuts/sheets/flag_defs_gen.go: regenerated. - shortcuts/sheets/lark_sheet_history_test.go: TestHistoryRevert_MissingRequiredFlag/+history-revert-status moved to the cobra "required flag(s)" text contract (the test rig invokes the shortcut via cmd.Execute, which sees the raw cobra error directly without the dispatcher's typed wrap). Drop now-unused `errors` and `errs` imports. Validation: - go test ./shortcuts/sheets/... PASS (sheets + backward) - TestFlagsFor_EveryRegisteredCommandHasDefs: PASS - TestFlagDefsGen_MatchesJSON: PASS - TestHistoryRevert_MissingRequiredFlag (both subtests): PASS * docs(sheets): sync history skill reference required badges from spec Companion to commit 9fa73312 (transaction-id) and 6ca35b06 (history-version-id): the two flag tables in skills/lark-sheets/references/lark-sheets-history.md still showed 'optional' even though the canonical contract — and shortcuts/sheets/data/ flag-defs.json — already moved to 'required'. The earlier syncs only picked up the data file from spec; the skill markdown drift slipped through. Pull in the spec-side regenerated reference (ee/sheet-skill-spec @9ca814d) so the human-readable doc matches the wire contract. * fix(sheets): lower cells-set --max-cells default to 50000 * docs(sheets): clarify workbook-import over read-then-recreate in skill * docs(sheets): bump lark-sheets skill version to 3.0.1 * docs(sheets): clarify number-vs-text typing and copy-to-range template guidance in references * docs(sheets): type by data nature, add pre-write reference column and chart/cond-format/filter rows - SKILL.md quick-reference: add a "read before acting" column pointing each intent at its reference doc; add chart / cond-format / filter rows. - Reframe number-vs-text decision to follow the data's nature (measure vs identifier), not whether the current task happens to sort/sum; a leaderboard/report "display only" use does not make a percentage text. - write-cells reference: mirror the same rule and the +cells-set fallback for layouts +table-put cannot express. * docs(sheets): tighten number-vs-text guidance and dedupe write-cells reference * Feat/lark sheets develop wzz (#1719) * feat(sheets): add +changeset-get shortcut for changeset review Wrap the get_changeset read tool: fetch the raw changeset (edit actions) between two versions to review whether an AI edit fulfilled the request. --start-revision required, --end-revision optional (defaults to latest), gap capped at 100. Adds flag-defs entry + regenerated gen, the ChangesetGet shortcut + tests, and skill docs. * feat(sheets): add +get-revision shortcut Return a spreadsheet's current document revision without pulling the full sub-sheet listing. +get-revision is a read-only derivative over get_workbook_structure (the lightest read — token only, no range) that projects the response down to the single revision field. Adds flag-defs entries and a unit test for the projection helper. * feat: 同步 spec 修改 * feat(sheets): rename +get-revision to +revision-get * feat: 移除 ppe 环境请求头 --------- Co-authored-by: wenzhuozhen <wenzhuozhen@bytedance.com> * docs(sheets): dedupe +changeset-get flag def and skill reference entry * feat(sheets): accept local_office_ token prefix for image parent_type The synthetic token prefix for imported office spreadsheets is being renamed from fake_office_ to local_office_. Accept either prefix when mapping a spreadsheet token to the drive media parent_type so image uploads keep working across the rename (main package and backward compat copy). * fix(sheets): replace undefined common.FlagErrorf with sheetsValidationForFlag changesetRevisions called common.FlagErrorf, which does not exist, breaking the build. Use sheetsValidationForFlag so the errors carry the offending flag param like the rest of the sheets validation paths. Also reword two doc comments in lark_sheet_history_revert.go that used '' for an empty shell string: gofmt (Go 1.19+) rewrites '' in doc comments to a curly quote, leaving the file permanently unformatted. * fix(sheets): satisfy errs-no-bare-wrap forbidigo and errorlint rules from main main introduced the errs-no-bare-wrap forbidigo rule and errorlint coverage that flag 27 issues in existing sheets code after the merge: - Replace direct *errs.ValidationError type assertions with errors.As in sheetsInputStatError and validateSheetMediaUploadFile so wrapped errors still match (errorlint). - Type the embedded flag-schemas.json parse failure as an InternalError with cause; it reaches the user directly via --print-schema. - Annotate genuine intermediate errors (recursive schema validator, batch sub-op raw type checks, A1 range/position parsers) with //nolint:forbidigo; every caller wraps them into typed flag validation errors. * docs: tighten formula verify workflow guidance * docs: align formula verify refs with file names * feat(sheets): let typed writes style blank cells past the data extent +workbook-create / +table-put apply cell_styles by writing them into the in-memory matrix, whose size was fixed to the data (cols × rows). A style range reaching past that extent was rejected as "outside the write range", so blank cells (reserved regions, decorative headers, empty borders) could not be styled on the typed --sheets path — only the untyped --values path padded for it. Pad the matrix down/right to cover every cell_styles range before applying (empty cells appended for the uncovered positions), mirroring the --values behavior. writeSheetData now derives the written width/range from the padded matrix; both dry-run previews and sheetCreateDims account for the style extent so the physical grid and the plan match Execute. Ranges above/left of the write anchor stay rejected (the matrix only grows down/right). * docs(sheets): warn that +csv-put silently coerces numeric-looking labels Add guidance that +csv-put numericizes date-like/ID-like columns whose values are all digits (12.10 becomes 12.1 losing the trailing zero, 001 becomes 1 losing the leading zero); recommend +table-put with dtypes=object/datetime64 or +cells-set + number_format="@". Also fix the batch-update example to use sheet_name instead of sheet_id. * docs(sheets): steer import-vs-append onto sheet-copy for existing workbooks * docs(sheets): warn that cells-clear --scope all is irreversibly destructive * docs(sheets): sync chart schema and labels guidance (#1716) * chore(sheets): update chart flag schema * docs(sheets): clarify chart labels field is presence-toggle, not value-toggle Synced from sheet-skill-spec. Chart labels (plotArea.plot.labels and per-series labels) are toggled by object existence — passing labels at all turns data labels on, even when value/category/series/percentage are all false (server falls back to showing value). Models repeatedly try `{ value: false, category: false, series: false }` to disable, which silently shows the value fallback. The reference doc now spells out both directions: pass labels to show, omit the whole labels field to hide. Also picks up earlier spec-side drift not yet propagated: - pivot-table reference: +pivot-list info return + overlap validation - flag-defs: cell-matrix fan-out cap default 200000 -> 50000 (#1578) * feat(sheets): drop pre-refactor aliases from `sheets --help` listing The refactored + commands have been the default for over a month. Hide the deprecated pre-refactor aliases from `sheets --help` via a custom cobra usage template that skips the deprecated group. Aliases stay registered and executable: their own `sheets <alias> --help` still shows the (→ +new-command) pointer, unknown-subcommand suggestions still span them, and execution still returns the _notice. * feat(sheets): let +csv-put fall back to piped stdin when --csv is omitted Agents routinely redirect a CSV into stdin but forget the `--csv -`, so `+csv-put ... < data.csv` failed its first try on a missing --csv and cost an extra round-trip (error, then --help, then retry). Relax --csv's cobra required-gate in the shortcut's PostMount and install a PreRunE that defaults an omitted --csv to "-" when stdin is a non-interactive pipe, so the standard stdin-resolution path reads it. The pipe guard keeps an interactive terminal from blocking on stdin, and a genuine miss (no piped data) still surfaces csvPutInput's typed "--csv is required" instead of cobra's bare "required flag(s) ... not set". Scoped entirely to the sheets domain — no changes to the shared runner or the flag schema. * feat(sheets): rework +rows-resize / +cols-resize to --height / --width 从上游 sheet-skill-spec 同步:+cols-resize 用 --width、+rows-resize 用 --height 直接给像素值, --type 变为可选(省略等价于 pixel)。--type standard/auto 走非像素模式,不能与像素 flag 同传; --type pixel 与 --width/--height 共存时视为等价形式。--size 已删除。 * docs(sheets): 更新 lark-sheets skill 版本至 3.0.2 将 SKILL.md 版本号从 3.0.1 升至 3.0.2,同步近期 sheets 命令改动(+rows-resize/+cols-resize 改 --height/--width、 +csv-put 支持 stdin 回退等)后的技能版本。 * feat(sheets): add --widths / --heights map form for per-column/row sizes 从上游 sheet-skill-spec 同步:+cols-resize --widths / +rows-resize --heights 接收 JSON map(键为单行列或闭区间,值为像素或 "standard"/"auto"),CLI 按起始位置排序后 展开为一次原子 batch_update 的多个 resize_range 操作,多列不同宽 / 多行不同高一次 调用完成,不再需要 +batch-update。map 形态与 --range/--width/--height/--type 互斥, 不可作为 +batch-update 子操作嵌入(batch_update 不支持嵌套)。列宽 < 20px 拒绝并提示 Excel 字符单位换算(px ≈ 字符数×8+16);--print-schema --flag-name widths/heights 可查 schema。 * fix(sheets): sync flag input/enum fixes from sheet-skill-spec 上游修复 spec-table 的 Input/Enum 字符串惯例后重新生成:--widths/--heights 现在带 file/stdin 输入声明,+sheet-create --type 的枚举正确进入 flag defs 与文档。 * feat(sheets): add sheets-scoped flag ergonomics via PostMount Two recovery loops from the edit-eval traces burn agent round-trips: hallucinated flag names (--cols for --range) whose unknown-flag error only points at --help, and enum values imported from CSS/Excel vocabulary ("center" for the vertical alignment Lark spells "middle"). - unknown-flag errors now inline the full valid-flag list (semantic guesses aren't rankable by edit distance; kills the --help round trip) - enum values with an unambiguous canonical form (casing, known alias) are normalized in place and the call proceeds; edit-distance typos stay errors with a did-you-mean hint and are never auto-applied Both ride the existing PostMount composition (same pattern as withTokenAlias), so the common framework is untouched and no other domain's behavior shifts. * feat(sheets): make validation errors prescriptive for hot failure modes Driven by the edit-eval-extra-35Q reports: ~70% of lark-cli sheets errors were missing-required / JSON-shape / wrong-value classes whose messages said what broke but not how to fix it, pushing agents into --help / --print-schema probe loops. - composite JSON shape errors inline a compact skeleton auto-generated from the schema (e.g. --cells -> [[{"value": ...}]]) when the type mismatch is shallow container confusion - +batch-update: missing 'shortcut' shows the entry template; a disallowed shortcut inlines the full allow-list; exceeding the 100-op cap says how many batches to split into; sub-op translator failures append the shortcut's complete input-key contract - +table-put: dtypes/formats keys that miss every column call out the A1-letter habit and inline the declared column names; empty cells in a date-typed column name the three ways out - schema enum errors suggest across casing, vocabulary aliases, and edit distance * fix(common): steer rejected @file paths to stdin instead of cd The absolute-path rejection hint said "cd to the target directory first" - advice the lark-sheets skill explicitly tells agents not to follow (it pollutes the working directory). The stdin-contention hint also demonstrated @file with an absolute path, which would itself be rejected. - @file failures on stdin-capable flags now show the equivalent stdin invocation (--csv - < /tmp/x.csv) - the path error recommends a relative path or stdin, not cd - the stdin-contention example uses a relative @file path Message-text only; no control-flow change for any domain. * chore(sheets): suppress forbidigo on csv-put stdin pipe detection os.Stdin.Stat is intentional here - pipe detection needs the real process fd; IOStreams.In is a plain io.Reader without Stat. Clears the lint failure left by the stdin-fallback commit. * fix(sheets): pass spreadsheet token to changeset tool (#1839) * fix(sheets): hide bitable sheet creation (#1843) * fix(sheets): resolve revision wiki URLs * fix(sheets): reject overlapping resize ranges * fix(sheets): address remaining review feedback * fix(sheets): avoid credential scanner false positive * fix(sheets): import mislabeled .xls workbooks by sniffing content Local .xls files that are actually OOXML (an .xlsx exported or renamed to .xls) failed +workbook-import with a cryptic backend "xml_version_not_support" because the CLI trusted the file name extension. +workbook-import now sniffs the file's leading magic bytes (PK -> xlsx, OLE2 -> xls) and passes the true extension to the drive import core via a new optional ImportParams.FileExtension override, correcting both the file_extension and the staged media file name (the latter avoids the backend's "import file extension not match", code 1069910). A declared Excel file whose bytes match neither container is rejected locally with a prescriptive error instead of the opaque backend failure. The drive import core gains only the neutral FileExtension override (empty = infer from the file name, i.e. unchanged behavior for drive +import); all Excel sniffing/correction policy lives in the sheets shortcut. * fix(ci): keep semantic waiver fixture active * fix(sheets): close remaining safety gaps * fix(sheets): align history shortcuts with generated flags Use generated flag defs for history revert commands, enforce control-character validation, and sync the refreshed lark-sheets references from sheet-skill-spec. * fix(sheets): require confirmation for history revert * fix(sheets): require explicit csv input --------- Co-authored-by: xiongyuanwen-byted <xiongyuanwen@bytedance.com> Co-authored-by: wuyanchun.anunwu <wuyanchun.anunwu@bytedance.com> Co-authored-by: wenzhuozhen <wenzhuozhen@bytedance.com>
1014 lines
37 KiB
Go
1014 lines
37 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable
|
|
// shortcut, the MCP body produced inside +batch-update must be byte-for-byte
|
|
// identical to the body the same shortcut produces when invoked standalone
|
|
// (both observed via --dry-run, comparing tool_name + decoded input). This is
|
|
// what guarantees "a sub-op behaves exactly like the standalone command", and
|
|
// it is the regression guard for the whole flag→body translator reuse.
|
|
//
|
|
// Each case provides the standalone CLI args and the equivalent sub-op input
|
|
// object (same CLI flag names, minus the spreadsheet locator which the batch
|
|
// supplies at the top level).
|
|
func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
shortcut string
|
|
sc common.Shortcut
|
|
// standalone args (excluding --url, which every case shares)
|
|
args []string
|
|
// sub-op input object as JSON (CLI flag names; no excel_id/url)
|
|
subInput string
|
|
}{
|
|
{
|
|
shortcut: "+cells-set",
|
|
sc: CellsSet,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`,
|
|
},
|
|
{
|
|
shortcut: "+cells-clear",
|
|
sc: CellsClear,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`,
|
|
},
|
|
{
|
|
shortcut: "+cells-replace",
|
|
sc: CellsReplace,
|
|
args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"},
|
|
subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`,
|
|
},
|
|
{
|
|
shortcut: "+csv-put",
|
|
sc: CsvPut,
|
|
args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"},
|
|
subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`,
|
|
},
|
|
{
|
|
shortcut: "+cells-merge",
|
|
sc: CellsMerge,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`,
|
|
},
|
|
{
|
|
shortcut: "+cells-unmerge",
|
|
sc: CellsUnmerge,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:C1"},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:C1"}`,
|
|
},
|
|
{
|
|
shortcut: "+dim-insert",
|
|
sc: DimInsert,
|
|
args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"},
|
|
subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`,
|
|
},
|
|
{
|
|
shortcut: "+dim-delete",
|
|
sc: DimDelete,
|
|
args: []string{"--sheet-id", "sh1", "--range", "C:D"},
|
|
subInput: `{"sheet-id":"sh1","range":"C:D"}`,
|
|
},
|
|
{
|
|
shortcut: "+dim-hide",
|
|
sc: DimHide,
|
|
args: []string{"--sheet-id", "sh1", "--range", "2:3"},
|
|
subInput: `{"sheet-id":"sh1","range":"2:3"}`,
|
|
},
|
|
{
|
|
shortcut: "+dim-freeze",
|
|
sc: DimFreeze,
|
|
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
|
|
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
|
|
},
|
|
{
|
|
shortcut: "+dim-group",
|
|
sc: DimGroup,
|
|
args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"},
|
|
subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`,
|
|
},
|
|
{
|
|
shortcut: "+rows-resize",
|
|
sc: RowsResize,
|
|
args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"},
|
|
subInput: `{"sheet-id":"sh1","range":"1","height":30}`,
|
|
},
|
|
{
|
|
shortcut: "+cols-resize",
|
|
sc: ColsResize,
|
|
args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"},
|
|
subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`,
|
|
},
|
|
{
|
|
shortcut: "+range-move",
|
|
sc: RangeMove,
|
|
args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"},
|
|
subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`,
|
|
},
|
|
{
|
|
shortcut: "+range-copy",
|
|
sc: RangeCopy,
|
|
args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"},
|
|
subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`,
|
|
},
|
|
{
|
|
shortcut: "+range-fill",
|
|
sc: RangeFill,
|
|
args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"},
|
|
subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`,
|
|
},
|
|
{
|
|
shortcut: "+range-sort",
|
|
sc: RangeSort,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-create",
|
|
sc: SheetCreate,
|
|
args: []string{"--title", "New", "--index", "2"},
|
|
subInput: `{"title":"New","index":2}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-delete",
|
|
sc: SheetDelete,
|
|
args: []string{"--sheet-id", "sh1"},
|
|
subInput: `{"sheet-id":"sh1"}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-rename",
|
|
sc: SheetRename,
|
|
args: []string{"--sheet-id", "sh1", "--title", "Renamed"},
|
|
subInput: `{"sheet-id":"sh1","title":"Renamed"}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-copy",
|
|
sc: SheetCopy,
|
|
args: []string{"--sheet-id", "sh1", "--title", "Copy"},
|
|
subInput: `{"sheet-id":"sh1","title":"Copy"}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-hide",
|
|
sc: SheetHide,
|
|
args: []string{"--sheet-id", "sh1"},
|
|
subInput: `{"sheet-id":"sh1"}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-unhide",
|
|
sc: SheetUnhide,
|
|
args: []string{"--sheet-id", "sh1"},
|
|
subInput: `{"sheet-id":"sh1"}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-set-tab-color",
|
|
sc: SheetSetTabColor,
|
|
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
|
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-show-gridline",
|
|
sc: SheetShowGridline,
|
|
args: []string{"--sheet-id", "sh1"},
|
|
subInput: `{"sheet-id":"sh1"}`,
|
|
},
|
|
{
|
|
shortcut: "+sheet-hide-gridline",
|
|
sc: SheetHideGridline,
|
|
args: []string{"--sheet-id", "sh1"},
|
|
subInput: `{"sheet-id":"sh1"}`,
|
|
},
|
|
{
|
|
shortcut: "+dropdown-set",
|
|
sc: DropdownSet,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"},
|
|
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`,
|
|
},
|
|
{
|
|
// --highlight=false explicitly opts out of the server's new
|
|
// enable_highlight=true default. Covers the tri-state Changed()
|
|
// branch in buildDropdownValidation: standalone reads the cobra
|
|
// "Changed" bit; sub-op reads the key's presence in the map.
|
|
shortcut: "+dropdown-set",
|
|
sc: DropdownSet,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--highlight=false"},
|
|
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"highlight":false}`,
|
|
},
|
|
{
|
|
shortcut: "+chart-create",
|
|
sc: ChartCreate,
|
|
args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
|
subInput: `{"sheet-id":"sh1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
|
|
},
|
|
{
|
|
shortcut: "+chart-update",
|
|
sc: ChartUpdate,
|
|
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
|
subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
|
|
},
|
|
{
|
|
shortcut: "+chart-delete",
|
|
sc: ChartDelete,
|
|
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
|
|
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
|
|
},
|
|
{
|
|
shortcut: "+pivot-create",
|
|
sc: PivotCreate,
|
|
// +pivot-create renamed --sheet-id / --sheet-name → --target-sheet-id /
|
|
// --target-sheet-name to flag the placement-sheet semantics (the data
|
|
// source is in --source). Both standalone args and the +batch-update
|
|
// sub-op input must use the new names.
|
|
args: []string{"--target-sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"},
|
|
subInput: `{"target-sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`,
|
|
},
|
|
{
|
|
shortcut: "+cond-format-create",
|
|
sc: CondFormatCreate,
|
|
args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicateValues", "--ranges", `["A1:A100"]`},
|
|
subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicateValues","ranges":["A1:A100"]}`,
|
|
},
|
|
{
|
|
shortcut: "+filter-create",
|
|
sc: FilterCreate,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
|
|
},
|
|
{
|
|
shortcut: "+filter-update",
|
|
sc: FilterUpdate,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
|
|
},
|
|
{
|
|
shortcut: "+filter-delete",
|
|
sc: FilterDelete,
|
|
args: []string{"--sheet-id", "sh1"},
|
|
subInput: `{"sheet-id":"sh1"}`,
|
|
},
|
|
{
|
|
shortcut: "+filter-view-create",
|
|
sc: FilterViewCreate,
|
|
args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`},
|
|
subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`,
|
|
},
|
|
{
|
|
shortcut: "+sparkline-create",
|
|
sc: SparklineCreate,
|
|
args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`},
|
|
subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`,
|
|
},
|
|
{
|
|
shortcut: "+sparkline-delete",
|
|
sc: SparklineDelete,
|
|
args: []string{"--sheet-id", "sh1", "--group-id", "g1"},
|
|
subInput: `{"sheet-id":"sh1","group-id":"g1"}`,
|
|
},
|
|
{
|
|
shortcut: "+float-image-create",
|
|
sc: FloatImageCreate,
|
|
args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
|
|
subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
|
},
|
|
{
|
|
shortcut: "+float-image-delete",
|
|
sc: FloatImageDelete,
|
|
args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"},
|
|
subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.shortcut, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mapping, ok := batchOpDispatch[tc.shortcut]
|
|
if !ok {
|
|
t.Fatalf("%s not in batchOpDispatch", tc.shortcut)
|
|
}
|
|
|
|
// Standalone body via the shortcut's own dry-run.
|
|
standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName)
|
|
|
|
// Batch body via the +batch-update translator.
|
|
var subInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
|
t.Fatalf("bad subInput JSON: %v", err)
|
|
}
|
|
fv := newMapFlagViewForCommand(tc.shortcut, subInput)
|
|
// Match what translateBatchOp does — read the sheet selector
|
|
// via the shortcut-specific flag names so +pivot-create
|
|
// (target-sheet-id / target-sheet-name) and the rest
|
|
// (sheet-id / sheet-name) both resolve correctly.
|
|
sidFlag, snameFlag := sheetSelectorFlagsForSubOp(tc.shortcut)
|
|
sidStr, _ := subInput[sidFlag].(string)
|
|
snameStr, _ := subInput[snameFlag].(string)
|
|
batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr)
|
|
if err != nil {
|
|
t.Fatalf("batch translate failed: %v", err)
|
|
}
|
|
|
|
// Round-trip the batch body through JSON so number types match the
|
|
// standalone path (which is decoded from a JSON string).
|
|
batchBody = jsonRoundTrip(t, batchBody)
|
|
|
|
if !reflect.DeepEqual(standaloneBody, batchBody) {
|
|
t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} {
|
|
t.Helper()
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
var out map[string]interface{}
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestBatchOp_ErrorEquivalence is the second half of the contract: for the
|
|
// same bad input, the standalone shortcut Validate and the +batch-update
|
|
// sub-op translator must emit the same friendly CLI error. Previously a
|
|
// sub-op that omitted --sheet-id (or another required flag) slipped through
|
|
// to the server and surfaced as "sheet undefined not found"; with the
|
|
// validation pushed down into the xxxInput builders both paths now stop the
|
|
// request before the API call.
|
|
//
|
|
// Scope: this test covers checks that cobra cannot enforce — XOR pairs
|
|
// (sheet selector, image token/uri), range relationships, enum-bound rules,
|
|
// pixel/size cross-flag coupling. cobra's own MarkFlagRequired catches the
|
|
// single-required cases on the standalone path with its own
|
|
// "required flag(s) \"X\" not set" wording; the batch path now catches the
|
|
// same situations with our friendlier "--X is required" wording — those are
|
|
// asserted by TestBatchOp_RejectsBadSubOpInput below.
|
|
func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
// shortcut & standalone args. --url is supplied by the runner. Args
|
|
// satisfy every cobra-required flag so cobra doesn't short-circuit
|
|
// before our shared validator runs.
|
|
shortcut common.Shortcut
|
|
args []string
|
|
// matching sub-op input; reach the same failing check.
|
|
subShortcut string
|
|
subInput string
|
|
// substring expected in both errors. We assert *contains* rather than
|
|
// equality because the batch path wraps the inner error with
|
|
// "operations[i] (<name>): " context — the inner message must match.
|
|
wantContains string
|
|
}{
|
|
{
|
|
name: "+cells-set missing sheet selector",
|
|
shortcut: CellsSet,
|
|
args: []string{"--range", "A1", "--cells", `[[{"value":"x"}]]`},
|
|
subShortcut: "+cells-set",
|
|
subInput: `{"range":"A1","cells":[[{"value":"x"}]]}`,
|
|
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
|
},
|
|
{
|
|
name: "+cells-set both sheet-id and sheet-name",
|
|
shortcut: CellsSet,
|
|
args: []string{"--sheet-id", "sh1", "--sheet-name", "Sheet1", "--range", "A1", "--cells", `[[{"value":"x"}]]`},
|
|
subShortcut: "+cells-set",
|
|
subInput: `{"sheet-id":"sh1","sheet-name":"Sheet1","range":"A1","cells":[[{"value":"x"}]]}`,
|
|
wantContains: "mutually exclusive",
|
|
},
|
|
{
|
|
name: "+dim-insert missing sheet selector",
|
|
shortcut: DimInsert,
|
|
args: []string{"--position", "1", "--count", "1"},
|
|
subShortcut: "+dim-insert",
|
|
subInput: `{"position":"1","count":1}`,
|
|
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
|
},
|
|
{
|
|
name: "+dim-insert count <= 0",
|
|
shortcut: DimInsert,
|
|
args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"},
|
|
subShortcut: "+dim-insert",
|
|
subInput: `{"sheet-id":"sh1","position":"5","count":0}`,
|
|
wantContains: "--count must be > 0",
|
|
},
|
|
{
|
|
name: "+rows-resize --height with --type standard",
|
|
shortcut: RowsResize,
|
|
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"},
|
|
subShortcut: "+rows-resize",
|
|
subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`,
|
|
wantContains: "--height cannot be combined with --type standard",
|
|
},
|
|
{
|
|
name: "+sheet-delete missing sheet selector",
|
|
shortcut: SheetDelete,
|
|
args: []string{},
|
|
subShortcut: "+sheet-delete",
|
|
subInput: `{}`,
|
|
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
|
},
|
|
{
|
|
name: "+float-image-create both image-token and image-uri",
|
|
shortcut: FloatImageCreate,
|
|
args: []string{"--sheet-id", "sh1", "--image-name", "x.png", "--image-token", "t", "--image-uri", "u", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
|
|
subShortcut: "+float-image-create",
|
|
subInput: `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","image-uri":"u","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
|
wantContains: "mutually exclusive",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Standalone path: run the shortcut with --dry-run + bad args.
|
|
// Validate runs before DryRun, so we expect it to fail there.
|
|
_, _, standaloneErr := runShortcutCapturingErr(
|
|
t, tc.shortcut,
|
|
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
|
|
)
|
|
requireValidation(t, standaloneErr, tc.wantContains)
|
|
|
|
// Batch path: translate the matching sub-op. The translator wraps
|
|
// the inner error with "operations[i] (<shortcut>): " — assert the
|
|
// inner message survives the wrap.
|
|
var subInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
|
t.Fatalf("bad subInput JSON: %v", err)
|
|
}
|
|
rawOp := map[string]interface{}{
|
|
"shortcut": tc.subShortcut,
|
|
"input": subInput,
|
|
}
|
|
_, batchErr := translateBatchOp(rawOp, testToken, 0)
|
|
batchVE := requireValidation(t, batchErr, tc.wantContains)
|
|
// And the wrap context must include the sub-op index + shortcut
|
|
// name so error reports stay actionable in multi-op batches.
|
|
wrapHint := "operations[0] (" + tc.subShortcut + "):"
|
|
if !strings.Contains(batchVE.Message, wrapHint) {
|
|
t.Errorf("batch error %q missing context prefix %q", batchVE.Message, wrapHint)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form
|
|
// (--widths/--heights) expands into its own batch_update, and batch_update
|
|
// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be
|
|
// rejected with a pointer to the standalone form — it is standalone-valid,
|
|
// so this case cannot live in the standalone-vs-batch equivalence table.
|
|
func TestBatchOp_RejectsResizeMapForm(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
shortcut string
|
|
input string
|
|
}{
|
|
{"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`},
|
|
{"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.shortcut, func(t *testing.T) {
|
|
t.Parallel()
|
|
var subInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil {
|
|
t.Fatalf("bad input JSON: %v", err)
|
|
}
|
|
rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput}
|
|
_, err := translateBatchOp(rawOp, testToken, 0)
|
|
requireValidation(t, err, "not supported inside +batch-update")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
|
|
// silent-coercion gap: `operations` skips parse-time schema validation, and
|
|
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
|
|
// whose JSON type contradicts its flag-defs type must be rejected up front
|
|
// rather than landing as 0 / false in the wrong place.
|
|
func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
subShortcut string
|
|
subInput string
|
|
wantContains string
|
|
}{
|
|
{
|
|
name: "int flag given a string",
|
|
subShortcut: "+sheet-move",
|
|
subInput: `{"sheet-id":"sh1","source-index":2,"index":"abc"}`,
|
|
wantContains: "--index must be a number",
|
|
},
|
|
{
|
|
name: "int flag given a boolean",
|
|
subShortcut: "+sheet-move",
|
|
subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`,
|
|
wantContains: "--source-index must be a number",
|
|
},
|
|
{
|
|
// Standalone cobra rejects 1.5 for an int flag at parse time;
|
|
// mapFlagView.Int would silently truncate it to 1, so the batch
|
|
// path must reject it too instead of executing on a floored index.
|
|
name: "int flag given a non-integer number",
|
|
subShortcut: "+sheet-move",
|
|
subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`,
|
|
wantContains: "--index must be an integer",
|
|
},
|
|
{
|
|
name: "bool flag given a string",
|
|
subShortcut: "+cells-set",
|
|
subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`,
|
|
wantContains: "--allow-overwrite must be a boolean",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var subInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
|
t.Fatalf("bad subInput JSON: %v", err)
|
|
}
|
|
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
|
_, err := translateBatchOp(rawOp, testToken, 0)
|
|
requireValidation(t, err, tc.wantContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone
|
|
// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is
|
|
// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor:
|
|
// - +csv-put: standalone requires one-of(start-cell, range) via cobra's
|
|
// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra.
|
|
// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in
|
|
// SheetMove.Validate; the batch path uses a dedicated builder.
|
|
//
|
|
// Without an explicit guard, mapFlagView's flag-default fallback silently wins
|
|
// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone
|
|
// contract instead of failing.
|
|
func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
subShortcut string
|
|
subInput string
|
|
wantContains string
|
|
}{
|
|
{
|
|
name: "+csv-put without start-cell or range",
|
|
subShortcut: "+csv-put",
|
|
subInput: `{"sheet-id":"sh1","csv":"a,b"}`,
|
|
wantContains: "--start-cell or --range is required",
|
|
},
|
|
{
|
|
name: "+sheet-move without index",
|
|
subShortcut: "+sheet-move",
|
|
subInput: `{"sheet-id":"sh1","source-index":2}`,
|
|
wantContains: "requires index",
|
|
},
|
|
{
|
|
name: "+sheet-move negative index",
|
|
subShortcut: "+sheet-move",
|
|
subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`,
|
|
wantContains: "--index must be >= 0",
|
|
},
|
|
{
|
|
name: "+sheet-move negative source-index",
|
|
subShortcut: "+sheet-move",
|
|
subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`,
|
|
wantContains: "--source-index must be >= 0",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var subInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
|
t.Fatalf("bad subInput JSON: %v", err)
|
|
}
|
|
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
|
_, err := translateBatchOp(rawOp, testToken, 0)
|
|
requireValidation(t, err, tc.wantContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for
|
|
// inputs that cobra's MarkFlagRequired catches on the standalone path,
|
|
// the +batch-update sub-op (which has no cobra layer) must still reject
|
|
// CLI-side with its own friendly error before issuing any API call. This
|
|
// closes the original bug — a sub-op missing --sheet-id used to slip
|
|
// through and surface as "sheet undefined not found" only after a
|
|
// network round-trip.
|
|
func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
subShortcut string
|
|
subInput string
|
|
wantContains string
|
|
}{
|
|
{
|
|
"+cells-set missing --range",
|
|
"+cells-set",
|
|
`{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`,
|
|
"--range is required",
|
|
},
|
|
{
|
|
"+dim-insert missing --position",
|
|
"+dim-insert",
|
|
`{"sheet-id":"sh1","count":1}`,
|
|
"--position is required",
|
|
},
|
|
{
|
|
"+rows-resize missing both --height and --type",
|
|
"+rows-resize",
|
|
`{"sheet-id":"sh1","range":"1:1"}`,
|
|
"give --height <px> for a pixel size, or --type standard / auto",
|
|
},
|
|
{
|
|
"+range-copy missing --target-range",
|
|
"+range-copy",
|
|
`{"sheet-id":"sh1","source-range":"A1:B2"}`,
|
|
"--target-range is required",
|
|
},
|
|
{
|
|
"+sheet-rename missing --title",
|
|
"+sheet-rename",
|
|
`{"sheet-id":"sh1"}`,
|
|
"--title is required",
|
|
},
|
|
{
|
|
"+chart-update missing --chart-id",
|
|
"+chart-update",
|
|
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
|
|
"--chart-id is required",
|
|
},
|
|
{
|
|
"+filter-create missing --range",
|
|
"+filter-create",
|
|
`{"sheet-id":"sh1"}`,
|
|
"--range is required",
|
|
},
|
|
{
|
|
"+float-image-update missing --float-image-id",
|
|
"+float-image-update",
|
|
`{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
|
"--float-image-id is required",
|
|
},
|
|
// +float-image-update's core (image_name / position / size) is mandatory
|
|
// on update too — the tool rejects without them and +float-image-list
|
|
// can't backfill image_name. cobra gates these on the standalone path;
|
|
// the batch sub-op must reject them here. The image source stays optional
|
|
// (omitting it keeps the current image), so these inputs omit it.
|
|
{
|
|
"+float-image-update missing --image-name",
|
|
"+float-image-update",
|
|
`{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
|
"--image-name is required",
|
|
},
|
|
{
|
|
"+float-image-update missing position",
|
|
"+float-image-update",
|
|
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`,
|
|
"--position-row and --position-col are required",
|
|
},
|
|
{
|
|
"+float-image-update missing size",
|
|
"+float-image-update",
|
|
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`,
|
|
"--size-width and --size-height are required",
|
|
},
|
|
// +filter-{update,delete} need sheet-id (not sheet-name) because
|
|
// server contract: filter_id === sheet_id, and we can't resolve
|
|
// sheet-name → sheet-id mid-batch.
|
|
{
|
|
"+filter-update with --sheet-name only (filter_id must equal sheet_id)",
|
|
"+filter-update",
|
|
`{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`,
|
|
"+filter-update requires --sheet-id",
|
|
},
|
|
{
|
|
"+filter-delete with --sheet-name only (filter_id must equal sheet_id)",
|
|
"+filter-delete",
|
|
`{"sheet-name":"Sheet1"}`,
|
|
"+filter-delete requires --sheet-id",
|
|
},
|
|
// +sparkline-update requires sparkline_id on every
|
|
// properties.sparklines[i] (server contract). CLI surfaces this
|
|
// with a pointer to +sparkline-list so the agent doesn't have to
|
|
// guess the id from an opaque server-side rejection.
|
|
{
|
|
"+sparkline-update item missing sparkline_id",
|
|
"+sparkline-update",
|
|
`{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`,
|
|
"missing sparkline_id",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var subInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
|
t.Fatalf("bad subInput JSON: %v", err)
|
|
}
|
|
rawOp := map[string]interface{}{
|
|
"shortcut": tc.subShortcut,
|
|
"input": subInput,
|
|
}
|
|
_, err := translateBatchOp(rawOp, testToken, 0)
|
|
requireValidation(t, err, tc.wantContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven
|
|
// validator fires on +batch-update sub-operations the same way it
|
|
// fires on standalone shortcuts. mapFlagView.Command() returns the
|
|
// sub-op's shortcut name, so validateInputAgainstSchema (called at
|
|
// each input builder's tail) routes through the same (command, flag)
|
|
// lookup pipeline a standalone invocation would. This regression
|
|
// pins that wiring — without it, agents could slip past CLI-side
|
|
// schema checks by wrapping a bad input in +batch-update.
|
|
func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
subShortcut string
|
|
subInput string
|
|
wantContains string
|
|
}{
|
|
// +pivot-create properties.values items enforce summarize_by
|
|
// enum — schema rejects an out-of-enum value as a sub-op too.
|
|
{
|
|
"+pivot-create summarize_by out of enum",
|
|
"+pivot-create",
|
|
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
|
"summarize_by",
|
|
},
|
|
// +chart-create properties.position.row has minimum:0 — P0
|
|
// addition; validator must catch -1 even in the batch path.
|
|
{
|
|
"+chart-create position.row below minimum",
|
|
"+chart-create",
|
|
`{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`,
|
|
"below minimum",
|
|
},
|
|
// +cells-set --cells is a 2D array of objects per the
|
|
// upstream-fixed schema; sub-op passing an object must be
|
|
// rejected at the schema layer (not "expected JSON array").
|
|
{
|
|
"+cells-set cells wrong shape",
|
|
"+cells-set",
|
|
`{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`,
|
|
`expected type "array"`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var subInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
|
t.Fatalf("bad subInput JSON: %v", err)
|
|
}
|
|
rawOp := map[string]interface{}{
|
|
"shortcut": tc.subShortcut,
|
|
"input": subInput,
|
|
}
|
|
_, err := translateBatchOp(rawOp, testToken, 0)
|
|
requireValidation(t, err, tc.wantContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two
|
|
// originally reported failures: +range-copy and +rows-resize sub-ops must
|
|
// translate to the correct MCP body (not a near-passthrough that drops
|
|
// required fields).
|
|
func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// +range-copy → transform_range with range / destination_range (not the
|
|
// raw source_range / target_range that used to leak through).
|
|
body := parseDryRunBody(t, BatchUpdate, []string{
|
|
"--url", testURL,
|
|
"--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`,
|
|
"--yes",
|
|
})
|
|
ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
|
copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
|
if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" {
|
|
t.Errorf("+range-copy sub-op body wrong: %#v", copyIn)
|
|
}
|
|
if copyIn["operation"] != "copy" {
|
|
t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"])
|
|
}
|
|
|
|
// +rows-resize → resize_range with range + resize_height. The CLI's single
|
|
// "23" input must be expanded to "23:23" because resize_range rejects
|
|
// bare single-element ranges.
|
|
body = parseDryRunBody(t, BatchUpdate, []string{
|
|
"--url", testURL,
|
|
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`,
|
|
"--yes",
|
|
})
|
|
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
|
resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
|
if resizeIn["range"] != "23:23" {
|
|
t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"])
|
|
}
|
|
rh, _ := resizeIn["resize_height"].(map[string]interface{})
|
|
if rh == nil || rh["type"] != "pixel" {
|
|
t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn)
|
|
}
|
|
}
|
|
|
|
// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity
|
|
// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies
|
|
// the sheet locator but omits all of the shortcut's business-required flags must
|
|
// fail in translateBatchOp — never silently fall back to a default. The earlier
|
|
// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked
|
|
// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it
|
|
// guards the whole surface and auto-covers any shortcut added later. If a future
|
|
// refactor moves a required check out of the shared *Input builder (the exact
|
|
// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op
|
|
// would start accepting missing args and this test fails.
|
|
func TestBatchOp_RequiredFlagParity(t *testing.T) {
|
|
t.Parallel()
|
|
defs, err := loadFlagDefs()
|
|
if err != nil {
|
|
t.Fatalf("loadFlagDefs: %v", err)
|
|
}
|
|
// Flags supplied by the +batch-update top level (url/token), or that form the
|
|
// sub-op's own sheet selector, are context — not "business" inputs.
|
|
locator := map[string]bool{
|
|
"url": true, "spreadsheet-token": true,
|
|
"sheet-id": true, "sheet-name": true,
|
|
"target-sheet-id": true, "target-sheet-name": true,
|
|
}
|
|
// How each command expresses its sheet locator in a sub-op, so the error we
|
|
// trigger is the business one, not a missing-locator error.
|
|
sheetSel := func(cmd string) map[string]interface{} {
|
|
switch cmd {
|
|
case "+sheet-create": // create needs no existing-sheet anchor
|
|
return map[string]interface{}{}
|
|
case "+pivot-create": // placement selector is target-sheet-*; data source is --source
|
|
return map[string]interface{}{"target-sheet-id": "sh1"}
|
|
default:
|
|
return map[string]interface{}{"sheet-id": "sh1"}
|
|
}
|
|
}
|
|
for cmd := range batchOpDispatch {
|
|
spec, ok := defs[cmd]
|
|
if !ok {
|
|
t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd)
|
|
continue
|
|
}
|
|
var business []string
|
|
for _, fl := range spec.Flags {
|
|
if fl.Kind == "system" || locator[fl.Name] {
|
|
continue
|
|
}
|
|
if fl.Required == "required" || fl.Required == "xor" {
|
|
business = append(business, fl.Name)
|
|
}
|
|
}
|
|
if len(business) == 0 {
|
|
continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit
|
|
}
|
|
t.Run(cmd, func(t *testing.T) {
|
|
t.Parallel()
|
|
rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)}
|
|
_, err := translateBatchOp(rawOp, testToken, 0)
|
|
if err == nil {
|
|
t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+
|
|
"(batch must reject missing required flags, not silently default)", cmd, business)
|
|
return
|
|
}
|
|
// The sub-op DID supply a sheet selector, so a missing-locator error
|
|
// would mean the fixture is wrong and the business-required check never
|
|
// actually ran — reject that shape so the parity check stays honest.
|
|
if strings.Contains(err.Error(), "specify at least one of") {
|
|
t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBatchOp_EnumParity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("canonical casing is normalized before translation", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := translateBatchOp(map[string]interface{}{
|
|
"shortcut": "+cells-clear",
|
|
"input": map[string]interface{}{
|
|
"sheet-id": "sh1",
|
|
"range": "A1:B2",
|
|
"scope": "FORMATS",
|
|
},
|
|
}, testToken, 0)
|
|
if err != nil {
|
|
t.Fatalf("translateBatchOp: %v", err)
|
|
}
|
|
input, _ := got["input"].(map[string]interface{})
|
|
if input["clear_type"] != "formats" {
|
|
t.Fatalf("clear_type = %v, want formats", input["clear_type"])
|
|
}
|
|
})
|
|
|
|
t.Run("cross-vocabulary alias is normalized", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := translateBatchOp(map[string]interface{}{
|
|
"shortcut": "+cells-set-style",
|
|
"input": map[string]interface{}{
|
|
"sheet-id": "sh1", "range": "A1", "vertical-alignment": "center",
|
|
},
|
|
}, testToken, 0)
|
|
if err != nil {
|
|
t.Fatalf("translateBatchOp: %v", err)
|
|
}
|
|
input := got["input"].(map[string]interface{})
|
|
cells := input["cells"].([][]interface{})
|
|
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
|
|
if style["vertical_alignment"] != "middle" {
|
|
t.Fatalf("vertical_alignment = %v, want middle", style["vertical_alignment"])
|
|
}
|
|
})
|
|
|
|
t.Run("underscore input keys are accepted", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := translateBatchOp(map[string]interface{}{
|
|
"shortcut": "+range-copy",
|
|
"input": map[string]interface{}{
|
|
"sheet_id": "sh1", "source_range": "A1:B2", "target_range": "D1", "paste_type": "values",
|
|
},
|
|
}, testToken, 0)
|
|
if err != nil {
|
|
t.Fatalf("translateBatchOp: %v", err)
|
|
}
|
|
input := got["input"].(map[string]interface{})
|
|
if input["range"] != "A1:B2" || input["destination_range"] != "D1" || input["paste_type"] != "value_only" {
|
|
t.Fatalf("translated underscore-key input = %#v", input)
|
|
}
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
shortcut string
|
|
input map[string]interface{}
|
|
want string
|
|
}{
|
|
{
|
|
name: "invalid clear scope",
|
|
shortcut: "+cells-clear",
|
|
input: map[string]interface{}{
|
|
"sheet-id": "sh1", "range": "A1:B2", "scope": "formtas",
|
|
},
|
|
want: "invalid value \"formtas\" for --scope",
|
|
},
|
|
{
|
|
name: "invalid copy paste type",
|
|
shortcut: "+range-copy",
|
|
input: map[string]interface{}{
|
|
"sheet-id": "sh1", "source-range": "A1:B2", "target-range": "D1", "paste-type": "valuez",
|
|
},
|
|
want: "invalid value \"valuez\" for --paste-type",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := translateBatchOp(map[string]interface{}{
|
|
"shortcut": tt.shortcut,
|
|
"input": tt.input,
|
|
}, testToken, 0)
|
|
validationErr := requireValidation(t, err, tt.want)
|
|
if validationErr.Param != "--operations" {
|
|
t.Errorf("param = %q, want --operations", validationErr.Param)
|
|
}
|
|
})
|
|
}
|
|
}
|