Implement canonical projection and detached copies
This commit is contained in:
394
projection_test.go
Normal file
394
projection_test.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package configkit
|
||||
|
||||
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 := compareDocumentTrees(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 := compareDocumentTrees(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 := buildSchema(reflect.TypeFor[T]())
|
||||
if issue != nil {
|
||||
t.Fatalf("buildSchema() 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user