Files
ConfigKit/resolution_test.go

458 lines
17 KiB
Go

package configkit
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
)
func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
t.Parallel()
type artifact struct {
Output string `yaml:"output"`
Timeout time.Duration `yaml:"timeout"`
}
type config struct {
PollInterval time.Duration `yaml:"poll_interval"`
Timeout time.Duration `yaml:"timeout"`
Artifacts map[string]artifact `yaml:"artifacts"`
}
definition := Definition[config]{
Defaults: func() config {
return config{PollInterval: time.Minute, Timeout: 5 * time.Second}
},
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("timeout"), &configuration.Timeout,
configuration.PollInterval/10, "derived from poll interval", Keys("poll_interval"))
for name, current := range configuration.Artifacts {
path := Keys("artifacts", name)
Default(resolution, path.Key("timeout"), &current.Timeout,
configuration.Timeout, "inherited configuration timeout", Keys("timeout"))
Normalize(resolution, path.Key("output"), &current.Output,
strings.TrimSpace(current.Output), "trimmed surrounding whitespace")
configuration.Artifacts[name] = current
}
resolution.Warning(Keys("timeout"), "using derived timeout", Keys("poll_interval"))
return nil
},
}
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
result, err := Load(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if result.Config.Timeout != 6*time.Second {
t.Fatalf("timeout = %s", result.Config.Timeout)
}
for name, want := range map[string]string{"first": "first", "second": "second"} {
current := result.Config.Artifacts[name]
if current.Timeout != 6*time.Second || current.Output != want {
t.Errorf("artifact %q = %#v", name, current)
}
}
if diagnostics := result.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != CodeResolveWarning || diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
metadata := result.Metadata()
timeout, ok := metadata.Lookup(Keys("timeout"))
if !ok || timeout.Explicit || timeout.Origin.Layer != LayerResolution || timeout.Origin.Operation != ChangeDerivedDefault {
t.Fatalf("timeout metadata = %#v, %v", timeout, ok)
}
output, ok := metadata.Lookup(Keys("artifacts", "first", "output"))
if !ok || !output.Explicit || output.Origin.Operation != ChangeNormalization {
t.Fatalf("output metadata = %#v, %v", output, ok)
}
history := metadata.History(Keys("timeout"))
last := history[len(history)-1]
if last.Origin.Operation != ChangeDerivedDefault || !reflect.DeepEqual(last.Reasons, []string{"derived from poll interval"}) || len(last.Dependencies) != 1 || last.Dependencies[0].String() != "/poll_interval" {
t.Fatalf("timeout history = %#v", history)
}
wantOrder := []string{
"/artifacts/first/output", "/artifacts/first/timeout",
"/artifacts/second/output", "/artifacts/second/timeout", "/timeout",
}
gotOrder := make([]string, len(result.resolution))
for index, snapshot := range result.resolution {
gotOrder[index] = snapshot.change.Path.String()
}
if !reflect.DeepEqual(gotOrder, wantOrder) {
t.Fatalf("resolution order = %#v", gotOrder)
}
}
func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *testing.T) {
t.Parallel()
type config struct {
Optional *int `yaml:"optional"`
Count int `yaml:"count"`
Absent int `yaml:"absent"`
}
five := 5
result, err := Load(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
return errors.New("unexpected presence")
}
Default(resolution, Keys("optional"), &configuration.Optional, &five, "would derive optional")
Default(resolution, Keys("count"), &configuration.Count, 9, "would derive count")
Default(resolution, Keys("absent"), &configuration.Absent, 9, "derived absent value")
return nil
},
}})
if err != nil {
var loadError *LoadError
_ = errors.As(err, &loadError)
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
}
if result.Config.Optional != nil || result.Config.Count != 0 || result.Config.Absent != 9 {
t.Fatalf("config = %#v", result.Config)
}
if len(result.Metadata().History(Keys("optional"))) != 2 || len(result.Metadata().History(Keys("count"))) != 2 || len(result.Metadata().History(Keys("absent"))) != 2 {
t.Fatal("resolution history disagrees with explicitness")
}
}
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
Other int `yaml:"other"`
}
result, err := Load(NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Value: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", Keys("other"))
Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other"))
return nil
},
}})
if err != nil {
t.Fatal(err)
}
history := result.Metadata().History(Keys("value"))
if len(history) != 2 {
t.Fatalf("history = %#v", history)
}
change := history[1]
if change.Origin.Operation != ChangeNormalization || !reflect.DeepEqual(change.Reasons, []string{"equal derived intent", "updated derived intent", "canonicalized value"}) || len(change.Dependencies) != 1 {
t.Fatalf("coalesced change = %#v", change)
}
}
func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t *testing.T) {
t.Parallel()
type item struct {
Value string `yaml:"value"`
}
type config struct {
Items map[string]item `yaml:"items"`
}
result, err := Load(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
if _, exists := result.Config.Items["remove"]; exists {
t.Fatal("removed entry remains")
}
if _, ok := result.Metadata().Lookup(Keys("items", "remove")); ok {
t.Fatal("removed entry remains effective metadata")
}
if len(result.resolution) != 1 || result.resolution[0].before == nil || result.resolution[0].after == nil {
t.Fatalf("snapshots = %#v", result.resolution)
}
result.Config.Items["keep"] = item{Value: "changed"}
if mappingValue(t, result.resolution[0].after, "keep").Mapping[0].Value.Text != "yes" {
t.Fatal("caller mutation changed private resolution snapshot")
}
}
func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
t.Parallel()
type config struct {
Values []int `yaml:"values"`
}
result, err := Load(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
info, ok := result.Metadata().Lookup(Keys("values").Index(0))
if !ok || !info.Explicit || info.Origin.Layer != LayerResolution || info.Origin.Operation != ChangeNormalization {
t.Fatalf("element metadata = %#v, %v", info, ok)
}
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
t.Fatal("removed sequence element remains effective")
}
}
func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
tests := []struct {
name string
resolve func(*config, *Resolution) error
code Code
}{
{name: "unrecorded", resolve: func(configuration *config, _ *Resolution) error {
configuration.Value = 2
return nil
}, code: CodeResolveUnrecordedChange},
{name: "unrelated target", resolve: func(_ *config, resolution *Resolution) error {
unrelated := 1
Normalize(resolution, Keys("value"), &unrelated, 2, "changed value")
return nil
}, code: CodeResolveInvalidTarget},
{name: "invalid record kind", resolve: func(_ *config, resolution *Resolution) error {
resolution.Record(Keys("value"), ChangeKind("other"), "invalid kind")
return nil
}, code: CodeResolveInvalidKind},
{name: "invalid path", resolve: func(_ *config, resolution *Resolution) error {
resolution.Record(Keys("unknown"), ChangeNormalization, "unknown field")
return nil
}, code: CodeResolveInvalidPath},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
assertLoadDiagnostic(t, result, err, test.code)
})
}
}
func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *testing.T) {
t.Parallel()
type config struct {
Number float64 `yaml:"number"`
Value int `yaml:"value"`
}
tests := []struct {
name string
resolve func(*config, *Resolution) error
code Code
}{
{name: "invalid helper value", resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("number"), &configuration.Number, math.NaN(), "invalid number")
return nil
}, code: CodeResolveInvalidValue},
{name: "reported error", resolve: func(_ *config, resolution *Resolution) error {
resolution.Error(Keys("value"), "value cannot be resolved")
return errors.New("CANARY_RESOLUTION_ERROR")
}, code: CodeResolveError},
{name: "returned error", resolve: func(_ *config, _ *Resolution) error {
return errors.New("CANARY_RESOLUTION_ERROR")
}, code: CodeResolveHookFailed},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
loadError := assertLoadDiagnostic(t, result, err, test.code)
for _, diagnostic := range loadError.Diagnostics() {
if strings.Contains(diagnostic.Message, "CANARY") {
t.Fatalf("diagnostic exposed returned error: %#v", diagnostic)
}
}
})
}
}
func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
t.Parallel()
type config struct {
Declared string `yaml:"declared"`
Default string `yaml:"default"`
Optional *string `yaml:"optional"`
}
filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n")
var savedField FieldInfo
var savedSource SourceInfo
result, err := Load[config](File(filename), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} {
info, found := resolution.InputInfo(path)
if !found || info.Explicit != resolution.IsExplicit(path) {
return errors.New("entry metadata does not agree with explicitness")
}
}
defaultInfo, _ := resolution.InputInfo(Keys("default"))
optionalInfo, _ := resolution.InputInfo(Keys("optional"))
if defaultInfo.Explicit || defaultInfo.Origin.Layer != LayerDefault || !optionalInfo.Explicit || !optionalInfo.Null {
return errors.New("entry metadata lost default or null state")
}
if _, found := resolution.InputInfo(Keys("missing")); found {
return errors.New("missing entry path was found")
}
declared, _ := resolution.InputInfo(Keys("declared"))
if len(declared.Origin.Locations) != 1 {
return errors.New("declared source location is missing")
}
source, found := resolution.Source(declared.Origin.Locations[0].SourceID)
if !found || source.Path != canonicalTestPath(t, filename) || source.ID != "source-1" {
return errors.New("declared source record is missing")
}
if _, found := resolution.Source(""); found {
return errors.New("empty source identity was found")
}
if _, found := resolution.Source("unknown"); found {
return errors.New("unknown source identity was found")
}
savedField, savedSource = declared, source
return nil
},
}})
if err != nil {
t.Fatal(err)
}
if result.Config.Declared != "file" || result.Config.Default != "retained" || result.Config.Optional != nil {
t.Fatalf("configuration = %#v", result.Config)
}
savedField.Origin.Locations[0].SourceID = "mutated"
savedSource.Label = "mutated"
field, _ := result.Metadata().Lookup(Keys("declared"))
if field.Origin.Locations[0].SourceID != "source-1" || result.Sources()[0].Label == "mutated" {
t.Fatal("saved resolution records alias a completed result")
}
noSource, err := Load[config](NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
info, found := resolution.InputInfo(Keys("default"))
if !found || info.Explicit || resolution.IsExplicit(Keys("default")) {
return errors.New("defaults-only entry metadata is wrong")
}
if _, found := resolution.Source("source-1"); found {
return errors.New("defaults-only load invented a source")
}
return nil
},
}})
if err != nil || noSource.Config.Default != "retained" {
t.Fatalf("defaults-only load = %#v, %v", noSource, err)
}
}
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
var retained *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution
return nil
}}}); err != nil {
t.Fatal(err)
}
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
retained.IsExplicit(Keys("value"))
}
func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
requireClosed := func(t *testing.T, call func()) {
t.Helper()
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
call()
}
requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) })
requireClosed(t, func() { (&Resolution{}).Source("source-1") })
var successful *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
successful = resolution
return nil
}}}); err != nil {
t.Fatal(err)
}
requireClosed(t, func() { successful.InputInfo(Keys("value")) })
requireClosed(t, func() { successful.Source("source-1") })
var failed *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
failed = resolution
return errors.New("resolver failed")
}}}); err == nil {
t.Fatal("failed resolver succeeded")
}
requireClosed(t, func() { failed.InputInfo(Keys("value")) })
requireClosed(t, func() { failed.Source("source-1") })
}
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
t.Parallel()
type config struct {
Version string `yaml:"version"`
}
var retained *Validation
if _, err := Load(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation
return nil
}},
}}); err != nil {
t.Fatal(err)
}
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
retained.Warning(Keys("version"), "too late")
}
func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError {
t.Helper()
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v", diagnostics)
}
return loadError
}