mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
5f35c72bd3
* feat(sheets): support partial chart snapshot schemas * feat(sheets): add semantic chart shortcuts * feat(sheets): improve semantic chart workflows * fix(sheets): prefer semantic chart shortcuts * fix(sheets): normalize chart range and flag inputs * fix(sheets): normalize irregular chart ranges * feat(sheets): add chart data update shortcut * feat(sheets): harden chart update workflows * feat(sheets): improve chart creation dimension handling * feat(sheets): add dedicated chart batch shortcuts * fix(sheets): allow chart color theme patches * fix(sheets): persist chart color theme updates * feat(sheets): simplify batch chart operations * fix(sheets): preserve batch scope and cross-sheet chart ranges * feat(sheets): support bubble waterfall and pareto charts * fix(sheets): sync special chart tool schema * feat(sheets): add semantic bubble chart indexes * docs(sheets): sync combined chart workflow guidance * fix(sheets): align combined chart artifacts * feat(sheets): add x-axis number interpretation flag * docs(sheets): validate chart axis semantics * fix(sheets): allow recursive chart update patches * test(sheets): isolate chart create schema check * feat(sheets): refine semantic chart creation * docs(sheets): sync semantic chart guidance * docs(sheets): remove unrelated label position guidance * fix(sheets): support all chart data label combinations * fix(sheets): support numeric x-axis bounds * feat(sheets): support chart y-axis bounds * feat(sheets): add last-point chart label flag * fix(sheets): preserve disabled waterfall stacking * fix(sheets): nest last-point chart label property * fix(sheets): resolve lint and dead-code CI failures - lark_sheet_chart.go: drop redundant chartConfigUpdateInput / chartDataUpdateInput calls in Execute whose result is immediately overwritten by the *FromSnapshot variant (ineffassign); the snapshot variants already re-run the same validation internally. - lark_sheet_chart_test.go: remove Go 1.22+ redundant loop-variable copies (copyloopvar). - batch_op_dispatch.go / lark_sheet_batch_update.go: remove unreachable allowedBatchShortcuts and batchUpdateInput; callers use the lower-level allowedShortcuts and buildBatchUpdatePlan directly. * fix(sheets): validate chart config updates * fix(sheets): sync skill specs and chart schema validation * fix(sheets): resolve chart review follow-ups * fix(sheets): restore the two-color contract * fix(sheets): require at least two chart colors * docs(sheets): expose advanced chart shortcut flags * fix(sheets): address chart review feedback * fix(sheets): surface ignored batch locators * chore(sheets): bump skill version to 3.1.6 * fix(sheets): surface batch warnings consistently * test(sheets): satisfy copyloopvar lint * docs(sheets): sync skill from spec * fix(sheets): harden chart batch updates * fix(sheets): tighten chart update validation * fix(sheets): canonicalize chart ranges and batch targets
349 lines
12 KiB
Go
349 lines
12 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
)
|
|
|
|
// TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json
|
|
// embedded blob is valid JSON and has at least one shortcut/flag entry.
|
|
// If sync_to_consumers.mjs ever ships an empty or broken artifact, this
|
|
// catches it at build time of the test binary.
|
|
func TestFlagSchemas_EmbedParses(t *testing.T) {
|
|
t.Parallel()
|
|
idx, err := loadFlagSchemas()
|
|
if err != nil {
|
|
t.Fatalf("loadFlagSchemas error: %v", err)
|
|
}
|
|
if idx == nil || len(idx.Flags) == 0 {
|
|
t.Fatalf("flag-schemas.json has no entries")
|
|
}
|
|
if idx.SchemaVersion == "" {
|
|
t.Errorf("schema_version missing")
|
|
}
|
|
// Spot-check a couple of canonical entries we know upstream guarantees.
|
|
for _, want := range []string{"+cells-set", "+chart-create", "+batch-update"} {
|
|
if _, ok := idx.Flags[want]; !ok {
|
|
t.Errorf("missing shortcut entry %q (regenerate via sheet-skill-spec/scripts/sync_to_consumers.mjs)", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPrintFlagSchema_ListIntrospectable verifies that calling the
|
|
// closure with an empty flag name returns the JSON listing of
|
|
// introspectable flags for the shortcut.
|
|
func TestPrintFlagSchema_ListIntrospectable(t *testing.T) {
|
|
t.Parallel()
|
|
out, err := printFlagSchemaFor("+cells-set")("")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
var got map[string]interface{}
|
|
if err := json.Unmarshal(out, &got); err != nil {
|
|
t.Fatalf("output not JSON: %v\n%s", err, out)
|
|
}
|
|
if got["shortcut"] != "+cells-set" {
|
|
t.Errorf("shortcut = %v, want +cells-set", got["shortcut"])
|
|
}
|
|
flags, _ := got["introspectable_flags"].([]interface{})
|
|
if len(flags) == 0 || flags[0] != "cells" {
|
|
t.Errorf("introspectable_flags = %v, want [cells]", flags)
|
|
}
|
|
}
|
|
|
|
// TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree verifies a hit on
|
|
// (+chart-create, properties) yields a JSON Schema object with the
|
|
// expected top-level fields.
|
|
func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
|
|
t.Parallel()
|
|
out, err := printFlagSchemaFor("+chart-create")("properties")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
var schema map[string]interface{}
|
|
if err := json.Unmarshal(out, &schema); err != nil {
|
|
t.Fatalf("output not JSON: %v\n%s", err, out)
|
|
}
|
|
if schema["type"] != "object" {
|
|
t.Errorf("schema.type = %v, want object", schema["type"])
|
|
}
|
|
if _, ok := schema["properties"]; !ok {
|
|
t.Errorf("schema missing nested .properties: keys=%v", keysOf(schema))
|
|
}
|
|
}
|
|
|
|
// TestPrintFlagSchema_ChartUpdateIsRecursivePartial keeps introspection aligned
|
|
// with update validation: callers may patch a deeply nested field without
|
|
// resending required siblings from the full chart snapshot.
|
|
func TestPrintFlagSchema_ChartUpdateIsRecursivePartial(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
decode := func(t *testing.T, command, path string) map[string]interface{} {
|
|
t.Helper()
|
|
out, err := printFlagSchemaFor(command)(path)
|
|
if err != nil {
|
|
t.Fatalf("print %s %s: %v", command, path, err)
|
|
}
|
|
var schema map[string]interface{}
|
|
if err := json.Unmarshal(out, &schema); err != nil {
|
|
t.Fatalf("schema is not JSON: %v\n%s", err, out)
|
|
}
|
|
return schema
|
|
}
|
|
|
|
createPlot := decode(t, "+chart-create", "properties.snapshot.plotArea.plot")
|
|
if required, _ := createPlot["required"].([]interface{}); len(required) == 0 {
|
|
t.Fatal("chart-create plot schema must retain required fields")
|
|
}
|
|
|
|
updatePlot := decode(t, "+chart-update", "properties.snapshot.plotArea.plot")
|
|
if _, present := updatePlot["required"]; present {
|
|
t.Fatalf("chart-update plot schema must be partial; required=%v", updatePlot["required"])
|
|
}
|
|
|
|
updateSeriesItem := decode(t, "+chart-update", "properties.snapshot.data.dim2.series.items")
|
|
required, _ := updateSeriesItem["required"].([]interface{})
|
|
if len(required) != 1 || required[0] != "index" {
|
|
t.Fatalf("chart-update replacement array item schema must retain required index; required=%v", required)
|
|
}
|
|
}
|
|
|
|
// TestPrintFlagSchema_UnknownFlagListsAvailable confirms the error
|
|
// message tells the caller which flags exist for the shortcut.
|
|
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
|
|
ve := requireValidation(t, err, "+chart-create")
|
|
if !strings.Contains(ve.Message, "properties") {
|
|
t.Errorf("message should list available flags; got %q", ve.Message)
|
|
}
|
|
if ve.Param != "--flag-name" {
|
|
t.Errorf("param = %q, want --flag-name", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestPrintFlagSchema_UnknownShortcut surfaces a missing shortcut entry.
|
|
func TestPrintFlagSchema_UnknownShortcut(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := printFlagSchemaFor("+not-a-real-shortcut")("")
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown shortcut")
|
|
}
|
|
}
|
|
|
|
// TestShortcuts_AttachesPrintFlagSchema confirms the registration loop
|
|
// in Shortcuts() wires PrintFlagSchema onto each shortcut whose command
|
|
// has a schema entry, and leaves it nil for shortcuts that don't.
|
|
func TestShortcuts_AttachesPrintFlagSchema(t *testing.T) {
|
|
t.Parallel()
|
|
all := Shortcuts()
|
|
withSchema := commandsWithFlagSchema()
|
|
for _, s := range all {
|
|
_, expected := withSchema[s.Command]
|
|
got := s.PrintFlagSchema != nil
|
|
if got != expected {
|
|
t.Errorf("%s: PrintFlagSchema attached=%v, expected=%v", s.Command, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPrintSchema_SystemFlagShortCircuit verifies the framework's
|
|
// --print-schema interception: required flags are relaxed, Validate /
|
|
// Execute are skipped, and the schema JSON appears on stdout.
|
|
func TestPrintSchema_SystemFlagShortCircuit(t *testing.T) {
|
|
t.Parallel()
|
|
// +cells-set has required --range / --cells / --sheet-id; without
|
|
// --print-schema, cobra would reject the call. With --print-schema,
|
|
// it should print the schema and exit cleanly. The PrintFlagSchema
|
|
// closure is normally attached by Shortcuts(), so we attach it here
|
|
// to mirror that registration path.
|
|
sc := CellsSet
|
|
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
|
|
stdout, err := runShortcut(t, sc, []string{"--print-schema", "--flag-name", "cells"})
|
|
if err != nil {
|
|
t.Fatalf("err: %v\nstdout=%s", err, stdout)
|
|
}
|
|
if !strings.Contains(stdout, "\"type\"") {
|
|
t.Errorf("expected JSON Schema with \"type\" key; got=%s", stdout)
|
|
}
|
|
}
|
|
|
|
// TestPrintSchema_ListingWhenNoFlagNameGiven exercises the discovery
|
|
// path: `--print-schema` without `--flag-name` should list the
|
|
// shortcut's introspectable flags as JSON on stdout.
|
|
func TestPrintSchema_ListingWhenNoFlagNameGiven(t *testing.T) {
|
|
t.Parallel()
|
|
sc := CellsSet
|
|
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
|
|
stdout, err := runShortcut(t, sc, []string{"--print-schema"})
|
|
if err != nil {
|
|
t.Fatalf("err: %v\nstdout=%s", err, stdout)
|
|
}
|
|
var got map[string]interface{}
|
|
if err := json.Unmarshal([]byte(stdout), &got); err != nil {
|
|
t.Fatalf("stdout not JSON: %v\n%s", err, stdout)
|
|
}
|
|
flags, _ := got["introspectable_flags"].([]interface{})
|
|
if len(flags) == 0 {
|
|
t.Errorf("introspectable_flags empty: %#v", got)
|
|
}
|
|
}
|
|
|
|
// TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut ensures we don't
|
|
// inject --print-schema onto shortcuts that have no composite flags.
|
|
// +workbook-info is read-only and not in the schema map.
|
|
func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) {
|
|
t.Parallel()
|
|
_, _, err := runShortcutCapturingErr(t, WorkbookInfo, []string{"--url", testURL, "--print-schema"})
|
|
if err == nil {
|
|
t.Fatal("expected unknown flag error")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown flag") {
|
|
t.Errorf("expected 'unknown flag'; got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPrintSchema_UnknownFlagNameIsStructured pins issue #6: an unregistered
|
|
// --flag-name passed to --print-schema must surface as a typed
|
|
// *errs.ValidationError, not a bare error string, so the agent-facing
|
|
// introspection path stays machine-parseable.
|
|
func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) {
|
|
t.Parallel()
|
|
// PrintFlagSchema is wired during registration (shortcuts.go), not on the
|
|
// literal, so replicate that here to make Mount inject the --print-schema /
|
|
// --flag-name system flags.
|
|
sc := CellsSet
|
|
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
|
|
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
|
"--print-schema", "--flag-name", "nonexistent",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected an error for --print-schema with an unregistered flag name")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error type = %T, want a typed *errs.ValidationError", err)
|
|
}
|
|
}
|
|
|
|
func keysOf(m map[string]interface{}) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestPrintSchema_DottedPathSlicing covers --flag-name's dotted-path form,
|
|
// which had no tests at all: disabling the implicit items/oneOf descent, or the
|
|
// explicit "items" segment, broke nothing.
|
|
//
|
|
// The feature exists so agents can pull one subtree out of chart-create's
|
|
// ~1,750-line properties schema instead of paging the whole dump (SKILL.md
|
|
// points at it by name). A silent regression pushes them straight back to full
|
|
// dumps, which is invisible in any output-correctness test.
|
|
func TestPrintSchema_DottedPathSlicing(t *testing.T) {
|
|
t.Parallel()
|
|
print := printFlagSchemaFor("+chart-create")
|
|
|
|
decode := func(t *testing.T, raw []byte) map[string]interface{} {
|
|
t.Helper()
|
|
var node map[string]interface{}
|
|
if err := json.Unmarshal(raw, &node); err != nil {
|
|
t.Fatalf("schema slice is not a JSON object: %v", err)
|
|
}
|
|
return node
|
|
}
|
|
props := func(t *testing.T, node map[string]interface{}) map[string]interface{} {
|
|
t.Helper()
|
|
p, ok := node["properties"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("node has no properties: %v", node)
|
|
}
|
|
return p
|
|
}
|
|
|
|
t.Run("one segment walks into properties", func(t *testing.T) {
|
|
t.Parallel()
|
|
raw, err := print("properties.snapshot")
|
|
if err != nil {
|
|
t.Fatalf("slice failed: %v", err)
|
|
}
|
|
if _, has := props(t, decode(t, raw))["plotArea"]; !has {
|
|
t.Errorf("snapshot subtree should expose plotArea, got %s", raw)
|
|
}
|
|
})
|
|
|
|
t.Run("array levels are descended implicitly", func(t *testing.T) {
|
|
t.Parallel()
|
|
// data.refs is an array; naming the field must land on the ITEM shape,
|
|
// not force the caller to spell ".items".
|
|
raw, err := print("properties.snapshot.data.refs")
|
|
if err != nil {
|
|
t.Fatalf("slice failed: %v", err)
|
|
}
|
|
node := decode(t, raw)
|
|
if node["type"] != "array" {
|
|
t.Errorf("refs should still be the array node, got %v", node["type"])
|
|
}
|
|
deeper, err := print("properties.snapshot.data.refs.value")
|
|
if err != nil {
|
|
t.Fatalf("descending through array items failed: %v", err)
|
|
}
|
|
if len(deeper) == 0 {
|
|
t.Error("expected the item's value field")
|
|
}
|
|
})
|
|
|
|
t.Run("an explicit items segment also works", func(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := print("properties.snapshot.plotArea.axes.items"); err != nil {
|
|
t.Fatalf("explicit items segment failed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("a slice is strictly smaller than the whole flag schema", func(t *testing.T) {
|
|
t.Parallel()
|
|
full, err := print("properties")
|
|
if err != nil {
|
|
t.Fatalf("full dump failed: %v", err)
|
|
}
|
|
slice, err := print("properties.snapshot.plotArea.axes")
|
|
if err != nil {
|
|
t.Fatalf("slice failed: %v", err)
|
|
}
|
|
if len(slice) >= len(full) {
|
|
t.Errorf("slice is %d bytes vs %d for the full schema — slicing saves nothing", len(slice), len(full))
|
|
}
|
|
})
|
|
|
|
t.Run("a miss names the keys actually available", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := print("properties.snapshot.nope")
|
|
if err == nil {
|
|
t.Fatal("want an error for an unknown segment")
|
|
}
|
|
ve := requireValidation(t, err, `no "nope" under properties.snapshot`)
|
|
if !strings.Contains(ve.Message, "plotArea") {
|
|
t.Errorf("the miss must list the reachable keys so the caller can retry without a full dump, got %q", ve.Message)
|
|
}
|
|
if ve.Param != "--flag-name" {
|
|
t.Errorf("param = %q, want --flag-name", ve.Param)
|
|
}
|
|
})
|
|
|
|
t.Run("the underscore spelling of the flag still resolves", func(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := printFlagSchemaFor("+cells-set-style")("border_styles"); err != nil {
|
|
t.Fatalf("underscore flag name should resolve to border-styles: %v", err)
|
|
}
|
|
})
|
|
}
|