HIGH RISK-001 : secondary comparator on def.id() prevents UB on equal-priority items — no longer depends on BodyRegionV2 enum order. New test asserts lexicographic ordering. HIGH RISK-002 : resetLivingAnimations restores defaultLivingAnimations snapshot. Javadoc now warns against calling setCurrentMotionsAsDefault anywhere in the TiedUp pipeline (would leak custom bindings into defaults, breaking unbind). Grep confirms no call site exists today. Polish : rename tautological null test, precise null-safety doc.
556 lines
22 KiB
Java
556 lines
22 KiB
Java
/*
|
|
* © 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).
|
|
*
|
|
* <h2>Stratégie de test</h2>
|
|
* <p>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 :</p>
|
|
* <ul>
|
|
* <li>{@link ClientRigEquipmentHandler#extractSortedDefinitions} —
|
|
* dedup identity, filter null, sort by posePriority ASC.</li>
|
|
* <li>{@link ClientRigEquipmentHandler#applyDefinitions} — itère +
|
|
* adder pour chaque binding non-null/non-vide, reset d'abord.</li>
|
|
* </ul>
|
|
*
|
|
* <p>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).</p>
|
|
*/
|
|
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<Map.Entry<LivingMotion, ResourceLocation>> calls = new ArrayList<>();
|
|
|
|
@Override
|
|
public void add(LivingMotion motion, AssetAccessor<? extends StaticAnimation> accessor) {
|
|
calls.add(Map.entry(motion, accessor.registryName()));
|
|
}
|
|
}
|
|
|
|
/** Stub {@link AssetAccessor} qui retourne juste un {@link ResourceLocation}. */
|
|
private static AssetAccessor<? extends StaticAnimation> stubAccessor(ResourceLocation id) {
|
|
return new AssetAccessor<StaticAnimation>() {
|
|
@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<ResourceLocation,
|
|
AssetAccessor<? extends StaticAnimation>> 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<DataDrivenItemDefinition> 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<DataDrivenItemDefinition> 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<Object, DataDrivenItemDefinition> resolver = s -> def;
|
|
|
|
List<DataDrivenItemDefinition> 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<Object, DataDrivenItemDefinition> 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<DataDrivenItemDefinition> 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<DataDrivenItemDefinition> 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<Object> input = Arrays.asList(null, new Object(), null);
|
|
DataDrivenItemDefinition def = makeDef(ITEM_A, 5, null);
|
|
|
|
List<DataDrivenItemDefinition> 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<LivingMotion, ResourceLocation> 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<LivingMotion, ResourceLocation> collected = new LinkedHashMap<>();
|
|
for (Map.Entry<LivingMotion, ResourceLocation> 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<LivingMotion> 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<DataDrivenItemDefinition> 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).
|
|
*
|
|
* <p>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.</p>
|
|
*/
|
|
@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());
|
|
}
|
|
}
|