From 744aef63b5ffdd81bf03ab200c5cdbbd2e3e9f8d Mon Sep 17 00:00:00 2001 From: notevil Date: Thu, 23 Apr 2026 16:01:30 +0200 Subject: [PATCH] P3-03 : parse DataDrivenItem JSON animations block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire le parser JSON pour le bloc "animations" (living_motions + on_equip + on_unequip). Résolution motion cross-enum (LivingMotions vanilla EF + TiedUpLivingMotions custom). Fuzzy-match Levenshtein pour suggestion en cas de typo modder (Did you mean 'IDLE'?). Remplace le null hardcodé à DataDrivenItemParser:352 (P3-02 TODO). body : tolerance malformed RL, non-string values, unknown motions. --- .../datadriven/DataDrivenItemParser.java | 250 +++++++++- .../DataDrivenItemParserAnimationsTest.java | 429 ++++++++++++++++++ 2 files changed, 676 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java index 426db2f..721198b 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java @@ -4,6 +4,9 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.tiedup.remake.rig.anim.LivingMotion; +import com.tiedup.remake.rig.anim.LivingMotions; +import com.tiedup.remake.rig.anim.TiedUpLivingMotions; import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.bondage.component.ComponentType; import com.tiedup.remake.v2.bondage.movement.MovementModifier; @@ -11,11 +14,14 @@ import com.tiedup.remake.v2.bondage.movement.MovementStyle; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; import java.util.EnumMap; import java.util.EnumSet; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import net.minecraft.resources.ResourceLocation; @@ -326,6 +332,12 @@ public final class DataDrivenItemParser { idPath ); + // Optional: animations (living_motions + on_equip + on_unequip) + // P3-03 : replaces the hardcoded null from P3-02. + // When the "animations" key is absent in JSON, we keep null so that + // items authored before P3-03 keep their vanilla animator behavior. + AnimationBindings animations = parseAnimationBindings(root, id); + return new DataDrivenItemDefinition( id, displayName, @@ -347,9 +359,7 @@ public final class DataDrivenItemParser { movementModifier, creator, animationBones, - // P3-02 : animations parsing will be wired in P3-03. For now pass - // null so existing items keep their vanilla animator behavior. - null, + animations, componentConfigs ); } @@ -629,4 +639,238 @@ public final class DataDrivenItemParser { return Collections.unmodifiableMap(result); } + + // ===== Animations (P3-03) ===== + + /** + * Parse the optional {@code "animations"} JSON block into an + * {@link AnimationBindings}. + * + *

Format : + *

{@code
+     * "animations": {
+     *   "living_motions": {
+     *     "WALK": "mymod:arms_cuffed_walk",
+     *     "SNEAK": "mymod:arms_cuffed_sneak"
+     *   },
+     *   "on_equip": "mymod:cuffs_equip_oneshot",
+     *   "on_unequip": "mymod:cuffs_unequip_oneshot"
+     * }
+     * }
+ * + *

Tolerance rules : + *

