Files
ConfigKit/internal/engine/projection_test.go

395 lines
14 KiB
Go

package engine
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type mutableText struct {
data []byte
}
func (value mutableText) MarshalText() ([]byte, error) {
return value.data, nil
}
func (value *mutableText) UnmarshalText(text []byte) error {
value.data = append(value.data[:0], text...)
return nil
}
type failingText struct {
failUnmarshal bool
}
func (value failingText) MarshalText() ([]byte, error) {
return []byte("CANARY_CODEC_TEXT"), errors.New("CANARY_MARSHAL_ERROR")
}
func (value *failingText) UnmarshalText([]byte) error {
if value.failUnmarshal {
return errors.New("CANARY_UNMARSHAL_ERROR")
}
return nil
}
type reconstructFailText string
func (value reconstructFailText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *reconstructFailText) UnmarshalText([]byte) error {
return errors.New("CANARY_RECONSTRUCT_ERROR")
}
func TestProjectionPreservesCanonicalTypesAndNilEmptyDistinctions(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type config struct {
Zero int `yaml:"zero"`
Wide uint64 `yaml:"wide"`
Delay time.Duration `yaml:"delay"`
Pointer *nested `yaml:"pointer"`
NilPointer *nested `yaml:"nil_pointer"`
EmptyMap map[string]string `yaml:"empty_map"`
NilMap map[string]string `yaml:"nil_map"`
EmptySlice []string `yaml:"empty_slice"`
NilSlice []string `yaml:"nil_slice"`
Objects map[string]*nested `yaml:"objects"`
Fixed [2]int `yaml:"fixed"`
Custom mutableText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
Ignored *nested `yaml:"-"`
private *nested
}
value := config{
Wide: math.MaxUint64,
Delay: 1500 * time.Millisecond,
Pointer: &nested{Value: "pointer"},
EmptyMap: map[string]string{},
EmptySlice: []string{},
Objects: map[string]*nested{
"z": {Value: "last"},
"a": {Value: "first"},
},
Fixed: [2]int{1, 2},
Custom: mutableText{data: []byte("canonical")},
Dynamic: map[string]any{"count": int64(9), "items": []any{"x", false}},
Ignored: &nested{Value: "ignored"},
private: &nested{Value: "private"},
}
schema := mustSchemaFor[config](t)
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatalf("projectConfiguration() issue = %#v", issue)
}
assertDocument(t, mappingValue(t, projected, "zero"), document.Number, "0")
assertDocument(t, mappingValue(t, projected, "wide"), document.Number, "18446744073709551615")
assertDocument(t, mappingValue(t, projected, "delay"), document.String, "1.5s")
assertDocument(t, mappingValue(t, projected, "custom"), document.String, "canonical")
if mappingValue(t, projected, "nil_pointer").Kind != document.Null || mappingValue(t, projected, "nil_map").Kind != document.Null || mappingValue(t, projected, "nil_slice").Kind != document.Null {
t.Fatal("nil values did not project as null")
}
if empty := mappingValue(t, projected, "empty_map"); empty.Kind != document.Mapping || empty.Mapping == nil || len(empty.Mapping) != 0 {
t.Fatalf("empty map = %#v", empty)
}
if empty := mappingValue(t, projected, "empty_slice"); empty.Kind != document.Sequence || empty.Sequence == nil || len(empty.Sequence) != 0 {
t.Fatalf("empty slice = %#v", empty)
}
objects := mappingValue(t, projected, "objects")
if len(objects.Mapping) != 2 || objects.Mapping[0].Key != "a" || objects.Mapping[1].Key != "z" {
t.Fatalf("map order = %#v", objects.Mapping)
}
if hasMappingKey(projected, "ignored") || hasMappingKey(projected, "private") {
t.Fatal("ignored field entered projection")
}
clonedValue, cloneIssue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if cloneIssue != nil {
t.Fatal(cloneIssue.message)
}
cloned := clonedValue.Interface().(config)
if cloned.NilMap != nil || cloned.NilSlice != nil || cloned.NilPointer != nil || cloned.EmptyMap == nil || cloned.EmptySlice == nil {
t.Fatalf("clone lost nil/empty distinctions: %#v", cloned)
}
}
func TestCloneConfigurationDetachesAllSupportedMutableValues(t *testing.T) {
t.Parallel()
type nested struct {
Values []string `yaml:"values"`
}
type config struct {
First *nested `yaml:"first"`
Second *nested `yaml:"second"`
Map map[string]*nested `yaml:"map"`
Slice []map[string]int `yaml:"slice"`
Custom mutableText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
Ignored *nested `yaml:"-"`
}
shared := &nested{Values: []string{"original"}}
value := config{
First: shared,
Second: shared,
Map: map[string]*nested{"entry": {Values: []string{"map"}}},
Slice: []map[string]int{{"count": 1}},
Custom: mutableText{data: []byte("text")},
Dynamic: map[string]any{
"items": []any{map[string]any{"name": "dynamic"}},
},
Ignored: &nested{Values: []string{"ignored"}},
}
schema := mustSchemaFor[config](t)
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatalf("cloneConfiguration() issue = %#v", issue)
}
cloned := clonedValue.Interface().(config)
if cloned.First == cloned.Second || cloned.First == shared || cloned.Custom.data == nil || &cloned.Custom.data[0] == &value.Custom.data[0] {
t.Fatal("clone retained mutable identity")
}
if cloned.Ignored != nil {
t.Fatal("ignored field was retained")
}
value.First.Values[0] = "changed"
value.Map["entry"].Values[0] = "changed"
value.Slice[0]["count"] = 2
value.Custom.data[0] = 'X'
value.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] = "changed"
if cloned.First.Values[0] != "original" || cloned.Map["entry"].Values[0] != "map" || cloned.Slice[0]["count"] != 1 || string(cloned.Custom.data) != "text" || cloned.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] != "dynamic" {
t.Fatalf("clone changed after source mutation: %#v", cloned)
}
}
func TestProjectionAndCloneRejectCyclesAndInvalidDynamicValues(t *testing.T) {
t.Parallel()
type recursive struct {
Next *recursive `yaml:"next"`
}
type config struct {
Tree *recursive `yaml:"tree"`
Dynamic any `yaml:"dynamic"`
}
cyclic := &recursive{}
cyclic.Next = cyclic
schema := mustSchemaFor[config](t)
acyclic := config{Tree: &recursive{Next: &recursive{}}}
if _, issue := projectConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
t.Fatalf("acyclic recursive projection issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
t.Fatalf("acyclic recursive clone issue = %#v", issue)
}
value := config{Tree: cyclic}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle || issue.path.String() != "/tree/next" {
t.Fatalf("projection cycle issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("clone cycle issue = %#v", issue)
}
dynamicCycle := map[string]any{}
dynamicCycle["self"] = dynamicCycle
value = config{Dynamic: dynamicCycle}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("dynamic cycle issue = %#v", issue)
}
value = config{Dynamic: make(chan int)}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic type issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic clone issue = %#v", issue)
}
}
func TestProjectionRejectsNonFiniteLimitsAndCodecFailuresSafely(t *testing.T) {
t.Parallel()
type numbers struct {
Value float64 `yaml:"value"`
}
numberSchema := mustSchemaFor[numbers](t)
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
if _, issue := projectConfiguration(numberSchema, reflect.ValueOf(numbers{Value: value}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaNonFinite {
t.Fatalf("non-finite issue = %#v", issue)
}
}
type codecConfig struct {
Value failingText `yaml:"value"`
}
codecSchema := mustSchemaFor[codecConfig](t)
if _, issue := projectConfiguration(codecSchema, reflect.ValueOf(codecConfig{}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
t.Fatalf("codec projection issue = %#v", issue)
}
type reconstructConfig struct {
Value reconstructFailText `yaml:"value"`
}
reconstructSchema := mustSchemaFor[reconstructConfig](t)
if _, issue := cloneConfiguration(reconstructSchema, reflect.ValueOf(reconstructConfig{Value: "safe-name"}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
t.Fatalf("codec clone issue = %#v", issue)
}
type simple struct {
Value string `yaml:"value"`
}
simpleSchema := mustSchemaFor[simple](t)
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 3}); issue != nil {
t.Fatalf("exact node limit rejected: %#v", issue)
}
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 2}); issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("node overflow issue = %#v", issue)
}
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 0, nodes: 3}); issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("depth overflow issue = %#v", issue)
}
}
func TestInlineProjectionAndCloneUsePhysicalMembers(t *testing.T) {
t.Parallel()
type Embedded struct {
Name string `yaml:"name"`
Items map[string]string `yaml:"items"`
}
type config struct {
*Embedded `yaml:",inline"`
Extras map[string]any `yaml:",inline"`
}
value := config{
Embedded: &Embedded{Name: "value", Items: map[string]string{"a": "b"}},
Extras: map[string]any{"extra": []string{"one"}},
}
schema := mustSchemaFor[config](t)
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil || !hasMappingKey(projected, "name") || !hasMappingKey(projected, "items") || !hasMappingKey(projected, "extra") {
t.Fatalf("inline projection = %#v, issue=%#v", projected, issue)
}
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
cloned := clonedValue.Interface().(config)
value.Items["a"] = "changed"
value.Extras["extra"].([]string)[0] = "changed"
if cloned.Items["a"] != "b" || cloned.Extras["extra"].([]string)[0] != "one" {
t.Fatal("inline clone retained aliases")
}
value.Extras["name"] = "collision"
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaAmbiguousField || issue.path.String() != "/name" {
t.Fatalf("inline collision issue = %#v", issue)
}
}
func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type config struct {
Nested nested `yaml:"nested"`
Mapping map[string]int `yaml:"mapping"`
Sequence []string `yaml:"sequence"`
Maybe map[string]int `yaml:"maybe"`
}
schema := mustSchemaFor[config](t)
before, issue := projectConfiguration(schema, reflect.ValueOf(config{
Nested: nested{Value: "before"},
Mapping: map[string]int{"remove": 1, "same": 2},
Sequence: []string{"a", "b"},
}), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
after, issue := projectConfiguration(schema, reflect.ValueOf(config{
Nested: nested{Value: "after"},
Mapping: map[string]int{"add": 3, "same": 2},
Sequence: []string{"a", "changed"},
Maybe: map[string]int{},
}), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
deltas := compareConfigurationValues(before, after)
want := []struct {
path string
kind documentDeltaKind
}{
{path: "/mapping/add", kind: documentAdded},
{path: "/mapping/remove", kind: documentRemoved},
{path: "/maybe", kind: documentReplaced},
{path: "/nested/value", kind: documentReplaced},
{path: "/sequence", kind: documentReplaced},
}
if len(deltas) != len(want) {
t.Fatalf("deltas = %#v", deltas)
}
for i := range want {
if deltas[i].path.String() != want[i].path || deltas[i].kind != want[i].kind {
t.Fatalf("delta[%d] = %#v, want %#v", i, deltas[i], want[i])
}
}
for _, delta := range deltas {
if strings.HasPrefix(delta.path.String(), "/sequence/") {
t.Fatal("sequence comparison created index correspondence")
}
}
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
t.Fatalf("equal trees changed = %#v", equal)
}
}
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
t.Helper()
schema, issue := testBuildSchema(reflect.TypeFor[T]())
if issue != nil {
t.Fatalf("testBuildSchema() issue = %#v", issue)
}
return schema
}
func mappingValue(t *testing.T, node *document.Node, key string) *document.Node {
t.Helper()
for _, entry := range node.Mapping {
if entry.Key == key {
return entry.Value
}
}
t.Fatalf("mapping key %q missing", key)
return nil
}
func hasMappingKey(node *document.Node, key string) bool {
for _, entry := range node.Mapping {
if entry.Key == key {
return true
}
}
return false
}
func assertDocument(t *testing.T, node *document.Node, kind document.Kind, text string) {
t.Helper()
if node.Kind != kind || node.Text != text {
t.Fatalf("node = %#v, want kind=%v text=%q", node, kind, text)
}
}