diff --git a/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java b/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java new file mode 100644 index 0000000..a2e983c --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java @@ -0,0 +1,287 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.v2.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +import com.tiedup.remake.rig.TiedUpAnimationRegistry; +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.anim.LivingMotion; +import com.tiedup.remake.rig.anim.client.ClientAnimator; +import com.tiedup.remake.rig.anim.types.StaticAnimation; +import com.tiedup.remake.rig.asset.AssetAccessor; +import com.tiedup.remake.rig.patch.PlayerPatch; +import com.tiedup.remake.rig.patch.TiedUpCapabilities; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.bondage.datadriven.AnimationBindings; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; + +/** + * Orchestrateur client-side — rebuild la map {@code livingAnimations} de + * l'animator RIG d'un {@link Player} en fonction des items bondage qu'il + * porte (P3-05). + * + *

Call sites (P3-06 & co)

+ * + * + *

Workflow

+ *
    + *
  1. Récupère le {@link PlayerPatch} via capability ; abort si absent + * (player pas encore patché, rare mais arrivable avant la fin du + * {@code onConstructed}).
  2. + *
  3. Récupère le {@link ClientAnimator} ; abort si null (server side ou + * race avec {@code Animator} stripping durant chunk unload).
  4. + *
  5. Appelle {@link ClientAnimator#resetLivingAnimations()} — les defaults + * EF (IDLE, WALK, etc.) sont re-poussés dans la map par le cycle + * {@code clear() + defaultLivingAnimations.forEach(addLivingAnimation)} + * implémenté dans {@link ClientAnimator}.
  6. + *
  7. Lit {@link V2EquipmentHelper#getAllEquipped} — déjà dédupliqué au + * niveau capability ({@link com.tiedup.remake.v2.bondage.IV2BondageEquipment} + * contract), on applique néanmoins une dédup défensive par identity + * pour ne pas dépendre d'un invariant en amont qui pourrait changer.
  8. + *
  9. Filtre les items non data-driven (V2 legacy sans + * {@link DataDrivenItemDefinition}) et trie par + * {@link DataDrivenItemDefinition#posePriority} ASC — ainsi le plus + * prioritaire itère EN DERNIER, et son {@code addLivingAnimation} écrase + * les lower-priority pour une même {@link LivingMotion} (sémantique + * {@code Map.put()} : dernier écrivain gagne).
  10. + *
  11. Pour chaque item, si {@link DataDrivenItemDefinition#animations()} + * est non-null et non-vide, ajoute chaque binding via + * {@link ClientAnimator#addLivingAnimation}. Les IDs inconnus sont + * résolus via {@link TiedUpAnimationRegistry#resolveWithFallback} + * (fallback {@code EMPTY_ANIMATION} + WARN une fois par ID).
  12. + *
+ * + *

Préconditions

+ * + * + *

Design notes

+ * + *

L'entrée publique {@link #rebuildBondageAnimations(Player)} est + * side-gated mais non testable sans MC runtime (player réel, capability + * réelle, animator réel). La logique pure est extraite dans deux helpers + * package-private :

+ * + * + * @see AnimationBindings + * @see TiedUpAnimationRegistry#resolveWithFallback(ResourceLocation) + */ +@OnlyIn(Dist.CLIENT) +public final class ClientRigEquipmentHandler { + + private ClientRigEquipmentHandler() { + // utility class + } + + /** + * Functional adapter autour de {@link ClientAnimator#addLivingAnimation} + * pour permettre l'injection en tests sans bootstrap MC. + * + *

En production, l'implémentation est une méthode-ref + * {@code animator::addLivingAnimation}. En test, un fake qui capture les + * appels dans une collection (voir {@code ClientRigEquipmentHandlerTest}).

+ */ + @FunctionalInterface + interface LivingAnimationAdder { + void add(LivingMotion motion, AssetAccessor accessor); + } + + /** + * Entry point publique — rebuild la map {@code livingAnimations} du + * {@link ClientAnimator} du player en fonction de ses items bondage V2 + * équipés. + * + *

No-op si :

+ * + * + * @param player le player dont on rebuild les anims ; null tolérée + */ + public static void rebuildBondageAnimations(Player player) { + if (player == null) return; + + PlayerPatch patch = TiedUpCapabilities.getPlayerPatch(player); + if (patch == null) return; + + ClientAnimator animator = patch.getClientAnimator(); + if (animator == null) return; + + Map equipped = V2EquipmentHelper.getAllEquipped(player); + + // La capability dédupe déjà par identity (cf V2BondageEquipment.getAllEquipped, + // IdentityHashMap seen+LinkedHashMap result) — on applique néanmoins notre + // propre dédup défensive pour ne pas dépendre d'un invariant qu'un refactor + // futur pourrait casser silencieusement. + List sortedDefs = + extractSortedDefinitions(equipped.values(), DataDrivenItemRegistry::get); + + applyDefinitions( + animator::resetLivingAnimations, + animator::addLivingAnimation, + sortedDefs, + TiedUpAnimationRegistry::resolveWithFallback + ); + + TiedUpRigConstants.LOGGER.debug( + "[ClientRigEquipmentHandler] Rebuilt livingAnimations for player {} " + + "({} data-driven items processed)", + player.getName().getString(), + sortedDefs.size() + ); + } + + /** + * Collecte les {@link DataDrivenItemDefinition} associées aux items + * d'entrée, dédupe par identity, et trie par {@code posePriority} + * ascendant. + * + *

Pure fonction sans dépendance MC — testable unit. Le resolver + * permet de mocker {@code DataDrivenItemRegistry.get(stack)} en test. + * Un item dont le resolver retourne {@code null} (item non data-driven) + * est skip silencieusement.

+ * + *

Tri ASC volontaire : dans {@link #applyDefinitions} la map + * {@code livingAnimations} suit la sémantique "dernier put gagne", donc + * pour qu'un item de priorité 20 batte un item de priorité 10 sur une + * même {@link LivingMotion}, on doit itérer le priorité 10 d'abord et + * le priorité 20 ensuite (il écrase).

+ * + *

Note sur la généricité : l'API est paramétrée {@code } + * plutôt que fixée à {@link ItemStack} pour permettre un unit test sans + * bootstrap MC (le test passe des {@code Object} dummy, la prod passe + * des {@code ItemStack}). Aucune méthode d'instance n'est appelée sur + * le type — seule l'identité objet est utilisée, donc l'abstraction est + * safe.

+ * + * @param type des éléments (typiquement {@link ItemStack} en + * prod, {@link Object} en test) + * @param stacks iterable d'items (peut contenir des duplicats identity + * pour les multi-region items) + * @param resolver fonction {@code item → DataDrivenItemDefinition} + * (null pour items non data-driven) + * @return liste dédupliquée + triée ASC par posePriority ; non-null, + * possiblement vide + */ + static List extractSortedDefinitions( + Iterable stacks, + Function resolver + ) { + // Dedup par identity — Collections.newSetFromMap(IdentityHashMap) est + // préféré à HashSet+equals() pour la sémantique "== vs equals()". + // Deux ItemStacks avec les mêmes NBT mais instances distinctes sont + // traités comme items distincts (convention V2 : un stack = une + // occurrence équipée). + Set unique = Collections.newSetFromMap(new IdentityHashMap<>()); + List defs = new ArrayList<>(); + + for (T stack : stacks) { + if (stack == null) continue; + if (!unique.add(stack)) continue; // duplicate identity (multi-region item) + DataDrivenItemDefinition def = resolver.apply(stack); + if (def == null) continue; // V2 legacy item sans JSON definition + defs.add(def); + } + + defs.sort(Comparator.comparingInt(DataDrivenItemDefinition::posePriority)); + return defs; + } + + /** + * Pousse les bindings de chaque définition dans l'animator via les + * callbacks fournis. + * + *

Workflow strict :

+ *
    + *
  1. Appelle {@code resetCallback} une fois — le contrat attendu est + * que cela vide la map {@code livingAnimations} et la repopule avec + * les defaults EF (comportement de + * {@link ClientAnimator#resetLivingAnimations}).
  2. + *
  3. Pour chaque définition (déjà triée ASC par posePriority), si elle + * porte un {@link AnimationBindings} non-null et non-vide, itère + * les entries {@code livingMotions()} et appelle {@code adder} avec + * l'{@link AssetAccessor} résolu via {@code animResolver}.
  4. + *
+ * + *

Si {@code animations()} est null (99% des items V2 legacy) ou + * {@link AnimationBindings#isEmpty()}, on skip — pas d'appel adder. Pas + * de warn : la majorité des items n'ont légitimement pas de binding.

+ * + * @param resetCallback callback pour reset la map animator (en prod: + * {@code animator::resetLivingAnimations}) + * @param adder callback pour ajouter un binding + * {@code (motion, accessor)} + * @param sortedDefinitions liste triée ASC par posePriority + * @param animResolver résout un {@link ResourceLocation} en + * {@link AssetAccessor} — en prod + * {@link TiedUpAnimationRegistry#resolveWithFallback} + */ + static void applyDefinitions( + Runnable resetCallback, + LivingAnimationAdder adder, + List sortedDefinitions, + Function> animResolver + ) { + resetCallback.run(); + + for (DataDrivenItemDefinition def : sortedDefinitions) { + AnimationBindings bindings = def.animations(); + if (bindings == null || bindings.isEmpty()) continue; + + for (Map.Entry entry + : bindings.livingMotions().entrySet()) { + AssetAccessor accessor = + animResolver.apply(entry.getValue()); + adder.add(entry.getKey(), accessor); + } + } + } +} diff --git a/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java b/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java new file mode 100644 index 0000000..c1e4225 --- /dev/null +++ b/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java @@ -0,0 +1,511 @@ +/* + * © 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 :

+ *
    + *
  • {@link ClientRigEquipmentHandler#extractSortedDefinitions} — + * dedup identity, filter null, sort by posePriority ASC.
  • + *
  • {@link ClientRigEquipmentHandler#applyDefinitions} — itère + + * adder pour chaque binding non-null/non-vide, reset d'abord.
  • + *
+ * + *

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)"); + } + + /** 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 animResolver retourne un accessor pour l'ID fourni, le + * handler ne suppose pas de non-nullité — contract de + * {@link com.tiedup.remake.rig.TiedUpAnimationRegistry#resolveWithFallback} + * garantit non-null en prod, mais on vérifie qu'aucune assertion plus + * stricte n'a été introduite dans le handler. + */ + @Test + void applyDefinitions_respectsAnimResolverContract() { + 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"); + }; + + 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()); + } +}