mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
fix(commands): render examples conditionally with inline help code
This commit is contained in:
1 parent
f5ba9d4032
commit
b5c7427520
16 files changed
+118
-60
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ./...`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 <i>desc</i>",
|
||||
// Locks html.EscapeString contract: & → &, " → ".
|
||||
@@ -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 <quantity> <ticker>. Buy & 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 & hold</code>") {
|
||||
t.Fatalf("help missing copyable example:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Buy & hold. Eg: <code>/buy 100 TCB & 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user