Add tests to check dynamic recipes are well-formed

- Check whether impostor recipes (and other custom shaped/shapeless
   recipes) match the "real" recipe.
 - Check whether our upgrade recipe generator generates valid recipes.
   Reasoning about left/right upgrades (and top/back ones on 26.2) is a
   bit confusing, so this should prevent any errors.
This commit is contained in:
Jonathan Coates
2026-07-02 15:03:00 +01:00
parent 0e8600b10c
commit b4805fd765
14 changed files with 291 additions and 37 deletions
@@ -17,6 +17,7 @@ import dan200.computercraft.shared.platform.PlatformHelper;
import dan200.computercraft.shared.platform.RecipeIngredients;
import dan200.computercraft.shared.platform.RegistryWrappers;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.turtle.core.TurtleBrain;
import dan200.computercraft.shared.turtle.items.TurtleItem;
import dan200.computercraft.shared.util.ColourUtils;
import net.minecraft.advancements.critereon.InventoryChangeTrigger;
@@ -182,6 +183,7 @@ class RecipeProvider extends net.minecraft.data.recipes.RecipeProvider {
for (var turtleItem : turtleItems()) {
var base = turtleItem.create(-1, null, -1, null, null, 0, null);
var name = RegistryWrappers.ITEMS.getKey(turtleItem);
var overlayName = new ResourceLocation(ComputerCraftAPI.MOD_ID, "block/" + overlay).toString();
var builder = ShapelessRecipeBuilder.shapeless(RecipeCategory.REDSTONE, base.getItem())
.group(name.withSuffix("_overlay").toString())
@@ -192,7 +194,8 @@ class RecipeProvider extends net.minecraft.data.recipes.RecipeProvider {
.save(
RecipeWrapper
.wrap(ModRegistry.RecipeSerializers.TURTLE_OVERLAY.get(), add)
.withExtraData(x -> x.addProperty("overlay", new ResourceLocation(ComputerCraftAPI.MOD_ID, "block/" + overlay).toString())),
.withExtraData(x -> x.addProperty("overlay", overlayName))
.withResultTag(x -> x.putString(TurtleBrain.NBT_OVERLAY, overlayName)),
name.withSuffix("_overlays/" + overlay)
);
}
@@ -28,7 +28,6 @@ import dan200.computercraft.shared.util.IDAssigner;
import dan200.computercraft.shared.util.InventoryUtil;
import dan200.computercraft.shared.util.NBTUtil;
import net.minecraft.ChatFormatting;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
@@ -378,11 +377,11 @@ public class PocketComputerItem extends Item implements IComputerItem, IColoured
compound.remove(NBT_UPGRADE_INFO);
} else {
compound.putString(NBT_UPGRADE, upgrade.upgrade().getUpgradeID().toString());
compound.put(NBT_UPGRADE_INFO, upgrade.data().copy());
if (upgrade.data().isEmpty()) {
compound.remove(NBT_UPGRADE_INFO);
} else {
compound.put(NBT_UPGRADE_INFO, upgrade.data().copy());
}
}
}
public static CompoundTag getUpgradeInfo(ItemStack stack) {
return stack.getOrCreateTagElement(NBT_UPGRADE_INFO);
}
}
@@ -5,14 +5,22 @@
package dan200.computercraft.gametest
import com.mojang.authlib.GameProfile
import dan200.computercraft.api.ComputerCraftAPI
import dan200.computercraft.gametest.api.Structures
import dan200.computercraft.gametest.api.assertCraftable
import dan200.computercraft.gametest.api.craftItem
import dan200.computercraft.gametest.api.sequence
import dan200.computercraft.gametest.api.immediate
import dan200.computercraft.impl.PocketUpgrades
import dan200.computercraft.impl.TurtleUpgrades
import dan200.computercraft.shared.ModRegistry
import dan200.computercraft.shared.integration.UpgradeRecipeGenerator
import dan200.computercraft.shared.recipe.CustomShapedRecipe
import dan200.computercraft.shared.recipe.CustomShapelessRecipe
import net.minecraft.gametest.framework.GameTest
import net.minecraft.gametest.framework.GameTestHelper
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.NbtUtils
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.entity.player.Player
import net.minecraft.world.inventory.AbstractContainerMenu
import net.minecraft.world.inventory.MenuType
@@ -28,22 +36,147 @@ class Recipe_Test {
* Mostly useful for Fabric, where we need a mixin for this.
*/
@GameTest(template = Structures.DEFAULT)
fun Craft_result_has_nbt(context: GameTestHelper) = context.sequence {
thenExecute {
val result = context.craftItem(
ItemStack(Items.SKELETON_SKULL),
ItemStack(ModRegistry.Items.COMPUTER_ADVANCED.get()),
)
fun Craft_result_has_nbt(context: GameTestHelper) = context.immediate {
val result = context.craftItem(
ItemStack(Items.SKELETON_SKULL),
ItemStack(ModRegistry.Items.COMPUTER_ADVANCED.get()),
)
val profile = GameProfile(UUID.fromString("f3c8d69b-0776-4512-8434-d1b2165909eb"), "dan200")
val profile = GameProfile(UUID.fromString("f3c8d69b-0776-4512-8434-d1b2165909eb"), "dan200")
val tag = CompoundTag()
tag.put("SkullOwner", NbtUtils.writeGameProfile(CompoundTag(), profile))
val tag = CompoundTag()
tag.put("SkullOwner", NbtUtils.writeGameProfile(CompoundTag(), profile))
assertEquals(tag, result.tag, "Expected NBT tags to be the same")
assertEquals(tag, result.tag, "Expected NBT tags to be the same")
}
/**
* Test that all impostor recipes are craftable.
*/
@GameTest(template = Structures.DEFAULT)
fun Imposter_recipes_craftable(context: GameTestHelper) = context.immediate {
// FIXME: Skip these recipes, as the NBT does not match.
val skipRecipes = setOf(
ResourceLocation(ComputerCraftAPI.MOD_ID, "printed_pages"),
ResourceLocation(ComputerCraftAPI.MOD_ID, "printed_book"),
)
for (recipe in context.level.server.recipeManager.recipes) {
if (recipe is CustomShapedRecipe || recipe is CustomShapelessRecipe) {
context.assertCraftable(recipe, matchNbt = !skipRecipes.contains(recipe.id))
}
}
}
/**
* Test that recipes generated by [UpgradeRecipeGenerator] are craftable.
*/
@GameTest(template = Structures.DEFAULT)
fun Upgrade_recipes_craftable(context: GameTestHelper) = context.immediate {
val generator = UpgradeRecipeGenerator { it }
fun checkUsage(message: String, size: Int, item: ItemStack) {
val recipes = generator.findRecipesWithInput(item)
assertEquals(size, recipes.size, message)
for (recipe in recipes) {
// Ensure the ingredient appears in the recipe.
if (!recipe.ingredients.any { ItemStack.isSameItemSameTags(it.items[0], item) }) {
context.fail("Cannot find item ${item}${item.tag} in ${recipe.ingredients}")
}
// Ensure the recipe can actually be crafted using the main recipe manager.
context.assertCraftable(recipe)
}
}
// Speakers should be an input to 4 recipes (2xPocket, 2xTurtle).
checkUsage("Usage: Speaker", 4, ItemStack(ModRegistry.Items.SPEAKER.get()))
// Check recipes which can be crafted with a turtle
checkUsage(
"Usage: Turtle without upgrades",
TurtleUpgrades.instance().upgrades.size * 2,
ItemStack(ModRegistry.Items.TURTLE_NORMAL.get()),
)
checkUsage(
"Usage: Turtle with one upgrade",
TurtleUpgrades.instance().upgrades.size,
ModRegistry.Items.TURTLE_NORMAL.get().create(
-1,
"",
-1,
TurtleUpgrades.instance().get(ItemStack(ModRegistry.Items.SPEAKER.get()))!!,
null,
0,
null,
),
)
// Check recipes which can be crafted with a pocket computer
checkUsage(
"Usage: Pocket computer without upgrades",
PocketUpgrades.instance().upgrades.size,
ItemStack(ModRegistry.Items.POCKET_COMPUTER_NORMAL.get()),
)
fun checkRecipe(message: String, size: Int, item: ItemStack) {
val recipes = generator.findRecipesWithOutput(item)
assertEquals(size, recipes.size, message)
for (recipe in recipes) {
// Ensure the result matches the input.
val result = recipe.getResultItem(context.level.registryAccess())
if (!ItemStack.isSameItemSameTags(result, item) || result.count != item.count) {
context.fail("Expected to craft ${item}${item.tag} but crafted ${result}${result.tag}")
}
// Ensure the recipe can actually be crafted using the main recipe manager.
context.assertCraftable(recipe)
}
}
// Check recipes which craft a turtle
checkRecipe(
"Recipe: Turtle with one upgrade",
1,
ModRegistry.Items.TURTLE_NORMAL.get().create(
-1,
"",
-1,
TurtleUpgrades.instance().get(ItemStack(ModRegistry.Items.SPEAKER.get()))!!,
null,
0,
null,
),
)
checkRecipe(
"Recipe: Turtle with two upgrades",
2,
ModRegistry.Items.TURTLE_NORMAL.get().create(
-1,
"",
-1,
TurtleUpgrades.instance().get(ItemStack(ModRegistry.Items.SPEAKER.get()))!!,
TurtleUpgrades.instance().get(ItemStack(ModRegistry.Items.WIRELESS_MODEM_NORMAL.get()))!!,
0,
null,
),
)
// Check recipes which craft a pocket computer
checkRecipe(
"Recipe: Pocket computer with one upgrade",
1,
ModRegistry.Items.POCKET_COMPUTER_NORMAL.get().create(
-1,
"",
-1,
PocketUpgrades.instance().get(ItemStack(ModRegistry.Items.SPEAKER.get()))!!,
),
)
}
object DummyMenu : AbstractContainerMenu(MenuType.GENERIC_9x1, 0) {
override fun quickMoveStack(player: Player, slot: Int): ItemStack = ItemStack.EMPTY
override fun stillValid(p0: Player): Boolean = true
@@ -27,7 +27,9 @@ import net.minecraft.world.inventory.TransientCraftingContainer
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.context.UseOnContext
import net.minecraft.world.item.crafting.CraftingRecipe
import net.minecraft.world.item.crafting.RecipeType
import net.minecraft.world.item.crafting.ShapedRecipe
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.entity.BarrelBlockEntity
import net.minecraft.world.level.block.entity.BlockEntity
@@ -346,6 +348,50 @@ fun GameTestHelper.placeItemAt(stack: ItemStack, pos: BlockPos, direction: Direc
stack.useOn(UseOnContext(player, InteractionHand.MAIN_HAND, hit))
}
/**
* Assert a recipe is craftable.
*/
fun GameTestHelper.assertCraftable(recipe: CraftingRecipe, matchNbt: Boolean = true) {
val (width, height) = if (recipe is ShapedRecipe) {
(recipe.width to recipe.height)
} else {
(3 to 3)
}
assertCraftable(
recipe.ingredients.map {
if (it.isEmpty) ItemStack.EMPTY else it.items[0]
},
recipe.getResultItem(level.registryAccess()),
width = width,
height = height,
matchNbt = matchNbt,
)
}
/**
* Assert a series of items craft the given recipe.
*/
fun GameTestHelper.assertCraftable(items: List<ItemStack>, result: ItemStack, width: Int = 3, height: Int = 3, matchNbt: Boolean = true) {
val container = TransientCraftingContainer(DummyMenu, width, height)
for ((i, item) in items.withIndex()) container.setItem(i, item)
val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, container, level)
if (recipe.isEmpty) fail("Expected recipe to match $items")
val actualResult = recipe.get().assemble(container, level.registryAccess())
if (matchNbt) {
if (!ItemStack.isSameItemSameTags(actualResult, result) || actualResult.count != result.count) {
fail("Expected $items to craft ${result}${result.tag}, got ${actualResult}${actualResult.tag}")
}
} else {
if (!ItemStack.isSameItem(actualResult, result) || actualResult.count != result.count) {
fail("Expected $items to craft $result, got $actualResult")
}
}
}
/**
* Assert a recipe is not craftable.
*/
@@ -4,13 +4,22 @@
package dan200.computercraft.gametest.core
import com.google.common.base.Stopwatch
import net.minecraft.gametest.framework.GameTestInfo
import net.minecraft.gametest.framework.JUnitLikeTestReporter
import net.minecraft.gametest.framework.TestReporter
import java.io.File
import java.io.IOException
import java.nio.file.Files
import org.w3c.dom.Document
import org.w3c.dom.Element
import java.io.*
import java.nio.file.Files.createDirectories
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.TransformerException
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
/**
* A test reporter which delegates to a list of other reporters.
@@ -33,15 +42,73 @@ class MultiTestReporter(private val reporters: List<TestReporter>) : TestReporte
/**
* Reports tests to a JUnit XML file. This is equivalent to [JUnitLikeTestReporter], except it ensures the destination
* directory exists.
* directory exists and includes the stack trace in the error.
*/
class JunitTestReporter(destination: File) : JUnitLikeTestReporter(destination) {
override fun save(file: File) {
open class JunitTestReporter(private val destination: File) : TestReporter {
private val document: Document
private val testSuite: Element
private val stopwatch: Stopwatch = Stopwatch.createStarted()
init {
document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
testSuite = document.createElement("testsuite")
testSuite.setAttribute("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now()))
val container = document.createElement("testsuite")
container.appendChild(testSuite)
document.appendChild(container)
}
private fun createTestCase(testInfo: GameTestInfo, name: String?): Element {
val testCase = document.createElement("testcase")
testCase.setAttribute("name", name)
testCase.setAttribute("classname", testInfo.structureName)
testCase.setAttribute("time", (testInfo.runTime.toDouble() / 1000.0).toString())
testSuite.appendChild(testCase)
return testCase
}
override fun onTestFailed(testInfo: GameTestInfo) {
val error = testInfo.error!!
val result: Element
if (testInfo.isRequired) {
result = document.createElement("failure")
result.setAttribute("message", error.message)
result.setAttribute("type", error.javaClass.name)
val writer = StringWriter()
error.printStackTrace(PrintWriter(writer))
result.textContent = writer.toString()
} else {
result = document.createElement("skipped")
result.setAttribute("message", error.message)
}
createTestCase(testInfo, testInfo.testName).appendChild(result)
}
override fun onTestSuccess(testInfo: GameTestInfo) {
createTestCase(testInfo, testInfo.testName)
}
override fun finish() {
stopwatch.stop()
testSuite.setAttribute(
"time",
(stopwatch.elapsed(TimeUnit.MILLISECONDS).toDouble() / 1000.0).toString(),
)
try {
Files.createDirectories(file.toPath().parent)
} catch (e: IOException) {
throw TransformerException("Failed to create parent directory", e)
try {
createDirectories(destination.toPath().parent)
} catch (e: IOException) {
throw UncheckedIOException("Failed to create parent directory", e)
}
TransformerFactory.newInstance().newTransformer().transform(DOMSource(document), StreamResult(destination))
} catch (transformerException: TransformerException) {
throw Error("Couldn't save test report", transformerException)
}
super.save(file)
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ loom {
systemProperties.put("fabric-api.gametest", "true")
systemProperties.put(
"fabric-api.gametest.report-file",
"cctest.gametest-report",
layout.buildDirectory.dir("test-results/runGametest.xml").getAbsolutePath(),
)
runDirectory = layout.projectDirectory.dir("run/gametest")
@@ -13,5 +13,8 @@
{"item": "computercraft:turtle_advanced"}
],
"overlay": "computercraft:block/turtle_rainbow_overlay",
"result": {"item": "computercraft:turtle_advanced"}
"result": {
"item": "computercraft:turtle_advanced",
"nbt": "{Overlay:\"computercraft:block/turtle_rainbow_overlay\"}"
}
}
@@ -10,5 +10,5 @@
{"item": "computercraft:turtle_advanced"}
],
"overlay": "computercraft:block/turtle_trans_overlay",
"result": {"item": "computercraft:turtle_advanced"}
"result": {"item": "computercraft:turtle_advanced", "nbt": "{Overlay:\"computercraft:block/turtle_trans_overlay\"}"}
}
@@ -13,5 +13,5 @@
{"item": "computercraft:turtle_normal"}
],
"overlay": "computercraft:block/turtle_rainbow_overlay",
"result": {"item": "computercraft:turtle_normal"}
"result": {"item": "computercraft:turtle_normal", "nbt": "{Overlay:\"computercraft:block/turtle_rainbow_overlay\"}"}
}
@@ -10,5 +10,5 @@
{"item": "computercraft:turtle_normal"}
],
"overlay": "computercraft:block/turtle_trans_overlay",
"result": {"item": "computercraft:turtle_normal"}
"result": {"item": "computercraft:turtle_normal", "nbt": "{Overlay:\"computercraft:block/turtle_trans_overlay\"}"}
}
@@ -13,5 +13,8 @@
{"item": "computercraft:turtle_advanced"}
],
"overlay": "computercraft:block/turtle_rainbow_overlay",
"result": {"item": "computercraft:turtle_advanced"}
"result": {
"item": "computercraft:turtle_advanced",
"nbt": "{Overlay:\"computercraft:block/turtle_rainbow_overlay\"}"
}
}
@@ -10,5 +10,5 @@
{"item": "computercraft:turtle_advanced"}
],
"overlay": "computercraft:block/turtle_trans_overlay",
"result": {"item": "computercraft:turtle_advanced"}
"result": {"item": "computercraft:turtle_advanced", "nbt": "{Overlay:\"computercraft:block/turtle_trans_overlay\"}"}
}
@@ -13,5 +13,5 @@
{"item": "computercraft:turtle_normal"}
],
"overlay": "computercraft:block/turtle_rainbow_overlay",
"result": {"item": "computercraft:turtle_normal"}
"result": {"item": "computercraft:turtle_normal", "nbt": "{Overlay:\"computercraft:block/turtle_rainbow_overlay\"}"}
}
@@ -10,5 +10,5 @@
{"item": "computercraft:turtle_normal"}
],
"overlay": "computercraft:block/turtle_trans_overlay",
"result": {"item": "computercraft:turtle_normal"}
"result": {"item": "computercraft:turtle_normal", "nbt": "{Overlay:\"computercraft:block/turtle_trans_overlay\"}"}
}