Files
factorio-expcluster/exp_scenario/test/features.test.js
T
bbassieandClaude Opus 5.5 4bc1773c82 Address review: page layout, five more features, framework changes
Framework:
- Intervals are gone. Update periods are hardcoded on_nth_tick values
  again, since nobody has needed to change them.
- apply is a single loop over the declared fields and logs a warning
  when an override is invalid. receive_update also warns about
  unknown keys.
- Nested tables are addressed as "section.key". Lists of strings can be
  overridden. Features.optional declares a value with no default.
- Features.on_apply rebuilds values derived from the config, such as
  lookup sets. It also runs during on_load.

exp_roles is assumed to be present:
- AFK Kick picks a role (active_role_id) instead of a custom check
  function.
- Chat Auto Reply, Protection, and Nuke Protection no longer take a
  permission as a setting.

New features, each chosen for an edge case:
- Discord Alerts: conditional handlers, and a command map that becomes
  a list.
- Entity Protection: lists turned into sets at load, and bookkeeping
  that must keep running while disabled.
- Nuke Protection: config keyed by defines, flattened to a banned items
  list.
- Popups: one config shared by two control files.
- Spawn Area: nested sections, and settings that only apply when the
  map is created.

Page: now laid out like the role page. It has a search across every
setting, one long list with a sticky sidebar to jump between sections,
and the enabled switch separated at the front of each header with
Reset to defaults on the right. Changed rows are highlighted, and a
bottom bar offers Revert and Apply. Role settings use a role picker and
lists use a tag select.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:28:23 +00:00

108 lines
4.0 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import t from "tap";
import { features, validateFeatureValues } from "../dist/node/features.js";
/** Every lua file in the module, by path relative to it. */
function luaFiles(dir, prefix = "") {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const relative = path.posix.join(prefix, entry.name);
if (entry.isDirectory()) {
return luaFiles(path.join(dir, entry.name), relative);
}
return entry.name.endsWith(".lua") ? [[relative, fs.readFileSync(path.join(dir, entry.name), "utf8")]] : [];
});
}
const moduleFiles = luaFiles(path.join(import.meta.dirname, "..", "module"));
/**
* Read a default from a Features.config call, a path of "a.b" looks for b after the table a starts.
* Only covers booleans, numbers, strings, lists of strings, and Features.optional.
*/
function luaDefault(source, path) {
const keys = path.split(".");
let start = 0;
for (const section of keys.slice(0, -1)) {
const match = new RegExp(`^\\s*${section} = \\{`, "m").exec(source.slice(start));
if (!match) {
return undefined;
}
start += match.index + match[0].length;
}
const key = keys.at(-1);
const match = new RegExp(`^\\s*${key} = (true|false|-?[\\d.]+|"[^"]*"|Features\\.optional\\("\\w+"\\)|\\{[^{}]*\\})`, "m")
.exec(source.slice(start));
if (!match) {
return undefined;
}
const literal = match[1];
if (literal.startsWith("Features.optional")) {
return null;
}
if (literal.startsWith("{")) {
return [...literal.matchAll(/"([^"]*)"/g)].map(item => item[1]);
}
return JSON.parse(literal);
}
t.test("features", t2 => {
for (const feature of features) {
t2.test(feature.name, t3 => {
const declarations = moduleFiles.filter(([, source]) => (
source.includes(`Features.config("${feature.name}"`) || source.includes(`Features.guard("${feature.name}"`)
));
t3.equal(declarations.length, 1, "one lua file declares the feature");
const source = declarations[0]?.[1] ?? "";
for (const field of feature.fields) {
t3.strictSame(luaDefault(source, field.name), field.default, `${field.name} default matches the lua side`);
}
t3.end();
});
}
t2.end();
});
t.test("validateFeatureValues()", t2 => {
t2.same(
validateFeatureValues("death_markers", { show_map_markers: false, collect_corpses: true }),
{ show_map_markers: false },
"values equal to their default are dropped",
);
t2.throws(() => validateFeatureValues("unknown", {}), { message: "Unknown feature unknown" });
t2.throws(
() => validateFeatureValues("death_markers", { map_icon: "x" }),
{ message: "Feature death_markers has no setting map_icon" },
);
t2.throws(
() => validateFeatureValues("death_markers", { show_map_markers: "no" }),
{ message: "Setting show_map_markers of death_markers must be a boolean" },
);
t2.throws(
() => validateFeatureValues("afk_kick", { afk_minutes: 0 }),
{ message: "Setting afk_minutes of afk_kick must be at least 1" },
);
t2.throws(
() => validateFeatureValues("afk_kick", { afk_minutes: Infinity }),
{ message: "Setting afk_minutes of afk_kick must be a finite number" },
);
t2.throws(
() => validateFeatureValues("protection", { always_protected_types: ["boiler", 1] }),
{ message: "Setting always_protected_types of protection must be a list of strings" },
);
t2.throws(
() => validateFeatureValues("afk_kick", { active_role_id: 1.5 }),
{ message: "Setting active_role_id of afk_kick must be a role id or null" },
);
t2.same(
validateFeatureValues("protection", { always_protected_types: ["boiler"], always_trigger_repeat_types: ["reactor", "fusion-reactor", "rocket-silo"] }),
{ always_protected_types: ["boiler"] },
"lists equal to their default are dropped",
);
t2.same(validateFeatureValues("afk_kick", { active_role_id: null }), {}, "no role is the default");
t2.same(validateFeatureValues("afk_kick", { active_role_id: 3 }), { active_role_id: 3 });
t2.same(validateFeatureValues("spawn_area", { "turrets.enabled": false }), { "turrets.enabled": false }, "nested settings");
t2.end();
});