// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt // SPDX-License-Identifier: Apache-2.0 package cli import ( "context" "sort" "strings" "testing" ) // fakeClient stands in for an app's gRPC client. The fixture tree's Dynamic // funcs read from it, proving the client is threaded through unchanged and that // Dynamic sees the args captured by earlier slot nodes. type fakeClient struct { instances []string evpns map[string][]string // per-instance EVPN names } func dynInstances(_ context.Context, c fakeClient, _ []string) []string { return c.instances } // dynEvpns demonstrates context-dependent lookup: it lists the EVPNs of the // instance captured as args[0] earlier on the path. func dynEvpns(_ context.Context, c fakeClient, args []string) []string { if len(args) == 0 { return nil } return c.evpns[args[0]] } func dynWatchOpts(_ context.Context, _ fakeClient, _ []string) []string { return []string{"num", "log", "crud"} } func runNoop(context.Context, fakeClient, []string) error { return nil } func runQuit(context.Context, fakeClient, []string) error { return ErrQuit } // buildFixture mirrors the real apps' tree shape: singular keyword nodes that // double as "list" and "show one", a context-dependent slot, a circular // watch-options slot, and a quit leaf. func buildFixture() *Node[fakeClient] { showInstanceEvpnName := &Node[fakeClient]{Word: "", Help: "show one EVPN", Dynamic: dynEvpns, Run: runNoop} showInstanceEvpn := &Node[fakeClient]{Word: "evpn", Help: "list the member's EVPNs", Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpnName}} showInstanceName := &Node[fakeClient]{Word: "", Help: "show one member", Dynamic: dynInstances, Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpn}} show := &Node[fakeClient]{Word: "show", Help: "show state", Children: []*Node[fakeClient]{ {Word: "instance", Help: "list fleet members (add for one)", Run: runNoop, Children: []*Node[fakeClient]{showInstanceName}}, }} // Circular watch-options slot: every option captured as an arg and may be // followed by another option. watchOpt := &Node[fakeClient]{Word: "", Dynamic: dynWatchOpts, Run: runNoop} watchOpt.Children = []*Node[fakeClient]{watchOpt} watch := &Node[fakeClient]{Word: "watch", Help: "stream events", Children: []*Node[fakeClient]{ {Word: "events", Help: "watch events", Run: runNoop, Children: []*Node[fakeClient]{watchOpt}}, }} return &Node[fakeClient]{Children: []*Node[fakeClient]{ show, watch, {Word: "quit", Help: "exit", Run: runQuit}, }} } func TestWalk(t *testing.T) { root := buildFixture() cases := []struct { toks []string runnable bool help string args []string remaining int }{ {[]string{"show", "instance"}, true, "list fleet members (add for one)", nil, 0}, {[]string{"show", "instance", "host1"}, true, "show one member", []string{"host1"}, 0}, {[]string{"show", "instance", "host1", "evpn"}, true, "list the member's EVPNs", []string{"host1"}, 0}, {[]string{"show", "instance", "host1", "evpn", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0}, // prefix abbreviation: singular, unique keywords resolve from a prefix. {[]string{"sh", "inst"}, true, "list fleet members (add for one)", nil, 0}, // circular watch options are captured as args. {[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0}, // unknown trailing token is reported as remaining. {[]string{"show", "bogus"}, false, "", nil, 1}, // "show" itself is not runnable and consumes everything. {[]string{"show"}, false, "", nil, 0}, } for _, tc := range cases { node, args, remaining := Walk(root, tc.toks) if len(remaining) != tc.remaining { t.Errorf("Walk(%v) remaining=%v, want %d", tc.toks, remaining, tc.remaining) continue } if tc.remaining > 0 { continue } if gotRunnable := node.Run != nil; gotRunnable != tc.runnable { t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, gotRunnable, tc.runnable) continue } if tc.runnable && node.Help != tc.help { t.Errorf("Walk(%v) help=%q, want %q", tc.toks, node.Help, tc.help) } if strings.Join(args, ",") != strings.Join(tc.args, ",") { t.Errorf("Walk(%v) args=%v, want %v", tc.toks, args, tc.args) } } } func TestExpandPathsCoversCommands(t *testing.T) { root := buildFixture() var joined string for _, l := range ExpandPaths(root, "") { joined += l.Path + "\n" } for _, want := range []string{ "show instance", "show instance evpn ", "watch events", "quit", } { if !strings.Contains(joined, want) { t.Errorf("ExpandPaths missing %q\n%s", want, joined) } } } func TestCandidates(t *testing.T) { root := buildFixture() client := fakeClient{ instances: []string{"host1", "host2"}, evpns: map[string][]string{"host1": {"blue", "red"}}, } ctx := context.Background() words := func(ns []*Node[fakeClient]) []string { out := make([]string, 0, len(ns)) for _, n := range ns { out = append(out, n.Word) } sort.Strings(out) return out } eq := func(got, want []string) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } // Fixed-child completion filtered by partial. if got := words(Candidates(ctx, client, root, []string{"show"}, "inst")); !eq(got, []string{"instance"}) { t.Errorf(`Candidates(show, "inst") = %v, want [instance]`, got) } // Dynamic slot values offered for an empty partial. if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "")); !eq(got, []string{"host1", "host2"}) { t.Errorf(`Candidates(show instance, "") = %v, want [host1 host2]`, got) } // Dynamic slot values filtered by partial. if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "host1")); !eq(got, []string{"host1"}) { t.Errorf(`Candidates(show instance, "host1") = %v, want [host1]`, got) } // Context-dependent slot: lists the EVPNs of host1 captured earlier. if got := words(Candidates(ctx, client, root, []string{"show", "instance", "host1", "evpn"}, "")); !eq(got, []string{"blue", "red"}) { t.Errorf(`Candidates(... host1 evpn, "") = %v, want [blue red]`, got) } // A broken left context offers nothing. if got := Candidates(ctx, client, root, []string{"show", "bogus"}, ""); got != nil { t.Errorf("Candidates(show bogus) = %v, want nil", got) } } func TestDispatchRunsCommand(t *testing.T) { root := buildFixture() var gotArgs []string root.Children = append(root.Children, &Node[fakeClient]{ Word: "echo", Children: []*Node[fakeClient]{{ Word: "", Dynamic: func(context.Context, fakeClient, []string) []string { return nil }, Run: func(_ context.Context, _ fakeClient, a []string) error { gotArgs = a return nil }, }}, }) if err := Dispatch(context.Background(), root, fakeClient{}, []string{"echo", "hi"}); err != nil { t.Fatalf("Dispatch echo: %v", err) } if len(gotArgs) != 1 || gotArgs[0] != "hi" { t.Errorf("Run got args %v, want [hi]", gotArgs) } // Unknown command surfaces as an error. if err := Dispatch(context.Background(), root, fakeClient{}, []string{"nope"}); err == nil { t.Error("Dispatch(nope) = nil, want unknown-command error") } }