RISK-003 self-heal coverage : extract shouldRebindIdle helper + tests

Le reviewer flagait une race entre PlayerPatch.initAnimator (bind IDLE
au capability attach time) et AnimationManager.apply (datapack reload).
En investigant : la race existe architecturalement mais le self-heal
est deja en place via maybePlayIdle — quand resolveWithFallback retourne
EMPTY pre-apply, le bind EMPTY est mis dans la map (EMPTY_ANIMATION a
isPresent()==true donc passe checkNull). Au tick suivant, une fois
apply tourne et le registry populé, maybePlayIdle detecte
currentIdleBind == EMPTY et rebind sur la vraie anim.

Le bind EMPTY est intentionnel : ClientAnimator.postInit appelle
playAnimationInstantly(livingAnimations.get(IDLE)) immediatement, et
skipper le bind crasherait postInit (NPE sur null.get()). EMPTY a une
pose identity (no-op visuel) → bootstrap propre, self-heal post-reload.

Le symptome runtime "F6 bindings: 1 mais idle silent" decrit dans la
review vient d'un cause differente : AnimationManager n'est pas encore
register comme reload listener (Phase 2 wiring pending, tracked sous
BUG-RACE-01 dans AnimationManager.apply javadoc). Une fois cable, le
self-heal fonctionne — verrouille par les 5 nouveaux tests.

Changements :
- RigAnimationTickHandler : extract shouldRebindIdle(currentBind, target)
  pure helper package-private. La logique inline dans maybePlayIdle
  reste identique, juste deleguee au helper pour testabilite.
- PlayerPatch.initAnimator : javadoc enrichi (race + raison du bind
  EMPTY + reference test coverage).
- RigAnimationTickHandlerTest : +5 cases sur shouldRebindIdle (null+real,
  empty+real RACE, real+real, two real instances, *+empty).

Tests : 459 → 464 (+5), tous green.
This commit is contained in:
notevil
2026-04-27 00:20:01 +02:00
parent 7c994a9ffa
commit d90ff14668
3 changed files with 264 additions and 11 deletions

View File

