Files
larksuite__cli/shortcuts/sheets/flag_ergonomics_test.go
xiongyuanwen-byted 835b52cc88 feat(sheets): cut the top command-error clusters from the 08-18..24 eval batch (#2559)
* feat(sheets): cut the top command-error clusters from the 08-18..24 eval batch

A trace analysis over 14,818 `lark-cli sheets` calls attributed 2,036 command
errors to 57 (subcommand, flag) groups, with the top 6 covering 76%. Four of
them are ours to fix; each is addressed at the layer that produced it.

--styles vocabulary (291 cases, the only group whose retry also failed):

  - Prescribe the border family as a whole. foldBorderFamilyAliases already
    absorbs border / borders / border_<side> / border_<attr>; what still
    reached the error path was the Lark OpenAPI's border_type (FULL_BORDER,
    OUTER_BORDER) and CSS's border_width — real vocabularies with no
    equivalent here, and border_type was the single top field in the group.
    Neither maps unambiguously onto a per-side style/weight/color triple, so
    they get one shared answer, not a silent alias.
  - Prescribe the OpenAPI's nested {range, style:{...}} envelope, plus
    bg_color / fill_color / text_color.
  - Match prescriptions on the key's letters alone, so border_type,
    borderType and border-type are one mistake, not three.
  - Collapse repeated issues in the --styles / --writes folds. One wrong
    field name in a payload styling N cells produced N identical issues, each
    re-listing the full supported vocabulary: the fold meant to save round
    trips was burying its own answer. The defect is now stated once and the
    other locations are named.

--sheets payload (419 cases):

  - Accept dtypes / formats as a positional array. The same pandas habit that
    produces `columns` and `data` produces df.dtypes.tolist(), and the
    payloads were otherwise correct. Only a 1:1 match with `columns` is
    accepted; a length mismatch is rejected rather than guessed.

+csv-put --file (35 cases):

  - Read the value as a path. --file is aliased onto --csv because agents
    reach for it, but the names promise different things — rewriting only the
    name left the path to be written into the sheet as literal text, which the
    file-path guard then rejected, with an error naming a flag the caller
    never typed. The read goes through the same cmdutil.ReadInputFile as
    @file, so the relative-path policy is unchanged and stdin stays the
    out-of-tree route. A value naming nothing readable still falls through to
    the guard, so --file holding literal CSV keeps working.

--help (88 cases of "required flag(s) ... not set"):

  - Mark required flags in the sheets help. MarkFlagRequired only sets a
    completion annotation cobra never renders, so a required flag read exactly
    like an optional one. Sourced from flag-defs, since +chart-create and
    +csv-put deliberately clear that annotation after mounting; a flag cobra
    has put in a one-required group is left unmarked, because neither member
    of such a pair is individually required.

The remaining big group (absolute paths passed to --file / @file, 636 cases)
is deliberately untouched: the cwd-relative policy is a protocol decision, and
that thread is being followed up separately.

* fix(sheets): correct --file alias provenance and mark its value resolved

Two defects in the +csv-put --file rule from the previous commit, both found
in review:

- The alias record was written from the flag-name normalizer, which pflag also
  runs for Lookup and Set — including once with the canonical name right after
  the rewrite, and again on every later lookup. `--file a.csv --csv ./b.csv`
  therefore still counted as "supplied by --file", and an explicit --csv path
  was silently read as a file instead of meeting its guard. The spelling is now
  staged by the normalizer and committed by the flag's Value, which runs once
  per real occurrence, so the last occurrence wins in either order.

- The rewritten value was not marked as read from a source, so a file whose
  contents are themselves path-shaped ("report.csv") failed csvPutInput's shape
  check — a valid CSV rejected as a caller who forgot the @. Marking it makes
  --file behave exactly like --csv @<path> all the way down, which also lets
  the guard skip it on its own rather than through a special case in Validate.

RuntimeContext gains an exported MarkInputResolved for the second half: the bit
already existed for @file / stdin, and a domain that resolves a source itself
needs to set it. No other domain calls it, so nothing else changes.

