Files
larksuite__cli/shortcuts/sheets/lark_sheet_table_io_test.go
xiongyuanwen-byted c2afcce1b5 feat(sheets): accept the payload spellings the 08-29..31 reflow rejected (#2611)
* feat(sheets): accept the payload spellings the 08-29..31 reflow rejected

The 08-29..31 reflow report attributes 2839 failures across 31388 sheets
calls, and a large share of them carry well-formed intent in a spelling the
flag does not document: a numeric column whose cells arrived as JSON strings
(155), a row cut short at its last non-empty cell (25), `columns` written as
one object per column (35), the sub-sheet list without its {"sheets":...}
envelope (10), a quoted font_size (30). Each rejection cost a retry that
rewrote the same data differently, and each shape has exactly one reading.

Accept them, on the acceptance-layer contract already stated in
style_vocab.go: one canonical form documented, a wide layer undocumented, and
no rewrite whose meaning is in doubt. A non-numeric string in a numeric
column, a row wider than `columns`, and a column-less sheet that does carry
rows all still fail. `dtypes` additionally reads the writer's own type
vocabulary (number, date), which used to fall through to the string default
and silently write figures as text -- a wrong result rather than a rejection,
so it never surfaced as an error at all.

Three more commands get the same treatment. --properties on
+cond-format-create renames `operator` to compare_type under the {value} /
{text} shapes, canonicalizes symbol and abbreviation comparisons, stringifies
a numeric threshold to match the schema, joins a two-element between list,
and lifts a bare attrs object into the one-entry list; rules whose own
contract spells `operator` keep it. +csv-put answers to --data / --content /
--csv-file, and a path-shaped --csv value naming a real file is read as
@<path> would, reported in the result's warnings rather than silently --
requiring the prefix there was an inconsistency of that flag's own making,
since every sibling path-valued flag takes the same value bare. --font-line
takes the CSS text-decoration words.

Where a fix would have to guess, the error carries the fix instead:
+cells-get rejects an Excel multi-area range client-side with the enclosing
rectangle spelled out (54 rejections the backend answered with a bare
"invalid range"), and row_height / col_width / wrap / unmerge_cells inside a
cell_styles item name where they really live.

Everything stays inside shortcuts/sheets. Deliberately unchanged: shrinking
--range to fit --cells (the dimension check documents why), chunking a
--styles spec past the 100-operation cap (it would drop the atomicity of one
batch_update), and the server-side merged-region conflicts.

* feat(sheets): answer the reflow report's full error breakdown

The 08-29..31 reflow report gained two sections: a 58-row table covering
every long-tail command, and a per-command breakdown of the "other" buckets
its first pass had summarized as a count. Together they name ~1000 failures
the earlier commit could not see. This works through them on the same rule:
accept what has one reading, prescribe what does not, and leave alone what
needs a guess.

The one defect among them is on --cells. A cell carrying style fields at its
top level ({"value":"x","font_weight":"bold","border":{…}}) passed every
client check and reached the backend verbatim, which answered
`[cells[0][0].border] unexpected property "border" is not defined` -- 33
rejections on payloads the --styles path accepts, which is exactly the
vocabulary-parity break style_vocab.go's contract forbids. Style fields now
fold into the carrier that holds them (border into border_styles, scalars
into cell_styles) and their values canonicalize on the same pass, so a
boolean word_wrap no longer dies on the schema's "expected type string" one
step later. A key this domain does not know is still passed through: the
tool contract may gain fields between builds.

Accepted, each with the rejection count it removes: the two object spellings
of --writes, the {"writes":[…]} envelope and a lone write object (54);
`type` as the line-kind slot inside a border spec, which is the Lark OpenAPI
and openpyxl spelling (29); a blank column heading, which is a spacer column
or the cells under a merged title (13); blank text in a date column, mirroring
the numeric rule (7); `custom` as a row/col sizing type when the op carries a
size (Excel's and Lark's own UI word); and cell-style vocabulary inside a
conditional-format rule's style block, where background_color / font_color /
font_weight / font_line fold onto back_color / fore_color / font /
text_decoration (9).

Prescribed, where the fix changes the shape: --position on +dim-delete, whose
sibling +dim-insert does take it (18); --ranges on +cells-unmerge (7);
--include on +csv-get (3); the sheet selectors on +styles-put; --payload on
+table-put; a whole-column or whole-row range where a cell style needs a
rectangle, naming row_sizes / col_sizes as the carrier that does take that
form (28); and border keys written on a styles item instead of a cell_styles
entry. Four invented subcommands (+cells-get-style, +dropdown-list, +meta,
+sheets-list) name the command they meant.

Renamed silently, where the command already has the input under another
spelling: --title on +workbook-import (16), --file / --outdir on
+workbook-export (15), --replace on +cells-replace (7), --output on +csv-get,
plus --sheet-name on +sheet-create and --new-title on +sheet-rename.

Finally, the Excel multi-area --range moves from +cells-get's own Validate to
the shared --range chain: 5 more of those landed on +csv-get and 4 on
+cells-set-style, so the habit is not specific to reads. It is chained after
the sheet-prefix rewrite, so a sheet name containing a comma cannot be read
as several areas.

* feat(sheets): retry transient reads, and answer three stale-state failures

Four fixes from the reflow report's long-tail table that need no protocol
decision, in descending order of the rejections they remove.

A read tool call is now reissued up to twice on a transient failure. The
report's single largest cause on +csv-get was "API call failed: server time
out error" -- 25 of its 71 rejections -- with more on +cells-get and
+workbook-info, each on a command that was written correctly and succeeded
when the agent reissued it by hand. Reads only: this API has no idempotency
key, so a create that timed out after the backend committed it would be
committed twice, which is why the shared RetryTransport is installed with
MaxRetries at 0 and why this sits where the read/write classification is
already known. A rate limit is excluded even though the classifier marks it
retryable -- the server is asking for less traffic, and a fixed sub-second
backoff answers that by sending more.

+filter-create accepted its own documented contract only in the flag
description. "The flag is optional overall -- if omitted, an empty filter is
created on --range" was false: `rules` is required at the properties root, so
omitting --properties failed with `required property "rules" is missing`, an
error about a flag the caller deliberately did not pass. The empty rule set
is now the default, which is what a filter with no column conditions is.

A "not found" on an id-addressed update or delete now points at the list
command that reports the live ids. The backend answers with the id alone
("conditional format iXGbyDwC not found"), which reads like a transport
problem rather than a stale reference; +cond-format-update and
+cond-format-delete contributed 14 rejections between them, and the same
prescription covers every id-addressed object.

Not done here, and not because they are hard: the ~480 rejections behind the
four protocol decisions (a --title default, shrinking --range to fit --cells,
merged-region conflicts, chunking past the operation cap) are the caller's
call, not mine; the ~185 PowerShell argv failures are destroyed before the
process starts and can only be answered by the Windows reference; and write
retries need an idempotency key from the backend.

* feat(sheets): narrow an oversized --range, prescribe merge conflicts, split large style specs

Three of the four decisions the reflow report left open, as decided by the
caller. The fourth, defaulting --title from the first sub-sheet name, is
declined: the title is what the workbook is called in Drive, and deriving it
would produce a shelf of spreadsheets named after their first tab. That one is
answered in the skill reference instead.

--cells that FITS inside --range now narrows the write to the payload instead
of failing. The anchor is unchanged, so every cell lands where the caller put
it, and no cell outside the payload is touched -- the stated range only ever
said how far the caller thought the payload reached. The dominant shape is a
one-cell title against the range it will occupy once merged, where writing the
top-left is what a merged region needs anyway. A payload that OVERFLOWS its
range is still rejected: growing the range would write over cells nobody
named. The narrowing rides back in the success envelope's warnings, because
the caller did state an extent. 122 rejections.

A merged-cell rejection now carries the commands that resolve it. The backend
names the obstacle -- the top-left of the region a write landed inside, or the
0-based bounds of the region a merge would overlap -- but never in A1
notation, and never with the command that clears it; "[0,0-0,6]" becomes
`+cells-unmerge --range "A1:G1"`. The message is read, never used to rewrite
the request: auto-redirecting a write would put data where the caller did not
ask for it, and auto-unmerging would discard a merge nobody agreed to lose. A
parse that finds nothing adds no hint. 193 rejections, which stay failures but
become one-retry failures.

+styles-put splits a spec past the per-request cap instead of refusing it. The
100 was never a server contract -- it is our own materialization guard -- so
the fix is to keep each request at that known-good size and send several,
which is the same license coalesceStyleStamps already takes when it fuses
adjacent stamps and the same thing +table-put does when it slices a large
write. The whole-spec ceiling moves to 1000, the request count rides in the
result, and a mid-way failure names the chunks that already applied and says
re-running the whole spec is safe. --writes keeps its cap: it is one atomic
batch_update by contract. 48 rejections.

* fix(sheets): make the payload prescriptions correct on PowerShell

Every composite flag in this domain takes its payload three ways -- inline, as
a relative @file, or on stdin -- and the prescriptions named the third one in
a spelling PowerShell does not have. `--cells - < cells.json` fails there with
"The '<' operator is reserved for future use": an error about the shell, on a
line this CLI told the caller to run. The pipe is no better, since PowerShell
5 re-encodes non-ASCII on the way through and turns a CJK payload into the
same invalid JSON by another route; the windows-compat skill reference rules
both out, and this code was contradicting it.

On windows every prescription now lands on @file, quoted -- a bare @ opens a
splatting expression -- and says why inlining failed in the first place: the
shell splits a JSON argument on the quotes and commas inside it, and single
quotes do not prevent that. The caller quoted the argument and has no reason
to suspect the quoting, which is what made these 89 rejections cost more than
one retry apiece. POSIX shells keep the redirection form they do have.

The other half of that class arrived as "positional arguments are not
supported": once the quote stack is split, the tail of the JSON is a
positional argument. The framework's message ("pass values via flags") is
correct and useless there -- the caller DID pass a flag -- but it is one
message for every command on every platform, so the cause is named here
instead, where the domain knows both the shell and which of its flags carry a
payload big enough to split. Chained onto cobra's Args the same way the
unknown-flag and enum rewrites chain onto their hooks; non-windows keeps the
framework wording, where a positional argument usually is one. 35 rejections.

* fix(sheets): answer the review of the reflow leniency PR

Nine defects the review found in the acceptance layer, plus the lint /
deadcode failure that blocked CI.

Silent wrong values. A `between` threshold list of any length was joined
into a comma string, so one or three thresholds passed the local shape
check and failed at the backend. The literal "null" decodes into any
destination without an error and leaves it at the zero value: in a
numeric column it marshalled back out as 0, and under `font_size` it
became a zero-point font. Both now reject it. `outer` and `all` in one
border spec dropped `outer` even when the two named different boxes,
applying half the caller's intent; a duplicate is still folded away, a
conflict now reaches the invalid-side check.

Rejections the acceptance layer meant to absorb. The multi-area `--range`
prescription took its rectangle from the first and last area, so
"A3,J3,G3" prescribed "A3:G3" and dropped J3; it now spans every area on
both axes. `type:"custom"` was rewritten to pixel only when the op
carried `size`, so the `height` / `width` alias the same parser accepts
everywhere else failed the enum check first.

False reports. `+cells-set` reconstructed the stated range without the
payload, so every bare `--range A1` with a multi-cell payload reported
itself as narrowed. `+table-put --dry-run` skipped the style expansion in
append mode, hiding a style-only write the execute path performs.

Contract. `+styles-put` still promised one fail-fast batch request after
gaining chunking, and its partial-failure advice called a whole-spec
retry safe — replaying an applied `merge_cells` is rejected as an
overlap. Both now state that a large spec can partially apply, and the
merge route is to read back and resend only what did not land.

The windows positional-argument annotation flattened the framework's
error to text; it keeps it as the cause, and the annotation is split out
so its typed shape is asserted on any host rather than only on windows.

CI: `payloadStdinForm` was unreachable — only its `goos` variant is used.

* fix(sheets): answer the second review round

Six findings, one of them a revert.

P1, --csv. A path-shaped value naming a real file was read as @<path>
would. That makes the flag's meaning depend on the working directory: a
caller writing the literal "./data.csv" into a cell uploaded a same-named
local file instead, and learned of the substitution only from a warning
that arrived after the remote write. File intent has to be explicit, so
the read is gone and the existing prescription -- @<path> or stdin --
stands on its own again. The substitution machinery goes with it.

--outdir and --output-dir were aliased onto --output-path, which reads a
value as a directory only when one already exists there. "--outdir
./exports" with no ./exports therefore wrote a file named "exports".
Both spellings say directory in their own name, so they now carry that
intent through the alias-provenance channel already built for +csv-put's
--file, and applyWorkbookOutputPath honors it without probing disk.

Range narrowing reached only the standalone +cells-set. The narrowing
happens in the shared input builder, so a --writes item or a
+batch-update sub-op shipped the shrunken range in silence. The builder
now returns the note fitCellsRange already produced, and all three paths
report it; the standalone path stops reconstructing it from its flags.

A conditional-format style folded any existing `font` value that was not
the incoming word into "bold italic", so {"font":"normal",
"font_weight":"bold"} became valid input with a slant nobody wrote. Only
bold and italic combine now; anything else keeps its value for the schema
to reject.

The stale-object-ID hint matched any "not found", including one about the
sheet selector, and told the caller to refresh rule ids when the ids were
the one input that was right. It now requires the message to name the id
the command addressed, and skips sheet/workbook-scoped misses.

P3: a first-chunk failure in +styles-put reported "requests 1-0 already
applied". That case says nothing landed instead, without the merge
caveat, which does not apply when the sheet is untouched.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(sheets): answer the third review round

Three follow-ups on the previous round, one of them a correction to it.

The narrowing note reached only the success envelope. A batch is
fail-fast but not transactional, and a transport failure leaves a single
write's outcome unknown, so a narrowed write can be on the sheet after an
error. Both branches now attach the note to the failure as well; a caller
reconciling against the range they stated rather than the one written is
exactly the confusion the note exists to prevent.

A `font` already reading "bold italic" sent the redundant flat spelling
along with it: the previous round stopped combining unrecognized values
but reached the same branch for this one, so the alias was left in the
outgoing style and the schema had no field for it. It is dropped now,
while a genuinely unknown `font` value still keeps its flat sibling
visible for the schema to reject.

The first-chunk case overcorrected. A failed FIRST request does not mean
an untouched sheet: operations before the failing one inside that request
stay applied, and a timeout settles nothing at all. Claiming otherwise
sent the caller into a whole-spec retry that can replay an applied
merge_cells. Only the backend's own "0 succeeded" now earns that message
-- the same test flattenToolErrorMsg uses, so the two cannot disagree
about what a failed batch left behind. Every other first-request failure
keeps the read-back route and the merge caveat.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-09-07 21:29:15 +08:00

2149 lines
92 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// ─── pure helpers: date serial, typed cell mapping ────────────────────
func TestTablePut_IsoDateToSerial(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want int
ok bool
}{
{"2024-01-15", 45306, true}, // the empirically verified anchor
{"2024-01-01", 45292, true},
{"2024-02-29", 45351, true}, // 2024 is a leap year
{"1899-12-31", 1, true}, // one day after the epoch
// pandas `df.to_json(orient="split", date_format="iso")` emits full ISO
// datetimes (`yyyy-mm-ddTHH:MM:SS.fff[±HH:MM]`); df_to_sheet hands those
// straight to --sheets, so the writer must round-trip them without
// asking agents to strip the time suffix themselves.
{"2024-01-15T00:00:00.000", 45306, true},
{"2024-01-15T08:30:00+08:00", 45306, true},
{"not-a-date", 0, false},
{"2024/01/15", 0, false}, // wrong separator
{"T2024-01-15", 0, false}, // a leading T isn't a valid prefix to strip
{"2024-15-01", 0, false}, // invalid month/day still rejected after T-strip
}
for _, tt := range cases {
got, err := isoDateToSerial(tt.in)
if tt.ok {
if err != nil {
t.Errorf("isoDateToSerial(%q) unexpected error: %v", tt.in, err)
continue
}
if got != float64(tt.want) && !(strings.Contains(tt.in, "T") && math.Abs(got-float64(tt.want)) < 1) {
t.Errorf("isoDateToSerial(%q) = %v, want %d", tt.in, got, tt.want)
}
} else if err == nil {
t.Errorf("isoDateToSerial(%q) = %v, want error", tt.in, got)
}
}
}
// TestTablePut_EmptyDatePrescription pins the empty-cell branch: the error
// must name the three ways out (drop rows / fill dates / object dtype)
// instead of the generic "must be ISO" parse failure.
func TestTablePut_EmptyDatePrescription(t *testing.T) {
t.Parallel()
for _, in := range []string{"", " "} {
_, err := isoDateToSerial(in)
if err == nil {
t.Fatalf("isoDateToSerial(%q) should fail", in)
}
for _, want := range []string{"empty cell", "object (text)"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("isoDateToSerial(%q) error should contain %q, got %q", in, want, err.Error())
}
}
}
}
// TestTablePut_ColumnKeyHint pins the dtypes/formats unknown-column hint:
// A1-style letter keys get the Excel-habit callout, and the declared column
// names ride inline either way.
func TestTablePut_ColumnKeyHint(t *testing.T) {
t.Parallel()
cols := []string{"姓名", "出生日期"}
got := columnKeyHint("dtypes", "A", cols)
for _, want := range []string{"not A1-style column letters", `"姓名", "出生日期"`} {
if !strings.Contains(got, want) {
t.Errorf("letter-key hint should contain %q, got %q", want, got)
}
}
got = columnKeyHint("formats", "出生 日期", cols)
if strings.Contains(got, "A1-style") {
t.Errorf("non-letter key must not get the letter callout, got %q", got)
}
if !strings.Contains(got, `"姓名", "出生日期"`) {
t.Errorf("hint should inline column names, got %q", got)
}
many := make([]string, 20)
for i := range many {
many[i] = fmt.Sprintf("col%02d", i)
}
got = columnKeyHint("dtypes", "X", many)
if !strings.Contains(got, ", …") || strings.Contains(got, "col19") {
t.Errorf("hint should truncate long column lists, got %q", got)
}
}
func TestIsColumnLetterKey(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"A": true, "Z": true, "AA": true, "ABC": true,
"": false, "ABCD": false, "a": false, "A1": false, "姓名": false,
}
for in, want := range cases {
if got := isColumnLetterKey(in); got != want {
t.Errorf("isColumnLetterKey(%q) = %v, want %v", in, got, want)
}
}
}
func TestTablePut_BuildTypedCell(t *testing.T) {
t.Parallel()
t.Run("string keeps literal + text format so digit-like ids survive read-back", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "id", Type: "string"}, "00123")
if err != nil {
t.Fatal(err)
}
if cell["value"] != "00123" {
t.Errorf("value = %#v, want \"00123\"", cell["value"])
}
if nf := numberFormatOf(cell); nf != "@" {
t.Errorf("number_format = %q, want @ (text format so +table-get infers string, not number)", nf)
}
})
t.Run("string stringifies a json.Number without scientific notation", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "code", Type: "string"}, json.Number("123456789012345"))
if cell["value"] != "123456789012345" {
t.Errorf("value = %#v, want literal digits", cell["value"])
}
})
t.Run("number preserves json.Number", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "amt", Type: "number", Format: "#,##0"}, json.Number("259874"))
if err != nil {
t.Fatal(err)
}
if n, ok := cell["value"].(json.Number); !ok || n.String() != "259874" {
t.Errorf("value = %#v, want json.Number 259874", cell["value"])
}
if nf := numberFormatOf(cell); nf != "#,##0" {
t.Errorf("number_format = %q, want #,##0", nf)
}
})
t.Run("date converts to serial + default format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, "2024-01-15")
if err != nil {
t.Fatal(err)
}
if cell["value"] != float64(45306) && cell["value"] != 45306 {
t.Errorf("value = %#v, want serial 45306", cell["value"])
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("number_format = %q, want default yyyy-mm-dd", nf)
}
})
t.Run("date honors explicit format", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date", Format: "yyyy-mm"}, "2024-01-15")
if nf := numberFormatOf(cell); nf != "yyyy-mm" {
t.Errorf("number_format = %q, want yyyy-mm", nf)
}
})
t.Run("bool maps to boolean", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "b", Type: "bool"}, true)
if err != nil || cell["value"] != true {
t.Errorf("value = %#v (err=%v), want true", cell["value"], err)
}
})
t.Run("null is an empty cell that still carries format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, nil)
if err != nil {
t.Fatal(err)
}
if _, has := cell["value"]; has {
t.Errorf("null cell should have no value: %#v", cell)
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("null date cell should still carry format, got %q", nf)
}
})
t.Run("type mismatches are rejected", func(t *testing.T) {
t.Parallel()
if _, err := buildTypedCell(&tableColumnSpec{Type: "number"}, "abc"); err == nil {
t.Error("number column accepting a string should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "date"}, json.Number("1")); err == nil {
t.Error("date column accepting a number should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "bool"}, "true"); err == nil {
t.Error("bool column accepting a string should error")
}
})
}
// TestDtypeToTypeFormat pins down the pandas-dtype → internal (type, format)
// mapping that drives the writer. Pandas dtype strings come in three flavors —
// lowercase numpy (`int64`), capitalized nullable pandas (`Int64`), and the
// stringified output of tz-aware datetimes (`datetime64[ns, UTC]`) — the table
// below exercises one of each per family so a future pandas release that adds
// (say) `float128` still maps to "number" via the prefix check rather than
// silently falling through to string.
func TestDtypeToTypeFormat(t *testing.T) {
t.Parallel()
cases := []struct {
dtype, wantType, wantFmt string
}{
// numeric: all variants → number (no default format; formats[] decides display)
{"int8", "number", ""}, {"int16", "number", ""}, {"int32", "number", ""}, {"int64", "number", ""},
{"uint8", "number", ""}, {"uint16", "number", ""}, {"uint32", "number", ""}, {"uint64", "number", ""},
{"float32", "number", ""}, {"float64", "number", ""},
{"Int8", "number", ""}, {"Int64", "number", ""}, {"UInt32", "number", ""}, {"Float64", "number", ""}, // nullable
{"complex64", "number", ""}, {"complex128", "number", ""},
// booleans: bool (numpy) + boolean (nullable pandas)
{"bool", "bool", ""}, {"boolean", "bool", ""},
// dates: every datetime* variant, incl. tz-aware
{"datetime64[ns]", "date", "yyyy-mm-dd"},
{"datetime64[ns, UTC]", "date", "yyyy-mm-dd"},
{"datetime64[ns, Asia/Shanghai]", "date", "yyyy-mm-dd"},
{"datetime64", "date", "yyyy-mm-dd"},
// strings / unknown: object, string, category, empty, gibberish → string + @
{"object", "string", "@"}, {"string", "string", "@"}, {"category", "string", "@"},
{"", "string", "@"}, {"timestamp", "string", "@"}, {"bigint", "string", "@"},
}
for _, tc := range cases {
t.Run(tc.dtype, func(t *testing.T) {
t.Parallel()
gotType, gotFmt := dtypeToTypeFormat(tc.dtype)
if gotType != tc.wantType || gotFmt != tc.wantFmt {
t.Errorf("dtypeToTypeFormat(%q) = (%q, %q), want (%q, %q)",
tc.dtype, gotType, gotFmt, tc.wantType, tc.wantFmt)
}
})
}
}
// TestTypeToDtype pins down the inverse mapping used by +table-get. The dtype
// string each internal type maps to must be one `df.astype(dtypes)` can
// consume without a per-column branch — that's the round-trip contract.
func TestTypeToDtype(t *testing.T) {
t.Parallel()
cases := []struct{ typ, want string }{
{"string", "object"}, // pandas default, astype("object") is a no-op
{"number", "float64"}, // works for ints, floats, and NaN-containing series
{"date", "datetime64[ns]"}, // matches ISO yyyy-mm-dd strings we emit
{"bool", "bool"}, // inferColumnType only picks bool when every cell is bool
{"", "object"}, // defensive default
{"surprise", "object"}, // ditto
}
for _, tc := range cases {
if got := typeToDtype(tc.typ); got != tc.want {
t.Errorf("typeToDtype(%q) = %q, want %q", tc.typ, got, tc.want)
}
}
}
// TestNormalize_DefaultsAndFormatOverride covers the wire-shape ergonomics that
// matter for one-line pandas writes:
// - omitting `dtypes` makes every column a text-format string (so a bare
// `df.to_json(orient="split")` payload is valid: leading-zero ids survive,
// digits don't sneak in as numbers);
// - `formats[col]` overrides the dtype-derived default (so a `float64` column
// gets `#,##0.00` instead of no format);
// - explicit `dtypes[col]` wins over the default-when-missing path.
func TestNormalize_DefaultsAndFormatOverride(t *testing.T) {
t.Parallel()
in := &tableSheetIn{
Name: "S",
Columns: headingNames("id", "amt", "d", "raw"),
Dtypes: labelsByName(map[string]string{"amt": "float64", "d": "datetime64[ns]"}), // id, raw left unspecified
Formats: labelsByName(map[string]string{"amt": "#,##0.00"}), // override float default ("")
Data: [][]interface{}{},
}
spec, err := in.normalize(0)
if err != nil {
t.Fatalf("normalize: %v", err)
}
want := []tableColumnSpec{
{Name: "id", Type: "string", Format: "@"}, // unspecified dtype → string + text format
{Name: "amt", Type: "number", Format: "#,##0.00"}, // float64 + formats override
{Name: "d", Type: "date", Format: "yyyy-mm-dd"}, // datetime → date + default date format
{Name: "raw", Type: "string", Format: "@"}, // unspecified → string + text format
}
for i, w := range want {
got := spec.Columns[i]
if got != w {
t.Errorf("columns[%d] = %+v, want %+v", i, got, w)
}
}
}
// numberFormatOf digs the number_format out of a built cell's cell_styles, or
// "" when absent.
func numberFormatOf(cell map[string]interface{}) string {
styles, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
return ""
}
nf, _ := styles["number_format"].(string)
return nf
}
// ─── payload validation ───────────────────────────────────────────────
func TestTablePut_PayloadValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
json string
want string
}{
{"empty sheets", `{"sheets":[]}`, "at least one sheet"},
{"missing name", `{"sheets":[{"columns":["a"],"data":[]}]}`, "name is required"},
{"duplicate name", `{"sheets":[{"name":"S","columns":["a"],"data":[]},{"name":"S","columns":["a"],"data":[]}]}`, "duplicate sheet name"},
{"no columns with data", `{"sheets":[{"name":"S","columns":[],"data":[["x"]]}]}`, "columns must be non-empty when `data` has rows"},
{"dtypes key on a blank column", `{"sheets":[{"name":"S","columns":["a",""],"dtypes":{"":"int64"},"data":[]}]}`, `dtypes references unknown column ""`},
{"duplicate column", `{"sheets":[{"name":"S","columns":["a","a"],"data":[]}]}`, "duplicate column name"},
{"dtypes refs unknown column", `{"sheets":[{"name":"S","columns":["a"],"data":[],"dtypes":{"b":"int64"}}]}`, "dtypes references unknown column"},
{"formats refs unknown column", `{"sheets":[{"name":"S","columns":["a"],"data":[],"formats":{"b":"0.0"}}]}`, "formats references unknown column"},
{"row wider than columns", `{"sheets":[{"name":"S","columns":["a"],"data":[["x","y"]]}]}`, "`columns` declares 1"},
{"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":["a"],"data":[]}]}`, "start_cell"},
{"bad date value", `{"sheets":[{"name":"S","columns":["d"],"dtypes":{"d":"datetime64[ns]"},"data":[["2025/03/31"]]}]}`, "must be ISO"},
{"number expects numeric", `{"sheets":[{"name":"S","columns":["n"],"dtypes":{"n":"int64"},"data":[["abc"]]}]}`, "number expects"},
{"invalid json", `{not json`, "invalid JSON"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": tt.json})
requireValidation(t, err, tt.want)
})
}
}
// stubFlagView is a minimal flagView backed by a map, for unit-testing the
// payload parser without a cobra command.
type stubFlagView map[string]string
func (s stubFlagView) Str(name string) string { return s[name] }
func (s stubFlagView) Bool(name string) bool { return s[name] == "true" }
func (s stubFlagView) Int(name string) int { return 0 }
func (s stubFlagView) Float64(name string) float64 { return 0 }
func (s stubFlagView) Changed(name string) bool { _, ok := s[name]; return ok }
func (s stubFlagView) StrArray(name string) []string { return nil }
func (s stubFlagView) StrSlice(name string) []string { return nil }
func (s stubFlagView) InputResolvedFromSource(name string) bool { return false }
func (s stubFlagView) Command() string { return "+table-put" }
// ─── dry-run: create + write rendering ────────────────────────────────
const tablePutSheetsJSON = `{"sheets":[{"name":"月度",` +
`"columns":["门店","月份","销售额"],` +
`"dtypes":{"门店":"object","月份":"datetime64[ns]","销售额":"int64"},` +
`"formats":{"月份":"yyyy-mm","销售额":"#,##0"},` +
`"data":[["北京","2024-01-15",259874]]}]}`
func TestTablePut_DryRunWrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", tablePutSheetsJSON})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (set_cell_range only)", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["excel_id"] != testToken {
t.Errorf("excel_id = %v, want %s", input["excel_id"], testToken)
}
if input["sheet_name"] != "月度" {
t.Errorf("sheet_name = %v, want 月度", input["sheet_name"])
}
if input["range"] != "A1:C2" {
t.Errorf("range = %v, want A1:C2 (1 header + 1 data row × 3 cols)", input["range"])
}
rows := input["cells"].([]interface{})
header := rows[0].([]interface{})
if hs := cellStyles(header[0]); len(hs) != 0 {
t.Errorf("header cell should carry no style now that --header-style is removed, got %#v", header[0])
}
data := rows[1].([]interface{})
// 月份 (date) → serial 45306, number_format yyyy-mm
if v := cellValue(data[1]); v != float64(45306) {
t.Errorf("date cell value = %#v, want 45306 serial", v)
}
if nf := cellStyles(data[1])["number_format"]; nf != "yyyy-mm" {
t.Errorf("date number_format = %v, want yyyy-mm", nf)
}
// 销售额 (number) → 259874 preserved
if v := cellValue(data[2]); v != float64(259874) {
t.Errorf("number cell value = %#v, want 259874", v)
}
}
func cellValue(c interface{}) interface{} {
m, _ := c.(map[string]interface{})
return m["value"]
}
func cellStyles(c interface{}) map[string]interface{} {
m, _ := c.(map[string]interface{})
s, _ := m["cell_styles"].(map[string]interface{})
return s
}
// TestTablePut_DryRunWithStyles confirms --styles flows through the same shared
// path as +workbook-create: cell_styles merge into the matrix, and merges /
// resizes render as their own tool calls after the set_cell_range write.
func TestTablePut_DryRunWithStyles(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"数据","columns":["a","b"],"data":[["x","y"]]}]}`,
"--styles", `{"styles":[{"name":"数据","cell_styles":[{"range":"A1:B1","font_weight":"bold"}],"cell_merges":[{"range":"A1:B1"}],"col_sizes":[{"range":"A:A","type":"pixel","size":120}]}]}`,
})
if len(calls) != 3 {
t.Fatalf("want 3 dry-run calls (set_cell_range + merge + resize), got %d", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
cellsRaw, _ := json.Marshal(input["cells"])
if !strings.Contains(string(cellsRaw), `"font_weight":"bold"`) {
t.Errorf("cell_styles should merge into the matrix; cells=%s", cellsRaw)
}
allRaw, _ := json.Marshal(calls)
if !strings.Contains(string(allRaw), "merge_cells") {
t.Errorf("dry-run should include merge_cells visual op; calls=%s", allRaw)
}
if !strings.Contains(string(allRaw), "resize_range") {
t.Errorf("dry-run should include resize_range visual op; calls=%s", allRaw)
}
}
// TestTablePut_StylesNameMismatchRejected confirms a styles item whose name
// doesn't match the payload sheet is rejected at Validate, before any write.
func TestTablePut_StylesNameMismatchRejected(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"其他","cell_styles":[{"range":"A1","font_weight":"bold"}]}]}`,
"--dry-run",
})
requireValidation(t, err, "must match")
}
func TestTablePut_StylePaddingBudgetRejectedBeforeDryRun(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"数据","cell_styles":[{"range":"A1:AX25000","font_weight":"bold"}]}]}`,
"--dry-run",
})
requireValidation(t, err, "over the 1000000-cell safety cap")
}
// TestTablePut_ExecuteWithStyles drives the full write + visual-ops path: the
// set_cell_range write carries the merged cell_styles, then merge_cells /
// resize_range tool calls apply the structural styles in the same call.
func TestTablePut_ExecuteWithStyles(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
// set_cell_range + merge_cells + resize_range all hit the same write endpoint;
// mark reusable so the FIFO stub serves each.
write := toolOutputStub(testToken, "write", `{"ok":true}`)
write.Reusable = true
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL,
"--sheets", `{"sheets":[{"name":"数据","columns":["a","b"],"data":[["x","y"]]}]}`,
"--styles", `{"styles":[{"name":"数据","cell_styles":[{"range":"A1:B1","font_weight":"bold"}],"cell_merges":[{"range":"A1:B1"}]}]}`},
structure, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data)
}
}
// ─── validation through the cobra surface ─────────────────────────────
func TestTablePut_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "missing spreadsheet locator rejected",
args: []string{"--sheets", tablePutSheetsJSON},
want: "at least one",
},
{
name: "url and token are mutually exclusive",
args: []string{"--url", testURL, "--spreadsheet-token", testToken, "--sheets", tablePutSheetsJSON},
want: "mutually exclusive",
},
{
name: "duplicate column name rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":["a","a"],"data":[]}]}`},
want: "duplicate column name",
},
{
name: "row wider than columns rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":["a"],"data":[["one","two"]]}]}`},
want: "`columns` declares 1",
},
{
name: "trailing JSON data after --sheets value rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":["a"],"data":[]}]} trailing`},
want: "trailing data after JSON value",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, TablePut, append(tt.args, "--dry-run"))
requireValidation(t, err, tt.want)
})
}
}
// ─── execute paths with stubbed tools ─────────────────────────────────
// TestTablePut_ExecuteWrite drives the write path: a structure read maps the
// existing sheet by name, then a set_cell_range write fills it.
func TestTablePut_ExecuteWrite(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":["a","b"],"dtypes":{"b":"int64"},"data":[["x",1]]}]}`},
structure, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data)
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "数据" || s0["sheet_id"] != testSheetID {
t.Errorf("sheet summary = %#v, want name=数据 sheet_id=%s", s0, testSheetID)
}
if s0["range"] != "A1:B2" {
t.Errorf("range = %v, want A1:B2", s0["range"])
}
}
// TestTablePut_ExecuteWriteCreatesMissingSheet covers the branch where the
// named sheet does not yet exist: a create precedes the write.
func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) {
t.Parallel()
// First structure read sees only "Sheet1"; the payload targets "新表", so
// createSheet runs, and the follow-up read (FIFO: second stub) resolves the
// newly created sheet's id.
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"新表","index":1}]}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
write.Reusable = true // modify_workbook_structure create + set_cell_range
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新表","columns":["a"],"data":[["x"]]}]}`},
structBefore, structAfter, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1", len(sheets))
}
if s0, _ := sheets[0].(map[string]interface{}); s0["sheet_id"] != testSheetID2 {
t.Errorf("created sheet id = %v, want %s", s0["sheet_id"], testSheetID2)
}
}
// TestTablePut_SheetCreateDims checks new-sheet sizing: small tables keep the
// 20×200 floor (unchanged behavior), wide/long tables grow past it (the fix for
// set_cell_range "exceeds sheet bounds"), and start_cell offset + header row are
// accounted for, with columns clamped to the backend's 200 ceiling.
func TestTablePut_SheetCreateDims(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
cols := func(n int) []tableColumnSpec { return make([]tableColumnSpec, n) }
rows := func(n int) [][]interface{} { return make([][]interface{}, n) }
cases := []struct {
name string
spec tableSheetSpec
styles *workbookCreateStylePayload
wantRows, wantCols int
}{
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, nil, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, nil, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, nil, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, nil, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, nil, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, nil, 200, 200},
// Default headerOn() is false for append mode, but writeSheetData forces
// a header when append hits an empty sheet with no explicit Header
// choice (so column names aren't lost). sheetCreateDims runs only on
// brand-new sheets — which are empty by definition — so it has to
// match: append + Header=nil ⇒ +1 row. Otherwise an append-near-50000
// payload would be created one row short.
{"append on new sheet sizes for the forced header row (49999 data rows + 1 header = 50000)",
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, nil, 50000, 20},
{"append + Header=false (explicit) does NOT add the forced header row",
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, nil, 200, 20},
// --styles reaching past the data grows the grid so a cell_styles op on a
// blank cell (or a merge / resize) still fits after the create.
{"styles past the data grow the grid",
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z400"}}}, 400, 26},
{"styles inside the data don't shrink the grid",
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:B2"}}}, 200, 20},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotRows, gotCols := sheetCreateDims(&tt.spec, tt.styles)
if gotRows != tt.wantRows || gotCols != tt.wantCols {
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
}
})
}
}
// TestTablePut_ExecuteCreatesWideSheetWithDims is the regression test for the
// wide-table bug: a 25-column payload targeting a not-yet-existing sheet must
// create it with 25 columns (past the 20-column default) so the follow-up
// set_cell_range fits instead of failing with "exceeds sheet bounds".
func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) {
t.Parallel()
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`)
writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range
const n = 25
// Distinct names per column — the new wire shape rejects duplicates at
// normalize-time, so a repeated "c" would never reach the create call.
colNames := make([]string, n)
for i := 0; i < n; i++ {
colNames[i] = fmt.Sprintf(`"c%d"`, i)
}
cols := strings.Join(colNames, ",")
vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",")
payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"data":[[` + vals + `]]}]}`
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets", payload},
structBefore, createStub, structAfter, writeStub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil {
t.Fatalf("decode create body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode create tool input: %v", err)
}
if input["operation"] != "create" {
t.Fatalf("first write should be the create op, got %#v", input["operation"])
}
if input["columns"] != float64(n) {
t.Errorf("create columns = %#v, want %d (sized to the wide payload)", input["columns"], n)
}
if input["rows"] != float64(200) {
t.Errorf("create rows = %#v, want 200 (floor)", input["rows"])
}
}
// TestWorkbookCreate_StylesAnchorFailsBeforeCreate pins that a cell_styles
// range left of / above the write anchor fails in Validate — with NO API call.
// The check used to live only in the write phase, after the workbook-create
// call, stranding an orphan workbook (live-verified 2026-08-06: the envelope
// read "spreadsheet … created but initial fill failed"). No stubs are
// registered, so reaching Execute would fail with "no stub" instead of the
// validation message this test requires.
func TestWorkbookCreate_StylesAnchorFailsBeforeCreate(t *testing.T) {
t.Parallel()
// Only the typed --sheets path can move the anchor (per-sheet start_cell);
// --values always writes from A1, where no range can be left of / above.
_, _, err := runShortcutCapturingErr(t, WorkbookCreate, []string{
"--title", "T",
"--sheets", `{"sheets":[{"name":"S","start_cell":"B2","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"S","cell_styles":[{"range":"A1","font_weight":"bold"}]}]}`,
})
requireValidation(t, err, "starts left of the write range")
}
// TestTablePut_StylesAnchorFailsBeforeWrite pins that +table-put rejects an
// out-of-anchor cell_styles range in Validate, with NO API call: a payload
// targeting a missing sheet used to CREATE that sheet before the write phase
// rejected the range — leaving a stray empty sheet behind while reporting
// "no sheets were written". No stubs are registered, so reaching Execute
// would fail with "no stub" instead of the required validation message.
func TestTablePut_StylesAnchorFailsBeforeWrite(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"S","start_cell":"B2","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"S","cell_styles":[{"range":"A1","font_weight":"bold"}]}]}`,
})
requireValidation(t, err, "starts left of the write range")
}
// TestTablePut_AppendStylesAnchorSkipsRowCheck pins the append-mode carve-out:
// the contract ignores start_cell's row (the base row comes from the sheet's
// existing data at execute time), so Validate must not compare style rows
// against the ignored static row — data ending at row 5 with start_cell B10
// appends at row 6, making a B6 style legal. The COLUMN is still static and
// still enforced. (dry-run-reproduced regression: B6 was rejected as "must be
// at or after B10".)
func TestTablePut_AppendStylesAnchorSkipsRowCheck(t *testing.T) {
t.Parallel()
t.Run("row above the ignored static anchor row passes Validate", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL,
"--sheets", `{"sheets":[{"name":"S","mode":"append","start_cell":"B10","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"S","cell_styles":[{"range":"B6","font_weight":"bold"}]}]}`,
})
if len(calls) == 0 {
t.Fatal("append payload with a legal style range must produce a dry-run plan, not a validation error")
}
})
t.Run("column left of the anchor still rejected", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"S","mode":"append","start_cell":"B10","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"S","cell_styles":[{"range":"A6","font_weight":"bold"}]}]}`,
})
requireValidation(t, err, "starts left of the write range")
})
t.Run("missing append target fails before the sheet is created", func(t *testing.T) {
// Execute path (not just dry-run): the missing target would be created
// EMPTY, so append resolves its base row to the static anchor and the
// B6 style is genuinely unreachable — but the failure must land BEFORE
// the create. Only the structure-read stub is registered: reaching the
// modify_workbook_structure create would fail with "no stub" instead
// of the anchor message this test requires.
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Other","index":0}]}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL,
"--sheets", `{"sheets":[{"name":"S","mode":"append","start_cell":"B10","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"S","cell_styles":[{"range":"B6","font_weight":"bold"}]}]}`},
structure)
if err == nil {
t.Fatalf("expected the pre-create anchor failure, got success: %s", out)
}
if !strings.Contains(err.Error(), "starts outside the write range") && !strings.Contains(out, "starts outside the write range") {
t.Fatalf("want the anchor message before any mutation; err=%v out=%s", err, out)
}
})
}
// TestTableGet_StructureErrorSurfaces pins that a failed get_workbook_structure
// read is a typed error, not a silent degradation: the dimensionless fallback
// anchored the used-range probe at A1, whose current_region stops at the first
// fully-empty row — the read then silently returned a truncated sheet with no
// incomplete marker (live-verified 2026-08-06).
func TestTableGet_StructureErrorSurfaces(t *testing.T) {
t.Parallel()
structureErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
Body: map[string]interface{}{"code": 99991400, "msg": "rate limited"},
}
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "数据"},
structureErr)
if err == nil {
t.Fatalf("structure failure must surface, got success: %s", out)
}
p := requireProblem(t, err, errs.CategoryAPI, errs.SubtypeRateLimit, "get_workbook_structure")
if !p.Retryable {
t.Errorf("rate-limited structure read should stay retryable, got %+v", p)
}
}
// TestWorkbookCreate_AdoptPurgesStaleDefaultMapping is the regression test for
// the adopt double-write bug: after the default "Sheet1" is renamed to the
// first payload sheet, a later payload sheet literally named "Sheet1" must get
// its OWN freshly created sheet — the stale byName["Sheet1"] entry used to
// route it into the adopted (renamed) sheet, silently destroying the first
// sheet's data while reporting both as written (live-verified 2026-08-06).
func TestWorkbookCreate_AdoptPurgesStaleDefaultMapping(t *testing.T) {
t.Parallel()
createWorkbook := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"spreadsheet": map[string]interface{}{"spreadsheet_token": testToken}},
},
}
defaultOnly := `{"sheets":[{"sheet_id":"` + testSheetID + `","sheet_name":"Sheet1","index":0,"row_count":200,"column_count":20}]}`
afterCreate := `{"sheets":[{"sheet_id":"` + testSheetID + `","sheet_name":"Sales","index":0,"row_count":200,"column_count":20},{"sheet_id":"` + testSheetID2 + `","sheet_name":"Sheet1","index":1,"row_count":200,"column_count":20}]}`
firstLookup := toolOutputStub(testToken, "read", defaultOnly) // lookupFirstSheetID
listByName := toolOutputStub(testToken, "read", defaultOnly) // writeTypedSheets' listSheetIDsByName
renameStub := toolOutputStub(testToken, "write", `{"ok":true}`) // rename Sheet1 → Sales
salesWrite := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range for Sales
createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create for Sheet1
postCreate := toolOutputStub(testToken, "read", afterCreate) // createSheet's id read-back
sheet1Write := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range for Sheet1
out, err := runShortcutWithStubs(t, WorkbookCreate,
[]string{"--title", "T", "--sheets",
`{"sheets":[{"name":"Sales","columns":["a"],"data":[["s"]]},{"name":"Sheet1","columns":["b"],"data":[["x"]]}]}`},
createWorkbook, firstLookup, listByName, renameStub, salesWrite, createStub, postCreate, sheet1Write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
// The 5th tool call must be a CREATE for "Sheet1" — with the stale mapping
// it was a set_cell_range into the adopted sheet instead.
var wire map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil {
t.Fatalf("decode create body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode create tool input: %v", err)
}
if input["operation"] != "create" || input["sheet_name"] != "Sheet1" {
t.Fatalf("expected a create op for Sheet1, got %#v", input)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 2 {
t.Fatalf("want 2 written sheets, got %d: %s", len(sheets), out)
}
id0 := sheets[0].(map[string]interface{})["sheet_id"]
id1 := sheets[1].(map[string]interface{})["sheet_id"]
if id0 == id1 {
t.Fatalf("both payload sheets landed on the same sheet_id %v — stale adopt mapping", id0)
}
}
// TestTablePut_ExecutePartialFailure covers the partial-success error path:
// a set_cell_range write fails mid-import and the structured error surfaces.
// TestTablePut_ExecuteTotalFailure: a single sheet whose write fails landed
// nothing — it must be a plain failure, NOT partial_success.
func TestTablePut_ExecuteTotalFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`},
structure, writeErr)
if err == nil {
t.Fatalf("expected failure; got nil. out=%s", out)
}
if strings.Contains(err.Error(), "partially applied") || strings.Contains(out, "partially applied") {
t.Errorf("single-sheet failure must NOT be partial_success; got err=%v out=%s", err, out)
}
if !strings.Contains(err.Error(), "failed") && !strings.Contains(out, "no sheets were written") {
t.Errorf("expected plain-failure message; got err=%v out=%s", err, out)
}
// The typed error passes through (mutated in place), so classifier
// metadata survives — rebuilding from err.Error() erased code and log_id
// (live-verified: the same failure carried code 40400 + log_id through
// +workbook-info but neither through +table-put).
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("total failure must stay a typed error, got %T: %v", err, err)
}
if p.Code != 1254000 {
t.Errorf("Code = %d, want 1254000 preserved through tablePutPartial", p.Code)
}
if !strings.Contains(p.Message, "table-put failed on") || !strings.Contains(p.Message, "boom") {
t.Errorf("message should carry the table-put context and the cause, got %q", p.Message)
}
}
// TestTablePut_ExecutePartialFailure: first sheet's write lands, second fails →
// partial_success carrying the first sheet in written_sheets.
func TestTablePut_ExecutePartialFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read",
`{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"汇总","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"明细","index":1}]}`)
writeOK := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"汇总","columns":["a"],"data":[["x"]]},{"name":"明细","columns":["a"],"data":[["y"]]}]}`},
structure, writeOK, writeErr)
if err == nil {
t.Fatalf("expected partial-failure exit signal; got nil. out=%s", out)
}
// Per the typed-error contract, a partial success is a result, not an error
// envelope: the ok:false result (with written_sheets and the failing sheet's
// name) lands on stdout, and err is the bare partial-failure exit signal.
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Errorf("expected *output.PartialFailureError exit signal; got %T %v", err, err)
}
if !strings.Contains(out, "written_sheets") {
t.Errorf("expected written_sheets in the partial-success result; got out=%s", out)
}
// The failing sheet is named in the reason; the written one lives in
// written_sheets — both on stdout.
if !strings.Contains(out, "明细") {
t.Errorf("partial_success should name the failed sheet 明细; got out=%s", out)
}
}
// ─── +workbook-create typed --sheets path ─────────────────────────────
// TestWorkbookCreate_TypedMutualExclusion locks the Validate contract: the typed
// --sheets entry can't be combined with the untyped --headers/--values.
func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) {
t.Parallel()
typed := `{"sheets":[{"name":"S","columns":["a"],"data":[["x"]]}]}`
for _, tc := range []struct {
name string
args []string
}{
{"sheets+values", []string{"--title", "X", "--sheets", typed, "--values", `[["x"]]`}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, WorkbookCreate, tc.args)
requireValidation(t, err, "mutually exclusive")
})
}
}
// TestWorkbookCreate_EmptySheetsErrors locks the fix for an explicitly-given but
// empty --sheets (e.g. empty stdin / file): it must error, not silently fall
// through to creating an empty workbook.
func TestWorkbookCreate_EmptySheetsErrors(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, WorkbookCreate, []string{"--title", "X", "--sheets", ""})
requireValidation(t, err, "empty")
}
// TestWorkbookCreate_TypedAdoptsDefaultSheet covers the one-step typed create:
// the new workbook's default sheet is renamed to the first payload sheet's name
// and reused (no empty Sheet1 left behind), then written type-faithfully (the
// date lands as an Excel serial, not text).
func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtTYPED", "title": "Demo"},
},
},
}
// lookupFirstSheetID and writeTypedSheets' listSheetIDsByName both read the
// structure; one reusable stub serves both, reporting only the default sheet.
structure := toolOutputStub("shtTYPED", "read", `{"sheets":[{"sheet_id":"shtDef","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
rename := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "modify_workbook_structure") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"ok":true}`}},
}
write := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "set_cell_range") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"updated_cells_count":4}`}},
}
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"Sales","columns":["d","amt"],"dtypes":{"d":"datetime64[ns]","amt":"float64"},"data":[["2024-01-15",1234.5]]}]}`,
}, create, structure, rename, write)
if err != nil {
t.Fatalf("typed create failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtTYPED" {
t.Errorf("spreadsheet_token = %v, want shtTYPED", data["spreadsheet"])
}
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Fatalf("want 1 written sheet, got %#v", data["sheets"])
}
// Default sheet adopted: rename targets shtDef → "Sales" (no new sheet, no
// stray Sheet1).
renameInput := decodeToolInput(t, decodeRawEnvelopeBody(t, rename.CapturedBody), "modify_workbook_structure")
if renameInput["operation"] != "rename" || renameInput["sheet_id"] != "shtDef" || renameInput["new_name"] != "Sales" {
t.Errorf("rename should adopt default shtDef→Sales; got %#v", renameInput)
}
// The data write carries the date as serial 45306, proving the type-faithful path.
writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "set_cell_range")
cellsJSON, _ := json.Marshal(writeInput["cells"])
if !strings.Contains(string(cellsJSON), "45306") {
t.Errorf("date 2024-01-15 should be written as serial 45306; cells=%s", cellsJSON)
}
}
// TestWorkbookCreate_TypedDryRun verifies the dry-run previews create + a typed
// set_cell_range write with the date already converted to a serial.
func TestWorkbookCreate_TypedDryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"S","columns":["d"],"dtypes":{"d":"datetime64[ns]"},"data":[["2024-01-15"]]}]}`,
})
if len(calls) != 2 {
t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls))
}
raw, _ := json.Marshal(calls[1])
if !strings.Contains(string(raw), "45306") {
t.Errorf("typed dry-run write should contain serial 45306; got %s", raw)
}
}
func TestWorkbookCreate_TypedDryRun_MultiSheetStyles(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"S1","columns":["name"],"data":[["alice"]]},{"name":"S2","columns":["amount"],"dtypes":{"amount":"int64"},"formats":{"amount":"0"},"data":[[12]]}]}`,
"--styles", `{"styles":[{"name":"S1","cell_styles":[{"range":"A1:A2","background_color":"#f5f5f5"}],"cell_merges":[{"range":"A1:A2"}]},{"name":"S2","cell_styles":[{"range":"A1","font_weight":"bold"},{"range":"A2","font_color":"#0f7b0f"}],"col_sizes":[{"range":"A:A","type":"pixel","size":120}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}]}`,
})
if len(calls) != 6 {
t.Fatalf("want 6 dry-run calls (create + two typed writes + merge + two resizes), got %d", len(calls))
}
firstBody, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
firstInput := decodeToolInput(t, firstBody, "set_cell_range")
firstRaw, _ := json.Marshal(firstInput["cells"])
if !strings.Contains(string(firstRaw), `"background_color":"#f5f5f5"`) {
t.Errorf("first sheet should carry global style; cells=%s", firstRaw)
}
secondBody, _ := calls[3].(map[string]interface{})["body"].(map[string]interface{})
secondInput := decodeToolInput(t, secondBody, "set_cell_range")
secondRaw, _ := json.Marshal(secondInput["cells"])
if !strings.Contains(string(secondRaw), `"font_weight":"bold"`) || !strings.Contains(string(secondRaw), `"font_color":"#0f7b0f"`) {
t.Errorf("second sheet should carry per-cell styles; cells=%s", secondRaw)
}
allRaw, _ := json.Marshal(calls)
if !strings.Contains(string(allRaw), "merge_cells") {
t.Errorf("dry-run should include merge_cells visual op; calls=%s", allRaw)
}
if got := strings.Count(string(allRaw), "resize_range"); got != 2 {
t.Errorf("dry-run resize_range count = %d, want 2; calls=%s", got, allRaw)
}
}
func TestTablePut_StringifyCellValue(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"plain", "plain"},
{json.Number("12345678901234"), "12345678901234"},
{true, "TRUE"},
{false, "FALSE"},
{3.5, "3.5"},
}
for _, tt := range cases {
if got := stringifyCellValue(tt.in); got != tt.want {
t.Errorf("stringifyCellValue(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_DescribeJSONType(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"x", "a string"},
{json.Number("1"), "a number"},
{true, "a boolean"},
{[]interface{}{}, "an array"},
{map[string]interface{}{}, "an object"},
{3.14, "float64"},
}
for _, tt := range cases {
if got := describeJSONType(tt.in); got != tt.want {
t.Errorf("describeJSONType(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_HeaderAndMode(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
// headerOn: overwrite writes header, append omits it by default, explicit wins
if !headerOn(&tableSheetSpec{}) {
t.Error("overwrite default should write header")
}
if headerOn(&tableSheetSpec{Mode: "append"}) {
t.Error("append should omit header by default")
}
if !headerOn(&tableSheetSpec{Mode: "append", Header: bp(true)}) {
t.Error("explicit header:true should override append default")
}
if headerOn(&tableSheetSpec{Header: bp(false)}) {
t.Error("explicit header:false should be honored")
}
// writeModeName
if writeModeName(&tableSheetSpec{}) != "overwrite" || writeModeName(&tableSheetSpec{Mode: "append"}) != "append" {
t.Error("writeModeName normalization wrong")
}
// buildSheetMatrix header toggle
s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}}
if m, _ := buildSheetMatrix(s, false); len(m) != 1 {
t.Errorf("header off → 1 data row, got %d", len(m))
}
if m, _ := buildSheetMatrix(s, true); len(m) != 2 {
t.Errorf("header on → header + 1 data row, got %d", len(m))
}
}
func TestTablePut_BadModeRejected(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":["a"],"data":[]}]}`})
requireValidation(t, err, "invalid")
}
// TestTablePut_AppendEmptySheetWritesHeader: appending to an EMPTY sheet still
// writes the header row, so column names aren't lost (and a later +table-get
// won't consume the first data row as the header).
func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"新","index":0}]}`)
region := toolOutputStub(testToken, "read", `{}`) // empty sheet: no current_region → lastRow 0
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新","mode":"append","columns":["列A"],"data":[["x"],["y"]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("empty-sheet append should write header + 2 data rows = 3, got %d", len(cells))
}
if header, _ := cells[0].([]interface{}); len(header) > 0 {
if h0, _ := header[0].(map[string]interface{}); h0["value"] != "列A" {
t.Errorf("first row should be the header 列A; got %#v", h0)
}
}
if input["range"] != "A1:A3" {
t.Errorf("range = %v, want A1:A3 (header + 2 rows at top of empty sheet)", input["range"])
}
}
// TestTablePut_ExecuteAppend verifies append placement: data lands below the
// sheet's existing data (current_region A1:B5 → start at row 6) with no repeated
// header.
func TestTablePut_ExecuteAppend(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:B5","actual_range":"A1:B5"}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"日志","mode":"append","columns":["时间","值"],"dtypes":{"值":"int64"},"data":[["t1",1],["t2",2]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
// inspect the set_cell_range request the append produced
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
if input["range"] != "A6:B7" {
t.Errorf("append range = %v, want A6:B7 (2 rows below last data row 5, no header)", input["range"])
}
if cells, _ := input["cells"].([]interface{}); len(cells) != 2 {
t.Errorf("append should write 2 data rows (no header), got %d", len(cells))
}
data := decodeEnvelopeData(t, out)
if s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{}); s0["mode"] != "append" {
t.Errorf("summary mode = %v, want append", s0["mode"])
}
}
// TestTablePut_HeaderFalseAndAllowOverwrite checks header:false drops the
// header row and allow_overwrite:false reaches the tool input.
func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":["a"],"data":[["x"],["y"]]}]}`})
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["allow_overwrite"] != false {
t.Errorf("allow_overwrite = %v, want false", input["allow_overwrite"])
}
rows, _ := input["cells"].([]interface{})
if len(rows) != 2 {
t.Fatalf("header:false → 2 data rows only, got %d", len(rows))
}
first, _ := rows[0].([]interface{})[0].(map[string]interface{})
if first["value"] != "x" {
t.Errorf("header:false first cell = %v, want data 'x' (no header row)", first["value"])
}
}
// ─── +table-get ───────────────────────────────────────────────────────
func TestTableGet_SerialRoundTrip(t *testing.T) {
t.Parallel()
for _, iso := range []string{"2024-01-15", "2024-02-29", "2000-01-01", "1899-12-31"} {
s, err := isoDateToSerial(iso)
if err != nil {
t.Fatalf("isoDateToSerial(%s): %v", iso, err)
}
if back := serialToISO(float64(s)); back != iso {
t.Errorf("roundtrip %s → %v → %s", iso, s, back)
}
}
}
func TestTableGet_IsDateNumberFormat(t *testing.T) {
t.Parallel()
for _, nf := range []string{
"yyyy-mm-dd", "yyyy-mm", "yyyy/m/d", "YYYY/MM/DD", "yy-mm-dd", "[Red]yyyy-mm-dd", `"At "yyyy`,
// Year-less date/time presets carry no 'yy' and were previously
// inferred as number — the column then read back raw serials
// (live-verified 2026-08-06: m/d → float64, same serial with
// yyyy-mm-dd → datetime64).
"m/d", "mm-dd", "d-m", "mm/dd hh:mm",
} {
if !isDateNumberFormat(nf) {
t.Errorf("%q should be a date format", nf)
}
}
for _, nf := range []string{"h:mm", "hh:mm:ss", "h:mm am/pm"} {
if isDateNumberFormat(nf) {
t.Errorf("time-only format %q must not be inferred as a calendar date", nf)
}
}
// JPY (and other currency / unit prefixes that happen to contain a lone Y)
// were previously misread as dates because the scanner saw any lowercase 'y'.
// Pure numeric formats, escaped/quoted literals, and bracket sections must
// never trigger the date branch — those rows would have round-tripped
// integer cells as ISO dates.
for _, nf := range []string{
"#,##0", "0.00", "0.00%", "@", "",
"JPY #,##0", "JPY 0", `"YEN "#,##0`, "[$JPY-411] #,##0",
`\y\y`, // escaped letters, not a year token
"General", // letters outside the date-token set
"0.00E+00", // scientific: 'e' is not a date token
"USD #,##0",
} {
if isDateNumberFormat(nf) {
t.Errorf("%q should NOT be a date format", nf)
}
}
}
func TestTableGet_InferColumnType(t *testing.T) {
t.Parallel()
mk := func(v interface{}, nf string) map[string]interface{} {
c := map[string]interface{}{"value": v}
if nf != "" {
c["cell_styles"] = map[string]interface{}{"number_format": nf}
}
return c
}
col := func(cells ...map[string]interface{}) [][]map[string]interface{} {
rows := make([][]map[string]interface{}, len(cells))
for i, c := range cells {
rows[i] = []map[string]interface{}{c}
}
return rows
}
if typ, f := inferColumnType(col(mk(45306.0, "yyyy-mm-dd")), 0); typ != "date" || f != "yyyy-mm-dd" {
t.Errorf("date col → %s/%s", typ, f)
}
if typ, f := inferColumnType(col(mk(100.0, "#,##0")), 0); typ != "number" || f != "#,##0" {
t.Errorf("number col → %s/%s", typ, f)
}
if typ, _ := inferColumnType(col(mk(true, "")), 0); typ != "bool" {
t.Errorf("bool col → %s", typ)
}
if typ, _ := inferColumnType(col(mk("x", "")), 0); typ != "string" {
t.Errorf("string col → %s", typ)
}
// digit-like value carrying text format (@) infers as string, not number —
// this is what makes +table-put's string columns (ids/postcodes) survive read-back.
if typ, _ := inferColumnType(col(mk(123.0, "@")), 0); typ != "string" {
t.Errorf("@-format numeric-looking col → %s, want string", typ)
}
if typ, _ := inferColumnType([][]map[string]interface{}{}, 0); typ != "string" {
t.Errorf("empty col → %s (want string)", typ)
}
// Mixed number+text degrades to string (self-consistent: every value is then
// a string), so the column round-trips and pandas doesn't choke. Numeric
// coercion of the dirty cells is left to the caller (pandas to_numeric).
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("N/A", ""), mk(200.0, "")), 0); typ != "string" {
t.Errorf("mixed number+text col → %s, want string", typ)
}
// A bare number mixed into a date column must NOT stay date (would serial-
// convert the number into a bogus date) — degrades to string.
if typ, _ := inferColumnType(col(mk(45306.0, "yyyy-mm-dd"), mk(5.0, "")), 0); typ != "string" {
t.Errorf("date+bare-number col → %s, want string", typ)
}
}
func TestTableGet_CellToTyped(t *testing.T) {
t.Parallel()
mk := func(v interface{}) map[string]interface{} { return map[string]interface{}{"value": v} }
if v := cellToTyped(mk(45306.0), "date"); v != "2024-01-15" {
t.Errorf("date serial → %v, want 2024-01-15", v)
}
if v := cellToTyped(mk(100.0), "number"); v != 100.0 {
t.Errorf("number → %v", v)
}
if v := cellToTyped(mk(true), "bool"); v != true {
t.Errorf("bool → %v", v)
}
if v := cellToTyped(mk(""), "string"); v != nil {
t.Errorf("empty string → %v, want nil", v)
}
if v := cellToTyped(nil, "string"); v != nil {
t.Errorf("nil → %v, want nil", v)
}
if v := cellToTyped(mk("hi"), "string"); v != "hi" {
t.Errorf("string → %v", v)
}
}
// TestTableGet_DigitStringRoundTrip: a column +table-put wrote as string (text
// format @) reads back as string, not number — so leading-zero ids / postcodes
// survive instead of collapsing to a number.
func TestTableGet_DigitStringRoundTrip(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"S","row_count":200,"column_count":20,"index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"邮编"}],`+
`[{"value":"00123","cell_styles":{"number_format":"@"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, structure, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
s0, _ := sheets[0].(map[string]interface{})
cols, _ := s0["columns"].([]interface{})
if cols[0] != "邮编" {
t.Errorf("columns[0] = %v, want 邮编", cols[0])
}
dtypes, _ := s0["dtypes"].(map[string]interface{})
if dtypes["邮编"] != "object" {
t.Errorf("dtypes[邮编] = %v, want object (text-format column round-trips as string)", dtypes["邮编"])
}
// The writer paints `@` on string columns so digit-like text survives;
// surfacing that back as a user-set format would round-trip noisily, so the
// reader strips it. Hence: no "formats" key at all on an all-string sheet.
if _, has := s0["formats"]; has {
t.Errorf("@ is a writer convention, must NOT surface in formats: %#v", s0["formats"])
}
rows, _ := s0["data"].([]interface{})
if r0, _ := rows[0].([]interface{}); r0[0] != "00123" {
t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0])
}
}
// TestTableGet_ExecuteRoundTrip reads a sheet back and checks the output is the
// same typed protocol +table-put consumes: date serial → ISO, number preserved,
// types inferred from number_format.
func TestTableGet_ExecuteRoundTrip(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"销售","row_count":200,"column_count":20,"index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:C2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"门店"},{"value":"月份"},{"value":"销售额"}],`+
`[{"value":"北京"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm"}},{"value":259874,"cell_styles":{"number_format":"#,##0"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "销售"}, structure, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("want 1 sheet, got %d", len(sheets))
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "销售" {
t.Errorf("name = %v, want 销售", s0["name"])
}
cols, _ := s0["columns"].([]interface{})
if len(cols) != 3 {
t.Fatalf("want 3 columns, got %d", len(cols))
}
if cols[0] != "门店" || cols[1] != "月份" || cols[2] != "销售额" {
t.Errorf("columns = %#v, want [门店 月份 销售额]", cols)
}
dtypes, _ := s0["dtypes"].(map[string]interface{})
if dtypes["月份"] != "datetime64[ns]" {
t.Errorf("dtypes[月份] = %v, want datetime64[ns]", dtypes["月份"])
}
if dtypes["销售额"] != "float64" {
t.Errorf("dtypes[销售额] = %v, want float64 (numeric)", dtypes["销售额"])
}
if dtypes["门店"] != "object" {
t.Errorf("dtypes[门店] = %v, want object (string column)", dtypes["门店"])
}
formats, _ := s0["formats"].(map[string]interface{})
if formats["月份"] != "yyyy-mm" {
t.Errorf("formats[月份] = %v, want yyyy-mm (number_format preserved)", formats["月份"])
}
if formats["销售额"] != "#,##0" {
t.Errorf("formats[销售额] = %v, want #,##0", formats["销售额"])
}
rows, _ := s0["data"].([]interface{})
r0, _ := rows[0].([]interface{})
if r0[1] != "2024-01-15" {
t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1])
}
if r0[2] != float64(259874) {
t.Errorf("number = %v, want 259874", r0[2])
}
}
// TestTableGet_DuplicateHeaderRejected covers a fangshuyu-flagged round-trip
// hole: a sheet with two columns named the same (e.g. `amount, amount`) used
// to read back successfully — with the dtypes map silently collapsing to a
// single entry — and then fail on the immediate +table-put write because the
// writer rejects duplicate column names. Fail fast at read time with an
// actionable hint to rename or pass --no-header instead.
func TestTableGet_DuplicateHeaderRejected(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"S","row_count":200,"column_count":20,"index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:B2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"amount"},{"value":"amount"}],`+
`[{"value":1},{"value":2}]`+
`]}]}`)
_, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, structure, region, cells)
ve := requireValidation(t, err, "duplicate header column name")
if !strings.Contains(ve.Message, "--no-header") {
t.Errorf("error should hint about --no-header; got message=%q", ve.Message)
}
}
// TestTableGet_SheetIDFallbackBackfillsName covers the fallback path: when
// get_workbook_structure cannot enumerate the target row (e.g. selector
// mismatch), the target name was previously left empty — which would later
// fail the +table-put round-trip because the writer requires a non-empty
// sheet name. The target should fall back to using the id as the name.
func TestTableGet_SheetIDFallbackBackfillsName(t *testing.T) {
t.Parallel()
// A successful structure read that does not contain the requested selector
// is a real locator error; do not fabricate a sheet name from the id and
// continue with a dimensionless A1 probe.
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"shtOther","sheet_name":"另一张","row_count":200,"column_count":20,"index":0}]}`)
_, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-id", testSheetID}, structure)
requireValidation(t, err, "was not found in workbook")
}
// TestTablePutFullRange_EmptyMatrix covers the dry-run report for the
// header=false + rows=[] case. Previously totalRows=0 produced an invalid
// "A1:C0"; the helper now returns an empty range string instead.
func TestTablePutFullRange_EmptyMatrix(t *testing.T) {
t.Parallel()
s := &tableSheetSpec{Name: "S", Columns: []tableColumnSpec{{Name: "a"}, {Name: "b"}, {Name: "c"}}, StartCell: "A1"}
if got := tablePutFullRange(s, 0); got != "" {
t.Errorf("tablePutFullRange(_, 0) = %q, want \"\" (no invalid A1:C0)", got)
}
// Sanity check: positive totals still produce a real rectangle so we
// haven't broken the happy path.
if got := tablePutFullRange(s, 2); got != "A1:C2" {
t.Errorf("tablePutFullRange(_, 2) = %q, want A1:C2", got)
}
}
// TestTableGet_OutputRoundTripsBackIntoTablePut is the contract test: the
// output of +table-get must be a payload +table-put accepts. This catches
// dtype/format symmetry breaks early — if the reader ever emits a dtype the
// writer doesn't recognize (or under a key the writer doesn't read), pipe-back
// loops in agent scripts would fail with a confusing JSON error instead of a
// schema error here.
func TestTableGet_OutputRoundTripsBackIntoTablePut(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"S","row_count":200,"column_count":20,"index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:D2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"city"},{"value":"day"},{"value":"revenue"},{"value":"closed"}],`+
`[{"value":"BJ"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm-dd"}},{"value":1234.5,"cell_styles":{"number_format":"#,##0.00"}},{"value":true}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, structure, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
// The reader's "sheets" array is the same key +table-put consumes, so wrap
// the whole `data` envelope back as a fresh --sheets payload and parse it.
// A name is mandatory on each sheet, so make sure it survived.
body, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal reader output: %v", err)
}
parsed, err := parseTablePutPayload(stubFlagView{"sheets": string(body)})
if err != nil {
t.Fatalf("reader output rejected by writer: %v\npayload=%s", err, body)
}
if len(parsed.Sheets) != 1 {
t.Fatalf("round-trip lost sheets: %#v", parsed.Sheets)
}
s := parsed.Sheets[0]
if s.Name != "S" {
t.Errorf("name = %q, want S", s.Name)
}
wantCols := []tableColumnSpec{
{Name: "city", Type: "string", Format: "@"},
{Name: "day", Type: "date", Format: "yyyy-mm-dd"},
{Name: "revenue", Type: "number", Format: "#,##0.00"},
{Name: "closed", Type: "bool", Format: ""},
}
for i, w := range wantCols {
if s.Columns[i] != w {
t.Errorf("columns[%d] after round-trip = %+v, want %+v", i, s.Columns[i], w)
}
}
if len(s.Rows) != 1 || len(s.Rows[0]) != 4 {
t.Fatalf("rows shape changed: %#v", s.Rows)
}
}
func TestTableGet_DryRunIncludesCellRead(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"})
found := false
for _, c := range calls {
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
if body == nil {
continue
}
if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" {
found = true
}
}
if !found {
t.Error("dry-run should include a get_cell_ranges read")
}
}
// TestTableGet_DryRunMirrorsExecuteSelector pins the dry-run get_cell_ranges
// body to the same selector field Execute will send. Agents validate the
// dry-run shape and then expect the live call to look the same — without this
// the selector field appeared in Execute but not in dry-run, so a request
// shape mismatch would have been invisible.
func TestTableGet_DryRunMirrorsExecuteSelector(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantField string
wantValue string
otherField string // must NOT be present
}{
{
name: "by --sheet-name",
args: []string{"--url", testURL, "--sheet-name", "销售"},
wantField: "sheet_name",
wantValue: "销售",
otherField: "sheet_id",
},
{
name: "by --sheet-id",
args: []string{"--url", testURL, "--sheet-id", testSheetID},
wantField: "sheet_id",
wantValue: testSheetID,
otherField: "sheet_name",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TableGet, tt.args)
var cellInput map[string]any
for _, c := range calls {
body, _ := c.(map[string]any)["body"].(map[string]any)
if body == nil {
continue
}
if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" {
cellInput = decodeToolInput(t, body, "get_cell_ranges")
}
}
if cellInput == nil {
t.Fatalf("dry-run had no get_cell_ranges body; calls=%#v", calls)
}
if got := cellInput[tt.wantField]; got != tt.wantValue {
t.Errorf("get_cell_ranges body %s = %#v, want %q", tt.wantField, got, tt.wantValue)
}
if _, has := cellInput[tt.otherField]; has {
t.Errorf("get_cell_ranges body should not carry %s when only the other selector was given; got=%#v", tt.otherField, cellInput)
}
})
}
}
// TestTableGet_AllSheets covers the "read every sheet" path (no --sheet-name):
// get_workbook_structure lists sheets, then each is read in order.
func TestTableGet_AllSheets(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"s1","sheet_name":"A","row_count":200,"column_count":20,"index":0},{"sheet_id":"s2","sheet_name":"B","row_count":200,"column_count":20,"index":1}]}`)
regionA := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsA := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"x"}]]}]}`)
regionB := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsB := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"y"}]]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL}, structure, regionA, cellsA, regionB, cellsB)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 2 {
t.Fatalf("want 2 sheets (all), got %d", len(sheets))
}
got := []string{
sheets[0].(map[string]interface{})["name"].(string),
sheets[1].(map[string]interface{})["name"].(string),
}
if got[0] != "A" || got[1] != "B" {
t.Errorf("sheet names = %v, want [A B] in workbook order", got)
}
}
// ─── +table-get: used-range probe (the pro016 / pro025 fix) ───────────
// anchorRecorderStub returns a BodyFilter-equipped reusable stub that records
// the `range` field of every get_range_as_csv request, so a test can assert the
// used-range probe spans the full physical grid rather than anchoring at A1.
func anchorRecorderStub(token, outputJSON string, anchors *[]string) *httpmock.Stub {
s := toolOutputStub(token, "read", outputJSON)
s.Reusable = true
s.BodyFilter = func(body []byte) bool {
var wire struct {
ToolName string `json:"tool_name"`
Input string `json:"input"`
}
if err := json.Unmarshal(body, &wire); err != nil || wire.ToolName != "get_range_as_csv" {
return false
}
var in struct {
Range string `json:"range"`
}
_ = json.Unmarshal([]byte(wire.Input), &in)
*anchors = append(*anchors, in.Range)
return true
}
return s
}
// TestTableGet_DefaultProbesFullGrid is the core regression for the pro016 /
// pro025 incident: with no --range, the used-range probe must anchor over the
// whole physical grid (A1:<lastCol><lastRow>) — not A1 — so current_region spans
// internal blank rows/columns and the returned range is the true used range.
func TestTableGet_DefaultProbesFullGrid(t *testing.T) {
t.Parallel()
// grid is 200×20 → last column index 19 → "T". The data spans an internal
// blank row (row 6) and blank column (D), so the true used range is A1:F10.
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"S","row_count":200,"column_count":20,"index":0}]}`)
var anchors []string
region := anchorRecorderStub(testToken, `{"current_region":"A1:F10"}`, &anchors)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"h"}],[{"value":"x"}]]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, structure, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if len(anchors) != 1 || anchors[0] != "A1:T200" {
t.Fatalf("used-range probe anchor = %v, want one [A1:T200] (full grid, not A1)", anchors)
}
data := decodeEnvelopeData(t, out)
s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{})
if s0["range"] != "A1:F10" {
t.Errorf("output range = %v, want A1:F10 (true used range reported back)", s0["range"])
}
}
// TestTableGet_UnknownDimsFallBackToA1 locks the fail-open path: when the
// structure read doesn't report row_count / column_count, the probe degrades to
// the legacy A1 anchor rather than building a bogus range or erroring.
func TestTableGet_UnknownDimsFallBackToA1(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"S","index":0}]}`) // no row/col counts
var anchors []string
region := anchorRecorderStub(testToken, `{"current_region":"A1:B2"}`, &anchors)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"h"}],[{"value":"x"}]]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, structure, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if len(anchors) != 1 || anchors[0] != "A1" {
t.Fatalf("anchor = %v, want one [A1] (fallback when dims unknown)", anchors)
}
}
// TestTableGet_SheetIDBackfillsName: when only --sheet-id is given, the structure
// read backfills the sheet's display name into the output spec (previously left
// empty), a free win from now always reading the structure.
func TestTableGet_SheetIDBackfillsName(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"销售","row_count":200,"column_count":20,"index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"h"}],[{"value":"x"}]]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-id", testSheetID}, structure, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{})
if s0["name"] != "销售" {
t.Errorf("name = %v, want 销售 (backfilled from structure)", s0["name"])
}
}
// TestTableGet_EmptySheetReportsEmptyRange: an empty sheet still carries a range
// key (empty string) so callers can rely on the field always being present.
func TestTableGet_EmptySheetReportsEmptyRange(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"S","row_count":200,"column_count":20,"index":0}]}`)
region := toolOutputStub(testToken, "read", `{}`) // no current_region → empty sheet
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, structure, region)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{})
if r, has := s0["range"]; !has || r != "" {
t.Errorf("empty sheet range = %#v (has=%v), want \"\"", r, has)
}
}
// TestTablePut_AppendProbesFullGrid is the append-side mirror of the table-get
// fix: lastDataRow must anchor its current_region probe over the whole grid so an
// internal blank row doesn't make append land on top of (overwrite) later data.
func TestTablePut_AppendProbesFullGrid(t *testing.T) {
t.Parallel()
// Existing data really ends at row 10 (a blank row 6 sits in the middle).
// Under an A1 anchor current_region would stop at row 5 and append would
// write to A6 — clobbering rows 7-10. The full-grid anchor reports A1:B10.
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","row_count":200,"column_count":20,"index":0}]}`)
var anchors []string
region := anchorRecorderStub(testToken, `{"current_region":"A1:B10"}`, &anchors)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"日志","mode":"append","columns":["时间","值"],"dtypes":{"值":"int64"},"data":[["t1",1]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if len(anchors) != 1 || anchors[0] != "A1:T200" {
t.Fatalf("append last-data-row probe anchor = %v, want one [A1:T200]", anchors)
}
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
if input["range"] != "A11:B11" {
t.Errorf("append range = %v, want A11:B11 (below true last row 10, not on top of data past the blank row)", input["range"])
}
}
// TestBuildTypedCell_TypeLess verifies a type-less column (Type == "") writes the
// raw scalar unchanged — no @ text format, json.Number preserved — so untyped
// --values lets the backend auto-detect types. An explicit format still attaches.
func TestBuildTypedCell_TypeLess(t *testing.T) {
t.Parallel()
num := json.Number("145487")
pct := json.Number("0.1")
cases := []struct {
name string
col tableColumnSpec
raw interface{}
wantVal interface{}
wantNF interface{}
}{
{"number stays json.Number", tableColumnSpec{Name: "c"}, num, num, nil},
{"string verbatim", tableColumnSpec{Name: "c"}, "00123", "00123", nil},
{"bool verbatim", tableColumnSpec{Name: "c"}, true, true, nil},
{"nil → empty cell", tableColumnSpec{Name: "c"}, nil, nil, nil},
{"explicit format attaches", tableColumnSpec{Name: "c", Format: "0.0%"}, pct, pct, "0.0%"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tc.col, tc.raw)
if err != nil {
t.Fatalf("buildTypedCell err: %v", err)
}
if cell["value"] != tc.wantVal {
t.Errorf("value = %#v, want %#v", cell["value"], tc.wantVal)
}
var gotNF interface{}
if cs, _ := cell["cell_styles"].(map[string]interface{}); cs != nil {
gotNF = cs["number_format"]
}
if gotNF != tc.wantNF {
t.Errorf("number_format = %#v, want %#v", gotNF, tc.wantNF)
}
})
}
}
// TestValidColumnType_AcceptsEmpty locks that an empty type is valid — the
// type-less / raw-passthrough column that --values synthesizes.
func TestValidColumnType_AcceptsEmpty(t *testing.T) {
t.Parallel()
for _, ty := range []string{"", "string", "number", "date", "bool"} {
if !validColumnType(ty) {
t.Errorf("validColumnType(%q) = false, want true", ty)
}
}
if validColumnType("float") {
t.Error(`validColumnType("float") = true, want false`)
}
}
// TestTableGet_CharBudgetSpansTheWholeWorkbook pins that --max-chars bounds the
// WHOLE multi-sheet read, not each sheet independently.
//
// The cap is a memory guard on a non-streaming path, so letting every sheet
// spend it in full would let a 30-sheet workbook pull 30x what the caller
// allowed — quietly, since each individual request looks compliant. The clamp
// that prevents it (charBudget in readSheetAsSpec) was unpinned: removing it
// passed the entire suite, because the outer loop's exhaustion check is a
// separate mechanism and keeps working.
//
// Asserted on the wire: sheet 2's request must ask for less than sheet 1's.
func TestTableGet_CharBudgetSpansTheWholeWorkbook(t *testing.T) {
t.Parallel()
const budget = 40000
structure := toolOutputStub(testToken, "read", `{"sheets":[`+
`{"sheet_id":"sh1","sheet_name":"S1","row_count":50,"column_count":3,"index":0},`+
`{"sheet_id":"sh2","sheet_name":"S2","row_count":50,"column_count":3,"index":1}`+
`]}`)
// One reusable stub answers both the current-region probes and the cell
// reads; every captured body is inspected below.
payload := `{"current_region":"A1:B2","ranges":[{"cells":[` +
`[{"value":"col1"},{"value":"col2"}],` +
`[{"value":"a"},{"value":"b"}]` +
`]}]}`
reads := toolOutputStub(testToken, "read", payload)
reads.Reusable = true
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--max-chars", strconv.Itoa(budget)}, structure, reads)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var caps []int
for _, body := range reads.CapturedBodies {
var wire struct {
ToolName string `json:"tool_name"`
Input string `json:"input"`
}
if json.Unmarshal(body, &wire) != nil || wire.ToolName != "get_cell_ranges" {
continue
}
var input struct {
MaxChars int `json:"max_chars"`
}
if json.Unmarshal([]byte(wire.Input), &input) != nil || input.MaxChars == 0 {
continue
}
caps = append(caps, input.MaxChars)
}
if len(caps) < 2 {
t.Fatalf("want a cell read per sheet, captured caps = %v", caps)
}
if caps[0] > budget {
t.Errorf("first sheet asked for max_chars=%d, over the %d budget", caps[0], budget)
}
if caps[1] >= caps[0] {
t.Errorf("second sheet asked for max_chars=%d, not reduced by what the first consumed (%d) — the budget is per-workbook, not per-sheet", caps[1], caps[0])
}
}
// TestPositionalColumnLabels covers the dtypes / formats array form: the same
// pandas habit that produces `columns` and `data` also produces a positional
// `df.dtypes.tolist()`, and rejecting it cost a full retry on payloads that
// were otherwise correct (08-18..24 eval, the largest --sheets decode failure).
// Accepting it is only unambiguous when the array lines up 1:1 with columns.
func TestPositionalColumnLabels(t *testing.T) {
t.Parallel()
t.Run("positional dtypes and formats zip onto columns", func(t *testing.T) {
t.Parallel()
p, err := parseTablePutPayload(newMapFlagViewForCommand("+table-put", map[string]interface{}{
"sheets": `{"sheets":[{"name":"S","columns":["id","amt"],"data":[["001",1.5]],` +
`"dtypes":["object","float64"],"formats":[null,"#,##0.00"]}]}`,
}))
if err != nil {
t.Fatalf("positional labels rejected: %v", err)
}
want := []tableColumnSpec{
{Name: "id", Type: "string", Format: "@"}, // object → text, leading zero survives
{Name: "amt", Type: "number", Format: "#,##0.00"}, // float64 + its positional format
}
for i, w := range want {
if got := p.Sheets[0].Columns[i]; got != w {
t.Errorf("columns[%d] = %+v, want %+v", i, got, w)
}
}
})
t.Run("a mismatched length is rejected, not guessed", func(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(newMapFlagViewForCommand("+table-put", map[string]interface{}{
"sheets": `{"sheets":[{"name":"S","columns":["id","amt"],"data":[["001",1.5]],"dtypes":["object"]}]}`,
}))
ve := requireValidation(t, err, "positional array of 1 entries but the sheet has 2 columns")
if !strings.Contains(ve.Hint, "line up 1:1") {
t.Errorf("hint should explain the alignment rule, got %q", ve.Hint)
}
})
t.Run("the map form is unchanged", func(t *testing.T) {
t.Parallel()
p, err := parseTablePutPayload(newMapFlagViewForCommand("+table-put", map[string]interface{}{
"sheets": `{"sheets":[{"name":"S","columns":["id","amt"],"data":[["001",1.5]],"dtypes":{"amt":"float64"}}]}`,
}))
if err != nil {
t.Fatalf("map form rejected: %v", err)
}
if got := p.Sheets[0].Columns[1].Type; got != "number" {
t.Errorf("columns[1].Type = %q, want number", got)
}
})
t.Run("an unknown key in the map form still errors", func(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(newMapFlagViewForCommand("+table-put", map[string]interface{}{
"sheets": `{"sheets":[{"name":"S","columns":["id"],"data":[["001"]],"dtypes":{"nope":"float64"}}]}`,
}))
requireValidation(t, err, `dtypes references unknown column "nope"`)
})
}
// TestTablePut_ReflowLeniency pins the --sheets acceptances added after the
// 08-29..31 reflow report, where the payload was otherwise correct and every
// retry rewrote the same data in a different spelling. Each case states the
// rejection count it removes; the rejections that remain are the ones where
// the caller's intent is genuinely unclear.
func TestTablePut_ReflowLeniency(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
sheets string
want []string // substrings the planned write body must carry
}{
{
name: "numeric column written as strings (155 rejections)",
sheets: `{"sheets":[{"name":"S","columns":["n","t"],"dtypes":{"n":"Int64"},"data":[["1","a"],["2.5","b"],["","c"]]}]}`,
want: []string{`"value":1`, `"value":2.5`},
},
{
name: "short rows padded to the column count (25)",
sheets: `{"sheets":[{"name":"S","columns":["a","b","c"],"data":[["title"]]}]}`,
want: []string{`"value":"title"`},
},
{
name: "object-per-column entries (35)",
sheets: `{"sheets":[{"name":"S","columns":[{"name":"n","dtype":"float64"},{"name":"t"}],"data":[[1.5,"a"]]}]}`,
want: []string{`"value":1.5`, `"value":"n"`},
},
{
name: "the writer's own type vocabulary as a dtype",
sheets: `{"sheets":[{"name":"S","columns":["n"],"dtypes":{"n":"number"},"data":[[1.5]]}]}`,
want: []string{`"value":1.5`},
},
{
// A spacer column, or the empty cells under a merged title (13).
name: "a blank column heading writes a blank header cell",
sheets: `{"sheets":[{"name":"S","columns":["a","","c"],"data":[["1","2","3"]]}]}`,
want: []string{`"value":""`, `"value":"c"`},
},
{
// A total row or a trailing blank inside a date column (7).
name: "blank text in a date column is a blank cell",
sheets: `{"sheets":[{"name":"S","columns":["d"],"dtypes":{"d":"datetime64[ns]"},"data":[["2026-01-01"],[""]]}]}`,
want: []string{`"number_format":"yyyy-mm-dd"`},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL, "--sheets", tc.sheets, "--dry-run",
})
if err != nil {
t.Fatalf("payload should be accepted, got: %v", err)
}
// The tool body is a JSON string inside the dry-run envelope, so
// its own quotes arrive escaped.
plain := strings.ReplaceAll(stdout, `\"`, `"`)
for _, want := range tc.want {
if !strings.Contains(plain, want) {
t.Errorf("write body should carry %s, got %q", want, stdout)
}
}
})
}
t.Run("a column-less sheet plans no write", func(t *testing.T) {
t.Parallel()
stdout, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[],"data":[]}]}`, "--dry-run",
})
if err != nil {
t.Fatalf("an empty sheet should be accepted, got: %v", err)
}
if strings.Contains(stdout, "set_cell_range") {
t.Errorf("nothing to write, so no set_cell_range should be planned, got %q", stdout)
}
})
t.Run("a genuinely duplicated heading still fails", func(t *testing.T) {
t.Parallel()
// Blank headings are exempt from the duplicate check (several blanks
// are one table shape); two real names that collide are not.
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":["a","a"],"data":[["x","y"]]}]}`, "--dry-run",
})
requireValidation(t, err, "duplicate column name")
})
t.Run("a non-numeric string in a numeric column still fails", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"S","columns":["n"],"dtypes":{"n":"Int64"},"data":[["1,234"]]}]}`,
"--dry-run",
})
// A thousands separator is either one number or two cells that lost
// their split — stripping it would guess at a locale.
requireValidation(t, err, "non-numeric string")
})
t.Run(`the literal "null" is not a number`, func(t *testing.T) {
t.Parallel()
// "null" decodes into a json.Number without an error and leaves it
// empty, which then marshals back out as 0 — a zero written into the
// cell under a success exit code. A blank cell is JSON null, not the
// four-letter word.
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"S","columns":["n"],"dtypes":{"n":"Int64"},"data":[["null"]]}]}`,
"--dry-run",
})
requireValidation(t, err, "non-numeric string")
})
t.Run("append with cell_styles plans the style-only write", func(t *testing.T) {
t.Parallel()
// header:false with no data rows leaves an empty matrix, but Execute
// expands it through the styles before deciding whether to write. The
// plan has to model that expansion or it shows no write where one
// happens; the range stays dynamic because the base row is resolved
// against the live sheet.
stdout, _, err := runShortcutCapturingErr(t, TablePut, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"S","mode":"append","header":false,"columns":["a"],"data":[]}]}`,
"--styles", `{"styles":[{"name":"S","cell_styles":[{"range":"A1:B2","background_color":"#FFE6E6"}]}]}`,
"--dry-run",
})
if err != nil {
t.Fatalf("the styled append should be planned, got: %v", err)
}
if !strings.Contains(stdout, "set_cell_range") {
t.Errorf("a style-only append still writes, so the plan must show it, got %q", stdout)
}
if !strings.Contains(stdout, "append below existing data") {
t.Errorf("the append range must stay dynamic in the plan, got %q", stdout)
}
})
}