diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 331f3177b..3d06c6b96 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -12,7 +12,7 @@ body: options: - "1.20.1" - "1.21.1" - - "26.1" + - "26.2" validations: required: true - type: input diff --git a/doc/reference/breaking_changes.md b/doc/reference/breaking_changes.md index df7fe0afc..b7327bb57 100644 --- a/doc/reference/breaking_changes.md +++ b/doc/reference/breaking_changes.md @@ -34,6 +34,7 @@ as documentation for breaking changes and "gotchas" one should look out for betw - [`math.random`] now uses Lua 5.4's random number generator. - File handles, HTTP requests and websockets now always use the original bytes rather than encoding/decoding to UTF-8. + Files containing non-ASCII characters will be read back differently if using text mode. ## Minecraft 1.13 {#mc-1.13} - The "key code" for [`key`] and [`key_up`] events has changed, due to Minecraft updating to LWJGL 3. Make sure you're diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt index b065c1afb..c6a08c693 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt @@ -5,17 +5,24 @@ package dan200.computercraft.gametest import com.mojang.authlib.GameProfile -import dan200.computercraft.gametest.api.GameTest -import dan200.computercraft.gametest.api.Structures -import dan200.computercraft.gametest.api.craftItem -import dan200.computercraft.gametest.api.sequence +import dan200.computercraft.api.pocket.IPocketUpgrade +import dan200.computercraft.api.turtle.ITurtleUpgrade +import dan200.computercraft.gametest.api.* +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.core.component.DataComponentPatch import net.minecraft.core.component.DataComponents import net.minecraft.gametest.framework.GameTestHelper import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.ItemStackTemplate import net.minecraft.world.item.Items import net.minecraft.world.item.component.ResolvableProfile +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers import org.junit.jupiter.api.Assertions.assertEquals import java.util.* @@ -26,19 +33,155 @@ 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 = - DataComponentPatch.builder().set(DataComponents.PROFILE, ResolvableProfile.createResolved(profile)) - .build() - assertEquals(tag, result.componentsPatch, "Expected NBT tags to be the same") + val tag = + DataComponentPatch.builder().set(DataComponents.PROFILE, ResolvableProfile.createResolved(profile)) + .build() + assertEquals(tag, result.componentsPatch, "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 { + for (holder in context.level.server.recipeManager.recipes) { + val recipe = holder.value + if (recipe is CustomShapedRecipe || recipe is CustomShapelessRecipe) { + context.assertCraftable(recipe) + } } } + + /** + * Test that recipes generated by [UpgradeRecipeGenerator] are craftable. + */ + @GameTest(template = Structures.DEFAULT) + fun Upgrade_recipes_craftable(context: GameTestHelper) = context.immediate { + val registries = context.level.registryAccess() + val turtleUpgrades = registries.lookupOrThrow(ITurtleUpgrade.REGISTRY) + val pocketUpgrades = registries.lookupOrThrow(IPocketUpgrade.REGISTRY) + + val generator = UpgradeRecipeGenerator(::BasicRecipe, registries) + + fun checkUsage(message: String, size: Int, item: ItemStackTemplate) { + val recipes = generator.findRecipesWithInput(item.create()) + assertEquals(size, recipes.size, message) + + for (recipe in recipes) { + // Ensure the ingredient appears in the recipe. + assertThat("Input is in ingredients", recipe.ingredients, Matchers.hasItem(item)) + + // 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, ItemStackTemplate(ModRegistry.Items.SPEAKER.get())) + + // Check recipes which can be crafted with a turtle + checkUsage( + "Usage: Turtle without upgrades", + (turtleUpgrades.listElements().count() * 2).toInt(), + ItemStackTemplate(ModRegistry.Items.TURTLE_NORMAL.get()), + ) + checkUsage( + "Usage: Turtle with one upgrade", + turtleUpgrades.listElements().count().toInt(), + ItemStackTemplate( + ModRegistry.Items.TURTLE_NORMAL.get(), + DataComponentPatch.builder() + .set( + ModRegistry.DataComponents.LEFT_TURTLE_UPGRADE.get(), + TurtleUpgrades.instance().get(registries, ItemStack(ModRegistry.Items.SPEAKER.get()))!!, + ) + .build(), + ), + ) + + // Check recipes which can be crafted with a pocket computer + checkUsage( + "Usage: Pocket computer without upgrades", + (pocketUpgrades.listElements().count() * 2).toInt(), + ItemStackTemplate(ModRegistry.Items.POCKET_COMPUTER_NORMAL.get()), + ) + + fun checkRecipe(message: String, size: Int, item: ItemStackTemplate) { + val recipes = generator.findRecipesWithOutput(item.create()) + assertEquals(size, recipes.size, message) + + for (recipe in recipes) { + // Ensure the result matches the input. + assertEquals(item, recipe.result, "Recipe has output") + + // 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, + ItemStackTemplate( + ModRegistry.Items.TURTLE_NORMAL.get(), + DataComponentPatch.builder().set( + ModRegistry.DataComponents.LEFT_TURTLE_UPGRADE.get(), + TurtleUpgrades.instance().get(registries, ItemStack(ModRegistry.Items.SPEAKER.get()))!!, + ).build(), + ), + ) + checkRecipe( + "Recipe: Turtle with two upgrades", + 2, + ItemStackTemplate( + ModRegistry.Items.TURTLE_NORMAL.get(), + DataComponentPatch.builder() + .set( + ModRegistry.DataComponents.LEFT_TURTLE_UPGRADE.get(), + TurtleUpgrades.instance() + .get(registries, ItemStack(ModRegistry.Items.SPEAKER.get()))!!, + ) + .set( + ModRegistry.DataComponents.RIGHT_TURTLE_UPGRADE.get(), + TurtleUpgrades.instance() + .get(registries, ItemStack(ModRegistry.Items.WIRELESS_MODEM_NORMAL.get()))!!, + ) + .build(), + ), + ) + + // Check recipes which craft a pocket computer + checkRecipe( + "Recipe: Pocket computer with one upgrade", + 1, + ItemStackTemplate( + ModRegistry.Items.POCKET_COMPUTER_NORMAL.get(), + DataComponentPatch.builder() + .set( + ModRegistry.DataComponents.BACK_POCKET_UPGRADE.get(), + PocketUpgrades.instance().get(registries, ItemStack(ModRegistry.Items.SPEAKER.get()))!!, + ).build(), + ), + ) + } +} + +private class BasicRecipe(val width: Int, val height: Int, val ingredients: List, val result: ItemStackTemplate) + +private fun GameTestHelper.assertCraftable(recipe: BasicRecipe) { + assertCraftable( + recipe.ingredients.map(ItemStackTemplate::create), + recipe.result.create(), + width = recipe.width, + height = recipe.height, + ) } diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt index f1383c81d..209336d5c 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt @@ -36,7 +36,12 @@ import net.minecraft.world.item.ItemStack import net.minecraft.world.item.ItemStackTemplate import net.minecraft.world.item.context.UseOnContext import net.minecraft.world.item.crafting.CraftingInput +import net.minecraft.world.item.crafting.CraftingRecipe import net.minecraft.world.item.crafting.RecipeType +import net.minecraft.world.item.crafting.display.RecipeDisplay +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay +import net.minecraft.world.item.crafting.display.SlotDisplayContext import net.minecraft.world.level.GameType import net.minecraft.world.level.block.Blocks import net.minecraft.world.level.block.entity.BarrelBlockEntity @@ -238,7 +243,7 @@ fun GameTestHelper.assertContainerExactly(pos: BlockPos, items: List) assertContainerExactlyImpl(pos, getContainerAt(pos), items) /** - * Assert an container contains exactly these items and no more. + * Assert a container contains exactly these items and no more. * * @param entity The entity containing these items. * @param items The list of items this container must contain. This should be equal to the expected contents of the @@ -247,7 +252,7 @@ fun GameTestHelper.assertContainerExactly(pos: BlockPos, items: List) fun GameTestHelper.assertContainerExactly(entity: T, items: List) where T : Entity, T : Container = assertContainerExactlyImpl(entity.blockPosition(), entity, items) -private fun ItemStack.toStringFull(): String = if (isEmpty) "" else "$count x $item$componentsPatch" +fun ItemStack.toStringFull(): String = if (isEmpty) "" else "$count x $item$componentsPatch" private fun formatItems(items: List) = items.joinToString(", ") { it.toStringFull() } @@ -364,6 +369,62 @@ fun GameTestHelper.placeItemAt(stack: ItemStack, pos: BlockPos, direction: Direc stack.useOn(UseOnContext(player, InteractionHand.MAIN_HAND, hit)) } +/** + * Assert a [CraftingRecipe] is craftable. + */ +fun GameTestHelper.assertCraftable(recipe: CraftingRecipe) { + val displays = recipe.display() + if (displays.isEmpty()) { + fail("$recipe has no display forms") + } + + for (display in displays) assertCraftable(display) +} + +/** + * Assert a [RecipeDisplay] is craftable. + */ +fun GameTestHelper.assertCraftable(recipe: RecipeDisplay) { + val context = SlotDisplayContext.fromLevel(level) + when (recipe) { + is ShapedCraftingRecipeDisplay -> { + assertCraftable( + recipe.ingredients.map { it.resolveForFirstStack(context) }, + recipe.result().resolveForFirstStack(context), + width = recipe.width, + height = recipe.height, + ) + } + + is ShapelessCraftingRecipeDisplay -> { + assertCraftable( + recipe.ingredients.map { it.resolveForFirstStack(context) }, + recipe.result().resolveForFirstStack(context), + ) + } + + else -> fail("Unsupported recipe $recipe") + } +} + +/** + * Assert a series of items craft the given recipe. + */ +fun GameTestHelper.assertCraftable(items: List, result: ItemStack, width: Int = 3, height: Int = 3) { + val container = NonNullList.withSize(width * height, ItemStack.EMPTY) + for ((i, item) in items.withIndex()) container[i] = item + val input = CraftingInput.of(width, height, container) + + val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level) + if (recipe.isEmpty) fail("Expected recipe to match ${formatItems(items)}") + + val actualResult = recipe.get().value.assemble(input) + + if (!ItemStack.isSameItemSameComponents(actualResult, result) || actualResult.count != result.count) { + fail("Expected $items to craft ${result.toStringFull()}, got ${actualResult.toStringFull()}") + } +} + /** * Assert a recipe is not craftable. */ @@ -374,7 +435,7 @@ fun GameTestHelper.assertNotCraftable(vararg items: ItemStack) { val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level) - if (recipe.isPresent) abort("Expected no recipe to match $items") + if (recipe.isPresent) abort("Expected no recipe to match ${items.contentToString()}") } /** @@ -386,7 +447,7 @@ fun GameTestHelper.craftItem(vararg items: ItemStack): ItemStack { val input = CraftingInput.of(3, 3, container) val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level).getOrNull() - ?: throw assertionException("No recipe matches $items") + ?: throw assertionException("No recipe matches ${items.contentToString()}") return recipe.value.assemble(input) } diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt index 483dbb0f0..7bd8f5ba0 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt @@ -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) : 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): Element { + val testCase = document.createElement("testcase") + testCase.setAttribute("name", testInfo.id().toString()) + testCase.setAttribute("classname", testInfo.structure.toString()) + 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).appendChild(result) + } + + override fun onTestSuccess(testInfo: GameTestInfo) { + createTestCase(testInfo) + } + + 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) } } diff --git a/projects/core/src/main/resources/data/computercraft/lua/rom/help/changelog.md b/projects/core/src/main/resources/data/computercraft/lua/rom/help/changelog.md index 697fbe5f1..ab5a1ad71 100644 --- a/projects/core/src/main/resources/data/computercraft/lua/rom/help/changelog.md +++ b/projects/core/src/main/resources/data/computercraft/lua/rom/help/changelog.md @@ -1,6 +1,6 @@ # New features in CC: Tweaked 1.120.0 -* Support spawning new parallel functions in `parallel.watiForAll`. +* Support spawning new parallel functions in `parallel.waitForAll`. One bug fix: * Make HTTP IP filtering stricter. @@ -331,12 +331,14 @@ Several bug fixes: * Allow placing seeds into composter barrels with `turtle.place()`. # New features in CC: Tweaked 1.109.0 - +Breaking changes: * Update to Lua 5.2 * `getfenv`/`setfenv` now only work on Lua functions. * Add support for `goto`. * Remove support for dumping and loading binary chunks. * File handles, HTTP requests and websocket messages now use raw bytes rather than converting to UTF-8. + +New features: * Add `allow_repetitions` option to `textutils.serialiseJSON`. * Track memory allocated by computers. * Update the version returned by `os.version()` to `CraftOS 1.9`. diff --git a/projects/core/src/main/resources/data/computercraft/lua/rom/help/whatsnew.md b/projects/core/src/main/resources/data/computercraft/lua/rom/help/whatsnew.md index dcb4e7baf..9c201a9f2 100644 --- a/projects/core/src/main/resources/data/computercraft/lua/rom/help/whatsnew.md +++ b/projects/core/src/main/resources/data/computercraft/lua/rom/help/whatsnew.md @@ -1,6 +1,6 @@ New features in CC: Tweaked 1.120.0 -* Support spawning new parallel functions in `parallel.watiForAll`. +* Support spawning new parallel functions in `parallel.waitForAll`. One bug fix: * Make HTTP IP filtering stricter. diff --git a/projects/fabric/build.gradle.kts b/projects/fabric/build.gradle.kts index e65c91e36..35b06d7b8 100644 --- a/projects/fabric/build.gradle.kts +++ b/projects/fabric/build.gradle.kts @@ -192,7 +192,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")