mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
835b52cc88
* 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.
653 lines
26 KiB
Go
653 lines
26 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ─── styles acceptance contract ───────────────────────────────────────
|
|
//
|
|
// Two closure properties that turn the --styles acceptance surface from
|
|
// "endless patching" into a locked contract (07-20 rerun lesson: the
|
|
// redesign moved traffic onto the payload path while the flag path's
|
|
// forgiveness layers stayed behind):
|
|
//
|
|
// 1. Vocabulary parity — every style the flag path (+cells-set-style)
|
|
// can express must be accepted verbatim by the payload path.
|
|
// 2. Prior corpus — every model spelling observed in eval traces must
|
|
// either normalize to the canonical form or produce a targeted
|
|
// prescription. Silent ignoring and bare rejection are both bugs.
|
|
// New eval finding → add a corpus row → fix → locked forever.
|
|
|
|
// acceptStyleItem runs one cell_styles item through the styles-put pipeline
|
|
// and returns the emitted cell prototype (cell_styles/border_styles) or the
|
|
// error.
|
|
func acceptStyleItem(t *testing.T, fields map[string]interface{}) (map[string]interface{}, error) {
|
|
t.Helper()
|
|
item := map[string]interface{}{"range": "A1:B2"}
|
|
for k, v := range fields {
|
|
item[k] = v
|
|
}
|
|
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{
|
|
"name": "S1",
|
|
"cell_styles": []interface{}{item},
|
|
}},
|
|
}), testToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
|
cells := input["cells"].([][]interface{})
|
|
return cells[0][0].(map[string]interface{}), nil
|
|
}
|
|
|
|
// TestStylesAcceptance_VocabularyParity locks property 1: iterate the
|
|
// +cells-set-style flag vocabulary from flag-defs and assert the payload
|
|
// path accepts each field with a valid value and emits it.
|
|
func TestStylesAcceptance_VocabularyParity(t *testing.T) {
|
|
t.Parallel()
|
|
defs, err := loadFlagDefs()
|
|
if err != nil {
|
|
t.Fatalf("loadFlagDefs: %v", err)
|
|
}
|
|
spec, ok := defs["+cells-set-style"]
|
|
if !ok {
|
|
t.Fatal("no +cells-set-style flag defs")
|
|
}
|
|
sample := func(df flagDef) interface{} {
|
|
if len(df.Enum) > 0 {
|
|
return df.Enum[0]
|
|
}
|
|
switch df.Type {
|
|
case "float64", "int":
|
|
return float64(12)
|
|
}
|
|
switch df.Name {
|
|
case "font-family":
|
|
return "Arial"
|
|
case "number-format":
|
|
return "0.00"
|
|
default: // colors and any future string field
|
|
return "#112233"
|
|
}
|
|
}
|
|
for _, df := range spec.Flags {
|
|
if df.Kind != "own" || df.Name == "range" {
|
|
continue
|
|
}
|
|
t.Run(df.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
field := strings.ReplaceAll(df.Name, "-", "_")
|
|
var value interface{}
|
|
if df.Name == "border-styles" {
|
|
value = map[string]interface{}{"all": map[string]interface{}{"style": "solid"}}
|
|
} else {
|
|
value = sample(df)
|
|
}
|
|
proto, err := acceptStyleItem(t, map[string]interface{}{field: value})
|
|
if err != nil {
|
|
t.Fatalf("payload path rejects flag-path field %s: %v", field, err)
|
|
}
|
|
if df.Name == "border-styles" {
|
|
if _, ok := proto["border_styles"].(map[string]interface{}); !ok {
|
|
t.Fatalf("border_styles not emitted: %v", proto)
|
|
}
|
|
return
|
|
}
|
|
cs, _ := proto["cell_styles"].(map[string]interface{})
|
|
if cs == nil || cs[field] == nil {
|
|
t.Fatalf("field %s silently dropped: %v", field, proto)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// stylesPriorCorpus is the observed-model-spelling corpus (source: eval
|
|
// batches 2026-07-08 → 07-20). Every row must either normalize (checked via
|
|
// wantCell) or produce a targeted prescription (wantErr). Add a row for every
|
|
// new spelling an eval surfaces — never let one be silently ignored.
|
|
var stylesPriorCorpus = []struct {
|
|
name string
|
|
fields map[string]interface{}
|
|
wantErr string // "" = must be accepted
|
|
check func(proto map[string]interface{}) string // "" = ok, else failure detail
|
|
}{
|
|
// border family (07-20: largest cluster)
|
|
{name: "borders attr-keyed means all sides",
|
|
fields: map[string]interface{}{"borders": map[string]interface{}{"style": "solid", "color": "#DDDDDD"}},
|
|
check: wantBorder("top", "style", "solid")},
|
|
{name: "border side-keyed",
|
|
fields: map[string]interface{}{"border": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}}},
|
|
check: wantBorder("top", "style", "solid")},
|
|
{name: "border_bottom object",
|
|
fields: map[string]interface{}{"border_bottom": map[string]interface{}{"style": "solid"}},
|
|
check: wantBorder("bottom", "style", "solid")},
|
|
{name: "border_style weight-vocabulary means thin solid",
|
|
fields: map[string]interface{}{"border_style": "thin"},
|
|
check: wantBorder("top", "weight", "thin")},
|
|
{name: "border_style style-vocabulary",
|
|
fields: map[string]interface{}{"border_style": "dashed"},
|
|
check: wantBorder("top", "style", "dashed")},
|
|
{name: "border_color scalar",
|
|
fields: map[string]interface{}{"border_color": "#FF0000"},
|
|
check: wantBorder("top", "color", "#FF0000")},
|
|
{name: "border_top_color flattened",
|
|
fields: map[string]interface{}{"border_top_color": "#FF0000"},
|
|
check: wantBorder("top", "color", "#FF0000")},
|
|
{name: "border_left_weight flattened",
|
|
fields: map[string]interface{}{"border_left_weight": "thin"},
|
|
check: wantBorder("left", "weight", "thin")},
|
|
{name: "border_styles invalid side prescribed",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"outer": map[string]interface{}{"style": "solid"}}},
|
|
wantErr: "not a valid side"},
|
|
// wrap family
|
|
{name: "wrap_text boolean", fields: map[string]interface{}{"wrap_text": true}, check: wantStyle("word_wrap", "auto-wrap")},
|
|
{name: "text_wrap string", fields: map[string]interface{}{"text_wrap": "auto-wrap"}, check: wantStyle("word_wrap", "auto-wrap")},
|
|
{name: "word_wrap false", fields: map[string]interface{}{"word_wrap": false}, check: wantStyle("word_wrap", "overflow")},
|
|
// alignment family
|
|
{name: "horizontal_align shorthand", fields: map[string]interface{}{"horizontal_align": "center"}, check: wantStyle("horizontal_alignment", "center")},
|
|
{name: "valign shorthand", fields: map[string]interface{}{"valign": "top"}, check: wantStyle("vertical_alignment", "top")},
|
|
{name: "CSS center for vertical", fields: map[string]interface{}{"vertical_alignment": "center"}, check: wantStyle("vertical_alignment", "middle")},
|
|
{name: "casing normalized", fields: map[string]interface{}{"font_weight": "BOLD"}, check: wantStyle("font_weight", "bold")},
|
|
// weight vocabulary in the FULL nested form's style slot (07-21 rerun:
|
|
// the dominant residual — 8 tasks wrote border_styles.<side>.style:"thin")
|
|
{name: "full-form thin in style slot",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "thin"}}},
|
|
check: wantBorder("top", "weight", "thin")},
|
|
{name: "full-form all-shorthand medium in style slot",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"all": map[string]interface{}{"style": "medium"}}},
|
|
check: wantBorder("bottom", "weight", "medium")},
|
|
// border VALUE vocabulary (08-11 trace tally over 596 traces): the words
|
|
// that actually recur are hair (476 hits / 19 tasks in the weight slot,
|
|
// 76 / 2 in the style slot) and a numeric width; openpyxl's line-style
|
|
// list and every other library's spelling scored zero and stay rejected.
|
|
{name: "openpyxl hair in the weight slot means thin",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid", "weight": "hair"}}},
|
|
check: wantBorder("top", "weight", "thin")},
|
|
{name: "openpyxl hair in the style slot means a thin solid line",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "hair"}}},
|
|
check: wantAll(wantBorder("top", "weight", "thin"),
|
|
wantBorder("top", "style", "solid"))},
|
|
{name: "flattened border_style hair",
|
|
fields: map[string]interface{}{"border_style": "hair"},
|
|
check: wantBorder("top", "weight", "thin")},
|
|
{name: "numeric weight reads as a line width",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid", "weight": float64(1)}}},
|
|
check: wantBorder("top", "weight", "thin")},
|
|
{name: "numeric weight as string",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid", "weight": "2"}}},
|
|
check: wantBorder("top", "weight", "medium")},
|
|
{name: "Google Sheets width key aliases to weight",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid", "width": float64(3)}}},
|
|
check: wantBorder("top", "weight", "thick")},
|
|
{name: "unobserved openpyxl line style stays rejected",
|
|
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "mediumDashed"}}},
|
|
wantErr: "is invalid"},
|
|
// side-first word order + Google Sheets wrap word (07-21 evening batch)
|
|
{name: "side-first bottom_border object",
|
|
fields: map[string]interface{}{"bottom_border": map[string]interface{}{"style": "solid"}},
|
|
check: wantBorder("bottom", "style", "solid")},
|
|
{name: "side-first bottom_border_style scalar",
|
|
fields: map[string]interface{}{"bottom_border_style": "solid"},
|
|
check: wantBorder("bottom", "style", "solid")},
|
|
{name: "wrap_strategy aliases to word_wrap",
|
|
fields: map[string]interface{}{"wrap_strategy": "auto-wrap"},
|
|
check: wantStyle("word_wrap", "auto-wrap")},
|
|
// 08-18..24 batch. The border family's remaining spellings come from the
|
|
// Lark OpenAPI (border_type: FULL_BORDER / OUTER_BORDER) and CSS
|
|
// (border_width) — real vocabularies, but neither maps onto a per-side
|
|
// style/weight/color triple, so they stay prescriptions. The nested
|
|
// {range, style:{…}} envelope is the OpenAPI request shape copied one
|
|
// level too deep.
|
|
{name: "border_type prescribed", fields: map[string]interface{}{"border_type": "solid"},
|
|
wantErr: "there is no border_type / border_width field"},
|
|
{name: "camelCase borderType prescribed", fields: map[string]interface{}{"borderType": "FULL_BORDER"},
|
|
wantErr: "borders go in border"},
|
|
{name: "kebab border-style prescribed", fields: map[string]interface{}{"border-style": "solid"},
|
|
wantErr: "borders go in border"},
|
|
{name: "border_width prescribed", fields: map[string]interface{}{"border_width": float64(1)},
|
|
wantErr: "borders go in border"},
|
|
{name: "nested style envelope prescribed",
|
|
fields: map[string]interface{}{"style": map[string]interface{}{"font_weight": "bold"}},
|
|
wantErr: "no nested style object"},
|
|
{name: "bg_color prescribed", fields: map[string]interface{}{"bg_color": "#FFFFFF"},
|
|
wantErr: "the cell fill is background_color"},
|
|
{name: "text_color prescribed", fields: map[string]interface{}{"text_color": "#000000"},
|
|
wantErr: "the text color is font_color"},
|
|
// prescriptions (ambiguous / unsupported / typo)
|
|
{name: "fore_color prescribed", fields: map[string]interface{}{"fore_color": "#F00"}, wantErr: "ambiguous"},
|
|
{name: "indent rejected not ignored", fields: map[string]interface{}{"indent": float64(2)}, wantErr: "not a supported style field"},
|
|
{name: "unknown field carries did-you-mean and the field list",
|
|
fields: map[string]interface{}{"fontcolor": "#000000"}, wantErr: `did you mean "font_color"`},
|
|
{name: "enum typo gets did-you-mean", fields: map[string]interface{}{"vertical_alignment": "botom"}, wantErr: "did you mean"},
|
|
}
|
|
|
|
func wantStyle(field, want string) func(map[string]interface{}) string {
|
|
return func(proto map[string]interface{}) string {
|
|
cs, _ := proto["cell_styles"].(map[string]interface{})
|
|
if cs == nil || cs[field] != want {
|
|
return fmt.Sprintf("cell_styles.%s = %v, want %q", field, cs[field], want)
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func wantBorder(side, attr, want string) func(map[string]interface{}) string {
|
|
return func(proto map[string]interface{}) string {
|
|
bs, _ := proto["border_styles"].(map[string]interface{})
|
|
sideObj, _ := bs[side].(map[string]interface{})
|
|
if sideObj == nil || sideObj[attr] != want {
|
|
return fmt.Sprintf("border_styles.%s.%s = %v, want %q", side, attr, sideObj[attr], want)
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// wantAll reports the first failing check, so one corpus row can pin every
|
|
// field a rewrite touches (a thickness word in the style slot moves the word
|
|
// to weight AND defaults the line type — asserting one of the two leaves the
|
|
// other free to regress).
|
|
func wantAll(checks ...func(map[string]interface{}) string) func(map[string]interface{}) string {
|
|
return func(proto map[string]interface{}) string {
|
|
for _, check := range checks {
|
|
if detail := check(proto); detail != "" {
|
|
return detail
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func TestStylesAcceptance_PriorCorpus(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range stylesPriorCorpus {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
proto, err := acceptStyleItem(t, tc.fields)
|
|
if tc.wantErr != "" {
|
|
// A prescription is only usable if it is also typed: an agent
|
|
// reads Param to know which flag to fix, and the message alone
|
|
// would keep passing if that attribution regressed.
|
|
ve := requireValidation(t, err, tc.wantErr)
|
|
if ve.Param != "--styles" {
|
|
t.Errorf("Param = %q, want --styles", ve.Param)
|
|
}
|
|
if ve.Cause == nil {
|
|
t.Error("the prescription should keep the underlying error as Cause")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("corpus spelling rejected: %v", err)
|
|
}
|
|
if detail := tc.check(proto); detail != "" {
|
|
t.Fatal(detail)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStylesPut_CoalescesSameStyleRanges pins the declarative-spec
|
|
// optimization: per-row entries with the identical style fuse into one
|
|
// rectangle, so row-by-row specs (07-21 rerun: 184/203/861-op expansions
|
|
// against the 100-op cap) no longer hit the cap.
|
|
func TestStylesPut_CoalescesSameStyleRanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("150 same-style rows fuse into one stamp", func(t *testing.T) {
|
|
t.Parallel()
|
|
entries := make([]interface{}, 0, 150)
|
|
for r := 1; r <= 150; r++ {
|
|
entries = append(entries, map[string]interface{}{
|
|
"range": fmt.Sprintf("A%d:F%d", r, r), "font_weight": "bold",
|
|
})
|
|
}
|
|
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": entries}},
|
|
}), testToken)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ops) != 1 {
|
|
t.Fatalf("got %d ops, want 1 fused stamp", len(ops))
|
|
}
|
|
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
|
if input["range"] != "A1:F150" {
|
|
t.Fatalf("range = %v, want A1:F150", input["range"])
|
|
}
|
|
})
|
|
|
|
t.Run("different styles stay separate", func(t *testing.T) {
|
|
t.Parallel()
|
|
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
|
map[string]interface{}{"range": "A1:F1", "font_weight": "bold"},
|
|
map[string]interface{}{"range": "A2:F2", "background_color": "#EEEEEE"},
|
|
}}},
|
|
}), testToken)
|
|
if err != nil || len(ops) != 2 {
|
|
t.Fatalf("ops=%d err=%v, want 2", len(ops), err)
|
|
}
|
|
})
|
|
|
|
t.Run("horizontal fuse with same rows", func(t *testing.T) {
|
|
t.Parallel()
|
|
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
|
map[string]interface{}{"range": "A1:C5", "font_weight": "bold"},
|
|
map[string]interface{}{"range": "D1:F5", "font_weight": "bold"},
|
|
}}},
|
|
}), testToken)
|
|
if err != nil || len(ops) != 1 {
|
|
t.Fatalf("ops=%d err=%v, want 1", len(ops), err)
|
|
}
|
|
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
|
if input["range"] != "A1:F5" {
|
|
t.Fatalf("range = %v, want A1:F5", input["range"])
|
|
}
|
|
})
|
|
|
|
// The cases above all pin that adjacent ranges DO fuse. The dangerous
|
|
// direction is the other one: coalescing rewrites a declarative spec into
|
|
// bigger rectangles, so a too-generous adjacency rule would paint cells the
|
|
// caller never named — silently, and only visible in the finished sheet.
|
|
// Widening the `+1` touch test in union() to `+2` passes every test above.
|
|
t.Run("a one-row gap is not fused across", func(t *testing.T) {
|
|
t.Parallel()
|
|
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
|
map[string]interface{}{"range": "A1:C1", "font_weight": "bold"},
|
|
map[string]interface{}{"range": "A3:C3", "font_weight": "bold"},
|
|
}}},
|
|
}), testToken)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ops) != 2 {
|
|
t.Fatalf("ops=%d, want 2 — row 2 was never named and must not be styled", len(ops))
|
|
}
|
|
})
|
|
|
|
t.Run("a one-column gap is not fused across", func(t *testing.T) {
|
|
t.Parallel()
|
|
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
|
map[string]interface{}{"range": "A1:B5", "font_weight": "bold"},
|
|
map[string]interface{}{"range": "D1:E5", "font_weight": "bold"},
|
|
}}},
|
|
}), testToken)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ops) != 2 {
|
|
t.Fatalf("ops=%d, want 2 — column C was never named and must not be styled", len(ops))
|
|
}
|
|
})
|
|
|
|
// The general property behind both: whatever coalescing does to the shape
|
|
// of the stamps, the SET of cells it covers must be exactly the set the
|
|
// caller named. Checked over a mix of touching, overlapping and separated
|
|
// rectangles so it constrains the merge rule rather than one example.
|
|
t.Run("coverage is preserved exactly", func(t *testing.T) {
|
|
t.Parallel()
|
|
inputs := []string{
|
|
"A1:C1", "A2:C2", // touching vertically -> may fuse
|
|
"E1:F2", "E3:F4", // touching vertically, different block
|
|
"A5:C5", // separated from A2:C2 by row 3-4 in columns A-C
|
|
"B2:D3", // overlaps the first block
|
|
"H10:H10",
|
|
}
|
|
entries := make([]interface{}, 0, len(inputs))
|
|
for _, r := range inputs {
|
|
entries = append(entries, map[string]interface{}{"range": r, "font_weight": "bold"})
|
|
}
|
|
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": entries}},
|
|
}), testToken)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
want := map[[2]int]bool{}
|
|
for _, r := range inputs {
|
|
addRangeCells(t, want, r)
|
|
}
|
|
got := map[[2]int]bool{}
|
|
for _, op := range ops {
|
|
input := op.(map[string]interface{})["input"].(map[string]interface{})
|
|
addRangeCells(t, got, input["range"].(string))
|
|
}
|
|
for cell := range want {
|
|
if !got[cell] {
|
|
t.Errorf("cell %v was named but no stamp covers it", cell)
|
|
}
|
|
}
|
|
for cell := range got {
|
|
if !want[cell] {
|
|
t.Errorf("cell %v is stamped but was never named by the caller", cell)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// addRangeCells records every (col,row) an A1 rectangle covers, so a test can
|
|
// compare what a spec named against what the expanded stamps actually touch.
|
|
func addRangeCells(t *testing.T, set map[[2]int]bool, rangeStr string) {
|
|
t.Helper()
|
|
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(rangeStr)
|
|
if err != nil {
|
|
t.Fatalf("bad range %q in test data: %v", rangeStr, err)
|
|
}
|
|
for c := c1; c <= c2; c++ {
|
|
for r := r1; r <= r2; r++ {
|
|
set[[2]int{c, r}] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTypedCellsHabitualKeys pins the typed --cells cell-object fixes
|
|
// (recurring server-side 900015206 across 07-20/07-21 reruns).
|
|
func TestTypedCellsHabitualKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("style object rewrites to cell_styles through batch", func(t *testing.T) {
|
|
t.Parallel()
|
|
translated, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
|
"sheet_name": "S1", "range": "A1",
|
|
"cells": []interface{}{[]interface{}{map[string]interface{}{
|
|
"value": "x", "style": map[string]interface{}{"font_weight": "bold"},
|
|
}}},
|
|
}), testToken, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
input := translated["input"].(map[string]interface{})
|
|
cell := input["cells"].([]interface{})[0].([]interface{})[0].(map[string]interface{})
|
|
cs, _ := cell["cell_styles"].(map[string]interface{})
|
|
if cs == nil || cs["font_weight"] != "bold" {
|
|
t.Fatalf("cell = %v, want cell_styles.font_weight bold", cell)
|
|
}
|
|
if _, has := cell["style"]; has {
|
|
t.Fatalf("style key must be renamed, got %v", cell)
|
|
}
|
|
})
|
|
|
|
t.Run("type key gets a prescription", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
|
"sheet_name": "S1", "range": "A1",
|
|
"cells": []interface{}{[]interface{}{map[string]interface{}{
|
|
"value": "x", "type": "text",
|
|
}}},
|
|
}), testToken, 0)
|
|
requireValidation(t, err, "not a cell field")
|
|
})
|
|
}
|
|
|
|
// TestStylesAcceptance_ResizeAndMergeCorpus extends the corpus to the
|
|
// row/col_sizes and cell_merges sections.
|
|
func TestStylesAcceptance_ResizeAndMergeCorpus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runSection := func(section string, entry interface{}) ([]interface{}, error) {
|
|
return stylesPutOperations(stylesPutView(map[string]interface{}{
|
|
"styles": []interface{}{map[string]interface{}{
|
|
"name": "S1",
|
|
section: []interface{}{entry},
|
|
}},
|
|
}), testToken)
|
|
}
|
|
pixelValue := func(t *testing.T, ops []interface{}, key string) interface{} {
|
|
t.Helper()
|
|
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
|
block, _ := input[key].(map[string]interface{})
|
|
if block == nil || block["type"] != "pixel" {
|
|
t.Fatalf("%s = %v, want pixel block", key, input[key])
|
|
}
|
|
return block["value"]
|
|
}
|
|
|
|
t.Run("size alone implies pixel", func(t *testing.T) {
|
|
t.Parallel()
|
|
ops, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "size": float64(36)})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if v := pixelValue(t, ops, "resize_height"); v != 36 {
|
|
t.Fatalf("value = %v, want 36", v)
|
|
}
|
|
})
|
|
|
|
t.Run("width alone implies pixel on col_sizes", func(t *testing.T) {
|
|
t.Parallel()
|
|
ops, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "width": float64(120)})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if v := pixelValue(t, ops, "resize_width"); v != 120 {
|
|
t.Fatalf("value = %v, want 120", v)
|
|
}
|
|
})
|
|
|
|
t.Run("type auto still works on rows", func(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "type": "auto"}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("neither size nor type prescribed", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := runSection("row_sizes", map[string]interface{}{"range": "1:1"})
|
|
requireValidation(t, err, "needs size (px) or type")
|
|
})
|
|
|
|
t.Run("wrong-dimension word prescribed", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "height": float64(36)})
|
|
requireValidation(t, err, "does not apply")
|
|
})
|
|
|
|
t.Run("raw OpenAPI merge_type accepted", func(t *testing.T) {
|
|
t.Parallel()
|
|
ops, err := runSection("cell_merges", map[string]interface{}{"range": "A1:B2", "merge_type": "MERGE_ALL"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
|
if input["merge_type"] != "all" {
|
|
t.Fatalf("merge_type = %v, want all", input["merge_type"])
|
|
}
|
|
})
|
|
|
|
t.Run("bare string merge accepted", func(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := runSection("cell_merges", "A1:B2"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestStylesAcceptance_FlagPathParity is property 1's missing half.
|
|
//
|
|
// TestStylesAcceptance_VocabularyParity walks the same flag-defs vocabulary but
|
|
// exercises the PAYLOAD path (--styles items). The FLAG path — the flat
|
|
// --font-color / --number-format / … flags on +cells-set-style — is a separate
|
|
// hand-written mapping in buildCellStyleFromFlags, and a coverage run showed
|
|
// six of its eleven branches never executed by any test. A typo there (writing
|
|
// the wrong wire key, or reading the wrong flag) silently drops a style the
|
|
// caller explicitly asked for: the request still succeeds, the sheet just does
|
|
// not change. Derived from flag-defs so a new style flag is covered the moment
|
|
// it is declared.
|
|
func TestStylesAcceptance_FlagPathParity(t *testing.T) {
|
|
t.Parallel()
|
|
defs, err := loadFlagDefs()
|
|
if err != nil {
|
|
t.Fatalf("loadFlagDefs: %v", err)
|
|
}
|
|
spec, ok := defs["+cells-set-style"]
|
|
if !ok {
|
|
t.Fatal("no +cells-set-style flag defs")
|
|
}
|
|
|
|
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}
|
|
want := map[string]interface{}{}
|
|
for _, df := range spec.Flags {
|
|
if df.Kind != "own" || df.Name == "range" || df.Name == "border-styles" {
|
|
continue // border-styles is a composite with its own structural tests
|
|
}
|
|
field := strings.ReplaceAll(df.Name, "-", "_")
|
|
var value string
|
|
switch {
|
|
case len(df.Enum) > 0:
|
|
value = df.Enum[0]
|
|
want[field] = value
|
|
case df.Type == "float64" || df.Type == "int":
|
|
value = "14"
|
|
want[field] = float64(14)
|
|
case df.Name == "font-family":
|
|
value, want[field] = "Arial", "Arial"
|
|
case df.Name == "number-format":
|
|
value, want[field] = "0.00", "0.00"
|
|
default:
|
|
value, want[field] = "#112233", "#112233"
|
|
}
|
|
args = append(args, "--"+df.Name, value)
|
|
}
|
|
if len(want) < 5 {
|
|
t.Fatalf("expected the flat style vocabulary, only built %d fields", len(want))
|
|
}
|
|
|
|
input := decodeToolInput(t, parseDryRunBody(t, CellsSetStyle, args), "set_cell_range")
|
|
cells, _ := input["cells"].([]interface{})
|
|
if len(cells) == 0 {
|
|
t.Fatalf("no cells in %v", input)
|
|
}
|
|
row, _ := cells[0].([]interface{})
|
|
cell, _ := row[0].(map[string]interface{})
|
|
got, _ := cell["cell_styles"].(map[string]interface{})
|
|
if got == nil {
|
|
t.Fatalf("no cell_styles emitted: %v", cell)
|
|
}
|
|
for field, expected := range want {
|
|
actual, present := got[field]
|
|
if !present {
|
|
t.Errorf("flag --%s produced no %q on the wire — the style is silently dropped",
|
|
strings.ReplaceAll(field, "_", "-"), field)
|
|
continue
|
|
}
|
|
if actual != expected {
|
|
t.Errorf("%s = %#v, want %#v", field, actual, expected)
|
|
}
|
|
}
|
|
for field := range got {
|
|
if _, expected := want[field]; !expected {
|
|
t.Errorf("unexpected wire field %q emitted by the flag path", field)
|
|
}
|
|
}
|
|
}
|