fix: align schema aliases and one-of coverage

This commit is contained in:
玉澜 committed 2026-07-10 17:13:29 +08:00
1 parent ddad2f648c
commit eec64bdf35
7 files changed
+269 -40

No files matched your search

@@ -80,6 +80,47 @@ func TestSchemaCoversVisibleProductLeafCommands(t *testing.T) {
}
}
func TestSchemaHidesChatFileUploadCompatibilityAliases(t *testing.T) {
root := NewRootCommand()
var out, errOut bytes.Buffer
root.SetOut(&out)
root.SetErr(&errOut)
root.SetArgs([]string{"schema", "chat.upload_conversation_file", "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("schema Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
var payload struct {
Parameters map[string]json.RawMessage `json:"parameters"`
Constraints struct {
RequireOneOf [][]string `json:"require_one_of"`
} `json:"constraints"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
for _, name := range []string{"conversation-id", "id", "file-path"} {
if _, ok := payload.Parameters[name]; ok {
t.Fatalf("compatibility alias %q leaked into schema parameters", name)
}
}
for _, name := range []string{"group", "user", "open-dingtalk-id", "file", "url"} {
if _, ok := payload.Parameters[name]; !ok {
t.Fatalf("canonical parameter %q missing from schema", name)
}
}
wantGroups := map[string]bool{
"group,user,open-dingtalk-id": true,
"file,url": true,
}
for _, group := range payload.Constraints.RequireOneOf {
delete(wantGroups, strings.Join(group, ","))
}
if len(wantGroups) != 0 {
t.Fatalf("require_one_of missing groups: %#v", wantGroups)
}
}
func TestSchemaProductIDsMatchRootHelpServices(t *testing.T) {
root := NewRootCommand()
helpProducts := map[string]bool{}
+8 -3
View File
@@ -164,12 +164,12 @@ func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
cmd.Flags().String("conversation-id", "", "--group 的别名")
cmd.Flags().String("id", "", "--group 的别名")
addChatFileHiddenStringFlag(cmd, "conversation-id", "--group 的别名")
addChatFileHiddenStringFlag(cmd, "id", "--group 的别名")
cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)")
cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)")
cmd.Flags().String("file-path", "", "--file 的别名")
addChatFileHiddenStringFlag(cmd, "file-path", "--file 的别名")
cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)")
cmd.Flags().String("file-name", "", "文件名(可选)")
cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)")
@@ -189,6 +189,11 @@ func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func addChatFileHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
}
func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) {
group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id"))
user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user"))
+37
View File
@@ -3,6 +3,8 @@ package helpers
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
@@ -15,6 +17,41 @@ type captureRunner struct {
last executor.Invocation
}
func TestChatFileUploadCompatibilityAliasesRemainCallable(t *testing.T) {
for _, name := range []string{"conversation-id", "id", "file-path"} {
cmd := newChatFileUploadCommand(&captureRunner{})
flag := cmd.Flags().Lookup(name)
if flag == nil || !flag.Hidden {
t.Fatalf("compatibility flag --%s must remain registered and hidden", name)
}
}
runner := &captureRunner{}
cmd := newChatFileUploadCommand(runner)
cmd.SetArgs([]string{
"--conversation-id", "cid-alias",
"--url", "https://example.com/report.pdf",
"--file-name", "report.pdf",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("conversation alias Execute() error = %v", err)
}
if got := runner.last.Params["openConversationId"]; got != "cid-alias" {
t.Fatalf("openConversationId = %#v, want cid-alias", got)
}
fixture := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(fixture, []byte("schema alias test\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
dryRun := newChatFileUploadCommand(&captureRunner{})
dryRun.Flags().Bool("dry-run", false, "dry run")
dryRun.SetArgs([]string{"--id", "cid-id-alias", "--file-path", fixture, "--dry-run"})
if err := dryRun.Execute(); err != nil {
t.Fatalf("id/file-path alias Execute() error = %v", err)
}
}
func (r *captureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.last = invocation
return executor.Result{Invocation: invocation}, nil
+4
View File
@@ -1066,6 +1066,8 @@ func newDocBlockInsertCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml"))
cmd.Flags().String("parent-block", "", i18n.T("父容器块 ID(JSONML 场景透传)"))
addDocJSONMLControlFlags(cmd)
annotateFlagEnum(cmd, "type", "paragraph", "heading", "blockquote", "callout", "code", "orderedList", "unorderedList", "columns", "divider")
annotateFlagExample(cmd, "type", "divider")
annotateFlagConstraints(cmd, nil, [][]string{{"text", "heading", "element", "type"}}, nil)
return cmd
}
@@ -1130,6 +1132,8 @@ func newDocBlockUpdateCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().Int("level", 1, i18n.T("标题级别"))
cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml"))
addDocJSONMLControlFlags(cmd)
annotateFlagEnum(cmd, "type", "paragraph", "heading", "blockquote", "callout", "code", "orderedList", "unorderedList", "columns", "divider")
annotateFlagExample(cmd, "type", "divider")
annotateFlagConstraints(cmd, nil, [][]string{{"text", "heading", "element", "type"}}, nil)
return cmd
}
+13
View File
@@ -1111,6 +1111,7 @@ func newSheetCondFormatCreateCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象")
cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象")
annotateFlagExample(cmd, "ranges", `["A1:B2"]`)
annotateFlagExample(cmd, "condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`)
return cmd
}
@@ -1132,6 +1133,7 @@ func newSheetCondFormatUpdateCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象")
cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象")
annotateFlagExample(cmd, "ranges", `["A1:B2"]`)
annotateFlagExample(cmd, "condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`)
annotateFlagConstraints(cmd, nil, [][]string{{
"ranges", "condition", "cell-style", "data-bar-style",
}}, nil)
@@ -2102,6 +2104,17 @@ func bindSheetStyleFlags(cmd *cobra.Command) {
cmd.Flags().String("font-weights-json", "", "字体粗细二维 JSON 数组")
cmd.Flags().String("word-wrap", "", "换行方式")
cmd.Flags().String("number-format", "", "数字格式 code")
annotateFlagEnum(cmd, "h-align", "left", "center", "right", "general")
annotateFlagEnum(cmd, "v-align", "top", "middle", "bottom")
annotateFlagEnum(cmd, "font-weight", "bold", "normal")
annotateFlagEnum(cmd, "word-wrap", "overflow", "clip", "autoWrap")
annotateFlagExample(cmd, "bg-colors-json", `[["#FFFFFF","#FFFFFF"],["#FFFFFF","#FFFFFF"]]`)
annotateFlagExample(cmd, "font-sizes-json", `[[12,12],[12,12]]`)
annotateFlagExample(cmd, "h-aligns-json", `[["left","left"],["left","left"]]`)
annotateFlagExample(cmd, "v-aligns-json", `[["top","top"],["top","top"]]`)
annotateFlagExample(cmd, "font-colors-json", `[["#000000","#000000"],["#000000","#000000"]]`)
annotateFlagExample(cmd, "font-weights-json", `[["normal","normal"],["normal","normal"]]`)
}
func sheetReadStyleSpec(cmd *cobra.Command) *sheetStyleSpec {
+4
View File
@@ -414,6 +414,10 @@ agentCode 配置:
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes")
cli.AnnotateRuntimeFlagEnum(chmodCmd, "grant-type", "once", "session", "permanent")
cli.AnnotateRuntimeFlagExample(chmodCmd, "product", "calendar")
cli.AnnotateRuntimeFlagExample(chmodCmd, "products", "calendar,aitable")
cli.AnnotateRuntimeFlagExample(chmodCmd, "domain", "calendar")
cli.AnnotateRuntimeFlagExample(chmodCmd, "domains", "calendar,chat")
cli.AnnotateRuntimePositionals(chmodCmd, cli.RuntimeSchemaPositional{
Name: "scope",
Type: "array",
+162 -37
View File
@@ -3,13 +3,15 @@
The script treats `dws schema --all --format json` as the command inventory, expands
each leaf schema, synthesizes flag values from parameter metadata, and executes
the runnable CLI path with `--dry-run --yes --format json`.
the runnable CLI path with `--dry-run --yes --format json`. Every available
combination of `require_one_of` branches is exercised independently.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import itertools
import json
import os
import re
@@ -26,6 +28,7 @@ from typing import Any
@dataclass
class SmokeResult:
canonical_path: str
case_name: str
cli_path: str
command: list[str]
status: str
@@ -74,13 +77,13 @@ def kebab_to_words(value: str) -> str:
def scalar_value(flag: str, param: dict[str, Any], canonical_path: str) -> str:
if param.get("example") not in (None, ""):
return str(param["example"])
enum = param.get("enum") or []
if enum:
return str(enum[0])
if param.get("example") not in (None, ""):
return str(param["example"])
value_format = str(param.get("format", "")).lower()
if value_format in {"numeric-id", "positive-integer"}:
return "1"
@@ -182,6 +185,9 @@ def value_for(flag: str, param: dict[str, Any], canonical_path: str) -> str | No
if canonical_path == "sheet.range_batch_set_style" and flag == "batch":
return str(Path(tempfile.gettempdir()) / "dws-schema-smoke-style.json")
if param.get("example") not in (None, ""):
return str(param["example"])
typ = str(param.get("type", "string")).lower()
prop = str(param.get("property", ""))
haystack = " ".join([kebab_to_words(flag), kebab_to_words(prop)])
@@ -223,7 +229,43 @@ def constraint_groups(leaf: dict[str, Any], name: str) -> list[list[str]]:
return [[str(item) for item in group] for group in groups if group]
def selected_inputs(leaf: dict[str, Any], include_optional: bool) -> tuple[set[str], set[str]]:
def available_one_of_groups(leaf: dict[str, Any]) -> list[list[str]]:
params = leaf.get("parameters") or {}
positional_names = {
str(item.get("name"))
for item in (leaf.get("positionals") or [])
if item.get("name")
}
available = set(params) | positional_names
return [
[name for name in group if name in available]
for group in constraint_groups(leaf, "require_one_of")
if any(name in available for name in group)
]
def one_of_cases(leaf: dict[str, Any]) -> list[tuple[str, ...]]:
groups = available_one_of_groups(leaf)
if not groups:
return [()]
cases: list[tuple[str, ...]] = []
seen: set[tuple[str, ...]] = set()
mutually_exclusive = constraint_groups(leaf, "mutually_exclusive")
for choices in itertools.product(*groups):
selected = set(choices)
if any(sum(name in selected for name in group) > 1 for group in mutually_exclusive):
continue
if choices not in seen:
seen.add(choices)
cases.append(choices)
return cases or [()]
def selected_inputs(
leaf: dict[str, Any],
include_optional: bool,
one_of_choices: tuple[str, ...] = (),
) -> tuple[set[str], set[str]]:
params = leaf.get("parameters") or {}
positionals = {
str(item.get("name")): item
@@ -251,12 +293,14 @@ def selected_inputs(leaf: dict[str, Any], include_optional: bool) -> tuple[set[s
return True
return False
for group in constraint_groups(leaf, "require_one_of"):
if any(is_selected(name) for name in group):
continue
one_of_groups = available_one_of_groups(leaf)
choices = one_of_choices or tuple(group[0] for group in one_of_groups)
preferred = set(choices)
for group, choice in zip(one_of_groups, choices):
for name in group:
if select(name):
break
selected_params.discard(name)
selected_positionals.discard(name)
select(choice)
changed = True
while changed:
@@ -270,20 +314,30 @@ def selected_inputs(leaf: dict[str, Any], include_optional: bool) -> tuple[set[s
for group in constraint_groups(leaf, "mutually_exclusive"):
selected = [name for name in group if is_selected(name)]
for name in selected[1:]:
keep = next((name for name in selected if name in preferred), selected[0] if selected else "")
for name in selected:
if name == keep:
continue
selected_params.discard(name)
selected_positionals.discard(name)
return selected_params, selected_positionals
def build_command(binary: str, leaf: dict[str, Any], include_optional: bool) -> list[str]:
def build_command(
binary: str,
leaf: dict[str, Any],
include_optional: bool,
one_of_choices: tuple[str, ...] = (),
) -> list[str]:
cli_path = str(leaf["cli_path"])
canonical_path = str(leaf.get("canonical_path", ""))
extra_flags = smoke_extra_flags(canonical_path)
argv = [binary, *shlex.split(cli_path)]
params = leaf.get("parameters") or {}
selected_params, selected_positionals = selected_inputs(leaf, include_optional)
selected_params, selected_positionals = selected_inputs(
leaf, include_optional, one_of_choices
)
positionals = sorted(
(leaf.get("positionals") or []),
@@ -326,24 +380,18 @@ def help_flags(binary: str, cwd: Path, cli_path: str, timeout: int) -> set[str]:
return set(re.findall(r"--([a-zA-Z0-9][a-zA-Z0-9_.-]*)", proc.stdout))
def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: bool) -> SmokeResult:
def run_smoke_case(
binary: str,
cwd: Path,
path: str,
leaf: dict[str, Any],
timeout: int,
include_optional: bool,
choices: tuple[str, ...],
) -> SmokeResult:
case_name = "default" if not choices else "one_of:" + "+".join(choices)
try:
leaf = run_json([binary, "schema", path, "--format", "json"], cwd, timeout)
cli_path = str(leaf.get("cli_path", ""))
declared = set((leaf.get("parameters") or {}).keys())
missing = sorted(declared - help_flags(binary, cwd, cli_path, timeout))
if missing:
return SmokeResult(
canonical_path=path,
cli_path=cli_path,
command=[binary, *shlex.split(cli_path), "--help"],
status="schema_flag_mismatch",
exit_code=3,
stdout="",
stderr="",
error="schema parameters missing from command help: " + ", ".join(missing),
)
command = build_command(binary, leaf, include_optional)
command = build_command(binary, leaf, include_optional, choices)
proc = subprocess.run(
command,
cwd=cwd,
@@ -355,6 +403,7 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b
status = "pass" if proc.returncode == 0 else "fail"
return SmokeResult(
canonical_path=path,
case_name=case_name,
cli_path=str(leaf.get("cli_path", "")),
command=command,
status=status,
@@ -365,6 +414,7 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b
except subprocess.TimeoutExpired as exc:
return SmokeResult(
canonical_path=path,
case_name=case_name,
cli_path="",
command=exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)],
status="timeout",
@@ -376,6 +426,7 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b
except Exception as exc: # noqa: BLE001 - record all smoke failures.
return SmokeResult(
canonical_path=path,
case_name=case_name,
cli_path="",
command=[],
status="error",
@@ -386,9 +437,79 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b
)
def run_one(
binary: str,
cwd: Path,
path: str,
timeout: int,
include_optional: bool,
) -> list[SmokeResult]:
try:
leaf = run_json([binary, "schema", path, "--format", "json"], cwd, timeout)
cli_path = str(leaf.get("cli_path", ""))
declared = set((leaf.get("parameters") or {}).keys())
missing = sorted(declared - help_flags(binary, cwd, cli_path, timeout))
if missing:
return [
SmokeResult(
canonical_path=path,
case_name="schema",
cli_path=cli_path,
command=[binary, *shlex.split(cli_path), "--help"],
status="schema_flag_mismatch",
exit_code=3,
stdout="",
stderr="",
error="schema parameters missing from command help: "
+ ", ".join(missing),
)
]
return [
run_smoke_case(
binary,
cwd,
path,
leaf,
timeout,
include_optional,
choices,
)
for choices in one_of_cases(leaf)
]
except subprocess.TimeoutExpired as exc:
return [
SmokeResult(
canonical_path=path,
case_name="schema",
cli_path="",
command=exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)],
status="timeout",
exit_code=124,
stdout=exc.stdout or "",
stderr=exc.stderr or "",
error=str(exc),
)
]
except Exception as exc: # noqa: BLE001 - record all smoke failures.
return [
SmokeResult(
canonical_path=path,
case_name="schema",
cli_path="",
command=[],
status="error",
exit_code=1,
stdout="",
stderr="",
error=str(exc),
)
]
def result_to_dict(result: SmokeResult) -> dict[str, Any]:
return {
"canonical_path": result.canonical_path,
"case": result.case_name,
"cli_path": result.cli_path,
"command": result.command,
"status": result.status,
@@ -399,7 +520,7 @@ def result_to_dict(result: SmokeResult) -> dict[str, Any]:
}
def write_markdown(results: list[SmokeResult], output: Path) -> None:
def write_markdown(results: list[SmokeResult], output: Path, tool_count: int) -> None:
counts: dict[str, int] = {}
for result in results:
counts[result.status] = counts.get(result.status, 0) + 1
@@ -408,7 +529,8 @@ def write_markdown(results: list[SmokeResult], output: Path) -> None:
"# Schema Command Smoke Results",
"",
f"- Generated: {datetime.now(timezone.utc).isoformat()}",
f"- Total: {len(results)}",
f"- Tools: {tool_count}",
f"- Cases: {len(results)}",
]
for status in sorted(counts):
lines.append(f"- {status}: {counts[status]}")
@@ -418,8 +540,8 @@ def write_markdown(results: list[SmokeResult], output: Path) -> None:
if not failures:
lines.append("No failures.")
else:
lines.append("| canonical_path | cli_path | status | exit | issue |")
lines.append("| --- | --- | --- | --- | --- |")
lines.append("| canonical_path | case | cli_path | status | exit | issue |")
lines.append("| --- | --- | --- | --- | --- | --- |")
for result in failures:
issue = result.error or result.stderr.strip() or result.stdout.strip()
issue = issue.replace("\n", "<br>")
@@ -430,6 +552,7 @@ def write_markdown(results: list[SmokeResult], output: Path) -> None:
+ " | ".join(
[
f"`{result.canonical_path}`",
f"`{result.case_name}`",
f"`{result.cli_path}`",
result.status,
str(result.exit_code),
@@ -469,14 +592,15 @@ def main() -> int:
paths = [tool["canonical_path"] for product in listing["products"] for tool in product["tools"]]
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as executor:
results = list(
result_groups = list(
executor.map(
lambda p: run_one(args.binary, cwd, p, args.timeout, args.include_optional),
paths,
)
)
results = [result for group in result_groups for result in group]
results.sort(key=lambda item: item.canonical_path)
results.sort(key=lambda item: (item.canonical_path, item.case_name))
jsonl_path = Path(args.output_jsonl)
jsonl_path.parent.mkdir(parents=True, exist_ok=True)
@@ -486,9 +610,10 @@ def main() -> int:
md_path = Path(args.output_md)
md_path.parent.mkdir(parents=True, exist_ok=True)
write_markdown(results, md_path)
write_markdown(results, md_path, len(paths))
failures = [result for result in results if result.status != "pass"]
print(f"tools={len(paths)}")
print(f"total={len(results)}")
print(f"failures={len(failures)}")
print(f"jsonl={jsonl_path}")