Files
dingtalk-workspace-cli/internal/helpers/coverage_pure_more_test.go
huwenjiao.hwj 0bb2b6ce98 feat(sheet): CSV export, style extensions and create-with-data
Adds four capabilities and, for the style surface, moves to the interface that
can actually express them.

Added:
- sheet create --values / --sheets / --styles: create a workbook and populate it
  in one command. --values takes a 2D array into the default sheet, --sheets
  takes typed tables across several sheets, --styles carries cell_styles /
  row_sizes / col_sizes / cell_merges. Every structure and enum is validated
  before the document is created, so an invalid config never leaves an orphan
  empty document behind.
- sheet export --export-format csv: synchronous single-sheet RFC4180 export with
  --sheet-id, --range and --value-render-option. --output writes to a file (a
  directory gets sheet-export.csv), otherwise the CSV goes to stdout while the
  truncation warning goes to stderr, keeping stdout pipeable.
- sheet update-dimension --size-type: pixel / standard (restore the default row
  height or column width) / auto (fit row height to content, ROWS only).
- sheet replace --match-formula: search and replace inside formula text.
- sheet range set-style --font-style / --font-line / --font-family /
  --border-styles-json.
- sheet range batch-set-style --ranges: stamp one style across several
  sheet-qualified ranges.

Changed (breaking Schema change, no CLI break):
- sheet range set-style moves from update_range to set_cell_range. The
  update_range style channel exposes exactly eight properties
  (backgroundColors, fontSizes, horizontalAlignments, verticalAlignments,
  fontColors, fontWeights, wordWrap, numberFormat) and has no slot for italic,
  underline/line-through, font family or borders, so the four new dimensions are
  not expressible there. interface_ref becomes set_cell_range and the twelve
  style flags stop publishing a flat property, because the value now lands in
  cells[i][j].cellStyles.* with no single top-level field to name.
- sheet range batch-set-style submits one atomic batch_update instead of looping
  update_range, so a partial failure no longer leaves half the ranges stamped.
  --continue-on-error becomes a server passthrough. Caps the fan-out at 100
  ranges and 200000 cells in aggregate.
- sheet export and sheet create declare interface_mode=composite: both route
  across several tools depending on the flags, so a single mcp ref was wrong.

Schema hygiene:
- Nineteen parameters that previously resolved through flag_name_inference into
  property names present in no request (bgColor, exportFormat, values, ...) are
  now reviewed mapping exclusions with a stated reason, so Schema omits the
  property instead of inventing one.

schema-compatibility reports 17 changed fields: 13 on sheet.range_set_style
(interface_ref plus twelve property mappings) and 2 each on
sheet.submit_export_job and sheet.create_workspace_sheet (interface_mode plus
interface_ref). No CLI flag is removed and no command path changes; the same
invocation runs on both the old and the new binary. Landing this needs a
decision on the Schema contract break.

Tests: targeted sheet suites in internal/helpers and the interface disposition
contract tests in internal/app pass; make build and gofmt clean;
check-schema-catalog, check-generated-drift, check-command-surface,
check-skill-commands and check-runtime-confirmation-truth all pass.
2026-08-07 20:25:21 +08:00

291 lines
10 KiB
Go

