Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.bukkit.World;

Expand Down Expand Up @@ -106,9 +108,11 @@ public boolean execute(User user, String label, List<String> args) {
// Check if the name is known if one is given
if (!args.isEmpty()) {
// Assemble the arguments into one string
final String name = String.join(" ", args);
// If the name is not in the list
if (!names.containsKey(name)) {
final String typed = String.join(" ", args);
// Forgiving lookup: exact, then case/space-insensitive, then unique prefix
final String name = resolveName(typed, names.keySet());
// If the name could not be resolved to a destination
if (name == null) {
// Failed home name check
user.sendMessage("commands.island.go.unknown-home");
user.sendMessage("commands.island.sethome.homes-are");
Expand Down Expand Up @@ -187,6 +191,56 @@ public record IslandInfo(
boolean islandName) {
}

/**
* Resolves the text a player typed to one of the valid destination names using
* forgiving matching, so small mistakes still teleport them instead of dumping a
* list. Matching is tried in decreasing order of confidence:
* <ol>
* <li>exact match (existing behaviour, always wins);</li>
* <li>case-, colour- and whitespace-insensitive exact match;</li>
* <li>a unique case-insensitive prefix (e.g. {@code hom} for {@code Home}).</li>
* </ol>
* Any step that is ambiguous (more than one candidate) is skipped, so an unclear
* input falls through to the normal unknown-home list rather than guessing.
*
* @param typed the raw string the player typed
* @param names the valid destination names
* @return the canonical destination name to use, or {@code null} if none matched confidently
*/
static String resolveName(String typed, Set<String> names) {
// 1. Exact match wins and preserves the original behaviour
if (names.contains(typed)) {
return typed;
}
String norm = normalize(typed);
if (norm.isEmpty()) {
return null;
}
// 2. Case/colour/whitespace-insensitive exact match, if unambiguous
List<String> exact = names.stream().filter(n -> normalize(n).equals(norm)).toList();
if (exact.size() == 1) {
return exact.get(0);
}
if (!exact.isEmpty()) {
// Several names collapse to the same normalized form - too ambiguous to guess
return null;
}
// 3. Unique prefix match
List<String> prefix = names.stream().filter(n -> normalize(n).startsWith(norm)).toList();
return prefix.size() == 1 ? prefix.get(0) : null;
}

/**
* Normalizes a name for forgiving comparison: strips colour codes, lower-cases and
* collapses runs of whitespace to a single space.
*
* @param s the string to normalize
* @return the normalized form
*/
private static String normalize(String s) {
return Util.stripColor(s).toLowerCase(Locale.ENGLISH).replaceAll("\\s+", " ").trim();
}

/**
* Creates a mapping of valid teleport destination names for a user.
* Includes:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package world.bentobox.bentobox.api.commands.island;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

Expand Down Expand Up @@ -111,6 +114,11 @@ public void setUp() throws Exception {
BukkitScheduler sch = mock(BukkitScheduler.class);
mockedBukkit.when(Bukkit::getScheduler).thenReturn(sch);
when(sch.runTaskLater(any(), any(Runnable.class), any(Long.class))).thenReturn(task);
// Run scheduled (zero-delay) teleport tasks synchronously so we can verify them
when(sch.runTask(any(), any(Runnable.class))).thenAnswer((Answer<BukkitTask>) invocation -> {
invocation.getArgument(1, Runnable.class).run();
return task;
});
// Event register
mockedBukkit.when(Bukkit::getPluginManager).thenReturn(pim);

Expand Down Expand Up @@ -253,6 +261,89 @@ void testExecuteArgs1MultipleHomes() {
checkSpigotMessage("commands.island.sethome.home-list-syntax");
}

/**
* Forgiving matching: an island name typed in the wrong case still teleports.
* Test method for {@link IslandGoCommand#execute(User, String, List)}
*/
@Test
void testExecuteIslandNameWrongCase() {
when(island.getName()).thenReturn("MyIsland");
assertTrue(igc.execute(user, igc.getLabel(), Collections.singletonList("myisland")));
// No unknown-home moan; we teleported to the island
verify(mockPlayer, never()).sendMessage("commands.island.go.unknown-home");
verify(im).homeTeleportAsync(island, user);
}

/**
* Forgiving matching: a unique prefix of a home name teleports to that home.
* Test method for {@link IslandGoCommand#execute(User, String, List)}
*/
@Test
void testExecuteHomeNamePrefix() {
when(island.getHomes()).thenReturn(Map.of("Home", mock(Location.class)));
when(im.homeTeleportAsync(world, mockPlayer, "Home"))
.thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true));
assertTrue(igc.execute(user, igc.getLabel(), Collections.singletonList("hom")));
verify(mockPlayer, never()).sendMessage("commands.island.go.unknown-home");
verify(im).homeTeleportAsync(world, mockPlayer, "Home");
}

/**
* Test method for {@link IslandGoCommand#resolveName(String, Set)}
*/
@Test
void testResolveNameExactWins() {
assertEquals("Home", IslandGoCommand.resolveName("Home", Set.of("Home", "Hut")));
}

/**
* Test method for {@link IslandGoCommand#resolveName(String, Set)}
*/
@Test
void testResolveNameCaseAndSpaceInsensitive() {
assertEquals("My Home", IslandGoCommand.resolveName("my HOME", Set.of("My Home", "Hut")));
}

/**
* Test method for {@link IslandGoCommand#resolveName(String, Set)}
*/
@Test
void testResolveNameUniquePrefix() {
assertEquals("Home", IslandGoCommand.resolveName("hom", Set.of("Home", "Base")));
}

/**
* Test method for {@link IslandGoCommand#resolveName(String, Set)}
*/
@Test
void testResolveNameAmbiguousPrefixReturnsNull() {
assertNull(IslandGoCommand.resolveName("h", Set.of("Home", "Hut")));
}

/**
* Test method for {@link IslandGoCommand#resolveName(String, Set)}
*/
@Test
void testResolveNameAmbiguousNormalizedReturnsNull() {
assertNull(IslandGoCommand.resolveName("home", Set.of("Home", "HOME")));
}

/**
* Test method for {@link IslandGoCommand#resolveName(String, Set)}
*/
@Test
void testResolveNameNoMatchReturnsNull() {
assertNull(IslandGoCommand.resolveName("xyzzy", Set.of("Home", "Hut")));
}

/**
* Test method for {@link IslandGoCommand#resolveName(String, Set)}
*/
@Test
void testResolveNameBlankReturnsNull() {
assertNull(IslandGoCommand.resolveName(" ", Set.of("Home", "Hut")));
}

/**
* Test method for {@link IslandGoCommand#execute(User, String, List)}
*/
Expand Down
Loading