package com.tiedup.remake.client.gltf; import static org.junit.jupiter.api.Assertions.assertEquals; import org.joml.Quaternionf; import org.joml.Vector3f; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; /** * Unit tests for {@link GltfPoseConverter#computeEndTick}. * *

This is the public entry point that controls how many ticks a GLB animation * occupies on the PlayerAnimator timeline. The P0-01 fix replaced a hardcoded * {@code endTick = 1} (which silently froze every animation to frame 0) with a * timestamp-derived computation. These tests pin down that behavior so a future * refactor can't silently re-introduce the bug.

* *

Full end-to-end tests (keyframe emission into a {@code KeyframeAnimation} * builder, multi-item composite, selective-part enabling) require instantiating * a complete {@code GltfData} and the PlayerAnimator library — deferred to a * GameTest or a later harness upgrade.

*/ class GltfPoseConverterTest { private static final int TICKS_PER_SECOND = 20; private static GltfData.AnimationClip clip(float... timestamps) { int n = timestamps.length; Quaternionf[][] rotations = new Quaternionf[1][n]; Vector3f[][] translations = new Vector3f[1][n]; for (int i = 0; i < n; i++) { rotations[0][i] = new Quaternionf(); // identity translations[0][i] = new Vector3f(); } return new GltfData.AnimationClip(timestamps, rotations, translations, n); } @Nested @DisplayName("computeEndTick — timestamp → tick conversion") class ComputeEndTickTests { @Test @DisplayName("null clip returns 1 (minimum valid endTick)") void nullClipReturnsOne() { assertEquals(1, GltfPoseConverter.computeEndTick(null)); } @Test @DisplayName("zero-frame clip returns 1") void zeroFrameReturnsOne() { GltfData.AnimationClip c = new GltfData.AnimationClip( new float[0], new Quaternionf[0][], null, 0 ); assertEquals(1, GltfPoseConverter.computeEndTick(c)); } @Test @DisplayName("single-frame clip at t=0 returns 1") void singleFrameAtZero() { assertEquals(1, GltfPoseConverter.computeEndTick(clip(0.0f))); } @Test @DisplayName("3 frames at 0/0.5/1.0s returns 20 ticks") void threeFramesOneSecondSpan() { // Last timestamp 1.0s → 1.0 * 20 = 20 ticks. assertEquals(20, GltfPoseConverter.computeEndTick(clip(0f, 0.5f, 1.0f))); } @Test @DisplayName("baseline normalization: timestamps 0.5/1.0/1.5s → endTick 20 (span 1s)") void baselineNormalizesToSpan() { // If baseline wasn't subtracted, this would return round(1.5*20)=30. // With baseline=0.5, endTick = round((1.5-0.5)*20) = 20. assertEquals( 20, GltfPoseConverter.computeEndTick(clip(0.5f, 1.0f, 1.5f)) ); } @Test @DisplayName("single-frame clip at t=3.0 still returns 1 (baseline collapses to zero)") void singleFrameAtLargeTime() { // times[0] = 3.0, baseline = 3.0, (3.0 - 3.0) * 20 = 0, clamped to 1. assertEquals(1, GltfPoseConverter.computeEndTick(clip(3.0f))); } @Test @DisplayName("long clip: 2.5s span returns 50 ticks") void longClipTwoAndHalfSeconds() { GltfData.AnimationClip c = clip(0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f); assertEquals(50, GltfPoseConverter.computeEndTick(c)); } @Test @DisplayName("rounding: last timestamp 0.025s (< 0.5 tick) still yields at least 1 tick") void subTickTimestampClampsToOne() { // round(0.025 * 20) = round(0.5) = 0 or 1 depending on rounding mode. // Math.round(float) returns (int)Math.floor(x+0.5) = 1 here. // But even if it were 0, the Math.max(1, ...) clamp should protect us. int endTick = GltfPoseConverter.computeEndTick(clip(0f, 0.025f)); assertEquals( 1, endTick, "Very short spans must collapse to endTick=1, not 0 (would break PlayerAnimator)" ); } @Test @DisplayName("high-FPS source preserves total span: 60 frames over 2s → 40 ticks") void highFpsPreservesSpan() { // 60 evenly-spaced frames from 0.0 to 2.0s. float[] times = new float[60]; for (int i = 0; i < 60; i++) { times[i] = i * (2.0f / 59.0f); } GltfData.AnimationClip c = clip(times); assertEquals( Math.round(2.0f * TICKS_PER_SECOND), GltfPoseConverter.computeEndTick(c) ); } @Test @DisplayName("timestamps array longer than frameCount: uses min(len-1, frameCount-1)") void timestampsLongerThanFrameCount() { // frameCount=3 means only the first 3 timestamps matter even if array is longer. Quaternionf[][] rotations = new Quaternionf[1][3]; rotations[0][0] = new Quaternionf(); rotations[0][1] = new Quaternionf(); rotations[0][2] = new Quaternionf(); GltfData.AnimationClip c = new GltfData.AnimationClip( new float[] { 0f, 0.5f, 1.0f, 99.0f, 99.0f }, // 5 timestamps rotations, null, 3 // but only 3 active frames ); // Last valid index = min(5-1, 3-1) = 2 → times[2] = 1.0 → 20 ticks. assertEquals(20, GltfPoseConverter.computeEndTick(c)); } } }