502 lines
18 KiB
Go
502 lines
18 KiB
Go
package commandkit
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
|
)
|
|
|
|
type registryInput struct{ value string }
|
|
|
|
func TestCapturedDeclarationsRetainCallbacksWithoutCallingThem(t *testing.T) {
|
|
var factoryCalls, prepareCalls, setterCalls, checkCalls, runCalls int
|
|
identity := newBindingIdentity()
|
|
flag := Flag[registryInput]{binding: flagBinding[registryInput]{
|
|
identity: identity, meta: FlagMeta{Name: "mode"}, valid: true,
|
|
prepare: func([]string) (preparedValue[registryInput], error) {
|
|
prepareCalls++
|
|
return preparedValue[registryInput]{identity: identity, apply: func(*registryInput) { setterCalls++ }}, nil
|
|
},
|
|
}}
|
|
command := NewCommand(CommandSpec[registryInput]{
|
|
Name: "Inspect",
|
|
NewInput: func() registryInput { factoryCalls++; return registryInput{} },
|
|
Flags: []Flag[registryInput]{flag},
|
|
CheckInput: func(registryInput) error { checkCalls++; return nil },
|
|
Run: func(Invocation, registryInput) Outcome { runCalls++; return Success() },
|
|
})
|
|
group := Group("Tools", "tools", command)
|
|
app, err := New(Identity{Name: "Example"}, group)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Register(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if factoryCalls+prepareCalls+setterCalls+checkCalls+runCalls != 0 {
|
|
t.Fatalf("construction invoked callback: factory=%d prepare=%d setter=%d check=%d run=%d", factoryCalls, prepareCalls, setterCalls, checkCalls, runCalls)
|
|
}
|
|
if !app.commands["Tools"].command.record.children[0].record.payload.hasRun() {
|
|
t.Fatal("registered command lost its handler")
|
|
}
|
|
}
|
|
|
|
func TestRoutingRuleFactoriesCaptureNormalizedValues(t *testing.T) {
|
|
commandRule := Alias(" old\tpath ", " new ")
|
|
if got, want := commandRule.record.sourcePath.String(), "old path"; got != want {
|
|
t.Fatalf("source path = %q, want %q", got, want)
|
|
}
|
|
if got, want := commandRule.record.targetPath.String(), "new"; got != want {
|
|
t.Fatalf("target path = %q, want %q", got, want)
|
|
}
|
|
globalRule := FlagAlias("--colour", "-c")
|
|
if globalRule.record.sourceFlag != (capturedFlagSpelling{name: "colour"}) {
|
|
t.Fatalf("source spelling = %#v", globalRule.record.sourceFlag)
|
|
}
|
|
if globalRule.record.targetFlag != (capturedFlagSpelling{name: "c", short: true}) {
|
|
t.Fatalf("target spelling = %#v", globalRule.record.targetFlag)
|
|
}
|
|
scopedRule := FlagAliasFor(" new\t", "-f", "--help")
|
|
if got, want := scopedRule.record.scopePath.String(), "new"; got != want {
|
|
t.Fatalf("scope path = %q, want %q", got, want)
|
|
}
|
|
|
|
command := NewCommand(CommandSpec[struct{}]{Name: "new", Summary: "new"})
|
|
app, err := New(Identity{Name: "Example"}, &command, &commandRule, globalRule, scopedRule)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(app.routingRules) != 3 {
|
|
t.Fatalf("captured routing rules = %d, want 3", len(app.routingRules))
|
|
}
|
|
registeredCommand := NewCommand(CommandSpec[struct{}]{Name: "registered", Summary: "registered"})
|
|
registeredRule := Alias("another", "registered")
|
|
if err := app.Register(®isteredCommand, ®isteredRule); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(app.routingRules) != 4 || app.commands["registered"] == nil {
|
|
t.Fatalf("pointer declarations were not normalized during Register: rules=%d command=%v", len(app.routingRules), app.commands["registered"])
|
|
}
|
|
}
|
|
|
|
func TestInvalidRoutingRuleValuesFailAtInstallation(t *testing.T) {
|
|
for name, rule := range map[string]RoutingRule{
|
|
"empty command source": Alias(" \t ", "target"),
|
|
"invalid command target": Alias("source", "--help"),
|
|
"flag assignment": FlagAlias("--format=yaml", "--format"),
|
|
"short cluster": FlagAlias("-xy", "--format"),
|
|
"invalid scope": FlagAliasFor("", "-f", "--format"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := New(Identity{Name: "Example"}, rule); err == nil {
|
|
t.Fatal("New() succeeded with an invalid routing rule")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNilAndZeroDeclarationsFailWithoutMutation(t *testing.T) {
|
|
app, err := New(Identity{Name: "Example"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var nilCommand *Command
|
|
var nilRule *RoutingRule
|
|
declarations := []Declaration{nil, nilCommand, nilRule, Command{}, RoutingRule{}}
|
|
for index, declaration := range declarations {
|
|
if _, err := New(Identity{Name: "Example"}, declaration); err == nil {
|
|
t.Errorf("New() accepted invalid declaration %d", index)
|
|
}
|
|
beforeCommands := len(app.commands)
|
|
beforeRules := len(app.routingRules)
|
|
if err := app.Register(declaration); err == nil {
|
|
t.Errorf("Register() accepted invalid declaration %d", index)
|
|
}
|
|
if len(app.commands) != beforeCommands || len(app.routingRules) != beforeRules {
|
|
t.Errorf("invalid declaration %d mutated the app", index)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAliasTargetsCanBeDeclaredInEitherOrder(t *testing.T) {
|
|
canonical := simpleRunnable("canonical")
|
|
for name, declarations := range map[string][]Declaration{
|
|
"targets last": {
|
|
Alias("shortcut", "alternate"),
|
|
Alias("alternate", "canonical"),
|
|
canonical,
|
|
},
|
|
"targets first": {
|
|
canonical,
|
|
Alias("alternate", "canonical"),
|
|
Alias("shortcut", "alternate"),
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
app, err := New(Identity{Name: "Example"}, declarations...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := app.aliases.Resolve(commandpath.Path{"shortcut"}); !got.Equal(commandpath.Path{"canonical"}) {
|
|
t.Fatalf("shortcut resolves to %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIdenticalCommandAliasesAreIdempotentAcrossRegistrations(t *testing.T) {
|
|
app, err := New(Identity{Name: "Example"}, simpleRunnable("canonical"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rule := Alias("shortcut", "canonical")
|
|
if err := app.Register(rule, Alias(" shortcut ", " canonical ")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(app.routingRules) != 1 {
|
|
t.Fatalf("routing rules = %d, want one normalized rule", len(app.routingRules))
|
|
}
|
|
snapshot := app.routeSnapshot()
|
|
if err := app.Register(Alias("shortcut", "canonical")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(app.routingRules) != 1 || app.routeSnapshot() != snapshot {
|
|
t.Fatal("identical registration changed aliases or invalidated the snapshot")
|
|
}
|
|
}
|
|
|
|
func TestCommandAliasesRejectMissingAndCompetingTargets(t *testing.T) {
|
|
if _, err := New(Identity{Name: "Example"}, Alias("shortcut", "missing")); err == nil {
|
|
t.Fatal("New() accepted a missing alias target")
|
|
}
|
|
_, err := New(Identity{Name: "Example"},
|
|
simpleRunnable("canonical"),
|
|
Alias("first", "canonical"),
|
|
Alias("second", "canonical"),
|
|
Alias("shortcut", "first"),
|
|
Alias("shortcut", "second"),
|
|
)
|
|
if err == nil {
|
|
t.Fatal("New() accepted competing declared targets with a shared endpoint")
|
|
}
|
|
}
|
|
|
|
func TestCommandAliasesRejectCanonicalRoutesAndOperands(t *testing.T) {
|
|
group := Group("group", "group", simpleRunnable("show"))
|
|
if _, err := New(Identity{Name: "Example"}, group, simpleRunnable("target"), Alias("group show", "target")); err == nil {
|
|
t.Fatal("New() accepted an alias source that replaces a canonical child")
|
|
}
|
|
|
|
run := runnableWithOperands("run", 1, 1)
|
|
if _, err := New(Identity{Name: "Example"}, run, simpleRunnable("inspect"), Alias("run full", "inspect")); err == nil {
|
|
t.Fatal("New() accepted an alias source that intercepts a valid operand")
|
|
}
|
|
if _, err := New(Identity{Name: "Example"}, run, Alias("fast", "run production")); err == nil {
|
|
t.Fatal("New() accepted an operand-bearing alias target")
|
|
}
|
|
|
|
promptkit := Group("promptkit", "promptkit", runnableWithOperands("test", 1, 1))
|
|
if _, err := New(Identity{Name: "Example"}, promptkit, Alias("promptkit test full", "promptkit test all")); err == nil {
|
|
t.Fatal("New() accepted an alias that replaces the test command's KIND operand")
|
|
}
|
|
}
|
|
|
|
func TestCommandAliasesProtectIncompleteCanonicalOperandPrefixes(t *testing.T) {
|
|
for _, bounds := range []struct{ min, max int }{{2, 2}, {3, -1}} {
|
|
t.Run(fmt.Sprintf("%d_%d", bounds.min, bounds.max), func(t *testing.T) {
|
|
run := runnableWithOperands("run", bounds.min, bounds.max)
|
|
mapping := Alias("run first", "inspect")
|
|
if _, err := New(Identity{Name: "Example"}, run, simpleRunnable("inspect"), mapping); err == nil {
|
|
t.Fatal("construction accepted an alias intercepting an operand prefix")
|
|
}
|
|
for _, aliasFirst := range []bool{false, true} {
|
|
app, err := New(Identity{Name: "Example"}, simpleRunnable("inspect"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var first, second Declaration = run, mapping
|
|
if aliasFirst {
|
|
first, second = second, first
|
|
}
|
|
if err := app.Register(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Register(second, simpleRunnable("unrelated")); err == nil {
|
|
t.Fatal("later registration accepted an operand interception")
|
|
}
|
|
if app.commands["unrelated"] != nil || (app.commands["run"] != nil) == aliasFirst {
|
|
t.Fatal("rejected batch changed canonical registration")
|
|
}
|
|
if (len(app.routingRules) == 1) != aliasFirst {
|
|
t.Fatal("rejected batch changed alias registration")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLongerAliasBeneathAlternateSpellingCanUseOperandWords(t *testing.T) {
|
|
app, err := New(Identity{Name: "Example"},
|
|
runnableWithOperands("run", 1, 1),
|
|
simpleRunnable("inspect"),
|
|
Alias("r", "run"),
|
|
Alias("r full", "inspect"),
|
|
Alias("run too many words", "inspect"),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := app.aliases.Resolve(commandpath.Path{"r", "full"}); !got.Equal(commandpath.Path{"inspect"}) {
|
|
t.Fatalf("longer alternate route resolves to %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestFailedAliasRegistrationPreservesCommandsRulesAndSnapshot(t *testing.T) {
|
|
app, err := New(Identity{Name: "Example"}, simpleRunnable("canonical"), Alias("existing", "canonical"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot := app.routeSnapshot()
|
|
oldResolver := app.aliases
|
|
retained := errors.New("retained construction failure")
|
|
invalid := WithError(simpleRunnable("new"), fmt.Errorf("integration: %w", retained))
|
|
err = app.Register(invalid, Alias("later", "missing"))
|
|
if !errors.Is(err, retained) {
|
|
t.Fatalf("Register() error = %v, want retained cause", err)
|
|
}
|
|
if app.commands["new"] != nil || len(app.routingRules) != 1 || app.routeSnapshot() != snapshot || app.aliases != oldResolver {
|
|
t.Fatal("failed registration changed the app registry or derived metadata")
|
|
}
|
|
}
|
|
|
|
func TestNewCanonicalCommandIsCheckedAgainstExistingAliases(t *testing.T) {
|
|
app, err := New(Identity{Name: "Example"}, simpleRunnable("canonical"), Alias("shortcut", "canonical"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot := app.routeSnapshot()
|
|
if err := app.Register(simpleRunnable("shortcut")); err == nil {
|
|
t.Fatal("Register() accepted a canonical command that collides with an alias source")
|
|
}
|
|
if app.commands["shortcut"] != nil || len(app.routingRules) != 1 || app.routeSnapshot() != snapshot {
|
|
t.Fatal("failed canonical registration changed the app registry or snapshot")
|
|
}
|
|
}
|
|
|
|
func simpleRunnable(name string) Command {
|
|
return NewCommand(CommandSpec[struct{}]{
|
|
Name: name, Summary: name,
|
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
|
})
|
|
}
|
|
|
|
func runnableWithOperands(name string, min, max int) Command {
|
|
return NewCommand(CommandSpec[struct{}]{
|
|
Name: name, Summary: name,
|
|
Arguments: []Argument[struct{}]{RepeatArgument(ArgumentMeta{Name: "ARG"}, min, max,
|
|
func(value string) (string, error) { return value, nil },
|
|
func(*struct{}, Supplied[[]string]) {},
|
|
)},
|
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
|
})
|
|
}
|
|
|
|
func TestAppsHaveIndependentRegistries(t *testing.T) {
|
|
first, err := New(Identity{Name: "First"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := New(Identity{Name: "Second"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
command := NewCommand(CommandSpec[struct{}]{Name: "custom"})
|
|
if err := first.Register(command); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, exists := second.commands["custom"]; exists {
|
|
t.Fatal("registration leaked to another app")
|
|
}
|
|
if first.commands["help"] == nil || first.commands["help"].command.record.payload == nil || first.commands["version"] == nil || first.commands["version"].command.record.payload == nil {
|
|
t.Fatal("New did not install real help and version declarations")
|
|
}
|
|
}
|
|
|
|
func TestDeclarationIssuesAreReportedAtInstallation(t *testing.T) {
|
|
for _, name := range []string{"", "--", "-option", "two words", " leading", "trailing "} {
|
|
app, err := New(Identity{Name: "Example"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Register(NewCommand(CommandSpec[struct{}]{Name: name})); err == nil {
|
|
t.Errorf("installation accepted invalid name %q", name)
|
|
}
|
|
}
|
|
app, err := New(Identity{Name: "Example"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Register(NewCommand(CommandSpec[struct{}]{Name: "CamelCase"})); err != nil {
|
|
t.Fatalf("mixed-case name rejected: %v", err)
|
|
}
|
|
|
|
duplicate := Group("parent", "", NewCommand(CommandSpec[struct{}]{Name: "repeat"}), NewCommand(CommandSpec[struct{}]{Name: "repeat"}))
|
|
if err := app.Register(duplicate); err == nil {
|
|
t.Fatal("installation accepted duplicate sibling names")
|
|
}
|
|
if err := app.Register(Group("parent", "", Command{})); err == nil {
|
|
t.Fatal("installation accepted a zero child handle")
|
|
}
|
|
if err := app.Register(Command{}); err == nil {
|
|
t.Fatal("installation accepted a zero command handle")
|
|
}
|
|
}
|
|
|
|
func TestRunnableCommandCapturesChildrenAndLeavesOriginalUnchanged(t *testing.T) {
|
|
child := NewCommand(CommandSpec[struct{}]{Name: "child"})
|
|
children := []Command{child}
|
|
parent := NewCommand(CommandSpec[struct{}]{Name: "parent", Children: children, Run: func(Invocation, struct{}) Outcome { return Success() }})
|
|
children[0] = NewCommand(CommandSpec[struct{}]{Name: "changed"})
|
|
if parent.record.payload == nil || !parent.record.payload.hasRun() {
|
|
t.Fatal("runnable parent lost its handler")
|
|
}
|
|
if len(parent.record.children) != 1 || parent.record.children[0].record.name != "child" {
|
|
t.Fatalf("captured children = %#v", parent.record.children)
|
|
}
|
|
app, err := New(Identity{Name: "Example"}, parent)
|
|
if err != nil {
|
|
t.Fatalf("runnable parent with children rejected: %v", err)
|
|
}
|
|
if len(app.commands["parent"].command.record.children) != 1 {
|
|
t.Fatal("child declaration was not installed")
|
|
}
|
|
}
|
|
|
|
func TestGroupCapturesChildrenAndSkipsNestedAbsence(t *testing.T) {
|
|
children := []Command{NewCommand(CommandSpec[struct{}]{Name: "visible"}), NoCommand()}
|
|
group := Group("parent", "", children...)
|
|
children[0] = NewCommand(CommandSpec[struct{}]{Name: "changed"})
|
|
if len(group.record.children) != 2 || group.record.children[0].record.name != "visible" {
|
|
t.Fatalf("group did not capture child handles: %#v", group.record.children)
|
|
}
|
|
app, err := New(Identity{Name: "Example"}, group)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(app.commands["parent"].command.record.children) != 2 {
|
|
t.Fatal("captured child missing")
|
|
}
|
|
}
|
|
|
|
func TestRootGroupRetainsPlacementMetadata(t *testing.T) {
|
|
root := RootGroup("config", "Configuration.")
|
|
if !root.record.rootOnly || root.record.absent || root.record.name != "config" {
|
|
t.Fatalf("root group metadata = %#v", root.record)
|
|
}
|
|
copy := root
|
|
if copy.record != root.record {
|
|
t.Fatal("copied handle did not retain captured record")
|
|
}
|
|
}
|
|
|
|
func TestBatchInstallationRejectsNestedRootOnlyAndKeepsExistingState(t *testing.T) {
|
|
root := RootGroup("config", "Configuration.")
|
|
app, err := New(Identity{Name: "Example"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Register(Group("outer", "", root)); err == nil {
|
|
t.Fatal("installation accepted nested root-only declaration")
|
|
}
|
|
if _, exists := app.commands["outer"]; exists {
|
|
t.Fatal("failed batch partially installed its parent")
|
|
}
|
|
if err := app.Register(root); err != nil {
|
|
t.Fatalf("root-level declaration rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNoOpOnlyRegistrationPreservesRoutingSnapshot(t *testing.T) {
|
|
app, err := New(Identity{Name: "Example"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := app.routeSnapshot()
|
|
if err := app.Register(NoCommand()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after := app.routeSnapshot(); after != before {
|
|
t.Fatal("no-op-only registration invalidated derived metadata")
|
|
}
|
|
}
|
|
|
|
func TestCapturedDeclarationCanBeInstalledIntoMultipleApps(t *testing.T) {
|
|
declaration := Group("tools", "", NewCommand(CommandSpec[struct{}]{Name: "run"}))
|
|
first, err := New(Identity{Name: "First"}, declaration)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := New(Identity{Name: "Second"}, declaration)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(first.commands["tools"].command.record.children) != 1 {
|
|
t.Fatal("first app lost reused declaration")
|
|
}
|
|
if len(second.commands["tools"].command.record.children) != 1 {
|
|
t.Fatal("second app lost reused declaration")
|
|
}
|
|
}
|
|
|
|
func TestWithErrorCopiesDeclarationAndRetainsWrappedCauses(t *testing.T) {
|
|
first := errors.New("first construction issue")
|
|
second := errors.New("second construction issue")
|
|
base := NewCommand(CommandSpec[struct{}]{Name: "run"})
|
|
withFirst := WithError(base, first)
|
|
withBoth := WithError(withFirst, errors.Join(errors.New("wrapper"), second))
|
|
if len(base.record.issues) != 0 {
|
|
t.Fatal("WithError mutated its source declaration")
|
|
}
|
|
if len(withFirst.record.issues) != 1 || len(withBoth.record.issues) != 2 {
|
|
t.Fatal("WithError did not retain prior causes")
|
|
}
|
|
app, err := New(Identity{Name: "Example"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = app.Register(withBoth)
|
|
if !errors.Is(err, first) || !errors.Is(err, second) {
|
|
t.Fatalf("installation error %v did not preserve causes", err)
|
|
}
|
|
if err := app.Register(WithError(NoCommand(), first)); !errors.Is(err, first) {
|
|
t.Fatalf("error on absence = %v", err)
|
|
}
|
|
if err := app.Register(NoCommand()); err != nil {
|
|
t.Fatalf("plain absence was not skipped: %v", err)
|
|
}
|
|
if err := app.Register(RootGroup("empty", "")); err != nil {
|
|
t.Fatalf("real empty root group rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsEmptyIdentity(t *testing.T) {
|
|
if _, err := New(Identity{}); err == nil {
|
|
t.Fatal("New accepted an empty identity name")
|
|
}
|
|
}
|
|
|
|
func TestNewCommandCopiesBindingMetadata(t *testing.T) {
|
|
identity := newBindingIdentity()
|
|
flags := []Flag[registryInput]{{binding: flagBinding[registryInput]{identity: identity, meta: FlagMeta{Name: "mode"}, valid: true, prepare: func([]string) (preparedValue[registryInput], error) {
|
|
return preparedValue[registryInput]{identity: identity, apply: func(*registryInput) {}}, nil
|
|
}}}}
|
|
command := NewCommand(CommandSpec[registryInput]{Name: "run", Flags: flags})
|
|
flags[0].binding.meta.Name = "changed"
|
|
payload := command.record.payload.(typedCommand[registryInput])
|
|
if got := payload.spec.Flags[0].binding.meta.Name; got != "mode" {
|
|
t.Fatalf("stored flag name = %q, want mode", got)
|
|
}
|
|
}
|