From c0b719a2f205eb6c5b4f9a3b1dd28d98d0840a60 Mon Sep 17 00:00:00 2001 From: Anton Tarasenko Date: Sun, 19 Jul 2020 19:24:32 +0700 Subject: [PATCH] Change `JObject` to use hash tables and classes --- sources/Data/JSON/JObject.uc | 434 +++++++++++++++++++-------- sources/Data/JSON/JSON.uc | 33 +- sources/Data/JSON/Tests/TEST_JSON.uc | 171 ++++++++++- 3 files changed, 499 insertions(+), 139 deletions(-) diff --git a/sources/Data/JSON/JObject.uc b/sources/Data/JSON/JObject.uc index 9d81cf8..4c0a0bb 100644 --- a/sources/Data/JSON/JObject.uc +++ b/sources/Data/JSON/JObject.uc @@ -42,21 +42,170 @@ struct JProperty var string name; var JStorageAtom value; }; -var private array properties; +// Bucket of alias-value pairs, with the same alias hash. +struct PropertyBucket +{ + var array properties; +}; +var private array hashTable; +var private int storedElementCount; + +// Reasonable lower and upper limits on hash table capacity, +// that will be enforced if user requires something outside those bounds +var private config const int MINIMUM_CAPACITY; +var private config const int MAXIMUM_CAPACITY; +var private config const float MINIMUM_DENSITY; +var private config const float MAXIMUM_DENSITY; +var private config const int MINIMUM_DIFFERENCE_FOR_REALLOCATION; +var private config const int ABSOLUTE_LOWER_CAPACITY_LIMIT; + +// Helper method that is needed as a replacement for `%`, since it is +// an operation on `float`s in UnrealScript and does not have enough precision +// to work with hashes. +// Assumes positive input. +private function int Remainder(int number, int divisor) +{ + local int quotient; + quotient = number / divisor; + return (number - quotient * divisor); +} -// Returns index of name-value pair in `properties` for a given name. -// Returns `-1` if such a pair does not exist. -private final function int GetPropertyIndex(string name) +// Finds indices for: +// 1. Bucked that contains specified alias (`bucketIndex`); +// 2. Pair for specified alias in the bucket's collection +// (`propertyIndex`). +// `bucketIndex` is always found, +// `propertyIndex` is valid iff method returns `true`, otherwise it's equal to +// the index at which new property can get inserted. +private final function bool FindPropertyIndices( + string name, + out int bucketIndex, + out int propertyIndex) { - local int i; - for (i = 0; i < properties.length; i += 1) + local int i; + local array bucketProperties; + TouchHashTable(); + bucketIndex = _.text.GetHash(name); + if (bucketIndex < 0) { + bucketIndex *= -1; + } + bucketIndex = Remainder(bucketIndex, hashTable.length); + // Check if bucket actually has given name. + bucketProperties = hashTable[bucketIndex].properties; + for (i = 0; i < bucketProperties.length; i += 1) { - if (name == properties[i].name) + if (bucketProperties[i].name == name) { - return i; + propertyIndex = i; + return true; + } + } + propertyIndex = bucketProperties.length; + return false; +} + +// Creates hash table in case it does not exist yet +private final function TouchHashTable() +{ + if (hashTable.length <= 0) { + UpdateHashTableCapacity(); + } +} + +// Attempts to find a property in a caller `JObject` by the name `name` and +// writes it into `result`. Returns `true` if it succeeds and `false` otherwise +// (in that case writes a blank property with a given name in `result`). +private final function bool FindProperty(string name, out JProperty result) +{ + local JProperty newProperty; + local int bucketIndex, propertyIndex; + if (FindPropertyIndices(name, bucketIndex, propertyIndex)) + { + result = hashTable[bucketIndex].properties[propertyIndex]; + return true; + } + newProperty.name = name; + result = newProperty; + return false; +} + +// Creates/replaces a property with a name `newProperty.name` in caller +// JSON object +private final function UpdateProperty(JProperty newProperty) +{ + local bool overriddenProperty; + local int bucketIndex, propertyIndex; + overriddenProperty = !FindPropertyIndices( newProperty.name, + bucketIndex, propertyIndex); + hashTable[bucketIndex].properties[propertyIndex] = newProperty; + if (overriddenProperty) { + storedElementCount += 1; + UpdateHashTableCapacity(); + } +} + +// Removes a property with a name `newProperty.name` from caller JSON object +// Returns `true` if something was actually removed. +private final function bool RemoveProperty(string propertyName) +{ + local int bucketIndex, propertyIndex; + // Ensure has table was initialized before any updates + if (hashTable.length <= 0) { + UpdateHashTableCapacity(); + } + if (FindPropertyIndices(propertyName, bucketIndex, propertyIndex)) { + hashTable[bucketIndex].properties.Remove(propertyIndex, 1); + storedElementCount = Max(0, storedElementCount - 1); + UpdateHashTableCapacity(); + return true; + } + return false; +} + +// Checks if we need to change our current capacity and does so if needed +private final function UpdateHashTableCapacity() +{ + local int oldCapacity, newCapacity; + oldCapacity = hashTable.length; + // Calculate new capacity (and whether it is needed) based on amount of + // stored properties and current capacity + newCapacity = oldCapacity; + if (storedElementCount < newCapacity * MINIMUM_DENSITY) { + newCapacity /= 2; + } + if (storedElementCount > newCapacity * MAXIMUM_DENSITY) { + newCapacity *= 2; + } + // Enforce our limits + newCapacity = Clamp(newCapacity, MINIMUM_CAPACITY, MAXIMUM_CAPACITY); + newCapacity = Max(ABSOLUTE_LOWER_CAPACITY_LIMIT, newCapacity); + // Only resize if difference is huge enough or table does not exists yet + if ( newCapacity - oldCapacity > MINIMUM_DIFFERENCE_FOR_REALLOCATION + || oldCapacity - newCapacity > MINIMUM_DIFFERENCE_FOR_REALLOCATION + || oldCapacity <= 0) { + ResizeHashTable(newCapacity); + } +} + +// Change size of the hash table, does not check any limits, does not check +// if `newCapacity` is a valid capacity (`newCapacity > 0`). +// Use `UpdateHashTableCapacity()` for that. +private final function ResizeHashTable(int newCapacity) +{ + local int i, j; + local array bucketProperties; + local array oldHashTable; + oldHashTable = hashTable; + // Clean current hash table + hashTable.length = 0; + hashTable.length = newCapacity; + for (i = 0; i < oldHashTable.length; i += 1) + { + bucketProperties = oldHashTable[i].properties; + for (j = 0; j < bucketProperties.length; j += 1) { + UpdateProperty(bucketProperties[j]); } } - return -1; } // Returns `JType` of a variable with a given name in our properties. @@ -65,11 +214,11 @@ private final function int GetPropertyIndex(string name) // function will return `JSON_Undefined`. public final function JType GetTypeOf(string name) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return JSON_Undefined; - - return properties[index].value.type; + local JProperty property; + FindProperty(name, property); + // If we did not find anything - `property` will be set up as + // a `JSON_Undefined` type value. + return property.value.type; } // Following functions are getters for various types of variables. @@ -82,66 +231,93 @@ public final function JType GetTypeOf(string name) // will simply return `none`. public final function float GetNumber(string name, optional float defaultValue) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return defaultValue; - if (properties[index].value.type != JSON_Number) return defaultValue; + local JProperty property; + FindProperty(name, property); + if (property.value.type != JSON_Number) { + return defaultValue; + } + return property.value.numberValue; +} - return properties[index].value.numberValue; +public final function int GetInteger(string name, optional int defaultValue) +{ + local JProperty property; + FindProperty(name, property); + if (property.value.type != JSON_Number) { + return defaultValue; + } + return property.value.numberValueAsInt; } -public final function string GetString -( - string name, +public final function string GetString( + string name, optional string defaultValue ) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return defaultValue; - if (properties[index].value.type != JSON_String) return defaultValue; + local JProperty property; + FindProperty(name, property); + if (property.value.type != JSON_String) { + return defaultValue; + } + return property.value.stringValue; +} - return properties[index].value.stringValue; +public final function class GetClass( + string name, + optional class defaultValue +) +{ + local JProperty property; + FindProperty(name, property); + if (property.value.type != JSON_String) { + return defaultValue; + } + if (!property.value.classLoadingWasAttempted) + { + property.value.classLoadingWasAttempted = true; + property.value.stringValueAsClass = + class(DynamicLoadObject( property.value.stringValue, + class'Class', true)); + } + if (property.value.stringValueAsClass != none) { + return property.value.stringValueAsClass; + } + return defaultValue; } public final function bool GetBoolean(string name, optional bool defaultValue) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return defaultValue; - if (properties[index].value.type != JSON_Boolean) return defaultValue; - - return properties[index].value.booleanValue; + local JProperty property; + FindProperty(name, property); + if (property.value.type != JSON_Boolean) { + return defaultValue; + } + return property.value.booleanValue; } public final function bool IsNull(string name) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return false; - if (properties[index].value.type != JSON_Null) return false; - - return (properties[index].value.type == JSON_Null); + local JProperty property; + FindProperty(name, property); + return (property.value.type == JSON_Null); } public final function JArray GetArray(string name) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return none; - if (properties[index].value.type != JSON_Array) return none; - - return JArray(properties[index].value.complexValue); + local JProperty property; + FindProperty(name, property); + if (property.value.type != JSON_Array) { + return none; + } + return JArray(property.value.complexValue); } public final function JObject GetObject(string name) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return none; - if (properties[index].value.type != JSON_Object) return none; - - return JObject(properties[index].value.complexValue); + local JProperty property; + FindProperty(name, property); + if (property.value.type != JSON_Object) return none; + return JObject(property.value.complexValue); } // Following functions provide simple setters for boolean, string, number @@ -150,65 +326,72 @@ public final function JObject GetObject(string name) // `object.SetNumber("num1", 1).SetNumber("num2", 2);`. public final function JObject SetNumber(string name, float value) { - local int index; - local JProperty newProperty; - index = GetPropertyIndex(name); - if (index < 0) - { - index = properties.length; - } + local JProperty property; + FindProperty(name, property); + property.value.type = JSON_Number; + property.value.numberValue = value; + property.value.numberValueAsInt = int(value); + property.value.complexValue = none; + UpdateProperty(property); + return self; +} - newProperty.name = name; - newProperty.value.type = JSON_Number; - newProperty.value.numberValue = value; - properties[index] = newProperty; +public final function JObject SetInteger(string name, int value) +{ + local JProperty property; + FindProperty(name, property); + property.value.type = JSON_Number; + property.value.numberValue = float(value); + property.value.numberValueAsInt = value; + property.value.complexValue = none; + UpdateProperty(property); return self; } public final function JObject SetString(string name, string value) { - local int index; - local JProperty newProperty; - index = GetPropertyIndex(name); - if (index < 0) - { - index = properties.length; - } - newProperty.name = name; - newProperty.value.type = JSON_String; - newProperty.value.stringValue = value; - properties[index] = newProperty; + local JProperty property; + FindProperty(name, property); + property.value.type = JSON_String; + property.value.stringValue = value; + property.value.stringValueAsClass = none; + property.value.classLoadingWasAttempted = false; + property.value.complexValue = none; + UpdateProperty(property); + return self; +} + +public final function JObject SetClass(string name, class value) +{ + local JProperty property; + FindProperty(name, property); + property.value.type = JSON_String; + property.value.stringValue = string(value); + property.value.stringValueAsClass = value; + property.value.classLoadingWasAttempted = true; + property.value.complexValue = none; + UpdateProperty(property); return self; } public final function JObject SetBoolean(string name, bool value) { - local int index; - local JProperty newProperty; - index = GetPropertyIndex(name); - if (index < 0) - { - index = properties.length; - } - newProperty.name = name; - newProperty.value.type = JSON_Boolean; - newProperty.value.booleanValue = value; - properties[index] = newProperty; + local JProperty property; + FindProperty(name, property); + property.value.type = JSON_Boolean; + property.value.booleanValue = value; + property.value.complexValue = none; + UpdateProperty(property); return self; } public final function JObject SetNull(string name) { - local int index; - local JProperty newProperty; - index = GetPropertyIndex(name); - if (index < 0) - { - index = properties.length; - } - newProperty.name = name; - newProperty.value.type = JSON_Null; - properties[index] = newProperty; + local JProperty property; + FindProperty(name, property); + property.value.type = JSON_Null; + property.value.complexValue = none; + UpdateProperty(property); return self; } @@ -218,48 +401,59 @@ public final function JObject SetNull(string name) // `object.CreateObject("folded object").CreateArray("names list");`. public final function JObject CreateArray(string name) { - local int index; - local JProperty newProperty; - index = GetPropertyIndex(name); - if (index < 0) - { - index = properties.length; + local JProperty property; + FindProperty(name, property); + if (property.value.complexValue != none) { + property.value.complexValue.Destroy(); } - newProperty.name = name; - newProperty.value.type = JSON_Array; - newProperty.value.complexValue = _.json.newArray(); - properties[index] = newProperty; + property.value.type = JSON_Array; + property.value.complexValue = _.json.NewArray(); + UpdateProperty(property); return self; } public final function JObject CreateObject(string name) { - local int index; - local JProperty newProperty; - index = GetPropertyIndex(name); - if (index < 0) - { - index = properties.length; + local JProperty property; + FindProperty(name, property); + if (property.value.complexValue != none) { + property.value.complexValue.Destroy(); } - newProperty.name = name; - newProperty.value.type = JSON_Object; - newProperty.value.complexValue = _.json.newObject(); - properties[index] = newProperty; + property.value.type = JSON_Object; + property.value.complexValue = _.json.NewObject(); + UpdateProperty(property); return self; } // Removes values with a given name. // Returns `true` if value was actually removed and `false` if it didn't exist. -public final function bool RemoveValue(string name) +public final function JObject RemoveValue(string name) { - local int index; - index = GetPropertyIndex(name); - if (index < 0) return false; + RemoveProperty(name); + return self; +} - properties.Remove(index, 1); - return true; +public final function array GetKeys() +{ + local int i, j; + local array result; + local array nextProperties; + for (i = 0; i < hashTable.length; i += 1) + { + nextProperties = hashTable[i].properties; + for (j = 0; j < nextProperties.length; j += 1) { + result[result.length] = nextProperties[j].name; + } + } + return result; } defaultproperties { + ABSOLUTE_LOWER_CAPACITY_LIMIT = 10 + MINIMUM_CAPACITY = 50 + MAXIMUM_CAPACITY = 100000 + MINIMUM_DENSITY = 0.25 + MAXIMUM_DENSITY = 0.75 + MINIMUM_DIFFERENCE_FOR_REALLOCATION = 50 } \ No newline at end of file diff --git a/sources/Data/JSON/JSON.uc b/sources/Data/JSON/JSON.uc index 8a94157..2369178 100644 --- a/sources/Data/JSON/JSON.uc +++ b/sources/Data/JSON/JSON.uc @@ -58,27 +58,24 @@ struct JStorageAtom { // What type is stored exactly? // Depending on that, uses one of the other fields as a storage. - var protected JType type; - var protected float numberValue; - var protected string stringValue; - var protected bool booleanValue; + var protected JType type; + var protected float numberValue; + var protected string stringValue; + var protected bool booleanValue; // Used for storing both JSON objects and arrays. - var protected JSON complexValue; + var protected JSON complexValue; + // Numeric value might not fit into a `float` very well, so we will store + // them as both `float` and `integer` and allow user to request any version + // of them + var protected int numberValueAsInt; + // Some `string` values might be actually used to represent classes, + // so we will give users an ability to request `string` value as a class. + var protected class stringValueAsClass; + // To avoid several unsuccessful attempts to load `class` object from + // a `string`, we will record whether we've already tied that. + var protected bool classLoadingWasAttempted; }; -// TODO: Rewrite JSON object to use more efficient storage data structures -// that will support subtypes: -// ~ Number: byte, int, float -// ~ String: string, class -// (maybe move to auto generated code?). -// TODO: Add cleanup queue to efficiently and without crashes clean up -// removed objects. -// TODO: Add `JValue` - a reference type for number / string / boolean / null -// TODO: Add accessors for last values. -// TODO: Add path-getters. -// TODO: Add iterators. -// TODO: Add parsing/printing. -// TODO: Add functions for deep copy. defaultproperties { } \ No newline at end of file diff --git a/sources/Data/JSON/Tests/TEST_JSON.uc b/sources/Data/JSON/Tests/TEST_JSON.uc index f9801c4..de0e791 100644 --- a/sources/Data/JSON/Tests/TEST_JSON.uc +++ b/sources/Data/JSON/Tests/TEST_JSON.uc @@ -26,6 +26,7 @@ protected static function TESTS() local JObject jsonData; jsonData = _().json.newObject(); Test_ObjectGetSetRemove(); + Test_ObjectKeys(); Test_ArrayGetSetRemove(); } @@ -33,8 +34,12 @@ protected static function Test_ObjectGetSetRemove() { SubTest_Undefined(); SubTest_StringGetSetRemove(); + SubTest_ClassGetSetRemove(); + SubTest_StringAsClass(); SubTest_BooleanGetSetRemove(); SubTest_NumberGetSetRemove(); + SubTest_IntegerGetSetRemove(); + SubTest_FloatAndInteger(); SubTest_NullGetSetRemove(); SubTest_MultipleVariablesGetSet(); SubTest_Object(); @@ -141,6 +146,72 @@ protected static function SubTest_StringGetSetRemove() TEST_ExpectTrue(testJSON.GetString("some_string", "other") == "other"); } +protected static function SubTest_ClassGetSetRemove() +{ + local JObject testJSON; + testJSON = _().json.newObject(); + testJSON.SetClass("info_class", class'Info'); + + Context("Testing `JObject`'s get/set/remove functions for" @ + "class variables"); + Issue("String type isn't properly set by `SetClass`"); + TEST_ExpectTrue(testJSON.GetTypeOf("info_class") == JSON_String); + + Issue("Value is incorrectly assigned by `SetClass`"); + TEST_ExpectTrue(testJSON.GetClass("info_class") == class'Info'); + + Issue( "Providing default variable value makes 'GetClass'" @ + "return wrong value"); + TEST_ExpectTrue( testJSON.GetClass("info_class", class'Actor') + == class'Info'); + + Issue("Variable value isn't correctly reassigned by `SetClass`"); + testJSON.SetClass("info_class", class'ReplicationInfo'); + TEST_ExpectTrue(testJSON.GetClass("info_class") == class'ReplicationInfo'); + + Issue( "Getting class variable as a wrong type" @ + "doesn't yield default value"); + TEST_ExpectTrue(testJSON.GetBoolean("info_class", true) == true); + + Issue("Class variable isn't being properly removed"); + testJSON.RemoveValue("info_class"); + TEST_ExpectTrue(testJSON.GetTypeOf("info_class") == JSON_Undefined); + + Issue( "Getters don't return default value for missing key that" @ + "previously stored class value, but got removed"); + TEST_ExpectTrue( testJSON.GetClass("info_class", class'Actor') + == class'Actor'); +} + +protected static function SubTest_StringAsClass() +{ + local JObject testJSON; + testJSON = _().json.newObject(); + testJSON.SetString("SetString", "Engine.Actor"); + testJSON.SetString("SetStringIncorrect", "blahblahblah"); + testJSON.SetClass("SetClass", class'Info'); + testJSON.SetClass("none", none); + + Context("Testing how `JObject` treats mixed string and" + @ "class setters/getters."); + Issue("Incorrect result of `SetClass().GetString()` sequence."); + TEST_ExpectTrue(testJSON.GetString("SetClass") == "Engine.Info"); + TEST_ExpectTrue(testJSON.GetString("none") == "None"); + TEST_ExpectTrue(testJSON.GetString("none", "alternative") == "None"); + + Issue("Incorrect result of `SetString().GetClass()` sequence for" + @ "correct value in `SetString()`."); + TEST_ExpectTrue(testJSON.GetClass("SetString") == class'Actor'); + TEST_ExpectTrue( testJSON.GetClass("SetString", class'Object') + == class'Actor'); + + Issue("Incorrect result of `SetString().GetClass()` sequence for" + @ "incorrect value in `SetString()`."); + TEST_ExpectTrue(testJSON.GetClass("SetStringIncorrect") == none); + TEST_ExpectTrue( testJSON.GetClass("SetStringIncorrect", class'Object') + == class'Object'); +} + protected static function SubTest_NumberGetSetRemove() { local JObject testJSON; @@ -148,7 +219,7 @@ protected static function SubTest_NumberGetSetRemove() testJSON.SetNumber("some_number", 3.5); Context("Testing `JObject`'s get/set/remove functions for" @ - "number variables"); + "number variables as floats"); Issue("Number type isn't properly set by `SetNumber`"); TEST_ExpectTrue(testJSON.GetTypeOf("some_number") == JSON_Number); @@ -176,6 +247,64 @@ protected static function SubTest_NumberGetSetRemove() TEST_ExpectTrue(testJSON.GetNumber("some_number", 13) == 13); } +protected static function SubTest_IntegerGetSetRemove() +{ + local JObject testJSON; + testJSON = _().json.newObject(); + testJSON.SetInteger("some_number", 33653); + + Context("Testing `JObject`'s get/set/remove functions for" @ + "number variables as integers"); + Issue("Number type isn't properly set by `SetInteger`"); + TEST_ExpectTrue(testJSON.GetTypeOf("some_number") == JSON_Number); + + Issue("Value is incorrectly assigned by `SetInteger`"); + TEST_ExpectTrue(testJSON.GetInteger("some_number") == 33653); + + Issue( "Providing default variable value makes 'GetInteger'" @ + "return wrong value"); + TEST_ExpectTrue(testJSON.GetInteger("some_number", 5) == 33653); + + Issue("Variable value isn't correctly reassigned by `SetInteger`"); + testJSON.SetInteger("some_number", MaxInt); + TEST_ExpectTrue(testJSON.GetInteger("some_number") == MaxInt); + + Issue( "Getting number variable as a wrong type" @ + "doesn't yield default value."); + TEST_ExpectTrue(testJSON.GetString("some_number", "default") == "default"); + + Issue("Number type isn't being properly removed"); + testJSON.RemoveValue("some_number"); + TEST_ExpectTrue(testJSON.GetTypeOf("some_number") == JSON_Undefined); + + Issue( "Getters don't return default value for missing key that" @ + "previously stored number value, that got removed"); + TEST_ExpectTrue(testJSON.GetInteger("some_number", -235) == -235); +} + +protected static function SubTest_FloatAndInteger() +{ + local JObject testJSON; + testJSON = _().json.newObject(); + testJSON.SetNumber("SetNumber", 6.70087); + testJSON.SetInteger("SetInteger", 62478623874); + + Context("Testing how `JObject` treats mixed float and" + @ "integer setters/getters."); + Issue("Incorrect result of `SetNumber().GetInteger()` sequence."); + TEST_ExpectTrue(testJSON.GetInteger("SetNumber") == 6); + + testJSON.SetInteger("SetNumber", 11); + testJSON.SetNumber("SetInteger", 0.43); + Issue("SetNumber().SetInteger() for same variable name does not overwrite" + @ "initial number value."); + TEST_ExpectTrue(testJSON.GetNumber("SetNumber") == 11); + + Issue("SetInteger().SetNumber() for same variable name does not overwrite" + @ "initial integer value."); + TEST_ExpectTrue(testJSON.GetInteger("SetInteger") == 0); +} + protected static function SubTest_NullGetSetRemove() { local JObject testJSON; @@ -284,6 +413,46 @@ protected static function SubTest_Object() .GetString("in", "default") == "string inside"); } +protected static function Test_ObjectKeys() +{ + local int i; + local bool varFound, clsFound, objFound; + local JObject testObject; + local array keys; + testObject = _().json.newObject(); + Context("Testing getting list of keys from the `JObject`."); + Issue("Just created `JObject` returns non-empty key list."); + TEST_ExpectTrue(testObject.GetKeys().length == 0); + + Issue("`JObject` returns incorrect key list."); + keys = testObject.SetInteger("var", 7).SetClass("cls", class'Actor') + .CreateObject("obj").GetKeys(); + TEST_ExpectTrue(keys.length == 3); + for (i = 0; i < keys.length; i += 1) + { + if (keys[i] == "var") { varFound = true; } + if (keys[i] == "cls") { clsFound = true; } + if (keys[i] == "obj") { objFound = true; } + } + TEST_ExpectTrue(varFound && clsFound && objFound); + + Issue("`JObject` returns incorrect key list after removing an element."); + keys = testObject.RemoveValue("cls").GetKeys(); + TEST_ExpectTrue(keys.length == 2); + varFound = false; + objFound = false; + for (i = 0; i < keys.length; i += 1) + { + if (keys[i] == "var") { varFound = true; } + if (keys[i] == "obj") { objFound = true; } + } + TEST_ExpectTrue(varFound && objFound); + + Issue("`JObject` returns incorrect key list after removing all elements."); + keys = testObject.RemoveValue("var").RemoveValue("obj").GetKeys(); + TEST_ExpectTrue(keys.length == 0); +} + protected static function SubTest_ArrayUndefined() { local JArray testJSON;