diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..f4ce81eb --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,35 @@ +name: Gradle (build & test) + +on: + push: + branches: &branches + - 'main' + - '1.16' + - '1.17' + - '1.18' + - '1.19' + pull_request: + branches: *branches + +permissions: + contents: read + +jobs: + build_and_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: microsoft + - name: Build + uses: gradle/gradle-build-action@v2 + with: + arguments: build + - name: Test + uses: gradle/gradle-build-action@v2 + with: + # We could run `check` instead, but we may want to add checks we don't want to use in CI...? + arguments: test diff --git a/README.md b/README.md index ab11eac6..dac141b9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Freecam +![CI](https://github.com/MinecraftFreecam/Freecam/actions/workflows/main.yml/badge.svg?event=push) [![Crowdin](https://badges.crowdin.net/freecam/localized.svg)](https://crowdin.com/project/freecam) This mod allows you to control your camera separately from your player. While it is enabled, you can fly around and travel through blocks within your render distance. Disabling it will restore you to your original position. This can be useful for quickly inspecting builds and exploring your world. diff --git a/build.gradle b/build.gradle index bff0f452..9a2c2d4e 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,24 @@ subprojects { officialMojangMappings() parchment("org.parchmentmc.data:parchment-${parchmentAppendix}:${parchmentVersion}@zip") } + + testImplementation(platform("org.junit:junit-bom:${rootProject.junit_version}")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testImplementation "org.assertj:assertj-core:${rootProject.assertj_version}" + testImplementation "org.mockito:mockito-core:${rootProject.mockito_version}" + testImplementation "org.mockito:mockito-junit-jupiter:${rootProject.mockito_version}" + + if (project.name != "test-util") { + dependencies.testImplementation dependencies.project(path: ":test-utils", configuration: "namedElements") + } + } + + test { + def dir = project.file "run" + dir.mkdirs() + workingDir dir + useJUnitPlatform() } } diff --git a/common/src/test/java/net/xolt/freecam/util/FreecamPositionTest.java b/common/src/test/java/net/xolt/freecam/util/FreecamPositionTest.java new file mode 100644 index 00000000..4614ffa7 --- /dev/null +++ b/common/src/test/java/net/xolt/freecam/util/FreecamPositionTest.java @@ -0,0 +1,205 @@ +package net.xolt.freecam.util; + +import com.mojang.authlib.GameProfile; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.RemotePlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.phys.Vec2; +import net.minecraft.world.phys.Vec3; +import net.xolt.freecam.testing.extension.BootstrapMinecraft; +import net.xolt.freecam.testing.extension.EnableMockito; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.withPrecision; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@EnableMockito +@BootstrapMinecraft +class FreecamPositionTest { + + Entity entity; + FreecamPosition position; + + static double[] distances() { + return new double[] { 1, -1, 2_000_000_001, 0.00456789, -0.0000445646456456060456, 2.5 }; + } + + static Vec2[] rotations() { + return new Vec2[] { Vec2.ZERO, Vec2.MIN, Vec2.MAX, Vec2.UNIT_X, Vec2.UNIT_Y, Vec2.NEG_UNIT_X, Vec2.NEG_UNIT_Y }; + } + + static Vec3[] positions() { + return new Vec3[] { Vec3.ZERO, new Vec3(1, 1, 1), new Vec3(1000, 100, 10) }; + } + + @BeforeEach + void setUp() { + ClientLevel level = mock(ClientLevel.class); + when(level.getSharedSpawnPos()).thenReturn(BlockPos.ZERO); + when(level.getSharedSpawnAngle()).thenReturn(0f); + GameProfile profile = new GameProfile(new UUID(0, 0), "TestPlayer"); + entity = new RemotePlayer(level, profile); + position = new FreecamPosition(entity); + } + + @AfterEach + void tearDown() { + } + + @ParameterizedTest + @EnumSource(Pose.class) + @DisplayName("Use entity position, adjusted for pose") + void init_position(Pose pose) { + entity.setPose(pose); + double diff = entity.getEyeHeight(pose) - entity.getEyeHeight(Pose.SWIMMING); + FreecamPosition swimPos = new FreecamPosition(entity); + + assertThat(swimPos.x).as("x is %01.2f".formatted(entity.getX())).isEqualTo(entity.getX()); + assertThat(swimPos.y).as("y is %01.2f higher than %01.2f".formatted(diff, entity.getY())).isEqualTo(entity.getY() + diff, withPrecision(0.0000004)); + assertThat(swimPos.z).as("z is %01.2f".formatted(entity.getZ())).isEqualTo(entity.getZ()); + } + + @ParameterizedTest + @MethodSource("rotations") + @DisplayName("Uses entity rotation") + void init_rotation(Vec2 rotation) { + entity.setXRot(rotation.x); + entity.setYRot(rotation.y); + FreecamPosition rotatedPos = new FreecamPosition(entity); + + assertThat(rotatedPos.yaw).as("yaw is %01.2f".formatted(rotation.y)).isEqualTo(rotation.y); + assertThat(rotatedPos.pitch).as("pitch is %01.2f".formatted(rotation.x)).isEqualTo(rotation.x); + } + + @ParameterizedTest + @MethodSource("distances") + @DisplayName("Moves forward on x axis") + void moveForward_x(double distance) { + float yaw = -90; + float pitch = 0; + + double x = position.x; + double y = position.y; + double z = position.z; + + position.setRotation(yaw, pitch); + position.moveForward(distance); + + assertThat(position.x).as("x increased by " + distance).isEqualTo(x + distance); + assertThat(position.y).as("y is unchanged").isEqualTo(y); + assertThat(position.z).as("z is unchanged").isEqualTo(z); + + // Moving the same distance after a mirror should revert + position.mirrorRotation(); + position.moveForward(distance); + + assertThat(position.x).as("x is reverted").isEqualTo(x); + assertThat(position.y).as("y is unchanged").isEqualTo(y); + assertThat(position.z).as("z is unchanged").isEqualTo(z); + } + + @ParameterizedTest + @MethodSource("distances") + @DisplayName("Moves forward on y axis") + void moveForward_y(double distance) { + float yaw = 0; + float pitch = -90; + + double x = position.x; + double y = position.y; + double z = position.z; + + position.setRotation(yaw, pitch); + position.moveForward(distance); + + assertThat(position.x).as("x is unchanged").isEqualTo(x); + assertThat(position.y).as("y increased by " + distance).isEqualTo(y + distance); + assertThat(position.z).as("z is unchanged").isEqualTo(z); + + // Moving the same distance after a mirror should revert + position.mirrorRotation(); + position.moveForward(distance); + + assertThat(position.x).as("x is unchanged").isEqualTo(x); + assertThat(position.y).as("y is reverted").isEqualTo(y); + assertThat(position.z).as("z is unchanged").isEqualTo(z); + } + + @ParameterizedTest + @MethodSource("distances") + @DisplayName("Moves forward on z axis") + void moveForward_z(double distance) { + float yaw = 0; + float pitch = 0; + + double x = position.x; + double y = position.y; + double z = position.z; + + position.setRotation(yaw, pitch); + position.moveForward(distance); + + assertThat(position.x).as("x is unchanged").isEqualTo(x); + assertThat(position.y).as("y is unchanged").isEqualTo(y); + assertThat(position.z).as("z increased by " + distance).isEqualTo(z + distance); + + // Moving the same distance after a mirror should revert + position.mirrorRotation(); + position.moveForward(distance); + + assertThat(position.x).as("x is unchanged").isEqualTo(x); + assertThat(position.y).as("y is unchanged").isEqualTo(y); + assertThat(position.z).as("z is reverted").isEqualTo(z); + } + + @ParameterizedTest + @DisplayName("setRotation correctly sets yaw & pitch") + @ValueSource(floats = { -16.456f, 0, 10, 2.5f, 2000008896.546f }) + void setRotation_YawPitch(float number) { + final float constant = 10; + assertThat(position).isNotNull().satisfies( + position -> { + position.setRotation(number, constant); + assertThat(position).as("Yaw is set correctly").satisfies( + p -> assertThat(p.yaw).as("Yaw is set to (var) " + number).isEqualTo(number), + p -> assertThat(p.pitch).as("Pitch is set to (const) " + constant).isEqualTo(constant) + ); + }, + position -> { + position.setRotation(constant, number); + assertThat(position).as("Pitch is set correctly").satisfies( + p -> assertThat(p.yaw).as("Yaw is set to (const) " + constant).isEqualTo(constant), + p -> assertThat(p.pitch).as("Pitch is set to (var) " + number).isEqualTo(number) + ); + } + ); + } + + @ParameterizedTest + @MethodSource("positions") + @DisplayName("ChunkPos should be 16 times smaller than position") + void chunkPos(Vec3 pos) { + position.x = pos.x; + position.y = pos.y; + position.z = pos.z; + // Should be 16 times smaller than x y z position, rounded down + int x = (int) (pos.x / 16); + int z = (int) (pos.z / 16); + ChunkPos chunkPos = position.getChunkPos(); + assertThat(chunkPos.x).isEqualTo(x); + assertThat(chunkPos.z).isEqualTo(z); + } +} \ No newline at end of file diff --git a/fabric/build.gradle b/fabric/build.gradle index c65c6a57..87b48dd4 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -37,6 +37,9 @@ dependencies { shadowCommon project(path: ":common", configuration: "transformProductionFabric") shadowCommon project(":variant:api") + + // Use fabric's Knot classloader in tests + testImplementation "net.fabricmc:fabric-loader-junit:${rootProject.fabric_loader_version}" } variants.each { variant -> @@ -58,6 +61,15 @@ variants.each { variant -> dependencies.add(set.implementationConfigurationName, dependencies.project(path: ":variant:${variant}", configuration: "namedElements")) dependencies.add(shadowConfig.name, dependencies.project(path: ":variant:${variant}", configuration: "transformProductionFabric")) + // Add the normal variant to the test classpath + // TODO consider testing other variants too + sourceSets { + if (variant == "normal") { + test.compileClasspath += set.compileClasspath + test.runtimeClasspath += set.runtimeClasspath + } + } + // Configure/create a run config def run if (variant == "normal") { diff --git a/fabric/src/test/java/net/xolt/freecam/environment/BootstrapTest.java b/fabric/src/test/java/net/xolt/freecam/environment/BootstrapTest.java new file mode 100644 index 00000000..92ca2aad --- /dev/null +++ b/fabric/src/test/java/net/xolt/freecam/environment/BootstrapTest.java @@ -0,0 +1,15 @@ +package net.xolt.freecam.environment; + +import net.minecraft.server.Bootstrap; +import net.xolt.freecam.testing.extension.BootstrapMinecraft; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@BootstrapMinecraft +class BootstrapTest { + @Test + @DisplayName("Validate Minecraft is bootstrapped") + void validateBootstrap() { + Bootstrap.validate(); + } +} diff --git a/fabric/src/test/java/net/xolt/freecam/environment/MixinTest.java b/fabric/src/test/java/net/xolt/freecam/environment/MixinTest.java new file mode 100644 index 00000000..bc855cb6 --- /dev/null +++ b/fabric/src/test/java/net/xolt/freecam/environment/MixinTest.java @@ -0,0 +1,15 @@ +package net.xolt.freecam.environment; + +import net.xolt.freecam.testing.extension.BootstrapMinecraft; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.spongepowered.asm.mixin.MixinEnvironment; + +@BootstrapMinecraft +class MixinTest { + @Test + @DisplayName("Audit mixin environment") + void mixinEnvironmentAudit() { + MixinEnvironment.getCurrentEnvironment().audit(); + } +} diff --git a/gradle.properties b/gradle.properties index 40d76a0f..973558b1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -38,3 +38,7 @@ neoforge_req=[20,) # https://mvnrepository.com/artifact/me.shedaniel.cloth/cloth-config?repo=architectury modmenu_version=9.0.0-pre.1 cloth_version=13.0.114 + +junit_version=5.10.1 +assertj_version=3.25.1 +mockito_version=5.8.0 \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index b27a6722..4f493a7c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,7 @@ include("common") include("fabric") include("neoforge") include("metadata") +include("test-utils") include("variant", "variant:api", "variant:normal", "variant:modrinth") rootProject.name = "freecam" diff --git a/test-utils/build.gradle b/test-utils/build.gradle new file mode 100644 index 00000000..8f2fff56 --- /dev/null +++ b/test-utils/build.gradle @@ -0,0 +1,11 @@ +architectury { + common(rootProject.enabled_platforms.split(',')) +} + +dependencies { + // Needed for Environment annotation + modCompileOnly "net.fabricmc:fabric-loader:${rootProject.fabric_loader_version}" + implementation(platform("org.junit:junit-bom:${rootProject.junit_version}")) + implementation("org.junit.jupiter:junit-jupiter-api") + implementation "org.mockito:mockito-junit-jupiter:${rootProject.mockito_version}" +} diff --git a/test-utils/src/main/java/net/xolt/freecam/testing/extension/BootstrapMinecraft.java b/test-utils/src/main/java/net/xolt/freecam/testing/extension/BootstrapMinecraft.java new file mode 100644 index 00000000..b80e7322 --- /dev/null +++ b/test-utils/src/main/java/net/xolt/freecam/testing/extension/BootstrapMinecraft.java @@ -0,0 +1,16 @@ +package net.xolt.freecam.testing.extension; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Ensure Minecraft is bootstrapped before running tests. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(BootstrapMinecraftExtension.class) +public @interface BootstrapMinecraft {} diff --git a/test-utils/src/main/java/net/xolt/freecam/testing/extension/BootstrapMinecraftExtension.java b/test-utils/src/main/java/net/xolt/freecam/testing/extension/BootstrapMinecraftExtension.java new file mode 100644 index 00000000..d804ae93 --- /dev/null +++ b/test-utils/src/main/java/net/xolt/freecam/testing/extension/BootstrapMinecraftExtension.java @@ -0,0 +1,15 @@ +package net.xolt.freecam.testing.extension; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; + +public class BootstrapMinecraftExtension implements Extension, BeforeAllCallback { + @Override + public void beforeAll(ExtensionContext context) { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } +} diff --git a/test-utils/src/main/java/net/xolt/freecam/testing/extension/EnableMockito.java b/test-utils/src/main/java/net/xolt/freecam/testing/extension/EnableMockito.java new file mode 100644 index 00000000..ae120a88 --- /dev/null +++ b/test-utils/src/main/java/net/xolt/freecam/testing/extension/EnableMockito.java @@ -0,0 +1,14 @@ +package net.xolt.freecam.testing.extension; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(MockitoExtension.class) +public @interface EnableMockito {} diff --git a/test-utils/src/main/java/net/xolt/freecam/testing/util/TestUtils.java b/test-utils/src/main/java/net/xolt/freecam/testing/util/TestUtils.java new file mode 100644 index 00000000..e616fd12 --- /dev/null +++ b/test-utils/src/main/java/net/xolt/freecam/testing/util/TestUtils.java @@ -0,0 +1,32 @@ +package net.xolt.freecam.testing.util; + +import java.lang.reflect.Field; +import java.lang.reflect.InaccessibleObjectException; + +public class TestUtils { + /** + * Get the value of an object's field using reflection. + * + * @param type The field type + * @param instance The object from which to get the field + * @param field The name of the field + * @param The field type + * @return the field's value + * @throws RuntimeException if anything goes wrong + */ + public static T getFieldValue(Class type, Object instance, String field) { + try { + Class instanceType = instance.getClass(); + Field declaredField = instanceType.getDeclaredField(field); + declaredField.setAccessible(true); + Object it = declaredField.get(instance); + return type.cast(it); + } catch (NoSuchFieldException + | IllegalAccessException + | InaccessibleObjectException + | SecurityException + | ClassCastException e) { + throw new RuntimeException(e); + } + } +}