diff --git a/src/main/java/ch/njol/skript/SkriptEventHandler.java b/src/main/java/ch/njol/skript/SkriptEventHandler.java index b3ad81d8d4e..30954ba5cf9 100644 --- a/src/main/java/ch/njol/skript/SkriptEventHandler.java +++ b/src/main/java/ch/njol/skript/SkriptEventHandler.java @@ -18,17 +18,12 @@ */ package ch.njol.skript; -import java.lang.ref.WeakReference; -import java.lang.reflect.Method; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import java.util.stream.Collectors; - +import ch.njol.skript.lang.SkriptEvent; +import ch.njol.skript.lang.Trigger; +import ch.njol.skript.timings.SkriptTimings; +import ch.njol.skript.util.Task; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; import org.bukkit.Bukkit; import org.bukkit.event.Cancellable; import org.bukkit.event.Event; @@ -42,13 +37,16 @@ import org.bukkit.plugin.RegisteredListener; import org.eclipse.jdt.annotation.Nullable; -import com.google.common.collect.ArrayListMultimap; -import com.google.common.collect.Multimap; - -import ch.njol.skript.lang.SkriptEvent; -import ch.njol.skript.lang.Trigger; -import ch.njol.skript.timings.SkriptTimings; -import ch.njol.skript.util.Task; +import java.lang.ref.WeakReference; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.stream.Collectors; public final class SkriptEventHandler { @@ -112,66 +110,94 @@ private static List getTriggers(Class event) { * @param priority The priority of the Event. */ private static void check(Event event, EventPriority priority) { + // get all triggers for this event, return if none List triggers = getTriggers(event.getClass()); if (triggers.isEmpty()) return; - if (Skript.logVeryHigh()) { - boolean hasTrigger = false; - for (Trigger trigger : triggers) { - SkriptEvent triggerEvent = trigger.getEvent(); - if ( - triggerEvent.getEventPriority() == priority - && triggerEvent.canExecuteAsynchronously() ? triggerEvent.check(event) : Boolean.TRUE.equals(Task.callSync(() -> triggerEvent.check(event))) - ) { - hasTrigger = true; - break; - } - } - if (!hasTrigger) - return; + // Check if this event should be treated as cancelled + boolean isCancelled = isCancelled(event); - logEventStart(event); - } - - boolean isCancelled = event instanceof Cancellable && ((Cancellable) event).isCancelled() && !listenCancelled.contains(event.getClass()); - boolean isResultDeny = !(event instanceof PlayerInteractEvent && (((PlayerInteractEvent) event).getAction() == Action.LEFT_CLICK_AIR || ((PlayerInteractEvent) event).getAction() == Action.RIGHT_CLICK_AIR) && ((PlayerInteractEvent) event).useItemInHand() != Result.DENY); - - if (isCancelled && isResultDeny) { - if (Skript.logVeryHigh()) - Skript.info(" -x- was cancelled"); - return; - } + // This logs events even if there isn't a trigger that's going to run at that priority. + // However, there should only be a priority listener IF there's a trigger at that priority. + // So the time will be logged even if no triggers pass check(), which is still useful information. + logEventStart(event, priority); for (Trigger trigger : triggers) { SkriptEvent triggerEvent = trigger.getEvent(); + + // check if the trigger is at the right priority if (triggerEvent.getEventPriority() != priority) continue; - // these methods need to be run on whatever thread the trigger is - Runnable execute = () -> { - logTriggerStart(trigger); - Object timing = SkriptTimings.start(trigger.getDebugLabel()); - trigger.execute(event); - SkriptTimings.stop(timing); - logTriggerEnd(trigger); - }; - - if (trigger.getEvent().canExecuteAsynchronously()) { - if (triggerEvent.check(event)) - execute.run(); - } else { // Ensure main thread - Task.callSync(() -> { - if (triggerEvent.check(event)) - execute.run(); - return null; // we don't care about a return value - }); - } + // check if the cancel state of the event is correct + if (!triggerEvent.getListeningBehavior().matches(isCancelled)) + continue; + + // execute the trigger + execute(trigger, event); } logEventEnd(); } + /** + * Helper method to check if we should treat the provided Event as cancelled. + * + * @param event The event to check. + * @return Whether the event should be treated as cancelled. + */ + private static boolean isCancelled(Event event) { + return event instanceof Cancellable && + (((Cancellable) event).isCancelled() && isResultDeny(event)) && + // TODO: listenCancelled is deprecated and should be removed in 2.10 + !listenCancelled.contains(event.getClass()); + } + + /** + * Helper method for when the provided Event is a {@link PlayerInteractEvent}. + * These events are special in that they are called as cancelled when the player is left/right clicking on air. + * We don't want to treat those as cancelled, so we need to check if the {@link PlayerInteractEvent#useItemInHand()} result is DENY. + * That means the event was purposefully cancelled, and we should treat it as cancelled. + * + * @param event The event to check. + * @return Whether the event was a PlayerInteractEvent with air and the result was DENY. + */ + private static boolean isResultDeny(Event event) { + return !(event instanceof PlayerInteractEvent && + (((PlayerInteractEvent) event).getAction() == Action.LEFT_CLICK_AIR || ((PlayerInteractEvent) event).getAction() == Action.RIGHT_CLICK_AIR) && + ((PlayerInteractEvent) event).useItemInHand() != Result.DENY); + } + + /** + * Executes the provided Trigger with the provided Event as context. + * + * @param trigger The Trigger to execute. + * @param event The Event to execute the Trigger with. + */ + private static void execute(Trigger trigger, Event event) { + // these methods need to be run on whatever thread the trigger is + Runnable execute = () -> { + logTriggerStart(trigger); + Object timing = SkriptTimings.start(trigger.getDebugLabel()); + trigger.execute(event); + SkriptTimings.stop(timing); + logTriggerEnd(trigger); + }; + + if (trigger.getEvent().canExecuteAsynchronously()) { + if (trigger.getEvent().check(event)) + execute.run(); + } else { // Ensure main thread + Task.callSync(() -> { + if (trigger.getEvent().check(event)) + execute.run(); + return null; // we don't care about a return value + }); + } + } + + private static long startEvent; /** @@ -180,11 +206,30 @@ private static void check(Event event, EventPriority priority) { * @param event The Event that started. */ public static void logEventStart(Event event) { + logEventStart(event, null); + } + + /** + * Logs that the provided Event has started with a priority. + * Requires {@link Skript#logVeryHigh()} to be true to log anything. + * @param event The Event that started. + * @param priority The priority of the Event. + */ + public static void logEventStart(Event event, @Nullable EventPriority priority) { startEvent = System.nanoTime(); if (!Skript.logVeryHigh()) return; Skript.info(""); - Skript.info("== " + event.getClass().getName() + " =="); + + String message = "== " + event.getClass().getName(); + + if (priority != null) + message += " with priority " + priority; + + if (event instanceof Cancellable && ((Cancellable) event).isCancelled()) + message += " (cancelled)"; + + Skript.info(message + " =="); } /** @@ -307,8 +352,10 @@ public static void unregisterBukkitEvents(Trigger trigger) { } /** - * Events which are listened even if they are cancelled. + * Events which are listened even if they are cancelled. This should no longer be used. + * @deprecated Users should specify the listening behavior in the event declaration. "on any %event%:", "on cancelled %event%:". */ + @Deprecated public static final Set> listenCancelled = new HashSet<>(); /** diff --git a/src/main/java/ch/njol/skript/events/SimpleEvents.java b/src/main/java/ch/njol/skript/events/SimpleEvents.java index 18679fcbbbf..4a7b7300430 100644 --- a/src/main/java/ch/njol/skript/events/SimpleEvents.java +++ b/src/main/java/ch/njol/skript/events/SimpleEvents.java @@ -18,11 +18,20 @@ */ package ch.njol.skript.events; +import ch.njol.skript.Skript; +import ch.njol.skript.lang.SkriptEvent.ListeningBehavior; +import ch.njol.skript.lang.util.SimpleEvent; import com.destroystokyo.paper.event.block.AnvilDamagedEvent; +import com.destroystokyo.paper.event.entity.EntityJumpEvent; +import com.destroystokyo.paper.event.entity.ProjectileCollideEvent; +import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent; +import com.destroystokyo.paper.event.player.PlayerJumpEvent; +import com.destroystokyo.paper.event.server.PaperServerListPingEvent; import com.destroystokyo.paper.event.player.PlayerReadyArrowEvent; import io.papermc.paper.event.player.PlayerStopUsingItemEvent; import io.papermc.paper.event.player.PlayerDeepSleepEvent; import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent; +import io.papermc.paper.event.player.PlayerTradeEvent; import org.bukkit.event.block.BlockCanBuildEvent; import org.bukkit.event.block.BlockDamageEvent; import org.bukkit.event.block.BlockFertilizeEvent; @@ -35,9 +44,9 @@ import org.bukkit.event.block.BlockSpreadEvent; import org.bukkit.event.block.LeavesDecayEvent; import org.bukkit.event.block.SignChangeEvent; +import org.bukkit.event.block.SpongeAbsorbEvent; import org.bukkit.event.enchantment.EnchantItemEvent; import org.bukkit.event.enchantment.PrepareItemEnchantEvent; -import org.bukkit.event.block.SpongeAbsorbEvent; import org.bukkit.event.entity.AreaEffectCloudApplyEvent; import org.bukkit.event.entity.CreeperPowerEvent; import org.bukkit.event.entity.EntityBreakDoorEvent; @@ -108,16 +117,6 @@ import org.spigotmc.event.entity.EntityDismountEvent; import org.spigotmc.event.entity.EntityMountEvent; -import com.destroystokyo.paper.event.entity.ProjectileCollideEvent; -import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent; -import com.destroystokyo.paper.event.player.PlayerJumpEvent; -import com.destroystokyo.paper.event.server.PaperServerListPingEvent; -import com.destroystokyo.paper.event.entity.EntityJumpEvent; -import io.papermc.paper.event.player.PlayerTradeEvent; -import ch.njol.skript.Skript; -import ch.njol.skript.SkriptEventHandler; -import ch.njol.skript.lang.util.SimpleEvent; - /** * @author Peter Güttinger */ @@ -453,13 +452,13 @@ public class SimpleEvents { Skript.registerEvent("Resurrect Attempt", SimpleEvent.class, EntityResurrectEvent.class, "[entity] resurrect[ion] [attempt]") .description("Called when an entity dies, always. If they are not holding a totem, this is cancelled - you can, however, uncancel it.") .examples( - "on resurrect attempt:", + "on resurrect attempt:", "\tentity is player", "\tentity has permission \"admin.undying\"", "\tuncancel the event" ) - .since("2.2-dev28"); - SkriptEventHandler.listenCancelled.add(EntityResurrectEvent.class); // Listen this even when cancelled + .since("2.2-dev28") + .listeningBehavior(ListeningBehavior.ANY); Skript.registerEvent("Player World Change", SimpleEvent.class, PlayerChangedWorldEvent.class, "[player] world chang(ing|e[d])") .description("Called when a player enters a world. Does not work with other entities!") .examples("on player world change:", diff --git a/src/main/java/ch/njol/skript/lang/SkriptEvent.java b/src/main/java/ch/njol/skript/lang/SkriptEvent.java index 3be262a6005..fc72138db30 100644 --- a/src/main/java/ch/njol/skript/lang/SkriptEvent.java +++ b/src/main/java/ch/njol/skript/lang/SkriptEvent.java @@ -26,12 +26,14 @@ import ch.njol.skript.events.EvtClick; import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.skript.structures.StructEvent.EventData; -import org.skriptlang.skript.lang.script.Script; -import org.skriptlang.skript.lang.entry.EntryContainer; -import org.skriptlang.skript.lang.structure.Structure; +import ch.njol.skript.util.Utils; +import org.bukkit.event.Cancellable; import org.bukkit.event.Event; import org.bukkit.event.EventPriority; import org.eclipse.jdt.annotation.Nullable; +import org.skriptlang.skript.lang.entry.EntryContainer; +import org.skriptlang.skript.lang.script.Script; +import org.skriptlang.skript.lang.structure.Structure; import java.util.List; import java.util.Locale; @@ -54,6 +56,9 @@ public abstract class SkriptEvent extends Structure { private String expr; @Nullable protected EventPriority eventPriority; + @Nullable + protected ListeningBehavior listeningBehavior; + protected boolean supportsListeningBehavior; private SkriptEventInfo skriptEventInfo; /** @@ -65,7 +70,9 @@ public abstract class SkriptEvent extends Structure { public final boolean init(Literal[] args, int matchedPattern, ParseResult parseResult, EntryContainer entryContainer) { this.expr = parseResult.expr; - EventPriority priority = getParser().getData(EventData.class).getPriority(); + EventData eventData = getParser().getData(EventData.class); + + EventPriority priority = eventData.getPriority(); if (priority != null && !isEventPrioritySupported()) { Skript.error("This event doesn't support event priority"); return false; @@ -77,6 +84,23 @@ public final boolean init(Literal[] args, int matchedPattern, ParseResult par throw new IllegalStateException(); skriptEventInfo = (SkriptEventInfo) syntaxElementInfo; + // evaluate whether this event supports listening to cancelled events + supportsListeningBehavior = false; + for (Class eventClass : getEventClasses()) { + if (Cancellable.class.isAssignableFrom(eventClass)) { + supportsListeningBehavior = true; + break; + } + } + + listeningBehavior = eventData.getListenerBehavior(); + // if the behavior is non-null, it was set by the user + if (listeningBehavior != null && !isListeningBehaviorSupported()) { + String eventName = skriptEventInfo.name.toLowerCase(Locale.ENGLISH); + Skript.error(Utils.A(eventName) + " event does not support listening for cancelled or uncancelled events."); + return false; + } + return init(args, matchedPattern, parseResult); } @@ -199,6 +223,21 @@ public boolean isEventPrioritySupported() { return true; } + /** + * @return the {@link ListeningBehavior} to be used for this event. Defaults to the default listening behavior + * of the SkriptEventInfo for this SkriptEvent. + */ + public ListeningBehavior getListeningBehavior() { + return listeningBehavior != null ? listeningBehavior : skriptEventInfo.getListeningBehavior(); + } + + /** + * @return whether this SkriptEvent supports listening behaviors + */ + public boolean isListeningBehaviorSupported() { + return supportsListeningBehavior; + } + /** * Override this method to allow Skript to not force synchronization. */ @@ -241,4 +280,44 @@ public static SkriptEvent parse(String expr, SectionNode sectionNode, @Nullable return (SkriptEvent) Structure.parse(expr, sectionNode, defaultError, Skript.getEvents().iterator()); } + /** + * The listening behavior of a Skript event. This determines whether the event should run for cancelled events, uncancelled events, or both. + */ + public enum ListeningBehavior { + + /** + * This Skript event should run for any uncancelled event. + */ + UNCANCELLED, + + /** + * This Skript event should run for any cancelled event. + */ + CANCELLED, + + /** + * This Skript event should run for any event, cancelled or uncancelled. + */ + ANY; + + /** + * Checks whether this listening behavior matches the given cancelled state. + * @param cancelled Whether the event is cancelled. + * @return Whether an event with the given cancelled state should be run for this listening behavior. + */ + public boolean matches(final boolean cancelled) { + switch (this) { + case CANCELLED: + return cancelled; + case UNCANCELLED: + return !cancelled; + case ANY: + return true; + default: + assert false; + return false; + } + } + } + } diff --git a/src/main/java/ch/njol/skript/lang/SkriptEventInfo.java b/src/main/java/ch/njol/skript/lang/SkriptEventInfo.java index 9805ab4b986..610b0671529 100644 --- a/src/main/java/ch/njol/skript/lang/SkriptEventInfo.java +++ b/src/main/java/ch/njol/skript/lang/SkriptEventInfo.java @@ -23,6 +23,7 @@ import org.bukkit.event.player.PlayerInteractAtEntityEvent; import org.eclipse.jdt.annotation.Nullable; import org.skriptlang.skript.lang.structure.StructureInfo; +import ch.njol.skript.lang.SkriptEvent.ListeningBehavior; import java.util.Locale; @@ -30,7 +31,9 @@ public final class SkriptEventInfo extends StructureInfo< public Class[] events; public final String name; - + + private ListeningBehavior listeningBehavior; + @Nullable private String[] description, examples, keywords, requiredPlugins; @@ -70,6 +73,20 @@ public SkriptEventInfo(String name, String[] patterns, Class eventClass, Stri // uses the name without 'on ' or '*' this.id = "" + name.toLowerCase(Locale.ENGLISH).replaceAll("[#'\"<>/&]", "").replaceAll("\\s+", "_"); + + // default listening behavior should be to listen to uncancelled events + this.listeningBehavior = ListeningBehavior.UNCANCELLED; + } + + /** + * Sets the default listening behavior for this SkriptEvent. If omitted, the default behavior is to listen to uncancelled events. + * + * @param listeningBehavior The listening behavior of this SkriptEvent. + * @return This SkriptEventInfo object + */ + public SkriptEventInfo listeningBehavior(ListeningBehavior listeningBehavior) { + this.listeningBehavior = listeningBehavior; + return this; } /** @@ -158,6 +175,10 @@ public String getName() { return name; } + public ListeningBehavior getListeningBehavior() { + return listeningBehavior; + } + @Nullable public String[] getDescription() { return description; diff --git a/src/main/java/ch/njol/skript/structures/StructEvent.java b/src/main/java/ch/njol/skript/structures/StructEvent.java index c09afc8b259..814571463ad 100644 --- a/src/main/java/ch/njol/skript/structures/StructEvent.java +++ b/src/main/java/ch/njol/skript/structures/StructEvent.java @@ -22,6 +22,7 @@ import ch.njol.skript.doc.NoDoc; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptEvent; +import ch.njol.skript.lang.SkriptEvent.ListeningBehavior; import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.skript.lang.parser.ParserInstance; import org.bukkit.event.Event; @@ -37,7 +38,7 @@ public class StructEvent extends Structure { static { Skript.registerStructure(StructEvent.class, - "[on] <.+> [with priority (:(lowest|low|normal|high|highest|monitor))]"); + "[on] [:uncancelled|:cancelled|any:(any|all)] <.+> [priority:with priority (:(lowest|low|normal|high|highest|monitor))]"); } private SkriptEvent event; @@ -51,8 +52,18 @@ public boolean init(Literal[] args, int matchedPattern, ParseResult parseResu // ensure there's no leftover data from previous parses data.clear(); - if (!parseResult.tags.isEmpty()) - data.priority = EventPriority.valueOf(parseResult.tags.get(0).toUpperCase(Locale.ENGLISH)); + if (parseResult.hasTag("uncancelled")) { + data.behavior = ListeningBehavior.UNCANCELLED; + } else if (parseResult.hasTag("cancelled")) { + data.behavior = ListeningBehavior.CANCELLED; + } else if (parseResult.hasTag("any")) { + data.behavior = ListeningBehavior.ANY; + } + + if (parseResult.hasTag("priority")) { + String lastTag = parseResult.tags.get(parseResult.tags.size() - 1); + data.priority = EventPriority.valueOf(lastTag.toUpperCase(Locale.ENGLISH)); + } event = SkriptEvent.parse(expr, entryContainer.getSource(), null); @@ -111,6 +122,8 @@ public static class EventData extends ParserInstance.Data { @Nullable private EventPriority priority; + @Nullable + private ListeningBehavior behavior; public EventData(ParserInstance parserInstance) { super(parserInstance); @@ -122,10 +135,19 @@ public EventPriority getPriority() { } /** + * @return the listening behavior that should be used for the event. Null indicates that the user did not specify a behavior. + */ + @Nullable + public ListeningBehavior getListenerBehavior() { + return behavior; + } + + /** * Clears all event-specific data from this instance. */ public void clear() { priority = null; + behavior = null; } } diff --git a/src/test/java/org/skriptlang/skript/test/tests/lang/CancelledEventsTest.java b/src/test/java/org/skriptlang/skript/test/tests/lang/CancelledEventsTest.java new file mode 100644 index 00000000000..17f7606fa6a --- /dev/null +++ b/src/test/java/org/skriptlang/skript/test/tests/lang/CancelledEventsTest.java @@ -0,0 +1,51 @@ +/** + * This file is part of Skript. + * + * Skript 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, either version 3 of the License, or + * (at your option) any later version. + * + * Skript 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 Skript. If not, see . + * + * Copyright Peter Güttinger, SkriptLang team and contributors + */ +package org.skriptlang.skript.test.tests.lang; + +import ch.njol.skript.test.runner.SkriptJUnitTest; +import org.bukkit.Bukkit; +import org.bukkit.entity.Pig; +import org.bukkit.event.entity.EntityDeathEvent; +import org.junit.Test; + +import java.util.ArrayList; + +public class CancelledEventsTest extends SkriptJUnitTest { + + + static { + setShutdownDelay(1); + } + + @Test + public void callCancelledEvent() { + Pig pig = spawnTestPig(); + EntityDeathEvent event = new EntityDeathEvent(pig, new ArrayList<>()); + + // call cancelled event + event.setCancelled(true); + Bukkit.getPluginManager().callEvent(event); + + // call non-cancelled event + event.setCancelled(false); + Bukkit.getPluginManager().callEvent(event); + } + +} + diff --git a/src/test/java/org/skriptlang/skript/test/tests/lang/package-info.java b/src/test/java/org/skriptlang/skript/test/tests/lang/package-info.java new file mode 100644 index 00000000000..ea942bce2d4 --- /dev/null +++ b/src/test/java/org/skriptlang/skript/test/tests/lang/package-info.java @@ -0,0 +1,24 @@ +/** + * This file is part of Skript. + * + * Skript 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, either version 3 of the License, or + * (at your option) any later version. + * + * Skript 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 Skript. If not, see . + * + * Copyright Peter Güttinger, SkriptLang team and contributors + */ +@NonNullByDefault({DefaultLocation.PARAMETER, DefaultLocation.RETURN_TYPE, DefaultLocation.FIELD}) +package org.skriptlang.skript.test.tests.lang; + +import org.eclipse.jdt.annotation.DefaultLocation; +import org.eclipse.jdt.annotation.NonNullByDefault; + diff --git a/src/test/skript/junit/CancelledEventTest.sk b/src/test/skript/junit/CancelledEventTest.sk new file mode 100644 index 00000000000..f7abc9d99bc --- /dev/null +++ b/src/test/skript/junit/CancelledEventTest.sk @@ -0,0 +1,32 @@ +options: + test: "org.skriptlang.skript.test.tests.lang.CancelledEventsTest" + +# TODO: add negative objectives to test if the cancelled listener is called when the event is not cancelled, for example. + +test "ExprDropsJUnit" when running JUnit: + set {_tests::1} to "listen for uncancelled event" + set {_tests::5} to "listen for cancelled event" + set {_tests::6} to "listen for any event" + + ensure junit test {@test} completes {_tests::*} + +on load: + set {-cancelled-event-test::call-count} to 0 + +on death of pig: + junit test is {@test} + + complete objective "listen for uncancelled event" for {@test} + +on cancelled death of pig: + junit test is {@test} + + complete objective "listen for cancelled event" for {@test} + +on any death of pig: + junit test is {@test} + + add 1 to {-cancelled-event-test::call-count} + + if {-cancelled-event-test::call-count} is 2: + complete objective "listen for any event" for {@test}