/* * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. */ package com.tiedup.remake.v2.client; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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 java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; 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; import com.tiedup.remake.rig.anim.types.StaticAnimation; import com.tiedup.remake.rig.asset.AssetAccessor; import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.bondage.datadriven.AnimationBindings; import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; import com.tiedup.remake.v2.bondage.movement.MovementModifier; import com.tiedup.remake.v2.bondage.movement.MovementStyle; /** * Tests unitaires pour {@link ClientRigEquipmentHandler} (P3-05). * *

Stratégie de test

*

L'entrée publique {@link ClientRigEquipmentHandler#rebuildBondageAnimations} * nécessite un {@code Player} réel + capability MC — non testable sans * bootstrap. On vérifie uniquement son null-safety. La logique métier est * extraite dans deux méthodes package-private pures :

* * *

Les ItemStack sont remplacés par des {@link Object} dummy : la logique * ne lit aucune méthode d'instance, seule l'identité objet compte pour la * dédup. Les {@link DataDrivenItemDefinition} sont construites via le * {@link #makeDef} helper avec des defaults neutres (seuls posePriority + * animations comptent pour ces tests).

*/ class ClientRigEquipmentHandlerTest { private static final ResourceLocation ITEM_A = ResourceLocation.fromNamespaceAndPath("tiedup", "item_a"); private static final ResourceLocation ITEM_B = ResourceLocation.fromNamespaceAndPath("tiedup", "item_b"); private static final ResourceLocation ANIM_IDLE_A = ResourceLocation.fromNamespaceAndPath("tiedup", "anim_idle_a"); private static final ResourceLocation ANIM_WALK_A = ResourceLocation.fromNamespaceAndPath("tiedup", "anim_walk_a"); private static final ResourceLocation ANIM_WALK_B = ResourceLocation.fromNamespaceAndPath("tiedup", "anim_walk_b"); /** * Helper : construit une {@link DataDrivenItemDefinition} minimale avec * les seuls champs testés (posePriority + animations). Les autres champs * reçoivent des defaults neutres pour satisfaire le compact ctor. */ private static DataDrivenItemDefinition makeDef( ResourceLocation id, int posePriority, AnimationBindings animations ) { return new DataDrivenItemDefinition( id, /* displayName */ id.getPath(), /* translationKey */ null, /* modelLocation */ ResourceLocation.fromNamespaceAndPath("tiedup", "model"), /* slimModelLocation */ null, /* animationSource */ null, /* occupiedRegions */ Set.of(BodyRegionV2.ARMS), /* blockedRegions */ Set.of(BodyRegionV2.ARMS), /* poseType */ null, posePriority, /* escapeDifficulty */ 100, /* lockable */ false, /* canAttachPadlock */ true, /* supportsColor */ false, /* tintChannels */ Map.of(), /* icon */ null, /* movementStyle */ (MovementStyle) null, /* movementModifier */ (MovementModifier) null, /* creator */ null, /* animationBones */ Map.of(), animations, /* componentConfigs */ Map.of() ); } /** * Fake adder qui capture chaque {@code (motion, accessor)} dans une * liste en ordre chronologique. L'accessor réel n'est pas inspecté — * on matérialise sa {@link AssetAccessor#registryName() registryName} * pour faciliter les assertions. */ private static final class CapturingAdder implements ClientRigEquipmentHandler.LivingAnimationAdder { final List> calls = new ArrayList<>(); @Override public void add(LivingMotion motion, AssetAccessor accessor) { calls.add(Map.entry(motion, accessor.registryName())); } } /** Stub {@link AssetAccessor} qui retourne juste un {@link ResourceLocation}. */ private static AssetAccessor stubAccessor(ResourceLocation id) { return new AssetAccessor() { @Override public StaticAnimation get() { return null; // jamais dereferenced dans le handler, juste adder capture } @Override public ResourceLocation registryName() { return id; } @Override public boolean inRegistry() { return false; } }; } /** Résolver passthrough : id → stubAccessor(id). */ private static final Function> PASSTHROUGH_RESOLVER = ClientRigEquipmentHandlerTest::stubAccessor; // ========== null-safety public API ========== /** * {@link ClientRigEquipmentHandler#rebuildBondageAnimations(net.minecraft.world.entity.player.Player)} * avec null ne doit rien throw — early return propre. */ @Test void rebuildBondageAnimations_nullPlayer_noThrow() { assertDoesNotThrow(() -> ClientRigEquipmentHandler.rebuildBondageAnimations(null)); } // ========== extractSortedDefinitions ========== /** Iterable vide → liste vide (pas de crash, pas de resolver appelé). */ @Test void extractSortedDefinitions_emptyInput_returnsEmpty() { List result = ClientRigEquipmentHandler.extractSortedDefinitions( Collections.emptyList(), stack -> { throw new AssertionError("resolver ne doit pas etre appele"); } ); assertNotNull(result); assertTrue(result.isEmpty()); } /** Items non data-driven (resolver → null) sont skip. */ @Test void extractSortedDefinitions_nullResolver_skipsItem() { Object stackA = new Object(); Object stackB = new Object(); List result = ClientRigEquipmentHandler.extractSortedDefinitions( List.of(stackA, stackB), any -> null // aucun item n'est data-driven ); assertTrue(result.isEmpty()); } /** * Un même {@link Object} répété N fois dans l'input ne doit produire * qu'une seule def dans la sortie — cas armbinder (3 régions, 1 stack). */ @Test void extractSortedDefinitions_duplicateIdentity_dedups() { Object stack = new Object(); DataDrivenItemDefinition def = makeDef(ITEM_A, 10, null); // Identity resolver — retourne def pour n'importe quel input Function resolver = s -> def; List result = ClientRigEquipmentHandler.extractSortedDefinitions( // Input : le MÊME stack 3 fois (multi-region simulation) List.of(stack, stack, stack), resolver ); assertEquals(1, result.size(), "dédup identity doit retenir un seul exemplaire du stack"); assertSame(def, result.get(0)); } /** * Deux items distincts avec des priorités 20 et 10 → triés ASC : 10 en * premier, 20 en dernier. Garantit que le plus prioritaire itère en * dernier dans {@link ClientRigEquipmentHandler#applyDefinitions}. */ @Test void extractSortedDefinitions_sortsAscendingByPosePriority() { Object stackHigh = new Object(); Object stackLow = new Object(); DataDrivenItemDefinition high = makeDef(ITEM_A, 20, null); DataDrivenItemDefinition low = makeDef(ITEM_B, 10, null); // Resolver by identity via IdentityHashMap IdentityHashMap map = new IdentityHashMap<>(); map.put(stackHigh, high); map.put(stackLow, low); // Input intentionnellement dans l'ordre "high d'abord" pour vérifier // que le sort le pousse en dernier List result = ClientRigEquipmentHandler.extractSortedDefinitions( List.of(stackHigh, stackLow), map::get ); assertEquals(2, result.size()); assertSame(low, result.get(0), "posePriority=10 doit sortir en premier (ASC)"); assertSame(high, result.get(1), "posePriority=20 doit sortir en dernier (ASC)"); } /** * Deux items distincts avec la MÊME {@code posePriority} doivent être * triés de manière déterministe via le tiebreaker secondaire * {@code id.toString()} lexicographique. Ainsi l'ordre final ne dépend * pas de {@link BodyRegionV2} declaration order (invariant fragile * qu'un refactor pourrait casser silencieusement). */ @Test void extractSortedDefinitions_equalPriority_usesIdLexicographicTiebreaker() { ResourceLocation idAlpha = ResourceLocation.fromNamespaceAndPath("tiedup", "alpha_cuffs"); ResourceLocation idBeta = ResourceLocation.fromNamespaceAndPath("tiedup", "beta_cuffs"); ResourceLocation idCharlie = ResourceLocation.fromNamespaceAndPath("tiedup", "charlie_cuffs"); DataDrivenItemDefinition alpha = makeDef(idAlpha, /* priority */ 10, null); DataDrivenItemDefinition beta = makeDef(idBeta, 10, null); DataDrivenItemDefinition charlie = makeDef(idCharlie, 10, null); // Input volontairement dans l'ordre inverse — [charlie, alpha, beta] // — pour vérifier que le sort le réorganise bien sur id // lexicographique et non sur ordre d'insertion. List sorted = ClientRigEquipmentHandler.extractSortedDefinitions( List.of(charlie, alpha, beta), Function.identity() ); assertEquals(3, sorted.size()); assertSame(alpha, sorted.get(0), "alpha_cuffs doit sortir en premier (id.toString() min)"); assertSame(beta, sorted.get(1), "beta_cuffs doit sortir au milieu"); assertSame(charlie, sorted.get(2), "charlie_cuffs doit sortir en dernier (id.toString() max)"); } /** Un {@code null} dans l'iterable est silencieusement skip. */ @Test void extractSortedDefinitions_nullItemInList_skips() { List input = Arrays.asList(null, new Object(), null); DataDrivenItemDefinition def = makeDef(ITEM_A, 5, null); List result = ClientRigEquipmentHandler.extractSortedDefinitions( input, stack -> stack == null ? null : def ); assertEquals(1, result.size()); } // ========== applyDefinitions ========== /** Reset callback doit être appelé exactement une fois, même sur liste vide. */ @Test void applyDefinitions_emptyList_callsResetOnly() { AtomicInteger resetCount = new AtomicInteger(); CapturingAdder adder = new CapturingAdder(); ClientRigEquipmentHandler.applyDefinitions( resetCount::incrementAndGet, adder, Collections.emptyList(), PASSTHROUGH_RESOLVER ); assertEquals(1, resetCount.get(), "reset doit etre appele exactement une fois"); assertTrue(adder.calls.isEmpty(), "aucune anim ajoutee sur input vide"); } /** * Une définition avec {@code animations() == null} (item V2 legacy sans * bloc JSON) doit être skip silencieusement — pas d'appel adder. */ @Test void applyDefinitions_nullAnimations_skipsSilently() { AtomicInteger resetCount = new AtomicInteger(); CapturingAdder adder = new CapturingAdder(); DataDrivenItemDefinition def = makeDef(ITEM_A, 10, null); // animations() == null ClientRigEquipmentHandler.applyDefinitions( resetCount::incrementAndGet, adder, List.of(def), PASSTHROUGH_RESOLVER ); assertEquals(1, resetCount.get()); assertTrue(adder.calls.isEmpty(), "def avec animations()==null doit etre skip sans appel adder"); } /** * Une définition avec {@link AnimationBindings#EMPTY} doit aussi être * skip (isEmpty() == true). */ @Test void applyDefinitions_emptyAnimations_skipsSilently() { CapturingAdder adder = new CapturingAdder(); DataDrivenItemDefinition def = makeDef(ITEM_A, 10, AnimationBindings.EMPTY); ClientRigEquipmentHandler.applyDefinitions( () -> {}, adder, List.of(def), PASSTHROUGH_RESOLVER ); assertTrue(adder.calls.isEmpty(), "def avec AnimationBindings.EMPTY doit etre skip (isEmpty() true)"); } /** * Un item avec plusieurs bindings → adder appelé une fois par binding * avec la motion + un accessor résolu depuis l'animResolver. */ @Test void applyDefinitions_singleItemWithBindings_addsAllMotions() { CapturingAdder adder = new CapturingAdder(); // Ordered map to make the motion iteration order deterministic LinkedHashMap motions = new LinkedHashMap<>(); motions.put(LivingMotions.WALK, ANIM_WALK_A); motions.put(TiedUpLivingMotions.WALK_BOUND, ANIM_IDLE_A); AnimationBindings bindings = new AnimationBindings(motions, null, null); DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings); ClientRigEquipmentHandler.applyDefinitions( () -> {}, adder, List.of(def), PASSTHROUGH_RESOLVER ); assertEquals(2, adder.calls.size(), "2 bindings => 2 appels adder"); // Chaque binding doit etre present — l'ordre est celui de livingMotions().entrySet() Map collected = new LinkedHashMap<>(); for (Map.Entry call : adder.calls) { collected.put(call.getKey(), call.getValue()); } assertEquals(ANIM_WALK_A, collected.get(LivingMotions.WALK)); assertEquals(ANIM_IDLE_A, collected.get(TiedUpLivingMotions.WALK_BOUND)); } /** * Deux items bindent la même motion avec priorities 20 et 10. Le flux * applyDefinitions attend une liste déjà triée ASC, donc la prod (via * extractSortedDefinitions) passe [low, high] — le dernier add gagne. * On teste ici que si l'ordre respecté est [low, high] l'adder est * appelé 2 fois, le dernier portant la valeur high. */ @Test void applyDefinitions_twoItemsConflictingMotion_lastCallWins() { CapturingAdder adder = new CapturingAdder(); // Low priority (itère en premier) bind WALK → ANIM_WALK_A AnimationBindings lowBindings = new AnimationBindings( Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null); DataDrivenItemDefinition low = makeDef(ITEM_A, 10, lowBindings); // High priority (itère en dernier) bind WALK → ANIM_WALK_B AnimationBindings highBindings = new AnimationBindings( Map.of(LivingMotions.WALK, ANIM_WALK_B), null, null); DataDrivenItemDefinition high = makeDef(ITEM_B, 20, highBindings); ClientRigEquipmentHandler.applyDefinitions( () -> {}, adder, // Ordre ASC par posePriority — contrat de applyDefinitions List.of(low, high), PASSTHROUGH_RESOLVER ); assertEquals(2, adder.calls.size()); assertEquals(LivingMotions.WALK, adder.calls.get(0).getKey()); assertEquals(ANIM_WALK_A, adder.calls.get(0).getValue()); assertEquals(LivingMotions.WALK, adder.calls.get(1).getKey()); assertEquals(ANIM_WALK_B, adder.calls.get(1).getValue()); // La sémantique "last write wins" dans la map livingAnimations est // une responsabilite de ClientAnimator.addLivingAnimation — ici on // vérifie juste l'ordre d'émission. } /** * Reset et adds forment un ordre strict : reset est le premier appel * observable par l'animator. On le vérifie via un compteur externe * et un wrapper adder qui fail si reset n'a pas encore été appelé. */ @Test void applyDefinitions_resetIsCalledBeforeAnyAdder() { boolean[] resetDone = { false }; List orderedCalls = new ArrayList<>(); ClientRigEquipmentHandler.LivingAnimationAdder adder = (motion, accessor) -> { assertTrue(resetDone[0], "adder ne doit jamais etre appele AVANT reset"); orderedCalls.add(motion); }; AnimationBindings bindings = new AnimationBindings( Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null); DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings); ClientRigEquipmentHandler.applyDefinitions( () -> resetDone[0] = true, adder, List.of(def), PASSTHROUGH_RESOLVER ); assertTrue(resetDone[0]); assertEquals(1, orderedCalls.size()); } /** * Sanity end-to-end : extractSortedDefinitions feed applyDefinitions * avec un input multi-région (même stack 3 fois) → dedup + add pour * le seul item réel. */ @Test void endToEnd_multiRegionStack_dedupsAndApplies() { Object multiRegionStack = new Object(); AnimationBindings bindings = new AnimationBindings( Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null); DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings); // Dedup step List defs = ClientRigEquipmentHandler.extractSortedDefinitions( List.of(multiRegionStack, multiRegionStack, multiRegionStack), any -> def ); assertEquals(1, defs.size()); // Apply step CapturingAdder adder = new CapturingAdder(); ClientRigEquipmentHandler.applyDefinitions( () -> {}, adder, defs, PASSTHROUGH_RESOLVER ); assertEquals(1, adder.calls.size(), "3 régions du même stack → un seul add (dedup identity)"); assertEquals(LivingMotions.WALK, adder.calls.get(0).getKey()); assertEquals(ANIM_WALK_A, adder.calls.get(0).getValue()); } /** * Sanity : si {@code animResolver} retourne {@code null} pour un id, le * handler le forwarde tel quel à l'adder sans dereferencer. La gestion * réelle des accessors null est la responsabilité de * {@link com.tiedup.remake.rig.anim.client.ClientAnimator#addLivingAnimation} * (out of scope de ce test — seule l'absence de NPE côté handler est * vérifiée ici). * *

En prod, {@link com.tiedup.remake.rig.TiedUpAnimationRegistry#resolveWithFallback} * garantit non-null (fallback {@code EMPTY_ANIMATION} + WARN). Le test * simule un resolver "buggy" pour s'assurer qu'aucune assertion plus * stricte n'a été introduite dans le handler côté TiedUp.

*/ @Test void applyDefinitions_nullAnimAccessor_forwardsToAdder() { AtomicInteger resolverCalls = new AtomicInteger(); ClientRigEquipmentHandler.LivingAnimationAdder adder = (motion, accessor) -> { // accessor vient du resolver, peut être null si resolver est buggy — // le handler ne doit pas le dereferencer lui-même assertNull(accessor, "resolver renvoie null ici, le handler forward tel quel"); }; AnimationBindings bindings = new AnimationBindings( Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null); DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings); ClientRigEquipmentHandler.applyDefinitions( () -> {}, adder, List.of(def), id -> { resolverCalls.incrementAndGet(); return null; } ); assertEquals(1, resolverCalls.get(), "animResolver appele une fois (un seul binding)"); } // ========== guard : makeDef helper sanity ========== /** * Sanity on the test-internal {@link #makeDef} helper — garantit qu'on * construit bien un record valide (le compact ctor pourrait throw en * cas de champ manquant). */ @Test void makeDef_returnsValidDefinition() { DataDrivenItemDefinition def = makeDef(ITEM_A, 42, null); assertEquals(ITEM_A, def.id()); assertEquals(42, def.posePriority()); assertNull(def.animations()); // un seul occupiedRegion par défaut assertFalse(def.occupiedRegions().isEmpty()); } }