Files
commandkit/route_test.go

354 lines
12 KiB
Go

package commandkit
import (
"bytes"
"context"
"io"
"reflect"
"strings"
"testing"
)
func routeTestApp(t *testing.T, extra ...Declaration) *App {
t.Helper()
collections := simpleRunnable("collections")
list := Group("list", "List", collections)
testCommand := runnableWithOperands("test", 0, 1)
promptkit := Group("promptkit", "PromptKit", list, testCommand)
run := runnableWithOperands("run", 0, 1)
inspect := runnableWithOperands("inspect", 1, 1)
declarations := []Declaration{
promptkit,
run,
inspect,
Alias("collections", "list collections"),
Alias("list collections", "promptkit list collections"),
Alias("pk", "promptkit"),
Alias("promptkit list bundles", "promptkit list collections"),
Alias("smoke", "promptkit test"),
Alias("r", "run"),
Alias("r full", "inspect"),
}
declarations = append(declarations, extra...)
app, err := New(Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
return app
}
func TestEnteredRoutesResolveChainsAndPreserveTokenOrigins(t *testing.T) {
app := routeTestApp(t)
snapshot := app.routeSnapshot()
for _, test := range []struct {
name string
args []string
entered string
canonical string
wantArgs []string
wantKind routeKind
}{
{
name: "command alias chain",
args: []string{"collections", "--tag", "first"},
entered: "collections",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections", "--tag", "first"},
wantKind: routeCanonical,
},
{
name: "group alias composition",
args: []string{"pk", "list", "bundles"},
entered: "pk list bundles",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections"},
wantKind: routeCanonical,
},
{
name: "smoke operand",
args: []string{"smoke", "all"},
entered: "smoke",
canonical: "promptkit test",
wantArgs: []string{"promptkit", "test", "all"},
wantKind: routeCanonical,
},
{
name: "explicit longer alternate spelling",
args: []string{"r", "full"},
entered: "r full",
canonical: "inspect",
wantArgs: []string{"inspect"},
wantKind: routeCanonical,
},
{
name: "canonical runnable operand",
args: []string{"run", "full"},
entered: "run",
canonical: "run",
wantArgs: []string{"run", "full"},
wantKind: routeCanonical,
},
{
name: "single dash remains an operand",
args: []string{"run", "-"},
entered: "run",
canonical: "run",
wantArgs: []string{"run", "-"},
wantKind: routeCanonical,
},
{
name: "unknown path",
args: []string{"missing", "route"},
entered: "missing route",
wantArgs: []string{"missing", "route"},
wantKind: routeUnknown,
},
{
name: "space-containing token is not split",
args: []string{"collections list"},
entered: "collections list",
wantArgs: []string{"collections list"},
wantKind: routeUnknown,
},
{
name: "flag values and spaced tokens",
args: []string{"collections", "--message", "--hlep", "--text=two words"},
entered: "collections",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections", "--message", "--hlep", "--text=two words"},
wantKind: routeCanonical,
},
{
name: "terminator stops command matching",
args: []string{"collections", "--", "r", "full"},
entered: "collections",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections", "--", "r", "full"},
wantKind: routeCanonical,
},
} {
t.Run(test.name, func(t *testing.T) {
got := resolveEnteredRoute(test.args, snapshot)
if got.kind != test.wantKind {
t.Fatalf("route kind = %d, want %d", got.kind, test.wantKind)
}
if got.enteredPath.String() != test.entered {
t.Errorf("entered path = %q, want %q", got.enteredPath.String(), test.entered)
}
if got.canonicalPath.String() != test.canonical {
t.Errorf("canonical path = %q, want %q", got.canonicalPath.String(), test.canonical)
}
if !reflect.DeepEqual(got.args, test.wantArgs) {
t.Errorf("routed args = %q, want %q", got.args, test.wantArgs)
}
})
}
}
func TestLongerRouteSelectionDoesNotFallBackAfterTargetParsingFails(t *testing.T) {
app := routeTestApp(t)
result := resolveEnteredRoute([]string{"r", "full"}, app.routeSnapshot())
if got, want := result.canonicalPath.String(), "inspect"; got != want {
t.Fatalf("canonical path = %q, want %q", got, want)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"r", "full"}, Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != OutcomeUsage {
t.Fatalf("outcome kind = %v, want usage; diagnostics: %s", outcome.Kind, diagnostics.String())
}
}
func TestEnteredAliasCanSelectExplicitLongerMapping(t *testing.T) {
app := routeTestApp(t, Alias("pk list bundles", "inspect"))
result := resolveEnteredRoute([]string{"pk", "list", "bundles"}, app.routeSnapshot())
if got, want := result.canonicalPath.String(), "inspect"; got != want {
t.Fatalf("canonical path = %q, want %q", got, want)
}
if got, want := result.enteredPath.String(), "pk list bundles"; got != want {
t.Fatalf("entered path = %q, want %q", got, want)
}
}
func TestExecuteDispatchesEnteredAliasToCanonicalRunner(t *testing.T) {
runs := 0
command := NewCommand(CommandSpec[struct{}]{
Name: "canonical", Summary: "Canonical",
Run: func(Invocation, struct{}) Outcome {
runs++
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "canonical"))
if err != nil {
t.Fatal(err)
}
outcome := app.Execute(context.Background(), []string{"shortcut"}, Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != OutcomeSuccess || runs != 1 {
t.Fatalf("alias execution = (%v, runs %d), want success and one canonical run", outcome.Kind, runs)
}
}
func TestInvocationReportsEnteredAndCanonicalPathsPerCall(t *testing.T) {
type input struct {
label Supplied[string]
values Supplied[[]string]
}
type observed struct {
entered string
canonical string
label string
values []string
}
var calls []observed
command := NewCommand(CommandSpec[input]{
Name: "run",
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "label"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *input, label Supplied[string]) { target.label = label },
)},
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "value"}, 0, -1,
func(value string) (string, error) { return value, nil },
func(target *input, values Supplied[[]string]) { target.values = values },
)},
Run: func(inv Invocation, value input) Outcome {
calls = append(calls, observed{
entered: inv.EnteredPath(),
canonical: inv.CanonicalPath(),
label: value.label.Value,
values: append([]string(nil), value.values.Value...),
})
return Success()
},
})
shortcut := Alias("shortcut", "workspace run")
app, err := New(Identity{Name: "Example"}, Group("workspace", "Workspace", command), shortcut)
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"workspace", "run", "--label=first", "first"}, {"shortcut", "--label=second", "second"}, {"workspace", "run"}} {
if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("Execute(%q) = %v, want success", args, outcome.Kind)
}
}
if len(calls) != 3 {
t.Fatalf("handler calls = %d, want 3", len(calls))
}
want := []observed{
{entered: "workspace run", canonical: "workspace run", label: "first", values: []string{"first"}},
{entered: "shortcut", canonical: "workspace run", label: "second", values: []string{"second"}},
{entered: "workspace run", canonical: "workspace run"},
}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("handler invocations = %#v, want %#v", calls, want)
}
secondApp, err := New(Identity{Name: "Another"}, Group("workspace", "Workspace", command), shortcut)
if err != nil {
t.Fatal(err)
}
if outcome := secondApp.Execute(context.Background(), []string{"shortcut"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("reused declaration execution = %v, want success", outcome.Kind)
}
if got := calls[3]; got.entered != "shortcut" || got.canonical != "workspace run" || len(got.values) != 0 {
t.Fatalf("reused declaration invocation = %#v", got)
}
var zero Invocation
if zero.EnteredPath() != "" || zero.CanonicalPath() != "" {
t.Fatalf("zero Invocation paths = (%q, %q), want empty paths", zero.EnteredPath(), zero.CanonicalPath())
}
}
func TestPrivateCommandCallbacksReceiveResolvedInvocationPaths(t *testing.T) {
var calls []Invocation
command := Command{record: &commandRecord{
name: "inspect",
payload: typedCommand[struct{}]{
spec: CommandSpec[struct{}]{Name: "inspect"},
privateRun: func(exec commandExecution, _ struct{}) Outcome {
calls = append(calls, exec.invocation)
return Success()
},
},
}}
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "inspect"))
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"inspect"}, {"shortcut"}} {
if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("Execute(%q) = %v, want success", args, outcome.Kind)
}
}
if len(calls) != 2 || calls[0].EnteredPath() != "inspect" || calls[0].CanonicalPath() != "inspect" || calls[1].EnteredPath() != "shortcut" || calls[1].CanonicalPath() != "inspect" {
t.Fatalf("private callback invocations = %#v", calls)
}
}
func TestExecuteAppliesCanonicalCommandFlagAliasesAfterRouting(t *testing.T) {
type input struct{ color string }
var got string
command := NewCommand(CommandSpec[input]{
Name: "inspect",
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "color", Short: "c"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *input, supplied Supplied[string]) { target.color = supplied.Value },
)},
Run: func(_ Invocation, value input) Outcome {
got = value.color
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command,
Alias("shortcut", "inspect"),
FlagAliasFor("inspect", "--colour", "-c"),
)
if err != nil {
t.Fatal(err)
}
outcome := app.Execute(context.Background(), []string{"shortcut", "--colour=blue"}, Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != OutcomeSuccess || got != "blue" {
t.Fatalf("alias flag execution = (%v, color %q), want success and blue", outcome.Kind, got)
}
}
func TestExecuteAppliesGlobalHelpAliasAtApplicationRoot(t *testing.T) {
app, err := New(Identity{Name: "Example"}, FlagAlias("--hlep", "--help"))
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"--hlep"}, Streams{Out: &output, Err: io.Discard})
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), "Usage:") {
t.Fatalf("root help alias outcome = %v, output %q", outcome.Kind, output.String())
}
}
func TestAliasOnlyPrefixProducesDiscoveryRoute(t *testing.T) {
app, err := New(Identity{Name: "Example"},
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
Alias("list collections", "promptkit list collections"),
)
if err != nil {
t.Fatal(err)
}
result := resolveEnteredRoute([]string{"list", "--help"}, app.routeSnapshot())
if result.kind != routeDiscovery {
t.Fatalf("route kind = %d, want discovery", result.kind)
}
if got, want := result.discoveryPath.String(), "list"; got != want {
t.Fatalf("discovery path = %q, want %q", got, want)
}
if got, want := result.enteredPath.String(), "list"; got != want {
t.Fatalf("entered path = %q, want %q", got, want)
}
if !reflect.DeepEqual(result.args, []string{"list", "--help"}) {
t.Fatalf("discovery args = %q, want [list --help]", result.args)
}
}