From 4390c1dce0d80b77918109691a0ac648679b55dd Mon Sep 17 00:00:00 2001 From: Xander Date: Sun, 9 Aug 2026 01:06:30 +0100 Subject: [PATCH 1/6] game testing --- .github/workflows/build.yml | 58 ++++- .github/workflows/publish.yml | 2 +- .github/workflows/update-gamecontrollerdb.yml | 2 +- build.gradle.kts | 74 +++++-- gradle/libs.versions.toml | 1 + .../gametest/framework/CTestUtil.java | 199 ++++++++++++++++++ .../framework/ControlifyGameTestContext.java | 27 +++ .../framework/ControllerInputDefinition.java | 144 +++++++++++++ .../framework/GamepadEffectDriver.java | 21 ++ .../framework/ReversibleCallback.java | 6 + .../framework/TestControllerContext.java | 126 +++++++++++ .../framework/TestControllerState.java | 106 ++++++++++ .../framework/TestControllerStateView.java | 26 +++ .../framework/VirtualControllerBuilder.java | 131 ++++++++++++ .../mixin/ControlifySettingsAccessor.java | 23 ++ .../mixin/SDLControllerManagerAccessor.java | 12 ++ .../mixin/SDLControllerManagerMixin.java | 41 ++++ .../gametest/tests/KeyboardLayoutTests.java | 76 +++++++ .../tests/ResetControlifySettingsTests.java | 43 ++++ .../gametest/tests/RumbleTests.java | 170 +++++++++++++++ .../tests/VirtualControllerInputTests.java | 64 ++++++ .../gametest/tests/package-info.java | 4 + .../resources/controlify_test.mixins.json | 20 ++ src/gametest/resources/fabric.mod.json | 21 ++ .../keyboard_layout/full/en_gb.json | 6 + .../keyboard_layout/full/en_us.json | 6 + .../keyboard_layout_test/pack.mcmeta | 9 + .../templates/keyboard_layout_en_gb.png | Bin 0 -> 12788 bytes .../templates/keyboard_layout_en_us.png | Bin 0 -> 15552 bytes .../dev/isxander/controlify/Controlify.java | 2 +- .../dev/isxander/controlify/utils/CUtil.java | 7 + .../test/SdlVirtualJoystickDescBuilder.java | 173 +++++++++++++++ 32 files changed, 1576 insertions(+), 24 deletions(-) create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/tests/package-info.java create mode 100644 src/gametest/resources/controlify_test.mixins.json create mode 100644 src/gametest/resources/fabric.mod.json create mode 100644 src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json create mode 100644 src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json create mode 100644 src/gametest/resources/resourcepacks/keyboard_layout_test/pack.mcmeta create mode 100644 src/gametest/resources/templates/keyboard_layout_en_gb.png create mode 100644 src/gametest/resources/templates/keyboard_layout_en_us.png create mode 100644 src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51fb061be..4bbc2431d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,7 +53,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: 25 - distribution: temurin + distribution: microsoft - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 @@ -89,11 +89,51 @@ jobs: path: versions/**/build/libs/* name: ${{ matrix.target }} -# run: -# needs: build -# runs-on: ubuntu-latest -# name: Run Minecraft -# -# strategy: -# matrix: -# target: ${{ fromJson(needs.retrieve-targets.outputs.targets) }} + client_game_test: + needs: generate-build-matrix + runs-on: ubuntu-latest + name: Client test with gradle + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-build-matrix.outputs.matrix) }} + + continue-on-error: ${{ matrix.experimental == 'true' }} + + steps: + - uses: actions/checkout@v6 + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: 25 + distribution: microsoft + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make gradlew executable + run: chmod +x ./gradlew + - name: Overwrite current version + run: printf "%s" "${{ matrix.target }}" | cut -d':' -f1 | tr -d '\n' > ./versions/current + - name: Install drivers + run: | + sudo apt-get update + sudo apt-get install -y mesa-vulkan-drivers xvfb + + - name: Setup stonecutter + run: ./gradlew "Refresh active project" + env: + CI_SINGLE_BUILD: ${{ matrix.target }} + + - name: Run client tests + run: ./gradlew runClientGameTest --stacktrace + env: + CI_SINGLE_BUILD: ${{ matrix.target }} + + - name: Upload screenshots + uses: actions/upload-artifact@v7 + if: always() + with: + name: Test Screenshots (${{ matrix.target }}) + path: run/secreenshots + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6a64b7d12..27ce7677d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-java@v5 with: java-version: 25 - distribution: temurin + distribution: microsoft - uses: actions/checkout@v7 diff --git a/.github/workflows/update-gamecontrollerdb.yml b/.github/workflows/update-gamecontrollerdb.yml index 88a188b2b..98ad09d9f 100644 --- a/.github/workflows/update-gamecontrollerdb.yml +++ b/.github/workflows/update-gamecontrollerdb.yml @@ -22,7 +22,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '25' - distribution: 'temurin' + distribution: 'microsoft' - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 diff --git a/build.gradle.kts b/build.gradle.kts index 3cb95948f..489d60b14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,13 +16,15 @@ base.archivesName = "controlify" java.toolchain.languageVersion = JavaLanguageVersion.of(25) +val fabricApiBom = dependencies.platform("net.fabricmc.fabric-api:fabric-api-bom:${property("dep.fapi")}") + dependencies { minecraft("com.mojang:minecraft:$minecraftVersion") fabricLoader(libs.fabric.loader) neoforgeImplementation("net.neoforged:neoforge:${property("dep.neoforge")}") - implementation(platform("net.fabricmc.fabric-api:fabric-api-bom:${property("dep.fapi")}")) - fabricImplementation(platform("net.fabricmc.fabric-api:fabric-api-bom:${property("dep.fapi")}")) + implementation(fabricApiBom) + fabricImplementation(fabricApiBom) fabricImplementation("net.fabricmc.fabric-api:fabric-resource-loader-v1") fabricImplementation("net.fabricmc.fabric-api:fabric-networking-api-v1") fabricImplementation("net.fabricmc.fabric-api:fabric-command-api-v2") @@ -54,40 +56,40 @@ dependencies { neoforgeApi("dev.isxander:yet-another-config-lib:${property("dep.yacl-neoforge")}") ifPresent("dep.mod-menu") { - fabricImplementation("maven.modrinth:modmenu:$it") + fabricCompileOnly("maven.modrinth:modmenu:$it") } ifPresent("dep.sodium") { compileOnly("net.caffeinemc:sodium-fabric:$it") { exclude(group = "net.fabricmc.fabric-api") } - fabricImplementation("net.caffeinemc:sodium-fabric:$it") - neoforgeImplementation("net.caffeinemc:sodium-neoforge:$it") - neoforgeImplementation("net.caffeinemc:sodium-neoforge-mod:$it") + fabricCompileOnly("net.caffeinemc:sodium-fabric:$it") + neoforgeCompileOnly("net.caffeinemc:sodium-neoforge:$it") + neoforgeCompileOnly("net.caffeinemc:sodium-neoforge-mod:$it") } ifPresent("dep.iris") { compileOnly("maven.modrinth:iris:$it") - fabricImplementation("maven.modrinth:iris:$it") + fabricCompileOnly("maven.modrinth:iris:$it") } ifPresent("dep.iris-neoforge") { - neoforgeImplementation("maven.modrinth:iris:$it") + neoforgeCompileOnly("maven.modrinth:iris:$it") } ifPresent("dep.rso") { compileOnly("maven.modrinth:reeses-sodium-options:$it") - fabricImplementation("maven.modrinth:reeses-sodium-options:$it") + fabricCompileOnly("maven.modrinth:reeses-sodium-options:$it") } ifPresent("dep.rso-neoforge") { - neoforgeImplementation("maven.modrinth:reeses-sodium-options:$it") + neoforgeCompileOnly("maven.modrinth:reeses-sodium-options:$it") } ifPresent("dep.svc") { compileOnly("maven.modrinth:simple-voice-chat:$it") - fabricImplementation("maven.modrinth:simple-voice-chat:$it") + fabricCompileOnly("maven.modrinth:simple-voice-chat:$it") } ifPresent("dep.svc-neoforge") { - neoforgeImplementation("maven.modrinth:simple-voice-chat:$it") + neoforgeCompileOnly("maven.modrinth:simple-voice-chat:$it") } ifPresent("dep.fancy-menu") { @@ -255,6 +257,54 @@ if (includeNatives) { } } +/// Testing + +fabricApi { + @Suppress("UnstableApiUsage") + configureTests { + createSourceSet = true + modId = "controlify_test" + enableGameTests = false + enableClientGameTests = true + eula = true + } +} + +sourceSets.named { it in listOf("gametest", "test") }.configureEach { + compileClasspath += sourceSets.fabric.get().output + runtimeClasspath += sourceSets.fabric.get().output +} +sourceSets.named("gametest") { + compileClasspath += sourceSets.test.get().output + runtimeClasspath += sourceSets.test.get().output +} +configurations.named("testCompileClasspath") { + extendsFrom(configurations.named("fabricCompileClasspath")) +} +configurations.named("gametestCompileClasspath") { + extendsFrom(configurations.named("testCompileClasspath")) +} +configurations.named("testRuntimeClasspath") { + extendsFrom(configurations.named("fabricRuntimeClasspath")) +} +configurations.named("gametestRuntimeClasspath") { + extendsFrom(configurations.named("testRuntimeClasspath")) +} + +val gametestImplementation = configurations.named("gametestImplementation") + +dependencies { + testImplementation(libs.fabric.loader.junit) + + gametestImplementation(fabricApiBom) + gametestImplementation("net.fabricmc.fabric-api:fabric-gametest-api-v1") + gametestImplementation("net.fabricmc.fabric-api:fabric-client-gametest-api-v1") +} + +tasks.test { + useJUnitPlatform() +} + /// Publishing publishMods { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bbaf17e59..676edd5a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ mod_publish_plugin = "2.1.1" [libraries] fabric_loader = { module = "net.fabricmc:fabric-loader", version.ref = "fabric_loader" } +fabric_loader_junit = { module = "net.fabricmc:fabric-loader-junit", version.ref = "fabric_loader" } sdl_java_api = { module = "dev.isxander.sdl:sdl-api", version.ref = "sdl_java" } sdl_java_backend_ffm = { module = "dev.isxander.sdl:sdl-backend-ffm", version.ref = "sdl_java" } steamdeck4j = { module = "dev.isxander:steamdeck4j", version.ref = "steamdeck4j" } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java new file mode 100644 index 000000000..9e97592f9 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java @@ -0,0 +1,199 @@ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.config.settings.GlobalSettings; +import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controller.gyro.GyroState; +import dev.isxander.controlify.controllermanager.ControllerManager; +import dev.isxander.controlify.gametest.mixin.ControlifySettingsAccessor; +import dev.isxander.controlify.utils.MinecraftUtil; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.fabricmc.fabric.api.resource.v1.pack.PackActivationType; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.SharedConstants; +import net.minecraft.client.gui.screens.LoadingOverlay; +import net.minecraft.client.gui.screens.Overlay; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@SuppressWarnings("UnstableApiUsage") +public final class CTestUtil { + + private CTestUtil() { + } + + public static Identifier id(String path) { + return Identifier.fromNamespaceAndPath("controlify_test", path); + } + + /// Resets Controlify's global, active-profile and device settings while + /// preserving the runtime invariants of the active profile and connected + /// controllers. + public static void resetControlifySettings(ClientGameTestContext context) { + context.runOnClient(_ -> { + Controlify controlify = Controlify.instance(); + var config = controlify.config(); + var settings = config.getSettings(); + var settingsAccessor = (ControlifySettingsAccessor) settings; + + int activeProfileIndex = config.getActiveProfileIndex(); + if (activeProfileIndex < 0) { + throw new IllegalStateException("Controlify has no active profile"); + } + + GlobalSettings globalDefaults = GlobalSettings.defaults(); + // The profile lock stays on the active index, so keep the preferred + // profile consistent with it rather than blindly restoring index zero. + globalDefaults.preferredProfile = activeProfileIndex; + settingsAccessor.controlify_test$setGlobalSettings(globalDefaults); + + ProfileSettings profileDefaults = ProfileSettings.createDefault(); + settingsAccessor.controlify_test$getProfileSettings().clear(); + settings.putProfileSettings(activeProfileIndex, profileDefaults); + + var connectedControllers = controlify.getControllerManager() + .map(ControllerManager::getConnectedControllers) + .orElseGet(List::of); + var connectedUids = connectedControllers.stream() + .map(ControllerEntity::uid) + .collect(Collectors.toSet()); + + // Keep the existing objects for connected devices because input + // components cache their DeviceSettings reference when attached. + var deviceSettings = settingsAccessor.controlify_test$getDeviceSettings(); + deviceSettings.keySet().retainAll(connectedUids); + for (ControllerEntity controller : connectedControllers) { + var device = settings.getOrCreateDeviceSettings(controller.uid()); + device.name = controller.name(); + device.lastSeen = System.currentTimeMillis(); + device.controllerType = controller.info().type().namespace(); + device.gyroCalibration.offset = new GyroState(); + device.mapping = null; + + controller.setSettings(profileDefaults); + } + + controlify.applyControllerSelection(false); + config.markDirty(); + }); + } + + public static void registerBuiltinResourcePack(Identifier packId) { + ResourceLoader.registerBuiltinPack( + packId, + FabricLoader.getInstance().getModContainer("controlify_test").orElseThrow(), + PackActivationType.NORMAL + ); + } + + public static ReversibleCallback applyResourcePack(ClientGameTestContext context, Identifier packId) { + return applyResourcePack(context, packId.toString()); + } + + public static ReversibleCallback applyResourcePack(ClientGameTestContext context, String packId) { + updatePackRepo(context, packRepo -> packRepo.addPack(packId)); + + return () -> { + updatePackRepo(context, packRepo -> packRepo.removePack(packId)); + }; + } + + private static void updatePackRepo(ClientGameTestContext context, Consumer updater) { + context.runOnClient(minecraft -> { + PackRepository packRepo = minecraft.getResourcePackRepository(); + updater.accept(packRepo); + // this initiates reload + minecraft.options.updateResourcePacks(packRepo); + }); + + // this initiates reload too, but if one is already occurring, it attaches to the current one + reloadResources(context); + } + + public static void reloadResources(ClientGameTestContext context) { + CompletableFuture reloadInstance = context.computeOnClient(minecraft -> { + var future = minecraft.reloadResourcePacks(); + if (MinecraftUtil.getOverlay() instanceof LoadingOverlay) { + return future; + } else { + throw new IllegalStateException("Reload did not initiate"); + } + }); + + // may take a while on a CI + context.waitFor(_ -> reloadInstance.isDone(), SharedConstants.TICKS_PER_SECOND * 30); + if (reloadInstance.isCompletedExceptionally()) { + throw new IllegalStateException("The reload failed", reloadInstance.exceptionNow()); + } + waitForOverlay(context, null); + } + + public static ReversibleCallback setLanguage(ClientGameTestContext context, String code) { + String previousLanguageCode = context.computeOnClient(minecraft -> { + String previous = minecraft.getLanguageManager().getSelected(); + minecraft.getLanguageManager().setSelected(code); + minecraft.options.languageCode = code; + return previous; + }); + + reloadResources(context); + + return () -> { + context.runOnClient(minecraft -> { + minecraft.getLanguageManager().setSelected(previousLanguageCode); + minecraft.options.languageCode = previousLanguageCode; + }); + + reloadResources(context); + }; + } + + public static int waitForOverlay(ClientGameTestContext context, @Nullable Class overlayClass) { + if (overlayClass == null) { + return context.waitFor(_ -> MinecraftUtil.getOverlay() == null); + } else { + return context.waitFor(_ -> overlayClass.isInstance(MinecraftUtil.getOverlay())); + } + } + + public static ServerPlayer getPrincipalPlayer(MinecraftServer server) { + // fabric gametest always calls player Player0 + return server.getPlayerList().getPlayer("Player0"); + } + + public static T summonEntityAtPlayer(MinecraftServer server, EntityType entityType, Consumer preAdd) { + ServerPlayer player = getPrincipalPlayer(server); + ServerLevel level = player.level(); + + T entity = entityType.create(level, EntitySpawnReason.COMMAND); + entity.teleportTo(player.getX(), player.getY(), player.getZ()); + preAdd.accept(entity); + level.addFreshEntity(entity); + return entity; + } + + public static T summonEntityAtPlayer(MinecraftServer server, EntityType entityType) { + return summonEntityAtPlayer(server, entityType, _ -> {}); + } + + public static void clearToasts(ClientGameTestContext context) { + context.runOnClient(minecraft -> { + minecraft.gui.toastManager().clear(); + }); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java new file mode 100644 index 000000000..2e7e98ff2 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java @@ -0,0 +1,27 @@ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.gametest.mixin.SDLControllerManagerAccessor; +import dev.isxander.sdl.Sdl; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +@SuppressWarnings("UnstableApiUsage") +public class ControlifyGameTestContext { + private final ClientGameTestContext context; + private final Sdl sdl; + + public ControlifyGameTestContext(ClientGameTestContext context) { + this.context = context; + this.sdl = ((SDLControllerManagerAccessor) Controlify.instance().getControllerManager().orElseThrow()).controlify_test$getSdl(); + } + + public VirtualControllerBuilder virtualControllerBuilder() { + return new VirtualControllerBuilder(this.context, this, this.sdl); + } + + public void setInputMode(InputMode inputMode) { + Controlify.instance().setInputMode(inputMode); + context.waitTick(); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java new file mode 100644 index 000000000..ba95e8a35 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java @@ -0,0 +1,144 @@ +package dev.isxander.controlify.gametest.framework; + +import java.util.Set; + +import static dev.isxander.sdl.SdlGamepad.*; + +public record ControllerInputDefinition( + Set buttons, + Set axes +) { + public int numButtons() { + return this.buttons.size(); + } + + public int numAxes() { + return this.axes.size(); + } + + /** + * SDL packs the canonical buttons selected by {@code buttonMask} into dense + * joystick button indices. Convert a gamepad button enum to that raw index. + */ + public int joystickButton(int gamepadButton) { + if (!this.buttons.contains(gamepadButton)) { + throw new IllegalArgumentException("Gamepad button is not available: " + gamepadButton); + } + + return (int) this.buttons.stream() + .filter(button -> button < gamepadButton) + .count(); + } + + /** + * SDL packs the canonical axes selected by {@code axisMask} into dense + * joystick axis indices. Convert a gamepad axis enum to that raw index. + */ + public int joystickAxis(int gamepadAxis) { + if (!this.axes.contains(gamepadAxis)) { + throw new IllegalArgumentException("Gamepad axis is not available: " + gamepadAxis); + } + + return (int) this.axes.stream() + .filter(axis -> axis < gamepadAxis) + .count(); + } + + public int buttonMask() { + int mask = 0; + + for (int button : this.buttons) { + mask |= 1 << button; + } + + return mask; + } + + public int axisMask() { + int mask = 0; + + for (int axis : this.axes) { + mask |= 1 << axis; + } + + return mask; + } + + public static final ControllerInputDefinition XINPUT = new ControllerInputDefinition( + Set.of( + SDL_GAMEPAD_BUTTON_SOUTH, + SDL_GAMEPAD_BUTTON_EAST, + SDL_GAMEPAD_BUTTON_WEST, + SDL_GAMEPAD_BUTTON_NORTH, + + SDL_GAMEPAD_BUTTON_BACK, + SDL_GAMEPAD_BUTTON_START, + + SDL_GAMEPAD_BUTTON_LEFT_STICK, + SDL_GAMEPAD_BUTTON_RIGHT_STICK, + + SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, + SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, + + SDL_GAMEPAD_BUTTON_DPAD_UP, + SDL_GAMEPAD_BUTTON_DPAD_DOWN, + SDL_GAMEPAD_BUTTON_DPAD_LEFT, + SDL_GAMEPAD_BUTTON_DPAD_RIGHT + ), + Set.of( + SDL_GAMEPAD_AXIS_LEFTX, + SDL_GAMEPAD_AXIS_LEFTY, + SDL_GAMEPAD_AXIS_RIGHTX, + SDL_GAMEPAD_AXIS_RIGHTY, + SDL_GAMEPAD_AXIS_LEFT_TRIGGER, + SDL_GAMEPAD_AXIS_RIGHT_TRIGGER + ) + ); + + public static final ControllerInputDefinition ALL_SDL_GAMEPAD_INPUTS = new ControllerInputDefinition( + Set.of( + SDL_GAMEPAD_BUTTON_SOUTH, + SDL_GAMEPAD_BUTTON_EAST, + SDL_GAMEPAD_BUTTON_WEST, + SDL_GAMEPAD_BUTTON_NORTH, + + SDL_GAMEPAD_BUTTON_BACK, + SDL_GAMEPAD_BUTTON_GUIDE, + SDL_GAMEPAD_BUTTON_START, + + SDL_GAMEPAD_BUTTON_LEFT_STICK, + SDL_GAMEPAD_BUTTON_RIGHT_STICK, + + SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, + SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, + + SDL_GAMEPAD_BUTTON_DPAD_UP, + SDL_GAMEPAD_BUTTON_DPAD_DOWN, + SDL_GAMEPAD_BUTTON_DPAD_LEFT, + SDL_GAMEPAD_BUTTON_DPAD_RIGHT, + + SDL_GAMEPAD_BUTTON_MISC1, + + SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, + SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, + SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, + SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, + + SDL_GAMEPAD_BUTTON_TOUCHPAD, + + SDL_GAMEPAD_BUTTON_MISC2, + SDL_GAMEPAD_BUTTON_MISC3, + SDL_GAMEPAD_BUTTON_MISC4, + SDL_GAMEPAD_BUTTON_MISC5, + SDL_GAMEPAD_BUTTON_MISC6 + ), + Set.of( + SDL_GAMEPAD_AXIS_LEFTX, + SDL_GAMEPAD_AXIS_LEFTY, + SDL_GAMEPAD_AXIS_RIGHTX, + SDL_GAMEPAD_AXIS_RIGHTY, + SDL_GAMEPAD_AXIS_LEFT_TRIGGER, + SDL_GAMEPAD_AXIS_RIGHT_TRIGGER + ) + ); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java new file mode 100644 index 000000000..2620d252e --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java @@ -0,0 +1,21 @@ +package dev.isxander.controlify.gametest.framework; + +import java.nio.ByteBuffer; + +public interface GamepadEffectDriver { + E receiveEffect(ByteBuffer effect); + + ByteBuffer sendEffect(E effect); + + class Noop implements GamepadEffectDriver { + @Override + public ByteBuffer receiveEffect(ByteBuffer effect) { + return effect; + } + + @Override + public ByteBuffer sendEffect(ByteBuffer effect) { + return effect; + } + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java new file mode 100644 index 000000000..806661247 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java @@ -0,0 +1,6 @@ +package dev.isxander.controlify.gametest.framework; + +public interface ReversibleCallback extends AutoCloseable { + @Override + void close(); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java new file mode 100644 index 000000000..c74a473de --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java @@ -0,0 +1,126 @@ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.controlify.driver.sdl.SDLException; +import dev.isxander.controlify.utils.CUtil; +import dev.isxander.sdl.Sdl; +import dev.isxander.sdl.SdlGamepad; +import dev.isxander.sdl.SdlJoystickHandle; +import dev.isxander.sdl.SdlJoystickId; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +import java.lang.foreign.Arena; +import java.lang.foreign.ValueLayout; + +@SuppressWarnings("UnstableApiUsage") +public class TestControllerContext implements AutoCloseable { + private final ClientGameTestContext context; + private final Sdl sdl; + private final SdlJoystickHandle joystickHandle; + private final SdlJoystickId joystickId; + private final ControllerInputDefinition inputs; + private final TestControllerState state; + + public TestControllerContext( + ClientGameTestContext context, + Sdl sdl, + SdlJoystickHandle joystickHandle, + SdlJoystickId joystickId, + ControllerInputDefinition inputs, + TestControllerState state + ) { + this.context = context; + this.sdl = sdl; + this.joystickHandle = joystickHandle; + this.joystickId = joystickId; + this.inputs = inputs; + this.state = state; + } + + public ControllerEntity getControllerEntity() { + return Controlify.instance().getControllerManager().orElseThrow() + .getConnectedControllers().stream() + .filter(c -> c.info().ucid().equals(new SDLControllerManager.SDLUniqueControllerID(this.joystickId))) + .findAny() + .orElseThrow(); + } + + public void holdButton(int button) { + this.setButton(button, true); + } + + public void releaseButton(int button) { + this.setButton(button, false); + } + + public void tapButton(int button) { + this.holdButton(button); + this.context.waitTick(); + this.releaseButton(button); + this.context.waitTick(); + } + + private void setButton(int button, boolean down) { + this.sdl.joystick().SDL_SetJoystickVirtualButton( + this.joystickHandle, + this.inputs.joystickButton(button), + down + ); + } + + public void holdAxis(int axis, float state) { + short stateShort = CUtil.mapFloatToShort(state); + + this.sdl.joystick().SDL_SetJoystickVirtualAxis( + this.joystickHandle, + this.inputs.joystickAxis(axis), + stateShort + ); + } + + public void releaseAxis(int axis) { + this.holdAxis(axis, 0f); + } + + public void tapAxis(int axis, float state) { + this.holdAxis(axis, state); + context.waitTick(); + this.releaseAxis(axis); + } + + public void sendSensorData(int sensorType, float... data) { + try (var arena = Arena.ofConfined()) { + var memory = arena.allocateFrom(ValueLayout.JAVA_FLOAT, data); + var floatBuffer = memory.asByteBuffer().asFloatBuffer(); + + this.sdl.joystick().SDL_SendJoystickVirtualSensorData( + this.joystickHandle, + sensorType, + 0, + floatBuffer + ); + } + } + + public void sendGyroData(float x, float y, float z) { + this.sendSensorData(SdlGamepad.SDL_SENSOR_GYRO, x, y, z); + } + + public TestControllerStateView state() { + return this.state; + } + + @Override + public void close() { + this.sdl.joystick().SDL_CloseJoystick(this.joystickHandle); + + if (!this.sdl.joystick().SDL_DetachVirtualJoystick(joystickId)) { + throw SDLException.useSDLError(this.sdl, "Failed to detach virtual joystick"); + } + + // wait for controlify event loop to realise its gone + this.context.waitTick(); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java new file mode 100644 index 000000000..57ff4d935 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java @@ -0,0 +1,106 @@ +package dev.isxander.controlify.gametest.framework; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.ByteBuffer; + +public class TestControllerState implements TestControllerStateView { + private int playerIndex; + private short lowFrequencyRumble, highFrequencyRumble; + private short leftTriggerRumble, rightTriggerRumble; + private byte ledRed, ledGreen, ledBlue; + private @Nullable ByteBuffer latestEffect; + private @Nullable E effectState; + private boolean sensorsEnabled; + + private final GamepadEffectDriver effectDriver; + + public TestControllerState(GamepadEffectDriver effectDriver) { + this.effectDriver = effectDriver; + } + + @Override + public int getPlayerIndex() { + return this.playerIndex; + } + + public void setPlayerIndex(int playerIndex) { + this.playerIndex = playerIndex; + } + + @Override + public short getLowFrequencyRumble() { + return this.lowFrequencyRumble; + } + + @Override + public short getHighFrequencyRumble() { + return highFrequencyRumble; + } + + public void setRumble(short lowFrequencyRumble, short highFrequencyRumble) { + this.lowFrequencyRumble = lowFrequencyRumble; + this.highFrequencyRumble = highFrequencyRumble; + } + + @Override + public short getLeftTriggerRumble() { + return leftTriggerRumble; + } + + @Override + public short getRightTriggerRumble() { + return rightTriggerRumble; + } + + public void setTriggerRumble(short leftTriggerRumble, short rightTriggerRumble) { + this.leftTriggerRumble = leftTriggerRumble; + this.rightTriggerRumble = rightTriggerRumble; + } + + @Override + public byte getLedRed() { + return ledRed; + } + + @Override + public byte getLedGreen() { + return ledGreen; + } + + @Override + public byte getLedBlue() { + return ledBlue; + } + + public void setLed(byte red, byte green, byte blue) { + this.ledRed = red; + this.ledGreen = green; + this.ledBlue = blue; + } + + @Override + public @Nullable ByteBuffer getLatestEffect() { + return latestEffect; + } + + public void setLatestEffect(@NotNull ByteBuffer latestEffect) { + this.latestEffect = latestEffect; + this.effectState = this.effectDriver.receiveEffect(latestEffect); + } + + @Override + public E getGamepadEffectState() { + return effectState; + } + + @Override + public boolean getSensorsEnabled() { + return sensorsEnabled; + } + + public void setSensorsEnabled(boolean sensorsEnabled) { + this.sensorsEnabled = sensorsEnabled; + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java new file mode 100644 index 000000000..8fee218be --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java @@ -0,0 +1,26 @@ +package dev.isxander.controlify.gametest.framework; + +import java.nio.ByteBuffer; + +public interface TestControllerStateView { + int getPlayerIndex(); + + short getLowFrequencyRumble(); + short getHighFrequencyRumble(); + + short getLeftTriggerRumble(); + short getRightTriggerRumble(); + + byte getLedRed(); + byte getLedGreen(); + byte getLedBlue(); + + ByteBuffer getLatestEffect(); + E getGamepadEffectState(); + + boolean getSensorsEnabled(); + + default boolean hasRumble() { + return getLowFrequencyRumble() != 0 || getHighFrequencyRumble() != 0; + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java new file mode 100644 index 000000000..d39e8badb --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java @@ -0,0 +1,131 @@ +package dev.isxander.controlify.gametest.framework; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.driver.sdl.SDLException; +import dev.isxander.controlify.test.SdlVirtualJoystickDescBuilder; +import dev.isxander.sdl.*; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import org.jetbrains.annotations.Nullable; + +import java.nio.ByteBuffer; +import java.util.Set; + +@SuppressWarnings("UnstableApiUsage") +public class VirtualControllerBuilder { + private final ClientGameTestContext context; + private final ControlifyGameTestContext controlify; + private final Sdl sdl; + + private final SdlVirtualJoystickDescBuilder descBuilder; + private ControllerInputDefinition inputs = new ControllerInputDefinition(Set.of(), Set.of()); + private @Nullable String gamepadMapping = null; + + public VirtualControllerBuilder(ClientGameTestContext context, ControlifyGameTestContext controlify, Sdl sdl) { + this.context = context; + this.controlify = controlify; + this.sdl = sdl; + this.descBuilder = new SdlVirtualJoystickDescBuilder(); + } + + public VirtualControllerBuilder withXbox() { + this.withInputs(ControllerInputDefinition.XINPUT); + this.descBuilder + .withName("Virtual Xbox Controller") + .withVendorId(0x45e) + .withProductId(0x2ff); + return this; + } + + public VirtualControllerBuilder withDualsense() { + this.withInputs(ControllerInputDefinition.ALL_SDL_GAMEPAD_INPUTS); + this.descBuilder + .withName("Virtual Dualsense Controller") + .withVendorId(0x54c) + .withProductId(0xce6); + return this; + } + + public VirtualControllerBuilder withGyro() { + this.descBuilder + .withSensor(SdlGamepad.SDL_SENSOR_GYRO, 2000); + return this; + } + + public VirtualControllerBuilder withTouchpad(int numFingers) { + this.descBuilder + .withTouchpad(numFingers); + return this; + } + + public VirtualControllerBuilder withInputs(ControllerInputDefinition inputs) { + this.inputs = inputs; + this.descBuilder + .withGamepadType() + .withNumButtons(inputs.numButtons()) + .withNumAxes(inputs.numAxes()) + .withButtonMask(inputs.buttonMask()) + .withAxisMask(inputs.axisMask()); + return this; + } + + public VirtualControllerBuilder withGamepadMapping(String mapping) { + this.gamepadMapping = mapping; + return this; + } + + public TestControllerContext attach() { + return this.attachWithDriver(new GamepadEffectDriver.Noop()); + } + + public TestControllerContext attachWithDriver(@Nullable GamepadEffectDriver driver) { + var state = new TestControllerState<>(driver); + + SdlVirtualJoystickDesc virtualJoystickDesc = this.descBuilder + .withSetPlayerIndex((_, playerIndex) -> state.setPlayerIndex(playerIndex)) + .withRumble((_, low, high) -> { + state.setRumble(low, high); + return true; + }) + .withRumbleTriggers((_, left, right) -> { + state.setTriggerRumble(left, right); + return true; + }) + .withSetLed((_, red, green, blue) -> { + state.setLed(red, green, blue); + return true; + }) + .withSendEffect((_, effect) -> { + // copy to a heap-allocated buffer + var buffer = ByteBuffer.allocate(effect.remaining()); + buffer.put(effect).flip().asReadOnlyBuffer(); + + state.setLatestEffect(buffer); + return true; + }) + .withSetSensorsEnabled((_, enabled) -> { + state.setSensorsEnabled(enabled); + return true; + }) + .build(); + + SdlJoystickId jid = this.sdl.joystick().SDL_AttachVirtualJoystick(virtualJoystickDesc); + if (jid.value() == 0) { + throw SDLException.useSDLError(sdl, "Failed to attach virtual joystick"); + } + + if (this.gamepadMapping != null) { + this.sdl.gamepad().SDL_SetGamepadMapping(jid, this.gamepadMapping); + } + + SdlJoystickHandle handle = this.sdl.joystick().SDL_OpenJoystick(jid); + if (handle.isNull()) { + throw SDLException.useSDLError(sdl, "Failed to open virtual joystick"); + } + + // Allow Controlify to poll and add controller + this.context.waitTick(); + + return new TestControllerContext<>(this.context, this.sdl, handle, jid, this.inputs, state); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java new file mode 100644 index 000000000..764604c52 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java @@ -0,0 +1,23 @@ +package dev.isxander.controlify.gametest.mixin; + +import dev.isxander.controlify.config.settings.ControlifySettings; +import dev.isxander.controlify.config.settings.GlobalSettings; +import dev.isxander.controlify.config.settings.device.DeviceSettings; +import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import it.unimi.dsi.fastutil.ints.Int2ObjectSortedMap; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import java.util.Map; + +@Mixin(ControlifySettings.class) +public interface ControlifySettingsAccessor { + @Accessor("globalSettings") + void controlify_test$setGlobalSettings(GlobalSettings globalSettings); + + @Accessor("profileSettings") + Int2ObjectSortedMap controlify_test$getProfileSettings(); + + @Accessor("deviceSettings") + Map controlify_test$getDeviceSettings(); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java new file mode 100644 index 000000000..4e6551dec --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java @@ -0,0 +1,12 @@ +package dev.isxander.controlify.gametest.mixin; + +import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.sdl.Sdl; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SDLControllerManager.class) +public interface SDLControllerManagerAccessor { + @Accessor("sdl") + Sdl controlify_test$getSdl(); +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java new file mode 100644 index 000000000..f58f99a48 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java @@ -0,0 +1,41 @@ +package dev.isxander.controlify.gametest.mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.controlify.controllermanager.UniqueControllerID; +import dev.isxander.controlify.hid.ControllerHIDInfo; +import dev.isxander.controlify.utils.log.ControlifyLogger; +import dev.isxander.sdl.Sdl; +import dev.isxander.sdl.SdlJoystickId; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +import java.util.Optional; + +@Mixin(SDLControllerManager.class) +public class SDLControllerManagerMixin { + @Shadow + @Final + private Sdl sdl; + + /// Only load virtual joysticks in test environments + @WrapMethod(method = "createController") + private Optional preventNonVirtual( + UniqueControllerID ucid, + ControllerHIDInfo hidInfo, + ControlifyLogger controllerLogger, + Operation> original + ) { + SdlJoystickId jid = ((SDLControllerManager.SDLUniqueControllerID) ucid).jid(); + + if (!sdl.joystick().SDL_IsJoystickVirtual(jid)) { + controllerLogger.warn("Suppressing creation of controller as the test environment permits only virtual controllers"); + return Optional.empty(); + } + + return original.call(ucid, hidInfo, controllerLogger); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java new file mode 100644 index 000000000..bcb0a25b1 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java @@ -0,0 +1,76 @@ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import dev.isxander.sdl.SdlGamepad; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.screenshot.TestScreenshotComparisonAlgorithm; +import net.fabricmc.fabric.api.client.gametest.v1.screenshot.TestScreenshotComparisonOptions; +import net.minecraft.client.gui.screens.ChatScreen; +import net.minecraft.resources.Identifier; + +@SuppressWarnings("UnstableApiUsage") +public class KeyboardLayoutTests implements FabricClientGameTest, ClientModInitializer { + private static final Identifier RESOURCE_PACK_ID = CTestUtil.id("keyboard_layout_test"); + + @Override + public void onInitializeClient() { + CTestUtil.registerBuiltinResourcePack(RESOURCE_PACK_ID); + } + + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + try (var world = context.worldBuilder() + .create()) { + world.getClientLevel().waitForChunksRender(); + + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + try (var _ = CTestUtil.applyResourcePack(context, RESOURCE_PACK_ID)) { + // Test with default en_us language + + // open on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); + context.waitForScreen(ChatScreen.class); + + // check american keyboard layout + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_us") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + + // press back button to close on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); + context.waitForScreen(null); + + // Test with en_gb language + try (var _ = CTestUtil.setLanguage(context, "en_gb")) { + // open on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); + context.waitForScreen(ChatScreen.class); + + // check british keyboard layout + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_gb") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + } + // change language without closing chat screen and check en_us loaded + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_us") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + + // press back button to close on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); + context.waitForScreen(null); + } + } + } + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java new file mode 100644 index 000000000..a3f41f812 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java @@ -0,0 +1,43 @@ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.controller.gyro.GyroState; +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +@SuppressWarnings("UnstableApiUsage") +public class ResetControlifySettingsTests implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + var controlifyContext = new ControlifyGameTestContext(context); + + try (var controller = controlifyContext.virtualControllerBuilder() + .withXbox() + .attach()) { + var controlify = Controlify.instance(); + var controllerEntity = controller.getControllerEntity(); + var settings = controlify.config().getSettings(); + var cachedDeviceSettings = settings.getOrCreateDeviceSettings(controllerEntity.uid()); + + cachedDeviceSettings.gyroCalibration.offset = new GyroState(1f, 2f, 3f); + settings.globalSettings().mixedInput = true; + + CTestUtil.resetControlifySettings(context); + + if (settings.globalSettings().mixedInput) { + throw new AssertionError("Global settings were not reset"); + } + if (cachedDeviceSettings != settings.getOrCreateDeviceSettings(controllerEntity.uid())) { + throw new AssertionError("Connected controller device settings reference was replaced"); + } + if (!cachedDeviceSettings.gyroCalibration.offset.equals(new GyroState())) { + throw new AssertionError("Device settings were not reset"); + } + if (controllerEntity.settings() != controlify.config().getActiveProfile()) { + throw new AssertionError("Controller was not rebound to the default active profile"); + } + } + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java new file mode 100644 index 000000000..36a14027d --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java @@ -0,0 +1,170 @@ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import dev.isxander.controlify.gametest.framework.TestControllerContext; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.LightningBolt; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.animal.cow.Cow; +import net.minecraft.world.entity.monster.cubemob.Slime; +import net.minecraft.world.level.Level; + +@SuppressWarnings("UnstableApiUsage") +public class RumbleTests implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + try (var world = context.worldBuilder() + .setUseConsistentSettings(true) + .create()) { + world.getClientLevel().waitForChunksDownload(); + + runTest(context, controlify, controller, world, "Lightning bolt", this::testLightningBoltRumble); + runTest(context, controlify, controller, world, "Explosion", this::testExplosionRumble); + runTest(context, controlify, controller, world, "Outgoing damage", this::testOutgoingDamage); + runTest(context, controlify, controller, world, "Incoming damage", this::testIncomingDamage); + } + } + } + + private void testLightningBoltRumble( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + ServerLevel level = player.level(); + + LightningBolt bolt = EntityTypes.LIGHTNING_BOLT.create(level, EntitySpawnReason.COMMAND); + bolt.snapTo(player.position()); + bolt.setVisualOnly(true); + level.addFreshEntity(bolt); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + } + + private void testExplosionRumble( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Test that explosion causes rumble + + + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + ServerLevel level = player.level(); + + level.explode(player, player.getX(), player.getY(), player.getZ(), 6f, Level.ExplosionInteraction.NONE); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + float highFreqRumble = controller.state().getHighFrequencyRumble(); + float lowFreqRumble = controller.state().getLowFrequencyRumble(); + + cleanupTest(context, controlify, controller, world); + + + // Test that distant explosion causes lesser rumble + + + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + ServerLevel level = player.level(); + + level.explode(player, player.getX(), player.getY() + 10.0, player.getZ(), 6f, Level.ExplosionInteraction.NONE); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + if (controller.state().getHighFrequencyRumble() > highFreqRumble + || controller.state().getLowFrequencyRumble() > lowFreqRumble) { + throw new AssertionError("Distant explosion rumble should rumble with less intensity."); + } + } + + private void testOutgoingDamage( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Attack cow and wait for rumble + + + Cow cow = world.getServer().computeOnServer(server -> + CTestUtil.summonEntityAtPlayer(server, EntityTypes.COW, c -> c.setNoAi(true))); + + context.waitTick(); + + context.runOnClient(minecraft -> minecraft.player.attack(cow)); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + } + + private void testIncomingDamage( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Make slime attack player and wait for rumble + + + world.getServer().runOnServer(server -> { + ServerPlayer player = CTestUtil.getPrincipalPlayer(server); + Slime slime = CTestUtil.summonEntityAtPlayer(server, EntityTypes.SLIME, c -> c.setNoAi(true)); + slime.getAttribute(Attributes.ATTACK_DAMAGE).setBaseValue(0.5); + slime.doHurtTarget(player.level(), player); + }); + + context.waitFor(_ -> controller.state().hasRumble(), 10); + } + + private void cleanupTest( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world + ) { + // Wait for rumble to stop + context.waitFor(_ -> !controller.state().hasRumble()); + + world.getServer().runCommand("kill @e[type=!minecraft:player]"); + } + + private void runTest( + ClientGameTestContext context, + ControlifyGameTestContext controlify, + TestControllerContext controller, + TestSingleplayerContext world, + String testName, + RumbleTest test + ) { + try { + test.run(context, controlify, controller, world); + } catch (Throwable e) { + throw new AssertionError("Test '" + testName + "' failed", e); + } + + cleanupTest(context, controlify, controller, world); + } + + private interface RumbleTest { + void run(ClientGameTestContext context, ControlifyGameTestContext controlify, TestControllerContext controller, TestSingleplayerContext world); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java new file mode 100644 index 000000000..fd4660ad7 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java @@ -0,0 +1,64 @@ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.controller.input.GamepadInputs; +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import dev.isxander.sdl.SdlGamepad; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.resources.Identifier; + +import java.util.Map; + +@SuppressWarnings("UnstableApiUsage") +public class VirtualControllerInputTests implements FabricClientGameTest { + private static final Map XINPUT_BUTTONS = Map.ofEntries( + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_SOUTH, GamepadInputs.SOUTH_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST, GamepadInputs.EAST_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_WEST, GamepadInputs.WEST_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_NORTH, GamepadInputs.NORTH_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_BACK, GamepadInputs.BACK_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_START, GamepadInputs.START_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_LEFT_STICK, GamepadInputs.LEFT_STICK_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_RIGHT_STICK, GamepadInputs.RIGHT_STICK_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, GamepadInputs.LEFT_SHOULDER_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, GamepadInputs.RIGHT_SHOULDER_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP, GamepadInputs.DPAD_UP_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_DOWN, GamepadInputs.DPAD_DOWN_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_LEFT, GamepadInputs.DPAD_LEFT_BUTTON), + Map.entry(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_RIGHT, GamepadInputs.DPAD_RIGHT_BUTTON) + ); + + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + var controllerEntity = controller.getControllerEntity(); + var input = controllerEntity.input().orElseThrow(); + + for (var expected : XINPUT_BUTTONS.entrySet()) { + controller.holdButton(expected.getKey()); + context.waitFor(_ -> input.rawStateNow().isButtonDown(expected.getValue()), 10); + + for (var other : XINPUT_BUTTONS.values()) { + if (!other.equals(expected.getValue()) && input.rawStateNow().isButtonDown(other)) { + throw new AssertionError( + "Pressed " + expected.getValue() + " but also received " + other + ); + } + } + + controller.releaseButton(expected.getKey()); + context.waitFor(_ -> !input.rawStateNow().isButtonDown(expected.getValue()), 10); + } + } + + CTestUtil.resetControlifySettings(context); + CTestUtil.clearToasts(context); + context.setScreen(TitleScreen::new); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/package-info.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/package-info.java new file mode 100644 index 000000000..e2c82f848 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.gametest.tests; + +import org.jspecify.annotations.NullMarked; diff --git a/src/gametest/resources/controlify_test.mixins.json b/src/gametest/resources/controlify_test.mixins.json new file mode 100644 index 000000000..b07d13e7b --- /dev/null +++ b/src/gametest/resources/controlify_test.mixins.json @@ -0,0 +1,20 @@ +{ + "package": "dev.isxander.controlify.gametest.mixin", + "required": true, + "minVersion": "0.8", + "mixinextras": { + "minVersion": "0.5.0" + }, + "injectors": { + "defaultRequire": 1 + }, + "compatibilityLevel": "JAVA_25", + "overwrites": { + "requireAnnotations": true + }, + "mixins": [ + "ControlifySettingsAccessor", + "SDLControllerManagerAccessor", + "SDLControllerManagerMixin" + ] +} diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json new file mode 100644 index 000000000..236454805 --- /dev/null +++ b/src/gametest/resources/fabric.mod.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "controlify_test", + "version": "1.0.0", + "name": "Controlify Test", + "icon": "icon.png", + "mixins": [ + "controlify_test.mixins.json" + ], + "entrypoints": { + "fabric-client-gametest": [ + "dev.isxander.controlify.gametest.tests.ResetControlifySettingsTests", + "dev.isxander.controlify.gametest.tests.VirtualControllerInputTests", + "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests", + "dev.isxander.controlify.gametest.tests.RumbleTests" + ], + "client": [ + "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests" + ] + } +} diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json new file mode 100644 index 000000000..81cee5708 --- /dev/null +++ b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json @@ -0,0 +1,6 @@ +{ + "width": 7, + "keys": [ + ["b", "r", "i", "t", "i", "s", "h"] + ] +} diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json new file mode 100644 index 000000000..938424245 --- /dev/null +++ b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json @@ -0,0 +1,6 @@ +{ + "width": 7, + "keys": [ + ["a", "m", "e", "r", "i", "c", "a"] + ] +} diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/pack.mcmeta b/src/gametest/resources/resourcepacks/keyboard_layout_test/pack.mcmeta new file mode 100644 index 000000000..d76e3c75c --- /dev/null +++ b/src/gametest/resources/resourcepacks/keyboard_layout_test/pack.mcmeta @@ -0,0 +1,9 @@ +{ + "pack": { + "description": "Keyboard layout test", + "supported_formats": [34, 10000], + "pack_format": 64, + "min_format": 34, + "max_format": [10000, 0] + } +} diff --git a/src/gametest/resources/templates/keyboard_layout_en_gb.png b/src/gametest/resources/templates/keyboard_layout_en_gb.png new file mode 100644 index 0000000000000000000000000000000000000000..f00da5946c10ec4599d6997762d11523d2271040 GIT binary patch literal 12788 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%0D+T`*Y|@YUTIl) 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 F^?&3flv)4) literal 0 HcmV?d00001 diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index eafc6c9d8..d22f9b828 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -387,7 +387,7 @@ private void onControllerAdded(ControllerEntity controller, boolean hotplugged) config().markDirty(); String selectedUid = config().getActiveProfile().controllerUid; - if (selectedUid != null && selectedUid.equals(controller.uid())) { + if (selectedUid == null || selectedUid.equals(controller.uid())) { this.setCurrentController(controller, true); } diff --git a/src/main/java/dev/isxander/controlify/utils/CUtil.java b/src/main/java/dev/isxander/controlify/utils/CUtil.java index 44bf485a8..52d1b5e26 100644 --- a/src/main/java/dev/isxander/controlify/utils/CUtil.java +++ b/src/main/java/dev/isxander/controlify/utils/CUtil.java @@ -143,6 +143,13 @@ public static float mapShortToFloat(short value) { + Mth.clampedMap(value, 0, Short.MAX_VALUE, 0f, 1f); } + public static short mapFloatToShort(float value) { + value = Mth.clamp(value, -1f, 1f); + + float scale = 32767.5f - 0.5f * Math.signum(value); + return (short) Math.round(value * scale); + } + public static boolean isInWithLocalFallback(TypedInstance type, HolderSet items) { return items.unwrapKey() .map(tag -> PlatformClientUtil.isInWithLocalFallback(tag, type.typeHolder())) diff --git a/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java b/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java new file mode 100644 index 000000000..ea4502e62 --- /dev/null +++ b/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java @@ -0,0 +1,173 @@ +package dev.isxander.controlify.test; + +import dev.isxander.sdl.*; + +import java.util.ArrayList; +import java.util.List; + +public class SdlVirtualJoystickDescBuilder { + private int type; + private int vendorId; + private int productId; + private int numAxes; + private int numButtons; + private int numBalls; + private int numHats; + private int buttonMask; + private int axisMask; + private String name; + private final List touchpads = new ArrayList<>(); + private final List sensors = new ArrayList<>(); + private SdlPointer userdata; + private SdlCallbacks.VirtualJoystickUpdateCallback update = (_) -> {}; + private SdlCallbacks.VirtualJoystickSetPlayerIndexCallback setPlayerIndex = (_, _) -> {}; + private SdlCallbacks.VirtualJoystickRumbleCallback rumble = (_, _, _) -> false; + private SdlCallbacks.VirtualJoystickRumbleTriggersCallback rumbleTriggers = (_, _, _) -> false; + private SdlCallbacks.VirtualJoystickSetLedCallback setLed = (_, _, _, _) -> false; + private SdlCallbacks.VirtualJoystickSendEffectCallback sendEffect = (_, _) -> false; + private SdlCallbacks.VirtualJoystickSetSensorsEnabledCallback setSensorsEnabled = (_, _) -> false; + private SdlCallbacks.VirtualJoystickCleanupCallback cleanup = (_) -> {}; + + public SdlVirtualJoystickDescBuilder withType(int type) { + this.type = type; + return this; + } + + public SdlVirtualJoystickDescBuilder withGamepadType() { + return this.withType(SdlJoystick.SDL_JOYSTICK_TYPE_GAMEPAD); + } + + public SdlVirtualJoystickDescBuilder withVendorId(int vendorId) { + this.vendorId = vendorId; + return this; + } + + public SdlVirtualJoystickDescBuilder withProductId(int productId) { + this.productId = productId; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumAxes(int numAxes) { + this.numAxes = numAxes; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumButtons(int numButtons) { + this.numButtons = numButtons; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumBalls(int numBalls) { + this.numBalls = numBalls; + return this; + } + + public SdlVirtualJoystickDescBuilder withNumHats(int numHats) { + this.numHats = numHats; + return this; + } + + public SdlVirtualJoystickDescBuilder withButtonMask(int buttonMask) { + this.buttonMask = buttonMask; + return this; + } + + public SdlVirtualJoystickDescBuilder withAxisMask(int axisMask) { + this.axisMask = axisMask; + return this; + } + + public SdlVirtualJoystickDescBuilder withName(String name) { + this.name = name; + return this; + } + + public SdlVirtualJoystickDescBuilder withTouchpad(SdlVirtualJoystickTouchpadDesc touchpad) { + this.touchpads.add(touchpad); + return this; + } + + public SdlVirtualJoystickDescBuilder withTouchpad(int numFingers) { + return this.withTouchpad(new SdlVirtualJoystickTouchpadDesc(numFingers)); + } + + public SdlVirtualJoystickDescBuilder withSensor(SdlVirtualJoystickSensorDesc sensor) { + this.sensors.add(sensor); + return this; + } + + public SdlVirtualJoystickDescBuilder withSensor(int type, float rate) { + return this.withSensor(new SdlVirtualJoystickSensorDesc(type, rate)); + } + + public SdlVirtualJoystickDescBuilder withUserdata(SdlPointer userdata) { + this.userdata = userdata; + return this; + } + + public SdlVirtualJoystickDescBuilder withUpdate(SdlCallbacks.VirtualJoystickUpdateCallback update) { + this.update = update; + return this; + } + + public SdlVirtualJoystickDescBuilder withSetPlayerIndex(SdlCallbacks.VirtualJoystickSetPlayerIndexCallback setPlayerIndex) { + this.setPlayerIndex = setPlayerIndex; + return this; + } + + public SdlVirtualJoystickDescBuilder withRumble(SdlCallbacks.VirtualJoystickRumbleCallback rumble) { + this.rumble = rumble; + return this; + } + + public SdlVirtualJoystickDescBuilder withRumbleTriggers(SdlCallbacks.VirtualJoystickRumbleTriggersCallback rumbleTriggers) { + this.rumbleTriggers = rumbleTriggers; + return this; + } + + public SdlVirtualJoystickDescBuilder withSetLed(SdlCallbacks.VirtualJoystickSetLedCallback setLed) { + this.setLed = setLed; + return this; + } + + public SdlVirtualJoystickDescBuilder withSendEffect(SdlCallbacks.VirtualJoystickSendEffectCallback sendEffect) { + this.sendEffect = sendEffect; + return this; + } + + public SdlVirtualJoystickDescBuilder withSetSensorsEnabled(SdlCallbacks.VirtualJoystickSetSensorsEnabledCallback setSensorsEnabled) { + this.setSensorsEnabled = setSensorsEnabled; + return this; + } + + public SdlVirtualJoystickDescBuilder withCleanup(SdlCallbacks.VirtualJoystickCleanupCallback cleanup) { + this.cleanup = cleanup; + return this; + } + + public SdlVirtualJoystickDesc build() { + return new SdlVirtualJoystickDesc( + this.type, + this.vendorId, + this.productId, + this.numAxes, + this.numButtons, + this.numBalls, + this.numHats, + this.buttonMask, + this.axisMask, + this.name, + this.touchpads.toArray(SdlVirtualJoystickTouchpadDesc[]::new), + this.sensors.toArray(SdlVirtualJoystickSensorDesc[]::new), + this.userdata, + this.update, + this.setPlayerIndex, + this.rumble, + this.rumbleTriggers, + this.setLed, + this.sendEffect, + this.setSensorsEnabled, + this.cleanup + ); + } +} From a2945a6e6a2ec811c8b6ed9e7232e2bfa522ad3f Mon Sep 17 00:00:00 2001 From: Xander Date: Sun, 9 Aug 2026 01:09:59 +0100 Subject: [PATCH 2/6] fix spotless --- .../isxander/controlify/gametest/framework/CTestUtil.java | 6 ++++++ .../gametest/framework/ControlifyGameTestContext.java | 6 ++++++ .../gametest/framework/ControllerInputDefinition.java | 6 ++++++ .../controlify/gametest/framework/GamepadEffectDriver.java | 6 ++++++ .../controlify/gametest/framework/ReversibleCallback.java | 6 ++++++ .../gametest/framework/TestControllerContext.java | 6 ++++++ .../controlify/gametest/framework/TestControllerState.java | 6 ++++++ .../gametest/framework/TestControllerStateView.java | 6 ++++++ .../gametest/framework/VirtualControllerBuilder.java | 6 ++++++ .../gametest/mixin/ControlifySettingsAccessor.java | 6 ++++++ .../gametest/mixin/SDLControllerManagerAccessor.java | 6 ++++++ .../gametest/mixin/SDLControllerManagerMixin.java | 6 ++++++ .../controlify/gametest/tests/KeyboardLayoutTests.java | 6 ++++++ .../gametest/tests/ResetControlifySettingsTests.java | 6 ++++++ .../dev/isxander/controlify/gametest/tests/RumbleTests.java | 6 ++++++ .../gametest/tests/VirtualControllerInputTests.java | 6 ++++++ .../controlify/test/SdlVirtualJoystickDescBuilder.java | 6 ++++++ 17 files changed, 102 insertions(+) diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java index 9e97592f9..3c895ca87 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import dev.isxander.controlify.Controlify; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java index 2e7e98ff2..a8d448de3 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControlifyGameTestContext.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import dev.isxander.controlify.Controlify; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java index ba95e8a35..37778d9d9 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ControllerInputDefinition.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import java.util.Set; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java index 2620d252e..b14486f32 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/GamepadEffectDriver.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import java.nio.ByteBuffer; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java index 806661247..950901370 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/ReversibleCallback.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; public interface ReversibleCallback extends AutoCloseable { diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java index c74a473de..76a63d7db 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerContext.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import dev.isxander.controlify.Controlify; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java index 57ff4d935..de2ad48e9 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerState.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import org.jetbrains.annotations.NotNull; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java index 8fee218be..1ba1b2afc 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestControllerStateView.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import java.nio.ByteBuffer; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java index d39e8badb..bf4e7ddb9 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.framework; import dev.isxander.controlify.Controlify; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java index 764604c52..9b627f2ab 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.mixin; import dev.isxander.controlify.config.settings.ControlifySettings; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java index 4e6551dec..bd8a23aa6 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.mixin; import dev.isxander.controlify.controllermanager.SDLControllerManager; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java index f58f99a48..fa6ab7d8c 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.mixin; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java index bcb0a25b1..75a401249 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.tests; import dev.isxander.controlify.gametest.framework.CTestUtil; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java index a3f41f812..70548a0f1 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.tests; import dev.isxander.controlify.Controlify; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java index 36a14027d..ce97314da 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.tests; import dev.isxander.controlify.gametest.framework.CTestUtil; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java index fd4660ad7..8d9dd4b04 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.tests; import dev.isxander.controlify.controller.input.GamepadInputs; diff --git a/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java b/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java index ea4502e62..e857b95a3 100644 --- a/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java +++ b/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.test; import dev.isxander.sdl.*; From d1c9ef68ed9ac9438e18875f68ca663827d276ed Mon Sep 17 00:00:00 2001 From: Xander Date: Sun, 9 Aug 2026 01:30:23 +0100 Subject: [PATCH 3/6] fix 26.1 --- .github/workflows/build.yml | 13 +++++--- .../gametest/framework/CEntityTypes.java | 32 +++++++++++++++++++ .../gametest/framework/CTestUtil.java | 4 +++ .../gametest/tests/RumbleTests.java | 12 +++---- 4 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/framework/CEntityTypes.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4bbc2431d..f34f8e486 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,9 +96,13 @@ jobs: strategy: fail-fast: false - matrix: ${{ fromJson(needs.generate-build-matrix.outputs.matrix) }} + matrix: + include: ${{ fromJson(needs.generate-build-matrix.outputs.matrix).include }} + graphics_backend: + - opengl + - vulkan - continue-on-error: ${{ matrix.experimental == 'true' }} + continue-on-error: ${{ contains(matrix.target, 'exp') }} steps: - uses: actions/checkout@v6 @@ -126,7 +130,7 @@ jobs: CI_SINGLE_BUILD: ${{ matrix.target }} - name: Run client tests - run: ./gradlew runClientGameTest --stacktrace + run: ./gradlew runClientGameTest --stacktrace --args="--graphicsBackend ${{ matrix.graphics_backend }}" env: CI_SINGLE_BUILD: ${{ matrix.target }} @@ -134,6 +138,5 @@ jobs: uses: actions/upload-artifact@v7 if: always() with: - name: Test Screenshots (${{ matrix.target }}) + name: Test Screenshots (${{ matrix.target }}) (${{ matrix.graphics_backend }}) path: run/secreenshots - diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/CEntityTypes.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/CEntityTypes.java new file mode 100644 index 000000000..6276a15eb --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/CEntityTypes.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.framework; + +//? if >=26.2 { +import net.minecraft.world.entity.EntityTypes; + +public final class CEntityTypes extends EntityTypes { +} +//?} else { +/*import com.google.common.collect.ImmutableSet; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.flag.FeatureFlagSet; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.storage.loot.LootTable; + +import java.util.Optional; + +public final class CEntityTypes extends EntityType { + private CEntityTypes(EntityFactory factory, MobCategory category, boolean serialize, boolean summon, boolean fireImmune, boolean canSpawnFarFromPlayer, ImmutableSet immuneTo, EntityDimensions dimensions, float spawnDimensionsScale, int clientTrackingRange, int updateInterval, String descriptionId, Optional> lootTable, FeatureFlagSet requiredFeatures, boolean allowedInPeaceful) { + super(factory, category, serialize, summon, fireImmune, canSpawnFarFromPlayer, immuneTo, dimensions, spawnDimensionsScale, clientTrackingRange, updateInterval, descriptionId, lootTable, requiredFeatures, allowedInPeaceful); + } +} +*///?} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java index 3c895ca87..3638c119a 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java @@ -199,7 +199,11 @@ public static T summonEntityAtPlayer(MinecraftServer server, public static void clearToasts(ClientGameTestContext context) { context.runOnClient(minecraft -> { + //? if >=26.2 { minecraft.gui.toastManager().clear(); + //?} else { + /*minecraft.getToastManager().clear(); + *///?} }); } } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java index ce97314da..5b380479f 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java @@ -6,6 +6,7 @@ */ package dev.isxander.controlify.gametest.tests; +import dev.isxander.controlify.gametest.framework.CEntityTypes; import dev.isxander.controlify.gametest.framework.CTestUtil; import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; import dev.isxander.controlify.gametest.framework.TestControllerContext; @@ -15,11 +16,8 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.EntitySpawnReason; -import net.minecraft.world.entity.EntityTypes; import net.minecraft.world.entity.LightningBolt; import net.minecraft.world.entity.ai.attributes.Attributes; -import net.minecraft.world.entity.animal.cow.Cow; -import net.minecraft.world.entity.monster.cubemob.Slime; import net.minecraft.world.level.Level; @SuppressWarnings("UnstableApiUsage") @@ -54,7 +52,7 @@ private void testLightningBoltRumble( ServerPlayer player = CTestUtil.getPrincipalPlayer(server); ServerLevel level = player.level(); - LightningBolt bolt = EntityTypes.LIGHTNING_BOLT.create(level, EntitySpawnReason.COMMAND); + LightningBolt bolt = CEntityTypes.LIGHTNING_BOLT.create(level, EntitySpawnReason.COMMAND); bolt.snapTo(player.position()); bolt.setVisualOnly(true); level.addFreshEntity(bolt); @@ -112,8 +110,8 @@ private void testOutgoingDamage( // Attack cow and wait for rumble - Cow cow = world.getServer().computeOnServer(server -> - CTestUtil.summonEntityAtPlayer(server, EntityTypes.COW, c -> c.setNoAi(true))); + var cow = world.getServer().computeOnServer(server -> + CTestUtil.summonEntityAtPlayer(server, CEntityTypes.COW, c -> c.setNoAi(true))); context.waitTick(); @@ -133,7 +131,7 @@ private void testIncomingDamage( world.getServer().runOnServer(server -> { ServerPlayer player = CTestUtil.getPrincipalPlayer(server); - Slime slime = CTestUtil.summonEntityAtPlayer(server, EntityTypes.SLIME, c -> c.setNoAi(true)); + var slime = CTestUtil.summonEntityAtPlayer(server, CEntityTypes.SLIME, c -> c.setNoAi(true)); slime.getAttribute(Attributes.ATTACK_DAMAGE).setBaseValue(0.5); slime.doHurtTarget(player.level(), player); }); From d8e9db14eb8f21ea8f9e695c6c8bda0923ec0cf2 Mon Sep 17 00:00:00 2001 From: Xander Date: Sun, 9 Aug 2026 01:36:37 +0100 Subject: [PATCH 4/6] fix testing --- .github/workflows/build.yml | 4 ++-- .../framework/VirtualControllerBuilder.java | 4 +--- .../controlify/test/ControlifyUnitTest.java | 16 ++++++++++++++++ .../SdlVirtualJoystickDescBuilder.java | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 src/test/java/dev/isxander/controlify/test/ControlifyUnitTest.java rename src/test/java/dev/isxander/controlify/test/{ => framework}/SdlVirtualJoystickDescBuilder.java (99%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f34f8e486..c6adf006c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,7 +97,7 @@ jobs: strategy: fail-fast: false matrix: - include: ${{ fromJson(needs.generate-build-matrix.outputs.matrix).include }} + target: ${{ fromJson(needs.generate-build-matrix.outputs.matrix).include.*.target }} graphics_backend: - opengl - vulkan @@ -139,4 +139,4 @@ jobs: if: always() with: name: Test Screenshots (${{ matrix.target }}) (${{ matrix.graphics_backend }}) - path: run/secreenshots + path: '**/run/clientGameTest/secreenshots' diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java index bf4e7ddb9..6c91737d3 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/VirtualControllerBuilder.java @@ -6,10 +6,8 @@ */ package dev.isxander.controlify.gametest.framework; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; import dev.isxander.controlify.driver.sdl.SDLException; -import dev.isxander.controlify.test.SdlVirtualJoystickDescBuilder; +import dev.isxander.controlify.test.framework.SdlVirtualJoystickDescBuilder; import dev.isxander.sdl.*; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; import org.jetbrains.annotations.Nullable; diff --git a/src/test/java/dev/isxander/controlify/test/ControlifyUnitTest.java b/src/test/java/dev/isxander/controlify/test/ControlifyUnitTest.java new file mode 100644 index 000000000..0acf2515d --- /dev/null +++ b/src/test/java/dev/isxander/controlify/test/ControlifyUnitTest.java @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.test; + + +import org.junit.jupiter.api.Test; + +public class ControlifyUnitTest { + @Test + void unitTest() { + } +} diff --git a/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java b/src/test/java/dev/isxander/controlify/test/framework/SdlVirtualJoystickDescBuilder.java similarity index 99% rename from src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java rename to src/test/java/dev/isxander/controlify/test/framework/SdlVirtualJoystickDescBuilder.java index e857b95a3..91649aa81 100644 --- a/src/test/java/dev/isxander/controlify/test/SdlVirtualJoystickDescBuilder.java +++ b/src/test/java/dev/isxander/controlify/test/framework/SdlVirtualJoystickDescBuilder.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.test; +package dev.isxander.controlify.test.framework; import dev.isxander.sdl.*; From 238e9c3327b81efbe460432995da0246c7cae72a Mon Sep 17 00:00:00 2001 From: Xander Date: Sun, 9 Aug 2026 01:55:54 +0100 Subject: [PATCH 5/6] add some documentation to existing tests and add new hotplug test --- .../gametest/tests/KeyboardLayoutTests.java | 76 +++++++++++-------- .../gametest/tests/ManyHotplugTests.java | 33 ++++++++ .../tests/ResetControlifySettingsTests.java | 2 + .../gametest/tests/RumbleTests.java | 17 +++++ .../tests/VirtualControllerInputTests.java | 5 ++ src/gametest/resources/fabric.mod.json | 1 + 6 files changed, 101 insertions(+), 33 deletions(-) create mode 100644 src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java index 75a401249..78085c018 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java @@ -34,49 +34,59 @@ public void runTest(ClientGameTestContext context) { .create()) { world.getClientLevel().waitForChunksRender(); - try (var controller = controlify.virtualControllerBuilder() - .withXbox() - .attach()) { - try (var _ = CTestUtil.applyResourcePack(context, RESOURCE_PACK_ID)) { - // Test with default en_us language + runLocaleTest(context, controlify); + } + } - // open on-screen keyboard - controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); - context.waitForScreen(ChatScreen.class); + /// Ensures that the keyboard layout changes appropriately based on the + /// current language setting of the client. + /// + /// Applies a resource pack that makes en_us and en_gb very clearly different, + /// and swaps locales, comparing screenshots. + private void runLocaleTest(ClientGameTestContext context, ControlifyGameTestContext controlify) { + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + try (var _ = CTestUtil.applyResourcePack(context, RESOURCE_PACK_ID)) { + // Test with default en_us language - // check american keyboard layout - context.assertScreenshotContains(TestScreenshotComparisonOptions - .of("keyboard_layout_en_us") - .save() - .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + // open on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); + context.waitForScreen(ChatScreen.class); - // press back button to close on-screen keyboard - controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); - context.waitForScreen(null); + // check american keyboard layout + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_us") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); - // Test with en_gb language - try (var _ = CTestUtil.setLanguage(context, "en_gb")) { - // open on-screen keyboard - controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); - context.waitForScreen(ChatScreen.class); + // press back button to close on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); + context.waitForScreen(null); - // check british keyboard layout - context.assertScreenshotContains(TestScreenshotComparisonOptions - .of("keyboard_layout_en_gb") - .save() - .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); - } - // change language without closing chat screen and check en_us loaded + // Test with en_gb language + try (var _ = CTestUtil.setLanguage(context, "en_gb")) { + // open on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_DPAD_UP); + context.waitForScreen(ChatScreen.class); + + // check british keyboard layout context.assertScreenshotContains(TestScreenshotComparisonOptions - .of("keyboard_layout_en_us") + .of("keyboard_layout_en_gb") .save() .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); - - // press back button to close on-screen keyboard - controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); - context.waitForScreen(null); } + // change language without closing chat screen and check en_us loaded + context.assertScreenshotContains(TestScreenshotComparisonOptions + .of("keyboard_layout_en_us") + .save() + .withAlgorithm(TestScreenshotComparisonAlgorithm.meanSquaredDifference(0.001f))); + + // press back button to close on-screen keyboard + controller.tapButton(SdlGamepad.SDL_GAMEPAD_BUTTON_EAST); + context.waitForScreen(null); } } + } } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java new file mode 100644 index 000000000..f53b6abc6 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java @@ -0,0 +1,33 @@ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.ControlifyGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; + +/// Ensures that there are no limits to the amount of controllers the game can witness in its lifetime. +/// There is the worry that for example SDL can only issue a certain amount of IDs, like #SDL-16. +/// This makes sure that is not the case. +@SuppressWarnings("UnstableApiUsage") +public class ManyHotplugTests implements FabricClientGameTest { + private static final int HOTPLUG_COUNT = 32; + + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + for (int i = 0; i < HOTPLUG_COUNT; i++) { + try (var controller = controlify.virtualControllerBuilder() + .withXbox() + .attach()) { + context.waitTicks(3); + + // finds within ControllerManager if it exists + controller.getControllerEntity(); + } + CTestUtil.clearToasts(context); + } + + CTestUtil.resetControlifySettings(context); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java index 70548a0f1..ea8fc70c3 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java @@ -13,6 +13,8 @@ import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +/// Ensures that {@link CTestUtil#resetControlifySettings(ClientGameTestContext)} +/// works appropriately, not leaving behind any stale references. @SuppressWarnings("UnstableApiUsage") public class ResetControlifySettingsTests implements FabricClientGameTest { @Override diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java index 5b380479f..d864eed37 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java @@ -20,6 +20,18 @@ import net.minecraft.world.entity.ai.attributes.Attributes; import net.minecraft.world.level.Level; +/// Tests the various in-game ways that a controller would receive a rumble effect. +/// To ensure all triggers are working properly. +/// +/// Current untested rumble triggers are: +/// - block breaking +/// - fishing (fish biting) +/// - item break +/// - ender dragon death +/// - wither death +/// - walking across slow block (e.g. soul sand) +/// - using items +/// - landing in water from a height @SuppressWarnings("UnstableApiUsage") public class RumbleTests implements FabricClientGameTest { @Override @@ -42,6 +54,7 @@ public void runTest(ClientGameTestContext context) { } } + /// Summons lightning bolt near the player, which should cause a rumble effect. private void testLightningBoltRumble( ClientGameTestContext context, ControlifyGameTestContext controlify, @@ -61,6 +74,8 @@ private void testLightningBoltRumble( context.waitFor(_ -> controller.state().hasRumble(), 10); } + /// Summons an explosion near the player, which should cause a rumble. + /// Summons a second, distant, explosion, which should cause a lesser rumble. private void testExplosionRumble( ClientGameTestContext context, ControlifyGameTestContext controlify, @@ -101,6 +116,7 @@ private void testExplosionRumble( } } + /// Makes the player apply damage to a mob, which should cause a rumble. private void testOutgoingDamage( ClientGameTestContext context, ControlifyGameTestContext controlify, @@ -120,6 +136,7 @@ private void testOutgoingDamage( context.waitFor(_ -> controller.state().hasRumble(), 10); } + /// Makes a mob apply damage to the player, which should cause a rumble. private void testIncomingDamage( ClientGameTestContext context, ControlifyGameTestContext controlify, diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java index 8d9dd4b04..0eacd788a 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java @@ -17,6 +17,11 @@ import java.util.Map; +/// A regression test: +/// +/// Tests the test framework's virtual button sending translates to the correct button IDs. +/// Without explicit mapping from logical button indices, sometimes a button would appear as +/// another on the mod side. @SuppressWarnings("UnstableApiUsage") public class VirtualControllerInputTests implements FabricClientGameTest { private static final Map XINPUT_BUTTONS = Map.ofEntries( diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json index 236454805..6c6687ab3 100644 --- a/src/gametest/resources/fabric.mod.json +++ b/src/gametest/resources/fabric.mod.json @@ -9,6 +9,7 @@ ], "entrypoints": { "fabric-client-gametest": [ + "dev.isxander.controlify.gametest.tests.ManyHotplugTests", "dev.isxander.controlify.gametest.tests.ResetControlifySettingsTests", "dev.isxander.controlify.gametest.tests.VirtualControllerInputTests", "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests", From 144dea9300839eba6aea6bf8c1ff92f036ef1d2d Mon Sep 17 00:00:00 2001 From: Xander Date: Sun, 9 Aug 2026 02:03:11 +0100 Subject: [PATCH 6/6] fix spotless --- .../controlify/gametest/tests/ManyHotplugTests.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java index f53b6abc6..2794c7992 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java @@ -1,3 +1,9 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ package dev.isxander.controlify.gametest.tests; import dev.isxander.controlify.gametest.framework.CTestUtil;