@@ -270,15 +270,47 @@ public abstract class PlayerPatch<T extends Player> extends LivingEntityPatch<T>
* IDLE sur {@code tiedup:context_stand_idle}, résolue via le registry * IDLE sur {@code tiedup:context_stand_idle}, résolue via le registry
* data-driven ({@link TiedUpAnimationRegistry#resolveWithFallback}). * data-driven ({@link TiedUpAnimationRegistry#resolveWithFallback}).
* *
* <p><b>Pre-reload window</b> : si le patch est construit avant que * <h3>Pre-reload window — race avec AnimationManager.apply (RISK-003)</h3>
* {@code AnimationManager.apply} n'ait tourné (bootstrap client, race * <p>Si le patch est construit AVANT que
* entre join et resource-pack reload, etc.), l'ID {@code context_stand_idle} * {@link com.tiedup.remake.rig.anim.AnimationManager#apply} n'ait tourné
* n'est pas encore dans {@code animationByName} et * (bootstrap client, race entre join et resource-pack reload, capability
* {@code resolveWithFallback} retourne {@link TiedUpRigRegistry#EMPTY_ANIMATION}. * attach pré-{@code AddReloadListenerEvent} en Phase 2 du wiring), l'ID
* C'est idempotent côté animator — une prochaine passe de * {@code tiedup:context_stand_idle} n'est pas encore dans
* {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#maybePlayIdle} * {@code animationByName} et {@code resolveWithFallback} retourne
* re-bind sur la vraie anim une fois le datapack chargé (self-heal).</p> * {@link TiedUpRigRegistry#EMPTY_ANIMATION}.</p>
* *
* <h3>Pourquoi on bind quand même EMPTY_ANIMATION (au lieu de skip)</h3>
* <p>{@link com.tiedup.remake.rig.anim.client.ClientAnimator#postInit} appelle
* immédiatement {@code playAnimationInstantly(livingAnimations.get(IDLE))}.
* Si on skipait le bind ({@code null} dans la map), {@code postInit} NPE
* sur {@code nextAnimation.get()}. EMPTY_ANIMATION a {@code isPresent()==true}
* (cf. {@link com.tiedup.remake.rig.anim.types.DirectStaticAnimation#isPresent})
* donc passe le {@code AnimationManager.checkNull} guard et bootstrap
* proprement avec une pose identity (no-op visuel).</p>
*
* <h3>Self-heal après reload</h3>
* <p>Une fois {@code AnimationManager.apply} terminé (datapack chargé, anim
* réelle dans le registry),
* {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#maybePlayIdle}
* détecte le bind EMPTY au tick suivant via
* {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#shouldRebindIdle}
* et rebind sur la vraie anim. Idempotent — les
* {@code addLivingAnimation} suivantes écrasent l'entry existante. Aucun
* code "retry hook" post-reload n'est nécessaire : le tick handler observe
* en pull (chaque tick) plutôt qu'en push (callback reload).</p>
*
* <h3>Couverture test (RISK-003 verrouillé)</h3>
* <p>Voir {@code RigAnimationTickHandlerTest.shouldRebindIdle_*} pour les
* tests unitaires de la logique de self-heal pure (sans MC bootstrap).
* Cas couverts :</p>
* <ul>
* <li>{@code currentBind=null, target=real} → rebind</li>
* <li>{@code currentBind=EMPTY, target=real} → rebind (race scenario)</li>
* <li>{@code currentBind=real, target=real} → no-op (steady state)</li>
* <li>{@code currentBind=*, target=EMPTY} → no-op (apply pas encore tourné)</li>
* </ul>
*
* <h3>Pourquoi on se limite à IDLE</h3>
* <p>L'ordre EF ajoute ~25 motions différentes (WALK, RUN, SNEAK, SIT, * <p>L'ordre EF ajoute ~25 motions différentes (WALK, RUN, SNEAK, SIT,
* SLEEP, etc.). On se limite à IDLE ici — ajouter les autres sans anim * SLEEP, etc.). On se limite à IDLE ici — ajouter les autres sans anim
* source = pollution registre pour rien. Au fur et à mesure que les JSON * source = pollution registre pour rien. Au fur et à mesure que les JSON
@@ -291,6 +323,7 @@ public abstract class PlayerPatch<T extends Player> extends LivingEntityPatch<T>
// Full data-driven : lookup par ID, EMPTY_ANIMATION fallback si // Full data-driven : lookup par ID, EMPTY_ANIMATION fallback si
// l'asset n'est pas encore chargé (rare mais possible au bootstrap). // l'asset n'est pas encore chargé (rare mais possible au bootstrap).
// Le tick handler self-heal le bind une fois le datapack loadé. // Le tick handler self-heal le bind une fois le datapack loadé.
// Voir javadoc ci-dessus pour le contrat complet (RISK-003).
AnimationAccessor<? extends StaticAnimation> idle = AnimationAccessor<? extends StaticAnimation> idle =
TiedUpAnimationRegistry.resolveWithFallback( TiedUpAnimationRegistry.resolveWithFallback(
TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID

View File

@@ -261,13 +261,62 @@ public final class RigAnimationTickHandler {
// EMPTY_ANIMATION. Dans les deux cas on rebind vers le target résolu. // EMPTY_ANIMATION. Dans les deux cas on rebind vers le target résolu.
AssetAccessor<? extends StaticAnimation> currentIdleBind = AssetAccessor<? extends StaticAnimation> currentIdleBind =
clientAnimator.getLivingAnimation(LivingMotions.IDLE, null); clientAnimator.getLivingAnimation(LivingMotions.IDLE, null);
if (currentIdleBind == null || currentIdleBind == TiedUpRigRegistry.EMPTY_ANIMATION) { if (shouldRebindIdle(currentIdleBind, target)) {
clientAnimator.addLivingAnimation(LivingMotions.IDLE, target); clientAnimator.addLivingAnimation(LivingMotions.IDLE, target);
} }
clientAnimator.playAnimation(target, 0.2F); clientAnimator.playAnimation(target, 0.2F);
} }
/**
* Pure helper — décide si le bind IDLE courant doit être remplacé par
* {@code target}. Extrait pour tests unitaires sans bootstrap MC (cf.
* {@code RigAnimationTickHandlerTest.shouldRebindIdle_*}).
*
* <p><b>Contrat</b> : retourne {@code true} ssi {@code target} est une
* vraie anim (pas {@link TiedUpRigRegistry#EMPTY_ANIMATION}) ET que le
* bind courant est soit absent ({@code null}), soit la sentinelle EMPTY.
* Dans tous les autres cas ({@code target} non-EMPTY identique au bind
* courant, ou {@code target} EMPTY) on garde le bind existant.</p>
*
* <p>L'appelant ({@link #maybePlayIdle}) a déjà filtré
* {@code target == EMPTY_ANIMATION} en early-return — la condition
* {@code target != EMPTY} ici est défensive (l'helper reste correct si
* jamais le filtre upstream est modifié).</p>
*
* <p><b>Race scenario couvert</b> (RISK-003) :</p>
* <ol>
* <li>{@code PlayerPatch.initAnimator} construit pre-{@code apply()} :
* {@code resolveWithFallback} retourne EMPTY → {@code addLivingAnimation}
* met EMPTY dans la map (car {@code EMPTY_ANIMATION.isPresent()==true}
* — cf. {@link com.tiedup.remake.rig.anim.types.DirectStaticAnimation#isPresent}).</li>
* <li>{@code AnimationManager.apply()} tourne plus tard (datapack reload
* wiring Phase 2). Le registry contient maintenant un nouvel accessor
* pour {@code context_stand_idle}.</li>
* <li>Tick suivant : {@code maybePlayIdle} résout {@code target = real
* accessor}, voit {@code currentIdleBind == EMPTY}, rebind via cet
* helper.</li>
* </ol>
*
* @param currentBind bind IDLE courant (peut être {@code null} si jamais
* bound, ou {@link TiedUpRigRegistry#EMPTY_ANIMATION} si
* bound pré-apply)
* @param target target résolu via {@link TiedUpAnimationRegistry#resolveWithFallback}
* (par contrat upstream, jamais {@code null})
* @return {@code true} si rebind nécessaire, {@code false} sinon
*/
static boolean shouldRebindIdle(
AssetAccessor<? extends StaticAnimation> currentBind,
AssetAccessor<? extends StaticAnimation> target
) {
// Garde défensive : un target EMPTY ne doit jamais écraser un bind
// existant (régresserait l'état). En pratique l'appelant filtre déjà.
if (target == TiedUpRigRegistry.EMPTY_ANIMATION) {
return false;
}
return currentBind == null || currentBind == TiedUpRigRegistry.EMPTY_ANIMATION;
}
/** /**
* Reset des erreurs loggées. Appelé : * Reset des erreurs loggées. Appelé :
* <ul> * <ul>

View File

@@ -5,10 +5,17 @@
package com.tiedup.remake.rig.tick; package com.tiedup.remake.rig.tick;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.anim.AnimationManager;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
/** /**
* Tests de {@link RigAnimationTickHandler} — uniquement la logique pure Java * Tests de {@link RigAnimationTickHandler} — uniquement la logique pure Java
* accessible sans MC runtime. * accessible sans MC runtime.
@@ -18,12 +25,14 @@ import org.junit.jupiter.api.Test;
* impossible sans bootstrap Forge. * impossible sans bootstrap Forge.
* - tickPlayer : necessite TiedUpCapabilities.getEntityPatch() qui utilise * - tickPlayer : necessite TiedUpCapabilities.getEntityPatch() qui utilise
* LazyOptional (Forge runtime) + un Player reel. * LazyOptional (Forge runtime) + un Player reel.
* - maybePlayIdle : necessite ClientAnimator + LivingEntityPatch (Forge * - maybePlayIdle (full path) : necessite ClientAnimator + LivingEntityPatch
* capability + MC class hierarchy). * (Forge capability + MC class hierarchy).
* *
* Ce qui est couvert : * Ce qui est couvert :
* - resetLoggedErrors() : expose explicitement pour les tests (Javadoc l'indique). * - resetLoggedErrors() : expose explicitement pour les tests (Javadoc l'indique).
* Verifie que la methode est idempotente et ne throw pas. * Verifie que la methode est idempotente et ne throw pas.
* - shouldRebindIdle() : helper pur extrait pour tester la logique de self-heal
* du bind IDLE post-{@code AnimationManager.apply()} (RISK-003).
*/ */
class RigAnimationTickHandlerTest { class RigAnimationTickHandlerTest {
@@ -65,4 +74,166 @@ class RigAnimationTickHandlerTest {
RigAnimationTickHandler.resetLoggedErrors(); RigAnimationTickHandler.resetLoggedErrors();
}, "Second reset doit rester idempotent (simulation F3+T double-reload)"); }, "Second reset doit rester idempotent (simulation F3+T double-reload)");
} }
// === shouldRebindIdle — RISK-003 self-heal logic ============================
//
// Verrouille le contrat de self-heal du bind IDLE : si initAnimator a bound
// EMPTY (apply pas encore tourné), le tick handler doit rebind sur la vraie
// anim une fois le registry populé. Voir PlayerPatch.initAnimator javadoc
// pour le scenario complet.
//
// Tests sans MC bootstrap : on injecte des accessors fakes via
// AnimationManager.AnimationBuilder (n'invoque pas onLoad tant qu'on ne
// call pas .get()).
/** Namespace arbitraire pour les IDs de test — isole des vraies registrations. */
private static final String TEST_NS = "tiedup_test_rebind";
/**
* Cas 1/4 — currentBind=null, target=real → rebind nécessaire.
*
* <p>Représente le cas "patch fraîchement construit, jamais bound IDLE
* encore" (théorique : initAnimator skip le bind, scénario alternatif).
* Le rebind doit fire pour amorcer la map.</p>
*/
@Test
void shouldRebindIdle_nullCurrent_realTarget_returnsTrue() {
AnimationAccessor<StaticAnimation> realTarget = registerFakeAccessor("real_anim_1");
boolean result = RigAnimationTickHandler.shouldRebindIdle(null, realTarget);
assertTrue(result, "currentBind=null + target=real doit déclencher rebind "
+ "(amorce la map, sinon postInit aurait NPE — defensive case).");
}
/**
* Cas 2/4 — currentBind=EMPTY, target=real → rebind (RACE SCENARIO RISK-003).
*
* <p>C'est LE scenario que RISK-003 décrit :</p>
* <ol>
* <li>{@code PlayerPatch.initAnimator} fire pre-{@code apply} → bind EMPTY.</li>
* <li>{@code AnimationManager.apply()} tourne → registry contient real anim.</li>
* <li>Tick suivant : {@code maybePlayIdle} résout {@code target=real},
* voit {@code currentIdleBind=EMPTY}, doit rebind.</li>
* </ol>
*
* <p>Si ce test échoue, le self-heal est cassé et l'idle reste silent
* (symptôme F6 overlay {@code bindings: 1} avec EMPTY).</p>
*/
@Test
void shouldRebindIdle_emptyCurrent_realTarget_returnsTrue() {
AnimationAccessor<StaticAnimation> realTarget = registerFakeAccessor("real_anim_2");
boolean result = RigAnimationTickHandler.shouldRebindIdle(
TiedUpRigRegistry.EMPTY_ANIMATION,
realTarget
);
assertTrue(result, "RISK-003 : currentBind=EMPTY + target=real DOIT rebind. "
+ "Régression si le self-heal post-AnimationManager.apply() ne triggere "
+ "plus → idle silent à vie après bootstrap pre-reload.");
}
/**
* Cas 3/4 — currentBind=real, target=real (même instance) → no-op.
*
* <p>Steady state : le bind est correct, pas de rebind à faire.
* Idempotence du tick handler.</p>
*/
@Test
void shouldRebindIdle_realCurrent_realTarget_returnsFalse() {
AnimationAccessor<StaticAnimation> realAnim = registerFakeAccessor("real_anim_3");
boolean result = RigAnimationTickHandler.shouldRebindIdle(realAnim, realAnim);
assertFalse(result, "Steady state (current=target=real) ne doit pas rebind "
+ "— sinon spam d'addLivingAnimation à chaque tick (perf hit + log noise).");
}
/**
* Cas 3 bis — currentBind=real (instance A), target=real (instance B, même ID).
*
* <p>Post-reload : l'accessor a une nouvelle instance (cf.
* {@code AnimationAccessorImpl} créé fresh dans
* {@code AnimationManager.readResourcepackAnimation}). On considère ça
* comme steady state — l'helper compare par identité (pas registryName)
* pour rester simple, et le caller gère le re-binding via la check
* {@code currentAnim.registryName().equals(targetId)} à un autre niveau.</p>
*
* <p>Cas important pour comprendre la portée de ce helper : il NE résout
* PAS le cas "post-reload, ancienne instance toujours bound mais registry
* a une nouvelle instance avec même ID". Cette resolution est handled par
* la check précédente dans {@code maybePlayIdle} qui compare via
* {@code registryName()}.</p>
*/
@Test
void shouldRebindIdle_differentInstancesSameId_returnsTrue() {
// Deux instances distinctes mais avec le MÊME registry name.
// Simule "pré-reload bind" vs "post-reload re-instantiation" du même asset.
AnimationAccessor<StaticAnimation> oldInstance = registerFakeAccessor("realod_test");
AnimationAccessor<StaticAnimation> newInstance = registerFakeAccessor("realod_test_new");
// Note : on ne peut pas avoir 2 accessors avec le même path via
// AnimationBuilder (nextAccessor nomme via path donné). On simule juste
// "deux instances réelles distinctes" = bind différent, helper rebind.
boolean result = RigAnimationTickHandler.shouldRebindIdle(oldInstance, newInstance);
// shouldRebindIdle compare par identité de référence — un current real
// != target real returns false (ne fait pas de smart "same registryName"
// check). La désynchro registry-name post-reload est gérée par
// maybePlayIdle via une check upstream sur registryName().equals(targetId).
assertFalse(result, "shouldRebindIdle compare par identité — current=real !=null "
+ "ET target=real → on ne rebind pas même si instances diffèrent. "
+ "La détection post-reload se fait upstream (registryName check).");
}
/**
* Cas 4/4 — currentBind=*, target=EMPTY → no-op (jamais écraser un bind par EMPTY).
*
* <p>Garde défensive : si {@code AnimationManager.apply()} n'a pas encore
* tourné, {@code resolveWithFallback} retourne EMPTY. Dans ce cas le
* caller ({@code maybePlayIdle}) a déjà early-returned, mais l'helper
* reste safe si jamais le filtre upstream est modifié — un EMPTY target
* ne doit jamais régresser un bind real existant.</p>
*/
@Test
void shouldRebindIdle_emptyTarget_returnsFalse() {
AnimationAccessor<StaticAnimation> realCurrent = registerFakeAccessor("real_anim_5");
// Avec current=real et target=EMPTY : pas de rebind.
boolean withReal = RigAnimationTickHandler.shouldRebindIdle(
realCurrent, TiedUpRigRegistry.EMPTY_ANIMATION
);
assertFalse(withReal, "target=EMPTY ne doit jamais écraser un bind real "
+ "(régresserait l'état). Defensive guard contre futur bug upstream.");
// Avec current=null et target=EMPTY : pas de rebind non plus (rien à
// faire de productif — laisse postInit gérer le bootstrap initial).
boolean withNull = RigAnimationTickHandler.shouldRebindIdle(
null, TiedUpRigRegistry.EMPTY_ANIMATION
);
assertFalse(withNull, "target=EMPTY + current=null : pas de rebind, "
+ "rien à amorcer car EMPTY n'apporte aucune info utile.");
// Cas dégénéré : current=EMPTY, target=EMPTY — aucune progression possible.
boolean withEmpty = RigAnimationTickHandler.shouldRebindIdle(
TiedUpRigRegistry.EMPTY_ANIMATION, TiedUpRigRegistry.EMPTY_ANIMATION
);
assertFalse(withEmpty, "current=EMPTY + target=EMPTY : registry pas encore "
+ "populé, attendre la prochaine reload — pas de rebind.");
}
/**
* Inject un accessor fake dans {@link AnimationManager} via le builder
* public. {@code onLoad} retourne null mais ce n'est pas invoqué tant
* qu'on ne call pas {@code .get()} dessus dans les tests (on compare
* uniquement par identité de référence).
*/
private static AnimationAccessor<StaticAnimation> registerFakeAccessor(String idPath) {
// Suffix nano pour éviter les collisions entre test cases (le builder
// jette IllegalArgumentException si le namespace est déjà utilisé).
String uniqueNs = TEST_NS + "_" + System.nanoTime();
return new AnimationManager.AnimationBuilder(uniqueNs, b -> {})
.nextAccessor(idPath, acc -> null);
}
} }