Audit-10 : add rig/ test coverage (37 new tests)

Added:
- TransformSheetTest (10 tests) : binary search getInterpolationInfo edge
  cases (empty, negative-wrap, exact boundary, multi-keyframe segment),
  maxFrameTime sentinel, copyAll/copy subrange
- PoseTest (10 tests) : interpolatePose merge + lerp correctness at 0/0.5/1,
  orElseEmpty fallback, load() all three LoadOperation modes, disableAllJoints
- LivingMotionIsSameTest (7 tests) : IDLE==INACTION symmetry, uniqueness of
  universalOrdinal across all LivingMotions values
- TimePairListTest (7 tests) : odd-arg rejection, empty list, inclusive begin /
  exclusive end boundary, multi-pair gap
- RigAnimationTickHandlerTest (2 tests) : resetLoggedErrors idempotency

Skipped (MC runtime dep):
- TiedUpCapabilityEventsTest : AttachCapabilitiesEvent + live ForgeEventBus
- EntityPatchProviderInvalidateTest : LazyOptional is a Forge runtime class
- LivingEntityPatch.onConstructed : requires real LivingEntity hierarchy
- RigAnimationTickHandler.tickPlayer/maybePlayIdle : require
  TiedUpCapabilities.getEntityPatch + ClientAnimator + LivingEntityPatch

Bug flagged (no fix) :
- TransformSheet.getFirstFrame() calls copy(0,2) without guarding size >= 2;
  a single-keyframe sheet would throw ArrayIndexOutOfBoundsException