Also assert the typed contract (Param, Cause) rather than message text alone in
the style-prescription corpus and the collapsed-issue fold, per the repo's
error-test guideline.

* fix(sheets): answer every unreadable --file path under --file, and stage alias provenance only while parsing

Both from self-review of the branch.

An unreadable path passed as --file fell through to the --csv guard, which
answered naming a flag the caller never typed — and for a file that exists but
cannot be opened, prescribed "pass the same path with an @ prefix", which routes
through this very reader and fails identically. Only one case may fall through
now: a value that names nothing AND is not path-shaped, i.e. literal CSV text,
which --file accepted before this rule existed. A path-shaped value naming
nothing, an unreadable file, and a directory each answer under --file.

The alias spelling was staged with no Parsed() guard (the first commit had one;
flagalias.Bind still does). chainFlagAliases looks its aliases up while
installing and pflag normalizes on Lookup, so composing PostMount twice — which
installAliasProvenance explicitly anticipates — replayed "file" through the
already-installed normalizer at mount time, and the next real --csv occurrence
committed it: an explicit --csv path would then be read from disk instead of
meeting its guard. Verified the new regression test fails without the guard.

* fix(sheets): reset alias staging on remount, and assert cause on every --file read error

Second review pass, both valid.

FlagSet.Parsed() stays true once parsing has started, so the guard added in the
last commit only covers a remount that happens BEFORE the first parse. A remount
afterwards — its own alias lookups running through the normalizer the first pass
installed — could still leave a spelling staged for the next occurrence to
commit, which would read an explicit --csv path from disk. Re-running the
install now resets staging, closing the window from the other side. Verified the
new parse/remount/reparse test fails without the reset.

The unreadable-file and directory branches preserve the read error as Cause;
their tests now assert it, matching the sibling that already did.
2026-09-01 15:05:10 +08:00