+ * + * @param rootJson the parsed root JSON object of the item definition + * @param itemId the resolved item ID (used in log messages) + * @return the parsed bindings, {@link AnimationBindings#EMPTY} for an empty block, + * or {@code null} if the {@code "animations"} key is absent + */ + @Nullable + static AnimationBindings parseAnimationBindings( + JsonObject rootJson, + ResourceLocation itemId + ) { + if (!rootJson.has("animations")) return null; + + JsonElement animElem = rootJson.get("animations"); + if (!animElem.isJsonObject()) { + LOGGER.warn( + "[DataDrivenItems] In {}: 'animations' is not an object, ignoring", + itemId + ); + return null; + } + JsonObject animBlock = animElem.getAsJsonObject(); + + // Fast-path : present but empty -> EMPTY sentinel (distinguishable from "absent = null"). + if (animBlock.size() == 0) return AnimationBindings.EMPTY; + + // living_motions : optional map of motionName -> animationResourceLocation + Map motions = new HashMap<>(); + if (animBlock.has("living_motions")) { + JsonElement motionsElem = animBlock.get("living_motions"); + if (!motionsElem.isJsonObject()) { + LOGGER.warn( + "[DataDrivenItems] In {}: 'animations.living_motions' is not an object, skipping", + itemId + ); + } else { + JsonObject motionsJson = motionsElem.getAsJsonObject(); + for (Map.Entry entry : motionsJson.entrySet()) { + String motionName = entry.getKey(); + LivingMotion motion = resolveMotionByName(motionName); + if (motion == null) { + String suggest = suggestClosestMotion(motionName); + LOGGER.warn( + "[DataDrivenItemParser] Unknown motion '{}' in item {}, skipping.{}", + motionName, + itemId, + suggest != null ? " Did you mean '" + suggest + "'?" : "" + ); + continue; + } + ResourceLocation animId = tryParseAnimationRL( + entry.getValue(), + "living_motions['" + motionName + "']", + itemId + ); + if (animId == null) continue; + motions.put(motion, animId); + } + } + } + + // on_equip / on_unequip : optional single RL strings + ResourceLocation onEquip = animBlock.has("on_equip") + ? tryParseAnimationRL(animBlock.get("on_equip"), "on_equip", itemId) + : null; + ResourceLocation onUnequip = animBlock.has("on_unequip") + ? tryParseAnimationRL(animBlock.get("on_unequip"), "on_unequip", itemId) + : null; + + return new AnimationBindings(motions, onEquip, onUnequip); + } + + /** + * Resolve a motion name string to its {@link LivingMotion} instance. + * + *

Both vanilla-EF {@link LivingMotions} and custom {@link TiedUpLivingMotions} + * are standard Java enums, so {@link Enum#valueOf(Class, String)} is used. The + * lookup is case-sensitive (must match the enum constant name exactly). + * + * @param name the motion name from JSON (e.g. "WALK", "STRUGGLE_BOUND") + * @return the resolved {@link LivingMotion}, or {@code null} if unknown in both enums + */ + @Nullable + static LivingMotion resolveMotionByName(String name) { + if (name == null || name.isEmpty()) return null; + // Try vanilla EF motions first (most common case) + try { + return LivingMotions.valueOf(name); + } catch (IllegalArgumentException ignored) { + // fall through + } + // Try TiedUp! custom motions + try { + return TiedUpLivingMotions.valueOf(name); + } catch (IllegalArgumentException ignored) { + // fall through + } + return null; + } + + /** + * Suggest the closest known motion name for an unknown input, using + * Levenshtein edit distance with a tolerance of 3 edits. + * + *

Cross-enum search (vanilla {@link LivingMotions} + {@link TiedUpLivingMotions}). + * + * @param unknown the unknown name from JSON (case-insensitive comparison) + * @return the closest candidate (enum name), or {@code null} if no candidate + * is within 3 edits + */ + @Nullable + static String suggestClosestMotion(String unknown) { + if (unknown == null || unknown.isEmpty()) return null; + String needle = unknown.toUpperCase(); + + List all = new ArrayList<>(); + for (LivingMotions m : LivingMotions.values()) all.add(m.name()); + for (TiedUpLivingMotions m : TiedUpLivingMotions.values()) all.add(m.name()); + + String best = null; + int bestDist = Integer.MAX_VALUE; + for (String candidate : all) { + int dist = levenshtein(needle, candidate); + if (dist < bestDist && dist <= 3) { + bestDist = dist; + best = candidate; + } + } + return best; + } + + /** + * Classic Levenshtein edit distance (2D dynamic programming). + * + *

Pure computation, no allocations beyond the DP table. Used for typo + * suggestions on unknown motion names. + */ + static int levenshtein(String a, String b) { + if (a == null) a = ""; + if (b == null) b = ""; + int la = a.length(); + int lb = b.length(); + if (la == 0) return lb; + if (lb == 0) return la; + + int[][] dp = new int[la + 1][lb + 1]; + for (int i = 0; i <= la; i++) dp[i][0] = i; + for (int j = 0; j <= lb; j++) dp[0][j] = j; + + for (int i = 1; i <= la; i++) { + for (int j = 1; j <= lb; j++) { + int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1; + dp[i][j] = Math.min( + Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), + dp[i - 1][j - 1] + cost + ); + } + } + return dp[la][lb]; + } + + /** + * Parse a JSON element as a ResourceLocation for an animation binding. + * Logs warn + returns null if the element is not a string or the string + * is not a valid RL. + */ + @Nullable + private static ResourceLocation tryParseAnimationRL( + JsonElement elem, + String context, + ResourceLocation itemId + ) { + if (elem == null || elem.isJsonNull() || !elem.isJsonPrimitive() + || !elem.getAsJsonPrimitive().isString()) { + LOGGER.warn( + "[DataDrivenItemParser] {} in item {} is not a string, skipping.", + context, + itemId + ); + return null; + } + String s = elem.getAsString(); + // Strict RL parsing : animations MUST carry an explicit namespace + // (e.g. "mymod:foo"). ResourceLocation.tryParse defaults bare paths + // to the "minecraft" namespace, which silently masks modder typos — + // we reject strings without ':' here. + if (s.isEmpty() || s.indexOf(':') < 0) { + LOGGER.warn( + "[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.", + s, + context, + itemId + ); + return null; + } + ResourceLocation rl = ResourceLocation.tryParse(s); + if (rl == null) { + LOGGER.warn( + "[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.", + s, + context, + itemId + ); + } + return rl; + } } diff --git a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java new file mode 100644 index 0000000..9d7dcdb --- /dev/null +++ b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java @@ -0,0 +1,429 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.v2.bondage.datadriven; + +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.assertTrue; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.LivingMotion; +import com.tiedup.remake.rig.anim.LivingMotions; +import com.tiedup.remake.rig.anim.TiedUpLivingMotions; + +/** + * Tests unitaires pour le parsing du bloc {@code "animations"} par + * {@link DataDrivenItemParser#parseAnimationBindings} (P3-03). + * + *

Couvre : + *

    + *
  • absence → {@code null},
  • + *
  • bloc vide → {@link AnimationBindings#EMPTY},
  • + *
  • motions vanilla EF et custom TiedUp!,
  • + *
  • tolerance typos (unknown motion) avec fuzzy-match suggestion,
  • + *
  • tolerance ResourceLocation malformee,
  • + *
  • tolerance values non-string,
  • + *
  • one-shots on_equip / on_unequip,
  • + *
  • Levenshtein distance (sanity check).
  • + *
+ * + * Pur Java (JsonParser + ResourceLocation hors runtime MC). + */ +class DataDrivenItemParserAnimationsTest { + + private static final ResourceLocation ITEM_ID = + ResourceLocation.fromNamespaceAndPath("tiedup", "test_item"); + + /** Helper : parse un JSON string en JsonObject. */ + private static JsonObject json(String s) { + return JsonParser.parseString(s).getAsJsonObject(); + } + + // ========== absence / presence de la cle ========== + + @Test + void parseAnimations_absent_returnsNull() { + JsonObject root = json("{\"display_name\": \"test\"}"); + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(root, ITEM_ID); + + assertNull(result, + "cle 'animations' absente => null (pas de binding, vanilla behavior)"); + } + + @Test + void parseAnimations_empty_returnsEmpty() { + JsonObject root = json("{\"animations\": {}}"); + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(root, ITEM_ID); + + assertNotNull(result); + assertSame(AnimationBindings.EMPTY, result, + "bloc 'animations' vide => sentinel EMPTY"); + assertTrue(result.isEmpty()); + } + + @Test + void parseAnimations_notObject_returnsNull() { + JsonObject root = json("{\"animations\": 42}"); + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(root, ITEM_ID); + + assertNull(result, "animations non-objet => null (tolere, logue warn)"); + } + + // ========== living_motions : cas nominaux ========== + + @Test + void parseAnimations_validLivingMotions_parses() { + String jsonStr = """ + { + "animations": { + "living_motions": { + "IDLE": "tiedup:arms_cuffed_idle", + "WALK": "tiedup:arms_cuffed_walk", + "SNEAK": "tiedup:arms_cuffed_sneak" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertEquals(3, result.livingMotions().size()); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"), + result.livingMotions().get(LivingMotions.IDLE) + ); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_walk"), + result.livingMotions().get(LivingMotions.WALK) + ); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_sneak"), + result.livingMotions().get(LivingMotions.SNEAK) + ); + assertNull(result.onEquip()); + assertNull(result.onUnequip()); + } + + @Test + void parseAnimations_tiedupCustomMotions_parses() { + String jsonStr = """ + { + "animations": { + "living_motions": { + "STRUGGLE_BOUND": "tiedup:bondage_struggle", + "POSE_DOG": "tiedup:pose_dog_idle", + "WALK_BOUND": "tiedup:cuffed_walk" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertEquals(3, result.livingMotions().size()); + assertNotNull(result.livingMotions().get(TiedUpLivingMotions.STRUGGLE_BOUND)); + assertNotNull(result.livingMotions().get(TiedUpLivingMotions.POSE_DOG)); + assertNotNull(result.livingMotions().get(TiedUpLivingMotions.WALK_BOUND)); + } + + @Test + void parseAnimations_mixedVanillaAndCustomMotions_parses() { + String jsonStr = """ + { + "animations": { + "living_motions": { + "WALK": "tiedup:arms_cuffed_walk", + "STRUGGLE_BOUND": "tiedup:bondage_struggle" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertEquals(2, result.livingMotions().size()); + assertNotNull(result.livingMotions().get(LivingMotions.WALK)); + assertNotNull(result.livingMotions().get(TiedUpLivingMotions.STRUGGLE_BOUND)); + } + + // ========== living_motions : tolerance erreurs ========== + + @Test + void parseAnimations_unknownMotion_logsWarnAndSkips() { + // "IDEL" (typo) doit etre skip, "WALK" doit passer + String jsonStr = """ + { + "animations": { + "living_motions": { + "IDEL": "tiedup:foo", + "WALK": "tiedup:arms_cuffed_walk" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertEquals(1, result.livingMotions().size(), + "La motion inconnue est skip, l'autre passe"); + assertNotNull(result.livingMotions().get(LivingMotions.WALK)); + } + + @Test + void parseAnimations_malformedResourceLocation_skipsEntry() { + // "noNamespace" n'a pas de ':' => RL invalide (strict parser) + String jsonStr = """ + { + "animations": { + "living_motions": { + "WALK": "noNamespace", + "IDLE": "tiedup:arms_cuffed_idle" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertEquals(1, result.livingMotions().size(), + "RL malformee => skip, l'autre passe"); + assertNotNull(result.livingMotions().get(LivingMotions.IDLE)); + assertNull(result.livingMotions().get(LivingMotions.WALK)); + } + + @Test + void parseAnimations_nonStringValue_skipsEntry() { + String jsonStr = """ + { + "animations": { + "living_motions": { + "WALK": 42, + "IDLE": "tiedup:arms_cuffed_idle" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertEquals(1, result.livingMotions().size(), + "Value non-string => skip"); + assertNotNull(result.livingMotions().get(LivingMotions.IDLE)); + } + + @Test + void parseAnimations_livingMotionsNotObject_tolerates() { + // living_motions est un array au lieu d'un objet => tolere (warn + skip bloc) + String jsonStr = """ + { + "animations": { + "living_motions": [1, 2, 3], + "on_equip": "tiedup:equip_oneshot" + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertTrue(result.livingMotions().isEmpty(), + "living_motions non-objet => map vide, pas crash"); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "equip_oneshot"), + result.onEquip(), + "on_equip reste traite normalement" + ); + } + + // ========== on_equip / on_unequip ========== + + @Test + void parseAnimations_onEquipOnUnequip_parses() { + String jsonStr = """ + { + "animations": { + "on_equip": "tiedup:cuffs_equip_oneshot", + "on_unequip": "tiedup:cuffs_unequip_oneshot" + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertTrue(result.livingMotions().isEmpty()); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_equip_oneshot"), + result.onEquip() + ); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_unequip_oneshot"), + result.onUnequip() + ); + } + + @Test + void parseAnimations_onEquipMissing_isNull() { + String jsonStr = """ + { + "animations": { + "living_motions": { + "IDLE": "tiedup:arms_cuffed_idle" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertNull(result.onEquip(), "on_equip absent => null"); + assertNull(result.onUnequip(), "on_unequip absent => null"); + assertFalse(result.livingMotions().isEmpty()); + } + + @Test + void parseAnimations_onEquipMalformed_isNull() { + String jsonStr = """ + { + "animations": { + "on_equip": "noNamespace", + "on_unequip": "tiedup:valid_rl" + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result); + assertNull(result.onEquip(), + "on_equip RL malformee => null (skip)"); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "valid_rl"), + result.onUnequip(), + "on_unequip valide => parse OK" + ); + } + + // ========== resolveMotionByName ========== + + @Test + void resolveMotionByName_vanilla_resolves() { + LivingMotion m = DataDrivenItemParser.resolveMotionByName("IDLE"); + assertSame(LivingMotions.IDLE, m); + } + + @Test + void resolveMotionByName_custom_resolves() { + LivingMotion m = + DataDrivenItemParser.resolveMotionByName("STRUGGLE_BOUND"); + assertSame(TiedUpLivingMotions.STRUGGLE_BOUND, m); + } + + @Test + void resolveMotionByName_unknown_returnsNull() { + assertNull(DataDrivenItemParser.resolveMotionByName("IDEL")); + assertNull(DataDrivenItemParser.resolveMotionByName("")); + assertNull(DataDrivenItemParser.resolveMotionByName(null)); + } + + @Test + void resolveMotionByName_caseSensitive() { + // JSON convention = UPPER_SNAKE_CASE (matches enum constants) + assertNull(DataDrivenItemParser.resolveMotionByName("idle"), + "Resolution case-sensitive : 'idle' ne match pas IDLE"); + } + + // ========== suggestClosestMotion ========== + + @Test + void suggestClosestMotion_oneEdit_suggestsMatch() { + // "IDEL" est a 1 edit de "IDLE" (swap E<->L) + String s = DataDrivenItemParser.suggestClosestMotion("IDEL"); + assertEquals("IDLE", s); + } + + @Test + void suggestClosestMotion_completelyUnrelated_returnsNull() { + // "XYZQWERTY" n'est a moins de 3 edits d'aucune motion + String s = DataDrivenItemParser.suggestClosestMotion("XYZQWERTY"); + assertNull(s); + } + + @Test + void suggestClosestMotion_caseInsensitive() { + // "idel" en minuscule doit quand meme suggerer "IDLE" + String s = DataDrivenItemParser.suggestClosestMotion("idel"); + assertEquals("IDLE", s); + } + + @Test + void suggestClosestMotion_customMotion_suggestsCustom() { + // "STRUGLE_BOUND" (typo) devrait suggerer "STRUGGLE_BOUND" + String s = DataDrivenItemParser.suggestClosestMotion("STRUGLE_BOUND"); + assertEquals("STRUGGLE_BOUND", s); + } + + // ========== levenshtein (sanity) ========== + + @Test + void levenshtein_sameString_returnsZero() { + assertEquals(0, DataDrivenItemParser.levenshtein("IDLE", "IDLE")); + } + + @Test + void levenshtein_oneEdit_returnsOne() { + // substitution + assertEquals(1, DataDrivenItemParser.levenshtein("IDLE", "IDLA")); + // insertion + assertEquals(1, DataDrivenItemParser.levenshtein("IDLE", "IDLEE")); + // deletion + assertEquals(1, DataDrivenItemParser.levenshtein("IDLE", "IDL")); + } + + @Test + void levenshtein_emptyStrings_handled() { + assertEquals(0, DataDrivenItemParser.levenshtein("", "")); + assertEquals(4, DataDrivenItemParser.levenshtein("IDLE", "")); + assertEquals(4, DataDrivenItemParser.levenshtein("", "IDLE")); + } + + @Test + void levenshtein_swap_returnsTwo() { + // "IDEL" -> "IDLE" : 2 edits (swap E/L = substitution + substitution) + assertEquals(2, DataDrivenItemParser.levenshtein("IDEL", "IDLE")); + } +}