diff --git a/CLAUDE.md b/CLAUDE.md
index c23cc2c9d..5789b55fe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -126,9 +126,110 @@ Guava (`ImmutableSet`, `ImmutableList`, etc.) is reliably available at runtime v
`Util.componentToLegacy` is therefore **not** a thin wrapper around Adventure's serializer — it's a custom Component walker (`appendComponentLegacy` / `emitStyleTransition`) that tracks the last-emitted color and decorations and inserts `§r` whenever any decoration was on and is now off, then re-applies color afterwards. **Do not replace it with `LegacyComponentSerializer.serialize()` directly** without re-introducing the leak. The round-trip is exercised by `LegacyToMiniMessageTest`.
+### Multi-line strings must be parsed as a single unit
+
+`User.convertToLegacy` parses the **whole** translated string at once — never per-line. MiniMessage tags can span newlines (e.g. a `...\n...` block from a multi-line YAML entry, or a multi-line value substituted into a `[description]` template). Splitting on `\n` before parsing orphans close tags: the line `bar` has no opening, and MiniMessage renders `` as **literal text** in the lore. Adventure preserves newlines through `text.content()`, so a single parse handles everything correctly.
+
+### Locale templates: do not wrap placeholders in MiniMessage tags
+
+A template like `[description]` looks harmless but is a trap. Translation placeholders are substituted **as legacy `§`-coded strings** before re-parsing, and they may contain their own colors and newlines. Wrapping them re-introduces the multi-line orphaning problem above and forces the wrapper color over content that already has its own. Leave placeholders bare (`[description]`) and let the value bring its own colors. The `protection.panel.flag-item.{description,menu,setting}-layout` keys all follow this rule across every bundled locale.
+
+### Splitting legacy strings on a literal character collapses same-color runs
+
+`componentToLegacy` does not re-emit a color code when an adjacent text segment has the same color — it relies on the §-code carrying over within the contiguous string. Code that takes a translated legacy string and then `.split("\\|")` (or any literal-character split) breaks this carry-over: subsequent segments lose their color prefix and render in default. If a panel uses `|`-as-line-separator on a translated value, it must propagate the active `§color`/`§format` codes across the split itself, or set lore via Adventure `Component`s instead of legacy `String`s. (See `addon-level/.../DonationPanel.java#splitWithStyleCarryover` for a working pattern.) Bukkit's deprecated `meta.setLore(List)` also does not suppress Minecraft's default lore italic — `meta.lore(List)` with the `removeDefaultItalic` helper does.
+
## Build Notes
- The Gradle build uses the Paper `userdev` plugin and Shadow plugin to produce a fat/shaded JAR at `build/libs/BentoBox-{version}.jar`.
- `plugin.yml` and `config.yml` are filtered for the `${version}` placeholder at build time; locale files are copied without filtering.
- Java preview features are enabled for both compilation and test execution.
- Local builds produce version `3.13.0-LOCAL-SNAPSHOT`; CI builds append `-b{BUILD_NUMBER}-SNAPSHOT`; `origin/master` builds produce the bare version.
+
+## Dependency Source Lookup
+
+When you need to inspect source code for a dependency (e.g., BentoBox, addons):
+
+1. **Check local Maven repo first**: `~/.m2/repository/` — sources jars are named `*-sources.jar`
+2. **Check the workspace**: Look for sibling directories or Git submodules that may contain the dependency as a local project (e.g., `../bentoBox`, `../addon-*`)
+3. **Check Maven local cache for already-extracted sources** before downloading anything
+4. Only download a jar or fetch from the internet if the above steps yield nothing useful
+
+Prefer reading `.java` source files directly from a local Git clone over decompiling or extracting a jar.
+
+In general, the latest version of BentoBox should be targeted.
+
+## Project Layout
+
+Related projects are checked out as siblings under `~/git/`:
+
+**Core:**
+- `bentobox/` — core BentoBox framework
+
+**Game modes:**
+- `addon-acidisland/` — AcidIsland game mode
+- `addon-bskyblock/` — BSkyBlock game mode
+- `Boxed/` — Boxed game mode (expandable box area)
+- `CaveBlock/` — CaveBlock game mode
+- `OneBlock/` — AOneBlock game mode
+- `SkyGrid/` — SkyGrid game mode
+- `RaftMode/` — Raft survival game mode
+- `StrangerRealms/` — StrangerRealms game mode
+- `Brix/` — plot game mode
+- `parkour/` — Parkour game mode
+- `poseidon/` — Poseidon game mode
+- `gg/` — gg game mode
+
+**Addons:**
+- `addon-level/` — island level calculation
+- `addon-challenges/` — challenges system
+- `addon-welcomewarpsigns/` — warp signs
+- `addon-limits/` — block/entity limits
+- `addon-invSwitcher/` / `invSwitcher/` — inventory switcher
+- `addon-biomes/` / `Biomes/` — biomes management
+- `Bank/` — island bank
+- `Border/` — world border for islands
+- `Chat/` — island chat
+- `CheckMeOut/` — island submission/voting
+- `ControlPanel/` — game mode control panel
+- `Converter/` — ASkyBlock to BSkyBlock converter
+- `DimensionalTrees/` — dimension-specific trees
+- `discordwebhook/` — Discord integration
+- `Downloads/` — BentoBox downloads site
+- `DragonFights/` — per-island ender dragon fights
+- `ExtraMobs/` — additional mob spawning rules
+- `FarmersDance/` — twerking crop growth
+- `GravityFlux/` — gravity addon
+- `Greenhouses-addon/` — greenhouse biomes
+- `IslandFly/` — island flight permission
+- `IslandRankup/` — island rankup system
+- `Likes/` — island likes/dislikes
+- `Limits/` — block/entity limits
+- `lost-sheep/` — lost sheep adventure
+- `MagicCobblestoneGenerator/` — custom cobblestone generator
+- `PortalStart/` — portal-based island start
+- `pp/` — pp addon
+- `Regionerator/` — region management
+- `Residence/` — residence addon
+- `TopBlock/` — top ten for OneBlock
+- `TwerkingForTrees/` — twerking tree growth
+- `Upgrades/` — island upgrades (Vault)
+- `Visit/` — island visiting
+- `weblink/` — web link addon
+- `CrowdBound/` — CrowdBound addon
+
+**Data packs:**
+- `BoxedDataPack/` — advancement datapack for Boxed
+
+**Documentation & tools:**
+- `docs/` — main documentation site
+- `docs-chinese/` — Chinese documentation
+- `docs-french/` — French documentation
+- `BentoBoxWorld.github.io/` — GitHub Pages site
+- `website/` — website
+- `translation-tool/` — translation tool
+
+Check these for source before any network fetch.
+
+## Key Dependencies (source locations)
+
+- `world.bentobox:bentobox` → `~/git/bentobox/src/`
diff --git a/README.md b/README.md
index 6994a485a..66119c4d9 100644
--- a/README.md
+++ b/README.md
@@ -7,18 +7,39 @@
[](https://sonarcloud.io/dashboard?id=BentoBoxWorld_BentoBox)
[](https://sonarcloud.io/dashboard?id=BentoBoxWorld_BentoBox)
-## About BentoBox
+# SkyBlock, OneBlock, AcidIsland, and more - all in one plugin
-### Description
+[](https://discord.bentobox.world)
+
+BentoBox powers island-style game modes for Paper servers. Pick the game modes you want, drop them in, and you're running. No forks, no outdated code — one actively maintained platform that stays current with every Minecraft release.
+
+**Game modes available:**
+
+- **BSkyBlock** — classic SkyBlock, successor to the original ASkyBlock
+- **AOneBlock** — the popular OneBlock experience
+- **AcidIsland** — survive in a sea of acid
+- **Boxed** — expand your world by completing advancements
+- **CaveBlock** — underground survival
+- **SkyGrid** — scattered blocks, maximum adventure
+- **Poseidon** — underwater island challenge
+- And more community-created game modes
+
+**Why server admins choose BentoBox:**
-BentoBox is a powerful Bukkit library plugin that provides core features for island-style games like SkyBlock, AcidIsland, SkyGrid and others.
-These games are added to it via its **unique Addon system**. Further, non-game addons can provide features across games, such as challenges or warps. This enables admins to mix and match games and features to customize their server. It also enables the same code to be run
-across games, reducing bugs and speeding updates across all games. For coders,
-BentoBox has a **powerful API** allows for quick and easy development of these addons and simplifies complex aspects such as island protection, GUIs, and team management.
+- Run multiple game modes on one server with shared features (challenges, warps, levels, leaderboards)
+- 20+ addons let you customize exactly the experience you want
+- Actively maintained and always up to date with the latest Minecraft version
+- Free and open source — used on 1,100+ servers worldwide
+- Rich API for developers who want to build custom addons
-BentoBox is **[free](https://www.gnu.org/philosophy/free-sw.en.html) and open-source software** so join us to make this platform grow, become even more powerful and popular! Admins can pay to support BentoBox and Addons via donations and sponsorship.
+[Full Documentation](https://docs.bentobox.world)
-Start now to create the server you've dreamed of!
+# Installation
+
+1. Place the BentoBox jar in your plugins folder
+2. Start the server
+3. Download the game mode and feature addons you want from [this site](https://hangar.papermc.io/BentoboxWorld/) or [download.bentobox.world](https://download.bentobox.world) and place them in the `plugins/BentoBox/addons` folder
+4. Restart the server — you're good to go
## Addons
These are some popular Gamemodes:
@@ -39,13 +60,8 @@ There are also plenty of other official or community-made Addons you can try and
* Start reading: [https://docs.bentobox.world](https://docs.bentobox.world)
* For developers: [Javadocs](https://ci.codemc.io/job/BentoBoxWorld/job/BentoBox/ws/target/apidocs/index.html)
-## Downloads
-
-### Webtool
-A [webtool](https://download.bentobox.world/) is currently being developed to allow you to easily setup BentoBox and Addons on your server.
-
-### Direct links
-* [Download](https://github.com/BentoBoxWorld/BentoBox/releases)
+## Bugs or Issues
+[File bugs on GitHub](https://github.com/BentoBoxWorld/BentoBox/issues). Confused? Ask on Discord. Note: we are **not** a company, so please be kind with your requests.
### Developers
* [Jenkins](https://ci.codemc.org/job/BentoBoxWorld/job/BentoBox/) (**untested and mostly unstable builds**)
@@ -116,12 +132,3 @@ dependencies {
```
**Note:** Due to a Gradle issue with versions for Maven, you need to use -SNAPSHOT at the end.
-### History
-
-[tastybento](https://github.com/tastybento) created ASkyBlock and AcidIsland that shared the same codebase. These plugins became very popular but became hard to maintain.
-[Poslovitch](https://github.com/Poslovitch) was running a Skyblock server before starting to contribute regularly to ASkyBlock's codebase. He proposed the idea of completely rewriting ASkyBlock
-to make it easier to maintain and richer in features. In May 2017, this became the *BSkyBlock* project. As development progressed it became clear that a lot of the new core features could be used by other
-island-style games and so that core functionality was split off and renamed *BentoBox* and the addon system was created. The addons for BSkyBlock and AcidIsland became very simple to develop and much smaller.
-The community started to grow and we added new game modes like SkyGrid and CaveBlock by BONNe. BONNe also took over maintenance of Challenges and Biomes and contributed to other addons.
-
-In December 2019, Poslovitch launched the BentoBox collection on SpigotMC and the story continues!
diff --git a/build.gradle.kts b/build.gradle.kts
index cb3a31791..3c4a888d8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -46,7 +46,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt
group = "world.bentobox" // From
// Base properties from
-val buildVersion = "3.14.0"
+val buildVersion = "3.14.1"
val buildNumberDefault = "-LOCAL" // Local build identifier
val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version
diff --git a/src/main/java/world/bentobox/bentobox/api/flags/Flag.java b/src/main/java/world/bentobox/bentobox/api/flags/Flag.java
index bd38dc545..0b9680388 100644
--- a/src/main/java/world/bentobox/bentobox/api/flags/Flag.java
+++ b/src/main/java/world/bentobox/bentobox/api/flags/Flag.java
@@ -143,6 +143,7 @@ public enum HideWhen {
private final Type type;
private boolean setting;
private final int defaultRank;
+ private final int minimumRank;
private final PanelItem.ClickHandler clickHandler;
private final boolean subPanel;
private Set gameModes = new HashSet<>();
@@ -161,6 +162,7 @@ private Flag(Builder builder) {
this.type = builder.type;
this.setting = builder.defaultSetting;
this.defaultRank = builder.defaultRank;
+ this.minimumRank = builder.minimumRank;
this.clickHandler = builder.clickHandler;
this.subPanel = builder.usePanel;
if (builder.gameModeAddon != null) {
@@ -284,6 +286,23 @@ public int getDefaultRank() {
return defaultRank;
}
+ /**
+ * @return the minimum rank that may be selected for this flag in the settings
+ * cycle click. Defaults to {@link RanksManager#VISITOR_RANK}.
+ * @since 3.13.0
+ */
+ public int getMinimumRank() {
+ return minimumRank;
+ }
+
+ /**
+ * @return the click handler associated with this flag's panel item
+ * @since 3.13.0
+ */
+ public PanelItem.ClickHandler getClickHandler() {
+ return clickHandler;
+ }
+
/**
* @return whether the flag uses a subpanel or not
*/
@@ -592,6 +611,7 @@ public static class Builder {
// Default settings
private boolean defaultSetting = false;
private int defaultRank = RanksManager.MEMBER_RANK;
+ private int minimumRank = RanksManager.VISITOR_RANK;
// ClickHandler - default depends on the type
private PanelItem.ClickHandler clickHandler;
@@ -677,6 +697,19 @@ public Builder defaultRank(int defaultRank) {
return this;
}
+ /**
+ * Set the minimum rank that may be selected for this {@link Type#PROTECTION} flag
+ * in the settings cycle click. The default is {@link RanksManager#VISITOR_RANK}.
+ * The cycle click listener will not allow ranks below this value to be chosen.
+ * @param minimumRank minimum rank value (e.g. {@link RanksManager#MEMBER_RANK})
+ * @return Builder
+ * @since 3.13.0
+ */
+ public Builder minimumRank(int minimumRank) {
+ this.minimumRank = minimumRank;
+ return this;
+ }
+
/**
* Set that this flag icon will open up a sub-panel
* @param usePanel - true or false
@@ -764,12 +797,18 @@ public Builder hideWhen(HideWhen hideWhen) {
* @return Flag
*/
public Flag build() {
+ // Ensure the default rank is not below the minimum selectable rank
+ if (defaultRank < minimumRank) {
+ BentoBox.getInstance().logWarning("Flag " + id + " defaultRank (" + defaultRank
+ + ") is below minimumRank (" + minimumRank + "); raising defaultRank to minimumRank.");
+ defaultRank = minimumRank;
+ }
// If no clickHandler has been set, then apply default ones
if (clickHandler == null) {
clickHandler = switch (type) {
case SETTING -> new IslandToggleClick(id);
case WORLD_SETTING -> new WorldToggleClick(id);
- default -> new CycleClick(id);
+ default -> new CycleClick(id, minimumRank, RanksManager.OWNER_RANK);
};
}
Flag flag = new Flag(this);
diff --git a/src/main/java/world/bentobox/bentobox/api/user/User.java b/src/main/java/world/bentobox/bentobox/api/user/User.java
index 935335929..ec4fb3ab7 100644
--- a/src/main/java/world/bentobox/bentobox/api/user/User.java
+++ b/src/main/java/world/bentobox/bentobox/api/user/User.java
@@ -1,6 +1,5 @@
package world.bentobox.bentobox.api.user;
-import java.util.Arrays;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashMap;
@@ -509,34 +508,29 @@ public String getTranslation(String reference, String... variables) {
* @return legacy §-coded string
*/
private String convertToLegacy(String raw) {
- // Process each line independently to preserve newlines
- if (raw.contains("\n")) {
- return Arrays.stream(raw.split("\n", -1))
- .map(this::convertLineToLegacy)
- .collect(Collectors.joining("\n"));
- }
- return convertLineToLegacy(raw);
- }
-
- private String convertLineToLegacy(String line) {
- boolean hasLegacy = Util.isLegacyFormat(line);
- boolean hasMiniMessage = line.contains("<") && line.contains(">");
+ boolean hasLegacy = Util.isLegacyFormat(raw);
+ boolean hasMiniMessage = raw.contains("<") && raw.contains(">");
if (hasLegacy && !hasMiniMessage) {
// Pure legacy — use the old path
@SuppressWarnings("deprecation")
- String result = Util.translateColorCodes(line);
+ String result = Util.translateColorCodes(raw);
return result;
}
+ // Process the whole string at once (not per line). MiniMessage tags can span
+ // newlines — splitting first would orphan close tags (e.g. foo\nbar
+ // becomes "foo" and "bar", and "" with no opening would
+ // be rendered as literal text). Component text preserves newlines through
+ // serialization, so a single parse is correct.
if (hasLegacy) {
// Mixed content: MiniMessage tags + legacy & codes.
// Replace legacy codes with MiniMessage opening tags inline (no closing tags).
// MiniMessage handles unclosed tags correctly — they apply until overridden.
// Using legacyToMiniMessage() would produce wrong nesting (e.g.,
// text where leaks as literal text).
- line = Util.replaceLegacyCodesInline(line);
+ raw = Util.replaceLegacyCodesInline(raw);
}
// Parse as MiniMessage and serialize to legacy
- return Util.componentToLegacy(Util.parseMiniMessage(line));
+ return Util.componentToLegacy(Util.parseMiniMessage(raw));
}
/**
diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml
index 782a760e2..8e036d1bf 100644
--- a/src/main/resources/locales/cs.yml
+++ b/src/main/resources/locales/cs.yml
@@ -1820,16 +1820,16 @@ protection:
Vyberte hodnost, která může
použít příkaz Border
description-layout: |
- [description]
+ [description]
Povoleno pro:
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'Nastavení má cooldown'
setting-layout: |
- [description]
+ [description]
Nynější nastavení: [setting]
setting-active: 'Povoleno'
diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml
index a859aee8f..b9bc13fbf 100644
--- a/src/main/resources/locales/de.yml
+++ b/src/main/resources/locales/de.yml
@@ -1898,16 +1898,16 @@ protection:
Wähle den Rang, der
den Border-Befehl nutzen darf
description-layout: |-
- [description]
+ [description]
Erlaubt für:
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'Einstellung ist auf Abklingzeit'
setting-layout: |-
- [description]
+ [description]
Aktuelle Einstellung: [setting]
setting-active: 'Aktiviert'
diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml
index 3743546e7..59416f7bc 100644
--- a/src/main/resources/locales/en-US.yml
+++ b/src/main/resources/locales/en-US.yml
@@ -1509,8 +1509,8 @@ protection:
obsidian-nearby: 'There are obsidian blocks within a [radius]-block radius
of this obsidian. You cannot scoop it up into lava.'
lavaTip: |-
- Scoop this up with a bucket
- as lava again if you need it!
+ Scoop this up with a bucket
+ as lava again if you need it!
OFFLINE_GROWTH:
description: |-
When disabled, plants
@@ -1841,7 +1841,7 @@ protection:
Select the rank that can
use the border command
description-layout: |
- [description]
+ [description]
Left Click to cycle downwards.
Right Click to cycle upwards.
@@ -1851,12 +1851,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Click to open.
setting-cooldown: 'Setting is on cooldown'
setting-layout: |
- [description]
+ [description]
Click to toggle.
diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml
index 29a407b73..d86a31332 100644
--- a/src/main/resources/locales/es.yml
+++ b/src/main/resources/locales/es.yml
@@ -1862,15 +1862,15 @@ protection:
Selecciona el rango que puede
usar el comando de borde
description-layout: |
- [description]
+ [description]
Permitido para:
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'La configuración está en enfriamiento'
setting-layout: |
- [description]
+ [description]
Configuración actual: [setting]
setting-active: 'Activo'
diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml
index 493430c7c..6a4d37312 100644
--- a/src/main/resources/locales/fr.yml
+++ b/src/main/resources/locales/fr.yml
@@ -1899,16 +1899,16 @@ protection:
Sélectionnez le grade qui peut
utiliser la commande de bordure
description-layout: |-
- [description]
+ [description]
Autorisé pour :
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'Le paramètre est en cours de recharge'
setting-layout: |-
- [description]
+ [description]
Réglage actuel : [setting]
setting-active: 'actif'
diff --git a/src/main/resources/locales/hr.yml b/src/main/resources/locales/hr.yml
index c1e1d09a1..b13e8df03 100644
--- a/src/main/resources/locales/hr.yml
+++ b/src/main/resources/locales/hr.yml
@@ -1860,7 +1860,7 @@ protection:
Odaberite rang koji može
koristiti naredbu za granicu
description-layout: |
- [description]
+ [description]
Lijevi klik za kretanje prema dolje.
Desni klik za kretanje prema gore.
@@ -1870,12 +1870,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Kliknite za otvaranje.
setting-cooldown: 'Postavka je na hlađenju'
setting-layout: |
- [description]
+ [description]
Kliknite za prebacivanje.
diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml
index 6883d499a..3cead1b17 100644
--- a/src/main/resources/locales/hu.yml
+++ b/src/main/resources/locales/hu.yml
@@ -1921,7 +1921,7 @@ protection:
Válaszd ki azt a rangot, amelyik
használhatja a határ parancsot
description-layout: |
- [description]
+ [description]
Bal klikk a gombra a lefelé léptetéshez.
Kattintson a jobb gombbal a gombra a felfelé lépéshez.
@@ -1931,12 +1931,12 @@ protection:
blocked-rank: '- stb'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Kattintson a gombra a megnyitáshoz.
setting-cooldown: 'A beállítás lehűlés alatt van'
setting-layout: |
- [description]
+ [description]
Kattintson a gombra a váltáshoz.
diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml
index f92dbcadb..f89313c32 100644
--- a/src/main/resources/locales/id.yml
+++ b/src/main/resources/locales/id.yml
@@ -1886,7 +1886,7 @@ protection:
Pilih peringkat yang dapat
menggunakan perintah batas
description-layout: |
- [description]
+ [description]
Klik Kiri untuk beralih ke bawah.
Klik Kanan untuk beralih ke atas.
@@ -1896,12 +1896,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Klik untuk membuka.
setting-cooldown: 'Pengaturan sedang dalam cooldown'
setting-layout: |
- [description]
+ [description]
Klik untuk beralih.
diff --git a/src/main/resources/locales/it.yml b/src/main/resources/locales/it.yml
index e89c44c29..9be1e9337 100644
--- a/src/main/resources/locales/it.yml
+++ b/src/main/resources/locales/it.yml
@@ -1873,7 +1873,7 @@ protection:
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'Impostazioni in cooldown'
setting-layout: '[description] Impostazione corrente: [setting]'
setting-active: 'Attivo'
diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml
index afaa3a039..44f8c5b59 100644
--- a/src/main/resources/locales/ja.yml
+++ b/src/main/resources/locales/ja.yml
@@ -1729,16 +1729,16 @@ protection:
できるランクを選択します
Borderコマンドを使用します
description-layout: |
- [description]
+ [description]
許可:
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'クールダウン中です'
setting-layout: |
- [description]
+ [description]
現在の設定: [setting]
setting-active: 有効
diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml
index 5b492e2b5..d0495a0c0 100644
--- a/src/main/resources/locales/ko.yml
+++ b/src/main/resources/locales/ko.yml
@@ -1734,7 +1734,7 @@ protection:
경계를 설정할 수 있는
순위를 선택하세요
description-layout: |
- [description]
+ [description]
좌클릭으로 아래쪽으로 순환합니다.
우클릭으로 위쪽으로 순환합니다.
@@ -1744,12 +1744,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
클릭하여 엽니다.
setting-cooldown: '설정이 재사용 대기 중임'
setting-layout: |
- [description]
+ [description]
클릭하여 설정합니다.
diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml
index 07ac0420f..990190052 100644
--- a/src/main/resources/locales/lv.yml
+++ b/src/main/resources/locales/lv.yml
@@ -1887,16 +1887,16 @@ protection:
Izvēlies rangu, kas var
izmantot robežas komandu
description-layout: |
- [description]
+ [description]
Atļauts priekš:
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'Iestatījumu maiņa ir ierobežota.'
setting-layout: |
- [description]
+ [description]
Šībrīža iestatījumi: [setting]
setting-active: 'Aktīvs'
diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml
index 555db92a2..cdc0f8f07 100644
--- a/src/main/resources/locales/nl.yml
+++ b/src/main/resources/locales/nl.yml
@@ -1907,7 +1907,7 @@ protection:
Selecteer de rang die
het bordercommando kan gebruiken
description-layout: |
- [description]
+ [description]
Links klikken om naar beneden te bladeren.
Klik met de rechtermuisknop om omhoog te bladeren.
@@ -1917,12 +1917,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Klik op om te openen.
setting-cooldown: 'Instelling staat op afkoeling'
setting-layout: |
- [description]
+ [description]
Klik op om te schakelen.
diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml
index c172f6add..ce56edb80 100644
--- a/src/main/resources/locales/pl.yml
+++ b/src/main/resources/locales/pl.yml
@@ -1840,16 +1840,16 @@ protection:
Wybierz rangę, która może
używać komendy granicy
description-layout: |
- [description]
+ [description]
Dozwolony dla:
allowed-rank: '- [rank]'
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'To ustawienie jest na cooldownie.'
setting-layout: |
- [description]
+ [description]
Obecne ustawienie: [setting]
setting-active: 'Aktywny'
diff --git a/src/main/resources/locales/pt-BR.yml b/src/main/resources/locales/pt-BR.yml
index 488e173bc..a883cdbd2 100644
--- a/src/main/resources/locales/pt-BR.yml
+++ b/src/main/resources/locales/pt-BR.yml
@@ -1855,7 +1855,7 @@ protection:
Selecione a classificação que pode
usar o comando de borda
description-layout: |
- [description]
+ [description]
Clique Esquerdo para alternar para baixo.
Clique Direito para alternar para cima.
@@ -1865,12 +1865,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Clique para abrir.
setting-cooldown: 'A configuração está em cooldown'
setting-layout: |
- [description]
+ [description]
Clique para alternar.
diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml
index 1f1001233..e7dfc57ff 100644
--- a/src/main/resources/locales/pt.yml
+++ b/src/main/resources/locales/pt.yml
@@ -1880,7 +1880,7 @@ protection:
Selecione a classificação que pode
usar o comando de borda
description-layout: |
- [description]
+ [description]
Clique com o botão esquerdo para descer.
Clique com o botão direito para avançar.
@@ -1890,12 +1890,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Clique em para abrir.
setting-cooldown: 'A configuração está em espera'
setting-layout: |
- [description]
+ [description]
Clique em para alternar.
diff --git a/src/main/resources/locales/ro.yml b/src/main/resources/locales/ro.yml
index 303db0932..fdb5ddfd3 100644
--- a/src/main/resources/locales/ro.yml
+++ b/src/main/resources/locales/ro.yml
@@ -1898,7 +1898,7 @@ protection:
Selectați rangul care poate
utilizați comanda border
description-layout: |
- [description]
+ [description]
Faceți clic stânga pentru a merge în jos.
Faceți clic dreapta pe pentru a merge în sus.
@@ -1908,12 +1908,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Faceți clic pe pentru a deschide.
setting-cooldown: 'Setarea este activată'
setting-layout: |
- [description]
+ [description]
Faceți clic pe pentru a comuta.
diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml
index 2f9bfe3b4..64920c52d 100644
--- a/src/main/resources/locales/tr.yml
+++ b/src/main/resources/locales/tr.yml
@@ -1752,16 +1752,16 @@ protection:
Sınır komutunu
kullanabilecek rütbeyi seçin
description-layout: |
- [description]
+ [description]
İzin verilenler:
allowed-rank: '- [rank]'
blocked-rank: '- '
minimal-rank: '- [rank]'
- menu-layout: '[description]'
+ menu-layout: '[description]'
setting-cooldown: 'Ayar bekleme süresinde!'
setting-layout: |
- [description]
+ [description]
Geçerli ayar: [setting]
setting-active: 'Aktif'
diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml
index 9c9559f51..e6cb772a5 100644
--- a/src/main/resources/locales/uk.yml
+++ b/src/main/resources/locales/uk.yml
@@ -1761,7 +1761,7 @@ protection:
Оберіть ранг, який може
використовувати команду межі
description-layout: |
- [description]
+ [description]
ЛКМ — перегортати вниз.
ПКМ — перегортати вгору.
@@ -1771,12 +1771,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Клік для відкриття.
setting-cooldown: 'Налаштування на кулдау́ні'
setting-layout: |
- [description]
+ [description]
Клік для перемикання.
diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml
index a43b67386..27f3ce995 100644
--- a/src/main/resources/locales/vi.yml
+++ b/src/main/resources/locales/vi.yml
@@ -1835,7 +1835,7 @@ protection:
Chọn cấp bậc có thể
sử dụng lệnh biên giới
description-layout: |
- [description]
+ [description]
Chuột trái để cuộn xuống.
Chuột phải để cuộn lên.
@@ -1845,12 +1845,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
Nhấp để mở.
setting-cooldown: 'Tùy chỉnh đang trong thời gian chờ'
setting-layout: |
- [description]
+ [description]
Nhấp để chỉnh.
diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml
index 2f08bcb2c..c19b70174 100644
--- a/src/main/resources/locales/zh-CN.yml
+++ b/src/main/resources/locales/zh-CN.yml
@@ -1681,7 +1681,7 @@ protection:
更改阶段的等级
border: '选择可以使用边界命令的等级'
description-layout: |-
- [description]
+ [description]
左键 向下循环选择
右键 向上循环选择
@@ -1691,12 +1691,12 @@ protection:
blocked-rank: '- [rank]'
minimal-rank: '- [rank]'
menu-layout: |
- [description]
+ [description]
点击 打开配置界面
setting-cooldown: '设置正在冷却中'
setting-layout: |
- [description]
+ [description]
点击 切换状态
diff --git a/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java b/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java
index f1cf3429b..4dc4f167e 100644
--- a/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java
+++ b/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java
@@ -38,6 +38,7 @@
import world.bentobox.bentobox.RanksManagerTestSetup;
import world.bentobox.bentobox.api.addons.GameModeAddon;
import world.bentobox.bentobox.api.configuration.WorldSettings;
+import world.bentobox.bentobox.api.flags.clicklisteners.CycleClick;
import world.bentobox.bentobox.api.panels.PanelItem;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.objects.Island;
@@ -397,4 +398,56 @@ void testCompareTo() {
assertTrue(aaa.compareTo(bbb) < bbb.compareTo(aaa));
assertEquals(0, aaa.compareTo(aaa));
}
+
+ /**
+ * Test method for {@link world.bentobox.bentobox.api.flags.Flag#getMinimumRank()}.
+ */
+ @Test
+ void testMinimumRankDefaultsToVisitor() {
+ Flag flag = new Flag.Builder("minDefault", Material.ACACIA_DOOR).type(Flag.Type.PROTECTION).build();
+ assertEquals(RanksManager.VISITOR_RANK, flag.getMinimumRank());
+ }
+
+ /**
+ * Test method for {@link world.bentobox.bentobox.api.flags.Flag.Builder#minimumRank(int)}.
+ */
+ @Test
+ void testMinimumRankSetViaBuilder() {
+ Flag flag = new Flag.Builder("minMember", Material.ACACIA_DOOR)
+ .type(Flag.Type.PROTECTION)
+ .minimumRank(RanksManager.MEMBER_RANK)
+ .build();
+ assertEquals(RanksManager.MEMBER_RANK, flag.getMinimumRank());
+ }
+
+ /**
+ * The auto-assigned CycleClick for a PROTECTION flag must be configured with the
+ * Builder's minimumRank, so the click cycle skips ranks below the minimum.
+ */
+ @Test
+ void testMinimumRankPropagatesToCycleClick() throws Exception {
+ Flag flag = new Flag.Builder("minCycle", Material.ACACIA_DOOR)
+ .type(Flag.Type.PROTECTION)
+ .minimumRank(RanksManager.MEMBER_RANK)
+ .build();
+ PanelItem.ClickHandler handler = flag.getClickHandler();
+ assertTrue(handler instanceof CycleClick, "Expected auto-assigned CycleClick handler");
+ java.lang.reflect.Field minRankField = CycleClick.class.getDeclaredField("minRank");
+ minRankField.setAccessible(true);
+ assertEquals(RanksManager.MEMBER_RANK, minRankField.getInt(handler));
+ }
+
+ /**
+ * If defaultRank is set below minimumRank, build() should clamp it up to minimumRank
+ * so the flag's default value is selectable.
+ */
+ @Test
+ void testDefaultRankClampedToMinimumRank() {
+ Flag flag = new Flag.Builder("clamp", Material.ACACIA_DOOR)
+ .type(Flag.Type.PROTECTION)
+ .defaultRank(RanksManager.VISITOR_RANK)
+ .minimumRank(RanksManager.MEMBER_RANK)
+ .build();
+ assertEquals(RanksManager.MEMBER_RANK, flag.getDefaultRank());
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/blueprints/BlueprintClipboardTest.java b/src/test/java/world/bentobox/bentobox/blueprints/BlueprintClipboardTest.java
index 5655e459b..28f3a97c1 100644
--- a/src/test/java/world/bentobox/bentobox/blueprints/BlueprintClipboardTest.java
+++ b/src/test/java/world/bentobox/bentobox/blueprints/BlueprintClipboardTest.java
@@ -4,6 +4,8 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -14,6 +16,8 @@
import java.util.Optional;
import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import org.eclipse.jdt.annotation.NonNull;
@@ -206,4 +210,203 @@ void testSetBlueprint() {
assertEquals(blueprint, bc.getBlueprint());
}
+ // ---- copy edge cases ----
+
+ @Test
+ void testCopyNoPos1() {
+ bc.setPos2(new Location(world, 10, 64, 10));
+ assertFalse(bc.copy(user, false, false, false));
+ verify(user).sendMessage("commands.admin.blueprint.need-pos1-pos2");
+ }
+
+ @Test
+ void testCopyNoPos2() {
+ bc.setPos1(new Location(world, 0, 64, 0));
+ assertFalse(bc.copy(user, false, false, false));
+ verify(user).sendMessage("commands.admin.blueprint.need-pos1-pos2");
+ }
+
+ @Test
+ void testCopyWithExplicitOriginNullWorld() {
+ // When origin is set and world is null, copy should return false
+ Location noWorldLoc1 = mock(Location.class);
+ when(noWorldLoc1.getWorld()).thenReturn(null);
+ when(noWorldLoc1.getBlockY()).thenReturn(64);
+ Location noWorldLoc2 = mock(Location.class);
+ when(noWorldLoc2.getWorld()).thenReturn(null);
+ when(noWorldLoc2.getBlockY()).thenReturn(74);
+
+ // Bypass setPos1/setPos2 which would clear origin
+ // Set origin first, then use direct field access via setPos methods
+ bc.setPos1(noWorldLoc1);
+ bc.setPos2(noWorldLoc2);
+ bc.setOrigin(new Vector(0, 64, 0)); // Set origin to avoid user.getLocation() call
+ assertFalse(bc.copy(user, false, false, false));
+ }
+
+ @Test
+ void testCopySuccess() {
+ when(world.getMinHeight()).thenReturn(-64);
+ when(world.getMaxHeight()).thenReturn(320);
+
+ // Set up pos1 and pos2 with a real world
+ bc.setPos1(new Location(world, 0, 64, 0));
+ bc.setPos2(new Location(world, 2, 66, 2));
+
+ // Mock the user location for origin
+ Location userLoc = mock(Location.class);
+ when(userLoc.toVector()).thenReturn(new Vector(1, 65, 1));
+ when(user.getLocation()).thenReturn(userLoc);
+
+ // Mock async task
+ when(sch.runTaskAsynchronously(any(), any(Runnable.class))).thenAnswer(inv -> {
+ ((Runnable) inv.getArgument(1)).run();
+ return mock(BukkitTask.class);
+ });
+ // Mock the copy task timer
+ when(sch.runTaskTimer(any(), any(Runnable.class), Mockito.anyLong(), Mockito.anyLong()))
+ .thenReturn(mock(BukkitTask.class));
+
+ assertTrue(bc.copy(user, false, false, false));
+ verify(user).sendMessage("commands.admin.blueprint.copying");
+ }
+
+ @Test
+ void testCopyWithOriginAlreadySet() {
+ when(world.getMinHeight()).thenReturn(-64);
+ when(world.getMaxHeight()).thenReturn(320);
+
+ bc.setPos1(new Location(world, 0, 64, 0));
+ bc.setPos2(new Location(world, 2, 66, 2));
+ bc.setOrigin(new Vector(0, 64, 0));
+
+ when(sch.runTaskAsynchronously(any(), any(Runnable.class))).thenAnswer(inv -> {
+ ((Runnable) inv.getArgument(1)).run();
+ return mock(BukkitTask.class);
+ });
+ when(sch.runTaskTimer(any(), any(Runnable.class), Mockito.anyLong(), Mockito.anyLong()))
+ .thenReturn(mock(BukkitTask.class));
+
+ assertTrue(bc.copy(user, true, true, false));
+ // Origin was already set, so user.getLocation() should NOT be called
+ verify(user, never()).getLocation();
+ }
+
+ // ---- setPos1 / setPos2 height clamping ----
+
+ @Test
+ void testSetPos1ClampsToMinHeight() {
+ when(world.getMinHeight()).thenReturn(0);
+ when(world.getMaxHeight()).thenReturn(256);
+
+ Location l = new Location(world, 10, -10, 10);
+ bc.setPos1(l);
+ assertEquals(0, bc.getPos1().getBlockY());
+ }
+
+ @Test
+ void testSetPos1ClampsToMaxHeight() {
+ when(world.getMinHeight()).thenReturn(0);
+ when(world.getMaxHeight()).thenReturn(256);
+
+ Location l = new Location(world, 10, 300, 10);
+ bc.setPos1(l);
+ assertEquals(256, bc.getPos1().getBlockY());
+ }
+
+ @Test
+ void testSetPos2ClampsToMinHeight() {
+ when(world.getMinHeight()).thenReturn(-64);
+ when(world.getMaxHeight()).thenReturn(320);
+
+ Location l = new Location(world, 10, -100, 10);
+ bc.setPos2(l);
+ assertEquals(-64, bc.getPos2().getBlockY());
+ }
+
+ @Test
+ void testSetPos2ClampsToMaxHeight() {
+ when(world.getMinHeight()).thenReturn(-64);
+ when(world.getMaxHeight()).thenReturn(320);
+
+ Location l = new Location(world, 10, 400, 10);
+ bc.setPos2(l);
+ assertEquals(320, bc.getPos2().getBlockY());
+ }
+
+ @Test
+ void testSetPos1NullWorld() {
+ // Null world should use defaults: min=0, max=255
+ Location l = new Location(null, 10, -5, 10);
+ bc.setPos1(l);
+ assertEquals(0, bc.getPos1().getBlockY());
+ }
+
+ @Test
+ void testSetPos2NullWorld() {
+ Location l = new Location(null, 10, 300, 10);
+ bc.setPos2(l);
+ assertEquals(255, bc.getPos2().getBlockY());
+ }
+
+ @Test
+ void testSetPos1Null() {
+ bc.setPos1(null);
+ assertNull(bc.getPos1());
+ }
+
+ @Test
+ void testSetPos2Null() {
+ bc.setPos2(null);
+ assertNull(bc.getPos2());
+ }
+
+ @Test
+ void testSetPos1ClearsOrigin() {
+ bc.setOrigin(new Vector(1, 2, 3));
+ assertNotNull(bc.getOrigin());
+ bc.setPos1(new Location(world, 0, 64, 0));
+ assertNull(bc.getOrigin());
+ }
+
+ @Test
+ void testSetPos2ClearsOrigin() {
+ bc.setOrigin(new Vector(1, 2, 3));
+ assertNotNull(bc.getOrigin());
+ bc.setPos2(new Location(world, 0, 64, 0));
+ assertNull(bc.getOrigin());
+ }
+
+ // ---- isFull ----
+
+ @Test
+ void testIsFullAfterSetBlueprint() {
+ assertFalse(bc.isFull());
+ bc.setBlueprint(blueprint);
+ assertTrue(bc.isFull());
+ }
+
+ // ---- setBlueprint returns this (fluent) ----
+
+ @Test
+ void testSetBlueprintReturnsSelf() {
+ BlueprintClipboard result = bc.setBlueprint(blueprint);
+ assertEquals(bc, result);
+ }
+
+ // ---- getVectors edge cases ----
+
+ @Test
+ void testGetVectorsSingleBlock() {
+ BoundingBox bb = new BoundingBox(0.5, 0.5, 0.5, 0.5, 0.5, 0.5);
+ List list = bc.getVectors(bb);
+ assertEquals(1, list.size());
+ }
+
+ @Test
+ void testGetVectorsSmallArea() {
+ BoundingBox bb = new BoundingBox(0.5, 0.5, 0.5, 2.5, 2.5, 2.5);
+ List list = bc.getVectors(bb);
+ assertEquals(27, list.size()); // 3x3x3
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java
index 59ac2b99a..418c8dc6c 100644
--- a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java
+++ b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java
@@ -1,17 +1,38 @@
package world.bentobox.bentobox.database.yaml;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.io.File;
+import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.EntityType;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
@@ -23,14 +44,33 @@
class YamlDatabaseHandlerTest extends CommonTestSetup {
/**
- * Minimal DataObject for constructing the handler.
+ * DataObject with various field types for testing serialization/deserialization.
*/
public static class TestDataObject implements DataObject {
private String uniqueId = "test";
+ private String name = "";
+ private int count = 0;
+ private Map scores = new HashMap<>();
+ private Set tags = new HashSet<>();
+ private List items = new java.util.ArrayList<>();
+ private Material material = Material.STONE;
+
@Override
public String getUniqueId() { return uniqueId; }
@Override
public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; }
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public int getCount() { return count; }
+ public void setCount(int count) { this.count = count; }
+ public Map getScores() { return scores; }
+ public void setScores(Map scores) { this.scores = scores; }
+ public Set getTags() { return tags; }
+ public void setTags(Set tags) { this.tags = tags; }
+ public List getItems() { return items; }
+ public void setItems(List items) { this.items = items; }
+ public Material getMaterial() { return material; }
+ public void setMaterial(Material material) { this.material = material; }
}
@Mock
@@ -239,4 +279,379 @@ void testDeserializeEnumZombifiedPiglinDirect() throws Exception {
Object result = deserializeEnumMethod.invoke(handler, "ZOMBIFIED_PIGLIN", EntityType.class);
assertSame(EntityType.class, result.getClass().getDeclaringClass() != null ? result.getClass().getDeclaringClass() : result.getClass());
}
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ // Clean up database folder
+ File dbFolder = new File(plugin.getDataFolder(), "database");
+ if (dbFolder.exists()) {
+ Files.walk(dbFolder.toPath())
+ .map(Path::toFile)
+ .sorted((a, b) -> -a.compareTo(b))
+ .forEach(File::delete);
+ }
+ }
+
+ // ---- serialize() tests via reflection ----
+
+ @Test
+ void testSerializeNull() throws Exception {
+ Method serializeMethod = YamlDatabaseHandler.class.getDeclaredMethod("serialize", Object.class);
+ serializeMethod.setAccessible(true);
+ assertEquals("null", serializeMethod.invoke(handler, (Object) null));
+ }
+
+ @Test
+ void testSerializeUUID() throws Exception {
+ Method serializeMethod = YamlDatabaseHandler.class.getDeclaredMethod("serialize", Object.class);
+ serializeMethod.setAccessible(true);
+ UUID uuid = UUID.randomUUID();
+ assertEquals(uuid.toString(), serializeMethod.invoke(handler, uuid));
+ }
+
+ @Test
+ void testSerializeWorld() throws Exception {
+ Method serializeMethod = YamlDatabaseHandler.class.getDeclaredMethod("serialize", Object.class);
+ serializeMethod.setAccessible(true);
+ when(world.getName()).thenReturn("my_world");
+ assertEquals("my_world", serializeMethod.invoke(handler, world));
+ }
+
+ @Test
+ void testSerializeLocation() throws Exception {
+ Method serializeMethod = YamlDatabaseHandler.class.getDeclaredMethod("serialize", Object.class);
+ serializeMethod.setAccessible(true);
+ mockedUtil.when(() -> Util.getStringLocation(location)).thenReturn("world:10:20:30:0:0");
+ assertEquals("world:10:20:30:0:0", serializeMethod.invoke(handler, location));
+ }
+
+ @Test
+ void testSerializeEnum() throws Exception {
+ Method serializeMethod = YamlDatabaseHandler.class.getDeclaredMethod("serialize", Object.class);
+ serializeMethod.setAccessible(true);
+ // Material implements Keyed, so the Keyed case handles it (lowercase key)
+ assertEquals("diamond", serializeMethod.invoke(handler, Material.DIAMOND));
+ }
+
+ @Test
+ void testSerializePlainObject() throws Exception {
+ Method serializeMethod = YamlDatabaseHandler.class.getDeclaredMethod("serialize", Object.class);
+ serializeMethod.setAccessible(true);
+ assertEquals("hello", serializeMethod.invoke(handler, "hello"));
+ assertEquals(42, serializeMethod.invoke(handler, 42));
+ }
+
+ // ---- objectExists() ----
+
+ @Test
+ void testObjectExistsTrue() {
+ when(connector.uniqueIdExists("TestDataObject", "test-id")).thenReturn(true);
+ assertTrue(handler.objectExists("test-id"));
+ }
+
+ @Test
+ void testObjectExistsFalse() {
+ when(connector.uniqueIdExists("TestDataObject", "missing")).thenReturn(false);
+ assertFalse(handler.objectExists("missing"));
+ }
+
+ // ---- loadObject() ----
+
+ @Test
+ void testLoadObject() throws Exception {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("uniqueId", "loaded-id");
+ config.set("name", "test name");
+ config.set("count", 42);
+ config.set("material", "DIAMOND");
+ when(connector.loadYamlFile(anyString(), eq("mykey"))).thenReturn(config);
+
+ TestDataObject result = handler.loadObject("mykey");
+ assertNotNull(result);
+ assertEquals("loaded-id", result.getUniqueId());
+ assertEquals("test name", result.getName());
+ assertEquals(42, result.getCount());
+ assertEquals(Material.DIAMOND, result.getMaterial());
+ }
+
+ @Test
+ void testLoadObjectWithCollections() throws Exception {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("uniqueId", "coll-test");
+ config.set("name", "");
+ config.set("count", 0);
+ config.set("material", "STONE");
+ // Map
+ config.set("scores.alice", 100);
+ config.set("scores.bob", 200);
+ // Set stored as list
+ config.set("tags", List.of("tag1", "tag2"));
+ // List
+ config.set("items", List.of("item1", "item2", "item3"));
+ when(connector.loadYamlFile(anyString(), eq("coll-test"))).thenReturn(config);
+
+ TestDataObject result = handler.loadObject("coll-test");
+ assertNotNull(result);
+ assertEquals(2, result.getScores().size());
+ assertEquals(100, result.getScores().get("alice"));
+ assertEquals(200, result.getScores().get("bob"));
+ assertEquals(2, result.getTags().size());
+ assertTrue(result.getTags().contains("tag1"));
+ assertEquals(3, result.getItems().size());
+ }
+
+ @Test
+ void testLoadObjectMissingFieldUsesDefault() throws Exception {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("uniqueId", "missing-test");
+ // name not set at all
+ config.set("count", 0);
+ config.set("material", "STONE");
+ when(connector.loadYamlFile(anyString(), eq("missing-test"))).thenReturn(config);
+
+ TestDataObject result = handler.loadObject("missing-test");
+ assertNotNull(result);
+ // name not in config, so keeps its default value
+ assertEquals("", result.getName());
+ }
+
+ // ---- loadObjects() ----
+
+ @Test
+ void testLoadObjectsEmptyFolder() throws Exception {
+ // Create the empty table folder
+ File tableFolder = new File(plugin.getDataFolder(), "database" + File.separator + "TestDataObject");
+ tableFolder.mkdirs();
+
+ List result = handler.loadObjects();
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testLoadObjectsWithFiles() throws Exception {
+ // Create the table folder with a yml file
+ File tableFolder = new File(plugin.getDataFolder(), "database" + File.separator + "TestDataObject");
+ tableFolder.mkdirs();
+ File ymlFile = new File(tableFolder, "obj1.yml");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("uniqueId", "obj1");
+ yaml.set("name", "Object One");
+ yaml.set("count", 5);
+ yaml.set("material", "STONE");
+ yaml.save(ymlFile);
+
+ // Stub the connector to return the config
+ YamlConfiguration loaded = new YamlConfiguration();
+ loaded.set("uniqueId", "obj1");
+ loaded.set("name", "Object One");
+ loaded.set("count", 5);
+ loaded.set("material", "STONE");
+ when(connector.loadYamlFile(anyString(), eq("obj1.yml"))).thenReturn(loaded);
+
+ List result = handler.loadObjects();
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals("obj1", result.getFirst().getUniqueId());
+ }
+
+ // ---- saveObject() ----
+
+ @Test
+ void testSaveObjectNull() throws Exception {
+ CompletableFuture result = handler.saveObject(null);
+ assertFalse(result.join());
+ verify(plugin).logError("YAML database request to store a null.");
+ }
+
+ @Test
+ void testSaveObjectNotDataObject() throws Exception {
+ // Create a handler for a non-DataObject class to test the check
+ // Actually, we can't easily do that since the generic is bound. Instead test with a null cast.
+ // The null case is already tested above, so let's test with a valid DataObject during shutdown
+ when(plugin.isShutdown()).thenReturn(true);
+
+ TestDataObject obj = new TestDataObject();
+ obj.setUniqueId("save-test");
+ obj.setName("Test Save");
+ obj.setCount(10);
+
+ when(connector.saveYamlFile(anyString(), anyString(), anyString(), any())).thenReturn(true);
+
+ CompletableFuture result = handler.saveObject(obj);
+ // During shutdown, processFile runs sync
+ assertTrue(result.join());
+ }
+
+ @Test
+ void testSaveObjectWithCollections() throws Exception {
+ when(plugin.isShutdown()).thenReturn(true);
+
+ TestDataObject obj = new TestDataObject();
+ obj.setUniqueId("coll-save");
+ obj.setName("Collections");
+ obj.setScores(Map.of("alice", 100, "bob", 200));
+ obj.setTags(Set.of("tag1", "tag2"));
+ obj.setItems(List.of("item1", "item2"));
+ obj.setMaterial(Material.DIAMOND);
+
+ when(connector.saveYamlFile(anyString(), anyString(), anyString(), any())).thenReturn(true);
+
+ CompletableFuture result = handler.saveObject(obj);
+ assertTrue(result.join());
+ verify(connector).saveYamlFile(anyString(), anyString(), eq("coll-save"), any());
+ }
+
+ // ---- deleteObject() ----
+
+ @Test
+ void testDeleteObjectNull() throws Exception {
+ handler.deleteObject(null);
+ verify(plugin).logError("YAML database request to delete a null.");
+ }
+
+ @Test
+ void testDeleteObject() throws Exception {
+ when(plugin.isEnabled()).thenReturn(false); // sync delete
+
+ TestDataObject obj = new TestDataObject();
+ obj.setUniqueId("del-test");
+
+ // Create the file so delete can find it
+ File tableFolder = new File(plugin.getDataFolder(), "database" + File.separator + "TestDataObject");
+ tableFolder.mkdirs();
+ File ymlFile = new File(tableFolder, "del-test.yml");
+ ymlFile.createNewFile();
+ assertTrue(ymlFile.exists());
+
+ handler.deleteObject(obj);
+
+ // File should be deleted
+ assertFalse(ymlFile.exists());
+ }
+
+ // ---- deleteID() ----
+
+ @Test
+ void testDeleteID() throws Exception {
+ when(plugin.isEnabled()).thenReturn(false); // sync delete
+
+ // Create the file
+ File tableFolder = new File(plugin.getDataFolder(), "database" + File.separator + "TestDataObject");
+ tableFolder.mkdirs();
+ File ymlFile = new File(tableFolder, "delete-me.yml");
+ ymlFile.createNewFile();
+ assertTrue(ymlFile.exists());
+
+ handler.deleteID("delete-me");
+
+ assertFalse(ymlFile.exists());
+ }
+
+ @Test
+ void testDeleteIDWithYmlSuffix() throws Exception {
+ when(plugin.isEnabled()).thenReturn(false); // sync delete
+
+ File tableFolder = new File(plugin.getDataFolder(), "database" + File.separator + "TestDataObject");
+ tableFolder.mkdirs();
+ File ymlFile = new File(tableFolder, "already-has.yml");
+ ymlFile.createNewFile();
+ assertTrue(ymlFile.exists());
+
+ handler.deleteID("already-has.yml");
+
+ assertFalse(ymlFile.exists());
+ }
+
+ @Test
+ void testDeleteIDNull() {
+ when(plugin.isEnabled()).thenReturn(false);
+ // Should not throw
+ handler.deleteID(null);
+ }
+
+ @Test
+ void testDeleteIDNoFolder() {
+ when(plugin.isEnabled()).thenReturn(false);
+ // Folder doesn't exist - should not throw
+ handler.deleteID("nonexistent");
+ }
+
+ // ---- close() ----
+
+ @Test
+ void testClose() {
+ // Should not throw
+ handler.close();
+ }
+
+ // ---- loadObject with map containing dots ----
+
+ @Test
+ void testLoadObjectMapWithDots() throws Exception {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("uniqueId", "dot-test");
+ config.set("name", "");
+ config.set("count", 0);
+ config.set("material", "STONE");
+ // Map key with serialized dot
+ config.set("scores.:dot:key:dot:name", 999);
+ when(connector.loadYamlFile(anyString(), eq("dot-test"))).thenReturn(config);
+
+ TestDataObject result = handler.loadObject("dot-test");
+ assertNotNull(result);
+ // :dot: should be converted back to .
+ assertTrue(result.getScores().containsKey(".key.name"));
+ assertEquals(999, result.getScores().get(".key.name"));
+ }
+
+ // ---- saveObject generates uniqueId if empty ----
+
+ @Test
+ void testSaveObjectGeneratesUniqueId() throws Exception {
+ when(plugin.isShutdown()).thenReturn(true);
+ when(connector.getUniqueId("TestDataObject")).thenReturn("generated-id");
+ when(connector.saveYamlFile(anyString(), anyString(), anyString(), any())).thenReturn(true);
+
+ TestDataObject obj = new TestDataObject();
+ obj.setUniqueId(""); // empty, should trigger generation
+
+ CompletableFuture result = handler.saveObject(obj);
+ assertTrue(result.join());
+ assertEquals("generated-id", obj.getUniqueId());
+ }
+
+ // ---- saveObject with null uniqueId ----
+
+ @Test
+ void testSaveObjectNullUniqueId() throws Exception {
+ when(plugin.isShutdown()).thenReturn(true);
+ when(connector.getUniqueId("TestDataObject")).thenReturn("auto-id");
+ when(connector.saveYamlFile(anyString(), anyString(), anyString(), any())).thenReturn(true);
+
+ TestDataObject obj = new TestDataObject();
+ obj.setUniqueId(null);
+
+ CompletableFuture result = handler.saveObject(obj);
+ assertTrue(result.join());
+ assertEquals("auto-id", obj.getUniqueId());
+ }
+
+ // ---- loadObject with empty config (missing fields use defaults) ----
+
+ @Test
+ void testLoadObjectEmptyConfig() throws Exception {
+ YamlConfiguration config = new YamlConfiguration();
+ // No fields set at all
+ when(connector.loadYamlFile(anyString(), eq("empty"))).thenReturn(config);
+
+ TestDataObject result = handler.loadObject("empty");
+ assertNotNull(result);
+ // Should have default values
+ assertEquals("test", result.getUniqueId()); // default from class
+ assertEquals("", result.getName());
+ assertEquals(0, result.getCount());
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/managers/AddonsManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/AddonsManagerTest.java
index ad8bd7354..f2b2d3046 100644
--- a/src/test/java/world/bentobox/bentobox/managers/AddonsManagerTest.java
+++ b/src/test/java/world/bentobox/bentobox/managers/AddonsManagerTest.java
@@ -7,14 +7,22 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.File;
+import java.io.IOException;
import java.nio.file.Files;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
@@ -36,6 +44,7 @@
import world.bentobox.bentobox.CommonTestSetup;
import world.bentobox.bentobox.api.addons.Addon;
import world.bentobox.bentobox.api.addons.Addon.State;
+import world.bentobox.bentobox.api.addons.AddonClassLoader;
import world.bentobox.bentobox.api.addons.AddonDescription;
import world.bentobox.bentobox.api.addons.GameModeAddon;
import world.bentobox.bentobox.api.addons.exceptions.InvalidAddonDescriptionException;
@@ -444,6 +453,399 @@ void testRegisterPermissionGameModePerm() throws InvalidAddonDescriptionExceptio
+ // ---- getAddonByName with addon present ----
+
+ @Test
+ void testGetAddonByNameFound() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main.class", "BSkyBlock", "1.0.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ am.getAddons().add(addon);
+
+ Optional result = am.getAddonByName("BSkyBlock");
+ assertTrue(result.isPresent());
+ assertEquals(addon, result.get());
+ }
+
+ @Test
+ void testGetAddonByNameCaseInsensitive() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main.class", "BSkyBlock", "1.0.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ am.getAddons().add(addon);
+
+ assertTrue(am.getAddonByName("bskyblock").isPresent());
+ assertTrue(am.getAddonByName("BSKYBLOCK").isPresent());
+ }
+
+ // ---- getAddonByMainClassName ----
+
+ @Test
+ void testGetAddonByMainClassNameFound() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("world.bentobox.bskyblock.BSkyBlock", "BSkyBlock", "1.0.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ am.getAddons().add(addon);
+
+ Optional result = am.getAddonByMainClassName("world.bentobox.bskyblock.BSkyBlock");
+ assertTrue(result.isPresent());
+ }
+
+ @Test
+ void testGetAddonByMainClassNameNotFound() {
+ assertFalse(am.getAddonByMainClassName("not.exist.Class").isPresent());
+ }
+
+ // ---- getLoadedAddons / getEnabledAddons / getGameModeAddons with state ----
+
+ @Test
+ void testGetLoadedAddonsWithLoadedAddon() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "TestAddon", "1.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.LOADED);
+ am.getAddons().add(addon);
+
+ assertEquals(1, am.getLoadedAddons().size());
+ }
+
+ @Test
+ void testGetLoadedAddonsExcludesEnabled() {
+ Addon addon = mock(Addon.class);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ am.getAddons().add(addon);
+
+ assertTrue(am.getLoadedAddons().isEmpty());
+ }
+
+ @Test
+ void testGetEnabledAddonsWithEnabledAddon() {
+ Addon addon = mock(Addon.class);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ am.getAddons().add(addon);
+
+ assertEquals(1, am.getEnabledAddons().size());
+ }
+
+ @Test
+ void testGetGameModeAddonsWithGameMode() {
+ GameModeAddon gma = new MyGameMode();
+ AddonDescription desc = new AddonDescription.Builder("main", "TestGM", "1.0").build();
+ gma.setDescription(desc);
+ gma.setState(State.ENABLED);
+ am.getAddons().add(gma);
+
+ assertEquals(1, am.getGameModeAddons().size());
+ assertEquals(gma, am.getGameModeAddons().getFirst());
+ }
+
+ @Test
+ void testGetGameModeAddonsExcludesNonGameMode() {
+ Addon addon = mock(Addon.class);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ am.getAddons().add(addon);
+
+ assertTrue(am.getGameModeAddons().isEmpty());
+ }
+
+ // ---- enableAddons with loaded addons ----
+
+ @Test
+ void testEnableAddonsWithLoadedGameMode() {
+ GameModeAddon gma = new MyGameMode();
+ AddonDescription desc = new AddonDescription.Builder("main", "TestGM", "1.0").apiVersion("1").build();
+ gma.setDescription(desc);
+ gma.setState(State.LOADED);
+ am.getAddons().add(gma);
+
+ am.enableAddons();
+ verify(plugin).log("Enabling game mode addons...");
+ }
+
+ @Test
+ void testEnableAddonsSkipsDisabledAddons() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "Disabled", "1.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.DISABLED);
+ am.getAddons().add(addon);
+
+ // Only LOADED addons (not DISABLED) are candidates. This addon is DISABLED
+ // so enableAddons should still log the enabling messages but skip this addon
+ am.enableAddons();
+ verify(addon, never()).onEnable();
+ }
+
+ // ---- disableAddons with addons ----
+
+ @Test
+ void testDisableAddonsWithEnabledAddon() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "TestAddon", "1.0")
+ .authors("Author1").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ when(addon.isEnabled()).thenReturn(true);
+ am.getAddons().add(addon);
+
+ am.disableAddons();
+ verify(plugin).log("Disabling addons...");
+ verify(addon).onDisable();
+ }
+
+ @Test
+ void testDisableAddonsHandlesOnDisableException() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "CrashAddon", "1.0")
+ .authors("Author1").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ when(addon.isEnabled()).thenReturn(true);
+ doThrow(new RuntimeException("crash")).when(addon).onDisable();
+ am.getAddons().add(addon);
+
+ am.disableAddons();
+ verify(plugin).logError(contains("Error occurred when disabling addon CrashAddon"));
+ }
+
+ // ---- disable with listeners ----
+
+ @Test
+ void testDisableUnregistersListeners() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "ListenerAddon", "1.0")
+ .authors("Author1").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ when(addon.isEnabled()).thenReturn(true);
+
+ // Register a listener first
+ Listener listener = mock(Listener.class);
+ am.registerListener(addon, listener);
+ am.getAddons().add(addon);
+
+ am.disableAddons();
+ // Listener should be unregistered via HandlerList.unregisterAll
+ verify(addon).onDisable();
+ }
+
+ // ---- disable with loaders ----
+
+ @Test
+ void testDisableClosesLoaders() throws IOException {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "LoaderAddon", "1.0")
+ .authors("Author1").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ when(addon.isEnabled()).thenReturn(true);
+
+ // Mock an AddonClassLoader
+ AddonClassLoader mockLoader = mock(AddonClassLoader.class);
+ when(mockLoader.getClasses()).thenReturn(Set.of("com.test.MyClass"));
+
+ // Put a class that should be removed
+ am.setClass("com.test.MyClass", String.class);
+ assertNotNull(am.getClassByName("com.test.MyClass"));
+
+ // Can't easily add to the private loaders map without loading a real addon,
+ // but we can verify the class cleanup happens via disableAddons
+ am.getAddons().add(addon);
+ am.disableAddons();
+
+ verify(addon).onDisable();
+ }
+
+ // ---- allLoaded ----
+
+ @Test
+ void testAllLoadedCallsAddonAllLoaded() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "AllLoadedAddon", "1.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ when(addon.isEnabled()).thenReturn(true);
+ am.getAddons().add(addon);
+
+ am.allLoaded();
+ verify(addon).allLoaded();
+ }
+
+ @Test
+ void testAllLoadedHandlesException() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "CrashAllLoaded", "1.0")
+ .authors("Author1").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ when(addon.isEnabled()).thenReturn(true);
+ doThrow(new RuntimeException("allLoaded crash")).when(addon).allLoaded();
+ am.getAddons().add(addon);
+
+ am.allLoaded();
+ // Should set state to ERROR and log
+ verify(plugin).logError(contains("Skipping CrashAllLoaded due to an unhandled exception"));
+ }
+
+ @Test
+ void testAllLoadedHandlesLinkageError() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "IncompatAllLoaded", "1.0")
+ .authors("Author1").build();
+ when(addon.getDescription()).thenReturn(desc);
+ when(addon.getState()).thenReturn(State.ENABLED);
+ when(addon.isEnabled()).thenReturn(true);
+ doThrow(new NoClassDefFoundError("missing.Class")).when(addon).allLoaded();
+ am.getAddons().add(addon);
+
+ am.allLoaded();
+ verify(plugin).logWarning(contains("Skipping IncompatAllLoaded"));
+ }
+
+ @Test
+ void testAllLoadedNoEnabledAddons() {
+ // No addons - should not throw
+ am.allLoaded();
+ // Just verify it completes without error
+ }
+
+ // ---- getDefaultWorldGenerator with world name match ----
+
+ @Test
+ void testGetDefaultWorldGeneratorStripsSuffixes() {
+ // "testworld_nether" should strip to "testworld" and not match anything
+ assertNull(am.getDefaultWorldGenerator("testworld_nether", ""));
+ assertNull(am.getDefaultWorldGenerator("testworld_the_end", ""));
+ }
+
+ // ---- setClass / getClassByName ----
+
+ @Test
+ void testSetClassDoesNotOverwrite() {
+ am.setClass("test", String.class);
+ am.setClass("test", Integer.class);
+ // putIfAbsent - should keep the first one
+ assertEquals(String.class, am.getClassByName("test"));
+ }
+
+ @Test
+ void testGetClassByNameReturnsNullForUnknown() {
+ assertNull(am.getClassByName("nonexistent.Class"));
+ }
+
+ // ---- getDataObjects filters correctly ----
+
+ @Test
+ void testGetDataObjectsFiltersNonDataObjects() {
+ am.setClass("regularClass", String.class);
+ assertTrue(am.getDataObjects().isEmpty());
+ }
+
+ @Test
+ void testGetDataObjectsReturnsDataObjects() {
+ am.setClass("dataobj", DataObject.class);
+ am.setClass("regularClass", String.class);
+ assertEquals(1, am.getDataObjects().size());
+ }
+
+ // ---- isAddonCompatibleWithBentoBox edge cases ----
+
+ @Test
+ void testIsAddonCompatibleExactMatch() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "Test", "1.0")
+ .apiVersion("2.5.3").build();
+ when(addon.getDescription()).thenReturn(desc);
+ assertTrue(am.isAddonCompatibleWithBentoBox(addon, "2.5.3"));
+ }
+
+ @Test
+ void testIsAddonCompatibleBentoBoxNewer() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "Test", "1.0")
+ .apiVersion("2.5.3").build();
+ when(addon.getDescription()).thenReturn(desc);
+ assertTrue(am.isAddonCompatibleWithBentoBox(addon, "3.0.0"));
+ }
+
+ @Test
+ void testIsAddonCompatibleBentoBoxOlder() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "Test", "1.0")
+ .apiVersion("3.0.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ assertFalse(am.isAddonCompatibleWithBentoBox(addon, "2.5.3"));
+ }
+
+ @Test
+ void testIsAddonCompatibleLocalSnapshot() {
+ Addon addon = mock(Addon.class);
+ AddonDescription desc = new AddonDescription.Builder("main", "Test", "1.0")
+ .apiVersion("3.14.0").build();
+ when(addon.getDescription()).thenReturn(desc);
+ assertTrue(am.isAddonCompatibleWithBentoBox(addon, "3.14.1-LOCAL-SNAPSHOT"));
+ }
+
+ // ---- setPerms with gamemode placeholder ----
+
+ @Test
+ void testSetPermsWithGameModePlaceholder() throws InvalidConfigurationException {
+ String perms = """
+ '[gamemode].admin':
+ description: Admin permission.
+ default: op
+ """;
+ YamlConfiguration config = new YamlConfiguration();
+ config.loadFromString(perms);
+
+ // Create a game mode addon and add it as enabled
+ GameModeAddon gma = new MyGameMode();
+ AddonDescription gmaDesc = new AddonDescription.Builder("main", "bskyblock", "1.0")
+ .permissions(config).build();
+ gma.setDescription(gmaDesc);
+ gma.setState(State.ENABLED);
+ am.getAddons().add(gma);
+
+ // Now set perms - should replace [gamemode] with the addon's permission prefix
+ assertTrue(am.setPerms(gma));
+ mockedStaticDP.verify(() -> DefaultPermissions.registerPermission(
+ eq("bskyblock.admin"), anyString(), any(PermissionDefault.class)));
+ }
+
+ // ---- registerListener stores listener ----
+
+ @Test
+ void testRegisterListenerStoresInMap() {
+ Addon addon = mock(Addon.class);
+ Listener listener1 = mock(Listener.class);
+ Listener listener2 = mock(Listener.class);
+
+ am.registerListener(addon, listener1);
+ am.registerListener(addon, listener2);
+
+ verify(pim, times(2)).registerEvents(any(Listener.class), eq(plugin));
+ }
+
+ // ---- loadAddons creates addons folder ----
+
+ @Test
+ void testLoadAddonsCreatesFolder() {
+ File addonsDir = new File(plugin.getDataFolder(), "addons");
+ assertFalse(addonsDir.exists());
+
+ am.loadAddons();
+ assertTrue(addonsDir.exists());
+ }
+
+ // ---- reloadAddons calls disable then load then enable ----
+
+ @Test
+ void testReloadAddonsCallsDisableAndLoad() {
+ am.reloadAddons();
+ // reloadAddons calls disableAddons, which calls unregisterCommands
+ verify(cm).unregisterCommands();
+ }
+
class MyGameMode extends GameModeAddon {
@Override
diff --git a/src/test/java/world/bentobox/bentobox/managers/BlueprintsManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/BlueprintsManagerTest.java
index 895619278..44dea3521 100644
--- a/src/test/java/world/bentobox/bentobox/managers/BlueprintsManagerTest.java
+++ b/src/test/java/world/bentobox/bentobox/managers/BlueprintsManagerTest.java
@@ -26,6 +26,7 @@
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
+import org.bukkit.World;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -617,4 +618,229 @@ void testBlueprintBundleCommandsSerializedInJson() throws IOException {
String content = Files.readString(savedFile.toPath());
assertTrue(content.contains("say hello [player]"), "Commands should be serialised into JSON");
}
+
+ // -----------------------------------------------------------------------
+ // loadBlueprintBundles
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testLoadBlueprintBundlesNoFolder() {
+ // No blueprints folder — should create defaults
+ manager.loadBlueprintBundles(addon);
+
+ // After loading, it should not be in the "loading" set anymore
+ assertTrue(manager.isBlueprintsLoaded());
+ }
+
+ @Test
+ void testLoadBlueprintBundlesWithValidBundle() throws IOException {
+ blueprintsFolder.mkdirs();
+
+ // Create a valid bundle JSON file
+ BlueprintBundle bb = new BlueprintBundle();
+ bb.setUniqueId(BUNDLE_NAME);
+ bb.setDisplayName("Test Bundle");
+ Blueprint islandBp = new Blueprint();
+ islandBp.setName("island");
+ bb.setBlueprint(World.Environment.NORMAL, islandBp);
+
+ // Save using the manager (runs synchronously with our mock)
+ manager.saveBlueprintBundle(addon, bb);
+
+ // Also write a blueprint file so loadBlueprints doesn't log error
+ File jsonFile = new File(blueprintsFolder, "island" + BlueprintsManager.BLUEPRINT_SUFFIX);
+ Files.writeString(jsonFile.toPath(), BLUEPRINT_JSON);
+
+ // Now load bundles
+ manager.loadBlueprintBundles(addon);
+
+ Map bundles = manager.getBlueprintBundles(addon);
+ assertEquals(1, bundles.size());
+ assertTrue(bundles.containsKey(BUNDLE_NAME));
+ }
+
+ @Test
+ void testLoadBlueprintBundlesDuplicateUniqueId() throws IOException {
+ blueprintsFolder.mkdirs();
+
+ // Create two bundle files with the same uniqueId
+ BlueprintBundle bb = new BlueprintBundle();
+ bb.setUniqueId(BUNDLE_NAME);
+ bb.setDisplayName("Bundle 1");
+ manager.saveBlueprintBundle(addon, bb);
+
+ // Create a second file with same uniqueId but different filename
+ File secondFile = new File(blueprintsFolder, "copy.json");
+ String content = Files.readString(new File(blueprintsFolder, BUNDLE_NAME + ".json").toPath());
+ Files.writeString(secondFile.toPath(), content);
+
+ // Write a blueprint so loadBlueprints works
+ File bpFile = new File(blueprintsFolder, "island" + BlueprintsManager.BLUEPRINT_SUFFIX);
+ Files.writeString(bpFile.toPath(), BLUEPRINT_JSON);
+
+ manager.loadBlueprintBundles(addon);
+
+ // Only one should be loaded, duplicate should be warned
+ Map bundles = manager.getBlueprintBundles(addon);
+ assertEquals(1, bundles.size());
+ }
+
+ // -----------------------------------------------------------------------
+ // saveBlueprintBundles (saves all)
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testSaveBlueprintBundlesSavesAll() throws IOException {
+ blueprintsFolder.mkdirs();
+
+ BlueprintBundle bb1 = new BlueprintBundle();
+ bb1.setUniqueId("bundle1");
+ manager.addBlueprintBundle(addon, bb1);
+
+ BlueprintBundle bb2 = new BlueprintBundle();
+ bb2.setUniqueId("bundle2");
+ manager.addBlueprintBundle(addon, bb2);
+
+ manager.saveBlueprintBundles();
+
+ assertTrue(new File(blueprintsFolder, "bundle1.json").exists());
+ assertTrue(new File(blueprintsFolder, "bundle2.json").exists());
+ }
+
+ // -----------------------------------------------------------------------
+ // saveBlueprintBundle error handling
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testSaveBlueprintBundleCreatesFolder() {
+ // Don't create blueprintsFolder — saveBlueprintBundle should create it
+ assertFalse(blueprintsFolder.exists());
+
+ BlueprintBundle bb = new BlueprintBundle();
+ bb.setUniqueId(BUNDLE_NAME);
+ manager.saveBlueprintBundle(addon, bb);
+
+ assertTrue(blueprintsFolder.exists());
+ assertTrue(new File(blueprintsFolder, BUNDLE_NAME + ".json").exists());
+ }
+
+ // -----------------------------------------------------------------------
+ // paste with valid blueprint
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testPasteNoOverworldBlueprint() {
+ // Bundle is registered and blueprints map exists, but overworld blueprint is missing
+ BlueprintBundle bb = new BlueprintBundle();
+ bb.setUniqueId(BUNDLE_NAME);
+ bb.setBlueprints(Map.of(World.Environment.NORMAL, "nonexistent"));
+ manager.addBlueprintBundle(addon, bb);
+
+ // Add a blueprint (but not the one the bundle references)
+ Blueprint bp = new Blueprint();
+ bp.setName("other");
+ manager.addBlueprint(addon, bp);
+
+ boolean result = manager.paste(addon, island, BUNDLE_NAME, null, true);
+ // Should log error about no default blueprint
+ verify(plugin).logError("Blueprint bundle has no normal world blueprint, using default");
+ }
+
+ @Test
+ void testPaste3ArgDelegates() {
+ // The 3-arg paste(addon, island, name) delegates to paste(addon, island, name, null, true)
+ // With no bundle registered, both should return false
+ BlueprintBundle bb = new BlueprintBundle();
+ bb.setUniqueId(BUNDLE_NAME);
+ manager.addBlueprintBundle(addon, bb);
+
+ // No blueprints loaded
+ manager.paste(addon, island, BUNDLE_NAME);
+ verify(plugin).logError("No blueprints loaded for bundle 'default'!");
+ }
+
+ // -----------------------------------------------------------------------
+ // extractDefaultBlueprints — no file
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testExtractDefaultBlueprintsNoJarFile() {
+ // addon.getFile() returns null by default, so extracting should handle gracefully
+ assertFalse(blueprintsFolder.exists());
+ when(addon.getFile()).thenReturn(new File("nonexistent.jar"));
+ manager.extractDefaultBlueprints(addon);
+ // Should log error about jar
+ verify(plugin).logError(anyString());
+ }
+
+ // -----------------------------------------------------------------------
+ // deleteBlueprint removes from bundles too
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testDeleteBlueprintAlsoDeletesLegacyFile() throws IOException {
+ blueprintsFolder.mkdirs();
+ Blueprint bp = new Blueprint();
+ bp.setName("island");
+ manager.addBlueprint(addon, bp);
+
+ // Create both modern and legacy files
+ File modernFile = new File(blueprintsFolder, "island" + BlueprintsManager.BLUEPRINT_SUFFIX);
+ File legacyFile = new File(blueprintsFolder, "island" + BlueprintsManager.LEGACY_BLUEPRINT_SUFFIX);
+ Files.writeString(modernFile.toPath(), "dummy");
+ Files.writeString(legacyFile.toPath(), "dummy");
+
+ manager.deleteBlueprint(addon, "island");
+
+ assertFalse(modernFile.exists());
+ assertFalse(legacyFile.exists());
+ }
+
+ // -----------------------------------------------------------------------
+ // renameBlueprint edge cases
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testRenameBlueprintNoOldFile() {
+ // Rename should work even if old file doesn't exist on disk
+ Blueprint bp = new Blueprint();
+ bp.setName("island");
+ manager.addBlueprint(addon, bp);
+
+ manager.renameBlueprint(addon, bp, "newname", "New Name");
+
+ assertEquals("newname", bp.getName());
+ assertTrue(manager.getBlueprints(addon).containsKey("newname"));
+ assertFalse(manager.getBlueprints(addon).containsKey("island"));
+ }
+
+ // -----------------------------------------------------------------------
+ // validate edge cases
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testValidateCaseSensitive() {
+ BlueprintBundle bb = new BlueprintBundle();
+ bb.setUniqueId(BUNDLE_NAME);
+ manager.addBlueprintBundle(addon, bb);
+
+ // validate uses exact key match
+ assertNull(manager.validate(addon, "DEFAULT"));
+ assertEquals(BUNDLE_NAME, manager.validate(addon, BUNDLE_NAME));
+ }
+
+ // -----------------------------------------------------------------------
+ // isBlueprintsLoaded during loading
+ // -----------------------------------------------------------------------
+
+ @Test
+ void testIsBlueprintsLoadedFalseDuringLoad() {
+ // Override scheduler to NOT run the task, simulating async in-progress
+ when(sch.runTaskAsynchronously(any(), any(Runnable.class))).thenReturn(mock(BukkitTask.class));
+
+ manager.loadBlueprintBundles(addon);
+
+ // Loading flag should be set (not yet cleared since task didn't run)
+ assertFalse(manager.isBlueprintsLoaded());
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/managers/IslandsManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/IslandsManagerTest.java
index f0404fc68..f8091e95c 100644
--- a/src/test/java/world/bentobox/bentobox/managers/IslandsManagerTest.java
+++ b/src/test/java/world/bentobox/bentobox/managers/IslandsManagerTest.java
@@ -77,6 +77,7 @@
import world.bentobox.bentobox.database.AbstractDatabaseHandler;
import world.bentobox.bentobox.database.Database;
import world.bentobox.bentobox.database.DatabaseSetup;
+import world.bentobox.bentobox.api.flags.Flag;
import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.lists.Flags;
import world.bentobox.bentobox.managers.island.IslandCache;
@@ -1714,4 +1715,572 @@ void testHomeTeleportAsyncIslandUserBooleanUnsafeLocation() throws Exception {
mockedUtil.verify(() -> Util.teleportAsync(eq(player), eq(homeLoc)), never());
verify(user).sendMessage("commands.island.go.teleport");
}
+
+ // ---- getIsland(World, User) ----
+
+ @Test
+ void testGetIslandWorldUser() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ Island result = islandsManager.getIsland(world, user);
+ assertNotNull(result);
+ }
+
+ @Test
+ void testGetIslandWorldNullUser() {
+ assertNull(islandsManager.getIsland(world, (User) null));
+ }
+
+ @Test
+ void testGetIslandWorldUserNullUuid() {
+ when(user.getUniqueId()).thenReturn(null);
+ assertNull(islandsManager.getIsland(world, user));
+ }
+
+ // ---- getIslands(World, User) ----
+
+ @Test
+ void testGetIslandsWorldUser() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ List result = islandsManager.getIslands(world, user);
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ }
+
+ // ---- getIslands(World, UUID) ----
+
+ @Test
+ void testGetIslandsWorldUuid() {
+ islandsManager.setIslandCache(islandCache);
+ List result = islandsManager.getIslands(world, uuid);
+ assertNotNull(result);
+ }
+
+ // ---- getIslands(UUID) - all worlds ----
+
+ @Test
+ void testGetIslandsUuid() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.getIslands(uuid)).thenReturn(List.of(island));
+ List result = islandsManager.getIslands(uuid);
+ assertEquals(1, result.size());
+ }
+
+ // ---- getOwnedIslands ----
+
+ @Test
+ void testGetOwnedIslandsWorldUser() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ when(island.getOwner()).thenReturn(uuid);
+ Set result = islandsManager.getOwnedIslands(world, user);
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void testGetOwnedIslandsWorldUserNullUuid() {
+ when(user.getUniqueId()).thenReturn(null);
+ Set result = islandsManager.getOwnedIslands(world, user);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testGetOwnedIslandsWorldUuid() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getOwner()).thenReturn(uuid);
+ Set result = islandsManager.getOwnedIslands(world, uuid);
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void testGetOwnedIslandsNotOwner() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getOwner()).thenReturn(UUID.randomUUID()); // different owner
+ Set result = islandsManager.getOwnedIslands(world, uuid);
+ assertTrue(result.isEmpty());
+ }
+
+ // ---- getIsland(World, UUID) with online player ----
+
+ @Test
+ void testGetIslandWorldUuidOnlinePlayerOnIsland() {
+ islandsManager.setIslandCache(islandCache);
+ mockedBukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player);
+ when(player.isOnline()).thenReturn(true);
+ when(player.getLocation()).thenReturn(location);
+ when(location.getWorld()).thenReturn(world);
+ // Island at location matches world and player is in team
+ when(island.getWorld()).thenReturn(world);
+ when(island.inTeam(uuid)).thenReturn(true);
+
+ Island result = islandsManager.getIsland(world, uuid);
+ assertEquals(island, result);
+ }
+
+ @Test
+ void testGetIslandWorldUuidOnlinePlayerNotOnIsland() {
+ islandsManager.setIslandCache(islandCache);
+ mockedBukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player);
+ when(player.isOnline()).thenReturn(true);
+ when(player.getLocation()).thenReturn(location);
+ when(location.getWorld()).thenReturn(world);
+ // Player not in team on this island
+ when(island.getWorld()).thenReturn(world);
+ when(island.inTeam(uuid)).thenReturn(false);
+
+ // Should fall back to cache
+ Island result = islandsManager.getIsland(world, uuid);
+ assertNotNull(result);
+ }
+
+ @Test
+ void testGetIslandWorldUuidOfflinePlayer() {
+ islandsManager.setIslandCache(islandCache);
+ mockedBukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null);
+
+ Island result = islandsManager.getIsland(world, uuid);
+ assertEquals(island, result);
+ }
+
+ @Test
+ void testGetIslandWorldUuidNullWorld() {
+ assertNull(islandsManager.getIsland(null, uuid));
+ }
+
+ // ---- isIslandAt ----
+
+ @Test
+ void testIsIslandAt() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.isIslandAt(location)).thenReturn(true);
+ assertTrue(islandsManager.isIslandAt(location));
+ }
+
+ @Test
+ void testIsIslandAtNotInWorld() {
+ when(iwm.inWorld(any(Location.class))).thenReturn(false);
+ assertFalse(islandsManager.isIslandAt(location));
+ }
+
+ // ---- getIslands() - all islands from DB ----
+
+ @Test
+ void testGetAllIslands() {
+ Collection result = islandsManager.getIslands();
+ assertNotNull(result);
+ assertFalse(result.isEmpty());
+ }
+
+ // ---- getIslands(World) ----
+
+ @Test
+ void testGetIslandsWorld() {
+ // 'is' already has its world set to staticWorld in setUp
+ is.setWorld(world);
+ Collection result = islandsManager.getIslands(world);
+ assertNotNull(result);
+ assertFalse(result.isEmpty());
+ }
+
+ // ---- getIslandCount(World) ----
+
+ @Test
+ void testGetIslandCountWorld() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.size(world)).thenReturn(5L);
+ assertEquals(5L, islandsManager.getIslandCount(world));
+ }
+
+ // ---- loadIsland(String) ----
+
+ @Test
+ void testLoadIslandString() throws Exception {
+ String uid = "test-id";
+ when(h.loadObject(uid)).thenReturn(is);
+ Optional result = islandsManager.loadIsland(uid);
+ assertTrue(result.isPresent());
+ }
+
+ @Test
+ void testLoadIslandStringNotFound() throws Exception {
+ when(h.loadObject("missing")).thenReturn(null);
+ Optional result = islandsManager.loadIsland("missing");
+ assertTrue(result.isEmpty());
+ }
+
+ // ---- deleteIslandId ----
+
+ @Test
+ void testDeleteIslandIdExists() {
+ when(h.objectExists("test-id")).thenReturn(true);
+ assertTrue(islandsManager.deleteIslandId("test-id"));
+ verify(h).deleteID("test-id");
+ }
+
+ @Test
+ void testDeleteIslandIdNotExists() {
+ when(h.objectExists("missing")).thenReturn(false);
+ assertFalse(islandsManager.deleteIslandId("missing"));
+ verify(h, never()).deleteID("missing");
+ }
+
+ // ---- hasIsland ----
+
+ @Test
+ void testHasIslandWorldUser() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ when(islandCache.hasIsland(world, uuid)).thenReturn(true);
+ assertTrue(islandsManager.hasIsland(world, user));
+ }
+
+ @Test
+ void testHasIslandWorldUuid() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.hasIsland(world, uuid)).thenReturn(true);
+ assertTrue(islandsManager.hasIsland(world, uuid));
+ }
+
+ @Test
+ void testHasIslandWorldUuidFalse() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.hasIsland(world, uuid)).thenReturn(false);
+ assertFalse(islandsManager.hasIsland(world, uuid));
+ }
+
+ // ---- isOwner (deprecated, delegates to hasIsland) ----
+
+ @Test
+ void testIsOwner() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.hasIsland(world, uuid)).thenReturn(true);
+ assertTrue(islandsManager.isOwner(world, uuid));
+ }
+
+ // ---- inTeam ----
+
+ @Test
+ void testInTeam() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid, UUID.randomUUID()));
+ when(island.inTeam(uuid)).thenReturn(true);
+ assertTrue(islandsManager.inTeam(world, uuid));
+ }
+
+ @Test
+ void testInTeamFalse() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid));
+ assertFalse(islandsManager.inTeam(world, uuid));
+ }
+
+ // ---- setHomeLocation overloads ----
+
+ @Test
+ void testSetHomeLocationUserLocation() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ when(island.getHome("")).thenReturn(null);
+ assertTrue(islandsManager.setHomeLocation(user, location));
+ }
+
+ @Test
+ void testSetHomeLocationUserLocationName() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ when(island.getHome("myHome")).thenReturn(null);
+ assertTrue(islandsManager.setHomeLocation(user, location, "myHome"));
+ }
+
+ @Test
+ void testSetHomeLocationUuidLocationName() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getHome("test")).thenReturn(null);
+ assertTrue(islandsManager.setHomeLocation(uuid, location, "test"));
+ }
+
+ @Test
+ void testSetHomeLocationUuidLocation() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getHome("")).thenReturn(null);
+ assertTrue(islandsManager.setHomeLocation(uuid, location));
+ }
+
+ @Test
+ void testSetHomeLocationIslandLocationName() {
+ when(island.getHome("base")).thenReturn(null);
+ assertTrue(islandsManager.setHomeLocation(island, location, "base"));
+ verify(island).addHome("base", location);
+ }
+
+ @Test
+ void testSetHomeLocationIslandSameLocation() {
+ when(island.getHome("base")).thenReturn(location);
+ assertFalse(islandsManager.setHomeLocation(island, location, "base"));
+ }
+
+ @Test
+ void testSetHomeLocationNullIsland() {
+ assertFalse(islandsManager.setHomeLocation((Island) null, location, "base"));
+ }
+
+ // ---- getHomeLocation overloads ----
+
+ @Test
+ void testGetHomeLocationWorldUuid() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getHome("")).thenReturn(location);
+ Location result = islandsManager.getHomeLocation(world, uuid);
+ assertEquals(location, result);
+ }
+
+ @Test
+ void testGetHomeLocationWorldUuidNoIsland() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.getIsland(any(), any())).thenReturn(null);
+ assertNull(islandsManager.getHomeLocation(world, uuid));
+ }
+
+ @Test
+ void testGetHomeLocationWorldUserName() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ when(island.getHomes()).thenReturn(Map.of("myHome", location));
+ when(island.getHome("myHome")).thenReturn(location);
+ Location result = islandsManager.getHomeLocation(world, user, "myHome");
+ assertEquals(location, result);
+ }
+
+ @Test
+ void testGetHomeLocationWorldUuidName() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getHomes()).thenReturn(Map.of("test", location));
+ when(island.getHome("test")).thenReturn(location);
+ Location result = islandsManager.getHomeLocation(world, uuid, "test");
+ assertEquals(location, result);
+ }
+
+ @Test
+ void testGetHomeLocationWorldUuidNameNotFound() {
+ islandsManager.setIslandCache(islandCache);
+ when(island.getHomes()).thenReturn(Map.of());
+ Location result = islandsManager.getHomeLocation(world, uuid, "missing");
+ assertNull(result);
+ }
+
+ @Test
+ void testGetHomeLocationIsland() {
+ when(island.getHome("")).thenReturn(location);
+ Location result = islandsManager.getHomeLocation(island);
+ assertEquals(location, result);
+ }
+
+ @Test
+ void testGetHomeLocationIslandName() {
+ when(island.getHome("base")).thenReturn(location);
+ Location result = islandsManager.getHomeLocation(island, "base");
+ assertEquals(location, result);
+ }
+
+ @Test
+ void testGetHomeLocationIslandNameFallsBackToCenter() {
+ when(island.getHome("missing")).thenReturn(null);
+ when(island.getProtectionCenter()).thenReturn(location);
+ Location result = islandsManager.getHomeLocation(island, "missing");
+ assertEquals(location, result);
+ }
+
+ // ---- removeHomeLocation ----
+
+ @Test
+ void testRemoveHomeLocation() {
+ when(island.removeHome("test")).thenReturn(true);
+ assertTrue(islandsManager.removeHomeLocation(island, "test"));
+ }
+
+ // ---- renameHomeLocation ----
+
+ @Test
+ void testRenameHomeLocation() {
+ when(island.renameHome("old", "new")).thenReturn(true);
+ assertTrue(islandsManager.renameHomeLocation(island, "old", "new"));
+ }
+
+ // ---- getHomeLocations ----
+
+ @Test
+ void testGetHomeLocations() {
+ Map homes = Map.of("home1", location);
+ when(island.getHomes()).thenReturn(homes);
+ assertEquals(homes, islandsManager.getHomeLocations(island));
+ }
+
+ // ---- isHomeLocation ----
+
+ @Test
+ void testIsHomeLocation() {
+ when(island.getHomes()).thenReturn(Map.of("base", location));
+ assertTrue(islandsManager.isHomeLocation(island, "base"));
+ }
+
+ @Test
+ void testIsHomeLocationFalse() {
+ when(island.getHomes()).thenReturn(Map.of());
+ assertFalse(islandsManager.isHomeLocation(island, "nonexistent"));
+ }
+
+ // ---- getNumberOfHomesIfAdded ----
+
+ @Test
+ void testGetNumberOfHomesIfAddedNew() {
+ when(island.getHomes()).thenReturn(Map.of("home1", location));
+ // "newhome" doesn't exist, so count = existing + 1
+ assertEquals(2, islandsManager.getNumberOfHomesIfAdded(island, "newhome"));
+ }
+
+ @Test
+ void testGetNumberOfHomesIfAddedExisting() {
+ when(island.getHomes()).thenReturn(Map.of("home1", location));
+ // "home1" already exists, count stays the same
+ assertEquals(1, islandsManager.getNumberOfHomesIfAdded(island, "home1"));
+ }
+
+ // ---- clearSpawn ----
+
+ @Test
+ void testClearSpawn() {
+ // First set a spawn
+ islandsManager.setSpawn(island);
+ // Now clear it
+ islandsManager.clearSpawn(world);
+ verify(island).setSpawn(false);
+ assertTrue(islandsManager.getSpawn(world).isEmpty());
+ }
+
+ @Test
+ void testClearSpawnNoSpawn() {
+ // Clear when no spawn set - should not throw
+ islandsManager.clearSpawn(world);
+ assertTrue(islandsManager.getSpawn(world).isEmpty());
+ }
+
+ // ---- removePlayer overloads ----
+
+ @Test
+ void testRemovePlayerWorldUser() {
+ islandsManager.setIslandCache(islandCache);
+ when(user.getUniqueId()).thenReturn(uuid);
+ when(islandCache.removePlayer(world, uuid)).thenReturn(Set.of(island));
+ islandsManager.removePlayer(world, user);
+ verify(islandCache).removePlayer(world, uuid);
+ }
+
+ @Test
+ void testRemovePlayerIslandUuid() {
+ islandsManager.setIslandCache(islandCache);
+ islandsManager.removePlayer(island, uuid);
+ verify(islandCache).removePlayer(island, uuid);
+ }
+
+ // ---- isSaveTaskRunning ----
+
+ @Test
+ void testIsSaveTaskRunning() {
+ // Default state should be false
+ assertFalse(islandsManager.isSaveTaskRunning());
+ }
+
+ // ---- setJoinTeam ----
+
+ @Test
+ void testSetJoinTeamAddsPlayerAndSaves() {
+ islandsManager.setIslandCache(islandCache);
+ UUID newPlayer = UUID.randomUUID();
+ islandsManager.setJoinTeam(island, newPlayer);
+ verify(island).addMember(newPlayer);
+ verify(islandCache).addPlayer(newPlayer, island);
+ verify(island).log(any());
+ }
+
+ // ---- getIslandById overloads ----
+
+ @Test
+ void testGetIslandByIdNotFound() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.getIslandById("missing")).thenReturn(null);
+ assertTrue(islandsManager.getIslandById("missing").isEmpty());
+ }
+
+ @Test
+ void testGetIslandByIdWithCache() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.getIslandById("id", false)).thenReturn(island);
+ Optional result = islandsManager.getIslandById("id", false);
+ assertTrue(result.isPresent());
+ }
+
+ // ---- isIslandId ----
+
+ @Test
+ void testIsIslandId() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.isIslandId("test-id")).thenReturn(true);
+ assertTrue(islandsManager.isIslandId("test-id"));
+ }
+
+ @Test
+ void testIsIslandIdFalse() {
+ islandsManager.setIslandCache(islandCache);
+ when(islandCache.isIslandId("missing")).thenReturn(false);
+ assertFalse(islandsManager.isIslandId("missing"));
+ }
+
+ // ---- resetAllFlags ----
+
+ @Test
+ void testResetAllFlags() {
+ islandsManager.setIslandCache(islandCache);
+ islandsManager.resetAllFlags(world);
+ verify(islandCache).resetAllFlags(world);
+ }
+
+ // ---- resetFlag ----
+
+ @Test
+ void testResetFlag() {
+ islandsManager.setIslandCache(islandCache);
+ islandsManager.resetFlag(world, Flags.BREAK_BLOCKS);
+ verify(islandCache).resetFlag(world, Flags.BREAK_BLOCKS);
+ }
+
+ // ---- getNumberOfConcurrentIslands ----
+
+ @Test
+ void testGetNumberOfConcurrentIslands() {
+ islandsManager.setIslandCache(islandCache);
+ assertEquals(1, islandsManager.getNumberOfConcurrentIslands(uuid, world));
+ }
+
+ // ---- getPrimaryIsland ----
+
+ @Test
+ void testGetPrimaryIsland() {
+ islandsManager.setIslandCache(islandCache);
+ Island result = islandsManager.getPrimaryIsland(world, uuid);
+ assertEquals(island, result);
+ }
+
+ // ---- getIslandsASync ----
+
+ @Test
+ void testGetIslandsASync() {
+ CompletableFuture> future = CompletableFuture.completedFuture(List.of(is));
+ when(h.loadObjectsASync()).thenReturn(future);
+ CompletableFuture> result = islandsManager.getIslandsASync();
+ assertNotNull(result);
+ assertEquals(1, result.join().size());
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/managers/LocalesManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/LocalesManagerTest.java
index a28c9a2f5..650495fb6 100644
--- a/src/test/java/world/bentobox/bentobox/managers/LocalesManagerTest.java
+++ b/src/test/java/world/bentobox/bentobox/managers/LocalesManagerTest.java
@@ -2,6 +2,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
@@ -32,7 +33,9 @@
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.CommonTestSetup;
import world.bentobox.bentobox.api.addons.Addon;
+import world.bentobox.bentobox.Settings;
import world.bentobox.bentobox.api.addons.AddonDescription;
+import world.bentobox.bentobox.api.localization.BentoBoxLocale;
import world.bentobox.bentobox.api.user.User;
/**
@@ -397,6 +400,307 @@ void testSetTranslationKnownLocale() throws IOException {
assertEquals("test string", lm.get("test.test"));
assertTrue(lm.setTranslation(Locale.US, "test.test", "a translation"));
assertEquals("a translation", lm.get("test.test"));
+ }
+
+ // ---- isLocaleAvailable ----
+
+ @Test
+ void testIsLocaleAvailableTrue() throws IOException {
+ makeFakeLocaleFile();
+ LocalesManager lm = new LocalesManager(plugin);
+ assertTrue(lm.isLocaleAvailable(Locale.US));
+ }
+
+ @Test
+ void testIsLocaleAvailableFalse() throws IOException {
+ makeFakeLocaleFile();
+ LocalesManager lm = new LocalesManager(plugin);
+ assertFalse(lm.isLocaleAvailable(Locale.JAPANESE));
+ }
+
+ // ---- getAvailablePrefixes ----
+
+ /**
+ * Makes a locale file that includes a prefixes section
+ */
+ private void makeFakeLocaleFileWithPrefixes() throws IOException {
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ localeDir.mkdirs();
+ File english = new File(localeDir, Locale.US.toLanguageTag() + ".yml");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("test.test", "test string");
+ yaml.set("prefixes.bskyblock", "[BSkyBlock]");
+ yaml.set("prefixes.acidisland", "[AcidIsland]");
+ yaml.save(english);
+
+ File french = new File(localeDir, Locale.FRANCE.toLanguageTag() + ".yml");
+ YamlConfiguration frYaml = new YamlConfiguration();
+ frYaml.set("test.test", "chaîne de test");
+ frYaml.set("prefixes.bskyblock", "[BSkyBlock-FR]");
+ frYaml.save(french);
+ }
+
+ @Test
+ void testGetAvailablePrefixes() throws IOException {
+ makeFakeLocaleFileWithPrefixes();
+ LocalesManager lm = new LocalesManager(plugin);
+ User user = mock(User.class);
+ when(user.getLocale()).thenReturn(Locale.US);
+ java.util.Set prefixes = lm.getAvailablePrefixes(user);
+ assertNotNull(prefixes);
+ assertTrue(prefixes.contains("bskyblock"));
+ assertTrue(prefixes.contains("acidisland"));
+ }
+
+ @Test
+ void testGetAvailablePrefixesUnknownUserLocale() throws IOException {
+ makeFakeLocaleFileWithPrefixes();
+ LocalesManager lm = new LocalesManager(plugin);
+ User user = mock(User.class);
+ // User has a locale that is not loaded
+ when(user.getLocale()).thenReturn(Locale.JAPANESE);
+ java.util.Set prefixes = lm.getAvailablePrefixes(user);
+ assertNotNull(prefixes);
+ // Should still contain prefixes from en-US and server default
+ assertTrue(prefixes.contains("bskyblock"));
+ }
+
+ @Test
+ void testGetAvailablePrefixesFrenchUser() throws IOException {
+ makeFakeLocaleFileWithPrefixes();
+ LocalesManager lm = new LocalesManager(plugin);
+ User user = mock(User.class);
+ when(user.getLocale()).thenReturn(Locale.FRANCE);
+ java.util.Set prefixes = lm.getAvailablePrefixes(user);
+ assertNotNull(prefixes);
+ // Should contain prefixes from all locales
+ assertTrue(prefixes.contains("bskyblock"));
+ assertTrue(prefixes.contains("acidisland"));
+ }
+
+ // ---- get(String) with server default language different from en-US ----
+
+ @Test
+ void testGetStringFromServerDefaultLocale() throws IOException {
+ // Create locale files with different content for a non-US server default
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ localeDir.mkdirs();
+
+ // en-US file
+ File english = new File(localeDir, "en-US.yml");
+ YamlConfiguration enYaml = new YamlConfiguration();
+ enYaml.set("shared.key", "english value");
+ enYaml.set("english.only", "only in english");
+ enYaml.save(english);
+
+ // fr-FR file with a different translation
+ File french = new File(localeDir, "fr-FR.yml");
+ YamlConfiguration frYaml = new YamlConfiguration();
+ frYaml.set("shared.key", "valeur française");
+ frYaml.set("french.only", "seulement en français");
+ frYaml.save(french);
+
+ // Set server default to fr-FR
+ Settings settings = new Settings();
+ settings.setDefaultLanguage("fr-FR");
+ when(plugin.getSettings()).thenReturn(settings);
+
+ LocalesManager lm = new LocalesManager(plugin);
+
+ // Should return from the server default language (fr-FR)
+ assertEquals("valeur française", lm.get("shared.key"));
+ // Key only in French should be found via server default
+ assertEquals("seulement en français", lm.get("french.only"));
+ // Key only in English - not in fr-FR, so falls through to en-US
+ assertEquals("only in english", lm.get("english.only"));
+ }
+
+ // ---- get(User, String) when user locale has translation ----
+
+ @Test
+ void testGetUserStringFromUserLocale() throws IOException {
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ localeDir.mkdirs();
+
+ File english = new File(localeDir, "en-US.yml");
+ YamlConfiguration enYaml = new YamlConfiguration();
+ enYaml.set("greeting", "Hello");
+ enYaml.save(english);
+
+ File french = new File(localeDir, "fr-FR.yml");
+ YamlConfiguration frYaml = new YamlConfiguration();
+ frYaml.set("greeting", "Bonjour");
+ frYaml.save(french);
+
+ LocalesManager lm = new LocalesManager(plugin);
+ User frenchUser = mock(User.class);
+ when(frenchUser.getLocale()).thenReturn(Locale.FRANCE);
+
+ // Should get translation from the user's locale (French)
+ assertEquals("Bonjour", lm.get(frenchUser, "greeting"));
+ }
+
+ @Test
+ void testGetUserStringFallsBackToServerDefault() throws IOException {
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ localeDir.mkdirs();
+
+ File english = new File(localeDir, "en-US.yml");
+ YamlConfiguration enYaml = new YamlConfiguration();
+ enYaml.set("greeting", "Hello");
+ enYaml.set("farewell", "Goodbye");
+ enYaml.save(english);
+
+ File french = new File(localeDir, "fr-FR.yml");
+ YamlConfiguration frYaml = new YamlConfiguration();
+ frYaml.set("greeting", "Bonjour");
+ // No "farewell" in French
+ frYaml.save(french);
+
+ LocalesManager lm = new LocalesManager(plugin);
+ User frenchUser = mock(User.class);
+ when(frenchUser.getLocale()).thenReturn(Locale.FRANCE);
+
+ // "farewell" not in French, should fall back to en-US
+ assertEquals("Goodbye", lm.get(frenchUser, "farewell"));
+ }
+
+ // ---- getOrDefault(User, ...) ----
+
+ @Test
+ void testGetOrDefaultUserFallsBackToDefault() throws IOException {
+ makeFakeLocaleFile();
+ LocalesManager lm = new LocalesManager(plugin);
+ User user = mock(User.class);
+ when(user.getLocale()).thenReturn(Locale.JAPANESE); // not loaded
+ assertEquals("fallback", lm.getOrDefault(user, "nonexistent.key", "fallback"));
+ }
+
+ // ---- getAvailableLocales sorting edge cases ----
+
+ @Test
+ void testGetAvailableLocalesSortDefaultFirst() throws IOException {
+ // Create 3 locale files: en-US, fr-FR, de-DE
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ localeDir.mkdirs();
+
+ for (String tag : new String[]{"en-US", "fr-FR", "de-DE"}) {
+ File f = new File(localeDir, tag + ".yml");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("test", tag);
+ yaml.save(f);
+ }
+
+ LocalesManager lm = new LocalesManager(plugin);
+ List sorted = lm.getAvailableLocales(true);
+
+ // en-US is the default language in Settings, so it should be first
+ assertEquals(Locale.US, sorted.get(0));
+ // Remaining English locales are second
+ assertEquals(3, sorted.size());
+ }
+
+ // ---- loadLocalesFromFile with invalid tag ----
+
+ @Test
+ void testLoadLocalesFromFileInvalidTag() throws IOException {
+ // Create a locale file with underscore separator (invalid BCP-47)
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ localeDir.mkdirs();
+
+ // Valid file
+ File english = new File(localeDir, "en-US.yml");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("test", "value");
+ yaml.save(english);
+
+ // Invalid tag file - uses underscore
+ File invalid = new File(localeDir, "zh_CN.yml");
+ YamlConfiguration invalidYaml = new YamlConfiguration();
+ invalidYaml.set("test", "value");
+ invalidYaml.save(invalid);
+
+ LocalesManager lm = new LocalesManager(plugin);
+
+ // Should have loaded en-US but skipped zh_CN
+ assertTrue(lm.isLocaleAvailable(Locale.US));
+ // zh_CN with underscore yields Locale.ROOT which has empty language
+ assertFalse(lm.isLocaleAvailable(Locale.ROOT));
+ verify(plugin).logWarning(Mockito.contains("zh_CN"));
+ }
+
+ // ---- loadLocalesFromFile with nonexistent folder ----
+
+ @Test
+ void testLoadLocalesFromFileNonexistentFolder() {
+ LocalesManager lm = new LocalesManager(plugin);
+ // Should not throw
+ lm.loadLocalesFromFile("NonExistentAddon");
+ }
+
+ // ---- getLanguages returns correct map ----
+
+ @Test
+ void testGetLanguagesMap() throws IOException {
+ makeFakeLocaleFile();
+ LocalesManager lm = new LocalesManager(plugin);
+ java.util.Map languages = lm.getLanguages();
+ assertNotNull(languages);
+ assertTrue(languages.containsKey(Locale.US));
+ assertTrue(languages.containsKey(Locale.FRANCE));
+ assertEquals(2, languages.size());
+ }
+
+ // ---- loadLocalesFromFile merges into existing locale ----
+
+ @Test
+ void testLoadLocalesFromFileMerge() throws IOException {
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ localeDir.mkdirs();
+
+ File english = new File(localeDir, "en-US.yml");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("key1", "value1");
+ yaml.save(english);
+
+ LocalesManager lm = new LocalesManager(plugin);
+ assertEquals("value1", lm.get("key1"));
+
+ // Now add a second file with more keys in a different addon folder
+ File addonDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + "TestAddon");
+ addonDir.mkdirs();
+ File addonEnglish = new File(addonDir, "en-US.yml");
+ YamlConfiguration addonYaml = new YamlConfiguration();
+ addonYaml.set("key2", "value2");
+ addonYaml.save(addonEnglish);
+
+ lm.loadLocalesFromFile("TestAddon");
+
+ // Both keys should be accessible
+ assertEquals("value1", lm.get("key1"));
+ assertEquals("value2", lm.get("key2"));
+ }
+
+ // ---- copyFile (via constructor) doesn't overwrite existing ----
+
+ @Test
+ void testCopyFileDoesNotOverwrite() throws IOException {
+ // Create locale files manually first
+ makeFakeLocaleFile();
+ LocalesManager lm = new LocalesManager(plugin);
+
+ // Get the current translation
+ assertEquals("test string", lm.get("test.test"));
+
+ // Modify the file
+ File localeDir = new File(plugin.getDataFolder(), LOCALE_FOLDER + File.separator + BENTOBOX);
+ File english = new File(localeDir, "en-US.yml");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("test.test", "modified value");
+ yaml.save(english);
+ // Create a new LocalesManager - should NOT overwrite the modified file
+ LocalesManager lm2 = new LocalesManager(plugin);
+ assertEquals("modified value", lm2.get("test.test"));
}
}
diff --git a/src/test/java/world/bentobox/bentobox/managers/WebManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/WebManagerTest.java
index 28b9a7205..4938704ed 100644
--- a/src/test/java/world/bentobox/bentobox/managers/WebManagerTest.java
+++ b/src/test/java/world/bentobox/bentobox/managers/WebManagerTest.java
@@ -1,11 +1,84 @@
package world.bentobox.bentobox.managers;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-class WebManagerTest {
+import world.bentobox.bentobox.CommonTestSetup;
+import world.bentobox.bentobox.web.catalog.CatalogEntry;
+import world.bentobox.bentobox.web.credits.Contributor;
+
+class WebManagerTest extends CommonTestSetup {
+
+ private WebManager wm;
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+ // Disable GitHub data download so the constructor doesn't make HTTP calls
+ plugin.getSettings().setGithubDownloadData(false);
+ wm = new WebManager(plugin);
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ // ---- Constructor ----
+
+ @Test
+ void testConstructorNoGitHub() {
+ assertNotNull(wm);
+ assertFalse(wm.getGitHub().isPresent());
+ }
+
+ // ---- Getters (empty state) ----
+
+ @Test
+ void testGetAddonsCatalogEmpty() {
+ List catalog = wm.getAddonsCatalog();
+ assertNotNull(catalog);
+ assertTrue(catalog.isEmpty());
+ }
+
+ @Test
+ void testGetGamemodesCatalogEmpty() {
+ List catalog = wm.getGamemodesCatalog();
+ assertNotNull(catalog);
+ assertTrue(catalog.isEmpty());
+ }
+
+ @Test
+ void testGetContributorsEmpty() {
+ List contributors = wm.getContributors("BentoBoxWorld/BentoBox");
+ assertNotNull(contributors);
+ assertTrue(contributors.isEmpty());
+ }
+
+ @Test
+ void testGetContributorsUnknownRepo() {
+ List contributors = wm.getContributors("unknown/repo");
+ assertNotNull(contributors);
+ assertTrue(contributors.isEmpty());
+ }
+
+ @Test
+ void testGetGitHubNotPresent() {
+ assertFalse(wm.getGitHub().isPresent());
+ }
+
+ // ---- isNewerVersion ----
@Test
void testIsNewerVersion_newerMinor() {
@@ -46,4 +119,30 @@ void testIsNewerVersion_majorBump() {
void testIsNewerVersion_stripsLeadingV() {
assertTrue(WebManager.isNewerVersion("3.11.2", "v3.12.0"));
}
+
+ @Test
+ void testIsNewerVersion_differentLengths() {
+ assertTrue(WebManager.isNewerVersion("3.11", "3.11.1"));
+ assertFalse(WebManager.isNewerVersion("3.11.1", "3.11"));
+ }
+
+ @Test
+ void testIsNewerVersion_singleDigit() {
+ assertTrue(WebManager.isNewerVersion("3", "4"));
+ assertFalse(WebManager.isNewerVersion("4", "3"));
+ }
+
+ @Test
+ void testIsNewerVersion_majorOlderMinorNewer() {
+ // 4.0.0 vs 3.99.99 - major takes precedence
+ assertFalse(WebManager.isNewerVersion("4.0.0", "3.99.99"));
+ }
+
+ // ---- requestGitHubData with no GitHub ----
+
+ @Test
+ void testRequestGitHubDataNoGitHub() {
+ // Should not throw when gitHub is not present
+ wm.requestGitHubData();
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/util/ItemParserTest.java b/src/test/java/world/bentobox/bentobox/util/ItemParserTest.java
new file mode 100644
index 000000000..8cd22d80a
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/util/ItemParserTest.java
@@ -0,0 +1,428 @@
+package world.bentobox.bentobox.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Base64;
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.bukkit.inventory.meta.PotionMeta;
+import org.bukkit.inventory.meta.SkullMeta;
+import org.bukkit.profile.PlayerProfile;
+import org.bukkit.profile.PlayerTextures;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import world.bentobox.bentobox.CommonTestSetup;
+
+/**
+ * Tests for {@link ItemParser}.
+ */
+class ItemParserTest extends CommonTestSetup {
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+
+ // Make createItemStack throw so parse() falls through to parseOld()
+ when(itemFactory.createItemStack(anyString())).thenThrow(new IllegalArgumentException("test"));
+
+ // Stub getItemMeta for materials that need specific meta types
+ when(itemFactory.getItemMeta(any(Material.class))).thenAnswer(invocation -> {
+ Material mat = invocation.getArgument(0);
+ if (mat == Material.POTION || mat == Material.SPLASH_POTION
+ || mat == Material.LINGERING_POTION || mat == Material.TIPPED_ARROW) {
+ return mock(PotionMeta.class);
+ }
+ if (mat == Material.PLAYER_HEAD) {
+ return mock(SkullMeta.class);
+ }
+ return mock(ItemMeta.class);
+ });
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ // ---- Null / blank input ----
+
+ @Test
+ void testParseNullReturnsNull() {
+ assertNull(ItemParser.parse(null));
+ }
+
+ @Test
+ void testParseNullWithDefaultReturnsDefault() {
+ ItemStack defaultItem = new ItemStack(Material.STONE);
+ assertEquals(defaultItem, ItemParser.parse(null, defaultItem));
+ }
+
+ @Test
+ void testParseEmptyStringReturnsNull() {
+ assertNull(ItemParser.parse(""));
+ }
+
+ @Test
+ void testParseBlankStringReturnsDefault() {
+ ItemStack defaultItem = new ItemStack(Material.STONE);
+ assertEquals(defaultItem, ItemParser.parse(" ", defaultItem));
+ }
+
+ // ---- Simple material (1 part) ----
+
+ @Test
+ void testParseSingleMaterial() {
+ ItemStack result = ItemParser.parse("DIAMOND");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND, result.getType());
+ assertEquals(1, result.getAmount());
+ }
+
+ @Test
+ void testParseSingleMaterialLowerCase() {
+ ItemStack result = ItemParser.parse("diamond");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND, result.getType());
+ }
+
+ @Test
+ void testParseSingleMaterialMixedCase() {
+ ItemStack result = ItemParser.parse("Diamond_Sword");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND_SWORD, result.getType());
+ }
+
+ // ---- Material:Quantity (2 parts) ----
+
+ @Test
+ void testParseMaterialWithQuantity() {
+ ItemStack result = ItemParser.parse("DIAMOND:20");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND, result.getType());
+ assertEquals(20, result.getAmount());
+ }
+
+ @Test
+ void testParseMaterialWithQuantityOne() {
+ ItemStack result = ItemParser.parse("IRON_INGOT:1");
+ assertNotNull(result);
+ assertEquals(Material.IRON_INGOT, result.getType());
+ assertEquals(1, result.getAmount());
+ }
+
+ @Test
+ void testParseInvalidMaterialReturnsDefault() {
+ ItemStack defaultItem = new ItemStack(Material.STONE);
+ ItemStack result = ItemParser.parse("NOT_A_REAL_MATERIAL:5", defaultItem);
+ assertEquals(defaultItem, result);
+ verify(plugin).logError(anyString());
+ }
+
+ @Test
+ void testParseInvalidMaterialReturnsNull() {
+ ItemStack result = ItemParser.parse("NOT_A_REAL_MATERIAL:5");
+ assertNull(result);
+ }
+
+ // ---- Material:Durability:Quantity (3 parts) ----
+
+ @Test
+ void testParseMaterialWithDurabilityAndQuantity() {
+ ItemStack result = ItemParser.parse("IRON_SWORD:10:5");
+ assertNotNull(result);
+ assertEquals(Material.IRON_SWORD, result.getType());
+ assertEquals(5, result.getAmount());
+ }
+
+ // ---- Quantity clamping ----
+
+ @Test
+ void testParseQuantityExceedsMaxClampedTo99() {
+ ItemStack result = ItemParser.parse("DIAMOND:500");
+ assertNotNull(result);
+ assertEquals(99, result.getAmount());
+ verify(plugin).logWarning(contains("exceeds max"));
+ }
+
+ @Test
+ void testParseQuantityZeroClampedTo1() {
+ ItemStack result = ItemParser.parse("DIAMOND:0");
+ assertNotNull(result);
+ assertEquals(1, result.getAmount());
+ verify(plugin).logWarning(contains("less than 1"));
+ }
+
+ @Test
+ void testParseNegativeQuantityClampedTo1() {
+ ItemStack result = ItemParser.parse("DIAMOND:-5");
+ assertNotNull(result);
+ assertEquals(1, result.getAmount());
+ verify(plugin).logWarning(contains("less than 1"));
+ }
+
+ @Test
+ void testParseQuantityExactly99IsValid() {
+ ItemStack result = ItemParser.parse("DIAMOND:99");
+ assertNotNull(result);
+ assertEquals(99, result.getAmount());
+ verify(plugin, never()).logWarning(anyString());
+ }
+
+ // ---- Custom Model Data (CMD-xxx) ----
+
+ @Test
+ void testParseWithCustomModelData() {
+ ItemStack result = ItemParser.parse("DIAMOND:CMD-500:1");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND, result.getType());
+ assertEquals(1, result.getAmount());
+ }
+
+ @Test
+ void testParseWithCustomModelDataNoQuantity() {
+ ItemStack result = ItemParser.parse("DIAMOND:CMD-42");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND, result.getType());
+ }
+
+ @Test
+ void testParseWithCustomModelDataAndQuantity() {
+ ItemStack result = ItemParser.parse("DIAMOND:CMD-100:64");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND, result.getType());
+ assertEquals(64, result.getAmount());
+ }
+
+ // ---- Potion parsing (new format: POTION:TYPE:QTY) ----
+
+ @Test
+ void testParsePotionNewFormat() {
+ ItemStack result = ItemParser.parse("POTION:STRENGTH:1");
+ assertNotNull(result);
+ assertEquals(Material.POTION, result.getType());
+ assertEquals(1, result.getAmount());
+ }
+
+ @Test
+ void testParseSplashPotion() {
+ ItemStack result = ItemParser.parse("SPLASH_POTION:HEALING:2");
+ assertNotNull(result);
+ assertEquals(Material.SPLASH_POTION, result.getType());
+ assertEquals(2, result.getAmount());
+ }
+
+ @Test
+ void testParseLingeringPotion() {
+ ItemStack result = ItemParser.parse("LINGERING_POTION:POISON:1");
+ assertNotNull(result);
+ assertEquals(Material.LINGERING_POTION, result.getType());
+ }
+
+ @Test
+ void testParseTippedArrow() {
+ ItemStack result = ItemParser.parse("TIPPED_ARROW:STRENGTH:3");
+ assertNotNull(result);
+ assertEquals(Material.TIPPED_ARROW, result.getType());
+ }
+
+ @Test
+ void testParsePotionInvalidTypeDefaultsToWater() {
+ ItemStack result = ItemParser.parse("POTION:NOT_A_POTION:1");
+ assertNotNull(result);
+ assertEquals(Material.POTION, result.getType());
+ // Invalid potion type should default to WATER via Enums.getIfPresent().or(WATER)
+ }
+
+ @Test
+ void testParsePotionWrongPartCountReturnsDefault() {
+ ItemStack defaultItem = new ItemStack(Material.STONE);
+ ItemStack result = ItemParser.parse("POTION:STRENGTH", defaultItem);
+ // Only 2 parts for a potion - not valid for either new (3) or old (6) format
+ // This falls through to parseItemQuantity which will fail since "STRENGTH" isn't a number
+ assertEquals(defaultItem, result);
+ }
+
+ // ---- Potion parsing (old format: POTION:NAME:LEVEL:EXTENDED:SPLASH:QTY) ----
+
+ @Test
+ void testParsePotionOldFormatSplash() {
+ ItemStack result = ItemParser.parse("POTION:STRENGTH:1:EXTENDED:SPLASH:1");
+ assertNotNull(result);
+ assertEquals(Material.SPLASH_POTION, result.getType());
+ }
+
+ @Test
+ void testParsePotionOldFormatLinger() {
+ ItemStack result = ItemParser.parse("POTION:WEAKNESS:1:NOTEXTENDED:LINGER:2");
+ assertNotNull(result);
+ assertEquals(Material.LINGERING_POTION, result.getType());
+ assertEquals(2, result.getAmount());
+ }
+
+ @Test
+ void testParsePotionOldFormatRegular() {
+ ItemStack result = ItemParser.parse("POTION:STRENGTH:2:NOTEXTENDED:NOSPLASH:1");
+ assertNotNull(result);
+ assertEquals(Material.POTION, result.getType());
+ }
+
+ @Test
+ void testParseTippedArrowOldFormat() {
+ ItemStack result = ItemParser.parse("TIPPED_ARROW:STRENGTH:1:EXTENDED:SPLASH:1");
+ assertNotNull(result);
+ assertEquals(Material.TIPPED_ARROW, result.getType());
+ }
+
+ // ---- Banner parsing ----
+
+ @Test
+ void testParseBannerSimple() {
+ ItemStack result = ItemParser.parse("WHITE_BANNER:1");
+ assertNotNull(result);
+ assertEquals(Material.WHITE_BANNER, result.getType());
+ assertEquals(1, result.getAmount());
+ }
+
+ @Test
+ void testParseBannerWithPattern() {
+ ItemStack result = ItemParser.parse("RED_BANNER:1:STRIPE_RIGHT:WHITE");
+ assertNotNull(result);
+ assertEquals(Material.RED_BANNER, result.getType());
+ }
+
+ @Test
+ void testParseBannerInvalidMaterialDefaultsToWhite() {
+ ItemStack result = ItemParser.parse("FAKE_BANNER:1");
+ // "FAKE_BANNER" contains "BANNER" so enters parseBanner path
+ // getMaterial returns null, so defaults to WHITE_BANNER
+ assertNotNull(result);
+ assertEquals(Material.WHITE_BANNER, result.getType());
+ }
+
+ // ---- Player head parsing ----
+
+ @Test
+ void testParsePlayerHeadWithQuantityOnly() {
+ ItemStack result = ItemParser.parse("PLAYER_HEAD:5");
+ assertNotNull(result);
+ assertEquals(Material.PLAYER_HEAD, result.getType());
+ assertEquals(5, result.getAmount());
+ }
+
+ @Test
+ void testParsePlayerHeadWithName() {
+ // Set up Bukkit.createPlayerProfile for name-based lookup
+ PlayerProfile profile = mock(PlayerProfile.class);
+ mockedBukkit.when(() -> Bukkit.createPlayerProfile("BONNe1704")).thenReturn(profile);
+
+ ItemStack result = ItemParser.parse("PLAYER_HEAD:BONNe1704");
+ assertNotNull(result);
+ assertEquals(Material.PLAYER_HEAD, result.getType());
+ }
+
+ @Test
+ void testParsePlayerHeadWithNameAndQuantity() {
+ PlayerProfile profile = mock(PlayerProfile.class);
+ mockedBukkit.when(() -> Bukkit.createPlayerProfile("TestPlayer")).thenReturn(profile);
+
+ ItemStack result = ItemParser.parse("PLAYER_HEAD:TestPlayer:3");
+ assertNotNull(result);
+ assertEquals(Material.PLAYER_HEAD, result.getType());
+ assertEquals(3, result.getAmount());
+ }
+
+ @Test
+ void testParsePlayerHeadWithFullUUID() {
+ UUID uuid = UUID.randomUUID();
+ PlayerProfile profile = mock(PlayerProfile.class);
+ mockedBukkit.when(() -> Bukkit.createPlayerProfile(uuid)).thenReturn(profile);
+
+ ItemStack result = ItemParser.parse("PLAYER_HEAD:" + uuid);
+ assertNotNull(result);
+ assertEquals(Material.PLAYER_HEAD, result.getType());
+ }
+
+ @Test
+ void testParsePlayerHeadWithTrimmedUUID() {
+ // Trimmed UUID is 32 chars (no dashes)
+ UUID uuid = UUID.randomUUID();
+ String trimmed = uuid.toString().replace("-", "");
+ PlayerProfile profile = mock(PlayerProfile.class);
+ mockedBukkit.when(() -> Bukkit.createPlayerProfile(uuid)).thenReturn(profile);
+
+ ItemStack result = ItemParser.parse("PLAYER_HEAD:" + trimmed);
+ assertNotNull(result);
+ assertEquals(Material.PLAYER_HEAD, result.getType());
+ }
+
+ @Test
+ void testParsePlayerHeadWithBase64Texture() {
+ // Create a valid base64-encoded texture JSON
+ String textureJson = "{\"textures\":{\"SKIN\":{\"url\":\"http://textures.minecraft.net/texture/abc123\"}}}";
+ String base64 = Base64.getEncoder().encodeToString(textureJson.getBytes());
+
+ PlayerProfile profile = mock(PlayerProfile.class);
+ PlayerTextures textures = mock(PlayerTextures.class);
+ when(profile.getTextures()).thenReturn(textures);
+ mockedBukkit.when(() -> Bukkit.createPlayerProfile(any(UUID.class), any(String.class))).thenReturn(profile);
+
+ ItemStack result = ItemParser.parse("PLAYER_HEAD:" + base64 + ":1");
+ assertNotNull(result);
+ assertEquals(Material.PLAYER_HEAD, result.getType());
+ }
+
+ @Test
+ void testParsePlayerHeadInvalidProfileLogsError() {
+ // Don't stub createPlayerProfile - it will throw
+ mockedBukkit.when(() -> Bukkit.createPlayerProfile("BadPlayer")).thenThrow(new RuntimeException("test"));
+
+ ItemStack result = ItemParser.parse("PLAYER_HEAD:BadPlayer");
+ // Should still return a head, just without the profile
+ assertNotNull(result);
+ assertEquals(Material.PLAYER_HEAD, result.getType());
+ verify(plugin).logError(contains("Could not parse player head"));
+ }
+
+ // ---- Single-arg parse delegates correctly ----
+
+ @Test
+ void testParseSingleArgDelegatesToTwoArg() {
+ // parse(String) should call parse(String, null)
+ assertNull(ItemParser.parse(null));
+
+ ItemStack result = ItemParser.parse("DIAMOND");
+ assertNotNull(result);
+ assertEquals(Material.DIAMOND, result.getType());
+ }
+
+ // ---- Various materials ----
+
+ @Test
+ void testParseVariousMaterials() {
+ // Test a variety of valid materials
+ String[] materials = {"STONE", "DIRT", "COBBLESTONE", "OAK_PLANKS", "GOLDEN_APPLE"};
+ for (String mat : materials) {
+ ItemStack result = ItemParser.parse(mat);
+ assertNotNull(result, "Failed to parse: " + mat);
+ assertEquals(Material.valueOf(mat), result.getType(), "Wrong material for: " + mat);
+ }
+ }
+}
diff --git a/src/test/java/world/bentobox/bentobox/util/UtilTest.java b/src/test/java/world/bentobox/bentobox/util/UtilTest.java
new file mode 100644
index 000000000..affaf12fc
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/util/UtilTest.java
@@ -0,0 +1,343 @@
+package world.bentobox.bentobox.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.Server;
+import org.bukkit.block.BlockFace;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import net.kyori.adventure.text.Component;
+import world.bentobox.bentobox.CommonTestSetup;
+import world.bentobox.bentobox.api.user.User;
+
+/**
+ * Tests for {@link Util}.
+ */
+class UtilTest extends CommonTestSetup {
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+ Util.setPlugin(plugin);
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ // ---- blockFaceToFloat ----
+
+ @Test
+ void testBlockFaceToFloatNorth() {
+ assertEquals(0F, Util.blockFaceToFloat(BlockFace.NORTH));
+ }
+
+ @Test
+ void testBlockFaceToFloatEast() {
+ assertEquals(90F, Util.blockFaceToFloat(BlockFace.EAST));
+ }
+
+ @Test
+ void testBlockFaceToFloatSouth() {
+ assertEquals(180F, Util.blockFaceToFloat(BlockFace.SOUTH));
+ }
+
+ @Test
+ void testBlockFaceToFloatWest() {
+ assertEquals(270F, Util.blockFaceToFloat(BlockFace.WEST));
+ }
+
+ @Test
+ void testBlockFaceToFloatNorthEast() {
+ assertEquals(45F, Util.blockFaceToFloat(BlockFace.NORTH_EAST));
+ }
+
+ @Test
+ void testBlockFaceToFloatSouthEast() {
+ assertEquals(135F, Util.blockFaceToFloat(BlockFace.SOUTH_EAST));
+ }
+
+ @Test
+ void testBlockFaceToFloatSouthWest() {
+ assertEquals(225F, Util.blockFaceToFloat(BlockFace.SOUTH_WEST));
+ }
+
+ @Test
+ void testBlockFaceToFloatNorthWest() {
+ assertEquals(315F, Util.blockFaceToFloat(BlockFace.NORTH_WEST));
+ }
+
+ @Test
+ void testBlockFaceToFloatEastNorthEast() {
+ assertEquals(67.5F, Util.blockFaceToFloat(BlockFace.EAST_NORTH_EAST));
+ }
+
+ @Test
+ void testBlockFaceToFloatNorthNorthEast() {
+ assertEquals(22.5F, Util.blockFaceToFloat(BlockFace.NORTH_NORTH_EAST));
+ }
+
+ @Test
+ void testBlockFaceToFloatNorthNorthWest() {
+ assertEquals(337.5F, Util.blockFaceToFloat(BlockFace.NORTH_NORTH_WEST));
+ }
+
+ @Test
+ void testBlockFaceToFloatSouthSouthEast() {
+ assertEquals(157.5F, Util.blockFaceToFloat(BlockFace.SOUTH_SOUTH_EAST));
+ }
+
+ @Test
+ void testBlockFaceToFloatSouthSouthWest() {
+ assertEquals(202.5F, Util.blockFaceToFloat(BlockFace.SOUTH_SOUTH_WEST));
+ }
+
+ @Test
+ void testBlockFaceToFloatWestNorthWest() {
+ assertEquals(292.5F, Util.blockFaceToFloat(BlockFace.WEST_NORTH_WEST));
+ }
+
+ @Test
+ void testBlockFaceToFloatWestSouthWest() {
+ assertEquals(247.5F, Util.blockFaceToFloat(BlockFace.WEST_SOUTH_WEST));
+ }
+
+ @Test
+ void testBlockFaceToFloatDefault() {
+ // UP, DOWN, SELF should return 0
+ assertEquals(0F, Util.blockFaceToFloat(BlockFace.UP));
+ }
+
+ // ---- isVersionCompatible ----
+
+ @Test
+ void testIsVersionCompatibleExactMatch() {
+ assertTrue(Util.isVersionCompatible("1.20.4", "1.20.4"));
+ }
+
+ @Test
+ void testIsVersionCompatibleNewer() {
+ assertTrue(Util.isVersionCompatible("1.21.0", "1.20.4"));
+ }
+
+ @Test
+ void testIsVersionCompatibleOlder() {
+ assertFalse(Util.isVersionCompatible("1.19.4", "1.20.0"));
+ }
+
+ @Test
+ void testIsVersionCompatibleSnapshotVsRelease() {
+ // Snapshot is lower precedence than release
+ assertFalse(Util.isVersionCompatible("1.20.4-SNAPSHOT", "1.20.4"));
+ }
+
+ @Test
+ void testIsVersionCompatibleReleaseVsSnapshot() {
+ assertTrue(Util.isVersionCompatible("1.20.4", "1.20.4-SNAPSHOT"));
+ }
+
+ @Test
+ void testIsVersionCompatibleBothSnapshots() {
+ assertTrue(Util.isVersionCompatible("1.20.4-SNAPSHOT", "1.20.4-SNAPSHOT"));
+ }
+
+ @Test
+ void testIsVersionCompatibleDifferentLengths() {
+ assertTrue(Util.isVersionCompatible("1.21", "1.20.4"));
+ }
+
+ @Test
+ void testIsVersionCompatibleShorterRequired() {
+ assertTrue(Util.isVersionCompatible("1.20.4", "1.20"));
+ }
+
+ // ---- parseGitHubDate ----
+
+ @Test
+ void testParseGitHubDateValid() {
+ Date result = Util.parseGitHubDate("2024-01-15T10:30:00Z");
+ assertNotNull(result);
+ }
+
+ @Test
+ void testParseGitHubDateInvalid() {
+ Date result = Util.parseGitHubDate("not-a-date");
+ assertNull(result);
+ }
+
+ // ---- getLocationString ----
+
+ @Test
+ void testGetLocationStringNull() {
+ assertNull(Util.getLocationString(null));
+ }
+
+ @Test
+ void testGetLocationStringEmpty() {
+ assertNull(Util.getLocationString(""));
+ }
+
+ @Test
+ void testGetLocationStringBlank() {
+ assertNull(Util.getLocationString(" "));
+ }
+
+ @Test
+ void testGetLocationStringValid() {
+ // Format: world:x:y:z:yaw:pitch
+ mockedBukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world);
+ int yawBits = Float.floatToIntBits(90F);
+ int pitchBits = Float.floatToIntBits(0F);
+ String loc = "world:10:64:20:" + yawBits + ":" + pitchBits;
+ Location result = Util.getLocationString(loc);
+ assertNotNull(result);
+ assertEquals(world, result.getWorld());
+ assertEquals(10.5D, result.getX());
+ assertEquals(64D, result.getY());
+ assertEquals(20.5D, result.getZ());
+ }
+
+ @Test
+ void testGetLocationStringWrongParts() {
+ assertNull(Util.getLocationString("world:10:64"));
+ }
+
+ @Test
+ void testGetLocationStringUnknownWorld() {
+ mockedBukkit.when(() -> Bukkit.getWorld("unknown")).thenReturn(null);
+ int bits = Float.floatToIntBits(0F);
+ assertNull(Util.getLocationString("unknown:10:64:20:" + bits + ":" + bits));
+ }
+
+ // ---- broadcast ----
+
+ @Test
+ void testBroadcastNoPlayers() {
+ mockedBukkit.when(Bukkit::getOnlinePlayers).thenReturn(Collections.emptyList());
+ assertEquals(0, Util.broadcast("test.message"));
+ }
+
+ @Test
+ void testBroadcastWithPermittedPlayer() {
+ // Use the mockPlayer from CommonTestSetup which is already wired up
+ when(mockPlayer.hasPermission(Server.BROADCAST_CHANNEL_USERS)).thenReturn(true);
+ mockedBukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(mockPlayer));
+
+ int count = Util.broadcast("test.message");
+ assertEquals(1, count);
+ }
+
+ @Test
+ void testBroadcastSkipsUnpermitted() {
+ when(mockPlayer.hasPermission(Server.BROADCAST_CHANNEL_USERS)).thenReturn(false);
+ mockedBukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(mockPlayer));
+
+ assertEquals(0, Util.broadcast("test.message"));
+ }
+
+ // ---- runCommands ----
+
+ @Test
+ void testRunCommandsSudo() {
+ User user = mock(User.class);
+ when(user.getName()).thenReturn("TestPlayer");
+ when(user.isOnline()).thenReturn(true);
+ when(user.performCommand("warp home")).thenReturn(true);
+
+ Util.runCommands(user, List.of("[SUDO]warp home"), "test");
+ verify(user).performCommand("warp home");
+ }
+
+ @Test
+ void testRunCommandsSudoOfflineLogsError() {
+ User user = mock(User.class);
+ when(user.getName()).thenReturn("OfflinePlayer");
+ when(user.isOnline()).thenReturn(false);
+
+ Util.runCommands(user, List.of("[SUDO]warp home"), "test");
+ verify(plugin).logError(contains("Could not execute test command for OfflinePlayer"));
+ }
+
+ @Test
+ void testRunCommandsSudoFails() {
+ User user = mock(User.class);
+ when(user.getName()).thenReturn("TestPlayer");
+ when(user.isOnline()).thenReturn(true);
+ when(user.performCommand(anyString())).thenReturn(false);
+
+ Util.runCommands(user, List.of("[SUDO]bad command"), "test");
+ verify(plugin).logError(contains("Could not execute test command for TestPlayer"));
+ }
+
+ @Test
+ void testRunCommandsEmptyList() {
+ User user = mock(User.class);
+ when(user.getName()).thenReturn("TestPlayer");
+
+ // Should not throw
+ Util.runCommands(user, List.of(), "test");
+ }
+
+ @Test
+ void testRunCommandsPlayerPlaceholderReplaced() {
+ User user = mock(User.class);
+ when(user.getName()).thenReturn("TestPlayer");
+ when(user.isOnline()).thenReturn(true);
+ when(user.performCommand("give TestPlayer diamond")).thenReturn(true);
+
+ Util.runCommands(user, List.of("[SUDO]give [player] diamond"), "test");
+ verify(user).performCommand("give TestPlayer diamond");
+ }
+
+ @Test
+ void testRunCommandsOwnerPlaceholderReplaced() {
+ User user = mock(User.class);
+ when(user.getName()).thenReturn("TestPlayer");
+ when(user.isOnline()).thenReturn(true);
+ when(user.performCommand("msg OwnerName hello")).thenReturn(true);
+
+ Util.runCommands(user, "OwnerName", List.of("[SUDO]msg [owner] hello"), "test");
+ verify(user).performCommand("msg OwnerName hello");
+ }
+
+ // ---- bukkitToAdventure ----
+
+ @Test
+ void testBukkitToAdventureNull() {
+ Component result = Util.bukkitToAdventure(null);
+ assertEquals(Component.empty(), result);
+ }
+
+ @Test
+ void testBukkitToAdventureSimpleString() {
+ Component result = Util.bukkitToAdventure("Hello World");
+ assertNotNull(result);
+ }
+
+ @Test
+ void testBukkitToAdventureLegacyCodes() {
+ Component result = Util.bukkitToAdventure("\u00a7aGreen text");
+ assertNotNull(result);
+ }
+}
diff --git a/src/test/java/world/bentobox/bentobox/util/teleport/SafeSpotTeleportTest.java b/src/test/java/world/bentobox/bentobox/util/teleport/SafeSpotTeleportTest.java
new file mode 100644
index 000000000..a9bec2862
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/util/teleport/SafeSpotTeleportTest.java
@@ -0,0 +1,382 @@
+package world.bentobox.bentobox.util.teleport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+import org.bukkit.Bukkit;
+import org.bukkit.ChunkSnapshot;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.scheduler.BukkitTask;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import world.bentobox.bentobox.CommonTestSetup;
+import world.bentobox.bentobox.database.objects.Island;
+import world.bentobox.bentobox.util.Pair;
+import world.bentobox.bentobox.util.Util;
+
+/**
+ * Tests for {@link SafeSpotTeleport}.
+ */
+class SafeSpotTeleportTest extends CommonTestSetup {
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+
+ // Location setup
+ when(location.getWorld()).thenReturn(world);
+ when(location.getBlockX()).thenReturn(0);
+ when(location.getBlockY()).thenReturn(64);
+ when(location.getBlockZ()).thenReturn(0);
+ when(location.clone()).thenReturn(location);
+
+ // World setup
+ when(world.getMaxHeight()).thenReturn(320);
+ when(world.getMinHeight()).thenReturn(-64);
+ when(world.getEnvironment()).thenReturn(World.Environment.NORMAL);
+
+ // Island setup
+ when(island.getProtectionRange()).thenReturn(50);
+ when(island.getProtectionCenter()).thenReturn(location);
+ when(im.getIslandAt(any(Location.class))).thenReturn(Optional.of(island));
+ when(island.inIslandSpace(any(Pair.class))).thenReturn(true);
+
+ // Safe location check
+ when(im.isSafeLocation(any(Location.class))).thenReturn(false);
+ when(im.checkIfSafe(any(World.class), any(Material.class), any(Material.class), any(Material.class))).thenReturn(false);
+
+ // Scheduler - return a mock task for the timer
+ BukkitTask mockTask = mock(BukkitTask.class);
+ when(sch.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())).thenReturn(mockTask);
+
+ // Util.getChunkAtAsync - complete immediately
+ mockedUtil.when(() -> Util.getChunkAtAsync(any(Location.class)))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ // Settings - getSafeSpotSearchVerticalRange defaults to 400 in Settings
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ // ---- Builder validation ----
+
+ @Test
+ void testBuilderBuildNullEntity() {
+ SafeSpotTeleport result = new SafeSpotTeleport.Builder(plugin)
+ .location(location)
+ .build();
+ assertNull(result);
+ verify(plugin).logError("Attempt to safe teleport a null entity!");
+ }
+
+ @Test
+ void testBuilderBuildNullLocation() {
+ SafeSpotTeleport result = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .build();
+ assertNull(result);
+ verify(plugin).logError("Attempt to safe teleport to a null location!");
+ }
+
+ @Test
+ void testBuilderBuildNullWorld() {
+ Location noWorld = mock(Location.class);
+ when(noWorld.getWorld()).thenReturn(null);
+ SafeSpotTeleport result = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(noWorld)
+ .build();
+ assertNull(result);
+ verify(plugin).logError("Attempt to safe teleport to a null world!");
+ }
+
+ @Test
+ void testBuilderBuildValid() {
+ SafeSpotTeleport result = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .build();
+ assertNotNull(result);
+ }
+
+ @Test
+ void testBuilderBuildFuture() {
+ CompletableFuture future = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .buildFuture();
+ assertNotNull(future);
+ }
+
+ @Test
+ void testBuilderBuildFutureNullEntity() {
+ CompletableFuture future = new SafeSpotTeleport.Builder(plugin)
+ .location(location)
+ .buildFuture();
+ // Should complete with null since entity is null
+ assertTrue(future.isDone());
+ assertNull(future.join());
+ }
+
+ // ---- Builder setters / getters ----
+
+ @Test
+ void testBuilderPortal() {
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin);
+ assertFalse(builder.isPortal());
+ builder.portal();
+ assertTrue(builder.isPortal());
+ }
+
+ @Test
+ void testBuilderHomeName() {
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin);
+ assertEquals("", builder.getHomeName());
+ builder.homeName("myHome");
+ assertEquals("myHome", builder.getHomeName());
+ }
+
+ @Test
+ void testBuilderCancelIfFail() {
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin);
+ assertFalse(builder.isCancelIfFail());
+ builder.cancelIfFail(true);
+ assertTrue(builder.isCancelIfFail());
+ }
+
+ @Test
+ void testBuilderFailureMessage() {
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin);
+ assertEquals("", builder.getFailureMessage());
+ builder.failureMessage("custom.message");
+ assertEquals("custom.message", builder.getFailureMessage());
+ }
+
+ @Test
+ void testBuilderThenRun() {
+ Runnable r = mock(Runnable.class);
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin);
+ assertNull(builder.getRunnable());
+ builder.thenRun(r);
+ assertEquals(r, builder.getRunnable());
+ }
+
+ @Test
+ void testBuilderIfFail() {
+ Runnable r = mock(Runnable.class);
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin);
+ assertNull(builder.getFailRunnable());
+ builder.ifFail(r);
+ assertEquals(r, builder.getFailRunnable());
+ }
+
+ @Test
+ void testBuilderIsland() {
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin);
+ builder.island(island);
+ assertEquals(location, builder.getLocation());
+ }
+
+ @Test
+ void testBuilderDefaultFailureMessageForPlayer() {
+ // When entity is a Player and failureMessage is empty, build() should set a default
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location);
+ builder.build();
+ assertEquals("general.errors.no-safe-location-found", builder.getFailureMessage());
+ }
+
+ @Test
+ void testBuilderCustomFailureMessageForPlayer() {
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .failureMessage("custom.fail");
+ builder.build();
+ assertEquals("custom.fail", builder.getFailureMessage());
+ }
+
+ @Test
+ void testBuilderNonPlayerEntityNoDefaultMessage() {
+ Entity entity = mock(Entity.class);
+ SafeSpotTeleport.Builder builder = new SafeSpotTeleport.Builder(plugin)
+ .entity(entity)
+ .location(location);
+ builder.build();
+ // Non-player entities should keep empty failure message
+ assertEquals("", builder.getFailureMessage());
+ }
+
+ // ---- tryToGo - safe location ----
+
+ @Test
+ void testTryToGoSafeLocation() {
+ // If location is already safe, should teleport immediately
+ when(im.isSafeLocation(location)).thenReturn(true);
+ mockedUtil.when(() -> Util.teleportAsync(any(Entity.class), any(Location.class)))
+ .thenReturn(CompletableFuture.completedFuture(true));
+
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .build();
+ assertNotNull(sst);
+ }
+
+ @Test
+ void testTryToGoSafeLocationPortalMode() {
+ // In portal mode, safe location becomes bestSpot, then starts scanning
+ when(im.isSafeLocation(location)).thenReturn(true);
+
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .portal()
+ .build();
+ assertNotNull(sst);
+ }
+
+ // ---- getChunksToScan ----
+
+ @Test
+ void testGetChunksToScan() {
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .build();
+
+ List> chunks = sst.getChunksToScan();
+ assertNotNull(chunks);
+ assertFalse(chunks.isEmpty());
+ }
+
+ // ---- checkBlock / scanChunk ----
+
+ @Test
+ void testCheckBlockUnsafe() {
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .build();
+
+ ChunkSnapshot chunk = mock(ChunkSnapshot.class);
+ when(chunk.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.STONE);
+ // checkIfSafe returns false by default
+
+ assertFalse(sst.checkBlock(chunk, 0, 64, 0));
+ }
+
+ @Test
+ void testCheckBlockPortalFound() {
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .portal()
+ .build();
+
+ ChunkSnapshot chunk = mock(ChunkSnapshot.class);
+ when(chunk.getBlockType(0, 64, 0)).thenReturn(Material.STONE);
+ when(chunk.getBlockType(0, 65, 0)).thenReturn(Material.NETHER_PORTAL);
+ when(chunk.getBlockType(0, 66, 0)).thenReturn(Material.NETHER_PORTAL);
+
+ // Not safe, but portal detection should toggle portal mode off
+ assertFalse(sst.checkBlock(chunk, 0, 64, 0));
+ }
+
+ @Test
+ void testScanChunkNoSafeSpot() {
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .build();
+
+ ChunkSnapshot chunk = mock(ChunkSnapshot.class);
+ when(chunk.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.LAVA);
+ when(chunk.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(64);
+
+ assertFalse(sst.scanChunk(chunk));
+ }
+
+ @Test
+ void testScanChunkSafeSpotFound() {
+ // Mock teleportAsync for the teleport that happens when a safe spot is found
+ mockedUtil.when(() -> Util.teleportAsync(any(Entity.class), any(Location.class)))
+ .thenReturn(CompletableFuture.completedFuture(true));
+
+ BukkitTask mockTask = mock(BukkitTask.class);
+ when(sch.runTask(any(), any(Runnable.class))).thenAnswer(inv -> {
+ ((Runnable) inv.getArgument(1)).run();
+ return mockTask;
+ });
+
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .build();
+
+ ChunkSnapshot chunk = mock(ChunkSnapshot.class);
+ when(chunk.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.AIR);
+ when(chunk.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(64);
+ // Make checkIfSafe return true for the first block
+ when(im.checkIfSafe(any(World.class), any(Material.class), any(Material.class), any(Material.class))).thenReturn(true);
+
+ assertTrue(sst.scanChunk(chunk));
+ }
+
+ @Test
+ void testScanChunkPortalModeStashesBestSpot() {
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(mockPlayer)
+ .location(location)
+ .portal()
+ .build();
+
+ ChunkSnapshot chunk = mock(ChunkSnapshot.class);
+ when(chunk.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.AIR);
+ when(chunk.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(64);
+ when(chunk.getX()).thenReturn(0);
+ when(chunk.getZ()).thenReturn(0);
+ when(im.checkIfSafe(any(World.class), any(Material.class), any(Material.class), any(Material.class))).thenReturn(true);
+
+ // In portal mode, safe() returns false (stashes bestSpot instead of teleporting)
+ assertFalse(sst.scanChunk(chunk));
+ }
+
+ // ---- Builder with non-player entity ----
+
+ @Test
+ void testBuildWithNonPlayerEntity() {
+ Entity entity = mock(Entity.class);
+ SafeSpotTeleport sst = new SafeSpotTeleport.Builder(plugin)
+ .entity(entity)
+ .location(location)
+ .build();
+ assertNotNull(sst);
+ }
+}