Bound JSON-compatible value copying
This commit is contained in:
@@ -1,112 +1,332 @@
|
||||
package jsonvalue_test
|
||||
package jsonvalue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
func TestCopyMapPreservesTypesAndIsolatesMutations(t *testing.T) {
|
||||
nested := map[string]int{"limit": 2}
|
||||
sequence := []string{"one", "two"}
|
||||
input := map[string]any{
|
||||
"count": int64(7),
|
||||
"number": json.Number("-1.25e+2"),
|
||||
"nested": nested,
|
||||
"sequence": sequence,
|
||||
}
|
||||
|
||||
copied, err := jsonvalue.CopyMap(input)
|
||||
if err != nil {
|
||||
t.Fatalf("copy map: %v", err)
|
||||
}
|
||||
nested["limit"] = 99
|
||||
sequence[0] = "changed"
|
||||
input["added"] = true
|
||||
|
||||
if got, ok := copied["count"].(int64); !ok || got != 7 {
|
||||
t.Fatalf("integer type or value changed: %#v", copied["count"])
|
||||
}
|
||||
if got, ok := copied["number"].(json.Number); !ok || got != "-1.25e+2" {
|
||||
t.Fatalf("JSON number type or value changed: %#v", copied["number"])
|
||||
}
|
||||
if got := copied["nested"].(map[string]int)["limit"]; got != 2 {
|
||||
t.Fatalf("nested map was not isolated: %d", got)
|
||||
}
|
||||
if got := copied["sequence"].([]string)[0]; got != "one" {
|
||||
t.Fatalf("sequence was not isolated: %q", got)
|
||||
}
|
||||
if _, ok := copied["added"]; ok {
|
||||
t.Fatalf("top-level map was not isolated: %#v", copied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAllowsEmptyObjectKeysAndIsolatesMutations(t *testing.T) {
|
||||
nested := map[string]any{"": []any{"original"}}
|
||||
|
||||
copiedValue, err := jsonvalue.Copy(nested)
|
||||
if err != nil {
|
||||
t.Fatalf("copy value: %v", err)
|
||||
}
|
||||
nested[""].([]any)[0] = "changed"
|
||||
|
||||
copied := copiedValue.(map[string]any)
|
||||
if got := copied[""].([]any)[0]; got != "original" {
|
||||
t.Fatalf("copied value was not isolated: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyMapRejectsInvalidValues(t *testing.T) {
|
||||
cyclicMap := map[string]any{}
|
||||
cyclicMap["self"] = cyclicMap
|
||||
cyclicSlice := []any{nil}
|
||||
cyclicSlice[0] = cyclicSlice
|
||||
type (
|
||||
namedBool bool
|
||||
namedString string
|
||||
namedInt64 int64
|
||||
namedUint64 uint64
|
||||
namedFloat32 float32
|
||||
namedFloat64 float64
|
||||
namedKey string
|
||||
namedMap map[namedKey]namedInt64
|
||||
namedSlice []namedString
|
||||
namedArray [1]map[string]int
|
||||
)
|
||||
|
||||
func TestCopyPreservesSupportedScalarAndNumberTypes(t *testing.T) {
|
||||
maxInt := int(^uint(0) >> 1)
|
||||
minInt := -maxInt - 1
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{name: "empty nested key", value: map[string]int{"": 1}},
|
||||
{name: "non-string map key", value: map[int]string{1: "one"}},
|
||||
{name: "unsupported value", value: make(chan int)},
|
||||
{name: "cyclic map", value: cyclicMap},
|
||||
{name: "cyclic slice", value: cyclicSlice},
|
||||
{name: "NaN", value: math.NaN()},
|
||||
{name: "positive infinity", value: math.Inf(1)},
|
||||
{name: "unsafe signed integer", value: int64(1 << 53)},
|
||||
{name: "unsafe unsigned integer", value: uint64(1 << 53)},
|
||||
{name: "bool", value: true},
|
||||
{name: "named bool", value: namedBool(true)},
|
||||
{name: "string", value: "value"},
|
||||
{name: "named string", value: namedString("value")},
|
||||
{name: "int", value: minInt},
|
||||
{name: "int8", value: int8(-1 << 7)},
|
||||
{name: "int16", value: int16(-1 << 15)},
|
||||
{name: "int32", value: int32(-1 << 31)},
|
||||
{name: "int64", value: int64(-1 << 63)},
|
||||
{name: "named int64", value: namedInt64(1<<63 - 1)},
|
||||
{name: "uint", value: ^uint(0)},
|
||||
{name: "uint8", value: ^uint8(0)},
|
||||
{name: "uint16", value: ^uint16(0)},
|
||||
{name: "uint32", value: ^uint32(0)},
|
||||
{name: "uint64", value: ^uint64(0)},
|
||||
{name: "uintptr", value: ^uintptr(0)},
|
||||
{name: "named uint64", value: namedUint64(^uint64(0))},
|
||||
{name: "float32", value: float32(1.25)},
|
||||
{name: "float64", value: float64(-2.5e100)},
|
||||
{name: "named float32", value: namedFloat32(3.5)},
|
||||
{name: "named float64", value: namedFloat64(-4.5e200)},
|
||||
{name: "JSON number integer", value: json.Number("18446744073709551615")},
|
||||
{name: "JSON number fraction", value: json.Number("-1.25e+2")},
|
||||
{name: "JSON number beyond float64", value: json.Number("1e9999")},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := jsonvalue.CopyMap(map[string]any{"value": tc.value}); err == nil {
|
||||
got, err := Copy(tc.value)
|
||||
if err != nil {
|
||||
t.Fatalf("copy value: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.value) {
|
||||
t.Fatalf("value or concrete type changed: got %#v (%T), want %#v (%T)", got, got, tc.value, tc.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyRejectsInvalidNumbers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{name: "float32 NaN", value: float32(math.NaN())},
|
||||
{name: "float64 NaN", value: math.NaN()},
|
||||
{name: "named float NaN", value: namedFloat64(math.NaN())},
|
||||
{name: "positive infinity", value: math.Inf(1)},
|
||||
{name: "negative infinity", value: math.Inf(-1)},
|
||||
{name: "empty JSON number", value: json.Number("")},
|
||||
{name: "leading zero JSON number", value: json.Number("01")},
|
||||
{name: "leading plus JSON number", value: json.Number("+1")},
|
||||
{name: "trailing decimal JSON number", value: json.Number("1.")},
|
||||
{name: "leading decimal JSON number", value: json.Number(".1")},
|
||||
{name: "non-number JSON number", value: json.Number("NaN")},
|
||||
{name: "spaced JSON number", value: json.Number(" 1")},
|
||||
{name: "quoted JSON number", value: json.Number(`"1"`)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := Copy(tc.value); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyMapValidatesJSONNumberSyntaxAndRange(t *testing.T) {
|
||||
for _, number := range []json.Number{"0", "-1", "1.25", "-1.25e+2"} {
|
||||
t.Run("valid "+number.String(), func(t *testing.T) {
|
||||
got, err := jsonvalue.CopyMap(map[string]any{"value": number})
|
||||
func TestCopyPreservesCompatibleCollectionsAndNilEmptyDistinctions(t *testing.T) {
|
||||
collections := []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{name: "unnamed map", value: map[string]int{"limit": 2}},
|
||||
{name: "named map", value: namedMap{"limit": 2}},
|
||||
{name: "unnamed slice", value: []string{"one", "two"}},
|
||||
{name: "named slice", value: namedSlice{"one", "two"}},
|
||||
{name: "unnamed array", value: [2]int{1, 2}},
|
||||
{name: "named array", value: namedArray{{"limit": 2}}},
|
||||
{name: "empty map", value: map[string]int{}},
|
||||
{name: "empty named map", value: namedMap{}},
|
||||
{name: "empty slice", value: []string{}},
|
||||
{name: "empty named slice", value: namedSlice{}},
|
||||
{name: "empty array", value: [0]string{}},
|
||||
}
|
||||
|
||||
for _, tc := range collections {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := Copy(tc.value)
|
||||
if err != nil {
|
||||
t.Fatalf("copy valid JSON number: %v", err)
|
||||
t.Fatalf("copy collection: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got["value"], number) {
|
||||
t.Fatalf("JSON number changed: got %#v want %#v", got["value"], number)
|
||||
if !reflect.DeepEqual(got, tc.value) || reflect.TypeOf(got) != reflect.TypeOf(tc.value) {
|
||||
t.Fatalf("collection changed: got %#v (%T), want %#v (%T)", got, got, tc.value, tc.value)
|
||||
}
|
||||
kind := reflect.ValueOf(got).Kind()
|
||||
if (kind == reflect.Map || kind == reflect.Slice) && reflect.ValueOf(got).IsNil() {
|
||||
t.Fatal("non-nil collection became nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, number := range []json.Number{"", "01", "+1", "1.", ".1", "1e9999", "not-a-number"} {
|
||||
t.Run("invalid "+number.String(), func(t *testing.T) {
|
||||
if _, err := jsonvalue.CopyMap(map[string]any{"value": number}); err == nil {
|
||||
t.Fatal("expected invalid JSON number error")
|
||||
var nilMap map[string]int
|
||||
var nilSlice []string
|
||||
var nilPointer *namedInt64
|
||||
for _, value := range []any{nil, nilMap, nilSlice, nilPointer} {
|
||||
got, err := Copy(value)
|
||||
if err != nil {
|
||||
t.Fatalf("copy null value: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("null value became %#v (%T)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
gotNil, err := CopyMap(nil)
|
||||
if err != nil || gotNil != nil {
|
||||
t.Fatalf("nil CopyMap result = %#v, %v", gotNil, err)
|
||||
}
|
||||
gotEmpty, err := CopyMap(map[string]any{})
|
||||
if err != nil || gotEmpty == nil || len(gotEmpty) != 0 {
|
||||
t.Fatalf("empty CopyMap result = %#v, %v", gotEmpty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyHandlesIndirectionAndIsolatesNestedMutations(t *testing.T) {
|
||||
integer := namedInt64(7)
|
||||
nestedMap := namedMap{"limit": 2}
|
||||
nestedSlice := namedSlice{"original"}
|
||||
nestedArray := namedArray{{"limit": 3}}
|
||||
shared := []any{map[string]int{"value": 4}}
|
||||
input := map[string]any{
|
||||
"integer": &integer,
|
||||
"map": nestedMap,
|
||||
"slice": nestedSlice,
|
||||
"array": nestedArray,
|
||||
"first": shared,
|
||||
"second": shared,
|
||||
}
|
||||
|
||||
copiedValue, err := Copy(input)
|
||||
if err != nil {
|
||||
t.Fatalf("copy mixed tree: %v", err)
|
||||
}
|
||||
copied := copiedValue.(map[string]any)
|
||||
nestedMap["limit"] = 20
|
||||
nestedSlice[0] = "changed"
|
||||
nestedArray[0]["limit"] = 30
|
||||
shared[0].(map[string]int)["value"] = 40
|
||||
|
||||
if got, ok := copied["integer"].(namedInt64); !ok || got != 7 {
|
||||
t.Fatalf("pointer target changed: %#v", copied["integer"])
|
||||
}
|
||||
if got := copied["map"].(namedMap)["limit"]; got != 2 {
|
||||
t.Fatalf("nested map aliased input: %d", got)
|
||||
}
|
||||
if got := copied["slice"].(namedSlice)[0]; got != "original" {
|
||||
t.Fatalf("nested slice aliased input: %q", got)
|
||||
}
|
||||
if got := copied["array"].(namedArray)[0]["limit"]; got != 3 {
|
||||
t.Fatalf("nested array aliased input: %d", got)
|
||||
}
|
||||
first := copied["first"].([]any)
|
||||
second := copied["second"].([]any)
|
||||
if got := first[0].(map[string]int)["value"]; got != 4 {
|
||||
t.Fatalf("shared child aliased input: %d", got)
|
||||
}
|
||||
first[0].(map[string]int)["value"] = 99
|
||||
if got := second[0].(map[string]int)["value"]; got != 4 {
|
||||
t.Fatalf("repeated acyclic value shared copied output: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAndCopyMapApplyDistinctEmptyKeyRules(t *testing.T) {
|
||||
nested := map[string]any{"": []any{"original"}}
|
||||
copiedValue, err := Copy(nested)
|
||||
if err != nil {
|
||||
t.Fatalf("Copy rejected empty schema key: %v", err)
|
||||
}
|
||||
nested[""].([]any)[0] = "changed"
|
||||
if got := copiedValue.(map[string]any)[""].([]any)[0]; got != "original" {
|
||||
t.Fatalf("copied schema value was not isolated: %v", got)
|
||||
}
|
||||
|
||||
_, err = CopyMap(map[string]any{"nested": map[string]any{"": true}})
|
||||
if err == nil || !strings.Contains(err.Error(), "extra_params.nested") {
|
||||
t.Fatalf("CopyMap empty-key error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyRejectsUnsupportedValuesAndActiveCycles(t *testing.T) {
|
||||
cyclicMap := map[string]any{}
|
||||
cyclicMap["self"] = cyclicMap
|
||||
cyclicSlice := []any{nil}
|
||||
cyclicSlice[0] = cyclicSlice
|
||||
var cyclicPointer any
|
||||
cyclicPointer = &cyclicPointer
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
wantPath string
|
||||
}{
|
||||
{name: "non-string map key", value: map[int]string{1: "one"}, wantPath: "value"},
|
||||
{name: "unsupported channel", value: make(chan int), wantPath: "value"},
|
||||
{name: "deterministic map path", value: map[string]any{"z": make(chan int), "a": make(chan int)}, wantPath: "value.a"},
|
||||
{name: "cyclic map", value: cyclicMap, wantPath: "value.self"},
|
||||
{name: "cyclic slice", value: cyclicSlice, wantPath: "value[0]"},
|
||||
{name: "cyclic pointer", value: cyclicPointer, wantPath: "value"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Copy(tc.value)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantPath) {
|
||||
t.Fatalf("error = %v, want structural path %q", err, tc.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyEnforcesContainerDepth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
depth int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "just below", depth: maxContainerDepth - 1},
|
||||
{name: "at limit", depth: maxContainerDepth},
|
||||
{name: "over limit", depth: maxContainerDepth + 1, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Copy(alternatingContainers(tc.depth))
|
||||
if tc.wantErr {
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "value") || !strings.Contains(err.Error(), "container depth limit") {
|
||||
t.Fatalf("depth error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("copy depth %d: %v", tc.depth, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyEnforcesProducedNodeBudgetForRepeatedAcyclicValues(t *testing.T) {
|
||||
shared := []any{true}
|
||||
sharedOccurrences := (maxProducedNodes - 2) / 2
|
||||
justBelow := repeatedValues(shared, sharedOccurrences, 0)
|
||||
atLimit := repeatedValues(shared, sharedOccurrences, 1)
|
||||
overLimit := repeatedValues(shared, sharedOccurrences, 2)
|
||||
|
||||
for name, value := range map[string]any{
|
||||
"just below": justBelow,
|
||||
"at limit": atLimit,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Copy(value); err != nil {
|
||||
t.Fatalf("copy value within work budget: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_, err := Copy(overLimit)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "value[") || !strings.Contains(err.Error(), "value work limit") {
|
||||
t.Fatalf("work-budget error = %v", err)
|
||||
}
|
||||
|
||||
_, err = Copy(make([]any, maxProducedNodes))
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "value:") || !strings.Contains(err.Error(), "value work limit") {
|
||||
t.Fatalf("flat work-budget error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func alternatingContainers(depth int) any {
|
||||
var value any = true
|
||||
for level := 0; level < depth; level++ {
|
||||
switch level % 3 {
|
||||
case 0:
|
||||
value = map[string]any{"child": value}
|
||||
case 1:
|
||||
value = []any{value}
|
||||
default:
|
||||
value = [1]any{value}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func repeatedValues(shared []any, occurrences, leadingScalars int) []any {
|
||||
values := make([]any, 0, leadingScalars+occurrences)
|
||||
for i := 0; i < leadingScalars; i++ {
|
||||
values = append(values, false)
|
||||
}
|
||||
for i := 0; i < occurrences; i++ {
|
||||
values = append(values, shared)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user