Files
TiedUp-/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java
notevil e37dad18aa P3-05 review fixes : deterministic tiebreaker + reset contract doc
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.
2026-04-23 22:11:33 +02:00

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());
}
}