Files
factorio-clusterio/test/lib/config/validators.test.js
T
bbassieandClaude Opus 4.8 9da48d5387 Add release channels as instance version targets
An instance's factorio.version can now target a release channel such as
"stable" or "experimental" (any key from factorio.com/api/latest-releases)
in addition to "latest" and specific X.Y / X.Y.Z versions.

The controller proxies the latest-releases API through a ValueCache and
persists the last successful response to latest-releases.json, serving that
copy when the API is unreachable. On instance start the host resolves a
channel to the concrete version it points to and feeds it into the existing
checkForUpdates/findVersion path, so a channel auto-installs and runs like a
pinned version. The version selector in the web UI lists the available
channels alongside "latest".

Also clarifies the host log/error when a newer version cannot be downloaded
automatically because the host uses a direct (single-version) Factorio
install rather than a versioned directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:19:34 +00:00

337 lines
9.3 KiB
JavaScript

"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const validators = lib.Config.validators;
const { ControllerConfig, HostConfig, InstanceConfig, ControlConfig } = lib;
describe("lib/config/validators", function() {
describe("all", function() {
it("should call all validators in order", function() {
let calls = [];
function v1(value, config) {
calls.push("v1");
}
function v2(value, config) {
calls.push("v2");
}
const combined = validators.all(v1, v2);
combined("value", {});
assert.deepEqual(calls, ["v1", "v2"], "validators were not called in order");
});
it("should propagate errors from validators", function() {
function v1() {}
function v2() {
throw new Error("failure");
}
const combined = validators.all(v1, v2);
assert.throws(
() => combined("value", {}),
/failure/,
"error from validator was not propagated"
);
});
});
describe("optional", function() {
it("should skip validation when value is null", function() {
let called = false;
function validator() {
called = true;
}
const wrapped = validators.optional(validator);
wrapped(null, {});
assert.equal(called, false, "validator should not have been called");
});
it("should run validator when value is not null", function() {
let called = false;
function validator() {
called = true;
}
const wrapped = validators.optional(validator);
wrapped("value", {});
assert.equal(called, true, "validator was not called");
});
});
describe("greaterThan", function() {
it("should allow values greater than min", function() {
const validator = validators.greaterThan(5);
assert.doesNotThrow(() => validator(6));
});
it("should throw if value equals min", function() {
const validator = validators.greaterThan(5);
assert.throws(
() => validator(5),
/Value must be greater than 5/,
"did not throw on equal value"
);
});
it("should throw if value is less than min", function() {
const validator = validators.greaterThan(5);
assert.throws(
() => validator(4),
/Value must be greater than 5/,
"did not throw on smaller value"
);
});
});
describe("greaterThanZero", function() {
it("should allow values greater than zero", function() {
assert.doesNotThrow(() => validators.greaterThanZero(1));
});
it("should throw on zero", function() {
assert.throws(
() => validators.greaterThanZero(0),
/Value must be greater than 0/
);
});
it("should throw on negative values", function() {
assert.throws(
() => validators.greaterThanZero(-1),
/Value must be greater than 0/
);
});
});
describe("greaterThanEqual", function() {
it("should allow values greater than min", function() {
const validator = validators.greaterThanEqual(5);
assert.doesNotThrow(() => validator(6));
});
it("should allow values equal to min", function() {
const validator = validators.greaterThanEqual(5);
assert.doesNotThrow(() => validator(5));
});
it("should throw if value is less than min", function() {
const validator = validators.greaterThanEqual(5);
assert.throws(
() => validator(4),
/Value must be greater than 5/,
"did not throw on smaller value"
);
});
});
describe("greaterThanEqualZero", function() {
it("should allow zero", function() {
assert.doesNotThrow(() => validators.greaterThanEqualZero(0));
});
it("should allow positive values", function() {
assert.doesNotThrow(() => validators.greaterThanEqualZero(1));
});
it("should throw on negative values", function() {
assert.throws(
() => validators.greaterThanEqualZero(-1),
/Value must be greater than 0/
);
});
});
describe("integer", function() {
it("should allow integer values", function() {
assert.doesNotThrow(() => validators.integer(10));
});
it("should throw on non-integer values", function() {
assert.throws(
() => validators.integer(1.5),
/Value must be an integer/,
"did not throw on float"
);
});
});
describe("filePath", function() {
it("should accept valid paths", function() {
assert.doesNotThrow(() => validators.filePath("test/file.txt"));
assert.doesNotThrow(() => validators.filePath("/absolute/path"));
});
it("should throw if value is not a string", function() {
// This is the only case that will cause path.resolve to throw on posix
assert.throws(
() => validators.filePath(null),
/Value must be a valid path/
);
});
});
});
describe("lib/config/definitions/validators", function() {
describe("Controller Config", function() {
});
describe("Host Config", function() {
});
describe("Instance Config", function() {
it("should validate factorio.version", function() {
const config = new InstanceConfig("controller");
// valid cases
assert.doesNotThrow(() => config.set("factorio.version", "latest"));
assert.doesNotThrow(() => config.set("factorio.version", "1.1"));
assert.doesNotThrow(() => config.set("factorio.version", "1.1.87"));
// invalid case
assert.throws(
() => config.set("factorio.version", "invalidValue"),
/Value must be 'latest', a release channel, or match X.Y or X.Y.Z/
);
});
describe("factorio.player_online_autosave_slots", function() {
it("should validate against autosave slots", function() {
const config = new InstanceConfig("controller", {
"factorio.settings": {
autosave_slots: 5,
},
});
// valid: equal
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 5));
// valid: greater
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 6));
// invalid: less than autosave_slots
assert.throws(
() => config.set("factorio.player_online_autosave_slots", 4),
/Value cannot be less than the number of autosave slots/
);
});
it("should allow when autosave_slots is not a number", function() {
const config = new InstanceConfig("controller", {
"factorio.settings": {
autosave_slots: null,
},
});
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 0));
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 1));
});
});
describe("plugin.load_plugin", function() {
const initialConfigFields = {};
before(function() {
for (const fieldName of Object.keys(InstanceConfig.fieldDefinitions)) {
if (fieldName.endsWith("load_plugin")) {
initialConfigFields[fieldName] = false;
}
}
});
it("should validate against factorio.enable_save_patching", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
}
lib.addPluginFieldDefinitions(
{name: "save_patching", features: ["SavePatching"] },
"instanceConfigFields", TestInstanceConfig
);
const config = new TestInstanceConfig("controller", initialConfigFields);
// valid: does not require script commands
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
// valid: can have load disabled
assert.doesNotThrow(() => config.set("save_patching.load_plugin", false));
// valid: does not require save patching when not loaded
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
// invalid: requires save patching when loaded
assert.throws(
() => config.set("save_patching.load_plugin", true),
/requires save patching/
);
});
it("should validate against factorio.enable_script_commands", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
}
lib.addPluginFieldDefinitions(
{ name: "script_commands", features: ["ScriptCommands"] },
"instanceConfigFields", TestInstanceConfig
);
const config = new TestInstanceConfig("controller", initialConfigFields);
// valid: does not require save patching
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
// valid: can have load disabled
assert.doesNotThrow(() => config.set("script_commands.load_plugin", false));
// valid: does not require script commands when not loaded
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
// invalid: requires script commands when loaded
assert.throws(
() => config.set("script_commands.load_plugin", true),
/requires script commands/
);
});
it("should allow when no feature flags are present", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
}
lib.addPluginFieldDefinitions(
{ name: "no_features", features: [] },
"instanceConfigFields", TestInstanceConfig
);
const config = new TestInstanceConfig("controller", initialConfigFields);
// valid: does not require save patching
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
// valid: does not require script commands
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
// valid: can have load disabled
assert.doesNotThrow(() => config.set("no_features.load_plugin", false));
// no invalid case
});
});
});
describe("Control Config", function() {
});
});