"use strict"; const lib = require("@clusterio/lib"); const assert = require("assert").strict; const events = require("events"); describe("lib/config/classes", function() { describe("ConfigGroup", function() { let mockConfig = new events.EventEmitter(); mockConfig.location = "local"; class TestGroup extends lib.ConfigGroup { } TestGroup.defaultAccess = ["local", "remote"]; TestGroup.groupName = "test_group"; describe(".define()", function() { it("should throw if groupName is not set", function() { class NoGroupName extends lib.ConfigGroup { } assert.throws( () => NoGroupName.define({ name: "a", type: "string", optional: true }), new Error("Expected ConfigGroup class NoGroupName to have the groupName property set to a string") ); }); it("should throw on unknown properties", function() { assert.throws( () => TestGroup.define({ name: "a", type: "string", optional: true, invalid: true }), new Error("Unknown property invalid") ); }); it("should throw on invalid properties", function() { for (let [def, error] of [ [ { access: "local", name: "a", type: "string", optional: true }, "access must be an array", ], [ { name: "a", type: "invalid", optional: true }, "invalid is not a valid type", ], [ { name: [], type: "string", optional: true }, "name must be a string", ], [ { name: "a", type: "string", enum: true, optional: true }, "enum must be an array", ], [ { name: "a", title: 2, type: "string", optional: true }, "title must be a string", ], [ { name: "a", description: 2, type: "string", optional: true }, "description must be a string", ], [ { name: "a", type: "string", optional: "yes" }, "optional must be a boolean", ], [ { name: "a", type: "string", initial_value: 1 }, "initial_value must match the type or be a function", ], [ { name: "a", type: "string" }, "Non-optional field a needs an initial_value", ], ]) { assert.throws(() => TestGroup.define(def), new Error(error)); } }); it("should throw on defaultAccess not set", function() { class NoAccessGroup extends lib.ConfigGroup { } NoAccessGroup.groupName = "no_access"; assert.throws( () => NoAccessGroup.define({ name: "a", type: "string", optional: true }), new Error("access not set and no defaultAccess defined for NoAccessGroup") ); }); it("should throw on missing properties", function() { assert.throws( () => TestGroup.define({ type: "string", optional: true }), new Error("name is required when defining an field") ); assert.throws( () => TestGroup.define({ name: "a", optional: true }), new Error("type is required when defining an field") ); }); it("should allow defining entries", function() { TestGroup.define({ name: "test", type: "string", optional: true }); TestGroup.define({ name: "func", type: "number", initial_value: () => 42 }); TestGroup.define({ name: "bool", type: "boolean", initial_value: false, optional: true }); TestGroup.define({ name: "json", type: "object", initial_value: {}, optional: true }); TestGroup.define({ name: "priv", access: ["local"], type: "string", optional: true }); assert(TestGroup._definitions.has("test"), "field test was not defined"); assert(TestGroup._definitions.has("func"), "field func was not defined"); assert(TestGroup._definitions.has("bool"), "field bool was not defined"); assert(TestGroup._definitions.has("json"), "field json was not defined"); assert(TestGroup._definitions.has("priv"), "field priv was not defined"); }); it("should throw on already defined entries", function() { assert.throws( () => TestGroup.define({ name: "test", type: "string", optional: true }), new Error("Config field test has already been defined") ); }); it("should set the entries on the class", function() { let field = { access: ["local", "remote"], type: "string", name: "enum", title: "Enum", description: "Enum thingy", restartRequired: true, enum: ["a", "b", "c"], optional: true, initial_value: "b", }; TestGroup.define(field); // Generated properties field.fullName = "test_group.enum"; assert.deepEqual(TestGroup._definitions.get("enum"), field); }); }); describe("get definitions", function() { it("should give the field definitions", function() { assert.equal(TestGroup.definitions, TestGroup._definitions); }); }); describe(".finalize()", function() { it("should throw if groupName is not set", function() { class NoGroupName extends lib.ConfigGroup { } assert.throws( () => NoGroupName.finalize(), new Error("Expected ConfigGroup class NoGroupName to have the groupName property set to a string") ); }); it("should throw when creating an instance before finalize", function() { assert.throws( () => new TestGroup(mockConfig), new Error("Cannot instantiate incomplete ConfigGroup class TestGroup") ); }); it("should not throw", function() { TestGroup.finalize(); }); it("should throw when calling define after finalize", function() { assert.throws( () => TestGroup.define({}), new Error("Cannot define field for ConfigGroup class TestGroup after it has been finalized") ); }); }); describe("constructor", function() { it("should create an instance", function() { let testInstance = new TestGroup(mockConfig); }); }); describe(".init()", function() { it("should initialize all fields with defaults", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.get("enum"), "b"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); assert.equal(testInstance.get("priv"), null); }); it("should not initalize inaccessible fields", async function() { let testInstance = new TestGroup({ location: "remote" }); await testInstance.init(); assert.equal(testInstance._fields.get("enum"), "b"); assert.equal(testInstance._fields.get("priv"), undefined); }); }); describe("get name", function() { it("should give the name of the group", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.name, "test_group"); }); }); describe(".serialize()", function() { it("should serialize a basic group", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { name: "test_group", fields: { enum: "b", test: null, func: 42, bool: false, json: {}, priv: null, }, }); }); it("should leave out inaccessible fields", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.deepEqual(testInstance.serialize("remote"), { name: "test_group", fields: { enum: "b", test: null, func: 42, bool: false, json: {}, }, }); }); }); describe(".load()", function() { it("should load a basic group", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: { enum: "c", test: "blah", func: 22, bool: null, json: { valid: true }, priv: "bar", }}); assert.equal(testInstance.get("enum"), "c"); assert.equal(testInstance.get("test"), "blah"); assert.equal(testInstance.get("func"), 22); assert.equal(testInstance.get("bool"), null); assert.equal(testInstance.get("priv"), "bar"); assert.deepEqual(testInstance.get("json"), { valid: true }); }); it("should load defaults for missing serialized fields", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: { enum: "a" } }); assert.equal(testInstance.get("enum"), "a"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); it("should ignore inaccessible fields", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: { priv: "bad" } }, "remote"); assert.equal(testInstance.get("priv"), null); }); it("should preserve unknown fields when serialized back", async function() { let testFields = { enum: "a", test: "blah", func: 50, bool: false, json: {}, priv: null, alpha: null, beta: "decay", gamma: 99, }; let testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: testFields }); assert.deepEqual(testInstance.serialize().fields, testFields); }); }); describe(".update()", function() { it("should skip updating invalid values", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.init(); testInstance.update({ name: "test_group", fields: { enum: "wrong", test: 3, func: null }}, false); assert.equal(testInstance.get("enum"), "b"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); it("should skip updating inaccessible fields", async function() { let testInstance = new TestGroup(mockConfig); await testInstance.init(); testInstance.update( { name: "test_group", fields: { test: "a", priv: "bad" }}, false, "remote" ); assert.equal(testInstance.get("test"), "a"); assert.equal(testInstance.get("priv"), null); }); it("should throw on invalid input", function() { let testInstance = new TestGroup(mockConfig); assert.throws( () => testInstance.update(), new Error("Expected object, not undefined for ConfigGroup") ); assert.throws( () => testInstance.update({}, false), new Error("Expected group name test_group, not undefined") ); assert.throws( () => testInstance.update({ name: "test_group" }, false), new Error("Expected fields to be an object, not undefined") ); assert.throws( () => testInstance.update({ name: "test_group", fields: [] }, false), new Error("Expected fields to be an object, not array") ); assert.throws( () => testInstance.update({ name: "test_group", fields: null }, false), new Error("Expected fields to be an object, not null") ); }); }); describe(".canAccess()", function() { let testInstance; before(async function() { testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: { enum: "a", priv: "blah", func: 27, }}); }); it("should return true for fields that are accessible", function() { for (let field of ["enum", "test", "func", "bool", "json"]) { assert.equal(testInstance.canAccess(field), true); assert.equal(testInstance.canAccess(field, "local"), true); assert.equal(testInstance.canAccess(field, "remote"), true); } assert.equal(testInstance.canAccess("priv"), true); assert.equal(testInstance.canAccess("priv", "local"), true); assert.equal(testInstance.canAccess("priv", "remote"), false); }); }); describe(".get()", function() { let testInstance; before(async function() { testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: { enum: "a", priv: "blah", func: 27, }}); }); it("should retreive value of field", function() { assert.equal(testInstance.get("enum"), "a"); assert.equal(testInstance.get("priv"), "blah"); assert.equal(testInstance.get("func"), 27); }); it("should throw if field is inaccessible", function() { assert.throws( () => testInstance.get("priv", "remote"), new lib.InvalidAccess("Field 'priv' is not accessible from remote") ); }); }); describe(".set()", function() { let testInstance; before(async function() { testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27 }}); }); it("should throw if field does not exist", function() { assert.throws( () => testInstance.set("bar", 1), new lib.InvalidField("No field named 'bar'") ); }); it("should throw if field is inaccesible", function() { assert.throws( () => testInstance.set("priv", "bad", "remote"), new lib.InvalidAccess("Field 'priv' is not accessible from remote") ); }); it("should throw if field is not in enum", function() { assert.throws( () => testInstance.set("enum", "bar"), new Error("Expected one of [a, b, c], not bar") ); }); it("should work if field is in enum", function() { testInstance.set("enum", "c"); assert.equal(testInstance.get("enum"), "c"); }); it("should throw if field is not optional", function() { assert.throws( () => testInstance.set("func", null), new Error("Field func cannot be null") ); }); it("should work if field is optional", function() { testInstance.set("test", "spam"); assert.equal(testInstance.get("test"), "spam"); }); it("should throw if field is of wrong type", function() { assert.throws( () => testInstance.set("test", 1), new Error("Expected type of test to be string, not number") ); }); it("should treat empty string as null", function() { testInstance.set("test", ""); assert.equal(testInstance.get("test"), null); }); it("should auto convert string to boolean if possible", function() { testInstance.set("bool", "true"); assert.equal(testInstance.get("bool"), true); testInstance.set("bool", "false"); assert.equal(testInstance.get("bool"), false); assert.throws( () => testInstance.set("bool", "blah"), new lib.InvalidValue("Expected type of bool to be boolean, not string") ); }); it("should auto convert string to number if possible", function() { for (let s of [ "1", "+23", ".23e5", "-.1e-2", "100.0001", "Infinity", ]) { testInstance.set("func", s); assert.equal(testInstance.get("func"), Number.parseFloat(s)); } assert.throws( () => testInstance.set("func", "blah"), new lib.InvalidValue("Expected type of func to be number, not string") ); }); it("should auto convert string to object", function() { testInstance.set("json", '{"json": true}'); assert.deepEqual(testInstance.get("json"), { json: true }); let errMsg; try { JSON.parse("blah"); } catch (err) { errMsg = err.message; } assert.throws( () => testInstance.set("json", "blah"), new lib.InvalidValue(`Error parsing value for json: ${errMsg}`) ); }); }); describe(".setProp()", function() { let testInstance; before(async function() { testInstance = new TestGroup(mockConfig); await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27, }}); }); it("should throw if field does not exist", function() { assert.throws( () => testInstance.setProp("bar", 1), new lib.InvalidField("No field named 'bar'") ); }); it("should throw if field is inaccesible", function() { assert.throws( () => testInstance.setProp("priv", "prop", "bad", "remote"), new lib.InvalidAccess("Field 'priv' is not accessible from remote") ); }); it("should throw if field is not an object", function() { assert.throws( () => testInstance.setProp("enum", "prop", "a"), new lib.InvalidField("Cannot set property on non-object field 'enum'") ); }); it("should work if field is an object", function() { testInstance.set("json", { prev: 32, test: false }); testInstance.setProp("json", "test", true); assert.deepEqual(testInstance.get("json"), { prev: 32, test: true }); }); it("should handle field being null", function() { testInstance.set("json", null); testInstance.setProp("json", "test", true); assert.deepEqual(testInstance.get("json"), { test: true }); }); it("should unset field if passed undefined", function() { testInstance.set("json", { test: true, extra: "yes" }); testInstance.setProp("json", "extra", undefined); assert.deepEqual(testInstance.get("json"), { test: true }); }); }); }); describe("Config", function() { class AlphaGroup extends lib.ConfigGroup { } AlphaGroup.defaultAccess = ["local", "remote"]; AlphaGroup.groupName = "alpha"; class BetaGroup extends lib.ConfigGroup { } BetaGroup.defaultAccess = ["local", "remote"]; BetaGroup.groupName = "beta"; class TestConfig extends lib.Config { } before(function() { AlphaGroup.define({ name: "foo", type: "string", optional: true }); AlphaGroup.finalize(); TestConfig.registerGroup(AlphaGroup); BetaGroup.define({ name: "bar", type: "object", initial_value: {} }); BetaGroup.finalize(); TestConfig.registerGroup(BetaGroup); }); describe(".registerGroup()", function() { it("should throw if the class is finalized", function() { class FinalizedConfig extends lib.Config { } FinalizedConfig.finalize(); class TestGroup extends lib.ConfigGroup { } TestGroup.groupName = "test"; TestGroup.finalize(); assert.throws( () => FinalizedConfig.registerGroup(TestGroup), new Error("Cannot register group on finalized config") ); }); it("should throw if the group is not finalized", function() { class UnfinishedGroup extends lib.ConfigGroup { } assert.throws( () => TestConfig.registerGroup(UnfinishedGroup), new Error("Group must be finalized before it can be registered") ); }); it("should throw if the group is already registered", function() { assert.throws( () => TestConfig.registerGroup(AlphaGroup), new Error("alpha has already been registered") ); }); }); describe("constructor", function() { it("should throw if config is not finalized", function() { assert.throws( () => new TestConfig("local"), new Error("Cannot instantiate incomplete Config class TestConfig") ); }); it("should construct a finalized class", function() { TestConfig.finalize(); let config = new TestConfig("local"); assert(config); }); it("should throw if location is not passed", function() { assert.throws( () => new TestConfig(), new Error("location must be a string") ); }); }); describe(".init()", function() { it("should initialize all fields", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); assert.equal(testInstance.get("alpha.foo"), null); assert.deepEqual(testInstance.get("beta.bar"), {}); }); }); describe(".serialize()", function() { it("should serialize a basic config", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { groups: [ { name: "alpha", fields: { foo: null }, }, { name: "beta", fields: { bar: {} }, }, ], }); }); }); describe(".load()", function() { it("should reject on incorrect input passed", async function() { let testInstance = new TestConfig("local"); await assert.rejects( testInstance.load(null), new Error("Expected object, not null for config") ); await assert.rejects( testInstance.load(undefined), new Error("Expected object, not undefined for config") ); await assert.rejects( testInstance.load({ groups: null }), new Error("Expected groups to be an array, not null") ); }); it("should load defaults for missing serialized groups", async function() { let testInstance = new TestConfig("local"); await testInstance.load({ groups: [{ name: "alpha", fields: { foo: "a" }}] }); assert.equal(testInstance.get("alpha.foo"), "a"); assert.deepEqual(testInstance.get("beta.bar"), {}); }); it("should preserve unknown groups when serialized back", async function() { let testGroups = [ { name: "extra", fields: { blah: true, spam: "foobar" }, }, { name: "alpha", fields: { foo: "true" }, }, { name: "beta", fields: { bar: { value: 20 }}, }, ]; let testInstance = new TestConfig("local"); await testInstance.load({ groups: testGroups }); assert.deepEqual(testInstance.serialize().groups, testGroups); }); }); describe(".update()", function() { it("should update a basic config", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.update({ groups: [ { name: "extra", fields: { blah: true, spam: "baz" }, }, { name: "beta", fields: { bar: { value: 30 }}, }, ]}, false); assert.deepEqual(testInstance.serialize(), { groups: [ { name: "extra", fields: { blah: true, spam: "baz" }, }, { name: "alpha", fields: { foo: null }, }, { name: "beta", fields: { bar: { value: 30 }}, }, ], }); }); }); describe(".group()", function() { it("should throw if instance is not initialized", function() { let testInstance = new TestConfig("local"); assert.throws( () => testInstance.group("test"), new Error("TestConfig instance is uninitialized") ); }); }); describe(".canAccess()", function() { it("should throw if group does not exist", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.canAccess("invalid"), new Error("No config group named 'invalid'")); }); it("should throw if no field is specified", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.canAccess("alpha"), new Error("No field named ''")); }); it("should return true for an accessible field", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); assert.equal(testInstance.canAccess("alpha.foo"), true); }); }); describe(".get()", function() { it("should throw if group does not exist", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("invalid"), new Error("No config group named 'invalid'")); }); it("should throw if no field is specified", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("alpha"), new Error("No field named ''")); }); }); describe(".set()", function() { it("should set the value", async function() { let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.set("alpha.foo", "new value"); assert.equal(testInstance.get("alpha.foo"), "new value"); }); }); describe("get groups", function() { it("should give the registered group classes", function() { assert.equal(TestConfig.groups, TestConfig._groups); }); }); describe("fieldChanged event", function() { let testInstance; let called; beforeEach(async function() { testInstance = new TestConfig("local"); await testInstance.init(); called = false; testInstance.once("fieldChanged", (group, field, prev) => { if (group.name === "beta" && field === "bar") { called = true; } }); }); it("should be called when setting a field", async function() { testInstance.set("beta.bar", { value: 1 }); assert(called, "fieldChanged was not called"); }); it("should be called when updating a config", async function() { testInstance.update({ groups: [{ name: "beta", fields: { bar: { value: 1 }}}]}, true); assert(called, "fieldChanged was not called"); }); it("should be called when setting a prop", async function() { testInstance.setProp("beta.bar", "value", 2); assert(called, "fieldChanged was not called"); }); }); }); });