851 lines
30 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestUnknownFlagFromParseError(t *testing.T) {
t.Parallel()
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --cols", "cols", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagFromParseError(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
// override of the root unknown-flag error: --cols is a semantic guess for
// --range that edit distance can't rank, so the hint must inline the full
// valid-flag list instead of deferring to a --help round trip.
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Int("width", 0, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
}
if strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
}
for _, want := range []string{"--range", "--width"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
}
}
}
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
// the sheets override, with the valid-flag list appended.
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Bool("dry-run", false, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
}
}
}
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
// the per-op locator contract instead of offering a misleading fuzzy guess.
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
t.Parallel()
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
}
if len(verr.Params[0].Suggestions) != 0 {
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
}
})
}
}
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
// special case is scoped to the two sheet-locator flags: any other unknown
// flag on +batch-update keeps the normal did-you-mean behaviour.
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if strings.Contains(verr.Message, "no top-level sheet locator") {
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
}
}
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
t.Parallel()
if got := inlineFlagList(nil); got != "" {
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
}
names := make([]string, inlineFlagListLimit+5)
for i := range names {
names[i] = fmt.Sprintf("flag-%02d", i)
}
got := inlineFlagList(names)
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
}
if strings.Contains(got, names[inlineFlagListLimit]) {
t.Errorf("list should stop at the limit, got %q", got)
}
}
func TestCanonicalEnumValue(t *testing.T) {
t.Parallel()
cases := []struct {
val string
enum []string
want string
}{
{"SUM", []string{"sum", "count"}, "sum"}, // casing
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias: CSS vertical center
{"middle", []string{"left", "center", "right"}, "center"}, // alias: horizontal middle
{"overwite", []string{"append", "overwrite"}, ""}, // typo is NOT canonical
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
}
for _, c := range cases {
if got := canonicalEnumValue(c.val, c.enum); got != c.want {
t.Errorf("canonicalEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
}
}
}
func TestClosestEnumValue(t *testing.T) {
t.Parallel()
cases := []struct {
val string
enum []string
want string
}{
{"SUM", []string{"sum", "count"}, "sum"}, // casing
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias
{"overwite", []string{"append", "overwrite"}, "overwrite"}, // edit distance
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
}
for _, c := range cases {
if got := closestEnumValue(c.val, c.enum); got != c.want {
t.Errorf("closestEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
}
}
}
// TestChainEnumNormalization_UnitContract pins the PreRunE stage in
// isolation: canonical vocabulary is auto-applied, typos error with a
// suggestion (never applied), the framework PreRunE keeps running first,
// and --print-schema skips enum gating entirely.
func TestChainEnumNormalization_UnitContract(t *testing.T) {
t.Parallel()
newCmd := func() (*cobra.Command, *bool) {
cmd := &cobra.Command{Use: "+cells-set-style"}
cmd.Flags().String("vertical-alignment", "", "")
cmd.Flags().Bool("print-schema", false, "")
prevCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error {
prevCalled = true
return nil
}
chainEnumNormalization(cmd)
return cmd, &prevCalled
}
// Alias auto-applied, framework PreRunE preserved.
cmd, prevCalled := newCmd()
cmd.Flags().Set("vertical-alignment", "center")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("center should normalize and pass, got: %v", err)
}
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "middle" {
t.Errorf("vertical-alignment = %q, want rewritten to %q", got, "middle")
}
if !*prevCalled {
t.Error("framework PreRunE must keep running first")
}
// Typo: error with suggestion, value untouched.
cmd, _ = newCmd()
cmd.Flags().Set("vertical-alignment", "botom")
err := cmd.PreRunE(cmd, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("typo should fail with *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(verr.Hint, `"bottom"`) {
t.Errorf("hint should suggest bottom for the typo, got %q", verr.Hint)
}
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "botom" {
t.Errorf("typo must not be rewritten, got %q", got)
}
// --print-schema skips enum gating (pure local introspection).
cmd, _ = newCmd()
cmd.Flags().Set("vertical-alignment", "not-a-value")
cmd.Flags().Set("print-schema", "true")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Errorf("--print-schema must skip enum gating, got: %v", err)
}
}
// shortcutFromRegistry returns the fully wired shortcut (PostMount
// ergonomics included) as Shortcuts() exposes it to the framework.
func shortcutFromRegistry(t *testing.T, command string) common.Shortcut {
t.Helper()
for _, sc := range Shortcuts() {
if sc.Command == command {
return sc
}
}
t.Fatalf("shortcut %q not found in Shortcuts()", command)
return common.Shortcut{}
}
// TestShortcuts_FlagErgonomicsMounted verifies the ergonomics ride every
// mounted sheets command end-to-end: enum vocabulary normalizes on a real
// invocation, and unknown flags answer with the inlined valid-flag list.
func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
t.Parallel()
t.Run("enum alias normalizes through a real run", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--vertical-alignment", "center",
"--dry-run",
})
if err != nil {
t.Fatalf("center should normalize to middle and pass, got: %v", err)
}
if !strings.Contains(stdout, "middle") || strings.Contains(stdout, "center") {
t.Errorf("dry-run body should carry the normalized value, got %q", stdout)
}
})
t.Run("enum typo errors with suggestion", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--vertical-alignment", "botom",
"--dry-run",
})
ve := requireValidation(t, err, `invalid value "botom" for --vertical-alignment`)
if !strings.Contains(ve.Hint, `"bottom"`) {
t.Errorf("hint should suggest bottom, got %q", ve.Hint)
}
})
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--col-size", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--col-size"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
// habitual name with identical value semantics parses as the real flag on a
// mounted command, costing zero round trips (eval: --cols, --file, --name,
// --source/--target each burned an unknown-flag failure plus a --help call).
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
t.Parallel()
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--width", "100",
"--dry-run",
})
if err != nil {
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
}
if !strings.Contains(stdout, "A:D") {
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
}
})
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-create")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--name", "汇总",
"--dry-run",
})
if err != nil {
t.Fatalf("--name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "汇总") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("sheet-rename --new-name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-rename")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--new-name", "授权需求清单",
"--dry-run",
})
if err != nil {
t.Fatalf("--new-name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "授权需求清单") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+range-fill")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--source", "B2",
"--target", "B3:B10",
"--dry-run",
})
if err != nil {
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
}
for _, want := range []string{"B2", "B3:B10"} {
if !strings.Contains(stdout, want) {
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
}
}
})
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+csv-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--start-cell", "A1",
"--file", "a,b\n1,2",
"--dry-run",
})
if err != nil {
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
}
if !strings.Contains(stdout, "a,b") {
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
}
})
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A:C",
"--size", "120",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
}
if !strings.Contains(stdout, "120") {
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
}
})
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+rows-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "1:3",
"--size", "36",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
}
})
t.Run("cells-set --values parses as --cells", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "C1",
// The gspread payload verbatim: a plain values matrix, not cell
// objects. It rides through unchanged because the alias fixes the
// name and normalizeCellsFlagValue lifts the bare scalar — the two
// halves have to hold together for the silent tier to be correct.
"--values", `[["工作内容"]]`,
"--dry-run",
})
if err != nil {
t.Fatalf("--values should alias to --cells and pass, got: %v", err)
}
input := decodeToolInput(t, decodeDryRunFirstCall(t, stdout), "set_cell_range")
cells, _ := json.Marshal(input["cells"])
if string(cells) != `[[{"value":"工作内容"}]]` {
t.Errorf("cells = %s, want the lifted [[{\"value\":\"工作内容\"}]]", cells)
}
})
t.Run("alias never shadows a registered flag", func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+csv-put"}
c.Flags().String("csv", "", "")
c.Flags().String("file", "", "") // hypothetical real flag wins
chainFlagAliases(c)
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
t.Fatalf("parse: %v", err)
}
if got, _ := c.Flags().GetString("file"); got != "x" {
t.Errorf("registered --file should keep its own value, got %q", got)
}
if got, _ := c.Flags().GetString("csv"); got != "" {
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
}
})
}
func TestMapFlagView_AliasResolutionIsDeterministic(t *testing.T) {
t.Parallel()
t.Run("multiple aliases use stable lexical precedence", func(t *testing.T) {
t.Parallel()
fv := newMapFlagViewForCommand("+sheet-rename", map[string]interface{}{
"name": "first",
"new-name": "second",
})
for range 100 {
if got := fv.Str("title"); got != "first" {
t.Fatalf("title = %q, want stable alias value %q", got, "first")
}
}
})
t.Run("underscored canonical name resolves alias", func(t *testing.T) {
t.Parallel()
fv := newMapFlagViewForCommand("+chart-config-update", map[string]interface{}{
"x-axis": "Month",
})
if got := fv.Str("x_axis_title"); got != "Month" {
t.Fatalf("x_axis_title = %q, want %q", got, "Month")
}
})
}
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
// names whose fix is not a rename answer with the exact correct form, so the
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
// --help calls, +dim-insert kept failing even after reading help).
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
t.Parallel()
cases := []struct {
command string
args []string
wrong string
wantHint []string
// rejectHint pins what a prescription must NOT name — used where the
// obvious wording would steer into a deprecated flag.
rejectHint []string
}{
{
command: "+dim-insert",
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
wrong: "--dimension",
wantHint: []string{"--position", "--count"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
wrong: "--frozen-rows",
// Must prescribe the CURRENT spelling: --dimension/--count is
// retired and hidden from --help, so a hint naming it would point at
// a flag missing from the same error's valid-flags list.
wantHint: []string{"--rows N"},
rejectHint: []string{"--dimension", "--count"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
wrong: "--bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+sheet-copy",
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
wrong: "--new-sheet-name",
wantHint: []string{"--title", "source sheet"},
},
{
command: "+table-put",
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
wrong: "--start-cell",
wantHint: []string{`"start_cell"`, "+csv-put"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-row-count", "1"},
wrong: "--frozen-row-count",
wantHint: []string{"--rows N"},
rejectHint: []string{"--dimension", "--count"},
},
{
// The parse error reports the flag as typed: the underscore
// spelling must hit the same curated entry as the hyphenated one.
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen_rows", "2"},
wrong: "--frozen_rows",
wantHint: []string{"--rows N"},
rejectHint: []string{"--dimension", "--count"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--font-bold", "true"},
wrong: "--font-bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bg-color", "#FFF"},
wrong: "--bg-color",
wantHint: []string{"--background-color"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--wrap-strategy", "overflow"},
wrong: "--wrap-strategy",
wantHint: []string{"--word-wrap"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-all", "thin"},
wrong: "--border-all",
wantHint: []string{"--border-styles", `"all"`},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-top", "thin"},
wrong: "--border-top",
wantHint: []string{"--border-styles", `"top"`},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-color", "#000"},
wrong: "--border-color",
wantHint: []string{"--border-styles", "color"},
},
{
command: "+chart-create-basic",
args: []string{"--url", testURL, "--sheet-name", "s", "--position", "F2"},
wrong: "--position",
wantHint: []string{"--anchor-cell F2", "--width", "--height"},
},
{
command: "+chart-create-basic",
args: []string{"--url", testURL, "--sheet-name", "s", "--show-labels", "true"},
wrong: "--show-labels",
wantHint: []string{"--data-labels value", "value_category_percentage", "series", "none"},
},
{
command: "+chart-config-update",
args: []string{"--url", testURL, "--sheet-name", "s", "--show-labels", "true"},
wrong: "--show-labels",
wantHint: []string{"--data-labels value", "value_category_percentage", "series", "none"},
},
}
for _, tc := range cases {
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, tc.command)
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
for _, want := range tc.wantHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
// The valid-flags list is appended to the same Hint, so only the
// prescription itself is checked for banned wording.
prescription, _, _ := strings.Cut(ve.Hint, "; valid flags:")
for _, banned := range tc.rejectHint {
if strings.Contains(prescription, banned) {
t.Errorf("prescription must not steer to %q, got %q", banned, prescription)
}
}
// A curated prescription must not ship contradicting edit-distance
// candidates (--font-bold used to carry --font-color/--font-line/
// --font-size in params while the fix is --font-weight).
for _, p := range ve.Params {
if len(p.Suggestions) > 0 {
t.Errorf("curated prescription should drop edit-distance suggestions, got %v", p.Suggestions)
}
}
})
}
}
// TestShortcuts_RequiredFlagsMarkedInHelp pins the required marker on the
// mounted commands. It is a sheets-local decoration (chainRequiredFlagHelp),
// so the assertions live here rather than against the framework: cobra renders
// no marker of its own, and the two commands that relax the annotation after
// mounting must still be answered from flag-defs.
func TestShortcuts_RequiredFlagsMarkedInHelp(t *testing.T) {
t.Parallel()
usageOf := func(t *testing.T, command, flag string) string {
t.Helper()
parent, _, _, _ := newTestRig(t, shortcutFromRegistry(t, command))
cmd, _, err := parent.Find([]string{command})
if err != nil {
t.Fatalf("Find(%q) error = %v", command, err)
}
fl := cmd.Flags().Lookup(flag)
if fl == nil {
t.Fatalf("%s has no --%s", command, flag)
}
return fl.Usage
}
t.Run("a required flag says so", func(t *testing.T) {
t.Parallel()
if got := usageOf(t, "+workbook-create", "title"); !strings.HasPrefix(got, "(required) ") {
t.Errorf("--title usage = %q, want the required marker", got)
}
})
t.Run("an optional flag is left alone", func(t *testing.T) {
t.Parallel()
if got := usageOf(t, "+workbook-create", "folder-token"); strings.Contains(got, "(required)") {
t.Errorf("--folder-token usage = %q, want no required marker", got)
}
})
t.Run("a relaxed-but-still-required flag says so", func(t *testing.T) {
t.Parallel()
// +chart-create clears the cobra annotation so --print-example can run
// without it; --properties is still required on every other path.
if got := usageOf(t, "+chart-create", "properties"); !strings.HasPrefix(got, "(required) ") {
t.Errorf("--properties usage = %q, want the required marker", got)
}
})
t.Run("a one-required pair marks neither member", func(t *testing.T) {
t.Parallel()
// +csv-put takes --start-cell OR its --range alias, so neither is
// individually required — saying otherwise would be a false statement.
for _, flag := range []string{"start-cell", "range"} {
if got := usageOf(t, "+csv-put", flag); strings.Contains(got, "(required)") {
t.Errorf("--%s usage = %q, want no required marker", flag, got)
}
}
if got := usageOf(t, "+csv-put", "csv"); !strings.HasPrefix(got, "(required) ") {
t.Errorf("--csv usage = %q, want the required marker", got)
}
})
}
// TestCsvPut_FileAliasProvenance pins which spelling a value is attributed to
// when both are on one command line. The record is committed by the flag's
// Value on each real occurrence, so the LAST occurrence wins — pflag also
// normalizes names on Lookup and Set (the framework's own input resolution
// looks --csv up before Validate runs), and attributing those would either
// lose a legitimate --file or steal an explicit --csv.
func TestCsvPut_FileAliasProvenance(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
t.Fatal(err)
}
run := func(t *testing.T, extra ...string) (string, error) {
t.Helper()
args := append([]string{"--url", testURL, "--sheet-name", "s", "--start-cell", "A1"}, extra...)
stdout, _, err := runShortcutCapturingErr(t, shortcutFromRegistry(t, "+csv-put"), append(args, "--dry-run"))
return stdout, err
}
t.Run("--file alone reads the file", func(t *testing.T) {
stdout, err := run(t, "--file", "./data.csv")
if err != nil {
t.Fatalf("--file should read the path, got: %v", err)
}
if !strings.Contains(stdout, `a,b`) {
t.Errorf("dry-run body should carry the file contents, got %q", stdout)
}
})
t.Run("a later --csv occurrence keeps its own semantics", func(t *testing.T) {
// The last occurrence supplied the value and it was typed --csv, so the
// path must hit the --csv guard rather than being read as a file.
_, err := run(t, "--file", "./data.csv", "--csv", "./data.csv")
requireValidation(t, err, "is an existing file, not inline CSV")
})
t.Run("a later --file occurrence reads the file", func(t *testing.T) {
stdout, err := run(t, "--csv", "./data.csv", "--file", "./data.csv")
if err != nil {
t.Fatalf("the last occurrence was --file, so it should read the path, got: %v", err)
}
if !strings.Contains(stdout, `a,b`) {
t.Errorf("dry-run body should carry the file contents, got %q", stdout)
}
})
}
// TestCsvPut_FileAliasProvenance_DoubleMount pins the staging guard. The
// ergonomics chain looks its aliases up while installing, and pflag normalizes
// a name on Lookup — so composing PostMount twice replays "file" through an
// already-installed normalizer at mount time. Without the Parsed() guard that
// arms the pending spelling before parsing starts, and the next real --csv
// occurrence commits it: an explicit --csv path would be read from disk instead
// of meeting its guard.
func TestCsvPut_FileAliasProvenance_DoubleMount(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
t.Fatal(err)
}
t.Run("mounted twice before parsing", func(t *testing.T) {
sc := shortcutFromRegistry(t, "+csv-put")
sc.PostMount = withFlagErgonomics(sc.PostMount) // a second, redundant pass
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL, "--sheet-name", "s", "--start-cell", "A1",
"--csv", "./data.csv", "--dry-run",
})
requireValidation(t, err, "is an existing file, not inline CSV")
})
t.Run("remounted after a parse", func(t *testing.T) {
// Parsed() stays true once parsing has started, so a remount at that
// point can stage through the normalizer the first pass installed.
// Re-running the install resets staging, which is what keeps the next
// parse's --csv occurrence from inheriting it.
parent, _, _, _ := newTestRig(t, shortcutFromRegistry(t, "+csv-put"))
parent.SetArgs([]string{"+csv-put", "--url", testURL, "--sheet-name", "s",
"--start-cell", "A1", "--file", "./data.csv", "--dry-run"})
if err := parent.Execute(); err != nil {
t.Fatalf("first run: %v", err)
}
cmd, _, err := parent.Find([]string{"+csv-put"})
if err != nil {
t.Fatalf("Find: %v", err)
}
withFlagErgonomics(nil)(cmd)
parent.SetArgs([]string{"+csv-put", "--url", testURL, "--sheet-name", "s",
"--start-cell", "A1", "--csv", "./data.csv", "--dry-run"})
requireValidation(t, parent.Execute(), "is an existing file, not inline CSV")
})
}