feat: builder + App runner, Makefile (v1.1.0)
Builder (cli.For[C]): Root/Dir/Cmd/SlotDir/Slot construct the tree without repeating the [C] type parameter at every node; returns plain *Node[C] so it interoperates with struct-literal construction. App[C]: collapses the per-binary main.go — standard flags (-color/-json/-version, -server when configured), mode-aware color defaults, version banner, client connect, and the one-shot-vs-shell split — into one Main(). Transport-agnostic via a Connect callback, so it never assumes gRPC. Makefile: `make check` = fixstyle vet lint test (the pre-commit gate), plus build and a linux+openbsd cross target. The example now dogfoods both Builder and App. Tests cover the builder tree and App's one-shot dispatch / -version / nil-Connect paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildFixtureB constructs the same tree shape as buildFixture (tree_test.go)
|
||||
// but via the Builder, so the two are interchangeable. dynInstances, dynEvpns,
|
||||
// dynWatchOpts, runNoop and runQuit are defined in tree_test.go (same package).
|
||||
func buildFixtureB() *Node[fakeClient] {
|
||||
b := For[fakeClient]()
|
||||
|
||||
// Circular watch-options slot: built then wired to itself.
|
||||
watchOpt := b.Slot("<opts>", "", dynWatchOpts, runNoop)
|
||||
watchOpt.Children = []*Node[fakeClient]{watchOpt}
|
||||
|
||||
return b.Root(
|
||||
b.Dir("show", "show state",
|
||||
b.Cmd("instance", "list fleet members (add <id> for one)", runNoop,
|
||||
b.Slot("<id>", "show one member", dynInstances, runNoop,
|
||||
b.Cmd("evpn", "list the member's EVPNs", runNoop,
|
||||
b.Slot("<evpn>", "show one EVPN", dynEvpns, runNoop))))),
|
||||
b.Dir("watch", "stream events",
|
||||
b.Cmd("events", "watch events", runNoop, watchOpt)),
|
||||
b.Cmd("quit", "exit", runQuit),
|
||||
)
|
||||
}
|
||||
|
||||
// TestBuilderMatchesLiteral checks the Builder produces a tree that Walks
|
||||
// identically to the struct-literal fixture: same runnable nodes, captured
|
||||
// args, prefix abbreviation, and circular-slot handling.
|
||||
func TestBuilderMatchesLiteral(t *testing.T) {
|
||||
root := buildFixtureB()
|
||||
cases := []struct {
|
||||
toks []string
|
||||
runnable bool
|
||||
help string
|
||||
args []string
|
||||
remaining int
|
||||
}{
|
||||
{[]string{"show", "instance"}, true, "list fleet members (add <id> for one)", nil, 0},
|
||||
{[]string{"show", "instance", "host1"}, true, "show one member", []string{"host1"}, 0},
|
||||
{[]string{"show", "instance", "host1", "evpn", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0},
|
||||
{[]string{"sh", "inst"}, true, "list fleet members (add <id> for one)", nil, 0},
|
||||
{[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0},
|
||||
{[]string{"show", "bogus"}, false, "", nil, 1},
|
||||
}
|
||||
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 got := node.Run != nil; got != tc.runnable {
|
||||
t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, got, tc.runnable)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilderDynamicCandidates checks a Builder-built slot still resolves
|
||||
// context-dependent candidates through its Dynamic function.
|
||||
func TestBuilderDynamicCandidates(t *testing.T) {
|
||||
root := buildFixtureB()
|
||||
client := fakeClient{
|
||||
instances: []string{"host1", "host2"},
|
||||
evpns: map[string][]string{"host1": {"blue", "red"}},
|
||||
}
|
||||
got := Candidates(context.Background(), client, root, []string{"show", "instance", "host1", "evpn"}, "")
|
||||
if len(got) != 2 || got[0].Word != "blue" || got[1].Word != "red" {
|
||||
t.Errorf("Candidates(... host1 evpn) = %v, want [blue red]", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user