package com.xfestudio.xfeservermanager.core.trigger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class TriggerEngineTest {
@Test
void compilesScriptAndEvaluatesAllConditions() {
var program = TriggerScriptCompiler.compile("""
# XFE Script v1
on player.join
match all
when player.firstJoin eq true
when server.online gte 2
do send_player message="Welcome, {player.name}!"
do server_command command="say {player.name} joined"
""");
assertEquals("player.join", program.event().type());
assertEquals(2, program.actions().size());
assertTrue(TriggerEvaluator.matches(program, Map.of(
"player.firstJoin", true, "server.online", 2)));
assertFalse(TriggerEvaluator.matches(program, Map.of(
"player.firstJoin", true, "server.online", "not-a-number")));
assertFalse(TriggerEvaluator.matches(program, Map.of(
"player.firstJoin", true, "server.online", Double.NaN)));
assertFalse(TriggerEvaluator.matches(
new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition("server.online", "eq", "NaN")),
List.of(new TriggerDefinition.Action("log", Map.of("message", "ignored")))),
Map.of("server.online", Double.NaN)));
assertEquals("Hi Alex (2/20)", TriggerEvaluator.render(
"Hi {player.name} ({online}/{maxPlayers})", Map.of(
"player.name", "Alex", "server.online", 2, "server.maxPlayers", 20)));
}
@Test
void rendersTemplatesOnceWithoutReinterpretingInsertedPlayerInput() {
Map<String, Object> context = new java.util.LinkedHashMap<>();
context.put("chat.message", "literal {player.name} and $1 \\ value");
context.put("player.name", "Alex");
context.put("player", Map.of("dimension", "minecraft:overworld"));
assertEquals("Alex said literal {player.name} and $1 \\ value in minecraft:overworld",
TriggerEvaluator.render(
"{player.name} said {chat.message} in {player.dimension}", context));
assertEquals("unknown {custom.missing}",
TriggerEvaluator.render("unknown {custom.missing}", context));
}
@Test
void formatsOnlyPlayerFacingFloatingPointValuesAtTheRequestedPrecision() {
Map<String, Object> context = Map.of(
"player.x", 592.5000000001D,
"player.level", 30,
"var.metrics", List.of(1.234D, 2L),
"economy.balance", new BigDecimal("25.5"));
assertEquals("592.50 / 30 / [1.23,2] / 25.50", TriggerEvaluator.renderMessage(
"{player.x} / {player.level} / {var.metrics} / {economy.balance}", context, 2));
assertEquals("592.5000", TriggerEvaluator.renderMessage("{player.x}", context, 4));
assertEquals("592.5000000001", TriggerEvaluator.render("{player.x}", context));
assertThrows(IllegalArgumentException.class,
() -> TriggerEvaluator.renderMessage("{player.x}", context, 11));
}
@Test
void rendersAndComparesNestedStateVariablesAsCompactJson() {
Map<String, Object> context = Map.of(
"var.names", List.of("甲", "line\nbreak"),
"global.rewards", Map.of("coins", List.of(1L, 2L)));
assertEquals("[\"甲\",\"line\\nbreak\"] / {\"coins\":[1,2]}",
TriggerEvaluator.render("{var.names} / {global.rewards}", context));
assertTrue(conditionMatches("global.rewards", "contains", "\"coins\":[1,2]", context));
assertTrue(conditionMatches("var.names", "eq", "[\"甲\",\"line\\nbreak\"]", context));
}
@Test
void validatesSchedulesAndRejectsUnknownActions() {
assertEquals("08:30", TriggerScriptCompiler.compile("""
on schedule.daily
set time="08:30"
set timezone="Asia/Shanghai"
do broadcast message="Good morning"
""").event().configuration().get("time"));
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on player.join
do arbitrary_jvm_code value="no"
"""));
assertTrue(failure.getMessage().contains("unsupported trigger action"));
}
@Test
void decodesScriptEscapesInOnePassWithoutCorruptingLiteralBackslashes() {
String script = "on player.join\n"
+ "do send_player message=\"C:\\\\new\\\\folder and literal \\\\n\"\n";
String message = TriggerScriptCompiler.compile(script).actions().get(0).parameters().get("message");
assertEquals("C:\\new\\folder and literal \\n", message);
}
@Test
void acceptsNamespacedCustomEventsAndRejectsAnUnboundPlaceholder() {
assertEquals("custom.example.boss_spawned", TriggerScriptCompiler.compile("""
on custom.example.boss_spawned
do log message="Boss spawned"
""").event().type());
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on custom
do log message="never"
"""));
}
@Test
void rejectsPlayerOnlyActionsForSchedulesWithoutPlayerContext() {
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on schedule.daily
set time="08:00"
set timezone="Asia/Shanghai"
do send_player message="Hello"
"""));
assertTrue(failure.getMessage().contains("player-context event"));
}
@Test
void rejectsUnsafeOrInvalidRegularExpressionsBeforeExecution() {
IllegalArgumentException nested = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on player.chat
when chat.message matches "(a+)+$"
do log message="matched"
"""));
assertTrue(nested.getMessage().contains("repeated regular-expression groups"));
IllegalArgumentException invalid = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on player.chat
when chat.message matches "[unterminated"
do log message="matched"
"""));
assertTrue(invalid.getMessage().contains("regular expression"));
}
@Test
void evaluatesSymmetricStringOperatorsAndRequiresPresentFieldsForNewNegations() {
Map<String, Object> context = Map.of("text", "Alpha-Beta");
assertTrue(conditionMatches("text", "starts_with", "Alpha", context));
assertTrue(conditionMatches("text", "not_starts_with", "Beta", context));
assertTrue(conditionMatches("text", "ends_with", "Beta", context));
assertTrue(conditionMatches("text", "not_ends_with", "Alpha", context));
assertTrue(conditionMatches("text", "matches", "^alpha-", context));
assertTrue(conditionMatches("text", "not_matches", "gamma", context));
assertFalse(conditionMatches("missing", "not_starts_with", "Alpha", context));
assertFalse(conditionMatches("missing", "not_ends_with", "Beta", context));
assertFalse(conditionMatches("missing", "not_matches", ".*", context));
// These three absence semantics predate the symmetric operators and remain compatible
// with already-persisted conditions.
assertTrue(conditionMatches("missing", "neq", "anything", context));
assertTrue(conditionMatches("missing", "not_contains", "anything", context));
assertTrue(conditionMatches("missing", "not_in", "a,b", context));
}
@Test
void evaluatesInclusiveRangesWithoutTreatingTypeErrorsAsNotBetween() {
assertTrue(conditionMatches("score", "between", "10, 20", Map.of("score", 10)));
assertTrue(conditionMatches("score", "between", "10, 20", Map.of("score", 20.0)));
assertFalse(conditionMatches("score", "between", "10, 20", Map.of("score", 21)));
assertTrue(conditionMatches("score", "not_between", "10, 20", Map.of("score", -1)));
assertFalse(conditionMatches("score", "not_between", "10, 20", Map.of("score", "unknown")));
assertFalse(conditionMatches("missing", "not_between", "10, 20", Map.of()));
assertThrows(IllegalArgumentException.class,
() -> validateCondition("between", "20,10"));
assertThrows(IllegalArgumentException.class,
() -> validateCondition("not_between", "0,NaN"));
assertThrows(IllegalArgumentException.class,
() -> validateCondition("between", "1,2,3"));
}
@Test
void evaluatesBooleanEmptyMembershipAndExistenceConditionsByType() {
Map<String, Object> context = new java.util.LinkedHashMap<>();
context.put("emptyText", "");
context.put("blankText", " ");
context.put("emptyList", List.of());
context.put("emptyMap", Map.of());
context.put("emptyArray", new int[0]);
context.put("values", List.of("one"));
context.put("enabled", true);
context.put("disabledText", " FALSE ");
context.put("number", 1);
assertTrue(conditionMatches("emptyText", "empty", "ignored", context));
assertFalse(conditionMatches("blankText", "empty", "ignored", context));
assertTrue(conditionMatches("emptyList", "empty", "ignored", context));
assertTrue(conditionMatches("emptyMap", "empty", "ignored", context));
assertTrue(conditionMatches("emptyArray", "empty", "ignored", context));
assertTrue(conditionMatches("values", "not_empty", "ignored", context));
assertFalse(conditionMatches("missing", "empty", "ignored", context));
assertFalse(conditionMatches("missing", "not_empty", "ignored", context));
assertFalse(conditionMatches("number", "empty", "ignored", context));
assertFalse(conditionMatches("number", "not_empty", "ignored", context));
assertTrue(conditionMatches("enabled", "true", "ignored", context));
assertTrue(conditionMatches("disabledText", "false", "ignored", context));
assertFalse(conditionMatches("number", "true", "ignored", context));
assertTrue(conditionMatches("enabled", "in", "false, true", context));
assertTrue(conditionMatches("number", "not_in", "2,3", context));
assertTrue(conditionMatches("enabled", "exists", "ignored", context));
assertTrue(conditionMatches("missing", "not_exists", "ignored", context));
}
@Test
void compilesNoValueOperatorsAndIgnoresLegacyValues() {
TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
on custom.example.condition
match all
when payload empty
when enabled true ignored-by-unary-operator
when disabled false
when absent not_exists legacy-value
do log message="matched"
""");
assertEquals("", program.conditions().get(0).value());
assertEquals("ignored-by-unary-operator", program.conditions().get(1).value());
assertEquals("legacy-value", program.conditions().get(3).value());
assertTrue(TriggerEvaluator.matches(program, Map.of(
"payload", List.of(), "enabled", true, "disabled", "false")));
}
@Test
void validatesEveryAdvertisedOperator() {
for (String operator : TriggerCatalog.OPERATORS) {
String value = switch (operator) {
case "matches", "not_matches" -> "^value$";
case "gt", "gte", "lt", "lte" -> "1";
case "between", "not_between" -> "1,2";
case "exists", "not_exists", "empty", "not_empty", "true", "false" -> "";
default -> "value";
};
TriggerCatalog.validate(programWithCondition(operator, value));
}
}
@Test
void enforcesActionParametersAndLeaveEventAvailability() {
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.join
do server_command command="say first\nsay second"
"""));
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.join
do set_gamemode gamemode=operator
"""));
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.leave
do title title="Too late"
"""));
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.join
do server_command command="say hello" showFeedback=yes
"""));
assertDoesNotThrow(() -> TriggerScriptCompiler.compile("""
on player.join
do server_command command="say hello" showFeedback=false
"""));
}
@Test
void defersTypedActionValidationForSupportedTemplateVariablesOnly() {
TriggerDefinition.Program templated = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(
new TriggerDefinition.Action("sound", Map.of(
"sound", "{args.sound}", "volume", "{args.volume}",
"pitch", "{args.pitch}")),
new TriggerDefinition.Action("give_item", Map.of(
"item", "minecraft:{args.item}", "count", "{args.count}")),
new TriggerDefinition.Action("clear_inventory", Map.of(
"item", "{args.item}", "maxCount", "{args.maximum}")),
new TriggerDefinition.Action("set_gamemode", Map.of(
"gamemode", "{args.mode}")),
new TriggerDefinition.Action("add_effect", Map.of(
"effect", "{args.effect}", "duration", "{args.duration}",
"amplifier", "{args.amplifier}")),
new TriggerDefinition.Action("set_time", Map.of(
"time", "{args.time}")),
new TriggerDefinition.Action("set_weather", Map.of(
"weather", "{args.weather}", "duration", "{args.duration}")),
new TriggerDefinition.Action("title", Map.of(
"title", "Hello", "fadeIn", "{args.fadeIn}",
"stay", "{args.stay}", "fadeOut", "{args.fadeOut}",
"numberPrecision", "{args.precision}")),
new TriggerDefinition.Action("log", Map.of(
"message", "changed", "level", "{args.level}"))));
assertDoesNotThrow(() -> TriggerCatalog.validate(templated));
assertTrue(TriggerEvaluator.hasTemplateVariable("minecraft:{args.item}"));
assertTrue(TriggerEvaluator.hasTemplateVariable("{server.time:uuuu-MM-dd HH:mm:ss}"));
assertFalse(TriggerEvaluator.hasTemplateVariable("stone{display:{Name:'value'}}"));
assertFalse(TriggerEvaluator.hasTemplateVariable("{player.name:unsupported-format}"));
assertFalse(TriggerEvaluator.hasTemplateVariable("{args.count"));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"give_item", Map.of("item", "NOT A RESOURCE", "count", "1"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"give_item", Map.of("item", "minecraft:stone", "count", "zero"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"give_item", Map.of("item", "minecraft:stone", "count", "{args.count"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"title", Map.of("title", "X={player.x}", "numberPrecision", "11"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"set_gamemode", Map.of("gamemode", "operator"))));
}
@Test
void validatesEveryAdvertisedBuiltInEvent() {
for (String event : TriggerCatalog.EVENTS) {
if (event.equals("custom")) continue;
Map<String, String> configuration = switch (event) {
case "schedule.daily" -> Map.of("time", "08:00", "timezone", "Asia/Shanghai");
case "schedule.interval" -> Map.of("seconds", "60");
case TriggerCommandConfiguration.EVENT_TYPE -> Map.of("command", "example");
case "region.enter", "region.leave", "region.stay" ->
Map.of("regionId", "example.region");
case "protection.item_overflow", "protection.mob_overflow",
"protection.entity_overflow" -> Map.of(
"threshold", "100", "scope", "dimension", "cooldownSeconds", "60");
case "protection.mod_entity_overflow" -> Map.of(
"threshold", "100", "namespace", "create", "cooldownSeconds", "60");
case "protection.slow_tick" -> Map.of(
"threshold", "200", "consecutive", "3", "cooldownSeconds", "60");
case "protection.memory_pressure" -> Map.of(
"threshold", "90", "cooldownSeconds", "60");
case "protection.spawn_burst", "protection.command_block_rate",
"protection.loaded_chunk_overflow" -> Map.of(
"threshold", "100", "cooldownSeconds", "60");
default -> Map.of();
};
TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent(event, configuration),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", event)))));
}
}
@Test
void validatesCrashProtectionThresholdScopeNamespaceAndCooldown() {
assertDoesNotThrow(() -> TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("protection.item_overflow", Map.of(
"threshold", "1500", "scope", "chunk", "cooldownSeconds", "30")),
TriggerDefinition.MatchMode.ALL, List.of(),
List.of(new TriggerDefinition.Action("broadcast", Map.of(
"message", "Items: {protection.count}"))))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("protection.item_overflow", Map.of(
"threshold", "0", "scope", "region")),
TriggerDefinition.MatchMode.ALL, List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", "bad"))))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("protection.mod_entity_overflow", Map.of(
"threshold", "100", "namespace", "Create Mod")),
TriggerDefinition.MatchMode.ALL, List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", "bad"))))));
}
@Test
void validatesVariableDrivenEconomyActionsForOfflinePlayerIdentityEvents() {
TriggerDefinition.Program program = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.leave", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition("economy.primary.balance", "gte", "0")),
List.of(new TriggerDefinition.Action("economy_deposit", Map.of(
"currency", "{economy.primary.code}",
"amount", "{economy.primary.balance}",
"reason", "离线结算 {player.name}"))));
assertDoesNotThrow(() -> TriggerCatalog.validate(program));
}
@Test
void validatesEventPlayerAndArbitraryPlayerEconomyActions() {
TriggerDefinition.Program eventPlayer = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition("player.dailyDue", "eq", "true")),
List.of(new TriggerDefinition.Action("economy_deposit", Map.of(
"currency", "coins", "amount", "500",
"reason", "Trigger economy operation"))));
TriggerDefinition.Program arbitraryPlayer = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("schedule.interval", Map.of("seconds", "900")),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(new TriggerDefinition.Action("economy_deposit_player", Map.of(
"player", "Alex", "currency", "coins", "amount", "500",
"reason", "Scheduled reward"))));
assertDoesNotThrow(() -> TriggerCatalog.validate(eventPlayer));
assertDoesNotThrow(() -> TriggerCatalog.validate(arbitraryPlayer));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(
programWithAction("economy_deposit_player", Map.of(
"player", "", "currency", "coins", "amount", "500",
"reason", "Missing player"))));
}
@Test
void reportsMalformedDocumentValuesAsValidationFailures() {
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.TriggerEvent(null, Map.of()));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Action("log", java.util.Collections.singletonMap("message", null)));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL, java.util.Collections.singletonList(null), List.of()));
}
@Test
void canonicalizesCodeDocumentsOnceFromTheirScript() {
Instant now = Instant.parse("2026-09-04T00:00:00Z");
TriggerDefinition definition = new TriggerDefinition(
UUID.randomUUID(), UUID.randomUUID(), "Scripted", "", true,
TriggerDefinition.Mode.CODE,
new TriggerDefinition.TriggerEvent("custom.stale", Map.of()),
TriggerDefinition.MatchMode.ANY, List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", "stale"))),
"on player.chat\nmatch all\ndo broadcast message=hello",
0, "owner", now, now, false);
assertEquals("player.chat", definition.event().type());
assertEquals(TriggerDefinition.MatchMode.ALL, definition.conditionMode());
assertEquals("broadcast", definition.actions().get(0).type());
assertEquals(definition.program(), definition.executableProgram());
}
@Test
void compilesAndSelectsNestedConditionalActionsInDocumentOrder() {
TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
on player.join
if player.firstJoin true
do send_player message="Welcome"
if server.online gte 10
do server_command command="say Busy server"
end
end
do log message="joined"
""");
TriggerDefinition.Action outer = program.actions().get(0);
assertEquals(TriggerActionTree.CONDITION_TYPE, outer.type());
assertEquals(Map.of(
"field", "player.firstJoin", "operator", "true", "value", ""),
outer.parameters());
assertEquals(2, outer.children().size());
assertEquals(TriggerActionTree.CONDITION_TYPE, outer.children().get(1).type());
assertEquals(List.of("send_player", "server_command", "log"),
TriggerActionTree.leafActions(program.actions()).stream()
.map(TriggerDefinition.Action::type).toList());
assertEquals(List.of("send_player", "log"),
TriggerActionTree.matchingActions(program.actions(), Map.of(
"player.firstJoin", true, "server.online", 4)).stream()
.map(TriggerDefinition.Action::type).toList());
assertEquals(List.of("send_player", "server_command", "log"),
TriggerActionTree.matchingActions(program.actions(), Map.of(
"player.firstJoin", true, "server.online", 10)).stream()
.map(TriggerDefinition.Action::type).toList());
assertEquals(List.of("log"),
TriggerActionTree.matchingActions(program.actions(), Map.of(
"player.firstJoin", false, "server.online", 10)).stream()
.map(TriggerDefinition.Action::type).toList());
}
@Test
void reportsExtraUnclosedAndEmptyConditionalBlocksAtTheirSourceLines() {
IllegalArgumentException extraEnd = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on custom.example.block
end
do log message="never"
"""));
assertTrue(extraEnd.getMessage().contains("script line 2: end has no matching if"));
IllegalArgumentException unclosed = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on custom.example.block
if value exists
do log message="inside"
"""));
assertTrue(unclosed.getMessage().contains(
"script line 2: if block is not closed; expected end"));
IllegalArgumentException empty = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on custom.example.block
if value exists
end
"""));
assertTrue(empty.getMessage().contains(
"script line 3: if block from line 2 needs at least one action"));
}
@Test
void compilesEveryNoValueOperatorInsideConditionalActionBlocks() {
for (String operator : List.of(
"exists", "not_exists", "empty", "not_empty", "true", "false")) {
TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
on custom.example.unary
if payload %s
do log message="matched"
end
""".formatted(operator));
assertEquals(operator, program.actions().get(0).parameters().get("operator"));
assertEquals("", program.actions().get(0).parameters().get("value"));
}
}
@Test
void acceptsActionTreesBeyondTheFormerNodeAndNestingLimits() {
assertEquals(1, TriggerScriptCompiler.compile(nestedScript(12)).actions().size());
StringBuilder manyNodes = new StringBuilder(
"on custom.example.limit\nif enabled true\n");
for (int index = 0; index < 40; index++) {
manyNodes.append("do log message=action-").append(index).append('\n');
}
manyNodes.append("end\n");
TriggerDefinition.Program accepted = TriggerScriptCompiler.compile(manyNodes.toString());
assertEquals(41, 1 + TriggerActionTree.leafActions(accepted.actions()).size());
List<TriggerDefinition.Action> nested = List.of(new TriggerDefinition.Action(
"log", Map.of("message", "deep")));
for (int index = 0; index < 12; index++) {
nested = List.of(conditionAction(nested));
}
assertEquals("log", TriggerActionTree.leafActions(nested).get(0).type());
List<TriggerDefinition.Action> manyModelNodes = java.util.stream.IntStream.range(0, 40)
.mapToObj(index -> new TriggerDefinition.Action(
"log", Map.of("message", "action-" + index))).toList();
TriggerDefinition.Program model = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("custom.example.limit", Map.of()),
TriggerDefinition.MatchMode.ALL,
java.util.stream.IntStream.range(0, 40)
.mapToObj(index -> new TriggerDefinition.Condition("value" + index, "exists", "")).toList(),
manyModelNodes);
assertEquals(40, model.conditions().size());
assertEquals(40, TriggerActionTree.leafActions(model.actions()).size());
}
@Test
void rejectsChildrenOnOrdinaryActionsAndInvalidConditionShapes() {
TriggerDefinition.Action leaf = new TriggerDefinition.Action(
"log", Map.of("message", "inside"));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Action(
"log", Map.of("message", "outside"), List.of(leaf)));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Action(
TriggerActionTree.CONDITION_TYPE,
Map.of("field", "enabled", "operator", "true", "unexpected", "value"),
List.of(leaf)));
TriggerDefinition.Action emptyCondition = new TriggerDefinition.Action(
TriggerActionTree.CONDITION_TYPE,
Map.of("field", "enabled", "operator", "true"), List.of());
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("custom.example.empty", Map.of()),
TriggerDefinition.MatchMode.ALL, List.of(), List.of(emptyCondition)));
assertTrue(failure.getMessage().contains("needs at least one child action"));
IllegalArgumentException noActions = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("on custom.example.empty\n"));
assertTrue(noActions.getMessage().contains("needs at least one executable action"));
}
private static boolean conditionMatches(
String field, String operator, String value, Map<String, ?> context) {
return TriggerEvaluator.matches(programWithCondition(field, operator, value), context);
}
private static void validateCondition(String operator, String value) {
TriggerCatalog.validate(programWithCondition(operator, value));
}
private static TriggerDefinition.Program programWithCondition(String operator, String value) {
return programWithCondition("value", operator, value);
}
private static TriggerDefinition.Program programWithCondition(String field, String operator, String value) {
return new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("custom.example.condition", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition(field, operator, value)),
List.of(new TriggerDefinition.Action("log", Map.of("message", "matched"))));
}
private static TriggerDefinition.Program programWithAction(String type, Map<String, String> parameters) {
return new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(new TriggerDefinition.Action(type, parameters)));
}
private static String nestedScript(int depth) {
StringBuilder script = new StringBuilder("on custom.example.depth\n");
for (int index = 0; index < depth; index++) script.append("if enabled true\n");
script.append("do log message=deep\n");
for (int index = 0; index < depth; index++) script.append("end\n");
return script.toString();
}
private static TriggerDefinition.Action conditionAction(List<TriggerDefinition.Action> children) {
return new TriggerDefinition.Action(
TriggerActionTree.CONDITION_TYPE,
Map.of("field", "enabled", "operator", "true"), children);
}
}
package com.xfestudio.xfeservermanager.core.trigger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class TriggerEngineTest {
@Test
void compilesScriptAndEvaluatesAllConditions() {
var program = TriggerScriptCompiler.compile("""
# XFE Script v1
on player.join
match all
when player.firstJoin eq true
when server.online gte 2
do send_player message="Welcome, {player.name}!"
do server_command command="say {player.name} joined"
""");
assertEquals("player.join", program.event().type());
assertEquals(2, program.actions().size());
assertTrue(TriggerEvaluator.matches(program, Map.of(
"player.firstJoin", true, "server.online", 2)));
assertFalse(TriggerEvaluator.matches(program, Map.of(
"player.firstJoin", true, "server.online", "not-a-number")));
assertFalse(TriggerEvaluator.matches(program, Map.of(
"player.firstJoin", true, "server.online", Double.NaN)));
assertFalse(TriggerEvaluator.matches(
new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition("server.online", "eq", "NaN")),
List.of(new TriggerDefinition.Action("log", Map.of("message", "ignored")))),
Map.of("server.online", Double.NaN)));
assertEquals("Hi Alex (2/20)", TriggerEvaluator.render(
"Hi {player.name} ({online}/{maxPlayers})", Map.of(
"player.name", "Alex", "server.online", 2, "server.maxPlayers", 20)));
}
@Test
void rendersTemplatesOnceWithoutReinterpretingInsertedPlayerInput() {
Map<String, Object> context = new java.util.LinkedHashMap<>();
context.put("chat.message", "literal {player.name} and $1 \\ value");
context.put("player.name", "Alex");
context.put("player", Map.of("dimension", "minecraft:overworld"));
assertEquals("Alex said literal {player.name} and $1 \\ value in minecraft:overworld",
TriggerEvaluator.render(
"{player.name} said {chat.message} in {player.dimension}", context));
assertEquals("unknown {custom.missing}",
TriggerEvaluator.render("unknown {custom.missing}", context));
}
@Test
void formatsOnlyPlayerFacingFloatingPointValuesAtTheRequestedPrecision() {
Map<String, Object> context = Map.of(
"player.x", 592.5000000001D,
"player.level", 30,
"var.metrics", List.of(1.234D, 2L),
"economy.balance", new BigDecimal("25.5"));
assertEquals("592.50 / 30 / [1.23,2] / 25.50", TriggerEvaluator.renderMessage(
"{player.x} / {player.level} / {var.metrics} / {economy.balance}", context, 2));
assertEquals("592.5000", TriggerEvaluator.renderMessage("{player.x}", context, 4));
assertEquals("592.5000000001", TriggerEvaluator.render("{player.x}", context));
assertThrows(IllegalArgumentException.class,
() -> TriggerEvaluator.renderMessage("{player.x}", context, 11));
}
@Test
void rendersAndComparesNestedStateVariablesAsCompactJson() {
Map<String, Object> context = Map.of(
"var.names", List.of("甲", "line\nbreak"),
"global.rewards", Map.of("coins", List.of(1L, 2L)));
assertEquals("[\"甲\",\"line\\nbreak\"] / {\"coins\":[1,2]}",
TriggerEvaluator.render("{var.names} / {global.rewards}", context));
assertTrue(conditionMatches("global.rewards", "contains", "\"coins\":[1,2]", context));
assertTrue(conditionMatches("var.names", "eq", "[\"甲\",\"line\\nbreak\"]", context));
}
@Test
void validatesSchedulesAndRejectsUnknownActions() {
assertEquals("08:30", TriggerScriptCompiler.compile("""
on schedule.daily
set time="08:30"
set timezone="Asia/Shanghai"
do broadcast message="Good morning"
""").event().configuration().get("time"));
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on player.join
do arbitrary_jvm_code value="no"
"""));
assertTrue(failure.getMessage().contains("unsupported trigger action"));
}
@Test
void decodesScriptEscapesInOnePassWithoutCorruptingLiteralBackslashes() {
String script = "on player.join\n"
+ "do send_player message=\"C:\\\\new\\\\folder and literal \\\\n\"\n";
String message = TriggerScriptCompiler.compile(script).actions().get(0).parameters().get("message");
assertEquals("C:\\new\\folder and literal \\n", message);
}
@Test
void acceptsNamespacedCustomEventsAndRejectsAnUnboundPlaceholder() {
assertEquals("custom.example.boss_spawned", TriggerScriptCompiler.compile("""
on custom.example.boss_spawned
do log message="Boss spawned"
""").event().type());
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on custom
do log message="never"
"""));
}
@Test
void rejectsPlayerOnlyActionsForSchedulesWithoutPlayerContext() {
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on schedule.daily
set time="08:00"
set timezone="Asia/Shanghai"
do send_player message="Hello"
"""));
assertTrue(failure.getMessage().contains("player-context event"));
}
@Test
void rejectsUnsafeOrInvalidRegularExpressionsBeforeExecution() {
IllegalArgumentException nested = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on player.chat
when chat.message matches "(a+)+$"
do log message="matched"
"""));
assertTrue(nested.getMessage().contains("repeated regular-expression groups"));
IllegalArgumentException invalid = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on player.chat
when chat.message matches "[unterminated"
do log message="matched"
"""));
assertTrue(invalid.getMessage().contains("regular expression"));
}
@Test
void evaluatesSymmetricStringOperatorsAndRequiresPresentFieldsForNewNegations() {
Map<String, Object> context = Map.of("text", "Alpha-Beta");
assertTrue(conditionMatches("text", "starts_with", "Alpha", context));
assertTrue(conditionMatches("text", "not_starts_with", "Beta", context));
assertTrue(conditionMatches("text", "ends_with", "Beta", context));
assertTrue(conditionMatches("text", "not_ends_with", "Alpha", context));
assertTrue(conditionMatches("text", "matches", "^alpha-", context));
assertTrue(conditionMatches("text", "not_matches", "gamma", context));
assertFalse(conditionMatches("missing", "not_starts_with", "Alpha", context));
assertFalse(conditionMatches("missing", "not_ends_with", "Beta", context));
assertFalse(conditionMatches("missing", "not_matches", ".*", context));
// These three absence semantics predate the symmetric operators and remain compatible
// with already-persisted conditions.
assertTrue(conditionMatches("missing", "neq", "anything", context));
assertTrue(conditionMatches("missing", "not_contains", "anything", context));
assertTrue(conditionMatches("missing", "not_in", "a,b", context));
}
@Test
void evaluatesInclusiveRangesWithoutTreatingTypeErrorsAsNotBetween() {
assertTrue(conditionMatches("score", "between", "10, 20", Map.of("score", 10)));
assertTrue(conditionMatches("score", "between", "10, 20", Map.of("score", 20.0)));
assertFalse(conditionMatches("score", "between", "10, 20", Map.of("score", 21)));
assertTrue(conditionMatches("score", "not_between", "10, 20", Map.of("score", -1)));
assertFalse(conditionMatches("score", "not_between", "10, 20", Map.of("score", "unknown")));
assertFalse(conditionMatches("missing", "not_between", "10, 20", Map.of()));
assertThrows(IllegalArgumentException.class,
() -> validateCondition("between", "20,10"));
assertThrows(IllegalArgumentException.class,
() -> validateCondition("not_between", "0,NaN"));
assertThrows(IllegalArgumentException.class,
() -> validateCondition("between", "1,2,3"));
}
@Test
void evaluatesBooleanEmptyMembershipAndExistenceConditionsByType() {
Map<String, Object> context = new java.util.LinkedHashMap<>();
context.put("emptyText", "");
context.put("blankText", " ");
context.put("emptyList", List.of());
context.put("emptyMap", Map.of());
context.put("emptyArray", new int[0]);
context.put("values", List.of("one"));
context.put("enabled", true);
context.put("disabledText", " FALSE ");
context.put("number", 1);
assertTrue(conditionMatches("emptyText", "empty", "ignored", context));
assertFalse(conditionMatches("blankText", "empty", "ignored", context));
assertTrue(conditionMatches("emptyList", "empty", "ignored", context));
assertTrue(conditionMatches("emptyMap", "empty", "ignored", context));
assertTrue(conditionMatches("emptyArray", "empty", "ignored", context));
assertTrue(conditionMatches("values", "not_empty", "ignored", context));
assertFalse(conditionMatches("missing", "empty", "ignored", context));
assertFalse(conditionMatches("missing", "not_empty", "ignored", context));
assertFalse(conditionMatches("number", "empty", "ignored", context));
assertFalse(conditionMatches("number", "not_empty", "ignored", context));
assertTrue(conditionMatches("enabled", "true", "ignored", context));
assertTrue(conditionMatches("disabledText", "false", "ignored", context));
assertFalse(conditionMatches("number", "true", "ignored", context));
assertTrue(conditionMatches("enabled", "in", "false, true", context));
assertTrue(conditionMatches("number", "not_in", "2,3", context));
assertTrue(conditionMatches("enabled", "exists", "ignored", context));
assertTrue(conditionMatches("missing", "not_exists", "ignored", context));
}
@Test
void compilesNoValueOperatorsAndIgnoresLegacyValues() {
TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
on custom.example.condition
match all
when payload empty
when enabled true ignored-by-unary-operator
when disabled false
when absent not_exists legacy-value
do log message="matched"
""");
assertEquals("", program.conditions().get(0).value());
assertEquals("ignored-by-unary-operator", program.conditions().get(1).value());
assertEquals("legacy-value", program.conditions().get(3).value());
assertTrue(TriggerEvaluator.matches(program, Map.of(
"payload", List.of(), "enabled", true, "disabled", "false")));
}
@Test
void validatesEveryAdvertisedOperator() {
for (String operator : TriggerCatalog.OPERATORS) {
String value = switch (operator) {
case "matches", "not_matches" -> "^value$";
case "gt", "gte", "lt", "lte" -> "1";
case "between", "not_between" -> "1,2";
case "exists", "not_exists", "empty", "not_empty", "true", "false" -> "";
default -> "value";
};
TriggerCatalog.validate(programWithCondition(operator, value));
}
}
@Test
void enforcesActionParametersAndLeaveEventAvailability() {
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.join
do server_command command="say first\nsay second"
"""));
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.join
do set_gamemode gamemode=operator
"""));
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.leave
do title title="Too late"
"""));
assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
on player.join
do server_command command="say hello" showFeedback=yes
"""));
assertDoesNotThrow(() -> TriggerScriptCompiler.compile("""
on player.join
do server_command command="say hello" showFeedback=false
"""));
}
@Test
void defersTypedActionValidationForSupportedTemplateVariablesOnly() {
TriggerDefinition.Program templated = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(
new TriggerDefinition.Action("sound", Map.of(
"sound", "{args.sound}", "volume", "{args.volume}",
"pitch", "{args.pitch}")),
new TriggerDefinition.Action("give_item", Map.of(
"item", "minecraft:{args.item}", "count", "{args.count}")),
new TriggerDefinition.Action("clear_inventory", Map.of(
"item", "{args.item}", "maxCount", "{args.maximum}")),
new TriggerDefinition.Action("set_gamemode", Map.of(
"gamemode", "{args.mode}")),
new TriggerDefinition.Action("add_effect", Map.of(
"effect", "{args.effect}", "duration", "{args.duration}",
"amplifier", "{args.amplifier}")),
new TriggerDefinition.Action("set_time", Map.of(
"time", "{args.time}")),
new TriggerDefinition.Action("set_weather", Map.of(
"weather", "{args.weather}", "duration", "{args.duration}")),
new TriggerDefinition.Action("title", Map.of(
"title", "Hello", "fadeIn", "{args.fadeIn}",
"stay", "{args.stay}", "fadeOut", "{args.fadeOut}",
"numberPrecision", "{args.precision}")),
new TriggerDefinition.Action("log", Map.of(
"message", "changed", "level", "{args.level}"))));
assertDoesNotThrow(() -> TriggerCatalog.validate(templated));
assertTrue(TriggerEvaluator.hasTemplateVariable("minecraft:{args.item}"));
assertTrue(TriggerEvaluator.hasTemplateVariable("{server.time:uuuu-MM-dd HH:mm:ss}"));
assertFalse(TriggerEvaluator.hasTemplateVariable("stone{display:{Name:'value'}}"));
assertFalse(TriggerEvaluator.hasTemplateVariable("{player.name:unsupported-format}"));
assertFalse(TriggerEvaluator.hasTemplateVariable("{args.count"));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"give_item", Map.of("item", "NOT A RESOURCE", "count", "1"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"give_item", Map.of("item", "minecraft:stone", "count", "zero"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"give_item", Map.of("item", "minecraft:stone", "count", "{args.count"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"title", Map.of("title", "X={player.x}", "numberPrecision", "11"))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
"set_gamemode", Map.of("gamemode", "operator"))));
}
@Test
void validatesEveryAdvertisedBuiltInEvent() {
for (String event : TriggerCatalog.EVENTS) {
if (event.equals("custom")) continue;
Map<String, String> configuration = switch (event) {
case "schedule.daily" -> Map.of("time", "08:00", "timezone", "Asia/Shanghai");
case "schedule.interval" -> Map.of("seconds", "60");
case TriggerCommandConfiguration.EVENT_TYPE -> Map.of("command", "example");
case "region.enter", "region.leave", "region.stay" ->
Map.of("regionId", "example.region");
case "protection.item_overflow", "protection.mob_overflow",
"protection.entity_overflow" -> Map.of(
"threshold", "100", "scope", "dimension", "cooldownSeconds", "60");
case "protection.mod_entity_overflow" -> Map.of(
"threshold", "100", "namespace", "create", "cooldownSeconds", "60");
case "protection.slow_tick" -> Map.of(
"threshold", "200", "consecutive", "3", "cooldownSeconds", "60");
case "protection.memory_pressure" -> Map.of(
"threshold", "90", "cooldownSeconds", "60");
case "protection.spawn_burst", "protection.command_block_rate",
"protection.loaded_chunk_overflow" -> Map.of(
"threshold", "100", "cooldownSeconds", "60");
default -> Map.of();
};
TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent(event, configuration),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", event)))));
}
}
@Test
void validatesCrashProtectionThresholdScopeNamespaceAndCooldown() {
assertDoesNotThrow(() -> TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("protection.item_overflow", Map.of(
"threshold", "1500", "scope", "chunk", "cooldownSeconds", "30")),
TriggerDefinition.MatchMode.ALL, List.of(),
List.of(new TriggerDefinition.Action("broadcast", Map.of(
"message", "Items: {protection.count}"))))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("protection.item_overflow", Map.of(
"threshold", "0", "scope", "region")),
TriggerDefinition.MatchMode.ALL, List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", "bad"))))));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("protection.mod_entity_overflow", Map.of(
"threshold", "100", "namespace", "Create Mod")),
TriggerDefinition.MatchMode.ALL, List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", "bad"))))));
}
@Test
void validatesVariableDrivenEconomyActionsForOfflinePlayerIdentityEvents() {
TriggerDefinition.Program program = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.leave", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition("economy.primary.balance", "gte", "0")),
List.of(new TriggerDefinition.Action("economy_deposit", Map.of(
"currency", "{economy.primary.code}",
"amount", "{economy.primary.balance}",
"reason", "离线结算 {player.name}"))));
assertDoesNotThrow(() -> TriggerCatalog.validate(program));
}
@Test
void validatesEventPlayerAndArbitraryPlayerEconomyActions() {
TriggerDefinition.Program eventPlayer = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition("player.dailyDue", "eq", "true")),
List.of(new TriggerDefinition.Action("economy_deposit", Map.of(
"currency", "coins", "amount", "500",
"reason", "Trigger economy operation"))));
TriggerDefinition.Program arbitraryPlayer = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("schedule.interval", Map.of("seconds", "900")),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(new TriggerDefinition.Action("economy_deposit_player", Map.of(
"player", "Alex", "currency", "coins", "amount", "500",
"reason", "Scheduled reward"))));
assertDoesNotThrow(() -> TriggerCatalog.validate(eventPlayer));
assertDoesNotThrow(() -> TriggerCatalog.validate(arbitraryPlayer));
assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(
programWithAction("economy_deposit_player", Map.of(
"player", "", "currency", "coins", "amount", "500",
"reason", "Missing player"))));
}
@Test
void reportsMalformedDocumentValuesAsValidationFailures() {
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.TriggerEvent(null, Map.of()));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Action("log", java.util.Collections.singletonMap("message", null)));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL, java.util.Collections.singletonList(null), List.of()));
}
@Test
void canonicalizesCodeDocumentsOnceFromTheirScript() {
Instant now = Instant.parse("2026-09-04T00:00:00Z");
TriggerDefinition definition = new TriggerDefinition(
UUID.randomUUID(), UUID.randomUUID(), "Scripted", "", true,
TriggerDefinition.Mode.CODE,
new TriggerDefinition.TriggerEvent("custom.stale", Map.of()),
TriggerDefinition.MatchMode.ANY, List.of(),
List.of(new TriggerDefinition.Action("log", Map.of("message", "stale"))),
"on player.chat\nmatch all\ndo broadcast message=hello",
0, "owner", now, now, false);
assertEquals("player.chat", definition.event().type());
assertEquals(TriggerDefinition.MatchMode.ALL, definition.conditionMode());
assertEquals("broadcast", definition.actions().get(0).type());
assertEquals(definition.program(), definition.executableProgram());
}
@Test
void compilesAndSelectsNestedConditionalActionsInDocumentOrder() {
TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
on player.join
if player.firstJoin true
do send_player message="Welcome"
if server.online gte 10
do server_command command="say Busy server"
end
end
do log message="joined"
""");
TriggerDefinition.Action outer = program.actions().get(0);
assertEquals(TriggerActionTree.CONDITION_TYPE, outer.type());
assertEquals(Map.of(
"field", "player.firstJoin", "operator", "true", "value", ""),
outer.parameters());
assertEquals(2, outer.children().size());
assertEquals(TriggerActionTree.CONDITION_TYPE, outer.children().get(1).type());
assertEquals(List.of("send_player", "server_command", "log"),
TriggerActionTree.leafActions(program.actions()).stream()
.map(TriggerDefinition.Action::type).toList());
assertEquals(List.of("send_player", "log"),
TriggerActionTree.matchingActions(program.actions(), Map.of(
"player.firstJoin", true, "server.online", 4)).stream()
.map(TriggerDefinition.Action::type).toList());
assertEquals(List.of("send_player", "server_command", "log"),
TriggerActionTree.matchingActions(program.actions(), Map.of(
"player.firstJoin", true, "server.online", 10)).stream()
.map(TriggerDefinition.Action::type).toList());
assertEquals(List.of("log"),
TriggerActionTree.matchingActions(program.actions(), Map.of(
"player.firstJoin", false, "server.online", 10)).stream()
.map(TriggerDefinition.Action::type).toList());
}
@Test
void reportsExtraUnclosedAndEmptyConditionalBlocksAtTheirSourceLines() {
IllegalArgumentException extraEnd = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on custom.example.block
end
do log message="never"
"""));
assertTrue(extraEnd.getMessage().contains("script line 2: end has no matching if"));
IllegalArgumentException unclosed = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on custom.example.block
if value exists
do log message="inside"
"""));
assertTrue(unclosed.getMessage().contains(
"script line 2: if block is not closed; expected end"));
IllegalArgumentException empty = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("""
on custom.example.block
if value exists
end
"""));
assertTrue(empty.getMessage().contains(
"script line 3: if block from line 2 needs at least one action"));
}
@Test
void compilesEveryNoValueOperatorInsideConditionalActionBlocks() {
for (String operator : List.of(
"exists", "not_exists", "empty", "not_empty", "true", "false")) {
TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
on custom.example.unary
if payload %s
do log message="matched"
end
""".formatted(operator));
assertEquals(operator, program.actions().get(0).parameters().get("operator"));
assertEquals("", program.actions().get(0).parameters().get("value"));
}
}
@Test
void acceptsActionTreesBeyondTheFormerNodeAndNestingLimits() {
assertEquals(1, TriggerScriptCompiler.compile(nestedScript(12)).actions().size());
StringBuilder manyNodes = new StringBuilder(
"on custom.example.limit\nif enabled true\n");
for (int index = 0; index < 40; index++) {
manyNodes.append("do log message=action-").append(index).append('\n');
}
manyNodes.append("end\n");
TriggerDefinition.Program accepted = TriggerScriptCompiler.compile(manyNodes.toString());
assertEquals(41, 1 + TriggerActionTree.leafActions(accepted.actions()).size());
List<TriggerDefinition.Action> nested = List.of(new TriggerDefinition.Action(
"log", Map.of("message", "deep")));
for (int index = 0; index < 12; index++) {
nested = List.of(conditionAction(nested));
}
assertEquals("log", TriggerActionTree.leafActions(nested).get(0).type());
List<TriggerDefinition.Action> manyModelNodes = java.util.stream.IntStream.range(0, 40)
.mapToObj(index -> new TriggerDefinition.Action(
"log", Map.of("message", "action-" + index))).toList();
TriggerDefinition.Program model = new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("custom.example.limit", Map.of()),
TriggerDefinition.MatchMode.ALL,
java.util.stream.IntStream.range(0, 40)
.mapToObj(index -> new TriggerDefinition.Condition("value" + index, "exists", "")).toList(),
manyModelNodes);
assertEquals(40, model.conditions().size());
assertEquals(40, TriggerActionTree.leafActions(model.actions()).size());
}
@Test
void rejectsChildrenOnOrdinaryActionsAndInvalidConditionShapes() {
TriggerDefinition.Action leaf = new TriggerDefinition.Action(
"log", Map.of("message", "inside"));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Action(
"log", Map.of("message", "outside"), List.of(leaf)));
assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Action(
TriggerActionTree.CONDITION_TYPE,
Map.of("field", "enabled", "operator", "true", "unexpected", "value"),
List.of(leaf)));
TriggerDefinition.Action emptyCondition = new TriggerDefinition.Action(
TriggerActionTree.CONDITION_TYPE,
Map.of("field", "enabled", "operator", "true"), List.of());
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("custom.example.empty", Map.of()),
TriggerDefinition.MatchMode.ALL, List.of(), List.of(emptyCondition)));
assertTrue(failure.getMessage().contains("needs at least one child action"));
IllegalArgumentException noActions = assertThrows(IllegalArgumentException.class,
() -> TriggerScriptCompiler.compile("on custom.example.empty\n"));
assertTrue(noActions.getMessage().contains("needs at least one executable action"));
}
private static boolean conditionMatches(
String field, String operator, String value, Map<String, ?> context) {
return TriggerEvaluator.matches(programWithCondition(field, operator, value), context);
}
private static void validateCondition(String operator, String value) {
TriggerCatalog.validate(programWithCondition(operator, value));
}
private static TriggerDefinition.Program programWithCondition(String operator, String value) {
return programWithCondition("value", operator, value);
}
private static TriggerDefinition.Program programWithCondition(String field, String operator, String value) {
return new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("custom.example.condition", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(new TriggerDefinition.Condition(field, operator, value)),
List.of(new TriggerDefinition.Action("log", Map.of("message", "matched"))));
}
private static TriggerDefinition.Program programWithAction(String type, Map<String, String> parameters) {
return new TriggerDefinition.Program(
new TriggerDefinition.TriggerEvent("player.join", Map.of()),
TriggerDefinition.MatchMode.ALL,
List.of(),
List.of(new TriggerDefinition.Action(type, parameters)));
}
private static String nestedScript(int depth) {
StringBuilder script = new StringBuilder("on custom.example.depth\n");
for (int index = 0; index < depth; index++) script.append("if enabled true\n");
script.append("do log message=deep\n");
for (int index = 0; index < depth; index++) script.append("end\n");
return script.toString();
}
private static TriggerDefinition.Action conditionAction(List<TriggerDefinition.Action> children) {
return new TriggerDefinition.Action(
TriggerActionTree.CONDITION_TYPE,
Map.of("field", "enabled", "operator", "true"), children);
}
}