package helpers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoveragePureScalarAndCommandHelpersCoverage(t *testing.T) {
previousDeps := deps
InitDeps(&productExampleCaller{})
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() { deps = previousDeps })
for _, value := range []any{" text ", float64(12), int64(13), 14, json.Number("15"), true, nil} {
_ = toFlatString(value)
}
for _, value := range []any{"2026-01-02", "2026-01-02 03:04:05", "bad", float64(1), int64(2), 3, true} {
_, _ = normalizeWorkDate(value)
}
for _, value := range []string{"", "12", " 34 ", "1a"} {
_ = isNumericUserID(value)
}
root := &cobra.Command{Use: "calendar"}
known := &cobra.Command{Use: "event", Aliases: []string{"e"}}
hidden := &cobra.Command{Use: "hidden", Hidden: true}
root.AddCommand(known, hidden)
for _, args := range [][]string{{"--x"}, {"event"}, {"e"}, {"missing"}} {
_ = findUnknownVerb(root, args)
}
printUnknownSubcmdError(root, "evnt")
for _, depth := range []int{0, 1, 3} {
_ = stripCommandPrefix([]string{"calendar", "event", "--x"}, depth)
}
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
root.Flags().StringP("known", "k", "", "")
for _, args := range [][]string{
{"dws", "calendar", "--known=x"},
{"dws", "calendar", "--unknown"},
{"dws", "calendar", "-z"},
{"dws", "calendar", "--", "--ignored"},
} {
os.Args = args
_ = findUnknownFlag(root)
}
for _, event := range []any{
nil,
map[string]any{"start": map[string]any{"dateTime": "2026-01-02T03:04:05Z"}},
map[string]any{"start": map[string]any{"dateTime": "bad"}, "created": float64(2)},
map[string]any{"updated": float64(3)},
map[string]any{},
} {
_ = calendarEventSortKey(event)
}
_ = buildReminders("5,bad,0,10")
params := map[string]string{"existing": "old"}
putChatChmodParam(params, "blank", "")
putChatChmodParam(params, "existing", "new")
putChatChmodParam(params, "new", " value ")
meta := conversationLocalFileMeta{FileName: "x.txt", FileType: "text/plain", ContentPath: "/tmp/x", FileSize: 3}
if _, err := buildConversationFileContent(1, 2, meta); err != nil {
t.Fatal(err)
}
for _, value := range []any{json.Number("1"), json.Number("bad"), float64(2), float64(-1), int64(3), 4, "5", "bad", true} {
_, _ = int64FromJSONScalar(value)
}
}
func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T) {
valid := map[string]any{
"position": map[string]any{"row": float64(1), "col": float64(1)},
"dimensions": map[string]any{"width": float64(10), "height": float64(10)},
"chart": map[string]any{
"type": "line",
"series": []any{map[string]any{"value": []any{"A1:A2"}}},
},
}
chartCases := []map[string]any{
{},
{"position": "bad"},
{"position": map[string]any{"col": 1}},
{"position": map[string]any{"row": 1}},
{"position": map[string]any{"row": 1, "col": 1}},
}
for _, props := range chartCases {
_ = validateChartProperties(props)
}
mutations := []func(map[string]any){
func(m map[string]any) { m["dimensions"] = "bad" },
func(m map[string]any) { m["dimensions"] = map[string]any{"height": 1} },
func(m map[string]any) { m["dimensions"] = map[string]any{"width": float64(-1), "height": 1} },
func(m map[string]any) { m["dimensions"] = map[string]any{"width": 1} },
func(m map[string]any) { m["dimensions"] = map[string]any{"width": 1, "height": float64(-1)} },
func(m map[string]any) { delete(m, "chart") },
func(m map[string]any) { m["chart"] = "bad" },
func(m map[string]any) { m["chart"] = map[string]any{} },
func(m map[string]any) { m["chart"] = map[string]any{"type": 1} },
func(m map[string]any) { m["chart"] = map[string]any{"type": "bad"} },
func(m map[string]any) { m["chart"] = map[string]any{"type": "line"} },
func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": "bad"} },
func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": []any{}} },
func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": []any{"bad"}} },
func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": []any{map[string]any{}}} },
func(m map[string]any) {
m["chart"] = map[string]any{"type": "line", "series": []any{map[string]any{"value": "bad"}}}
},
func(m map[string]any) {
m["chart"] = map[string]any{"type": "line", "series": []any{map[string]any{"value": []any{}}}}
},
}
for _, mutate := range mutations {
var clone map[string]any
raw, _ := json.Marshal(valid)
_ = json.Unmarshal(raw, &clone)
mutate(clone)
_ = validateChartProperties(clone)
}
if err := validateChartProperties(valid); err != nil {
t.Fatal(err)
}
mailCases := []string{
`{`,
`[1]`,
`[{"object":"bad","or":[]}]`,
`[{"object":"from","or":"bad"}]`,
`[{"object":"from","or":[1]}]`,
`[{"object":"from","or":[{"and":"bad"}]}]`,
`[{"object":"from","or":[{"and":[1]}]}]`,
`[{"object":"from","or":[{"and":[{"operation":"bad"}]}]}]`,
`[{"object":"from","or":[{"and":[{"operation":"include"}]}]}]`,
}
for _, raw := range mailCases {
_, _ = parseMailRuleConditions(raw)
}
_ = generateContentId("My Image.PNG", 2)
_ = validateInlineAttachmentType("x.png")
_ = validateInlineAttachmentType("x.pdf")
_ = inlineHtmlTag("cid", "x.png")
_ = injectInlineCids("hello\n[inline:x.png]", []inlineAttachInfo{{name: "x.png", contentId: "cid"}, {name: "y.png", contentId: "cid2"}})
for _, raw := range []string{`{`, `{}`, `{"result":{"message":{"id":"m"}}}`, `{"result":{"messageId":"m2"}}`} {
_, _ = parseMailDraftId(raw)
}
for _, raw := range []string{`{`, `{}`, `{"result":{"uploadUrl":"/u"}}`} {
_, _ = parseMailUploadSession(raw)
}
for _, kind := range []string{"personal", "enterprise", ""} {
_ = mailUploadBaseURL(kind)
}
for _, text := range []string{`{`, `{}`, `{"fileName":"../x\\y.txt"}`, `{"result":{"fileName":"x.txt"}}`} {
_ = extractFileNameFromResponse(text)
}
for _, index := range []int{-1, 0, 25, 26, 701} {
_ = sheetColumnLetterFromZeroBased(index)
}
for _, value := range []any{nil, "bad", map[string]any{}, map[string]any{"x": nil}, map[string]any{"x": true}} {
_ = isEmptyDataValidation(value)
_ = isEmptyHyperlink(value)
}
_ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
_ = maxColLen2D([][]int{{1}, {2, 3}})
_ = checkMatrixShape(1, 2, 1, 2, "x")
_ = checkMatrixShape(1, 1, 1, 2, "x")
for _, value := range []any{1, -1, int64(2), int64(-2), float64(3), float64(3.5), float64(-3), json.Number("4"), json.Number("bad"), "bad"} {
_, _ = nonNegativeJSONInt(value)
}
_ = buildNonEmptyRangeFromLegacy(map[string]any{})
_ = buildNonEmptyRangeFromLegacy(map[string]any{"lastNonEmptyRow": float64(1), "lastNonEmptyColumn": float64(2)})
_ = normalizeNonEmptyRangeObject(nil)
_ = normalizeNonEmptyRangeObject(map[string]any{"range": "A1:B2"})
_ = normalizeNonEmptyRangeObject(map[string]any{"range": "A1:B2", "lastCell": "B2", "lastRow": float64(2), "lastColumn": "B"})
for _, sheet := range []map[string]any{
{"nonEmptyRange": map[string]any{"range": "A1:B2", "lastCell": "B2", "lastRow": float64(2), "lastColumn": "B"}},
{"lastNonEmptyRow": float64(1), "lastNonEmptyColumn": float64(2)},
{},
} {
normalizeSheetInfoCoordinatesForAgent(sheet)
}
styles := []struct {
spec styleSpec
rows, cols int
}{
{styleSpec{}, 0, 1},
{styleSpec{}, 1001, 1},
{styleSpec{}, 1000, 31},
{styleSpec{BgColor: "red", BgColorsJSON: `[["red"]]`}, 1, 1},
{styleSpec{FontSize: 1, FontSizesJSON: `[[1]]`}, 1, 1},
{styleSpec{FontSize: -1}, 1, 1},
{styleSpec{FontSizesJSON: `{`}, 1, 1},
{styleSpec{FontSizesJSON: `[[1,2]]`}, 1, 1},
{styleSpec{FontSizesJSON: `[[1]]`}, 1, 1},
{styleSpec{HAlign: "bad"}, 1, 1},
{styleSpec{VAlign: "bad"}, 1, 1},
{styleSpec{FontWeight: "bad"}, 1, 1},
{styleSpec{WordWrap: "bad"}, 1, 1},
{styleSpec{WordWrap: "clip"}, 1, 1},
{styleSpec{NumberFormat: "0.00"}, 1, 1},
{styleSpec{}, 1, 1},
}
for _, style := range styles {
_, _ = buildStyleCells(&style.spec, style.rows, style.cols)
}
for _, tc := range []struct {
scalar, raw string
enum map[string]bool
}{
{"x", `[["x"]]`, nil},
{"bad", "", hAlignEnum},
{"", "{", nil},
{"", `[["x","y"]]`, nil},
{"", `[["bad"]]`, hAlignEnum},
{"", `[[""]]`, hAlignEnum},
{"", `[["left"]]`, hAlignEnum},
} {
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
_, _ = get(0, 0)
}
}
views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
_, _ = findFilterViewByID(views, "one")
_, _ = findFilterViewByID(views, "two")
_, _ = findFilterViewByID(views, "missing")
}
func TestCrossPlatformCoverageSheetResponseCleanupCoverage(t *testing.T) {
testseam.Protect(t, &deps)
caller := &helpersCoreCaller{format: "json"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
for _, text := range []string{
"",
"not-json",
`{"cells":"bad"}`,
`{"cells":["bad",["bad",{"value":1},{"dataValidation":null,"hyperlink":null},{"dataValidation":{"x":null},"hyperlink":{"x":null}},{"dataValidation":{"x":true},"hyperlink":{"x":true}}]]}`,
} {
caller.result = textToolResult(text)
_ = callMCPToolCellInfos(nil)
}
for _, text := range []string{"", "not-json", `{}`, `{"lastNonEmptyRow":1,"lastNonEmptyColumn":2}`} {
caller.result = textToolResult(text)
_ = callMCPToolSheetInfo(nil)
}
caller.err = fmt.Errorf("failed")
_ = callMCPToolCellInfos(nil)
_ = callMCPToolSheetInfo(nil)
}
func TestCrossPlatformCoverageSmallHandlerAndFormatterCoverage(t *testing.T) {
if (Manifest{Vendor: " vendor ", Name: " name "}).FullName() != "vendor/name" {
t.Fatal("manifest full name")
}
_ = (openCompatHandler{name: "conference", buildFn: newConferenceCommand}).Name()
_ = (wukongHandler{name: "doc", buildFn: newDocCommand}).Name()
_ = (devHandler{}).Name()
_ = newDocCommentCommand()
_ = newHrmregisterCommand()
_ = newPatCommand()
var out bytes.Buffer
f := &Formatter{w: &out, errW: io.Discard}
f.PrintSuccess("ok")
f.PrintError("bad")
if out.Len() == 0 {
t.Fatal("formatter output is empty")
}
parent := &cobra.Command{Use: "sheet"}
group := &cobra.Command{Use: "range"}
group.AddCommand(&cobra.Command{Use: "read"})
parent.AddCommand(group)
_ = deepSuggestSubcommand(parent, "read")
_ = deepSuggestSubcommand(parent, "missing")
_ = time.Now()
}