This commit is contained in:
notevil
2026-04-23 09:11:32 +02:00
parent 05cc07a97d
commit 1fa291563c
5 changed files with 576 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Tests de {@link LivingMotion#isSame} — en particulier la symetrie speciale
* IDLE == INACTION definie dans le code source.
*
* Aucun MC runtime requis — LivingMotions est un enum Java pur.
* Note : le chargement de LivingMotions trigger ExtendableEnumManager.assign()
* qui utilise TiedUpRigConstants.LOGGER (LogUtils, SLF4J pur) — pas de
* bootstrap Forge requis.
*/
class LivingMotionIsSameTest {
/**
* IDLE.isSame(INACTION) doit etre true selon le contrat du code source.
* Ce comportement special est utilise par RigAnimationTickHandler pour
* ne pas re-trigger l'animation idle quand la motion courante est INACTION.
*/
@Test
void idle_isSame_inaction_returnsTrue() {
assertTrue(LivingMotions.IDLE.isSame(LivingMotions.INACTION),
"IDLE.isSame(INACTION) doit etre true (alias semantique)");
}
/** Symetrie : INACTION.isSame(IDLE) aussi. */
@Test
void inaction_isSame_idle_returnsTrue() {
assertTrue(LivingMotions.INACTION.isSame(LivingMotions.IDLE),
"INACTION.isSame(IDLE) doit etre true (symetrique)");
}
/** Une motion quelconque est identique a elle-meme. */
@Test
void walk_isSame_walk_returnsTrue() {
assertTrue(LivingMotions.WALK.isSame(LivingMotions.WALK));
}
/** WALK != IDLE — isSame doit retourner false. */
@Test
void walk_isSame_idle_returnsFalse() {
assertFalse(LivingMotions.WALK.isSame(LivingMotions.IDLE),
"WALK.isSame(IDLE) doit etre false");
}
/** IDLE != RUN — pas d'alias entre autres motions. */
@Test
void idle_isSame_run_returnsFalse() {
assertFalse(LivingMotions.IDLE.isSame(LivingMotions.RUN));
}
/** DEATH != DEATH_other — verification que l'alias IDLE/INACTION est exclusif. */
@Test
void death_isSame_walk_returnsFalse() {
assertFalse(LivingMotions.DEATH.isSame(LivingMotions.WALK));
}
/**
* Verification que tous les enums ont un universalOrdinal unique (pas de
* collision dans ExtendableEnumManager.assign).
* BUG potentiel : si deux enums avaient le meme nom lowercase, assign()
* throwrait IllegalArgumentException. Ce test verifie qu'il n'y a pas de
* doublon en triggerant le chargement de la classe.
*/
@Test
void allLivingMotions_haveUniqueOrdinals() {
LivingMotions[] values = LivingMotions.values();
long uniqueOrdinals = java.util.Arrays.stream(values)
.mapToInt(LivingMotions::universalOrdinal)
.distinct()
.count();
org.junit.jupiter.api.Assertions.assertEquals(
values.length, uniqueOrdinals,
"Chaque LivingMotions doit avoir un universalOrdinal unique"
);
}
}

View File

@@ -0,0 +1,189 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
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.assertTrue;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.math.Vec3f;
/**
* Tests de {@link Pose} : interpolatePose, orElseEmpty, load (SET / OVERWRITE /
* APPEND_ABSENT), disableAllJoints.
*
* Aucun MC runtime requis — Pose utilise uniquement des collections Java et
* JointTransform (JOML + Vec3f, pas de Minecraft bootstrap).
*/
class PoseTest {
// --- helpers ---
private static JointTransform translation(float x, float y, float z) {
return JointTransform.translation(new Vec3f(x, y, z));
}
// --- orElseEmpty ---
/** Un joint absent retourne JointTransform.empty() (pas null). */
@Test
void orElseEmpty_unknownJoint_returnsEmpty() {
Pose pose = new Pose();
JointTransform result = pose.orElseEmpty("nonexistent");
assertNotNull(result, "orElseEmpty ne doit pas retourner null");
// Verifier que la translation est zeros (empty = identite)
assertEquals(0.0f, result.translation().x, 1e-5f);
assertEquals(0.0f, result.translation().y, 1e-5f);
assertEquals(0.0f, result.translation().z, 1e-5f);
}
/** Un joint present retourne son transform, pas le fallback. */
@Test
void orElseEmpty_knownJoint_returnsTransform() {
Pose pose = new Pose();
JointTransform jt = translation(1.0f, 2.0f, 3.0f);
pose.putJointData("spine", jt);
assertEquals(jt, pose.orElseEmpty("spine"));
}
// --- interpolatePose ---
/**
* interpolatePose a progression 0.0 doit retourner les valeurs de pose1.
*/
@Test
void interpolatePose_progressionZero_returnsFirstPose() {
Pose pose1 = new Pose();
pose1.putJointData("arm", translation(0.0f, 0.0f, 0.0f));
Pose pose2 = new Pose();
pose2.putJointData("arm", translation(1.0f, 0.0f, 0.0f));
Pose result = Pose.interpolatePose(pose1, pose2, 0.0f);
assertTrue(result.hasTransform("arm"));
assertEquals(0.0f, result.get("arm").translation().x, 1e-5f);
}
/**
* interpolatePose a progression 1.0 doit retourner les valeurs de pose2.
*/
@Test
void interpolatePose_progressionOne_returnsSecondPose() {
Pose pose1 = new Pose();
pose1.putJointData("arm", translation(0.0f, 0.0f, 0.0f));
Pose pose2 = new Pose();
pose2.putJointData("arm", translation(10.0f, 0.0f, 0.0f));
Pose result = Pose.interpolatePose(pose1, pose2, 1.0f);
assertEquals(10.0f, result.get("arm").translation().x, 1e-4f);
}
/**
* interpolatePose fusionne les joints des deux poses.
* Un joint present dans pose2 seulement doit apparaitre dans le resultat
* (interpole avec JointTransform.empty() comme pose1).
*/
@Test
void interpolatePose_mergesJointsFromBothPoses() {
Pose pose1 = new Pose();
pose1.putJointData("head", translation(0.0f, 1.0f, 0.0f));
Pose pose2 = new Pose();
pose2.putJointData("hand", translation(2.0f, 0.0f, 0.0f));
Pose result = Pose.interpolatePose(pose1, pose2, 0.5f);
// Les deux joints doivent etre presents
assertTrue(result.hasTransform("head"), "head from pose1 doit etre dans le resultat");
assertTrue(result.hasTransform("hand"), "hand from pose2 doit etre dans le resultat");
}
/**
* interpolatePose a 0.5 doit etre a mi-chemin de la translation.
*/
@Test
void interpolatePose_halfProgression_midpointTranslation() {
Pose pose1 = new Pose();
pose1.putJointData("leg", translation(0.0f, 0.0f, 0.0f));
Pose pose2 = new Pose();
pose2.putJointData("leg", translation(4.0f, 0.0f, 0.0f));
Pose result = Pose.interpolatePose(pose1, pose2, 0.5f);
assertEquals(2.0f, result.get("leg").translation().x, 1e-4f);
}
// --- load(LoadOperation) ---
/** SET efface les joints existants puis copie depuis la source. */
@Test
void load_set_replacesAllJoints() {
Pose dest = new Pose();
dest.putJointData("old_joint", JointTransform.empty());
Pose src = new Pose();
src.putJointData("new_joint", translation(1.0f, 0.0f, 0.0f));
dest.load(src, Pose.LoadOperation.SET);
assertFalse(dest.hasTransform("old_joint"),
"SET doit supprimer les joints de la destination avant fusion");
assertTrue(dest.hasTransform("new_joint"),
"SET doit copier les joints source");
}
/** OVERWRITE ecrase les joints communs sans supprimer les joints uniquement dans dest. */
@Test
void load_overwrite_keepsExistingAndOverwritesCommon() {
Pose dest = new Pose();
dest.putJointData("kept", JointTransform.empty());
dest.putJointData("shared", translation(0.0f, 0.0f, 0.0f));
Pose src = new Pose();
src.putJointData("shared", translation(5.0f, 0.0f, 0.0f));
dest.load(src, Pose.LoadOperation.OVERWRITE);
assertTrue(dest.hasTransform("kept"),
"OVERWRITE doit conserver les joints de dest non presents dans src");
assertEquals(5.0f, dest.get("shared").translation().x, 1e-4f,
"OVERWRITE doit ecraser le joint commun avec la valeur src");
}
/** APPEND_ABSENT n'ajoute que les joints absents de dest. */
@Test
void load_appendAbsent_doesNotOverwriteExisting() {
Pose dest = new Pose();
dest.putJointData("existing", translation(9.0f, 0.0f, 0.0f));
Pose src = new Pose();
src.putJointData("existing", translation(1.0f, 0.0f, 0.0f));
src.putJointData("added", translation(2.0f, 0.0f, 0.0f));
dest.load(src, Pose.LoadOperation.APPEND_ABSENT);
assertEquals(9.0f, dest.get("existing").translation().x, 1e-4f,
"APPEND_ABSENT ne doit pas ecraser un joint existant");
assertTrue(dest.hasTransform("added"),
"APPEND_ABSENT doit ajouter les joints absents");
}
// --- disableAllJoints ---
/** disableAllJoints vide la map de transforms. */
@Test
void disableAllJoints_clearsTransforms() {
Pose pose = new Pose();
pose.putJointData("head", JointTransform.empty());
pose.putJointData("arm", JointTransform.empty());
pose.disableAllJoints();
assertTrue(pose.getJointTransformData().isEmpty(),
"disableAllJoints doit vider la map de transforms");
}
}

View File

@@ -0,0 +1,149 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.anim.TransformSheet.InterpolationInfo;
import com.tiedup.remake.rig.armature.JointTransform;
/**
* Tests de {@link TransformSheet} : interpolation (binary search),
* maxFrameTime, copy/copyAll.
*
* Aucun MC runtime requis — TransformSheet n'utilise que
* net.minecraft.util.Mth (pure math, pas de bootstrap) et des types
* RIG internes (Vec3f, JointTransform).
*/
class TransformSheetTest {
private static Keyframe kf(float time) {
return new Keyframe(time, JointTransform.empty());
}
private static TransformSheet sheet(float... times) {
Keyframe[] frames = new Keyframe[times.length];
for (int i = 0; i < times.length; i++) {
frames[i] = kf(times[i]);
}
return new TransformSheet(frames);
}
// --- getInterpolationInfo ---
/** Sheet vide doit retourner INVALID, pas NPE. */
@Test
void interpolationInfo_emptySheet_returnsInvalid() {
TransformSheet ts = new TransformSheet(new Keyframe[0]);
assertSame(InterpolationInfo.INVALID, ts.getInterpolationInfo(0.5f),
"Sheet vide doit retourner INVALID");
}
/** Sur [0.0, 1.0] au temps 0.5, delta = 0.5. */
@Test
void interpolationInfo_twoKeyframes_midpointDelta() {
TransformSheet ts = sheet(0.0f, 1.0f);
InterpolationInfo info = ts.getInterpolationInfo(0.5f);
assertEquals(0, info.prev());
assertEquals(1, info.next());
assertEquals(0.5f, info.delta(), 1e-5f);
}
/**
* Temps negatif est converti en maxTime + currentTime.
* Sur [0.0, 1.0], temps=-0.25 -> effectif=0.75 -> delta=0.75.
*/
@Test
void interpolationInfo_negativeTime_wrapsToEnd() {
TransformSheet ts = sheet(0.0f, 1.0f);
InterpolationInfo info = ts.getInterpolationInfo(-0.25f);
assertEquals(0.75f, info.delta(), 1e-5f,
"Temps negatif doit wrapper : maxTime + t");
}
/** Binary search sur 5 keyframes irreguliers — segment correct. */
@Test
void interpolationInfo_multipleKeyframes_correctSegment() {
TransformSheet ts = sheet(0.0f, 0.1f, 0.5f, 0.9f, 1.0f);
InterpolationInfo info = ts.getInterpolationInfo(0.7f);
assertEquals(2, info.prev(), "0.7 entre index 2 (0.5) et 3 (0.9)");
assertEquals(3, info.next());
float expected = (0.7f - 0.5f) / (0.9f - 0.5f);
assertEquals(expected, info.delta(), 1e-5f);
}
/** Temps exactement sur un keyframe interne : delta = 0. */
@Test
void interpolationInfo_exactKeyframeTime_deltaZero() {
TransformSheet ts = sheet(0.0f, 0.5f, 1.0f);
InterpolationInfo info = ts.getInterpolationInfo(0.5f);
assertEquals(0.0f, info.delta(), 1e-5f,
"Temps exact sur keyframe => delta 0");
}
/** Temps au-dela du max : delta clamp a 1.0. */
@Test
void interpolationInfo_timeExceedsMax_deltaClampedToOne() {
TransformSheet ts = sheet(0.0f, 1.0f);
InterpolationInfo info = ts.getInterpolationInfo(2.0f);
assertEquals(1.0f, info.delta(), 1e-5f,
"Temps > max => delta clamp a 1.0");
}
// --- maxFrameTime ---
/** Sheet vide : maxFrameTime retourne la sentinelle -1. */
@Test
void maxFrameTime_emptySheet_returnsMinusOne() {
TransformSheet ts = new TransformSheet(new Keyframe[0]);
assertEquals(-1.0f, ts.maxFrameTime(), 1e-5f);
}
/** maxFrameTime retourne le max quel que soit l'ordre des keyframes. */
@Test
void maxFrameTime_multipleKeyframes_returnsMax() {
TransformSheet ts = sheet(0.0f, 0.3f, 1.5f, 0.7f);
assertEquals(1.5f, ts.maxFrameTime(), 1e-5f);
}
// --- copy / copyAll ---
/** copyAll produit une instance distincte de meme taille. */
@Test
void copyAll_producesIndependentCopy() {
TransformSheet ts = sheet(0.0f, 0.5f, 1.0f);
TransformSheet copy = ts.copyAll();
assertNotSame(ts, copy);
assertEquals(ts.getKeyframes().length, copy.getKeyframes().length);
}
/** copy(start, end) extrait le bon sous-intervalle. */
@Test
void copy_subrange_correctLength() {
TransformSheet ts = sheet(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
TransformSheet sub = ts.copy(1, 4);
assertEquals(3, sub.getKeyframes().length,
"copy(1,4) extrait 3 keyframes");
assertEquals(0.25f, sub.getKeyframes()[0].time(), 1e-5f,
"Premier keyframe de la sous-copie = index 1 de l'original");
}
/**
* BUG FLAG (pas de correctif demande) : getFirstFrame() appelle copy(0, 2)
* sans verifier que la sheet a au moins 2 keyframes. Sur une sheet de 1
* keyframe, ArrayIndexOutOfBoundsException. Ce test documente le cas sain
* (2 keyframes) pour catcher toute regression.
*/
@Test
void getFirstFrame_twoKeyframes_doesNotThrow() {
TransformSheet ts = sheet(0.0f, 1.0f);
TransformSheet first = ts.getFirstFrame();
assertEquals(2, first.getKeyframes().length);
}
}

View File

@@ -0,0 +1,68 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.tick;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
/**
* Tests de {@link RigAnimationTickHandler} — uniquement la logique pure Java
* accessible sans MC runtime.
*
* Ce qui est SKIP ici (et pourquoi) :
* - onClientTick : necessite Minecraft.getInstance() / ClientLevel / Player —
* impossible sans bootstrap Forge.
* - tickPlayer : necessite TiedUpCapabilities.getEntityPatch() qui utilise
* LazyOptional (Forge runtime) + un Player reel.
* - maybePlayIdle : necessite ClientAnimator + LivingEntityPatch (Forge
* capability + MC class hierarchy).
*
* Ce qui est couvert :
* - resetLoggedErrors() : expose explicitement pour les tests (Javadoc l'indique).
* Verifie que la methode est idempotente et ne throw pas.
*/
class RigAnimationTickHandlerTest {
@AfterEach
void cleanUp() {
RigAnimationTickHandler.resetLoggedErrors();
}
/**
* resetLoggedErrors() doit etre un no-op sur un set deja vide.
* Double-appel ne doit pas throw.
*/
@Test
void resetLoggedErrors_idempotent_doesNotThrow() {
assertDoesNotThrow(() -> {
RigAnimationTickHandler.resetLoggedErrors();
RigAnimationTickHandler.resetLoggedErrors();
}, "Double resetLoggedErrors() ne doit pas throw");
}
/**
* Verifie que la constante MAX_LOGGED_UUIDS guard est coherente avec le
* comportement documente : le set est reinitialisable. Ce test appelle
* reset, puis reverifie que reset fonctionne, garantissant que le set
* n'est pas final non-clearable.
*
* Pattern : si ConcurrentHashMap.newKeySet() etait remplace par un Set
* immutable par erreur de refacto, ce test detecterait le throw.
*/
@Test
void resetLoggedErrors_afterReset_secondResetStillDoesNotThrow() {
assertDoesNotThrow(() -> {
RigAnimationTickHandler.resetLoggedErrors();
});
// Second reset simule la sequence F3+T reload (appelee deux fois
// si deux resource-packs swappent rapidement).
assertDoesNotThrow(() -> {
RigAnimationTickHandler.resetLoggedErrors();
}, "Second reset doit rester idempotent (simulation F3+T double-reload)");
}
}

View File

@@ -0,0 +1,85 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.util;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Tests de {@link TimePairList} : creation, isTimeInPairs, bornes.
*
* Classe completement pure Java — aucune dependance MC ou Forge.
*/
class TimePairListTest {
/** Un nombre impair d'arguments doit lever IllegalArgumentException. */
@Test
void create_oddNumberOfArgs_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> TimePairList.create(0.0f, 1.0f, 2.0f),
"create() avec nombre impair d'args doit lever IllegalArgumentException");
}
/** Aucune paire (create()) : rien n'est dans les paires. */
@Test
void create_empty_nothingIsInPairs() {
TimePairList list = TimePairList.create();
assertFalse(list.isTimeInPairs(0.5f),
"Liste vide : aucun temps ne doit etre dans les paires");
}
/** Temps dans une paire [0.0, 1.0]. */
@Test
void isTimeInPairs_timeInsidePair_returnsTrue() {
TimePairList list = TimePairList.create(0.0f, 1.0f);
assertTrue(list.isTimeInPairs(0.0f), "0.0 est la borne de debut (inclusive)");
assertTrue(list.isTimeInPairs(0.5f), "0.5 est dans [0.0, 1.0)");
}
/**
* La borne de fin est EXCLUSIVE (code : time >= begin && time < end).
* Temps = 1.0 ne doit PAS etre dans la paire [0.0, 1.0).
*/
@Test
void isTimeInPairs_exactEndBound_returnsFalse() {
TimePairList list = TimePairList.create(0.0f, 1.0f);
assertFalse(list.isTimeInPairs(1.0f),
"La borne de fin est exclusive : 1.0 ne doit pas etre dans [0.0, 1.0)");
}
/** Temps avant toute paire. */
@Test
void isTimeInPairs_beforeFirstPair_returnsFalse() {
TimePairList list = TimePairList.create(0.5f, 1.0f);
assertFalse(list.isTimeInPairs(0.2f),
"0.2 < 0.5 ne doit pas etre dans [0.5, 1.0)");
}
/** Deux paires non-contigues — le trou entre elles ne doit pas matcher. */
@Test
void isTimeInPairs_gapBetweenPairs_returnsFalse() {
TimePairList list = TimePairList.create(0.0f, 0.3f, 0.7f, 1.0f);
assertFalse(list.isTimeInPairs(0.5f),
"0.5 est dans le trou entre [0.0,0.3) et [0.7,1.0)");
}
/** Temps dans la deuxieme paire d'une liste multi-paires. */
@Test
void isTimeInPairs_secondPair_returnsTrue() {
TimePairList list = TimePairList.create(0.0f, 0.3f, 0.7f, 1.0f);
assertTrue(list.isTimeInPairs(0.8f),
"0.8 est dans la deuxieme paire [0.7, 1.0)");
}
/** Valeur negative — hors de [0.5, 1.0). */
@Test
void isTimeInPairs_negativeTime_returnsFalse() {
TimePairList list = TimePairList.create(0.5f, 1.0f);
assertFalse(list.isTimeInPairs(-0.1f));
}
}