Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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 net.minecraft.client.gui.screens.recipebook.RecipeBookPage;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;

@Mixin(RecipeBookPage.class)
public interface RecipeBookPageAccessor {
@Accessor("currentPage")
int controlify_test$getCurrentPage();

@Accessor("totalPages")
int controlify_test$getTotalPages();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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;
import dev.isxander.controlify.InputMode;
import dev.isxander.controlify.gametest.framework.TestUnitWorldContext;
import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext;
import dev.isxander.controlify.gametest.mixin.RecipeBookPageAccessor;
import dev.isxander.controlify.mixins.feature.virtualmouse.snapping.RecipeBookComponentAccessor;
import dev.isxander.controlify.screenop.compat.vanilla.RecipeBookScreenProcessor;
import dev.isxander.controlify.utils.MinecraftUtil;
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.inventory.CraftingScreen;
import net.minecraft.core.BlockPos;
import net.minecraft.world.inventory.RecipeBookType;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;

import java.util.function.BooleanSupplier;

/// Exercises the simulated mouse clicks used by recipe-book controller navigation.
@SuppressWarnings("UnstableApiUsage")
public class RecipeBookNavigationTests implements FabricClientGameTest {
@Override
public void runTest(ClientGameTestContext context) {
var controlify = new ControlifyGameTestContext(context);
controlify.resetSettings();

try (var controller = controlify.virtualControllerBuilder().withXbox().attach();
var world = new TestUnitWorldContext(context);
var region = world.allocateRegion(3, 3, 3)) {
var tablePos = new BlockPos(1, 1, 2);
region.setBlock(tablePos, Blocks.CRAFTING_TABLE);
region.runOnServer((level, player) -> {
player.setGameMode(GameType.SURVIVAL);
player.awardRecipes(level.getServer().getRecipeManager().getRecipes());
});

context.runOnClient(client -> {
client.player.getRecipeBook().setOpen(RecipeBookType.CRAFTING, true);
client.player.getRecipeBook().setFiltering(RecipeBookType.CRAFTING, false);
Controlify.instance().setCurrentController(controller.getControllerEntity(), true);
Controlify.instance().setInputMode(InputMode.CONTROLLER);
});
region.runOnServer((level, player) -> {
var pos = region.absolutePos(tablePos);
player.openMenu(level.getBlockState(pos).getMenuProvider(level, pos));
});
context.waitForScreen(CraftingScreen.class);

var recipeBook = context.computeOnClient(_ ->
((RecipeBookScreenProcessor.RecipeBookScreenAccessor) MinecraftUtil.getScreen())
.controlify$getRecipeBookComponent());
var component = (RecipeBookComponentAccessor) recipeBook;
var page = context.computeOnClient(_ ->
(RecipeBookPageAccessor) component.controlify$getRecipeBookPage());

// Require real navigation targets so empty recipes or a closed book
// cannot produce a misleading navigation failure.
context.waitFor(_ -> recipeBook.isVisible() && page.controlify_test$getTotalPages() > 1);
var tabs = context.computeOnClient(_ -> component.controlify$getTabButtons().stream()
.filter(tab -> tab.visible).toList());
context.runOnClient(_ -> {
if (tabs.size() < 2 || component.controlify$getSelectedTab() != tabs.getFirst()
|| page.controlify_test$getCurrentPage() != 0) {
throw new AssertionError("Expected the first recipe category and page, with multiple visible categories");
}
if (!Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled()) {
throw new AssertionError("Crafting screen must have virtual mouse enabled for recipe navigation");
}
});

pressAndWait(context,
() -> controller.holdButton(SdlGamepad.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER),
() -> controller.releaseButton(SdlGamepad.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER),
() -> page.controlify_test$getCurrentPage() == 1,
"Right shoulder should advance the recipe page from 0 to 1");
pressAndWait(context,
() -> controller.holdButton(SdlGamepad.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER),
() -> controller.releaseButton(SdlGamepad.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER),
() -> page.controlify_test$getCurrentPage() == 0,
"Left shoulder should return the recipe page from 1 to 0");
pressAndWait(context,
() -> controller.holdAxis(SdlGamepad.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1f),
() -> controller.releaseAxis(SdlGamepad.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER),
() -> component.controlify$getSelectedTab() == tabs.get(1),
"Right trigger should select the next recipe category");
pressAndWait(context,
() -> controller.holdAxis(SdlGamepad.SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1f),
() -> controller.releaseAxis(SdlGamepad.SDL_GAMEPAD_AXIS_LEFT_TRIGGER),
() -> component.controlify$getSelectedTab() == tabs.getFirst(),
"Left trigger should select the previous recipe category");
} finally {
controlify.resetSettings();
}
}

private static void pressAndWait(ClientGameTestContext context, Runnable press, Runnable release,
BooleanSupplier condition, String message) {
press.run();
try {
context.waitFor(_ -> condition.getAsBoolean(), 20);
} catch (AssertionError failure) {
throw new AssertionError(message, failure);
} finally {
release.run();
context.waitTick();
}
}
}
1 change: 1 addition & 0 deletions src/gametest/resources/controlify_test.mixins.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"SDLControllerManagerMixin"
],
"client": [
"RecipeBookPageAccessor",
"SDLEventHandlerAccessor",
"SystemToastMixin",
"ToastInstanceAccessor",
Expand Down
1 change: 1 addition & 0 deletions src/gametest/resources/fabric.mod.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dev.isxander.controlify.gametest.tests.ManyHotplugTests",
"dev.isxander.controlify.gametest.tests.ResetControlifySettingsTests",
"dev.isxander.controlify.gametest.tests.VirtualControllerInputTests",
"dev.isxander.controlify.gametest.tests.RecipeBookNavigationTests",
"dev.isxander.controlify.gametest.tests.KeyboardLayoutTests",
"dev.isxander.controlify.gametest.tests.RumbleTests",
"dev.isxander.controlify.gametest.tests.AdaptiveTriggerTests",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
package dev.isxander.controlify.screenop.compat.vanilla;

import com.mojang.blaze3d.platform.InputConstants;
import dev.isxander.controlify.bindings.ControlifyBindings;
import dev.isxander.controlify.controller.ControllerEntity;
import dev.isxander.controlify.mixins.feature.virtualmouse.snapping.RecipeBookComponentAccessor;
Expand Down Expand Up @@ -76,7 +77,7 @@ protected void handleScreenVMouse(ControllerEntity controller, VirtualMouseHandl
if (button != null) {
recipeBookComponent.mouseClicked(new MouseButtonEvent(
button.getX(), button.getY(),
new MouseButtonInfo(0, 0)), false);
new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT, 0)), false);
}
}

Expand Down
Loading