358 lines
15 KiB
Go
358 lines
15 KiB
Go
package ckconfig
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
|
)
|
|
|
|
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
|
|
type input struct {
|
|
Enabled commandkit.Supplied[bool]
|
|
Limit commandkit.Supplied[int]
|
|
Tags commandkit.Supplied[[]string]
|
|
}
|
|
var inputCalls, checkCalls, stateCalls int
|
|
var parseCalls int
|
|
var preparedInputs []*input
|
|
var selections []Selection
|
|
enabled := commandkit.BoolFlag[*input](commandkit.FlagMeta{Name: "enabled"}, true, func(value **input, supplied commandkit.Supplied[bool]) {
|
|
(*value).Enabled = supplied
|
|
})
|
|
limit := commandkit.ValueFlag[*input](commandkit.FlagMeta{Name: "limit", Required: true}, (*int)(nil), func(raw string) (int, error) {
|
|
parseCalls++
|
|
return strconv.Atoi(raw)
|
|
}, func(value **input, supplied commandkit.Supplied[int]) {
|
|
(*value).Limit = supplied
|
|
})
|
|
tags := commandkit.RepeatFlag[*input](commandkit.FlagMeta{Name: "tag"}, nil, func(raw string) (string, error) {
|
|
parseCalls++
|
|
return raw, nil
|
|
}, func(value **input, supplied commandkit.Supplied[[]string]) {
|
|
(*value).Tags = supplied
|
|
})
|
|
suite := Suite[*input, struct{}]{
|
|
NewInput: func() *input {
|
|
inputCalls++
|
|
return &input{}
|
|
},
|
|
NewState: func() struct{} {
|
|
stateCalls++
|
|
if checkCalls != 2 {
|
|
t.Errorf("shared state created after %d input checks", checkCalls)
|
|
}
|
|
if len(preparedInputs) != 2 || preparedInputs[0] == preparedInputs[1] {
|
|
t.Errorf("inputs were not initialized separately: %#v", preparedInputs)
|
|
} else {
|
|
left, right := preparedInputs[0].Tags.Value, preparedInputs[1].Tags.Value
|
|
if len(left) != 2 || len(right) != 2 || &left[0] == &right[0] {
|
|
t.Errorf("repeated values share storage: left=%#v right=%#v", left, right)
|
|
} else {
|
|
left[0] = "changed"
|
|
if right[0] != "alpha" {
|
|
t.Errorf("mutating one side changed the other: %#v", right)
|
|
}
|
|
}
|
|
}
|
|
return struct{}{}
|
|
},
|
|
Selectors: []Selector[*input]{{Binding: enabled}, {Binding: limit}, {Binding: tags}},
|
|
CheckInput: func(scope string, selection Selection, value *input) error {
|
|
checkCalls++
|
|
preparedInputs = append(preparedInputs, value)
|
|
selections = append(selections, selection)
|
|
if scope != "app" || value == nil {
|
|
return errors.New("unexpected scope input")
|
|
}
|
|
if !value.Enabled.Set || value.Enabled.Value {
|
|
return errors.New("explicit false was not preserved")
|
|
}
|
|
if !value.Limit.Set || value.Limit.Value != 0 {
|
|
return errors.New("explicit zero was not preserved")
|
|
}
|
|
if !value.Tags.Set || len(value.Tags.Value) != 2 || value.Tags.Value[0] != "alpha" || value.Tags.Value[1] != "beta" {
|
|
return errors.New("repeated input was not preserved")
|
|
}
|
|
return nil
|
|
},
|
|
Scopes: []Scope[*input, struct{}]{{
|
|
Name: "app", Operations: []Operation{Diff},
|
|
Compare: func(context.Context, ComparisonRequest[*input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
},
|
|
}},
|
|
}
|
|
app := newConfigTestApp(t, Command(suite))
|
|
outcome := app.Execute(context.Background(), []string{
|
|
"config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=",
|
|
"--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta",
|
|
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
|
if outcome.Kind != commandkit.OutcomeSuccess {
|
|
t.Fatalf("valid comparison outcome = %#v", outcome)
|
|
}
|
|
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 {
|
|
t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
|
}
|
|
if parseCalls != 3 {
|
|
t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls)
|
|
}
|
|
wantSelection := Selection{
|
|
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
|
|
Overlay: commandkit.Supplied[string]{Set: true},
|
|
}
|
|
if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection {
|
|
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
|
|
}
|
|
}
|
|
|
|
func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
|
|
type input struct{ ID int }
|
|
type state struct{ Label string }
|
|
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
|
|
checked := make(map[Selection]input)
|
|
shared := &state{Label: "shared"}
|
|
var request ComparisonRequest[input]
|
|
app := newConfigTestApp(t, Command(Suite[input, *state]{
|
|
NewInput: func() input {
|
|
inputCalls++
|
|
return input{ID: inputCalls}
|
|
},
|
|
NewState: func() *state {
|
|
stateCalls++
|
|
if checkCalls != 2 {
|
|
t.Errorf("shared state created after %d input checks", checkCalls)
|
|
}
|
|
return shared
|
|
},
|
|
CheckInput: func(scope string, selection Selection, value input) error {
|
|
checkCalls++
|
|
if scope != "app" {
|
|
return errors.New("unexpected scope")
|
|
}
|
|
checked[selection] = value
|
|
return nil
|
|
},
|
|
Scopes: []Scope[input, *state]{{
|
|
Name: "app", Operations: []Operation{Show, Diff},
|
|
Acquire: func(context.Context, Request, input, *state) (commandkit.Acquired[View], error) {
|
|
acquireCalls++
|
|
return commandkit.Acquired[View]{}, errors.New("ordinary acquisition reached during comparison")
|
|
},
|
|
Compare: func(_ context.Context, pair ComparisonRequest[input], value *state) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
compareCalls++
|
|
if checkCalls != 2 || stateCalls != 1 || value != shared {
|
|
t.Errorf("provider reached with checks=%d states=%d shared=%t", checkCalls, stateCalls, value == shared)
|
|
}
|
|
request = pair
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
},
|
|
}},
|
|
}))
|
|
outcome := app.Execute(context.Background(), []string{
|
|
"config", "diff", "--config", "before.yml", "--against-overlay=",
|
|
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
|
if outcome.Kind != commandkit.OutcomeSuccess {
|
|
t.Fatalf("comparison outcome = %#v", outcome)
|
|
}
|
|
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
|
|
right := Selection{Config: left.Config, Overlay: commandkit.Supplied[string]{Set: true}}
|
|
if request.Left.Selection != left || request.Right.Selection != right {
|
|
t.Fatalf("provider selections = %#v", request)
|
|
}
|
|
if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] {
|
|
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
|
|
}
|
|
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {
|
|
t.Fatalf("invocation calls input=%d check=%d state=%d compare=%d acquire=%d", inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls)
|
|
}
|
|
}
|
|
|
|
func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) {
|
|
checkCalls := 0
|
|
failure := errors.New("right input rejected")
|
|
prepared := captureSuite(Suite[struct{}, struct{}]{
|
|
CheckInput: func(string, Selection, struct{}) error {
|
|
checkCalls++
|
|
if checkCalls == 2 {
|
|
return failure
|
|
}
|
|
return nil
|
|
},
|
|
Scopes: []Scope[struct{}, struct{}]{{
|
|
Name: "app", Operations: []Operation{Diff},
|
|
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
},
|
|
}},
|
|
})
|
|
commandInput := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
|
if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
|
|
t.Fatalf("right-side input failure = %v", err)
|
|
}
|
|
if commandInput.plan != nil || checkCalls != 2 {
|
|
t.Fatalf("failed pair retained partial state: plan=%#v checks=%d", commandInput.plan, checkCalls)
|
|
}
|
|
}
|
|
|
|
func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) {
|
|
checkCalls := 0
|
|
prepared := captureSuite(Suite[*int, struct{}]{
|
|
CheckInput: func(_ string, _ Selection, value *int) error {
|
|
checkCalls++
|
|
if value != nil {
|
|
return errors.New("nil factory did not supply the zero value")
|
|
}
|
|
return nil
|
|
},
|
|
Scopes: []Scope[*int, struct{}]{{
|
|
Name: "app", Operations: []Operation{Diff},
|
|
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
},
|
|
}},
|
|
})
|
|
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
|
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := commandInput.plan.comparison.request
|
|
if checkCalls != 2 || request.Left.Input != nil || request.Right.Input != nil {
|
|
t.Fatalf("nil input value changed: checks=%d request=%#v", checkCalls, request)
|
|
}
|
|
}
|
|
|
|
func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) {
|
|
shared := new(int)
|
|
inputCalls, checkCalls := 0, 0
|
|
prepared := captureSuite(Suite[*int, struct{}]{
|
|
NewInput: func() *int { inputCalls++; return shared },
|
|
CheckInput: func(_ string, _ Selection, value *int) error {
|
|
checkCalls++
|
|
if value != shared {
|
|
return errors.New("caller-owned alias was replaced")
|
|
}
|
|
return nil
|
|
},
|
|
Scopes: []Scope[*int, struct{}]{{
|
|
Name: "app", Operations: []Operation{Diff},
|
|
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
},
|
|
}},
|
|
})
|
|
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
|
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := commandInput.plan.comparison.request
|
|
if inputCalls != 2 || checkCalls != 2 || request.Left.Input != shared || request.Right.Input != shared {
|
|
t.Fatalf("caller-owned alias changed: calls=%d/%d request=%#v", inputCalls, checkCalls, request)
|
|
}
|
|
}
|
|
|
|
func TestComparisonInputFailureStopsBeforeStateAndPreservesWiringClassification(t *testing.T) {
|
|
var inputCalls, checkCalls, stateCalls int
|
|
suite := Suite[struct{}, struct{}]{
|
|
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
|
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
|
CheckInput: func(string, Selection, struct{}) error {
|
|
checkCalls++
|
|
if checkCalls == 2 {
|
|
return commandkit.ErrFlagWiring
|
|
}
|
|
return nil
|
|
},
|
|
Scopes: []Scope[struct{}, struct{}]{{
|
|
Name: "app", Operations: []Operation{Diff},
|
|
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
},
|
|
}},
|
|
}
|
|
app := newConfigTestApp(t, Command(suite))
|
|
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, commandkit.ErrFlagWiring) {
|
|
t.Fatalf("right-side wiring failure = %#v", outcome)
|
|
}
|
|
if !strings.Contains(outcome.Cause.Error(), `scope "app" right input`) {
|
|
t.Fatalf("right-side error lacks scope context: %v", outcome.Cause)
|
|
}
|
|
if inputCalls != 2 || checkCalls != 2 || stateCalls != 0 {
|
|
t.Fatalf("failed preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
|
}
|
|
}
|
|
|
|
func TestComparisonMissingRequiredSelectorIsUsageBeforeState(t *testing.T) {
|
|
type input struct{ Token commandkit.Supplied[string] }
|
|
var inputCalls, checkCalls, stateCalls int
|
|
token := commandkit.ValueFlag(commandkit.FlagMeta{Name: "token", Required: true}, (*string)(nil), parseConfigString, func(value *input, supplied commandkit.Supplied[string]) {
|
|
value.Token = supplied
|
|
})
|
|
suite := Suite[input, struct{}]{
|
|
NewInput: func() input { inputCalls++; return input{} },
|
|
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
|
Selectors: []Selector[input]{{Binding: token, Scopes: []string{"app"}}},
|
|
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
|
|
Scopes: []Scope[input, struct{}]{{
|
|
Name: "app", Operations: []Operation{Diff},
|
|
Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
},
|
|
}},
|
|
}
|
|
app := newConfigTestApp(t, Command(suite))
|
|
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
|
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) {
|
|
t.Fatalf("missing required selector = %#v", outcome)
|
|
}
|
|
if inputCalls != 1 || checkCalls != 0 || stateCalls != 0 {
|
|
t.Fatalf("missing selector calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
|
}
|
|
}
|
|
|
|
func TestComparisonHelpConvertsInapplicableSelectorsWithoutBuildingInputs(t *testing.T) {
|
|
type input struct{ Tenant int }
|
|
parseErr := errors.New("invalid tenant")
|
|
var parseCalls, inputCalls, checkCalls, stateCalls int
|
|
tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*int)(nil), func(raw string) (int, error) {
|
|
parseCalls++
|
|
if raw == "bad" {
|
|
return 0, parseErr
|
|
}
|
|
return strconv.Atoi(raw)
|
|
}, func(value *input, supplied commandkit.Supplied[int]) {
|
|
value.Tenant = supplied.Value
|
|
})
|
|
makeScope := func(name string) Scope[input, struct{}] {
|
|
return Scope[input, struct{}]{Name: name, Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
|
}}
|
|
}
|
|
suite := Suite[input, struct{}]{
|
|
NewInput: func() input { inputCalls++; return input{} },
|
|
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
|
Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"promptkit"}}},
|
|
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
|
|
Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")},
|
|
}
|
|
app := newConfigTestApp(t, Command(suite))
|
|
var output strings.Builder
|
|
app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=7", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
|
if !strings.Contains(output.String(), "--tenant") || parseCalls != 1 {
|
|
t.Fatalf("help did not convert the inapplicable supplied selector: calls=%d help=%q", parseCalls, output.String())
|
|
}
|
|
outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=bad", "--help"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
|
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, parseErr) || parseCalls != 2 {
|
|
t.Fatalf("malformed help selector outcome=%#v conversions=%d", outcome, parseCalls)
|
|
}
|
|
if inputCalls != 0 || checkCalls != 0 || stateCalls != 0 {
|
|
t.Fatalf("help built suite work: input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
|
}
|
|
}
|