Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions shortcuts/base/base_dashboard_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,49 @@ func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
}
}

func TestBaseDashboardBlockCreate_CanonicalizesNPSType(t *testing.T) {
dataConfig := `{"table_name":"Survey","group_by":[{"field_name":"Score"}]}`
for _, blockType := range []string{"nps", "NPS", "NpS", " nps", " NpS "} {
t.Run("dry-run "+blockType, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001",
"--name", "NPS", "--type", blockType, "--data-config", dataConfig, "--dry-run"}
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"type": "nps"`) {
t.Fatalf("dry-run body did not canonicalize type: %s", got)
}
})

t.Run("execute "+blockType, func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_nps", "type": "nps"},
},
}
reg.Register(stub)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001",
"--name", "NPS", "--type", blockType, "--data-config", dataConfig}
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
body := decodeCapturedJSONBody(t, stub)
if body["type"] != "nps" {
t.Fatalf("request type=%#v, want nps", body["type"])
}
group := body["data_config"].(map[string]interface{})["group_by"].([]interface{})[0].(map[string]interface{})
if _, hasMode := group["mode"]; hasMode {
t.Fatalf("CLI unexpectedly added group_by mode: %#v", group)
}
})
}
}

func TestBaseDashboardBlockDryRun_CreateRankingDefaults(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "负责人排行", "--type", "ranking",
Expand Down Expand Up @@ -899,6 +942,222 @@ func TestNormalizeDataConfigSortOrder(t *testing.T) {
})
}

func TestValidateNPSDataConfig(t *testing.T) {
valid := map[string]interface{}{
"table_name": "Survey",
"group_by": []interface{}{
map[string]interface{}{"field_name": "Score", "mode": "integrated"},
},
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
}
if problems := validateBlockDataConfig("nps", valid); len(problems) != 0 {
t.Fatalf("valid nps config got problems: %v", problems)
}
if problems := validateBlockDataConfig(" NpS ", cloneMap(valid)); len(problems) != 0 {
t.Fatalf("normalized nps type got problems: %v", problems)
}
withoutMode := cloneMap(valid)
withoutMode["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score"}}
if problems := validateBlockDataConfig("nps", withoutMode); len(problems) != 0 {
t.Fatalf("nps should allow omitted group_by mode: %v", problems)
}

withoutCountAll := cloneMap(valid)
if problems := validateBlockDataConfig("nps", withoutCountAll); len(problems) != 0 {
t.Fatalf("nps should allow omitted count_all: %v", problems)
}

withCountAll := cloneMap(valid)
withCountAll["count_all"] = true
if problems := validateBlockDataConfig("nps", withCountAll); len(problems) != 0 {
t.Fatalf("nps should allow count_all true: %v", problems)
}

invalid := cloneMap(valid)
invalid["series"] = []interface{}{map[string]interface{}{"field_name": "Score", "rollup": "SUM"}}
invalid["count_all"] = false
invalid["group_by"] = []interface{}{
map[string]interface{}{"field_name": "Score", "mode": "enumerated", "sort": map[string]interface{}{"type": "group"}},
}
problems := validateBlockDataConfig("nps", invalid)
for _, want := range []string{"不支持 series", "只能为 true", "只能为 integrated", "不支持 sort"} {
if !containsProblem(problems, want) {
t.Fatalf("problems=%v, want containing %q", problems, want)
}
}

for _, tc := range []struct {
field string
value interface{}
}{
{field: "sort", value: map[string]interface{}{"type": "group", "order": "asc"}},
{field: "limit_size", value: float64(10)},
{field: "number_format", value: map[string]interface{}{"formatName": "digital"}},
{field: "text", value: "NPS"},
} {
t.Run("reject top-level "+tc.field, func(t *testing.T) {
cfg := cloneMap(valid)
cfg[tc.field] = tc.value
problems := validateBlockDataConfig("nps", cfg)
if !containsProblem(problems, "nps 不支持 "+tc.field) {
t.Fatalf("problems=%v, want containing %q", problems, "nps 不支持 "+tc.field)
}
})
}

for _, tc := range []struct {
name string
mut func(map[string]interface{})
want string
}{
{
name: "missing table_name",
mut: func(cfg map[string]interface{}) {
delete(cfg, "table_name")
},
want: "缺少必填字段 table_name",
},
{
name: "invalid group_by shape",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = map[string]interface{}{"field_name": "Score", "mode": "integrated"}
},
want: "nps.group_by 必须是长度为 1 的数组",
},
{
name: "missing group_by field_name",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"mode": "integrated"}}
},
want: "nps.group_by[0].field_name 不能为空",
},
{
name: "empty group_by mode",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": ""}}
},
want: "nps.group_by[0].mode 只能为 integrated",
},
{
name: "padded group_by mode",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": " integrated "}}
},
want: "nps.group_by[0].mode 只能为 integrated",
},
{
name: "non-string group_by mode",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": true}}
},
want: "nps.group_by[0].mode 只能为 integrated",
},
{
name: "invalid category_range",
mut: func(cfg map[string]interface{}) {
cfg["category_range"] = []interface{}{float64(0), float64(6), float64(10)}
},
want: "nps.category_range 必须是长度为 4 的数组",
},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := cloneMap(valid)
tc.mut(cfg)
problems := validateBlockDataConfig("nps", cfg)
if !containsProblem(problems, tc.want) {
t.Fatalf("problems=%v, want containing %q", problems, tc.want)
}
})
}
}

func TestValidateNonNPSRejectsCategoryRange(t *testing.T) {
for _, tc := range []struct {
name string
blockType string
cfg map[string]interface{}
}{
{
name: "statistics",
blockType: "statistics",
cfg: map[string]interface{}{
"table_name": "T",
"count_all": true,
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
},
},
{
name: "ordinary chart",
blockType: "column",
cfg: map[string]interface{}{
"table_name": "T",
"count_all": true,
"group_by": []interface{}{map[string]interface{}{"field_name": "Score"}},
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
},
},
{
name: "text",
blockType: "text",
cfg: map[string]interface{}{
"text": "Summary",
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
problems := validateBlockDataConfig(tc.blockType, tc.cfg)
if !containsProblem(problems, "category_range 仅支持 nps 类型组件") {
t.Fatalf("problems=%v, want category_range rejection", problems)
}
})
}
}

func TestBaseDashboardBlockCreate_NPSRequiresDataConfig(t *testing.T) {
for _, tc := range []struct {
name string
blockType string
}{
{name: "nps", blockType: "nps"},
{name: "padded nps", blockType: " NpS "},
{name: "padded text", blockType: " text "},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseDashboardBlockCreate, []string{
"+dashboard-block-create",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
"--name", "Block",
"--type", tc.blockType,
}, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for missing data_config, got nil (stdout=%s)", stdout.String())
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype)
}
if ve.Param != "--data-config" {
t.Fatalf("param=%q, want --data-config", ve.Param)
}
})
}
}

func containsProblem(problems []string, want string) bool {
for _, p := range problems {
if strings.Contains(p, want) {
return true
}
}
return false
}

// ── Text Block Tests ────────────────────────────────────────────────

// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.
Expand Down
1 change: 1 addition & 0 deletions shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,7 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
shortcut: BaseDashboardBlockCreate,
wantTips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`--type nps --data-config '{"table_name":"Survey","group_by":[{"field_name":"Score","mode":"integrated"}],"category_range":[0,6,8,10]}'`,
`--type ranking --data-config '{"table_name":"Orders"`,
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
"+table-list and +field-list",
Expand Down
Loading
Loading