diff --git a/sources/Config/AcediaConfig.uc b/sources/Config/AcediaConfig.uc new file mode 100644 index 0000000..8a4868d --- /dev/null +++ b/sources/Config/AcediaConfig.uc @@ -0,0 +1,318 @@ +/** + * Acedia makes extensive use of `perobjectconfig` for storing information + * in ini config files. Their data is usually stored in ini config files as: + * "[ ]". + * Making a child class for `AcediaConfig` defines "" and + * contents of appropriate section. Then any such class can have multiple + * records with different "" values. The only requirement + * is that "" must only contain latin letters, digits, + * dot ('.') and underscore ('_'). Otherwise they will be ignored. + * + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class AcediaConfig extends AcediaObject + dependson(AssociativeArray) + abstract; + +/** + * This class deals with several issues related to use of such objects, + * steming from the lack of documentation: + * + * 1. Not all `Object` names are a usable. + * [Beyond Unreal wiki]( + * https://wiki.beyondunreal.com/Legacy:PerObjectConfig) + * lists a couple of limitations: whitespace and ']' character. + * However there are more, including '.'. + * We limit available character set to ASCII latin letters, digits and + * the dot ('.') / underscore ('_'). Dot is a forbidden character, but it + * is often used in class names and, therefore, was added via workaround: + * it is automatically converted into colon ':' character to allow its + * storage inside ini config files. It also will not lead to the name + * conflicts, since colon is a forbidden character for `AcediaConfig`. + * 2. Behavior of loading `perobjectconfig`-objects a second time is wonky and + * is fixed by `AcediaConfig`: it provides concrete behavior guarantees + * for all of its config object-managing methods. + */ + +// All config objects of a particular class only get loaded once +// per session (unless new one is created) and then accessed through this +// collection. +// This array stores `AcediaConfig` values with `Text` keys in +// a case-insensitive way (by converting keys into lower case). +// In case it has a `none` value stored under some key - it means that value +// was detected in config, but not yet loaded. +// Only its default value is ever used. +var private AssociativeArray existingConfigs; + +// Stores name of the config where settings are to be stored. +// Must correspond to value in `config(...)` modifier in class definition. +var protected const string configName; + +// Set this to `true` if you implement `ToData()` / `FromData()` pair of +// methods. +// This will tell Acedia that your config can be converted into +// JSON-compatible types. +var public const bool supportsDataConversion; + +/** + * These methods must be overloaded to store and load all the config + * variables inside an `AssociativeArray` collection. How exactly to store + * them is up to each config class to decide, as long as it allows conversion + * into JSON (see `JSONAPI.IsCompatible()` for details). + * Note that values reference `FromData()` receives is not necessarily + * the one your `ToData()` method returns - any particular value boxes can be + * replaced with value references and vice versa. + * NOTE: DO NOT use `P()`, `C()`, `F()` or `T()` methods for keys or + * values in collections you return. All keys and values will be automatically + * deallocated when necessary, so these methods for creating `Text` values are + * not suitable. +*/ +protected function AssociativeArray ToData() { return none; } +protected function FromData(AssociativeArray source) {} + +/** + * This method must be overloaded to setup default values for all config + * variables. You should use it instead of the `defaultproperties` block. + */ +protected function DefaultIt() {} + +/** + * This reads all of the `AcediaConfig`'s settings objects into internal + * storage. Must be called before any other methods. Actual loading might be + * postponed until a particular config is needed. + */ +public static function Initialize() +{ + local int i; + local Text nextName; + local array names; + if (default.existingConfigs != none) { + return; + } + default.existingConfigs = __().collections.EmptyAssociativeArray(); + names = GetPerObjectNames( default.configName, string(default.class.name), + MaxInt); + for (i = 0; i < names.length; i += 1) + { + if (names[i] == "") { + continue; + } + nextName = __().text.FromString(NameToActualVersion(names[i])); + if (nextName.IsValidConfigName()) { + default.existingConfigs.SetItem(nextName.LowerCopy(), none); + } + nextName.FreeSelf(); + } +} + +private static function string NameToStorageVersion(string configObjectName) +{ + return Repl(configObjectName, ".", ":"); +} + +private static function string NameToActualVersion(string configObjectName) +{ + return Repl(configObjectName, ":", "."); +} + +/** + * Creates a brand new config object with a given name. + * + * Fails if config object with that name already exists. + * Names are case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. + * + * Always writes new config inside the ini file on disk. + * + * @param name Name of the new config object. + * Case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. + * @return `false` iff config object name `name` already existed + * or `name` is invalid for config object. + */ +public final static function bool NewConfig(Text name) +{ + local AcediaConfig newConfig; + if (name == none) return false; + if (!name.IsValidConfigName()) return false; + if (default.existingConfigs == none) return false; + + name = name.LowerCopy(); + if (default.existingConfigs.HasKey(name)) + { + name.FreeSelf(); + return false; + } + newConfig = + new(none, NameToStorageVersion(name.ToPlainString())) default.class; + newConfig.DefaultIt(); + newConfig.SaveConfig(); + default.existingConfigs.SetItem(name, newConfig); + return true; +} + +/** + * Checks if a config object with a given name exists. + * + * Names are case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. + * + * @param name Name of the new config object. + * Case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. + * @return `true` iff new config object was created. + */ +public final static function bool Exists(Text name) +{ + local bool result; + if (name == none) return false; + if (!name.IsValidConfigName()) return false; + if (default.existingConfigs == none) return false; + + name = name.LowerCopy(); + result = default.existingConfigs.HasKey(name); + name.FreeSelf(); + return result; +} + +/** + * Deletes config object with a given name. + * + * Names are case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. + * + * If given config object exists, this method cannot fail. + * `Exists()` is guaranteed to return `false` after this method call. + * + * Always removes any present config entries from ini files. + * + * @param name Name of the config object to delete. + * Case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. +*/ +public final static function DeleteConfig(Text name) +{ + local AssociativeArray.Entry entry; + if (default.existingConfigs == none) { + return; + } + entry = default.existingConfigs.TakeEntry(name); + if (entry.value != none) { + entry.value.ClearConfig(); + } + __().memory.Free(entry.key); +} + +/** + * Returns array containing names of all available config objects. + * + * @return Array with names of all available config objects. + */ +public static function array AvailableConfigs() +{ + local array emptyResult; + if (default.existingConfigs != none) { + return default.existingConfigs.CopyTextKeys(); + } + return emptyResult; +} + +/** + * Returns `AcediaConfig` of caller class with name `name`. + * + * @param name Name of the config object, whos settings data is to + * be loaded. Case-insensitive, must contain only ASCII latin letters, + * digits and dot ('.') character. + * @return `AcediaConfig` of caller class with name `name`. + */ +public final static function AcediaConfig GetConfigInstance(Text name) +{ + local AssociativeArray.Entry configEntry; + if (name == none) return none; + if (!name.IsValidConfigName()) return none; + if (default.existingConfigs == none) return none; + + name = name.LowerCopy(); + configEntry = default.existingConfigs.GetEntry(name); + if (configEntry.value == none && configEntry.key != none) + { + configEntry.value = + new(none, NameToStorageVersion(name.ToPlainString())) default.class; + default.existingConfigs.SetItem(configEntry.key, configEntry.value); + } + __().memory.Free(name); + return AcediaConfig(configEntry.value); +} + +/** + * Loads Acedia's representation of settings data of a particular config + * object, given by the `name`. + * + * Should only be called if caller class has `supportsDataConversion` set to + * `true`. + * + * @param name Name of the config object, whos data is to be loaded. + * Case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. + * @return Data of a particular config object, given by the `name`. + * Expected to be in format that allows for JSON serialization + * (see `JSONAPI.IsCompatible()` for details). + * For correctly implemented config objects should only return `none` if + * their class was not yet initialized (see `self.Initialize()` method). +*/ +public final static function AssociativeArray LoadData(Text name) +{ + local AssociativeArray result; + local AcediaConfig requiredConfig; + requiredConfig = GetConfigInstance(name); + if (requiredConfig != none) { + result = requiredConfig.ToData(); + } + return result; +} + +/** + * Saves Acedia's representation of settings data (`data`) for a particular + * config object, given by the `name`. + * + * Should only be called if caller class has `supportsDataConversion` set to + * `true`. + * + * @param name Name of the config object, whos data is to be modified. + * Case-insensitive, must contain only ASCII latin letters, digits + * and dot ('.') character. + * @param data New data for config variables. Expected to be in format that + * allows for JSON deserialization (see `JSONAPI.IsCompatible()` for + * details). +*/ +public final static function SaveData(Text name, AssociativeArray data) +{ + local AcediaConfig requiredConfig; + requiredConfig = GetConfigInstance(name); + if (requiredConfig != none) + { + requiredConfig.FromData(data); + requiredConfig.SaveConfig(); + } +} + +defaultproperties +{ + supportsDataConversion = false + usesObjectPool = false +} \ No newline at end of file diff --git a/sources/Config/Tests/MockConfig.uc b/sources/Config/Tests/MockConfig.uc new file mode 100644 index 0000000..bb2ecf2 --- /dev/null +++ b/sources/Config/Tests/MockConfig.uc @@ -0,0 +1,50 @@ +/** + * Mock object for testing config functionality of Acedia's `Feature`s. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class MockConfig extends AcediaConfig + perobjectconfig + config(AcediaMockConfig); + +var public config int value; + +protected function AssociativeArray ToData() +{ + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetInt(P("value").Copy(), value, true); + return data; +} + +protected function FromData(AssociativeArray source) +{ + if (source != none) { + value = source.GetIntBy(P("/value")); + } +} + +protected function DefaultIt() +{ + value = 13; +} + +defaultproperties +{ + configName = "AcediaMockConfig" + supportsDataConversion = true +} \ No newline at end of file diff --git a/sources/Config/Tests/TEST_AcediaConfig.uc b/sources/Config/Tests/TEST_AcediaConfig.uc new file mode 100644 index 0000000..53750e4 --- /dev/null +++ b/sources/Config/Tests/TEST_AcediaConfig.uc @@ -0,0 +1,125 @@ +/** + * Set of tests for `AcediaConfig` class. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class TEST_AcediaConfig extends TestCase + abstract; + +protected static function TESTS() +{ + class'MockConfig'.static.Initialize(); + Context("Testing `AcediaConfig` functionality."); + TEST_AvailableConfigs(); + TEST_DataGetSet(); + TEST_DataNew(); + TEST_BadName(); +} + +protected static function TEST_AvailableConfigs() +{ + local int i; + local bool foundConfig; + local array configNames; + configNames = class'MockConfig'.static.AvailableConfigs(); + Issue("Incorrect amount of configs are loaded."); + TEST_ExpectTrue(configNames.length == 3); + + Issue("Configs with incorrect names or values are loaded."); + for (i = 0; i < configNames.length; i += 1) + { + if (configNames[i].CompareToPlainString("default", SCASE_INSENSITIVE)) { + foundConfig = true; + } + } + TEST_ExpectTrue(foundConfig); + foundConfig = false; + for (i = 0; i < configNames.length; i += 1) + { + if (configNames[i].CompareToPlainString("other", SCASE_INSENSITIVE)) { + foundConfig = true; + } + } + TEST_ExpectTrue(foundConfig); + foundConfig = false; + for (i = 0; i < configNames.length; i += 1) + { + if (configNames[i].CompareToPlainString("another.config", + SCASE_INSENSITIVE)) { + foundConfig = true; + } + } + TEST_ExpectTrue(foundConfig); +} + +protected static function TEST_DataGetSet() +{ + local AssociativeArray data, newData; + data = class'MockConfig'.static.LoadData(P("other")); + Issue("Wrong value is loaded from config."); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == 11); + + newData = __().collections.EmptyAssociativeArray(); + newData.SetItem(P("value"), __().box.int(903)); + class'MockConfig'.static.SaveData(P("other"), newData); + data = class'MockConfig'.static.LoadData(P("other")); + Issue("Wrong value is loaded from config after saving another value."); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == 903); + + Issue("`AcediaConfig` returns `AssociativeArray` reference that was" + @ "passed in `SaveData()` call instead of a new collection."); + TEST_ExpectTrue(data != newData); + + // Restore configs + data.SetItem(P("value"), __().box.int(11)); + class'MockConfig'.static.SaveData(P("other"), data); +} + +protected static function TEST_DataNew() +{ + local AssociativeArray data; + Issue("Creating new config with existing name succeeds."); + TEST_ExpectFalse(class'MockConfig'.static.NewConfig(P("another.config"))); + data = class'MockConfig'.static.LoadData(P("another.config")); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == -2956); + + Issue("Cannot create new config."); + TEST_ExpectTrue(class'MockConfig'.static.NewConfig(P("new_one"))); + + Issue("New config does not have expected default value."); + data = class'MockConfig'.static.LoadData(P("new_one")); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == 13); + + // Restore configs, cannot properly test `DeleteConfig()` + class'MockConfig'.static.DeleteConfig(P("new_one")); +} + +protected static function TEST_BadName() +{ + Issue("`AcediaConfig` allows creation of config objects with" + @ "invalid names."); + TEST_ExpectFalse(class'MockConfig'.static.NewConfig(P("new:config"))); + TEST_ExpectFalse(class'MockConfig'.static.NewConfig(P("what]"))); + TEST_ExpectFalse(class'MockConfig'.static.NewConfig(P("why#not"))); + TEST_ExpectFalse(class'MockConfig'.static.NewConfig(P("stop@it"))); +} + +defaultproperties +{ + caseName = "AcediaConfig" + caseGroup = "Config" +} \ No newline at end of file diff --git a/sources/Manifest.uc b/sources/Manifest.uc index 8ae99d9..dda085b 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -57,6 +57,7 @@ defaultproperties testCases(21) = class'TEST_LogMessage' testCases(22) = class'TEST_LocalDatabase' testCases(23) = class'TEST_FeatureConfig' - testCases(24) = class'TEST_UTF8EncoderDecoder' - testCases(25) = class'TEST_AvariceStreamReader' + testCases(24) = class'TEST_AcediaConfig' + testCases(25) = class'TEST_UTF8EncoderDecoder' + testCases(26) = class'TEST_AvariceStreamReader' } \ No newline at end of file