fix(commands): render examples conditionally with inline help code

This commit is contained in:
tiennm99 committed 2026-07-21 13:21:01 +07:00
1 parent f5ba9d4032
commit b5c7427520
16 files changed
+118 -60

No files matched your search

+4
View File
@@ -34,6 +34,10 @@ Include units or currencies when meaningful (for example, `<vnd_amount>`), use
`[...]` for optional input, append `...` for remaining free text, and use
parentheses to document structured input (for example,
`<ratio(owned:new)>`). Keep metadata, usage errors, examples, and tests exact.
Public commands with parameters must provide an explicit copyable example;
commands without parameters must omit it. In both Telegram's native menu and
`/help`, append `Eg: <invocation>` inline; `/help` must wrap only the invocation
in Telegram HTML `<code>` formatting.
## Stats Compatibility
+8 -7
View File
@@ -28,12 +28,13 @@ field supports only single-line plain text—no copyable code block. For
`/stock_buy`, the bot therefore sends this description:
```text
<quantity> <ticker>. Buy VN stock at market price. Example: /stock_buy 100 TCB
<quantity> <ticker>. Buy VN stock at market price. Eg: /stock_buy 100 TCB
```
`/help` combines the command and parameter syntax, adds the same summary, then
puts the example on the next line in a copyable code block. When `Example` is
omitted, both surfaces use the bare command (for example, `/ping`).
`/help` combines the command syntax and summary, then appends `Eg:` and the
example on the same line. It wraps only the invocation in Telegram HTML
`<code>` formatting so it is easy to copy. Public commands with parameters must
register an explicit example; commands without parameters omit it entirely.
For future commands, use lowercase descriptive parameter names. Include units
or currencies when they affect meaning (`<vnd_amount>`, `<usd_to_spend>`), use
@@ -46,9 +47,9 @@ metadata, handler usage text, examples, tests, and this documentation aligned.
Stock dividends are manual portfolio adjustments:
- `/stock_cash_dividend <vnd_per_share> <ticker>` credits a positive whole-VND amount for each pre-event share held. Example: `/stock_cash_dividend 1500 TCB`.
- `/stock_share_dividend <ratio(owned:new)> <ticker>` adds `floor(pre_event_shares × new / owned)` whole shares. Example: `/stock_share_dividend 100:10 TCB`.
- `/stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>` applies both parts from the same pre-event holding and saves them together. Example: `/stock_dividend 1500 100:10 TCB`.
- `/stock_cash_dividend <vnd_per_share> <ticker>` credits a positive whole-VND amount for each pre-event share held. Eg: `/stock_cash_dividend 1500 TCB`.
- `/stock_share_dividend <ratio(owned:new)> <ticker>` adds `floor(pre_event_shares × new / owned)` whole shares. Eg: `/stock_share_dividend 100:10 TCB`.
- `/stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>` applies both parts from the same pre-event holding and saves them together. Eg: `/stock_dividend 1500 100:10 TCB`.
Ratios use `owned:new` exactly as written in the issuer notice. Equivalent
unreduced ratios are accepted and the entered ratio is preserved in the reply.
+10 -5
View File
@@ -38,8 +38,8 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) {
got := botCommandMenu(reg)
want := []models.BotCommand{
{Command: "beta_public", Description: "<value>. Beta public. Example: /beta_public demo"},
{Command: "alpha_public", Description: "Alpha public. Example: /alpha_public"},
{Command: "beta_public", Description: "<value>. Beta public. Eg: /beta_public demo"},
{Command: "alpha_public", Description: "Alpha public."},
}
if len(got) != len(want) {
t.Fatalf("commands = %v, want %v", got, want)
@@ -90,8 +90,13 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
if got := command.Parameters; got != expectedParameters[command.Name] {
t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name])
}
if !strings.HasPrefix(command.ExampleInvocation(), "/"+command.Name) {
t.Errorf("/%s example = %q", command.Name, command.ExampleInvocation())
example := command.ExampleInvocation()
if command.Parameters == "" {
if example != "" || strings.Contains(command.TelegramMenuDescription(), "Eg:") {
t.Errorf("/%s without parameters has example %q", command.Name, example)
}
} else if !strings.HasPrefix(example, "/"+command.Name) {
t.Errorf("/%s example = %q", command.Name, example)
}
description := command.TelegramMenuDescription()
if strings.ContainsAny(description, "\r\n") {
@@ -164,7 +169,7 @@ func TestRegisterCommandMenu_CallsTelegramSetMyCommands(t *testing.T) {
if err := json.Unmarshal([]byte(call.Form["commands"]), &cmds); err != nil {
t.Fatalf("decode commands form field: %v; raw=%q", err, call.Form["commands"])
}
if len(cmds) != 1 || cmds[0].Command != "demo" || cmds[0].Description != "Demo command. Example: /demo" {
if len(cmds) != 1 || cmds[0].Command != "demo" || cmds[0].Description != "Demo command." {
t.Fatalf("commands payload = %+v, want demo command", cmds)
}
}
+4 -2
View File
@@ -139,8 +139,10 @@ every startup. The Go module registry is the single source of truth; no separate
command-menu file or manual registration step is required. A command's
description plus optional `Parameters` and `Example` metadata feed both
surfaces. Telegram renders the command name separately and accepts only a
single-line plain-text description, while `/help` can put the example in a
copyable code block. An omitted example defaults to the bare command.
single-line plain-text description, so parameterized commands append
`Eg: <invocation>` inline. `/help` uses the same inline layout and wraps only
the invocation in Telegram HTML `<code>` formatting. Commands without
parameters omit examples.
## Operations
@@ -22,12 +22,16 @@ source documentation.
`<ratio(owned:new)> <ticker>`, and
`<vnd_per_share> <ratio(owned:new)> <ticker>` for cash, share, and combined
commands. Examples and parsing remain unchanged.
- Native menu descriptions now combine parameters, summary, and example on one
plain-text line. Registration validation rejects multiline metadata, examples
for another command, and public descriptions over Telegram's 256-character
limit.
- `/help` now renders each invocation and summary together, followed by a
copyable HTML `<pre>` example. Dynamic command fields are HTML-escaped.
- Native menu descriptions show only the summary for no-parameter commands.
Parameterized commands append an explicit example with the short `Eg:` label
on the same plain-text line.
- `/help` renders each invocation and summary together. Parameterized commands
append `Eg: <code>invocation</code>` on the same line, with only the copyable
invocation inside Telegram's HTML `<code>` formatting. No-parameter commands
omit the label and example. Dynamic command fields are HTML-escaped.
- Registration validation rejects multiline metadata, examples for another
command, public commands that provide only one of parameters or example, and
public descriptions over Telegram's 256-character limit.
- Updated user and deployment documentation for the shared registry behavior.
## Reflection
@@ -42,13 +46,15 @@ supports without sacrificing safe HTML rendering.
- Existing command names, handlers, parsers, and persisted data remain
unchanged; normalization is presentation-only.
- No command was added, renamed, or deleted, so no stats migration is needed.
- Commands without parameters default their example to the command invocation.
- Public commands with parameters require an explicit example; commands
without parameters omit it.
- The complete `/help` output remains within Telegram's 4,096-character limit.
## Verification
- Passed: command presentation, validation, menu, and `/help` tests for all 40
public commands.
public commands, including inline `<code>` rendering, no-parameter omission,
and both parameter/example validation branches.
- Passed: `go test ./...`, including real MongoDB Testcontainers suites.
- Passed: `go vet ./...`
- Passed: `go build ./...`
+4 -4
View File
@@ -20,7 +20,7 @@ var (
func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price <coin>\nExample: /coin_price BTC")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price <coin>\nEg: /coin_price BTC")
}
coin, err := ResolveCoinSymbol(args[0])
if err != nil {
@@ -42,7 +42,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_topup <usd_amount>\nExample: /coin_topup 1000")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_topup <usd_amount>\nEg: /coin_topup 1000")
}
amount, ok := parsePositiveFinite(args[0])
if !ok || !isSafeUSD(amount) {
@@ -70,7 +70,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy <coin> <usd_to_spend>\nSpend USD to buy coin.\nAlternative: /coin_buy <usd_to_spend> <coin>\nExample: /coin_buy BTC 10")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy <coin> <usd_to_spend>\nSpend USD to buy coin.\nAlternative: /coin_buy <usd_to_spend> <coin>\nEg: /coin_buy BTC 10")
}
parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
if errors.Is(err, errInvalidUSDAmount) {
@@ -124,7 +124,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell <coin> <usd_to_receive>\nSell enough coin to receive USD.\nAlternative: /coin_sell <usd_to_receive> <coin>\nExample: /coin_sell BTC 10")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell <coin> <usd_to_receive>\nSell enough coin to receive USD.\nAlternative: /coin_sell <usd_to_receive> <coin>\nEg: /coin_sell BTC 10")
}
parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
if errors.Is(err, errInvalidUSDAmount) {
+6 -8
View File
@@ -17,13 +17,9 @@ func (c Command) InvocationSentence() string {
return withTerminalPunctuation(c.Invocation())
}
// ExampleInvocation returns the copyable example, defaulting to the command
// itself for commands that take no parameters.
// ExampleInvocation returns the explicitly registered copyable example.
func (c Command) ExampleInvocation() string {
if example := strings.TrimSpace(c.Example); example != "" {
return example
}
return "/" + c.Name
return strings.TrimSpace(c.Example)
}
// SummarySentence normalizes a command summary to a sentence without
@@ -42,8 +38,10 @@ func (c Command) TelegramMenuDescription() string {
sb.WriteByte(' ')
}
sb.WriteString(c.SummarySentence())
sb.WriteString(" Example: ")
sb.WriteString(c.ExampleInvocation())
if example := c.ExampleInvocation(); example != "" {
sb.WriteString(" Eg: ")
sb.WriteString(example)
}
return sb.String()
}
@@ -20,14 +20,14 @@ func TestCommandPresentation(t *testing.T) {
},
invocation: "/stock_buy <quantity> <ticker>",
example: "/stock_buy 100 TCB",
menu: "<quantity> <ticker>. Buy VN stock at market price. Example: /stock_buy 100 TCB",
menu: "<quantity> <ticker>. Buy VN stock at market price. Eg: /stock_buy 100 TCB",
},
{
name: "no parameters uses command as example",
name: "no parameters omits example",
command: Command{Name: "ping", Description: "Health check!"},
invocation: "/ping",
example: "/ping",
menu: "Health check! Example: /ping",
example: "",
menu: "Health check!",
},
{
name: "variadic parameters keep ellipsis",
@@ -39,7 +39,7 @@ func TestCommandPresentation(t *testing.T) {
},
invocation: "/random <options(comma-separated)>",
example: "/random pizza, sushi",
menu: "<options(comma-separated)>. Pick one option. Example: /random pizza, sushi",
menu: "<options(comma-separated)>. Pick one option. Eg: /random pizza, sushi",
},
}
+3 -3
View File
@@ -52,7 +52,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_topup <vnd_amount>\nExample: /gold_topup 5000000")
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_topup <vnd_amount>\nEg: /gold_topup 5000000")
}
amount, ok := parsePositiveFinite(args[0])
if !ok || !isSafeVND(amount) {
@@ -81,7 +81,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_buy <luong>\nExample: /gold_buy 1")
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_buy <luong>\nEg: /gold_buy 1")
}
qty, ok := parsePositiveFinite(args[0])
if !ok {
@@ -128,7 +128,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_sell <luong>\nExample: /gold_sell 0.5")
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_sell <luong>\nEg: /gold_sell 0.5")
}
qty, ok := parsePositiveFinite(args[0])
if !ok {
+1 -1
View File
@@ -40,7 +40,7 @@ type Command struct {
Visibility Visibility // public/protected/private
Description string // concise summary shown in command discovery (required, non-empty)
Parameters string // optional syntax after the command, e.g. "<quantity> <ticker>"
Example string // optional full invocation; defaults to "/" + Name
Example string // full invocation required with public Parameters; omitted without them
Handler CommandHandler // required
}
+7 -7
View File
@@ -79,7 +79,7 @@ func parsePositiveFinite(raw string) (float64, bool) {
func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price <ticker>\nExample: /stock_price TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price <ticker>\nEg: /stock_price TCB")
}
symbol, err := normalizeStockSymbol(args[0])
if err != nil {
@@ -109,7 +109,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup <vnd_amount>\nExample: /stock_topup 5000000")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup <vnd_amount>\nEg: /stock_topup 5000000")
}
amount, ok := parsePositiveFinite(args[0])
if !ok {
@@ -141,7 +141,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy <quantity> <ticker>\nExample: /stock_buy 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy <quantity> <ticker>\nEg: /stock_buy 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
@@ -198,7 +198,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell <quantity> <ticker>\nExample: /stock_sell 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell <quantity> <ticker>\nEg: /stock_sell 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
@@ -258,7 +258,7 @@ func (s *state) handleCashDividend(ctx context.Context, b *bot.Bot, update *mode
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_cash_dividend <vnd_per_share> <ticker>\nExample: /stock_cash_dividend 1500 TCB")
"Usage: /stock_cash_dividend <vnd_per_share> <ticker>\nEg: /stock_cash_dividend 1500 TCB")
}
vndPerShare, ok := parsePositiveWhole(args[0])
if !ok {
@@ -314,7 +314,7 @@ func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *mod
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_share_dividend <ratio(owned:new)> <ticker>\nExample: /stock_share_dividend 100:10 TCB")
"Usage: /stock_share_dividend <ratio(owned:new)> <ticker>\nEg: /stock_share_dividend 100:10 TCB")
}
ratio, ok := parseShareRatio(args[0])
if !ok {
@@ -374,7 +374,7 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U
args := argsAfterCommand(update.Message.Text)
if len(args) != 3 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>\nExample: /stock_dividend 1500 100:10 TCB")
"Usage: /stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>\nEg: /stock_dividend 1500 100:10 TCB")
}
vndPerShare, ok := parsePositiveWhole(args[0])
if !ok {
+2 -2
View File
@@ -90,8 +90,8 @@ func TestHelp_RendersHTML(t *testing.T) {
if !strings.Contains(got.Text(), "/help. Show all available commands.") {
t.Errorf("/help body missing formatted command summary; got %q", got.Text())
}
if !strings.Contains(got.Text(), "<pre>/help</pre>") {
t.Errorf("/help body missing copyable example; got %q", got.Text())
if strings.Contains(got.Text(), "<code>/help</code>") || strings.Contains(got.Text(), "Eg:") {
t.Errorf("/help command has no parameters and should not show an example; got %q", got.Text())
}
}
+5 -3
View File
@@ -47,10 +47,12 @@ func RenderHelp(reg *modules.Registry) string {
var sb strings.Builder
fmt.Fprintf(&sb, "<b>%s</b>", html.EscapeString(mod.Name))
for _, command := range es {
fmt.Fprintf(&sb, "\n%s %s\n<pre>%s</pre>",
fmt.Fprintf(&sb, "\n%s %s",
html.EscapeString(command.InvocationSentence()),
html.EscapeString(command.SummarySentence()),
html.EscapeString(command.ExampleInvocation()))
html.EscapeString(command.SummarySentence()))
if example := command.ExampleInvocation(); example != "" {
fmt.Fprintf(&sb, " Eg: <code>%s</code>", html.EscapeString(example))
}
}
sections = append(sections, sb.String())
}
+11 -3
View File
@@ -52,7 +52,6 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) {
"<b>alpha</b>",
"<b>beta</b>",
"/a_pub. alpha public.",
"<pre>/a_pub</pre>",
// HTML in user descriptions must be escaped.
"beta &lt;i&gt;desc&lt;/i&gt;",
// Locks html.EscapeString contract: & → &amp;, " → &#34;.
@@ -70,6 +69,9 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) {
if strings.Contains(out, "a_prot") {
t.Errorf("output leaked protected command\n---output---\n%s", out)
}
if strings.Contains(out, "<pre>/a_pub</pre>") || strings.Contains(out, "Eg:") {
t.Errorf("commands without parameters should not render examples\n---output---\n%s", out)
}
}
func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) {
@@ -78,7 +80,7 @@ func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) {
Visibility: modules.VisibilityPublic,
Description: "Buy & hold",
Parameters: "<quantity> <ticker>",
Example: "/buy 100 TCB",
Example: "/buy 100 TCB & hold",
Handler: helpTestNoop,
}
reg, err := modules.Build(
@@ -95,9 +97,15 @@ func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) {
if !strings.Contains(out, "/buy &lt;quantity&gt; &lt;ticker&gt;. Buy &amp; hold.") {
t.Fatalf("help missing formatted invocation and summary:\n%s", out)
}
if !strings.Contains(out, "<pre>/buy 100 TCB</pre>") {
if !strings.Contains(out, "<code>/buy 100 TCB &amp; hold</code>") {
t.Fatalf("help missing copyable example:\n%s", out)
}
if !strings.Contains(out, "Buy &amp; hold. Eg: <code>/buy 100 TCB &amp; hold</code>") {
t.Fatalf("help missing short example label:\n%s", out)
}
if strings.Contains(out, "<pre>") {
t.Fatalf("help example should use inline code, not a pre block:\n%s", out)
}
}
func TestRenderHelp_ModuleOrderMatchesEnvOrder(t *testing.T) {
+11 -1
View File
@@ -32,7 +32,17 @@ func validateCommand(c Command) error {
if strings.ContainsAny(c.Example, "\r\n") {
return fmt.Errorf("command %q: example must be single-line", c.Name)
}
if example := strings.TrimSpace(c.Example); example != "" {
parameters := strings.TrimSpace(c.Parameters)
example := strings.TrimSpace(c.Example)
if c.Visibility == VisibilityPublic {
if parameters == "" && example != "" {
return fmt.Errorf("command %q: example requires parameters", c.Name)
}
if parameters != "" && example == "" {
return fmt.Errorf("command %q: example is required when parameters are present", c.Name)
}
}
if example != "" {
prefix := "/" + c.Name
if example != prefix && !strings.HasPrefix(example, prefix+" ") {
return fmt.Errorf("command %q: example must invoke %s", c.Name, prefix)
+23 -1
View File
@@ -55,9 +55,17 @@ func TestValidateCommand_RejectsInvalidPresentationMetadata(t *testing.T) {
name: "example invokes another command",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Example: "/other", Handler: okHandler},
},
{
name: "example without parameters",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Example: "/ok", Handler: okHandler},
},
{
name: "parameters without example",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Parameters: "<value>", Handler: okHandler},
},
{
name: "native description too long",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: strings.Repeat("x", 250), Handler: okHandler},
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: strings.Repeat("x", 257), Handler: okHandler},
},
}
@@ -78,6 +86,20 @@ func TestValidateCommand_AcceptsLegalNames(t *testing.T) {
}
}
func TestValidateCommand_AcceptsMatchedParametersAndExample(t *testing.T) {
command := Command{
Name: "ok",
Visibility: VisibilityPublic,
Description: "Do it",
Parameters: "<value>",
Example: "/ok demo",
Handler: okHandler,
}
if err := validateCommand(command); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateCommand_RequiresDescriptionAndHandler(t *testing.T) {
if err := validateCommand(Command{Name: "ok", Visibility: VisibilityPublic, Description: "", Handler: okHandler}); err == nil {
t.Error("expected error for empty description")