2026-10-19 15:11:43 +00:00
|
|
|
package alias
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-19 15:16:07 +00:00
|
|
|
"errors"
|
2026-10-19 15:11:43 +00:00
|
|
|
"reflect"
|
2026-10-19 15:16:07 +00:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
2026-10-19 15:11:43 +00:00
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestResolverUsesLongestTokenPrefixAndPreservesSuffix(t *testing.T) {
|
|
|
|
|
resolver, err := NewResolver([]Mapping{
|
|
|
|
|
{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}},
|
|
|
|
|
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"app", "collections"}},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := resolver.Resolve(commandpath.Path{"list", "collections", "extra"})
|
|
|
|
|
want := commandpath.Path{"app", "collections", "extra"}
|
|
|
|
|
if !got.Equal(want) {
|
|
|
|
|
t.Fatalf("Resolve() = %#v, want %#v", got, want)
|
|
|
|
|
}
|
|
|
|
|
got = resolver.Resolve(commandpath.Path{"list", "prompts"})
|
|
|
|
|
want = commandpath.Path{"promptkit", "list", "prompts"}
|
|
|
|
|
if !got.Equal(want) {
|
|
|
|
|
t.Fatalf("Resolve() = %#v, want %#v", got, want)
|
|
|
|
|
}
|
|
|
|
|
reversed, err := NewResolver([]Mapping{
|
|
|
|
|
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"app", "collections"}},
|
|
|
|
|
{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if got := reversed.Resolve(commandpath.Path{"list", "collections", "extra"}); !got.Equal(commandpath.Path{"app", "collections", "extra"}) {
|
|
|
|
|
t.Fatalf("reverse-order Resolve() = %#v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestResolverFallsBackAfterLongerTokenMatchFails(t *testing.T) {
|
|
|
|
|
resolver, err := NewResolver([]Mapping{
|
|
|
|
|
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"short"}},
|
|
|
|
|
{Source: commandpath.Path{"a", "b", "c"}, Target: commandpath.Path{"long"}},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
got := resolver.Resolve(commandpath.Path{"a", "b", "x"})
|
|
|
|
|
want := commandpath.Path{"short", "b", "x"}
|
|
|
|
|
if !got.Equal(want) {
|
|
|
|
|
t.Fatalf("Resolve() = %#v, want %#v", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestResolverDoesNotMatchUnknownTokens(t *testing.T) {
|
|
|
|
|
resolver, err := NewResolver([]Mapping{{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
input := commandpath.Path{"listing", "collections"}
|
|
|
|
|
got := resolver.Resolve(input)
|
|
|
|
|
if !got.Equal(input) {
|
|
|
|
|
t.Fatalf("Resolve() = %#v, want unchanged %#v", got, input)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCompiledMachineMatchesDirectResolver(t *testing.T) {
|
|
|
|
|
mappings := []Mapping{
|
|
|
|
|
{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}},
|
|
|
|
|
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"promptkit", "list", "collections"}},
|
|
|
|
|
{Source: commandpath.Path{"pk"}, Target: commandpath.Path{"promptkit"}},
|
|
|
|
|
{Source: commandpath.Path{"promptkit", "list", "bundles"}, Target: commandpath.Path{"promptkit", "list", "collections"}},
|
|
|
|
|
{Source: commandpath.Path{"short"}, Target: commandpath.Path{"promptkit", "list"}},
|
|
|
|
|
{Source: commandpath.Path{"short", "b", "c"}, Target: commandpath.Path{"long"}},
|
|
|
|
|
}
|
|
|
|
|
resolver, machine, err := Compile(mappings)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for key, operation := range machine.transitions {
|
|
|
|
|
if len(operation.push) > 2 {
|
|
|
|
|
t.Fatalf("transition %+v pushes %d symbols", key, len(operation.push))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
cases := []commandpath.Path{
|
|
|
|
|
{"list"},
|
|
|
|
|
{"list", "prompts"},
|
|
|
|
|
{"list", "collections"},
|
|
|
|
|
{"list", "collections", "extra"},
|
|
|
|
|
{"pk", "list", "bundles", "tail"},
|
|
|
|
|
{"short", "b", "x"},
|
|
|
|
|
{"unknown", "operand"},
|
|
|
|
|
{},
|
|
|
|
|
}
|
|
|
|
|
for _, marker := range []symbolKind{endSymbol, boundarySymbol} {
|
|
|
|
|
for _, input := range cases {
|
|
|
|
|
t.Run(input.String(), func(t *testing.T) {
|
|
|
|
|
got, err := runMachineForTest(machine, input, marker)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
want := expectedSymbols(machine, resolver.Resolve(input), marker)
|
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
|
|
|
t.Fatalf("machine result = %#v, want direct result %#v", got, want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestResolverTreatsIdenticalMappingsAsIdempotent(t *testing.T) {
|
|
|
|
|
mapping := Mapping{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}
|
|
|
|
|
resolver, err := NewResolver([]Mapping{mapping, mapping})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
got := resolver.Resolve(commandpath.Path{"list", "prompts"})
|
|
|
|
|
if want := (commandpath.Path{"promptkit", "list", "prompts"}); !got.Equal(want) {
|
|
|
|
|
t.Fatalf("Resolve() = %#v, want %#v", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestResolverRejectsInvalidAndConflictingMappings(t *testing.T) {
|
|
|
|
|
for name, mappings := range map[string][]Mapping{
|
|
|
|
|
"empty source": {{Target: commandpath.Path{"target"}}},
|
|
|
|
|
"empty target": {{Source: commandpath.Path{"source"}}},
|
|
|
|
|
"conflicting target": {
|
|
|
|
|
{Source: commandpath.Path{"source"}, Target: commandpath.Path{"first"}},
|
|
|
|
|
{Source: commandpath.Path{"source"}, Target: commandpath.Path{"second"}},
|
|
|
|
|
},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
|
if _, err := NewResolver(mappings); err == nil {
|
|
|
|
|
t.Fatal("NewResolver() succeeded")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 15:16:07 +00:00
|
|
|
func TestTerminationRejectsCyclesAndGrowingPaths(t *testing.T) {
|
|
|
|
|
for name, mappings := range map[string][]Mapping{
|
|
|
|
|
"direct cycle": {
|
|
|
|
|
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b"}},
|
|
|
|
|
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"a"}},
|
|
|
|
|
},
|
|
|
|
|
"growing path": {
|
|
|
|
|
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b", "extra"}},
|
|
|
|
|
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"a"}},
|
|
|
|
|
},
|
|
|
|
|
"suffix-dependent cycle": {
|
|
|
|
|
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b"}},
|
|
|
|
|
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"group"}},
|
|
|
|
|
{Source: commandpath.Path{"b", "x"}, Target: commandpath.Path{"a", "x"}},
|
|
|
|
|
},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
|
err := ValidateTermination(mappings)
|
|
|
|
|
var loop *NonTerminationError
|
|
|
|
|
if !errors.As(err, &loop) {
|
|
|
|
|
t.Fatalf("ValidateTermination() error = %v, want NonTerminationError", err)
|
|
|
|
|
}
|
|
|
|
|
if len(loop.Mappings) == 0 {
|
|
|
|
|
t.Fatal("NonTerminationError has no participating mappings")
|
|
|
|
|
}
|
|
|
|
|
for _, mapping := range loop.Mappings {
|
|
|
|
|
if !strings.Contains(err.Error(), mapping.Source.String()) {
|
|
|
|
|
t.Fatalf("error %q omits participating source %q", err, mapping.Source.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if name == "suffix-dependent cycle" {
|
|
|
|
|
resolver, err := NewResolver(mappings)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if got := resolver.Resolve(commandpath.Path{"a"}); !got.Equal(commandpath.Path{"group"}) {
|
|
|
|
|
t.Fatalf("bare route resolves to %#v, want [group]", got)
|
|
|
|
|
}
|
|
|
|
|
first, _, matched := resolver.ResolveOnce(commandpath.Path{"a", "x"})
|
|
|
|
|
second, _, matchedAgain := resolver.ResolveOnce(first)
|
|
|
|
|
if !matched || !matchedAgain || !second.Equal(commandpath.Path{"a", "x"}) {
|
|
|
|
|
t.Fatalf("suffix cycle is not demonstrated: first %#v, second %#v", first, second)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTerminationAcceptsConsumingRepetition(t *testing.T) {
|
|
|
|
|
mappings := []Mapping{
|
|
|
|
|
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"group"}},
|
|
|
|
|
{Source: commandpath.Path{"group", "again"}, Target: commandpath.Path{"a"}},
|
|
|
|
|
}
|
|
|
|
|
if err := ValidateTermination(mappings); err != nil {
|
|
|
|
|
t.Fatalf("ValidateTermination() = %v", err)
|
|
|
|
|
}
|
|
|
|
|
resolver, err := NewResolver(mappings)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
got := resolver.Resolve(commandpath.Path{"a", "again", "again"})
|
|
|
|
|
if want := (commandpath.Path{"group"}); !got.Equal(want) {
|
|
|
|
|
t.Fatalf("Resolve() = %#v, want %#v", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTerminationUsesLongestMatchBeforeFollowingShorterRule(t *testing.T) {
|
|
|
|
|
mappings := []Mapping{
|
|
|
|
|
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b", "x"}},
|
|
|
|
|
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"a"}},
|
|
|
|
|
{Source: commandpath.Path{"b", "x"}, Target: commandpath.Path{"group"}},
|
|
|
|
|
}
|
|
|
|
|
if err := ValidateTermination(mappings); err != nil {
|
|
|
|
|
t.Fatalf("ValidateTermination() = %v", err)
|
|
|
|
|
}
|
|
|
|
|
resolver, err := NewResolver(mappings)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if got := resolver.Resolve(commandpath.Path{"a"}); !got.Equal(commandpath.Path{"group"}) {
|
|
|
|
|
t.Fatalf("Resolve() = %#v, want [group]", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTerminationAcceptsLongChainsInAnyRegistrationOrder(t *testing.T) {
|
|
|
|
|
const count = 80
|
|
|
|
|
mappings := make([]Mapping, 0, count)
|
|
|
|
|
for index := 0; index < count; index++ {
|
|
|
|
|
source := commandpath.Path{fmtAliasName(index)}
|
|
|
|
|
target := commandpath.Path{fmtAliasName(index + 1)}
|
|
|
|
|
if index == count-1 {
|
|
|
|
|
target = commandpath.Path{"canonical"}
|
|
|
|
|
}
|
|
|
|
|
mappings = append(mappings, Mapping{Source: source, Target: target})
|
|
|
|
|
}
|
|
|
|
|
if err := ValidateTermination(mappings); err != nil {
|
|
|
|
|
t.Fatalf("forward registration order: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for left, right := 0, len(mappings)-1; left < right; left, right = left+1, right-1 {
|
|
|
|
|
mappings[left], mappings[right] = mappings[right], mappings[left]
|
|
|
|
|
}
|
|
|
|
|
if err := ValidateTermination(mappings); err != nil {
|
|
|
|
|
t.Fatalf("reverse registration order: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTerminalMarkersCannotBePopped(t *testing.T) {
|
|
|
|
|
_, machine, err := Compile([]Mapping{{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b"}}})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
pop := saturatePopSummaries(machine)
|
|
|
|
|
for markerName, marker := range map[string]symbolKind{"end": endSymbol, "boundary": boundarySymbol} {
|
|
|
|
|
for from := range pop {
|
|
|
|
|
if from.symbol.kind == marker {
|
|
|
|
|
t.Fatalf("%s marker can be popped from control %d", markerName, from.control)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := ValidateTermination(nil); err != nil {
|
|
|
|
|
t.Fatalf("empty mapping set: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fmtAliasName(index int) string {
|
|
|
|
|
return "alias" + strconv.Itoa(index)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 15:11:43 +00:00
|
|
|
func expectedSymbols(machine *Machine, path commandpath.Path, marker symbolKind) []symbol {
|
|
|
|
|
result := make([]symbol, 0, len(path)+1)
|
|
|
|
|
for _, token := range path {
|
|
|
|
|
result = append(result, machine.classify(token))
|
|
|
|
|
}
|
|
|
|
|
result = append(result, symbol{kind: marker})
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func runMachineForTest(machine *Machine, input commandpath.Path, marker symbolKind) ([]symbol, error) {
|
|
|
|
|
stack := expectedSymbols(machine, input, marker)
|
|
|
|
|
control := machine.start
|
|
|
|
|
for steps := 0; steps < 10000; steps++ {
|
|
|
|
|
if control == machine.halt {
|
|
|
|
|
return stack, nil
|
|
|
|
|
}
|
|
|
|
|
if len(stack) == 0 {
|
|
|
|
|
return nil, errMachineStackEmpty
|
|
|
|
|
}
|
|
|
|
|
operation, ok := machine.transitions[transitionKey{control: control, read: stack[0]}]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, errMachineTransitionMissing
|
|
|
|
|
}
|
|
|
|
|
stack = append(append([]symbol(nil), operation.push...), stack[1:]...)
|
|
|
|
|
control = operation.next
|
|
|
|
|
}
|
|
|
|
|
return nil, errMachineTestDidNotHalt
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
errMachineStackEmpty = testError("compiled machine emptied its stack")
|
|
|
|
|
errMachineTransitionMissing = testError("compiled machine has no transition")
|
|
|
|
|
errMachineTestDidNotHalt = testError("compiled machine did not halt in the test interpreter")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type testError string
|
|
|
|
|
|
|
|
|
|
func (err testError) Error() string { return string(err) }
|
|
|
|
|
|
|
|
|
|
func (machine *Machine) classify(token string) symbol {
|
|
|
|
|
if _, exists := machine.literals[token]; exists {
|
|
|
|
|
return symbol{kind: literalSymbol, value: token}
|
|
|
|
|
}
|
|
|
|
|
return symbol{kind: otherSymbol}
|
|
|
|
|
}
|