From 27525cc031c23f56bdbc17a765b0495d07bce385 Mon Sep 17 00:00:00 2001 From: hspragg Date: Tue, 16 Jun 2026 13:11:52 -0700 Subject: [PATCH 01/18] Add headless game test harness with a BlockEvents.broken test Enable AlmostGradle's game tests and run them headlessly via NeoForge's gameTestServer (./gradlew runGametest), scoped to the testmod namespace. Adds a testmod game test where a fake player breaks dirt with an empty hand; a KubeJS server script reacting to BlockEvents.broken reports the result through a TestRuntime script binding, which the test asserts. --- build.gradle.kts | 29 +++++++++++- .../kubejs/testmod/BlockBrokenGameTest.java | 47 +++++++++++++++++++ .../mods/kubejs/testmod/KubeJSGameTests.java | 38 +++++++++++++++ .../mods/kubejs/testmod/TestRuntime.java | 22 +++++++++ .../kubejs/testmod/TestRuntimePlugin.java | 11 +++++ .../kubejs/server_scripts/block_broken.js | 6 +++ .../resources/META-INF/neoforge.mods.toml | 25 ++++++++++ src/test/resources/kubejs.plugins.txt | 1 + 8 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntimePlugin.java create mode 100644 src/test/kubejs/server_scripts/block_broken.js create mode 100644 src/test/resources/META-INF/neoforge.mods.toml create mode 100644 src/test/resources/kubejs.plugins.txt diff --git a/build.gradle.kts b/build.gradle.kts index d9c83e04c..3696e41dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,7 +23,9 @@ almostgradle.setup { withAccessTransformerValidation = !runningInCI tests { - + enabled = true + testMod = true + gameTests = true } recipeViewers { @@ -61,6 +63,15 @@ neoForge { from(file("interfaces.json")) publish(file("interfaces.json")) } + + runs { + // AlmostGradle's gametest run launches as a plain server here; use Neo's headless type and run only our tests. + matching { it.name == "gametest" }.configureEach { + type.set("gameTestServer") + programArgument("--tests") + programArgument("testmod:*") + } + } } repositories { @@ -121,6 +132,22 @@ dependencies { }) } +// Make the game tests' KubeJS scripts available in the gametest run's game directory. +val copyGameTestScripts by tasks.registering(Copy::class) { + mustRunAfter("prepareGametestRun") + from(layout.projectDirectory.dir("src/test/kubejs")) + into(layout.buildDirectory.dir("tmp/gametestRuns/gametest/kubejs")) +} + +tasks.matching { it.name == "runGametest" }.configureEach { + dependsOn(copyGameTestScripts) +} + +// The test source set holds game tests (run via runGametest), not JUnit tests. +tasks.named("test") { + failOnNoDiscoveredTests = false +} + publishing { repositories { val mavenUrl = System.getenv("MAVEN_URL") ?: return@repositories diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java b/src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java new file mode 100644 index 000000000..7336ed0b7 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java @@ -0,0 +1,47 @@ +package dev.latvian.mods.kubejs.testmod; + +import com.mojang.serialization.MapCodec; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.GameTestInstance; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.neoforge.common.util.FakePlayer; +import net.neoforged.neoforge.common.util.FakePlayerFactory; + +/// Has an empty-handed player break a dirt block and asserts the {@code block_broken.js} server +/// script reacted to {@code BlockEvents.broken} by calling {@code TestRuntime.pass}. +public class BlockBrokenGameTest extends GameTestInstance { + public BlockBrokenGameTest(TestData>> info) { + super(info); + } + + @Override + public void run(GameTestHelper helper) { + TestRuntime.reset(); + + var dirtPos = new BlockPos(1, 2, 1); + helper.setBlock(dirtPos, Blocks.DIRT); + + // A fake player has no real connection, so its empty-handed break runs the normal + // player break path without KubeJS' join-time sync (which fails over a test connection). + FakePlayer player = FakePlayerFactory.getMinecraft(helper.getLevel()); + player.gameMode.destroyBlock(helper.absolutePos(dirtPos)); + + helper.succeedWhen(() -> helper.assertTrue(TestRuntime.passed("block.break.dirt"), "script did not report block.break.dirt")); + } + + @Override + public MapCodec codec() { + return MapCodec.unit(this); + } + + @Override + protected MutableComponent typeDescription() { + return Component.literal("KubeJS BlockEvents.broken test"); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java new file mode 100644 index 000000000..4d7b7fea9 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java @@ -0,0 +1,38 @@ +package dev.latvian.mods.kubejs.testmod; + +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.resources.Identifier; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +/// Entry point for the {@code testmod} game-test mod, registering KubeJS' game tests on the +/// mod bus. Loaded only by the headless {@code runGametest} ({@code gameTestServer}) run. +@Mod("testmod") +public class KubeJSGameTests { + public static final String MOD_ID = "testmod"; + + public KubeJSGameTests(IEventBus modBus) { + modBus.addListener(this::registerGameTests); + } + + private void registerGameTests(RegisterGameTestsEvent event) { + Holder> environment = event.registerEnvironment(id("default")); + + TestData>> data = new TestData<>( + environment, + Identifier.withDefaultNamespace("empty"), + 100, + 0, + true + ); + + event.registerTest(id("block_broken_dirt"), new BlockBrokenGameTest(data)); + } + + private static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(MOD_ID, path); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java new file mode 100644 index 000000000..985e2312a --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java @@ -0,0 +1,22 @@ +package dev.latvian.mods.kubejs.testmod; + +import java.util.HashSet; +import java.util.Set; + +/// Bound into scripts as {@code TestRuntime} so a script can report a passing condition back to +/// the game test that drives it, e.g. {@code TestRuntime.pass('block.break.dirt')}. +public class TestRuntime { + private static final Set PASSED = new HashSet<>(); + + public static void pass(String id) { + PASSED.add(id); + } + + public static boolean passed(String id) { + return PASSED.contains(id); + } + + public static void reset() { + PASSED.clear(); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntimePlugin.java b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntimePlugin.java new file mode 100644 index 000000000..acdcdc3a1 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntimePlugin.java @@ -0,0 +1,11 @@ +package dev.latvian.mods.kubejs.testmod; + +import dev.latvian.mods.kubejs.plugin.KubeJSPlugin; +import dev.latvian.mods.kubejs.script.BindingRegistry; + +public class TestRuntimePlugin implements KubeJSPlugin { + @Override + public void registerBindings(BindingRegistry bindings) { + bindings.add("TestRuntime", TestRuntime.class); + } +} diff --git a/src/test/kubejs/server_scripts/block_broken.js b/src/test/kubejs/server_scripts/block_broken.js new file mode 100644 index 000000000..cbbef450c --- /dev/null +++ b/src/test/kubejs/server_scripts/block_broken.js @@ -0,0 +1,6 @@ +// Game-test fixture for BlockBrokenGameTest. +BlockEvents.broken(event => { + if (event.block.id === 'minecraft:dirt') { + TestRuntime.pass('block.break.dirt'); + } +}); diff --git a/src/test/resources/META-INF/neoforge.mods.toml b/src/test/resources/META-INF/neoforge.mods.toml new file mode 100644 index 000000000..69e9e6b3a --- /dev/null +++ b/src/test/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,25 @@ +modLoader = "javafml" +loaderVersion = "[2,)" +license = "GNU LGPLv3" + +[[mods]] +modId = "testmod" +version = "1.0.0" +displayName = "KubeJS Game Tests" +description = ''' +Internal game tests for KubeJS. Not shipped. +''' + +[[dependencies.testmod]] +modId = "neoforge" +type = "required" +versionRange = "[0,)" +ordering = "AFTER" +side = "BOTH" + +[[dependencies.testmod]] +modId = "kubejs" +type = "required" +versionRange = "[0,)" +ordering = "AFTER" +side = "BOTH" diff --git a/src/test/resources/kubejs.plugins.txt b/src/test/resources/kubejs.plugins.txt new file mode 100644 index 000000000..7f001c971 --- /dev/null +++ b/src/test/resources/kubejs.plugins.txt @@ -0,0 +1 @@ +dev.latvian.mods.kubejs.testmod.TestRuntimePlugin From 1301955d646e3c8da128184bfea1dba9ac5705ab Mon Sep 17 00:00:00 2001 From: hspragg Date: Tue, 16 Jun 2026 16:08:18 -0700 Subject: [PATCH 02/18] Add JaCoCo code-coverage reporting across unit and game tests Enable AlmostGradle's JUnit and testframework test sets, attach JaCoCo to both the `test` (JUnit) and `runGametest` (game-test server) JVMs, and merge their execution data into one report (HTML + CSV + a JSON summary). Coverage tasks force a fresh run of both test JVMs so the report always reflects a real execution. Adds a coverage CI workflow that runs both suites, uploads the HTML report, and posts the coverage summary as a PR comment. --- .github/workflows/coverage.yml | 84 ++++++++++++++++ build.gradle.kts | 99 ++++++++++++++++++- .../mods/kubejs/unittest/ListJSTest.java | 45 +++++++++ .../mods/kubejs/unittest/RegExpKJSTest.java | 49 +++++++++ 4 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/coverage.yml create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ListJSTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..38ea77519 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,84 @@ +name: Coverage + +on: + workflow_dispatch: + pull_request: + branches: + - "2601" + paths: + - "gradle/**" + - "**.java" + - "**.kts" + - "**.properties" + - "**/coverage.yml" + - "src/test/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup JDK 25 + uses: actions/setup-java@v4 + with: + distribution: "microsoft" + java-version: "25" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Runs the JUnit (`test`) and headless game-test (`runGametest`) JVMs, merges their JaCoCo + # data into one report, and derives build/reports/coverage/coverage-summary.json. + - name: Generate coverage + run: ./gradlew coverageSummaryJson + + - name: Upload HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report-html + path: build/reports/coverage/html + + # Same-repo PRs only: PRs from forks get a read-only token and cannot comment. For fork + # coverage comments, split this into a companion `workflow_run` job that has write access. + - name: Comment coverage on PR + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const s = JSON.parse(fs.readFileSync('build/reports/coverage/coverage-summary.json', 'utf8')); + const row = (label, m) => `| ${label} | ${m.percent}% | ${m.covered} | ${m.missed} |`; + const marker = ''; + const body = [ + marker, + '## Coverage report', + '', + '| Metric | Coverage | Covered | Missed |', + '| --- | --- | --- | --- |', + row('Instructions', s.instruction), + row('Branches', s.branch), + row('Lines', s.line), + '', + '_Merged from the JUnit unit tests and the headless game test. Full HTML report is in the `coverage-report-html` artifact._', + ].join('\n'); + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.rest.issues.listComments({ owner, repo, issue_number }); + const existing = comments.data.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } diff --git a/build.gradle.kts b/build.gradle.kts index 3696e41dc..294bf8502 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,13 @@ import com.almostreliable.almostgradle.dependency.LoadingMode +import java.util.Locale +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoReport plugins { id("net.neoforged.moddev") version "2.0.138" id("com.almostreliable.almostgradle") version "2.1.1" id("idea") + jacoco // id("me.shedaniel.unified-publishing") version "0.1.+" } @@ -26,6 +30,8 @@ almostgradle.setup { enabled = true testMod = true gameTests = true + testFramework = true + jUnit = true } recipeViewers { @@ -143,9 +149,100 @@ tasks.matching { it.name == "runGametest" }.configureEach { dependsOn(copyGameTestScripts) } -// The test source set holds game tests (run via runGametest), not JUnit tests. +// Code coverage. JaCoCo's agent is attached to both test JVMs and scoped to KubeJS' own classes; +// `coverageReport` merges the two execution-data files into one report. +val coverageIncludes = listOf("dev.latvian.mods.kubejs.*") + +jacoco { + toolVersion = "0.8.15" // 0.8.14+ supports Java 25 class files + // Gradle's JaCoCo plugin only instruments `Test` tasks by default; opt the gametest JavaExec in. + applyTo(tasks.withType().matching { it.name == "runGametest" }) +} + +// When a coverage task is requested, force a fresh run of both test JVMs so the report always +// reflects a real execution (otherwise Gradle's up-to-date check skips them and reuses old data). +val coverageRequested = gradle.startParameter.taskNames.any { + it.substringAfterLast(':').startsWith("coverage") +} + +// The test source set holds both JUnit unit tests (...unittest, run by `test`) and the game-test +// mod (...testmod, run by `runGametest`). Allow `test` to pass before any unit tests are present. tasks.named("test") { failOnNoDiscoveredTests = false + if (coverageRequested) { + outputs.upToDateWhen { false } + } + extensions.configure { + includes = coverageIncludes + } +} + +tasks.withType().matching { it.name == "runGametest" }.configureEach { + if (coverageRequested) { + outputs.upToDateWhen { false } + } + extensions.configure { + isEnabled = true + includes = coverageIncludes + } +} + +val coverageReport by tasks.registering(JacocoReport::class) { + group = "verification" + description = "Merges coverage from the JUnit (test) and game-test (runGametest) JVMs." + dependsOn("test", "runGametest") + sourceSets(sourceSets["main"]) + executionData(fileTree(layout.buildDirectory) { + include("jacoco/test.exec", "jacoco/runGametest.exec") + }) + reports { + html.required = true + csv.required = true + xml.required = false + html.outputLocation = layout.buildDirectory.dir("reports/coverage/html") + csv.outputLocation = layout.buildDirectory.file("reports/coverage/coverage.csv") + } +} + +// Derive a small JSON summary (overall instruction/branch/line %) from the JaCoCo CSV, for CI to +// read when posting the coverage PR comment. JaCoCo has no native JSON report. +val coverageSummaryJson by tasks.registering { + group = "verification" + description = "Writes coverage-summary.json from the JaCoCo CSV report." + dependsOn(coverageReport) + val csvFile = layout.buildDirectory.file("reports/coverage/coverage.csv") + val jsonFile = layout.buildDirectory.file("reports/coverage/coverage-summary.json") + inputs.file(csvFile) + outputs.file(jsonFile) + doLast { + val rows = csvFile.get().asFile.readLines().drop(1).filter { it.isNotBlank() } + var instrMissed = 0L; var instrCovered = 0L + var branchMissed = 0L; var branchCovered = 0L + var lineMissed = 0L; var lineCovered = 0L + for (row in rows) { + val c = row.split(',') + if (c.size < 9) continue + instrMissed += c[3].toLong(); instrCovered += c[4].toLong() + branchMissed += c[5].toLong(); branchCovered += c[6].toLong() + lineMissed += c[7].toLong(); lineCovered += c[8].toLong() + } + fun pct(covered: Long, missed: Long): String { + val total = covered + missed + val v = if (total == 0L) 0.0 else covered * 100.0 / total + return String.format(Locale.ROOT, "%.2f", v) + } + fun metric(name: String, covered: Long, missed: Long) = + """ "$name": { "covered": $covered, "missed": $missed, "percent": ${pct(covered, missed)} }""" + val json = buildString { + appendLine("{") + appendLine(metric("instruction", instrCovered, instrMissed) + ",") + appendLine(metric("branch", branchCovered, branchMissed) + ",") + appendLine(metric("line", lineCovered, lineMissed)) + appendLine("}") + } + jsonFile.get().asFile.writeText(json) + logger.lifecycle("Coverage (instructions): ${pct(instrCovered, instrMissed)}% -> ${jsonFile.get().asFile}") + } } publishing { diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/ListJSTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/ListJSTest.java new file mode 100644 index 000000000..421aa4da4 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/ListJSTest.java @@ -0,0 +1,45 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.ListJS; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ListJSTest { + @Test + void primitiveArrayBecomesBoxedList() { + assertEquals(List.of(1, 2, 3), ListJS.of(new int[]{1, 2, 3})); + } + + @Test + void existingListIsReturnedAsIs() { + var list = List.of("a", "b"); + assertEquals(list, ListJS.of(list)); + } + + @Test + void orSelfWrapsScalar() { + assertEquals(List.of("hello"), ListJS.orSelf("hello")); + } + + @Test + void orSelfOfNullIsEmpty() { + assertTrue(ListJS.orSelf(null).isEmpty()); + } + + @Test + void ofScalarIsNull() { + assertNull(ListJS.of("hello")); + } + + @Test + void ofSetDeduplicates() { + var set = ListJS.ofSet(Arrays.asList(1, 1, 2, 3, 3)); + assertEquals(3, set.size()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java new file mode 100644 index 000000000..6720a3485 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java @@ -0,0 +1,49 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.RegExpKJS; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class RegExpKJSTest { + @Test + void parsesPatternWithFlag() { + var pattern = RegExpKJS.ofString("/foo/i"); + assertNotNull(pattern); + assertEquals("foo", pattern.pattern()); + assertTrue((pattern.flags() & Pattern.CASE_INSENSITIVE) != 0); + } + + @Test + void rejectsNonRegExpStrings() { + assertNull(RegExpKJS.ofString("foo")); + assertNull(RegExpKJS.ofString("/x")); + } + + @Test + void roundTripsThroughString() { + assertEquals("/a.b/is", RegExpKJS.toRegExpString(RegExpKJS.ofString("/a.b/is"))); + assertEquals("/plain/", RegExpKJS.toRegExpString(RegExpKJS.ofString("/plain/"))); + } + + @Test + void getFlagsCombinesBits() { + assertEquals(Pattern.CASE_INSENSITIVE, RegExpKJS.getFlags("i")); + assertEquals( + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL, + RegExpKJS.getFlags("ims") + ); + } + + @Test + void flagValidation() { + assertTrue(RegExpKJS.isValidFlag('i')); + assertFalse(RegExpKJS.isValidFlag('z')); + } +} From 4cd44709a7a0d07fbaf19dce5cae3332f73b8b39 Mon Sep 17 00:00:00 2001 From: hspragg Date: Tue, 16 Jun 2026 17:53:06 -0700 Subject: [PATCH 03/18] Restructure game tests to use NeoForge's test framework Mirror how NeoForge structures its own tests: stand up a TestFramework in the testmod bootstrap and write the block-break test as an annotated @GameTest/@EmptyTemplate/@TestHolder method in a @ForEachTest holder, replacing the hand-rolled GameTestInstance + manual RegisterGameTestsEvent. The test now simulates a real player join via makeTickingMockServerPlayerInCorner and breaks the block through the player game-mode path, following NeoForge's decoratedPotBreaking sequence idiom. --- .../kubejs/testmod/BlockBrokenGameTest.java | 47 ------------------- .../mods/kubejs/testmod/KubeJSGameTests.java | 36 +++++--------- .../kubejs/testmod/block/BlockEventTests.java | 26 ++++++++++ .../mods/kubejs/testmod/package-info.java | 4 ++ 4 files changed, 41 insertions(+), 72 deletions(-) delete mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/package-info.java diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java b/src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java deleted file mode 100644 index 7336ed0b7..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/BlockBrokenGameTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package dev.latvian.mods.kubejs.testmod; - -import com.mojang.serialization.MapCodec; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Holder; -import net.minecraft.gametest.framework.GameTestHelper; -import net.minecraft.gametest.framework.GameTestInstance; -import net.minecraft.gametest.framework.TestData; -import net.minecraft.gametest.framework.TestEnvironmentDefinition; -import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.MutableComponent; -import net.minecraft.world.level.block.Blocks; -import net.neoforged.neoforge.common.util.FakePlayer; -import net.neoforged.neoforge.common.util.FakePlayerFactory; - -/// Has an empty-handed player break a dirt block and asserts the {@code block_broken.js} server -/// script reacted to {@code BlockEvents.broken} by calling {@code TestRuntime.pass}. -public class BlockBrokenGameTest extends GameTestInstance { - public BlockBrokenGameTest(TestData>> info) { - super(info); - } - - @Override - public void run(GameTestHelper helper) { - TestRuntime.reset(); - - var dirtPos = new BlockPos(1, 2, 1); - helper.setBlock(dirtPos, Blocks.DIRT); - - // A fake player has no real connection, so its empty-handed break runs the normal - // player break path without KubeJS' join-time sync (which fails over a test connection). - FakePlayer player = FakePlayerFactory.getMinecraft(helper.getLevel()); - player.gameMode.destroyBlock(helper.absolutePos(dirtPos)); - - helper.succeedWhen(() -> helper.assertTrue(TestRuntime.passed("block.break.dirt"), "script did not report block.break.dirt")); - } - - @Override - public MapCodec codec() { - return MapCodec.unit(this); - } - - @Override - protected MutableComponent typeDescription() { - return Component.literal("KubeJS BlockEvents.broken test"); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java index 4d7b7fea9..69ceb6c91 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java @@ -1,38 +1,24 @@ package dev.latvian.mods.kubejs.testmod; -import net.minecraft.core.Holder; -import net.minecraft.gametest.framework.TestData; -import net.minecraft.gametest.framework.TestEnvironmentDefinition; import net.minecraft.resources.Identifier; import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; -import net.neoforged.neoforge.event.RegisterGameTestsEvent; +import net.neoforged.testframework.conf.FrameworkConfiguration; -/// Entry point for the {@code testmod} game-test mod, registering KubeJS' game tests on the -/// mod bus. Loaded only by the headless {@code runGametest} ({@code gameTestServer}) run. +/// Entry point for the {@code testmod} game-test mod. Stands up a NeoForge test framework that collects +/// the mod's annotated tests and registers them on the mod bus. Loaded only by the headless +/// {@code runGametest} ({@code gameTestServer}) run. @Mod("testmod") public class KubeJSGameTests { public static final String MOD_ID = "testmod"; - public KubeJSGameTests(IEventBus modBus) { - modBus.addListener(this::registerGameTests); - } - - private void registerGameTests(RegisterGameTestsEvent event) { - Holder> environment = event.registerEnvironment(id("default")); - - TestData>> data = new TestData<>( - environment, - Identifier.withDefaultNamespace("empty"), - 100, - 0, - true - ); - - event.registerTest(id("block_broken_dirt"), new BlockBrokenGameTest(data)); - } + public KubeJSGameTests(IEventBus modBus, ModContainer container) { + var framework = FrameworkConfiguration + .builder(Identifier.fromNamespaceAndPath(MOD_ID, "tests")) + .build() + .create(); - private static Identifier id(String path) { - return Identifier.fromNamespaceAndPath(MOD_ID, path); + framework.init(modBus, container); } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java new file mode 100644 index 000000000..b9dd6851e --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java @@ -0,0 +1,26 @@ +package dev.latvian.mods.kubejs.testmod.block; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +@ForEachTest(groups = "kubejs.block.event") +public class BlockEventTests { + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_broken_dirt", description = "KubeJS BlockEvents.broken fires when a player breaks dirt") + static void blockBrokenDirt(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.reset()) + .thenExecute(() -> helper.setBlock(1, 2, 1, Blocks.DIRT.defaultBlockState())) + .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(new BlockPos(1, 2, 1)))) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.break.dirt"), "script did not report block.break.dirt")) + .thenSucceed()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/package-info.java b/src/test/java/dev/latvian/mods/kubejs/testmod/package-info.java new file mode 100644 index 000000000..553a24a2a --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.latvian.mods.kubejs.testmod; + +import org.jspecify.annotations.NullMarked; From 9b19b4e311403a9bc980520f10defbf551a25477 Mon Sep 17 00:00:00 2001 From: hspragg Date: Wed, 17 Jun 2026 17:06:29 -0700 Subject: [PATCH 04/18] Remove generic type from TagLoader interface injection entry The `` in the injected interface name leaks into the raw class name that NeoForge's TransformerClassWriter feeds to Class.forName during runtime class-hierarchy computation. As the loader does not strip generics, this throws ClassNotFoundException for dev.latvian.mods.kubejs.core.TagLoaderKJS while loading the vanilla datapack, crashing the game test server. The generic remains in the Java source/signature; only the injection name needs the raw type. --- interfaces.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interfaces.json b/interfaces.json index 651c56d93..73e48e70d 100644 --- a/interfaces.json +++ b/interfaces.json @@ -81,6 +81,6 @@ "dev/latvian/mods/kubejs/core/EntityTypeKJS" ], "net/minecraft/tags/TagLoader":[ - "dev/latvian/mods/kubejs/core/TagLoaderKJS" + "dev/latvian/mods/kubejs/core/TagLoaderKJS" ] } \ No newline at end of file From 84ee77a38b6f058399434a48f91096db91532d47 Mon Sep 17 00:00:00 2001 From: hspragg Date: Wed, 17 Jun 2026 17:06:42 -0700 Subject: [PATCH 05/18] Remove redundant tests.enabled flag AlmostGradle sets it implicitly when the tests {} block is used, per upstream review feedback. --- build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 294bf8502..fd6f8690e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,6 @@ almostgradle.setup { withAccessTransformerValidation = !runningInCI tests { - enabled = true testMod = true gameTests = true testFramework = true From eb09321ebb6ea8eacf9a2692b0b484e993203da6 Mon Sep 17 00:00:00 2001 From: hspragg Date: Thu, 18 Jun 2026 13:00:20 -0700 Subject: [PATCH 06/18] Updated build script such that unit tests run before game test --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index fd6f8690e..47993e256 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -177,6 +177,9 @@ tasks.named("test") { } tasks.withType().matching { it.name == "runGametest" }.configureEach { + // When both run (the coverage flow), order the cheap unit tests first so the heavy game-test + // server never shares a heap window with the JUnit fork, even under --parallel. + mustRunAfter("test") if (coverageRequested) { outputs.upToDateWhen { false } } From 9ddaf103c6013eeda6ad09045a74424b817fdaa4 Mon Sep 17 00:00:00 2001 From: hspragg Date: Thu, 18 Jun 2026 13:10:35 -0700 Subject: [PATCH 07/18] Bump AlmostGradle to 2.2.0 and drop redundant gametest run config AlmostGradle 2.2.0 launches the gametest run as a headless gameTestServer, so the custom runs{} override is no longer needed. --- build.gradle.kts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 47993e256..b226188e6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.gradle.testing.jacoco.tasks.JacocoReport plugins { id("net.neoforged.moddev") version "2.0.138" - id("com.almostreliable.almostgradle") version "2.1.1" + id("com.almostreliable.almostgradle") version "2.2.0" id("idea") jacoco // id("me.shedaniel.unified-publishing") version "0.1.+" @@ -68,15 +68,6 @@ neoForge { from(file("interfaces.json")) publish(file("interfaces.json")) } - - runs { - // AlmostGradle's gametest run launches as a plain server here; use Neo's headless type and run only our tests. - matching { it.name == "gametest" }.configureEach { - type.set("gameTestServer") - programArgument("--tests") - programArgument("testmod:*") - } - } } repositories { From 3c51c748cdbd3a21fc8686732964c9b10ca458db Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 12:27:11 -0700 Subject: [PATCH 08/18] Add game tests for block and entity events; fix DetectorBlock id Adds NeoForge TestFramework game tests exercising every BlockEvents and EntityEvents handler, driven by paired server/startup scripts through a hardened TestRuntime bridge (concurrent, per-marker clears, count and startup-scoped markers). DetectorBlock could not be constructed on 26.1: it built its Properties from bedrock without setting the block id that BlockBehaviour now requires, crashing registration and world load. It now stamps the id the same way BlockBuilder does, which also unblocks the detector event tests. --- CHANGELOG.md | 2 + .../mods/kubejs/block/DetectorBlock.java | 4 +- .../mods/kubejs/testmod/TestRuntime.java | 34 ++- .../kubejs/testmod/block/BlockEventTests.java | 213 +++++++++++++++++- .../testmod/entity/EntityEventTests.java | 92 ++++++++ .../kubejs/server_scripts/block_broken.js | 6 - .../kubejs/server_scripts/block_events.js | 38 ++++ .../kubejs/server_scripts/entity_events.js | 14 ++ .../kubejs/startup_scripts/block_fixtures.js | 10 + 9 files changed, 397 insertions(+), 16 deletions(-) create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java delete mode 100644 src/test/kubejs/server_scripts/block_broken.js create mode 100644 src/test/kubejs/server_scripts/block_events.js create mode 100644 src/test/kubejs/server_scripts/entity_events.js create mode 100644 src/test/kubejs/startup_scripts/block_fixtures.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b56fa6f2..682207156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Fix `Client` binding not being available from startup/client scripts (#1145) - Fixed circular initialization between ConsoleJS and ScriptType before script consoles are ready (#1147) +- Fix `DetectorBlock` failing to register (and crashing world load) because its block id was never set (#NNNN) +- Add game tests covering block and entity events (#NNNN) ## [8.0.2] - 2026-06-12 diff --git a/src/main/java/dev/latvian/mods/kubejs/block/DetectorBlock.java b/src/main/java/dev/latvian/mods/kubejs/block/DetectorBlock.java index 3b61dcd82..4d92b4919 100644 --- a/src/main/java/dev/latvian/mods/kubejs/block/DetectorBlock.java +++ b/src/main/java/dev/latvian/mods/kubejs/block/DetectorBlock.java @@ -6,8 +6,10 @@ import dev.latvian.mods.kubejs.plugin.builtin.event.BlockEvents; import dev.latvian.mods.rhino.util.ReturnsSelf; import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; @@ -66,7 +68,7 @@ protected void generateItemModel(ModelGenerator m) { private final Builder builder; public DetectorBlock(Builder b) { - super(Properties.ofFullCopy(Blocks.BEDROCK)); + super(Properties.ofFullCopy(Blocks.BEDROCK).setId(ResourceKey.create(BuiltInRegistries.BLOCK.key(), b.id))); builder = b; registerDefaultState(stateDefinition.any().setValue(BlockStateProperties.POWERED, false)); } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java index 985e2312a..ee2d0cd50 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java @@ -1,22 +1,44 @@ package dev.latvian.mods.kubejs.testmod; -import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /// Bound into scripts as {@code TestRuntime} so a script can report a passing condition back to /// the game test that drives it, e.g. {@code TestRuntime.pass('block.break.dirt')}. +/// +/// Markers are counted (so a test can assert an event fired once or repeatedly) and stored in +/// concurrent collections because {@link #pass} runs on the server thread as events fire while the +/// game test polls from its own sequence. A test clears only the marker(s) it owns via {@link #clear} +/// so parallel or reordered tests can't wipe each other's in-flight markers. Startup-fired markers +/// (e.g. {@code BlockEvents.modification}) use {@link #passStartup} so they survive other tests' clears. public class TestRuntime { - private static final Set PASSED = new HashSet<>(); + private static final Map COUNTS = new ConcurrentHashMap<>(); + private static final Set STARTUP = ConcurrentHashMap.newKeySet(); public static void pass(String id) { - PASSED.add(id); + COUNTS.merge(id, 1, Integer::sum); } public static boolean passed(String id) { - return PASSED.contains(id); + return COUNTS.containsKey(id); } - public static void reset() { - PASSED.clear(); + public static int count(String id) { + return COUNTS.getOrDefault(id, 0); + } + + public static void clear(String... ids) { + for (var id : ids) { + COUNTS.remove(id); + } + } + + public static void passStartup(String id) { + STARTUP.add(id); + } + + public static boolean passedStartup(String id) { + return STARTUP.contains(id); } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java index b9dd6851e..a7caedf9d 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java @@ -1,9 +1,21 @@ package dev.latvian.mods.kubejs.testmod.block; +import dev.latvian.mods.kubejs.core.GameRulesKJS; import dev.latvian.mods.kubejs.testmod.TestRuntime; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; import net.minecraft.world.level.GameType; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; import net.neoforged.testframework.DynamicTest; import net.neoforged.testframework.annotation.ForEachTest; import net.neoforged.testframework.annotation.TestHolder; @@ -12,15 +24,210 @@ @ForEachTest(groups = "kubejs.block.event") public class BlockEventTests { + private static final BlockPos POS = new BlockPos(1, 2, 1); + @GameTest @EmptyTemplate(floor = true) @TestHolder(value = "block_broken_dirt", description = "KubeJS BlockEvents.broken fires when a player breaks dirt") static void blockBrokenDirt(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) - .thenExecute(() -> TestRuntime.reset()) - .thenExecute(() -> helper.setBlock(1, 2, 1, Blocks.DIRT.defaultBlockState())) - .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(new BlockPos(1, 2, 1)))) + .thenExecute(() -> TestRuntime.clear("block.break.dirt")) + .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) + .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.break.dirt"), "script did not report block.break.dirt")) .thenSucceed()); } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_drops_dirt", description = "KubeJS BlockEvents.drops fires when a broken block drops items") + static void blockDropsDirt(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.drops.dirt")) + .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) + .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.drops.dirt"), "script did not report block.drops.dirt")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_placed", description = "KubeJS BlockEvents.placed fires when a player places a block") + static void blockPlaced(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.placed")) + .thenExecute(() -> helper.setBlock(POS, Blocks.STONE.defaultBlockState())) + .thenExecute(player -> { + var stack = new ItemStack(Items.OAK_PLANKS); + player.setItemInHand(InteractionHand.MAIN_HAND, stack); + var abs = helper.absolutePos(POS); + var hit = new BlockHitResult(Vec3.atCenterOf(abs).add(0, 0.5, 0), Direction.UP, abs, false); + player.gameMode.useItemOn(player, (ServerLevel) player.level(), stack, InteractionHand.MAIN_HAND, hit); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.placed"), "script did not report block.placed")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_right_clicked", description = "KubeJS BlockEvents.rightClicked fires when a player right-clicks a block") + static void blockRightClicked(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.rightClicked")) + .thenExecute(() -> helper.setBlock(POS, Blocks.STONE.defaultBlockState())) + .thenExecute(player -> { + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + var abs = helper.absolutePos(POS); + var hit = new BlockHitResult(Vec3.atCenterOf(abs).add(0, 0.5, 0), Direction.UP, abs, false); + player.gameMode.useItemOn(player, (ServerLevel) player.level(), ItemStack.EMPTY, InteractionHand.MAIN_HAND, hit); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.rightClicked"), "script did not report block.rightClicked")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_left_clicked", description = "KubeJS BlockEvents.leftClicked fires when a player starts breaking a block") + static void blockLeftClicked(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.leftClicked")) + .thenExecute(() -> helper.setBlock(POS, Blocks.STONE.defaultBlockState())) + .thenExecute(player -> player.gameMode.handleBlockBreakAction( + helper.absolutePos(POS), + ServerboundPlayerActionPacket.Action.START_DESTROY_BLOCK, + Direction.UP, + player.level().getMaxY(), + 0)) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.leftClicked"), "script did not report block.leftClicked")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_started_falling", description = "KubeJS BlockEvents.startedFalling fires when a falling block starts to fall") + static void blockStartedFalling(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.startedFalling")) + .thenExecute(() -> helper.setBlock(new BlockPos(1, 2, 1), Blocks.AIR.defaultBlockState())) + .thenExecute(() -> helper.setBlock(new BlockPos(1, 3, 1), Blocks.SAND.defaultBlockState())) + .thenIdle(4) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.startedFalling"), "script did not report block.startedFalling")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_stopped_falling", description = "KubeJS BlockEvents.stoppedFalling fires when a falling block lands") + static void blockStoppedFalling(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.stoppedFalling")) + .thenExecute(() -> helper.setBlock(new BlockPos(1, 2, 1), Blocks.AIR.defaultBlockState())) + .thenExecute(() -> helper.setBlock(new BlockPos(1, 3, 1), Blocks.SAND.defaultBlockState())) + .thenIdle(20) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.stoppedFalling"), "script did not report block.stoppedFalling")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(value = "5x8x5", floor = true) + @TestHolder(value = "block_farmland_trampled", description = "KubeJS BlockEvents.farmlandTrampled fires when an entity falls on farmland") + static void blockFarmlandTrampled(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.farmlandTrampled")) + .thenExecute(player -> ((GameRulesKJS) ((ServerLevel) player.level()).getGameRules()).kjs$set("mob_griefing", "true")) + .thenExecute(() -> helper.setBlock(POS, Blocks.FARMLAND.defaultBlockState())) + .thenExecute(() -> helper.spawnWithNoFreeWill(EntityType.GOAT, new BlockPos(1, 5, 1).getCenter())) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.farmlandTrampled"), "script did not report block.farmlandTrampled")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_random_tick", description = "KubeJS BlockEvents.randomTick fires when a block is randomly ticked") + static void blockRandomTick(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.randomTick.dirt")) + .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) + .thenExecute(player -> { + var abs = helper.absolutePos(POS); + var level = (ServerLevel) player.level(); + helper.getBlockState(POS).randomTick(level, abs, level.getRandom()); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.randomTick.dirt"), "script did not report block.randomTick.dirt")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_detector_powered", description = "KubeJS BlockEvents.detectorPowered/detectorChanged fire when a detector is powered") + static void blockDetectorPowered(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.detector.powered", "block.detector.changed")) + .thenExecute(() -> helper.setBlock(POS, detector())) + .thenExecute(() -> helper.setBlock(new BlockPos(2, 2, 1), Blocks.REDSTONE_BLOCK.defaultBlockState())) + .thenIdle(4) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.detector.powered"), "script did not report block.detector.powered")) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.detector.changed"), "script did not report block.detector.changed")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_detector_unpowered", description = "KubeJS BlockEvents.detectorUnpowered fires when a detector loses power") + static void blockDetectorUnpowered(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.detector.unpowered")) + .thenExecute(() -> helper.setBlock(POS, detector())) + .thenExecute(() -> helper.setBlock(new BlockPos(2, 2, 1), Blocks.REDSTONE_BLOCK.defaultBlockState())) + .thenIdle(4) + .thenExecute(() -> helper.setBlock(new BlockPos(2, 2, 1), Blocks.AIR.defaultBlockState())) + .thenIdle(4) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.detector.unpowered"), "script did not report block.detector.unpowered")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_entity_tick", description = "KubeJS BlockEvents.blockEntityTick fires while a ticking block entity is present") + static void blockEntityTick(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.blockEntityTick")) + .thenExecute(() -> helper.setBlock(POS, block("kubejs:test_ticker"))) + .thenIdle(5) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.count("block.blockEntityTick") >= 1, "script did not report block.blockEntityTick")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_picked", description = "KubeJS BlockEvents.picked fires when a block is pick-blocked") + static void blockPicked(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.picked")) + .thenExecute(() -> helper.setBlock(POS, Blocks.STONE.defaultBlockState())) + .thenExecute(player -> { + var abs = helper.absolutePos(POS); + helper.getBlockState(POS).getCloneItemStack(abs, (ServerLevel) player.level(), true, player); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.picked"), "script did not report block.picked")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_modification", description = "KubeJS BlockEvents.modification fired during startup script load") + static void blockModification(final DynamicTest test) { + test.onGameTest(helper -> { + helper.assertTrue(TestRuntime.passedStartup("block.modification"), "script did not report block.modification"); + helper.succeed(); + }); + } + + private static net.minecraft.world.level.block.state.BlockState detector() { + return block("kubejs:test_detector"); + } + + private static net.minecraft.world.level.block.state.BlockState block(String id) { + return BuiltInRegistries.BLOCK.getValue(Identifier.parse(id)).defaultBlockState(); + } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java new file mode 100644 index 000000000..294100890 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java @@ -0,0 +1,92 @@ +package dev.latvian.mods.kubejs.testmod.entity; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.GameType; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +@ForEachTest(groups = "kubejs.entity.event") +public class EntityEventTests { + private static final BlockPos POS = new BlockPos(1, 2, 1); + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_spawned", description = "KubeJS EntityEvents.spawned fires when an entity is added to the level") + static void entitySpawned(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("entity.spawned")) + .thenExecute(() -> helper.spawn(EntityType.PIG, POS)) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.spawned"), "script did not report entity.spawned")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_before_hurt", description = "KubeJS EntityEvents.beforeHurt fires before an entity takes damage") + static void entityBeforeHurt(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("entity.beforeHurt")) + .thenExecute(player -> { + var level = (ServerLevel) player.level(); + var pig = helper.spawn(EntityType.PIG, POS); + pig.hurtServer(level, level.damageSources().generic(), 1.0F); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.beforeHurt"), "script did not report entity.beforeHurt")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_after_hurt", description = "KubeJS EntityEvents.afterHurt fires after an entity takes damage") + static void entityAfterHurt(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("entity.afterHurt")) + .thenExecute(player -> { + var level = (ServerLevel) player.level(); + var pig = helper.spawn(EntityType.PIG, POS); + pig.hurtServer(level, level.damageSources().generic(), 1.0F); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.afterHurt"), "script did not report entity.afterHurt")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_death", description = "KubeJS EntityEvents.death fires when an entity dies") + static void entityDeath(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("entity.death")) + .thenExecute(player -> helper.spawn(EntityType.PIG, POS).kill((ServerLevel) player.level())) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.death"), "script did not report entity.death")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_drops", description = "KubeJS EntityEvents.drops fires when an entity drops loot on death") + static void entityDrops(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("entity.drops")) + .thenExecute(player -> helper.spawn(EntityType.PIG, POS).kill((ServerLevel) player.level())) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.drops"), "script did not report entity.drops")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_check_spawn", description = "KubeJS EntityEvents.checkSpawn fires when a mob is finalized during spawning") + static void entityCheckSpawn(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("entity.checkSpawn")) + .thenExecute(player -> EntityType.ZOMBIE.spawn((ServerLevel) player.level(), helper.absolutePos(POS), EntitySpawnReason.SPAWNER)) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.checkSpawn"), "script did not report entity.checkSpawn")) + .thenSucceed()); + } +} diff --git a/src/test/kubejs/server_scripts/block_broken.js b/src/test/kubejs/server_scripts/block_broken.js deleted file mode 100644 index cbbef450c..000000000 --- a/src/test/kubejs/server_scripts/block_broken.js +++ /dev/null @@ -1,6 +0,0 @@ -// Game-test fixture for BlockBrokenGameTest. -BlockEvents.broken(event => { - if (event.block.id === 'minecraft:dirt') { - TestRuntime.pass('block.break.dirt'); - } -}); diff --git a/src/test/kubejs/server_scripts/block_events.js b/src/test/kubejs/server_scripts/block_events.js new file mode 100644 index 000000000..eb9dd3a35 --- /dev/null +++ b/src/test/kubejs/server_scripts/block_events.js @@ -0,0 +1,38 @@ +// Game-test fixtures for BlockEventTests. Each listener flags TestRuntime with a marker the +// paired @GameTest polls after driving the action that should fire the event. + +BlockEvents.broken(event => { + if (event.block.id === 'minecraft:dirt') { + TestRuntime.pass('block.break.dirt'); + } +}); + +BlockEvents.drops(event => { + if (event.block.id === 'minecraft:dirt') { + TestRuntime.pass('block.drops.dirt'); + } +}); + +BlockEvents.placed(event => TestRuntime.pass('block.placed')); + +BlockEvents.rightClicked(event => TestRuntime.pass('block.rightClicked')); + +BlockEvents.leftClicked(event => TestRuntime.pass('block.leftClicked')); + +BlockEvents.startedFalling(event => TestRuntime.pass('block.startedFalling')); + +BlockEvents.stoppedFalling(event => TestRuntime.pass('block.stoppedFalling')); + +BlockEvents.farmlandTrampled(event => TestRuntime.pass('block.farmlandTrampled')); + +BlockEvents.randomTick('minecraft:dirt', event => TestRuntime.pass('block.randomTick.dirt')); + +BlockEvents.detectorChanged('test', event => TestRuntime.pass('block.detector.changed')); + +BlockEvents.detectorPowered('test', event => TestRuntime.pass('block.detector.powered')); + +BlockEvents.detectorUnpowered('test', event => TestRuntime.pass('block.detector.unpowered')); + +BlockEvents.blockEntityTick('kubejs:test_ticker', event => TestRuntime.pass('block.blockEntityTick')); + +BlockEvents.picked('minecraft:stone', event => TestRuntime.pass('block.picked')); diff --git a/src/test/kubejs/server_scripts/entity_events.js b/src/test/kubejs/server_scripts/entity_events.js new file mode 100644 index 000000000..009127148 --- /dev/null +++ b/src/test/kubejs/server_scripts/entity_events.js @@ -0,0 +1,14 @@ +// Game-test fixtures for EntityEventTests. Listeners are unfiltered; each @GameTest clears its +// marker right before driving the action, so the mock player's own spawn can't false-flag them. + +EntityEvents.spawned(event => TestRuntime.pass('entity.spawned')); + +EntityEvents.beforeHurt(event => TestRuntime.pass('entity.beforeHurt')); + +EntityEvents.afterHurt(event => TestRuntime.pass('entity.afterHurt')); + +EntityEvents.death(event => TestRuntime.pass('entity.death')); + +EntityEvents.drops(event => TestRuntime.pass('entity.drops')); + +EntityEvents.checkSpawn(event => TestRuntime.pass('entity.checkSpawn')); diff --git a/src/test/kubejs/startup_scripts/block_fixtures.js b/src/test/kubejs/startup_scripts/block_fixtures.js new file mode 100644 index 000000000..85edffb50 --- /dev/null +++ b/src/test/kubejs/startup_scripts/block_fixtures.js @@ -0,0 +1,10 @@ +// Startup fixtures for BlockEventTests: the custom blocks that back the events which only fire for +// a KubeJS-registered block (a detector and a ticking block entity), plus the startup-time +// modification listener. + +StartupEvents.registry('block', event => { + event.create('test_detector', 'detector').detectorId('test'); + event.create('test_ticker').blockEntity(be => be.serverTicking()); +}); + +BlockEvents.modification(event => TestRuntime.passStartup('block.modification')); From 847baf50c747936019180cb7c0667cca3c0906e7 Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 15:24:15 -0700 Subject: [PATCH 09/18] Add script-side assertThat game tests for event objects Add typed TestRuntime.assertThat entry points backed by AssertJ so game-test scripts can assert on event object contents: custom KubeEntityEventAssert and LevelBlockAssert plus String/boolean/double/Object entry points. check() captures failures (the event dispatcher swallows handler exceptions) and verify() re-throws them on the game-test thread. Adds block_broken_asserts, block_placed_asserts and entity_death_asserts covering the new API. --- CHANGELOG.md | 1 + .../mods/kubejs/testmod/TestRuntime.java | 64 +++++++++++++++++++ .../assertion/KubeEntityEventAssert.java | 45 +++++++++++++ .../testmod/assertion/LevelBlockAssert.java | 35 ++++++++++ .../testmod/assertion/package-info.java | 4 ++ .../kubejs/testmod/block/BlockEventTests.java | 32 ++++++++++ .../testmod/entity/EntityEventTests.java | 12 ++++ .../kubejs/server_scripts/assertion_events.js | 23 +++++++ 8 files changed, 216 insertions(+) create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/assertion/KubeEntityEventAssert.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/assertion/LevelBlockAssert.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/assertion/package-info.java create mode 100644 src/test/kubejs/server_scripts/assertion_events.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6987aa4ec..e4efef436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Fix dedicated server crash from block tint function client types (#1159) - Fix `DetectorBlock` failing to register (and crashing world load) because its block id was never set (#1153) - Add game tests covering block and entity events (#1153) +- Add typed script-side `TestRuntime.assertThat` entry points and game tests asserting on event objects (#NNNN) ## [8.0.3] - 2026-06-22 diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java index ee2d0cd50..4f5b7ad26 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java @@ -1,5 +1,16 @@ package dev.latvian.mods.kubejs.testmod; +import dev.latvian.mods.kubejs.entity.KubeEntityEvent; +import dev.latvian.mods.kubejs.level.LevelBlock; +import dev.latvian.mods.kubejs.testmod.assertion.KubeEntityEventAssert; +import dev.latvian.mods.kubejs.testmod.assertion.LevelBlockAssert; +import org.assertj.core.api.AbstractBooleanAssert; +import org.assertj.core.api.AbstractDoubleAssert; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ObjectAssert; +import org.jspecify.annotations.Nullable; + import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -12,9 +23,15 @@ /// game test polls from its own sequence. A test clears only the marker(s) it owns via {@link #clear} /// so parallel or reordered tests can't wipe each other's in-flight markers. Startup-fired markers /// (e.g. {@code BlockEvents.modification}) use {@link #passStartup} so they survive other tests' clears. +/// +/// A script can also assert on the event object with AssertJ via {@link #assertThat}, wrapped in a +/// {@link #check} block, e.g. {@code TestRuntime.check('block.broken', () => assertThat(event.block).hasId('minecraft:dirt'))}. +/// The event dispatcher swallows exceptions thrown by a handler, so {@link #check} captures any failure +/// under the marker and {@link #verify} re-throws it on the game-test thread with AssertJ's message. public class TestRuntime { private static final Map COUNTS = new ConcurrentHashMap<>(); private static final Set STARTUP = ConcurrentHashMap.newKeySet(); + private static final Map FAILURES = new ConcurrentHashMap<>(); public static void pass(String id) { COUNTS.merge(id, 1, Integer::sum); @@ -31,6 +48,7 @@ public static int count(String id) { public static void clear(String... ids) { for (var id : ids) { COUNTS.remove(id); + FAILURES.remove(id); } } @@ -41,4 +59,50 @@ public static void passStartup(String id) { public static boolean passedStartup(String id) { return STARTUP.contains(id); } + + /// Marks {@code id} reached and runs the script's assertions, capturing any [AssertionError] they + /// throw so it survives the event dispatcher (which otherwise swallows handler exceptions). + public static void check(String id, Runnable assertions) { + pass(id); + + try { + assertions.run(); + } catch (AssertionError error) { + FAILURES.put(id, error); + } + } + + /// Re-throws any assertion failure captured under {@code id}, so the game test fails on its own + /// thread with AssertJ's message instead of merely timing out. + public static void verify(String id) { + var error = FAILURES.get(id); + + if (error != null) { + throw error; + } + } + + public static AbstractStringAssert assertThat(String actual) { + return Assertions.assertThat(actual); + } + + public static AbstractBooleanAssert assertThat(boolean actual) { + return Assertions.assertThat(actual); + } + + public static AbstractDoubleAssert assertThat(double actual) { + return Assertions.assertThat(actual); + } + + public static KubeEntityEventAssert assertThat(KubeEntityEvent actual) { + return new KubeEntityEventAssert(actual); + } + + public static LevelBlockAssert assertThat(LevelBlock actual) { + return new LevelBlockAssert(actual); + } + + public static ObjectAssert assertThat(@Nullable Object actual) { + return Assertions.assertThat(actual); + } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/KubeEntityEventAssert.java b/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/KubeEntityEventAssert.java new file mode 100644 index 000000000..514ec9f12 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/KubeEntityEventAssert.java @@ -0,0 +1,45 @@ +package dev.latvian.mods.kubejs.testmod.assertion; + +import dev.latvian.mods.kubejs.entity.KubeEntityEvent; +import net.minecraft.core.registries.BuiltInRegistries; +import org.assertj.core.api.AbstractAssert; + +/// AssertJ entry point for asserting on any [KubeEntityEvent] - which every entity event and most +/// block events implement - from a game-test script via {@code TestRuntime.assertThat(event)}. +public class KubeEntityEventAssert extends AbstractAssert { + public KubeEntityEventAssert(KubeEntityEvent actual) { + super(actual, KubeEntityEventAssert.class); + } + + public KubeEntityEventAssert hasEntityType(String expected) { + isNotNull(); + var id = BuiltInRegistries.ENTITY_TYPE.getKey(actual.getEntity().getType()).toString(); + + if (!id.equals(expected)) { + failWithMessage("Expected entity type <%s> but was <%s>", expected, id); + } + + return this; + } + + public KubeEntityEventAssert hasPlayer() { + isNotNull(); + + if (actual.getPlayer() == null) { + failWithMessage("Expected the event to have a player but it did not"); + } + + return this; + } + + public KubeEntityEventAssert hasNoPlayer() { + isNotNull(); + var player = actual.getPlayer(); + + if (player != null) { + failWithMessage("Expected the event to have no player but was <%s>", player); + } + + return this; + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/LevelBlockAssert.java b/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/LevelBlockAssert.java new file mode 100644 index 000000000..d7feec2f5 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/LevelBlockAssert.java @@ -0,0 +1,35 @@ +package dev.latvian.mods.kubejs.testmod.assertion; + +import dev.latvian.mods.kubejs.level.LevelBlock; +import net.minecraft.core.registries.BuiltInRegistries; +import org.assertj.core.api.AbstractAssert; + +/// AssertJ entry point for asserting on the [LevelBlock] a block event exposes as {@code event.block}, +/// e.g. {@code TestRuntime.assertThat(event.block).hasId('minecraft:dirt')}. +public class LevelBlockAssert extends AbstractAssert { + public LevelBlockAssert(LevelBlock actual) { + super(actual, LevelBlockAssert.class); + } + + public LevelBlockAssert hasId(String expected) { + isNotNull(); + var id = BuiltInRegistries.BLOCK.getKey(actual.kjs$getBlock()).toString(); + + if (!id.equals(expected)) { + failWithMessage("Expected block id <%s> but was <%s>", expected, id); + } + + return this; + } + + public LevelBlockAssert hasProperty(String key, String value) { + isNotNull(); + var actualValue = actual.getProperties().get(key); + + if (!value.equals(actualValue)) { + failWithMessage("Expected block property <%s>=<%s> but was <%s>", key, value, actualValue); + } + + return this; + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/package-info.java b/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/package-info.java new file mode 100644 index 000000000..193ba6e14 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/assertion/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.latvian.mods.kubejs.testmod.assertion; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java index a7caedf9d..d54c4b42d 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java @@ -38,6 +38,19 @@ static void blockBrokenDirt(final DynamicTest test) { .thenSucceed()); } + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_broken_asserts", description = "KubeJS BlockEvents.broken exposes the broken block and player to script assertions") + static void blockBrokenAsserts(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.broken.assert")) + .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) + .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.broken.assert"), "script did not assert on block.broken")) + .thenExecute(() -> TestRuntime.verify("block.broken.assert")) + .thenSucceed()); + } + @GameTest @EmptyTemplate(floor = true) @TestHolder(value = "block_drops_dirt", description = "KubeJS BlockEvents.drops fires when a broken block drops items") @@ -68,6 +81,25 @@ static void blockPlaced(final DynamicTest test) { .thenSucceed()); } + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_placed_asserts", description = "KubeJS BlockEvents.placed exposes the placed block to script assertions") + static void blockPlacedAsserts(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("block.placed.assert")) + .thenExecute(() -> helper.setBlock(POS, Blocks.STONE.defaultBlockState())) + .thenExecute(player -> { + var stack = new ItemStack(Items.OAK_PLANKS); + player.setItemInHand(InteractionHand.MAIN_HAND, stack); + var abs = helper.absolutePos(POS); + var hit = new BlockHitResult(Vec3.atCenterOf(abs).add(0, 0.5, 0), Direction.UP, abs, false); + player.gameMode.useItemOn(player, (ServerLevel) player.level(), stack, InteractionHand.MAIN_HAND, hit); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.placed.assert"), "script did not assert on block.placed")) + .thenExecute(() -> TestRuntime.verify("block.placed.assert")) + .thenSucceed()); + } + @GameTest @EmptyTemplate(floor = true) @TestHolder(value = "block_right_clicked", description = "KubeJS BlockEvents.rightClicked fires when a player right-clicks a block") diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java index 294100890..200a14ccf 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java @@ -68,6 +68,18 @@ static void entityDeath(final DynamicTest test) { .thenSucceed()); } + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_death_asserts", description = "KubeJS EntityEvents.death exposes the dead entity and damage source to script assertions") + static void entityDeathAsserts(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("entity.death.assert")) + .thenExecute(player -> helper.spawn(EntityType.PIG, POS).kill((ServerLevel) player.level())) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.death.assert"), "script did not assert on entity.death")) + .thenExecute(() -> TestRuntime.verify("entity.death.assert")) + .thenSucceed()); + } + @GameTest @EmptyTemplate(floor = true) @TestHolder(value = "entity_drops", description = "KubeJS EntityEvents.drops fires when an entity drops loot on death") diff --git a/src/test/kubejs/server_scripts/assertion_events.js b/src/test/kubejs/server_scripts/assertion_events.js new file mode 100644 index 000000000..37dcb8295 --- /dev/null +++ b/src/test/kubejs/server_scripts/assertion_events.js @@ -0,0 +1,23 @@ +// Game-test fixtures exercising TestRuntime's typed assertions. Each listener asserts on the event +// object inside a check() block; the paired @GameTest clears the marker, drives the action, then +// verify()s the captured result so a wrong value fails with an AssertJ message, not a bare timeout. + +BlockEvents.broken(event => { + TestRuntime.check('block.broken.assert', () => { + TestRuntime.assertThat(event.block).hasId('minecraft:dirt'); + TestRuntime.assertThat(event).hasPlayer(); + }); +}); + +BlockEvents.placed(event => { + TestRuntime.check('block.placed.assert', () => { + TestRuntime.assertThat(event.block).hasId('minecraft:oak_planks'); + }); +}); + +EntityEvents.death(event => { + TestRuntime.check('entity.death.assert', () => { + TestRuntime.assertThat(event).hasEntityType('minecraft:pig').hasNoPlayer(); + TestRuntime.assertThat(event.source).isNotNull(); + }); +}); From 00c1f2d3e7d9db91b27897653a9bdde63f0c4237 Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 15:28:07 -0700 Subject: [PATCH 10/18] Add AssertJ test dependency for game-test assertions --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 81acfbb06..404cbee33 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -126,6 +126,8 @@ dependencies { prefer(batVersion) } }) + + testImplementation("org.assertj:assertj-core:3.27.3") } // Make the game tests' KubeJS scripts available in the gametest run's game directory. From 3db42cea3273469f8f78561a32323dc5584ff6e4 Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 17:53:50 -0700 Subject: [PATCH 11/18] Expand game-test and unit-test coverage Add game tests for level, server, item, and player events (tick, explosion, command, item drop/right-click/entity-interact/pickup) reusing TestRuntime + assertThat, plus an assertThat(Iterable) overload. Add pure-logic unit tests for util classes (ID, Tristate, TimeJS, TickDuration, IntBounds, CountingMap, Object2LongEntry, ErrorStack, TinyMap). Bump the changelog PR numbers to #1153. --- CHANGELOG.md | 3 +- .../mods/kubejs/testmod/TestRuntime.java | 5 ++ .../kubejs/testmod/item/ItemEventTests.java | 82 +++++++++++++++++++ .../kubejs/testmod/level/LevelEventTests.java | 42 ++++++++++ .../testmod/player/PlayerEventTests.java | 23 ++++++ .../testmod/server/ServerEventTests.java | 35 ++++++++ .../mods/kubejs/unittest/CountingMapTest.java | 39 +++++++++ .../mods/kubejs/unittest/ErrorStackTest.java | 45 ++++++++++ .../latvian/mods/kubejs/unittest/IDTest.java | 56 +++++++++++++ .../mods/kubejs/unittest/IntBoundsTest.java | 29 +++++++ .../kubejs/unittest/Object2LongEntryTest.java | 23 ++++++ .../kubejs/unittest/TickDurationTest.java | 44 ++++++++++ .../mods/kubejs/unittest/TimeJSTest.java | 37 +++++++++ .../mods/kubejs/unittest/TinyMapTest.java | 28 +++++++ .../mods/kubejs/unittest/TristateTest.java | 42 ++++++++++ src/test/kubejs/server_scripts/item_events.js | 20 +++++ .../kubejs/server_scripts/level_events.js | 16 ++++ .../kubejs/server_scripts/player_events.js | 4 + .../kubejs/server_scripts/server_events.js | 9 ++ 19 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java create mode 100644 src/test/kubejs/server_scripts/item_events.js create mode 100644 src/test/kubejs/server_scripts/level_events.js create mode 100644 src/test/kubejs/server_scripts/player_events.js create mode 100644 src/test/kubejs/server_scripts/server_events.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e4efef436..e1a8df41c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ - Fix dedicated server crash from block tint function client types (#1159) - Fix `DetectorBlock` failing to register (and crashing world load) because its block id was never set (#1153) - Add game tests covering block and entity events (#1153) -- Add typed script-side `TestRuntime.assertThat` entry points and game tests asserting on event objects (#NNNN) +- Add typed script-side `TestRuntime.assertThat` entry points and game tests asserting on event objects (#1153) +- Expand game-test and unit-test coverage: level/server/item/player events and util classes (#1153) ## [8.0.3] - 2026-06-22 diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java index 4f5b7ad26..43429ee6a 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java @@ -8,6 +8,7 @@ import org.assertj.core.api.AbstractDoubleAssert; import org.assertj.core.api.AbstractStringAssert; import org.assertj.core.api.Assertions; +import org.assertj.core.api.IterableAssert; import org.assertj.core.api.ObjectAssert; import org.jspecify.annotations.Nullable; @@ -102,6 +103,10 @@ public static LevelBlockAssert assertThat(LevelBlock actual) { return new LevelBlockAssert(actual); } + public static IterableAssert assertThat(Iterable actual) { + return Assertions.assertThat(actual); + } + public static ObjectAssert assertThat(@Nullable Object actual) { return Assertions.assertThat(actual); } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java new file mode 100644 index 000000000..e95350dfa --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java @@ -0,0 +1,82 @@ +package dev.latvian.mods.kubejs.testmod.item; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.GameType; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +@ForEachTest(groups = "kubejs.item.event") +public class ItemEventTests { + private static final BlockPos POS = new BlockPos(1, 2, 1); + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "item_dropped", description = "KubeJS ItemEvents.dropped fires when a player drops an item") + static void itemDropped(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("item.dropped")) + .thenExecute(player -> player.drop(new ItemStack(Items.DIAMOND), false)) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.dropped"), "script did not assert on item.dropped")) + .thenExecute(() -> TestRuntime.verify("item.dropped")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "item_right_clicked", description = "KubeJS ItemEvents.rightClicked fires when a player right-clicks with an item") + static void itemRightClicked(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("item.rightClicked")) + .thenExecute(player -> { + var stack = new ItemStack(Items.STICK); + player.setItemInHand(InteractionHand.MAIN_HAND, stack); + player.gameMode.useItem(player, player.level(), stack, InteractionHand.MAIN_HAND); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.rightClicked"), "script did not report item.rightClicked")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "item_entity_interacted", description = "KubeJS ItemEvents.entityInteracted fires when a player interacts with an entity") + static void itemEntityInteracted(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("item.entityInteracted")) + .thenExecute(player -> { + player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.STICK)); + var pig = helper.spawn(EntityType.PIG, POS); + player.interactOn(pig, InteractionHand.MAIN_HAND, pig.position()); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.entityInteracted"), "script did not report item.entityInteracted")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "item_picked_up", description = "KubeJS ItemEvents.canPickUp/pickedUp fire when a player picks up an item") + static void itemPickedUp(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("item.canPickUp", "item.pickedUp")) + .thenExecute(player -> { + var abs = helper.absolutePos(POS); + var entity = new ItemEntity((ServerLevel) player.level(), abs.getX() + 0.5, abs.getY(), abs.getZ() + 0.5, new ItemStack(Items.DIAMOND)); + entity.setNoPickUpDelay(); + player.level().addFreshEntity(entity); + entity.playerTouch(player); + }) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.canPickUp"), "script did not assert on item.canPickUp")) + .thenExecute(() -> TestRuntime.verify("item.canPickUp")) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.pickedUp"), "script did not report item.pickedUp")) + .thenSucceed()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java new file mode 100644 index 000000000..28a5f78df --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java @@ -0,0 +1,42 @@ +package dev.latvian.mods.kubejs.testmod.level; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.Level; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +@ForEachTest(groups = "kubejs.level.event") +public class LevelEventTests { + private static final BlockPos POS = new BlockPos(1, 2, 1); + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "level_tick", description = "KubeJS LevelEvents.tick fires while the level ticks") + static void levelTick(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("level.tick")) + .thenIdle(2) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("level.tick"), "script did not report level.tick")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(value = "5x5x5", floor = true) + @TestHolder(value = "level_explosion", description = "KubeJS LevelEvents.beforeExplosion/afterExplosion fire when a level explodes") + static void levelExplosion(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("level.beforeExplosion", "level.afterExplosion")) + .thenExecute(() -> helper.getLevel().explode(null, null, null, helper.absoluteVec(POS.getCenter()), 3.0F, false, Level.ExplosionInteraction.BLOCK)) + .thenIdle(2) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("level.beforeExplosion"), "script did not assert on level.beforeExplosion")) + .thenExecute(() -> TestRuntime.verify("level.beforeExplosion")) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("level.afterExplosion"), "script did not assert on level.afterExplosion")) + .thenExecute(() -> TestRuntime.verify("level.afterExplosion")) + .thenSucceed()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java new file mode 100644 index 000000000..b809e59d8 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java @@ -0,0 +1,23 @@ +package dev.latvian.mods.kubejs.testmod.player; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.world.level.GameType; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +@ForEachTest(groups = "kubejs.player.event") +public class PlayerEventTests { + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "player_tick", description = "KubeJS PlayerEvents.tick fires while a player ticks") + static void playerTick(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("player.tick")) + .thenIdle(2) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("player.tick"), "script did not report player.tick")) + .thenSucceed()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java new file mode 100644 index 000000000..e57c42c70 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java @@ -0,0 +1,35 @@ +package dev.latvian.mods.kubejs.testmod.server; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.world.level.GameType; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +@ForEachTest(groups = "kubejs.server.event") +public class ServerEventTests { + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "server_tick", description = "KubeJS ServerEvents.tick fires while the server ticks") + static void serverTick(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("server.tick")) + .thenIdle(2) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("server.tick"), "script did not report server.tick")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "server_command", description = "KubeJS ServerEvents.command fires when a command runs") + static void serverCommand(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("server.command")) + .thenExecute(player -> helper.getLevel().getServer().getCommands().performPrefixedCommand(player.createCommandSourceStack(), "help")) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("server.command"), "script did not assert on server.command")) + .thenExecute(() -> TestRuntime.verify("server.command")) + .thenSucceed()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java new file mode 100644 index 000000000..51c354d0f --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java @@ -0,0 +1,39 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.CountingMap; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CountingMapTest { + @Test + void missingKeyCountsAsZero() { + assertThat(new CountingMap().get("x")).isZero(); + } + + @Test + void addAccumulates() { + var map = new CountingMap(); + map.add("x", 3L); + map.add("x", 2L); + assertThat(map.get("x")).isEqualTo(5L); + } + + @Test + void settingToZeroRemovesKey() { + var map = new CountingMap(); + map.set("x", 4L); + map.set("x", 0L); + assertThat(map.getSize()).isZero(); + assertThat(map.get("x")).isZero(); + } + + @Test + void totalCountSumsValues() { + var map = new CountingMap(); + map.set("a", 2L); + map.set("b", 3L); + assertThat(map.getSize()).isEqualTo(2); + assertThat(map.getTotalCount()).isEqualTo(5L); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java new file mode 100644 index 000000000..a1641fb1c --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java @@ -0,0 +1,45 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.ErrorStack; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ErrorStackTest { + @Test + void emptyStackRendersNothing() { + var stack = new ErrorStack(); + assertThat(stack.toString()).isEmpty(); + assertThat(stack.atString()).isEmpty(); + } + + @Test + void nestedKeysRenderInOrder() { + var stack = new ErrorStack(); + stack.push("first"); + stack.setKey("a"); + stack.push("second"); + stack.setKey("b"); + assertThat(stack.toString()).isEqualTo("[a][b]"); + assertThat(stack.atString()).isEqualTo(" @ [a][b]"); + assertThat(stack.stringAt()).isEqualTo("[a][b] @ "); + } + + @Test + void poppingUnwindsTheStack() { + var stack = new ErrorStack(); + stack.push("first"); + stack.setKey("a"); + stack.push("second"); + stack.setKey("b"); + stack.pop(); + assertThat(stack.toString()).isEmpty(); + } + + @Test + void noneIsANoOp() { + ErrorStack.NONE.push("x"); + ErrorStack.NONE.setKey("y"); + assertThat(ErrorStack.NONE.toString()).isEmpty(); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java new file mode 100644 index 000000000..d9ab92716 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java @@ -0,0 +1,56 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.ID; +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class IDTest { + @Test + void namespaceDefaultsToMinecraft() { + assertThat(ID.namespace("mod:thing")).isEqualTo("mod"); + assertThat(ID.namespace("thing")).isEqualTo("minecraft"); + assertThat(ID.namespace(null)).isEqualTo("minecraft"); + assertThat(ID.namespace("")).isEqualTo("minecraft"); + } + + @Test + void pathStripsNamespace() { + assertThat(ID.path("mod:thing")).isEqualTo("thing"); + assertThat(ID.path("thing")).isEqualTo("thing"); + assertThat(ID.path(null)).isEqualTo("air"); + } + + @Test + void stringQualifiesWithMinecraft() { + assertThat(ID.string("air")).isEqualTo("minecraft:air"); + assertThat(ID.string("mod:x")).isEqualTo("mod:x"); + assertThat(ID.string("")).isEmpty(); + } + + @Test + void kjsStringQualifiesWithKubeJS() { + assertThat(ID.kjsString("x")).isEqualTo("kubejs:x"); + assertThat(ID.kjsString("mod:x")).isEqualTo("mod:x"); + } + + @Test + void reduceDropsMinecraftNamespaceOnly() { + assertThat(ID.reduce(Identifier.parse("minecraft:stone"))).isEqualTo("stone"); + assertThat(ID.reduce(Identifier.parse("mod:stone"))).isEqualTo("mod:stone"); + } + + @Test + void resourcePathFlattensForeignNamespace() { + assertThat(ID.resourcePath(Identifier.parse("minecraft:stone"))).isEqualTo("stone"); + assertThat(ID.resourcePath(Identifier.parse("mod:stone"))).isEqualTo("mod/stone"); + } + + @Test + void isValidKeyRejectsMalformedStrings() { + assertThat(ID.isValidKey("minecraft:stone")).isTrue(); + assertThat(ID.isValidKey("bad id!")).isFalse(); + assertThat(ID.isValidKey(42)).isFalse(); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java new file mode 100644 index 000000000..caf41bbf4 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java @@ -0,0 +1,29 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.IntBounds; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class IntBoundsTest { + @Test + void commonBoundsAreInterned() { + assertThat(IntBounds.of(1, Integer.MAX_VALUE)).isSameAs(IntBounds.DEFAULT); + assertThat(IntBounds.of(0, Integer.MAX_VALUE)).isSameAs(IntBounds.OPTIONAL); + } + + @Test + void otherBoundsAreDistinct() { + var bounds = IntBounds.of(2, 5); + assertThat(bounds).isNotSameAs(IntBounds.DEFAULT).isNotSameAs(IntBounds.OPTIONAL); + assertThat(bounds.min()).isEqualTo(2); + assertThat(bounds.max()).isEqualTo(5); + } + + @Test + void internedBoundsExposeExpectedValues() { + assertThat(IntBounds.DEFAULT.min()).isEqualTo(1); + assertThat(IntBounds.OPTIONAL.min()).isEqualTo(0); + assertThat(IntBounds.DEFAULT.max()).isEqualTo(Integer.MAX_VALUE); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java new file mode 100644 index 000000000..a885159e0 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java @@ -0,0 +1,23 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.Object2LongEntry; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class Object2LongEntryTest { + @Test + void higherValueSortsFirst() { + var high = new Object2LongEntry("a", 5L); + var low = new Object2LongEntry("b", 3L); + assertThat(high).isLessThan(low); + assertThat(low).isGreaterThan(high); + } + + @Test + void equalValuesTieByCaseInsensitiveKey() { + var apple = new Object2LongEntry("apple", 5L); + var banana = new Object2LongEntry("Banana", 5L); + assertThat(apple).isLessThan(banana); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java new file mode 100644 index 000000000..84ad0e852 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java @@ -0,0 +1,44 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.TickDuration; +import dev.latvian.mods.kubejs.util.TickTemporalUnit; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TickDurationTest { + @Test + void zeroIsInterned() { + assertThat(TickDuration.of(0L)).isSameAs(TickDuration.ZERO); + assertThat(TickDuration.of(5L).ticks()).isEqualTo(5L); + } + + @Test + void intTicksClampsToIntRange() { + assertThat(TickDuration.of(Long.MAX_VALUE).intTicks()).isEqualTo(Integer.MAX_VALUE); + assertThat(TickDuration.of(7L).intTicks()).isEqualTo(7); + } + + @Test + void getReturnsTicksOnlyForTickUnit() { + var d = TickDuration.of(5L); + assertThat(d.get(TickTemporalUnit.INSTANCE)).isEqualTo(5L); + assertThat(d.get(ChronoUnit.SECONDS)).isEqualTo(0L); + } + + @Test + void tickUnitIsFiftyMillis() { + assertThat(TickTemporalUnit.INSTANCE.getDuration()).isEqualTo(Duration.ofMillis(50L)); + assertThat(TickTemporalUnit.INSTANCE.isTimeBased()).isTrue(); + assertThat(TickTemporalUnit.INSTANCE.isDateBased()).isFalse(); + } + + @Test + void betweenDividesMillisByFifty() { + assertThat(TickTemporalUnit.INSTANCE.between(Instant.EPOCH, Instant.ofEpochMilli(1000L))).isEqualTo(20L); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java new file mode 100644 index 000000000..eef14f5d1 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java @@ -0,0 +1,37 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.TimeJS; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TimeJSTest { + @Test + void readsSimpleUnits() { + assertThat(TimeJS.readDuration("1s").result()).contains(Duration.ofSeconds(1)); + assertThat(TimeJS.readDuration("500ms").result()).contains(Duration.ofMillis(500)); + } + + @Test + void readsTicksAsFiftyMillisEach() { + assertThat(TimeJS.readDuration("20t").result()).contains(Duration.ofSeconds(1)); + } + + @Test + void readsCompoundDuration() { + assertThat(TimeJS.readDuration("1h30m").result()).contains(Duration.ofMinutes(90)); + } + + @Test + void rejectsGarbage() { + assertThat(TimeJS.readDuration("garbage").result()).isEmpty(); + } + + @Test + void msToStringSwitchesUnitAtOneSecond() { + assertThat(TimeJS.msToString(500L)).isEqualTo("500 ms"); + assertThat(TimeJS.msToString(1500L)).isEqualTo("1.500 s"); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java new file mode 100644 index 000000000..3b34e35a8 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java @@ -0,0 +1,28 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.TinyMap; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TinyMapTest { + @Test + void roundTripsThroughMap() { + var source = Map.of("a", 1, "b", 2); + assertThat(TinyMap.ofMap(source).toMap()).isEqualTo(source); + } + + @Test + void emptyMapIsEmpty() { + assertThat(TinyMap.ofMap(Map.of()).isEmpty()).isTrue(); + } + + @Test + void copyConstructorPreservesEntries() { + var original = TinyMap.ofMap(Map.of("a", 1, "b", 2)); + var copy = new TinyMap<>(original); + assertThat(copy.toMap()).isEqualTo(original.toMap()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java new file mode 100644 index 000000000..264962b79 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java @@ -0,0 +1,42 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.Tristate; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TristateTest { + @Test + void wrapCoercesCommonValues() { + assertThat(Tristate.wrap(null)).isEqualTo(Tristate.DEFAULT); + assertThat(Tristate.wrap(true)).isEqualTo(Tristate.TRUE); + assertThat(Tristate.wrap(false)).isEqualTo(Tristate.FALSE); + assertThat(Tristate.wrap(Tristate.TRUE)).isEqualTo(Tristate.TRUE); + } + + @Test + void wrapParsesStringsCaseInsensitively() { + assertThat(Tristate.wrap("TRUE")).isEqualTo(Tristate.TRUE); + assertThat(Tristate.wrap("false")).isEqualTo(Tristate.FALSE); + assertThat(Tristate.wrap("garbage")).isEqualTo(Tristate.DEFAULT); + } + + @Test + void defaultAlwaysPasses() { + assertThat(Tristate.DEFAULT.test(true)).isTrue(); + assertThat(Tristate.DEFAULT.test(false)).isTrue(); + } + + @Test + void trueAndFalseMatchTheirState() { + assertThat(Tristate.TRUE.test(true)).isTrue(); + assertThat(Tristate.TRUE.test(false)).isFalse(); + assertThat(Tristate.FALSE.test(false)).isTrue(); + assertThat(Tristate.FALSE.test(true)).isFalse(); + } + + @Test + void serializedNameMatchesToken() { + assertThat(Tristate.DEFAULT.getSerializedName()).isEqualTo("default"); + } +} diff --git a/src/test/kubejs/server_scripts/item_events.js b/src/test/kubejs/server_scripts/item_events.js new file mode 100644 index 000000000..713e14644 --- /dev/null +++ b/src/test/kubejs/server_scripts/item_events.js @@ -0,0 +1,20 @@ +// Game-test fixtures for ItemEventTests. Listeners are untargeted; each @GameTest clears its +// marker before driving the interaction that should fire the event. + +ItemEvents.dropped(event => { + TestRuntime.check('item.dropped', () => { + TestRuntime.assertThat(event.item.id).isEqualTo('minecraft:diamond'); + }); +}); + +ItemEvents.rightClicked(event => TestRuntime.pass('item.rightClicked')); + +ItemEvents.entityInteracted(event => TestRuntime.pass('item.entityInteracted')); + +ItemEvents.canPickUp(event => { + TestRuntime.check('item.canPickUp', () => { + TestRuntime.assertThat(event.item.id).isEqualTo('minecraft:diamond'); + }); +}); + +ItemEvents.pickedUp(event => TestRuntime.pass('item.pickedUp')); diff --git a/src/test/kubejs/server_scripts/level_events.js b/src/test/kubejs/server_scripts/level_events.js new file mode 100644 index 000000000..6cc099c3a --- /dev/null +++ b/src/test/kubejs/server_scripts/level_events.js @@ -0,0 +1,16 @@ +// Game-test fixtures for LevelEventTests. Listeners are untargeted (the events support but don't +// require a dimension target); each @GameTest clears its marker before driving the action. + +LevelEvents.tick(event => TestRuntime.pass('level.tick')); + +LevelEvents.beforeExplosion(event => { + TestRuntime.check('level.beforeExplosion', () => { + TestRuntime.assertThat(event.size).isGreaterThan(0); + }); +}); + +LevelEvents.afterExplosion(event => { + TestRuntime.check('level.afterExplosion', () => { + TestRuntime.assertThat(event.affectedBlocks).isNotEmpty(); + }); +}); diff --git a/src/test/kubejs/server_scripts/player_events.js b/src/test/kubejs/server_scripts/player_events.js new file mode 100644 index 000000000..488261c94 --- /dev/null +++ b/src/test/kubejs/server_scripts/player_events.js @@ -0,0 +1,4 @@ +// Game-test fixtures for PlayerEventTests. tick is untargeted. (Stage/inventory/advancement events +// aren't cleanly drivable from a headless game test, so they're not covered here.) + +PlayerEvents.tick(event => TestRuntime.pass('player.tick')); diff --git a/src/test/kubejs/server_scripts/server_events.js b/src/test/kubejs/server_scripts/server_events.js new file mode 100644 index 000000000..dfb992494 --- /dev/null +++ b/src/test/kubejs/server_scripts/server_events.js @@ -0,0 +1,9 @@ +// Game-test fixtures for ServerEventTests. tick is untargeted; command targets the command name. + +ServerEvents.tick(event => TestRuntime.pass('server.tick')); + +ServerEvents.command('help', event => { + TestRuntime.check('server.command', () => { + TestRuntime.assertThat(event.commandName).isEqualTo('help'); + }); +}); From 70d1bdcaee975b3060eb01d064ae11f4161cbf60 Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 18:32:53 -0700 Subject: [PATCH 12/18] Add script-API coverage gate and bootstrapped unit-test harness Add a coverageGate Gradle task that measures coverage over the script-exposed API surface (partitioned into reachable / client-only / excluded-internal), prints the top uncovered reachable classes, and fails below a configurable threshold (-PscriptCoverageGate). Add a BootstrapExtension so unit tests can exercise registry-backed script API without a game server, and unit tests for wrappers (Item/Color/NBT/Misc/Text), recipe components, and util (JsonUtils/UtilsJS/NBTUtils). Raises reachable script-API coverage from 21.8% to 24.8%. --- CHANGELOG.md | 1 + build.gradle.kts | 67 +++++++ .../kubejs/unittest/BooleanComponentTest.java | 58 ++++++ .../kubejs/unittest/BootstrapExtension.java | 26 +++ .../unittest/CharacterComponentTest.java | 38 ++++ .../kubejs/unittest/ColorWrapperTest.java | 37 ++++ .../mods/kubejs/unittest/ItemWrapperTest.java | 33 ++++ .../mods/kubejs/unittest/JsonUtilsTest.java | 137 ++++++++++++++ .../kubejs/unittest/MiscWrappersTest.java | 102 ++++++++++ .../mods/kubejs/unittest/NBTUtilsTest.java | 92 +++++++++ .../mods/kubejs/unittest/NBTWrapperTest.java | 177 ++++++++++++++++++ .../kubejs/unittest/NumberComponentTest.java | 107 +++++++++++ .../kubejs/unittest/StringComponentTest.java | 53 ++++++ .../mods/kubejs/unittest/TextWrapperTest.java | 105 +++++++++++ .../mods/kubejs/unittest/UtilsJSTest.java | 86 +++++++++ 15 files changed, 1119 insertions(+) create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/MiscWrappersTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/NBTUtilsTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/NBTWrapperTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a8df41c..9e77df870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Add game tests covering block and entity events (#1153) - Add typed script-side `TestRuntime.assertThat` entry points and game tests asserting on event objects (#1153) - Expand game-test and unit-test coverage: level/server/item/player events and util classes (#1153) +- Add a script-exposed-API coverage gate (`coverageGate`) and a bootstrapped JUnit harness; expand unit coverage of wrappers, recipe components, and util (#1153) ## [8.0.3] - 2026-06-22 diff --git a/build.gradle.kts b/build.gradle.kts index 404cbee33..28613d5fc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -240,6 +240,73 @@ val coverageSummaryJson by tasks.registering { } } +// Coverage gate over the *script-exposed* API surface (what KubeJS scripts can call), as opposed to +// the whole mod. Packages are partitioned into: excluded internals (mixins, datagen, networking, the +// local web server, mod integrations, the script engine itself, command/tag internals), a client-only +// sub-surface (only reachable by a client run), and the reachable subset that server/unit tests can +// exercise. The gate enforces a minimum on the reachable subset and prints the biggest gaps so the +// test-writing loop knows what to target next. Override the threshold with -PscriptCoverageGate=<0..1>. +val scriptCoverageGate = (findProperty("scriptCoverageGate") as String?)?.toDouble() ?: 0.40 + +fun coveragePackageExcluded(pkg: String): Boolean { + val internal = listOf(".core.mixin", ".generator", ".error", ".misc", ".command", ".server.tag", ".integration.gamestages") + return internal.any { pkg.contains(it) } || + pkg == "dev.latvian.mods.kubejs.net" || + pkg.startsWith("dev.latvian.mods.kubejs.web") || + pkg.startsWith("dev.latvian.mods.kubejs.script") +} + +fun coveragePackageClientOnly(pkg: String): Boolean = + pkg.startsWith("dev.latvian.mods.kubejs.client") || + pkg.startsWith("dev.latvian.mods.kubejs.gui") || + pkg.startsWith("dev.latvian.mods.kubejs.integration.jei") + +val coverageGate by tasks.registering { + group = "verification" + description = "Fails if reachable script-exposed API coverage is below the gate; prints the split and top uncovered classes." + dependsOn(coverageReport) + val csvFile = layout.buildDirectory.file("reports/coverage/coverage.csv") + inputs.file(csvFile) + val gate = scriptCoverageGate + doLast { + var rc = 0L; var rm = 0L // reachable covered / missed + var cc = 0L; var cm = 0L // client-only covered / missed + val uncovered = mutableListOf>() // class, missed, covered (reachable) + + csvFile.get().asFile.readLines().drop(1).filter { it.isNotBlank() }.forEach { row -> + val c = row.split(',') + if (c.size < 9) return@forEach + val pkg = c[1] + if (coveragePackageExcluded(pkg)) return@forEach + val missed = c[3].toLong(); val covered = c[4].toLong() + if (coveragePackageClientOnly(pkg)) { + cc += covered; cm += missed + } else { + rc += covered; rm += missed + if (missed > 0) uncovered.add(Triple("${pkg.removePrefix("dev.latvian.mods.kubejs.")}.${c[2]}", missed, covered)) + } + } + + fun pct(covered: Long, total: Long) = if (total == 0L) 0.0 else covered * 100.0 / total + val reachPct = pct(rc, rc + rm) + + logger.lifecycle("Script-exposed API coverage (instructions):") + logger.lifecycle(String.format(Locale.ROOT, " reachable : %.2f%% (%d / %d)", reachPct, rc, rc + rm)) + logger.lifecycle(String.format(Locale.ROOT, " client-only : %.2f%% (%d / %d)", pct(cc, cc + cm), cc, cc + cm)) + logger.lifecycle(String.format(Locale.ROOT, " full : %.2f%% (%d / %d)", pct(rc + cc, rc + rm + cc + cm), rc + cc, rc + rm + cc + cm)) + logger.lifecycle("Top uncovered reachable classes (missed instructions):") + uncovered.sortedByDescending { it.second }.take(25).forEach { + logger.lifecycle(String.format(Locale.ROOT, " %-7d %s", it.second, it.first)) + } + + val gatePct = gate * 100.0 + if (reachPct < gatePct) { + throw GradleException(String.format(Locale.ROOT, "Reachable script-exposed coverage %.2f%% is below the gate %.2f%%", reachPct, gatePct)) + } + logger.lifecycle(String.format(Locale.ROOT, "Coverage gate passed: %.2f%% >= %.2f%%", reachPct, gatePct)) + } +} + publishing { repositories { val mavenUrl = System.getenv("MAVEN_URL") ?: return@repositories diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java new file mode 100644 index 000000000..8ed1919fb --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java @@ -0,0 +1,58 @@ +package dev.latvian.mods.kubejs.unittest; + +import com.google.gson.JsonPrimitive; +import com.mojang.serialization.Codec; +import dev.latvian.mods.kubejs.recipe.component.BooleanComponent; +import dev.latvian.mods.rhino.type.TypeInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(BootstrapExtension.class) +public class BooleanComponentTest { + @Test + void wrapReadsBooleanValues() { + assertThat(BooleanComponent.BOOLEAN.wrap(null, true)).isTrue(); + assertThat(BooleanComponent.BOOLEAN.wrap(null, false)).isFalse(); + } + + @Test + void wrapReadsJsonPrimitive() { + assertThat(BooleanComponent.BOOLEAN.wrap(null, new JsonPrimitive(true))).isTrue(); + assertThat(BooleanComponent.BOOLEAN.wrap(null, new JsonPrimitive(false))).isFalse(); + } + + @Test + void wrapParsesStrings() { + assertThat(BooleanComponent.BOOLEAN.wrap(null, "true")).isTrue(); + assertThat(BooleanComponent.BOOLEAN.wrap(null, "TRUE")).isTrue(); + assertThat(BooleanComponent.BOOLEAN.wrap(null, "false")).isFalse(); + assertThat(BooleanComponent.BOOLEAN.wrap(null, "yes")).isFalse(); + } + + @Test + void wrapRejectsOtherTypes() { + assertThatThrownBy(() -> BooleanComponent.BOOLEAN.wrap(null, 5)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> BooleanComponent.BOOLEAN.wrap(null, null)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void hasPriorityOnlyForBooleans() { + assertThat(BooleanComponent.BOOLEAN.hasPriority(null, true)).isTrue(); + assertThat(BooleanComponent.BOOLEAN.hasPriority(null, new JsonPrimitive(true))).isTrue(); + assertThat(BooleanComponent.BOOLEAN.hasPriority(null, "true")).isFalse(); + assertThat(BooleanComponent.BOOLEAN.hasPriority(null, new JsonPrimitive("x"))).isFalse(); + assertThat(BooleanComponent.BOOLEAN.hasPriority(null, 5)).isFalse(); + } + + @Test + void metadataIsStable() { + assertThat(BooleanComponent.BOOLEAN.toString()).isEqualTo("boolean"); + assertThat(BooleanComponent.BOOLEAN.typeInfo()).isEqualTo(TypeInfo.BOOLEAN); + assertThat(BooleanComponent.BOOLEAN.codec()).isSameAs(Codec.BOOL); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java b/src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java new file mode 100644 index 000000000..e292b1ed3 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java @@ -0,0 +1,26 @@ +package dev.latvian.mods.kubejs.unittest; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +/// JUnit 5 extension that boots Minecraft's registries once (process-wide) so unit tests can exercise +/// registry-backed script API - wrappers, builders, codecs - without standing up a game server. +/// Apply with {@code @ExtendWith(BootstrapExtension.class)}. +public class BootstrapExtension implements BeforeAllCallback { + private static volatile boolean bootstrapped = false; + + @Override + public void beforeAll(ExtensionContext context) { + if (!bootstrapped) { + synchronized (BootstrapExtension.class) { + if (!bootstrapped) { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + bootstrapped = true; + } + } + } + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java new file mode 100644 index 000000000..e6f87ddcb --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java @@ -0,0 +1,38 @@ +package dev.latvian.mods.kubejs.unittest; + +import com.google.gson.JsonPrimitive; +import dev.latvian.mods.kubejs.recipe.component.CharacterComponent; +import dev.latvian.mods.rhino.type.TypeInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(BootstrapExtension.class) +public class CharacterComponentTest { + @Test + void hasPriorityForCharacterLikeValues() { + assertThat(CharacterComponent.CHARACTER.hasPriority(null, 'a')).isTrue(); + assertThat(CharacterComponent.CHARACTER.hasPriority(null, "a")).isTrue(); + assertThat(CharacterComponent.CHARACTER.hasPriority(null, new JsonPrimitive("a"))).isTrue(); + assertThat(CharacterComponent.CHARACTER.hasPriority(null, 5)).isFalse(); + assertThat(CharacterComponent.CHARACTER.hasPriority(null, new JsonPrimitive(5))).isFalse(); + } + + @Test + void isEmptyOnlyForNullCharacter() { + assertThat(CharacterComponent.CHARACTER.isEmpty('\0')).isTrue(); + assertThat(CharacterComponent.CHARACTER.isEmpty('a')).isFalse(); + } + + @Test + void displayStringWrapsInSingleQuotes() { + assertThat(CharacterComponent.CHARACTER.toString(null, 'a')).isEqualTo("'a'"); + } + + @Test + void metadataIsStable() { + assertThat(CharacterComponent.CHARACTER.typeInfo()).isEqualTo(TypeInfo.CHARACTER); + assertThat(CharacterComponent.CHARACTER.toString()).contains("character"); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java new file mode 100644 index 000000000..6817a18c2 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java @@ -0,0 +1,37 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.plugin.builtin.wrapper.ColorWrapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ColorWrapperTest { + @Test + void nullAndBlankMapToNone() { + assertThat(ColorWrapper.wrap(null)).isSameAs(ColorWrapper.NONE); + assertThat(ColorWrapper.wrap("")).isSameAs(ColorWrapper.NONE); + assertThat(ColorWrapper.wrap("transparent")).isSameAs(ColorWrapper.NONE); + } + + @Test + void unknownStringMapsToNone() { + assertThat(ColorWrapper.wrap("this_is_not_a_color")).isSameAs(ColorWrapper.NONE); + } + + @Test + void hexStringMakesAColor() { + assertThat(ColorWrapper.wrap("#abcdef")).isNotSameAs(ColorWrapper.NONE); + assertThat(ColorWrapper.wrap("#80abcdef")).isNotSameAs(ColorWrapper.NONE); + } + + @Test + void nonZeroNumberMakesAColor() { + assertThat(ColorWrapper.wrap(0x123456)).isNotSameAs(ColorWrapper.NONE); + assertThat(ColorWrapper.wrap(0)).isSameAs(ColorWrapper.NONE); + } + + @Test + void rgbaBuildsColor() { + assertThat(ColorWrapper.rgba(255, 0, 0, 255)).isNotNull(); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java new file mode 100644 index 000000000..c04a13142 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java @@ -0,0 +1,33 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.plugin.builtin.wrapper.ItemWrapper; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.Items; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(BootstrapExtension.class) +public class ItemWrapperTest { + @Test + void findsVanillaItem() { + var result = ItemWrapper.findItem("minecraft:diamond"); + assertThat(result.result()).isPresent(); + assertThat(result.result().get()).isSameAs(Items.DIAMOND); + } + + @Test + void getIdRoundTrips() { + assertThat(ItemWrapper.getId(Items.DIAMOND)).isEqualTo(Identifier.parse("minecraft:diamond")); + assertThat(ItemWrapper.getItem(Identifier.parse("minecraft:diamond"))).isSameAs(Items.DIAMOND); + } + + @Test + void existsAndIsItem() { + assertThat(ItemWrapper.exists(Identifier.parse("minecraft:diamond"))).isTrue(); + assertThat(ItemWrapper.exists(Identifier.parse("minecraft:definitely_not_an_item"))).isFalse(); + assertThat(ItemWrapper.isItem("not an item object")).isFalse(); + assertThat(ItemWrapper.isItem(Items.DIAMOND)).isFalse(); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java new file mode 100644 index 000000000..7fd0e4143 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java @@ -0,0 +1,137 @@ +package dev.latvian.mods.kubejs.unittest; + +import com.google.gson.JsonArray; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import dev.latvian.mods.kubejs.util.JsonUtils; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JsonUtilsTest { + @Test + void copyReturnsJsonNullForNull() { + assertThat(JsonUtils.copy(null)).isSameAs(JsonNull.INSTANCE); + assertThat(JsonUtils.copy(JsonNull.INSTANCE)).isSameAs(JsonNull.INSTANCE); + } + + @Test + void copyReturnsSamePrimitiveInstance() { + var prim = new JsonPrimitive(42); + assertThat(JsonUtils.copy(prim)).isSameAs(prim); + } + + @Test + void copyDeepCopiesContainersIndependently() { + var inner = new JsonArray(); + inner.add(1); + + var obj = new JsonObject(); + obj.add("list", inner); + + var copy = JsonUtils.copy(obj).getAsJsonObject(); + + assertThat(copy).isEqualTo(obj); + assertThat(copy).isNotSameAs(obj); + + inner.add(2); + + assertThat(copy.getAsJsonArray("list").size()).isEqualTo(1); + assertThat(obj.getAsJsonArray("list").size()).isEqualTo(2); + } + + @Test + void toObjectMapsNullAndJsonNullToNull() { + assertThat(JsonUtils.toObject(null)).isNull(); + assertThat(JsonUtils.toObject(JsonNull.INSTANCE)).isNull(); + } + + @Test + void toObjectConvertsObjectToMap() { + var obj = new JsonObject(); + obj.addProperty("a", 1); + obj.addProperty("b", "x"); + + var result = JsonUtils.toObject(obj); + + assertThat(result).isInstanceOf(Map.class); + var map = (Map) result; + assertThat(map).containsOnlyKeys("a", "b"); + assertThat(((Number) map.get("a")).intValue()).isEqualTo(1); + assertThat(map.get("b")).isEqualTo("x"); + } + + @Test + void toObjectConvertsArrayToList() { + var array = new JsonArray(); + array.add(1); + array.add(2); + + var result = JsonUtils.toObject(array); + + assertThat(result).isInstanceOf(List.class); + assertThat((List) result).hasSize(2); + } + + @Test + void toStringSerializesCompactAndKeepsHtmlChars() { + assertThat(JsonUtils.toString(JsonNull.INSTANCE)).isEqualTo("null"); + assertThat(JsonUtils.toString(new JsonPrimitive("a())).isTrue(); + assertThat(NBTWrapper.isTagCompound(new JsonObject())).isTrue(); + } + + @Test + void isTagCompoundRejectsOtherValues() { + assertThat(NBTWrapper.isTagCompound(5)).isFalse(); + assertThat(NBTWrapper.isTagCompound(new ListTag())).isFalse(); + assertThat(NBTWrapper.isTagCompound(IntTag.valueOf(1))).isFalse(); + } + + @Test + void isTagCollectionAcceptsCollectionLikeValues() { + assertThat(NBTWrapper.isTagCollection(null)).isTrue(); + assertThat(NBTWrapper.isTagCollection("a string")).isTrue(); + assertThat(NBTWrapper.isTagCollection(List.of(1, 2))).isTrue(); + assertThat(NBTWrapper.isTagCollection(new JsonArray())).isTrue(); + } + + @Test + void isTagCollectionRejectsOtherValues() { + assertThat(NBTWrapper.isTagCollection(new CompoundTag())).isFalse(); + assertThat(NBTWrapper.isTagCollection(5)).isFalse(); + assertThat(NBTWrapper.isTagCollection(new HashMap<>())).isFalse(); + } + + @Test + void fromTagHandlesNullAndEnd() { + assertThat(NBTWrapper.fromTag(null)).isNull(); + assertThat(NBTWrapper.fromTag(EndTag.INSTANCE)).isNull(); + } + + @Test + void fromTagUnwrapsScalars() { + assertThat(NBTWrapper.fromTag(StringTag.valueOf("hello"))).isEqualTo("hello"); + assertThat(NBTWrapper.fromTag(IntTag.valueOf(5))).isEqualTo(5); + } + + @Test + void fromTagUnwrapsEmptyCompoundAndList() { + assertThat(NBTWrapper.fromTag(new CompoundTag())).isEqualTo(Map.of()); + assertThat(NBTWrapper.fromTag(new ListTag())).isEqualTo(List.of()); + } + + @Test + void fromTagUnwrapsPopulatedCompound() { + var tag = new CompoundTag(); + tag.put("s", StringTag.valueOf("v")); + tag.put("n", IntTag.valueOf(7)); + + var result = NBTWrapper.fromTag(tag); + assertThat(result).isInstanceOf(Map.class); + assertThat((Map) result).containsEntry("s", "v").containsEntry("n", 7); + } + + @Test + void fromTagUnwrapsPopulatedList() { + var list = new ListTag(); + list.add(StringTag.valueOf("a")); + list.add(StringTag.valueOf("b")); + + var result = NBTWrapper.fromTag(list); + assertThat(result).isInstanceOf(List.class); + assertThat((List) result).containsExactly("a", "b"); + } + + @Test + void toTagIsIdentity() { + var tag = IntTag.valueOf(3); + assertThat(NBTWrapper.toTag(tag)).isSameAs(tag); + assertThat(NBTWrapper.toTag(null)).isNull(); + } + + @Test + void scalarBuildersProduceMatchingTags() { + assertThat(NBTWrapper.byteTag((byte) 5)).isInstanceOf(ByteTag.class).isEqualTo(ByteTag.valueOf((byte) 5)); + assertThat(NBTWrapper.b((byte) 5)).isEqualTo(ByteTag.valueOf((byte) 5)); + assertThat(NBTWrapper.shortTag((short) 6)).isInstanceOf(ShortTag.class).isEqualTo(ShortTag.valueOf((short) 6)); + assertThat(NBTWrapper.s((short) 6)).isEqualTo(ShortTag.valueOf((short) 6)); + assertThat(NBTWrapper.intTag(7)).isInstanceOf(IntTag.class).isEqualTo(IntTag.valueOf(7)); + assertThat(NBTWrapper.i(7)).isEqualTo(IntTag.valueOf(7)); + assertThat(NBTWrapper.longTag(8L)).isInstanceOf(LongTag.class).isEqualTo(LongTag.valueOf(8L)); + assertThat(NBTWrapper.l(8L)).isEqualTo(LongTag.valueOf(8L)); + assertThat(NBTWrapper.floatTag(1.5F)).isInstanceOf(FloatTag.class).isEqualTo(FloatTag.valueOf(1.5F)); + assertThat(NBTWrapper.f(1.5F)).isEqualTo(FloatTag.valueOf(1.5F)); + assertThat(NBTWrapper.doubleTag(2.5)).isInstanceOf(DoubleTag.class).isEqualTo(DoubleTag.valueOf(2.5)); + assertThat(NBTWrapper.d(2.5)).isEqualTo(DoubleTag.valueOf(2.5)); + assertThat(NBTWrapper.stringTag("x")).isInstanceOf(StringTag.class).isEqualTo(StringTag.valueOf("x")); + } + + @Test + void arrayBuildersProduceMatchingTags() { + assertThat(NBTWrapper.intArrayTag(new int[]{1, 2, 3})).isInstanceOf(IntArrayTag.class); + assertThat(NBTWrapper.ia(new int[]{1, 2, 3})).isInstanceOf(IntArrayTag.class); + assertThat(NBTWrapper.longArrayTag(new long[]{1L, 2L})).isInstanceOf(LongArrayTag.class); + assertThat(NBTWrapper.la(new long[]{1L, 2L})).isInstanceOf(LongArrayTag.class); + assertThat(NBTWrapper.byteArrayTag(new byte[]{1, 2})).isInstanceOf(ByteArrayTag.class); + assertThat(NBTWrapper.ba(new byte[]{1, 2})).isInstanceOf(ByteArrayTag.class); + } + + @Test + void emptyContainerBuilders() { + assertThat(NBTWrapper.compoundTag()).isInstanceOf(CompoundTag.class); + assertThat(NBTWrapper.listTag()).isInstanceOf(ListTag.class); + } + + @Test + void toJsonConvertsTags() { + assertThat(NBTWrapper.toJson(null)).isEqualTo(JsonNull.INSTANCE); + assertThat(NBTWrapper.toJson(EndTag.INSTANCE)).isEqualTo(JsonNull.INSTANCE); + + var stringJson = NBTWrapper.toJson(StringTag.valueOf("x")); + assertThat(stringJson).isInstanceOf(JsonPrimitive.class); + assertThat(stringJson.getAsString()).isEqualTo("x"); + + var intJson = NBTWrapper.toJson(IntTag.valueOf(5)); + assertThat(intJson).isInstanceOf(JsonPrimitive.class); + assertThat(intJson.getAsInt()).isEqualTo(5); + } + + @Test + void toJsonRoundTripsCompound() { + var tag = new CompoundTag(); + tag.put("k", StringTag.valueOf("v")); + + var json = NBTWrapper.toJson(tag); + assertThat(json).isInstanceOf(JsonObject.class); + assertThat(json.getAsJsonObject().get("k").getAsString()).isEqualTo("v"); + } + + @Test + void toJsonHandlesList() { + var list = new ListTag(); + list.add(IntTag.valueOf(1)); + list.add(IntTag.valueOf(2)); + + Tag tag = list; + var json = NBTWrapper.toJson(tag); + assertThat(json).isInstanceOf(JsonArray.class); + assertThat(json.getAsJsonArray()).hasSize(2); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java new file mode 100644 index 000000000..251a456d1 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java @@ -0,0 +1,107 @@ +package dev.latvian.mods.kubejs.unittest; + +import com.google.gson.JsonPrimitive; +import dev.latvian.mods.kubejs.recipe.component.NumberComponent; +import dev.latvian.mods.rhino.type.TypeInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(BootstrapExtension.class) +public class NumberComponentTest { + @Test + void constantsCoverFullRange() { + assertThat(NumberComponent.INT.min()).isEqualTo(Integer.MIN_VALUE); + assertThat(NumberComponent.INT.max()).isEqualTo(Integer.MAX_VALUE); + assertThat(NumberComponent.LONG.min()).isEqualTo(Long.MIN_VALUE); + assertThat(NumberComponent.LONG.max()).isEqualTo(Long.MAX_VALUE); + assertThat(NumberComponent.FLOAT.min()).isEqualTo(Float.NEGATIVE_INFINITY); + assertThat(NumberComponent.FLOAT.max()).isEqualTo(Float.POSITIVE_INFINITY); + assertThat(NumberComponent.DOUBLE.min()).isEqualTo(Double.NEGATIVE_INFINITY); + assertThat(NumberComponent.DOUBLE.max()).isEqualTo(Double.POSITIVE_INFINITY); + } + + @Test + void rangeFactoriesReuseSharedConstantsForFullRange() { + assertThat(NumberComponent.intRange(Integer.MIN_VALUE, Integer.MAX_VALUE)).isSameAs(NumberComponent.INT); + assertThat(NumberComponent.longRange(Long.MIN_VALUE, Long.MAX_VALUE)).isSameAs(NumberComponent.LONG); + assertThat(NumberComponent.floatRange(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY)).isSameAs(NumberComponent.FLOAT); + assertThat(NumberComponent.doubleRange(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY)).isSameAs(NumberComponent.DOUBLE); + } + + @Test + void intWrapClampsToBounds() { + var range = NumberComponent.intRange(0, 10); + assertThat(range.wrap(null, 5)).isEqualTo(5); + assertThat(range.wrap(null, 20)).isEqualTo(10); + assertThat(range.wrap(null, -3)).isEqualTo(0); + } + + @Test + void longWrapClampsToBounds() { + var range = NumberComponent.longRange(0L, 100L); + assertThat(range.wrap(null, 50L)).isEqualTo(50L); + assertThat(range.wrap(null, 200L)).isEqualTo(100L); + assertThat(range.wrap(null, -5L)).isEqualTo(0L); + } + + @Test + void floatWrapClampsToBounds() { + var range = NumberComponent.floatRange(0F, 1F); + assertThat(range.wrap(null, 0.5)).isEqualTo(0.5F); + assertThat(range.wrap(null, 2.0)).isEqualTo(1.0F); + assertThat(range.wrap(null, -1.0)).isEqualTo(0.0F); + } + + @Test + void doubleWrapClampsToBounds() { + var range = NumberComponent.doubleRange(0.0, 1.0); + assertThat(range.wrap(null, 0.5)).isEqualTo(0.5); + assertThat(range.wrap(null, 2.0)).isEqualTo(1.0); + assertThat(range.wrap(null, -1.0)).isEqualTo(0.0); + } + + @Test + void wrapParsesNumbersFromStringAndJson() { + assertThat(NumberComponent.INT.wrap(null, "7")).isEqualTo(7); + assertThat(NumberComponent.DOUBLE.wrap(null, "2.5")).isEqualTo(2.5); + assertThat(NumberComponent.INT.wrap(null, new JsonPrimitive(42))).isEqualTo(42); + } + + @Test + void wrapRejectsNonNumericValues() { + assertThatThrownBy(() -> NumberComponent.INT.wrap(null, new Object())) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> NumberComponent.INT.wrap(null, null)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void toStringReflectsBounds() { + assertThat(NumberComponent.INT.toString()).isEqualTo("int"); + assertThat(NumberComponent.LONG.toString()).isEqualTo("long"); + assertThat(NumberComponent.FLOAT.toString()).isEqualTo("float"); + assertThat(NumberComponent.DOUBLE.toString()).isEqualTo("double"); + assertThat(NumberComponent.intRange(0, 10).toString()).isEqualTo("int<0,10>"); + assertThat(NumberComponent.intRange(Integer.MIN_VALUE, 10).toString()).isEqualTo("int"); + assertThat(NumberComponent.intRange(0, Integer.MAX_VALUE).toString()).isEqualTo("int<0,max>"); + } + + @Test + void hasPriorityOnlyForNumbers() { + assertThat(NumberComponent.INT.hasPriority(null, 5)).isTrue(); + assertThat(NumberComponent.INT.hasPriority(null, new JsonPrimitive(5))).isTrue(); + assertThat(NumberComponent.INT.hasPriority(null, "5")).isFalse(); + assertThat(NumberComponent.INT.hasPriority(null, new JsonPrimitive("x"))).isFalse(); + } + + @Test + void typeInfoMatchesNumberKind() { + assertThat(NumberComponent.INT.typeInfo()).isEqualTo(TypeInfo.INT); + assertThat(NumberComponent.LONG.typeInfo()).isEqualTo(TypeInfo.LONG); + assertThat(NumberComponent.FLOAT.typeInfo()).isEqualTo(TypeInfo.FLOAT); + assertThat(NumberComponent.DOUBLE.typeInfo()).isEqualTo(TypeInfo.DOUBLE); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java new file mode 100644 index 000000000..c57745890 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java @@ -0,0 +1,53 @@ +package dev.latvian.mods.kubejs.unittest; + +import com.google.gson.JsonPrimitive; +import dev.latvian.mods.kubejs.recipe.component.StringComponent; +import dev.latvian.mods.rhino.type.TypeInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(BootstrapExtension.class) +public class StringComponentTest { + @Test + void hasPriorityForStringLikeValues() { + assertThat(StringComponent.STRING.hasPriority(null, "hello")).isTrue(); + assertThat(StringComponent.STRING.hasPriority(null, 'c')).isTrue(); + assertThat(StringComponent.STRING.hasPriority(null, new JsonPrimitive("x"))).isTrue(); + assertThat(StringComponent.STRING.hasPriority(null, 5)).isFalse(); + assertThat(StringComponent.STRING.hasPriority(null, new JsonPrimitive(5))).isFalse(); + } + + @Test + void isEmptyChecksLength() { + assertThat(StringComponent.STRING.isEmpty("")).isTrue(); + assertThat(StringComponent.STRING.isEmpty("a")).isFalse(); + } + + @Test + void spreadSplitsIntoCharacters() { + assertThat(StringComponent.STRING.spread("abc")).containsExactly('a', 'b', 'c'); + assertThat(StringComponent.STRING.spread("")).isEqualTo(List.of()); + } + + @Test + void displayStringIsEscapedAndWrapped() { + assertThat(StringComponent.STRING.toString(null, "hello")).isEqualTo("'hello'"); + assertThat(StringComponent.STRING.toString(null, "it's")).isEqualTo("\"it's\""); + } + + @Test + void allowEmptyFollowsVariant() { + assertThat(StringComponent.STRING.allowEmpty()).isFalse(); + assertThat(StringComponent.OPTIONAL_STRING.allowEmpty()).isTrue(); + } + + @Test + void metadataIsStable() { + assertThat(StringComponent.STRING.typeInfo()).isEqualTo(TypeInfo.STRING); + assertThat(StringComponent.STRING.toString()).contains("string"); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java new file mode 100644 index 000000000..2ae591d1e --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java @@ -0,0 +1,105 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.plugin.builtin.wrapper.TextWrapper; +import net.minecraft.nbt.IntTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.contents.KeybindContents; +import net.minecraft.network.chat.contents.TranslatableContents; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TextWrapperTest { + @Test + void ofReturnsSameComponent() { + var component = Component.literal("hi"); + assertThat(TextWrapper.of(component)).isSameAs(component); + } + + @Test + void ofStringEmptyGivesEmptyComponent() { + var component = TextWrapper.ofString(""); + assertThat(TextWrapper.isEmpty(component)).isTrue(); + } + + @Test + void ofStringNonEmptyGivesLiteral() { + var component = TextWrapper.ofString("hello"); + assertThat(component.getString()).isEqualTo("hello"); + assertThat(TextWrapper.isEmpty(component)).isFalse(); + } + + @Test + void emptyIsEmpty() { + var component = TextWrapper.empty(); + assertThat(component.getString()).isEmpty(); + assertThat(TextWrapper.isEmpty(component)).isTrue(); + } + + @Test + void stringAndLiteralProduceLiterals() { + assertThat(TextWrapper.string("abc").getString()).isEqualTo("abc"); + assertThat(TextWrapper.literal("abc").getString()).isEqualTo("abc"); + } + + @Test + void translateProducesTranslatableContents() { + var contents = TextWrapper.translate("my.key").getContents(); + assertThat(contents).isInstanceOf(TranslatableContents.class); + assertThat(((TranslatableContents) contents).getKey()).isEqualTo("my.key"); + } + + @Test + void translatableProducesTranslatableContents() { + var contents = TextWrapper.translatable("my.key").getContents(); + assertThat(contents).isInstanceOf(TranslatableContents.class); + assertThat(((TranslatableContents) contents).getKey()).isEqualTo("my.key"); + } + + @Test + void translateWithFallbackKeepsKeyAndFallback() { + var contents = TextWrapper.translateWithFallback("my.key", "fallback").getContents(); + assertThat(contents).isInstanceOf(TranslatableContents.class); + assertThat(((TranslatableContents) contents).getKey()).isEqualTo("my.key"); + assertThat(((TranslatableContents) contents).getFallback()).isEqualTo("fallback"); + } + + @Test + void translatableWithFallbackKeepsKeyAndFallback() { + var contents = TextWrapper.translatableWithFallback("my.key", "fallback").getContents(); + assertThat(contents).isInstanceOf(TranslatableContents.class); + assertThat(((TranslatableContents) contents).getFallback()).isEqualTo("fallback"); + } + + @Test + void keybindProducesKeybindContents() { + var contents = TextWrapper.keybind("key.jump").getContents(); + assertThat(contents).isInstanceOf(KeybindContents.class); + assertThat(((KeybindContents) contents).getName()).isEqualTo("key.jump"); + } + + @Test + void joinVarargsConcatenates() { + var joined = TextWrapper.join(Component.literal("a"), Component.literal("b")); + assertThat(joined.getString()).isEqualTo("ab"); + } + + @Test + void joinWithSeparator() { + var joined = TextWrapper.join(Component.literal("-"), List.of(Component.literal("a"), Component.literal("b"))); + assertThat(joined.getString()).isEqualTo("a-b"); + } + + @Test + void loreKeepsLines() { + List lines = List.of(Component.literal("line1"), Component.literal("line2")); + assertThat(TextWrapper.lore(lines).lines()).isEqualTo(lines); + } + + @Test + void prettyPrintNbtReturnsComponent() { + assertThat(TextWrapper.prettyPrintNbt(IntTag.valueOf(5))).isNotNull(); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java new file mode 100644 index 000000000..f23c46a59 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java @@ -0,0 +1,86 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.util.UtilsJS; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class UtilsJSTest { + @SuppressWarnings("unused") + private static final class Fields { + List stringList; + Map stringIntMap; + List[] stringListArray; + List extendsNumber; + List superInteger; + List unbounded; + } + + @SuppressWarnings("unused") + private static final class Simple { + } + + @SuppressWarnings("unused") + private static final class BoundedSingle { + } + + @SuppressWarnings("unused") + private static final class BoundedMulti> { + } + + private static Type fieldType(String name) throws NoSuchFieldException { + return Fields.class.getDeclaredField(name).getGenericType(); + } + + @Test + void classRendersSimpleName() { + assertThat(UtilsJS.toMappedTypeString(String.class)).isEqualTo("String"); + assertThat(UtilsJS.toMappedTypeString(int.class)).isEqualTo("int"); + } + + @Test + void parameterizedTypeRendersGenerics() throws NoSuchFieldException { + assertThat(UtilsJS.toMappedTypeString(fieldType("stringList"))).isEqualTo("List"); + assertThat(UtilsJS.toMappedTypeString(fieldType("stringIntMap"))).isEqualTo("Map"); + } + + @Test + void genericArrayTypeAppendsBrackets() throws NoSuchFieldException { + assertThat(UtilsJS.toMappedTypeString(fieldType("stringListArray"))).isEqualTo("List[]"); + } + + @Test + void wildcardTypesRenderBounds() throws NoSuchFieldException { + assertThat(UtilsJS.toMappedTypeString(fieldType("extendsNumber"))).isEqualTo("List"); + assertThat(UtilsJS.toMappedTypeString(fieldType("superInteger"))).isEqualTo("List"); + assertThat(UtilsJS.toMappedTypeString(fieldType("unbounded"))).isEqualTo("List"); + } + + @Test + void typeVariableRendersNameAndBounds() { + assertThat(UtilsJS.toMappedTypeString(Simple.class.getTypeParameters()[0])).isEqualTo("T"); + assertThat(UtilsJS.toMappedTypeString(BoundedSingle.class.getTypeParameters()[0])).isEqualTo("T extends Number"); + assertThat(UtilsJS.toMappedTypeString(BoundedMulti.class.getTypeParameters()[0])).isEqualTo("T extends Number & Comparable"); + } + + @Test + void nullTypeThrows() { + assertThatThrownBy(() -> UtilsJS.toMappedTypeString(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void onMatchDoRunsConsumerOnlyOnMatch() { + var seen = new ArrayList(); + var predicate = UtilsJS.onMatchDo((Integer i) -> i > 2, seen::add); + + assertThat(predicate.test(5)).isTrue(); + assertThat(predicate.test(1)).isFalse(); + assertThat(seen).containsExactly(5); + } +} From 22c01279d298ab4ed8f78d268d5dac335b68e950 Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 18:45:39 -0700 Subject: [PATCH 13/18] Add builder startup fixtures for game-test coverage Register a spread of blocks (basic + all custom types: slab/stairs/fence/wall/ fence_gate/pressure_plate/button/falling/carpet/door/trapdoor/cardinal/pillar/crop), items, and fluids via startup-registry scripts, exercising BlockBuilder, block.custom.*, ItemBuilder, and FluidBuilder during headless startup. Raises reachable script-API coverage from 24.8% to 28.8%. --- .../startup_scripts/builder_fixtures.js | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/test/kubejs/startup_scripts/builder_fixtures.js diff --git a/src/test/kubejs/startup_scripts/builder_fixtures.js b/src/test/kubejs/startup_scripts/builder_fixtures.js new file mode 100644 index 000000000..133d6b885 --- /dev/null +++ b/src/test/kubejs/startup_scripts/builder_fixtures.js @@ -0,0 +1,128 @@ +// Startup fixtures that exercise a wide variety of builder code paths (BlockBuilder, the custom +// block builders, ItemBuilder and FluidBuilder) so that game-test coverage records them. These +// blocks/items/fluids only need to register successfully; no events assert on them. + +StartupEvents.registry('block', event => { + // Basic block hitting a broad spread of BlockBuilder setters. + event.create('cov_basic') + .hardness(2.5) + .resistance(6.0) + .lightLevel(0.5) + .opaque(false) + .fullBlock(true) + .requiresTool() + .slipperiness(0.9) + .speedFactor(1.1) + .jumpFactor(0.8) + .soundType('stone') + .mapColor('stone') + .noValidSpawns(true) + .suffocating(false) + .viewBlocking(false) + .redstoneConductor(false) + .transparent(true) + .waterlogged() + .box(0, 0, 0, 16, 8, 16) + .tagBoth('minecraft:mineable/pickaxe') + .randomTick(cb => {}) + .entityInside(cb => {}) + .steppedOn(cb => {}) + .fallenOn(cb => {}) + .afterFallenOn(cb => {}) + .exploded(cb => {}) + .rotateState(cb => {}) + .mirrorState(cb => {}) + .rightClick(cb => {}) + .defaultState(cb => {}) + .placementState(cb => {}) + .canBeReplaced(cb => true); + + // Helper methods and no-item / unbreakable paths. + event.create('cov_basic_unbreakable') + .unbreakable() + .defaultTranslucent() + .dynamicMapColor(state => 'stone') + .noDrops() + .noItem() + .tagBlock('minecraft:mineable/axe'); + + // Cutout helper, sound helpers, bounciness and drops-less item. + event.create('cov_basic_cutout') + .defaultCutout() + .woodSoundType() + .bounciness(0.5) + .color(0x44FF88); + + // Block that customises its item representation. + event.create('cov_basic_with_item') + .grassSoundType() + .item(i => i.maxStackSize(16).glow(true).rarity('epic').tooltip('A covered block')); + + // Block entity path (BasicKubeBlock.WithEntity). + event.create('cov_basic_entity').blockEntity(be => be.serverTicking()); + + // Custom block types. + event.create('cov_slab', 'slab').hardness(2.0).requiresTool(); + event.create('cov_stairs', 'stairs').hardness(2.0).stoneSoundType(); + event.create('cov_fence', 'fence').hardness(2.0); + event.create('cov_wall', 'wall').hardness(2.0).gravelSoundType(); + event.create('cov_fence_gate', 'fence_gate').hardness(2.0); + event.create('cov_pressure_plate', 'pressure_plate').hardness(0.5).behaviour('birch').ticksToStayPressed(40); + event.create('cov_button', 'button').behaviour('oak').ticksToStayPressed(30); + event.create('cov_falling', 'falling').sandSoundType().dustColor(0x807C7B); + event.create('cov_carpet', 'carpet').glassSoundType(); + event.create('cov_door', 'door').behaviour('spruce').wooden(); + event.create('cov_trapdoor', 'trapdoor').behaviour('oak'); + + // Cardinal / pillar with custom shapes to exercise their shape-rotation code. + event.create('cov_cardinal', 'cardinal').box(2, 0, 2, 14, 12, 14); + event.create('cov_cardinal_entity', 'cardinal').blockEntity(be => be.serverTicking()); + event.create('cov_pillar', 'pillar').box(1, 0, 1, 15, 16, 15).cropSoundType(); + event.create('cov_pillar_entity', 'pillar').blockEntity(be => be.serverTicking()); + + // Crop with a shaped age curve and grow callbacks. + event.create('cov_crop', 'crop') + .age(4, shapes => shapes.wheat()) + .noSeeds() + .farmersCanPlant() + .bonemeal(cb => 2) + .survive(cb => true) + .growTick(cb => 1.0); +}); + +StartupEvents.registry('item', event => { + // Stackable item with fuel, container, food, glow, tooltip, tags. + event.create('cov_item_stack') + .maxStackSize(16) + .glow(true) + .tooltip('Coverage item') + .fireResistant() + .containerItem('minecraft:bucket') + .burnTime(200) + .food(4, 0.3) + .disableRepair() + .rarity('rare') + .tag('minecraft:planks'); + + // Durability item with a rich food builder. + event.create('cov_item_durable') + .maxDamage(250) + .food(f => f.nutrition(6).saturation(0.6).alwaysEdible().fastToEat().eatSeconds(1.2)) + .unstackable(); +}); + +StartupEvents.registry('fluid', event => { + event.create('cov_fluid') + .slopeFindDistance(3) + .levelDecreasePerBlock(2) + .explosionResistance(50) + .tickRate(10) + .tint(0x3F76E4) + .bucketColor(0x3F76E4) + .translucent() + .type(t => {}); + + event.create('cov_fluid_bare') + .noBucket() + .noBlock(); +}); From b696ab29c534a72f1a9438a8bdb9f75bd767587b Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 19:07:06 -0700 Subject: [PATCH 14/18] Add *KJS getter coverage via property-reading game-test fixtures Server scripts that read the vanilla-injected *KJS convenience getters (EntityKJS, LivingEntityKJS, ServerPlayerKJS, LevelBlock, ItemStackKJS) off the event objects the existing game tests already drive. Raises reachable script-API coverage from 28.8% to 29.8%. --- src/test/kubejs/server_scripts/kjs_reads.js | 149 ++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/test/kubejs/server_scripts/kjs_reads.js diff --git a/src/test/kubejs/server_scripts/kjs_reads.js b/src/test/kubejs/server_scripts/kjs_reads.js new file mode 100644 index 000000000..d4773be4c --- /dev/null +++ b/src/test/kubejs/server_scripts/kjs_reads.js @@ -0,0 +1,149 @@ +// Coverage fixtures for the vanilla-injected *KJS convenience getters (EntityKJS, LivingEntityKJS, +// PlayerKJS/ServerPlayerKJS, LevelBlock, ItemStackKJS). These read-only listeners piggy-back on the +// events the existing @GameTests already drive (entity spawns, block break/place, item drop/use), so +// every no-arg getter runs as its bean property is read. They report no markers and assert nothing; +// the event dispatcher isolates each listener, so a getter that throws for one entity type can't fail +// a paired test. Reads that need a script Context, an argument, or a specific entity capability are +// either skipped or guarded behind the relevant is* check. + +function readEntity(e) { + e.level; + e.server; + e.type; + e.username; + e.name; + e.displayName; + e.profile; + e.self; + e.player; + e.serverPlayer; + e.clientPlayer; + e.item; + e.frame; + e.living; + e.monster; + e.animal; + e.ambientCreature; + e.waterCreature; + e.peacefulCreature; + e.motionX; + e.motionY; + e.motionZ; + e.passengers; + e.teamId; + e.teamName; + e.onScoreboardTeam; + e.facing; + e.block; + e.nbt; + e.scriptType; +} + +function readLiving(e) { + e.undead; + e.potionEffects; + e.mainHandItem; + e.offHandItem; + e.headArmorItem; + e.chestArmorItem; + e.legsArmorItem; + e.feetArmorItem; + e.totalMovementSpeed; + e.defaultMovementSpeed; +} + +function readPlayer(e) { + e.fake; + e.stages; + e.stats; + e.miningBlock; + e.inventory; + e.selectedSlot; + e.mouseItem; + e.openInventory; + e.foodLevel; + e.saturation; + e.xp; + e.xpLevel; + e.reachDistance; +} + +function readServerPlayer(e) { + e.serverPlayer; + e.op; + e.spawnLocation; +} + +EntityEvents.spawned(event => { + let e = event.entity; + readEntity(e); + + if (e.living) { + readLiving(e); + } + + if (e.player) { + readPlayer(e); + } + + if (e.serverPlayer) { + readServerPlayer(e); + } +}); + +function readBlock(b) { + b.id; + b.block; + b.dimension; + b.dimensionKey; + b.x; + b.y; + b.z; + b.centerX; + b.centerY; + b.centerZ; + b.blockState; + b.properties; + b.entity; + b.entityId; + b.entityData; + b.light; + b.skyLight; + b.blockLight; + b.canSeeSky; + b.inventory; + b.item; + b.drops; + b.biomeId; + b.playersInRadius; + b.up; + b.down; + b.north; + b.south; + b.east; + b.west; + b.toBlockStateString(); +} + +BlockEvents.broken(event => readBlock(event.block)); + +BlockEvents.placed(event => readBlock(event.block)); + +function readItem(item) { + item.id; + item.mod; + item.block; + item.idLocation; + item.key; + item.registry; + item.registryId; + item.enchantments; + item.harvestSpeed; + item.typeData; + item.asHolder(); + item.asIngredient(); +} + +ItemEvents.dropped(event => readItem(event.item)); + +ItemEvents.rightClicked(event => readItem(event.item)); From 189ab72a926913d6a328225c56381ea9d2791aa2 Mon Sep 17 00:00:00 2001 From: hspragg Date: Mon, 6 Jul 2026 19:18:51 -0700 Subject: [PATCH 15/18] Assert *KJS reads resolve; add wrapper/map-color tests; drop coverage gate Make the *KJS getter fixtures real tests: TestRuntime.assertDefined verifies each exposed property resolves (not null / not JS undefined), driven+verified by new KjsReadTests, rather than merely calling getters. Add bootstrapped BlockWrapper and pure MapColorHelper unit tests. Reachable script-API coverage now 30.1%. Remove the temporary coverageGate task from the build script now that it has served its purpose. --- CHANGELOG.md | 2 +- build.gradle.kts | 67 ------------------- .../mods/kubejs/testmod/TestRuntime.java | 9 +++ .../mods/kubejs/testmod/kjs/KjsReadTests.java | 58 ++++++++++++++++ .../kubejs/unittest/BlockWrapperTest.java | 42 ++++++++++++ .../kubejs/unittest/MapColorHelperTest.java | 27 ++++++++ src/test/kubejs/server_scripts/kjs_reads.js | 67 ++++++++++++++++--- 7 files changed, 195 insertions(+), 77 deletions(-) create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/MapColorHelperTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e77df870..b7bc2b49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Add game tests covering block and entity events (#1153) - Add typed script-side `TestRuntime.assertThat` entry points and game tests asserting on event objects (#1153) - Expand game-test and unit-test coverage: level/server/item/player events and util classes (#1153) -- Add a script-exposed-API coverage gate (`coverageGate`) and a bootstrapped JUnit harness; expand unit coverage of wrappers, recipe components, and util (#1153) +- Add a bootstrapped JUnit harness and expand unit + game-test coverage of wrappers, recipe components, util, builders, and script-exposed `*KJS` getters (#1153) ## [8.0.3] - 2026-06-22 diff --git a/build.gradle.kts b/build.gradle.kts index 28613d5fc..404cbee33 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -240,73 +240,6 @@ val coverageSummaryJson by tasks.registering { } } -// Coverage gate over the *script-exposed* API surface (what KubeJS scripts can call), as opposed to -// the whole mod. Packages are partitioned into: excluded internals (mixins, datagen, networking, the -// local web server, mod integrations, the script engine itself, command/tag internals), a client-only -// sub-surface (only reachable by a client run), and the reachable subset that server/unit tests can -// exercise. The gate enforces a minimum on the reachable subset and prints the biggest gaps so the -// test-writing loop knows what to target next. Override the threshold with -PscriptCoverageGate=<0..1>. -val scriptCoverageGate = (findProperty("scriptCoverageGate") as String?)?.toDouble() ?: 0.40 - -fun coveragePackageExcluded(pkg: String): Boolean { - val internal = listOf(".core.mixin", ".generator", ".error", ".misc", ".command", ".server.tag", ".integration.gamestages") - return internal.any { pkg.contains(it) } || - pkg == "dev.latvian.mods.kubejs.net" || - pkg.startsWith("dev.latvian.mods.kubejs.web") || - pkg.startsWith("dev.latvian.mods.kubejs.script") -} - -fun coveragePackageClientOnly(pkg: String): Boolean = - pkg.startsWith("dev.latvian.mods.kubejs.client") || - pkg.startsWith("dev.latvian.mods.kubejs.gui") || - pkg.startsWith("dev.latvian.mods.kubejs.integration.jei") - -val coverageGate by tasks.registering { - group = "verification" - description = "Fails if reachable script-exposed API coverage is below the gate; prints the split and top uncovered classes." - dependsOn(coverageReport) - val csvFile = layout.buildDirectory.file("reports/coverage/coverage.csv") - inputs.file(csvFile) - val gate = scriptCoverageGate - doLast { - var rc = 0L; var rm = 0L // reachable covered / missed - var cc = 0L; var cm = 0L // client-only covered / missed - val uncovered = mutableListOf>() // class, missed, covered (reachable) - - csvFile.get().asFile.readLines().drop(1).filter { it.isNotBlank() }.forEach { row -> - val c = row.split(',') - if (c.size < 9) return@forEach - val pkg = c[1] - if (coveragePackageExcluded(pkg)) return@forEach - val missed = c[3].toLong(); val covered = c[4].toLong() - if (coveragePackageClientOnly(pkg)) { - cc += covered; cm += missed - } else { - rc += covered; rm += missed - if (missed > 0) uncovered.add(Triple("${pkg.removePrefix("dev.latvian.mods.kubejs.")}.${c[2]}", missed, covered)) - } - } - - fun pct(covered: Long, total: Long) = if (total == 0L) 0.0 else covered * 100.0 / total - val reachPct = pct(rc, rc + rm) - - logger.lifecycle("Script-exposed API coverage (instructions):") - logger.lifecycle(String.format(Locale.ROOT, " reachable : %.2f%% (%d / %d)", reachPct, rc, rc + rm)) - logger.lifecycle(String.format(Locale.ROOT, " client-only : %.2f%% (%d / %d)", pct(cc, cc + cm), cc, cc + cm)) - logger.lifecycle(String.format(Locale.ROOT, " full : %.2f%% (%d / %d)", pct(rc + cc, rc + rm + cc + cm), rc + cc, rc + rm + cc + cm)) - logger.lifecycle("Top uncovered reachable classes (missed instructions):") - uncovered.sortedByDescending { it.second }.take(25).forEach { - logger.lifecycle(String.format(Locale.ROOT, " %-7d %s", it.second, it.first)) - } - - val gatePct = gate * 100.0 - if (reachPct < gatePct) { - throw GradleException(String.format(Locale.ROOT, "Reachable script-exposed coverage %.2f%% is below the gate %.2f%%", reachPct, gatePct)) - } - logger.lifecycle(String.format(Locale.ROOT, "Coverage gate passed: %.2f%% >= %.2f%%", reachPct, gatePct)) - } -} - publishing { repositories { val mavenUrl = System.getenv("MAVEN_URL") ?: return@repositories diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java index 43429ee6a..04f71d013 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java @@ -4,6 +4,7 @@ import dev.latvian.mods.kubejs.level.LevelBlock; import dev.latvian.mods.kubejs.testmod.assertion.KubeEntityEventAssert; import dev.latvian.mods.kubejs.testmod.assertion.LevelBlockAssert; +import dev.latvian.mods.rhino.Undefined; import org.assertj.core.api.AbstractBooleanAssert; import org.assertj.core.api.AbstractDoubleAssert; import org.assertj.core.api.AbstractStringAssert; @@ -83,6 +84,14 @@ public static void verify(String id) { } } + /// Fails if a script-read value is `null` or JS `undefined` - used by the *KJS getter fixtures to + /// prove the exposed properties actually resolve, not merely that calling them doesn't throw. + public static void assertDefined(String label, @Nullable Object value) { + if (value == null || Undefined.isUndefined(value)) { + throw new AssertionError("Expected '" + label + "' to be defined but was: " + value); + } + } + public static AbstractStringAssert assertThat(String actual) { return Assertions.assertThat(actual); } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java new file mode 100644 index 000000000..a37d25e10 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java @@ -0,0 +1,58 @@ +package dev.latvian.mods.kubejs.testmod.kjs; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +/// Drives the events the `kjs_reads.js` fixture listens on, then verifies its assertions (that the +/// *KJS getters resolve to defined values) surfaced no failure. +@ForEachTest(groups = "kubejs.kjs.reads") +public class KjsReadTests { + private static final BlockPos POS = new BlockPos(1, 2, 1); + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "kjs_entity_reads", description = "EntityKJS/LivingEntityKJS getters resolve for a spawned entity") + static void entityReads(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("kjs.entity.reads")) + .thenExecute(() -> helper.spawn(EntityType.PIG, POS)) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("kjs.entity.reads"), "entity read fixture did not run")) + .thenExecute(() -> TestRuntime.verify("kjs.entity.reads")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "kjs_block_reads", description = "LevelBlock getters resolve for a broken block") + static void blockReads(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("kjs.block.reads")) + .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) + .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("kjs.block.reads"), "block read fixture did not run")) + .thenExecute(() -> TestRuntime.verify("kjs.block.reads")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "kjs_item_reads", description = "ItemStackKJS getters resolve for a dropped item") + static void itemReads(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("kjs.item.reads")) + .thenExecute(player -> player.drop(new ItemStack(Items.DIAMOND), false)) + .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("kjs.item.reads"), "item read fixture did not run")) + .thenExecute(() -> TestRuntime.verify("kjs.item.reads")) + .thenSucceed()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java new file mode 100644 index 000000000..bf281b0c0 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java @@ -0,0 +1,42 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.plugin.builtin.wrapper.BlockWrapper; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Blocks; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(BootstrapExtension.class) +public class BlockWrapperTest { + @Test + void resolvesVanillaBlock() { + assertThat(BlockWrapper.getBlock(Identifier.parse("minecraft:stone"))).isSameAs(Blocks.STONE); + assertThat(BlockWrapper.getId(Blocks.STONE)).isEqualTo(Identifier.parse("minecraft:stone")); + } + + @Test + void facingMapIsPopulated() { + assertThat(BlockWrapper.getFacing()).isNotEmpty().containsKey("north"); + } + + @Test + void blockStateStringFormats() { + assertThat(BlockWrapper.toBlockStateString("minecraft:stone", Map.of())).isEqualTo("minecraft:stone"); + assertThat(BlockWrapper.toBlockStateString("minecraft:furnace", Map.of("lit", "true"))) + .contains("minecraft:furnace").contains("lit").contains("true"); + } + + @Test + void idPredicateBuilds() { + assertThat(BlockWrapper.id(Identifier.parse("minecraft:stone"))).isNotNull(); + } + + @Test + void typeListListsCustomTypes() { + assertThat(BlockWrapper.getTypeList()).isNotEmpty(); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/MapColorHelperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/MapColorHelperTest.java new file mode 100644 index 000000000..daf064e2e --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/unittest/MapColorHelperTest.java @@ -0,0 +1,27 @@ +package dev.latvian.mods.kubejs.unittest; + +import dev.latvian.mods.kubejs.block.MapColorHelper; +import net.minecraft.world.level.material.MapColor; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MapColorHelperTest { + @Test + void reverseOfNoneIsNone() { + assertThat(MapColorHelper.reverse(MapColor.NONE)).isSameAs(MapColorHelper.NONE); + } + + @Test + void findClosestAlwaysResolves() { + assertThat(MapColorHelper.findClosest(0xFFFFFF)).isNotNull(); + assertThat(MapColorHelper.findClosest(0x000000)).isNotNull(); + } + + @Test + void wrapResolvesNamesAndNumbers() { + assertThat(MapColorHelper.wrap("none")).isNotNull(); + assertThat(MapColorHelper.wrap(0xFF0000)).isNotNull(); + assertThat(MapColorHelper.wrap(MapColor.NONE)).isSameAs(MapColor.NONE); + } +} diff --git a/src/test/kubejs/server_scripts/kjs_reads.js b/src/test/kubejs/server_scripts/kjs_reads.js index d4773be4c..30139f97f 100644 --- a/src/test/kubejs/server_scripts/kjs_reads.js +++ b/src/test/kubejs/server_scripts/kjs_reads.js @@ -1,10 +1,10 @@ -// Coverage fixtures for the vanilla-injected *KJS convenience getters (EntityKJS, LivingEntityKJS, -// PlayerKJS/ServerPlayerKJS, LevelBlock, ItemStackKJS). These read-only listeners piggy-back on the -// events the existing @GameTests already drive (entity spawns, block break/place, item drop/use), so -// every no-arg getter runs as its bean property is read. They report no markers and assert nothing; -// the event dispatcher isolates each listener, so a getter that throws for one entity type can't fail -// a paired test. Reads that need a script Context, an argument, or a specific entity capability are -// either skipped or guarded behind the relevant is* check. +// Coverage + sanity fixtures for the vanilla-injected *KJS convenience getters (EntityKJS, +// LivingEntityKJS, PlayerKJS/ServerPlayerKJS, LevelBlock, ItemStackKJS). Each listener piggy-backs +// on an event the existing @GameTests already drive: it first reads a broad set of getters (so every +// property is exercised for coverage), then asserts - inside a TestRuntime.check block that the paired +// KjsReadTests verify - that the always-present properties actually resolve (not null / not JS +// undefined). Conditionally-null getters (profile on a non-player, block-entity on a plain block, ...) +// are read for coverage but not asserted. function readEntity(e) { e.level; @@ -37,6 +37,8 @@ function readEntity(e) { e.block; e.nbt; e.scriptType; + e.id; + e.rawPersistentData; } function readLiving(e) { @@ -89,6 +91,22 @@ EntityEvents.spawned(event => { if (e.serverPlayer) { readServerPlayer(e); } + + TestRuntime.check('kjs.entity.reads', () => { + TestRuntime.assertDefined('entity.type', e.type); + TestRuntime.assertDefined('entity.name', e.name); + TestRuntime.assertDefined('entity.displayName', e.displayName); + TestRuntime.assertDefined('entity.username', e.username); + TestRuntime.assertDefined('entity.level', e.level); + TestRuntime.assertDefined('entity.block', e.block); + TestRuntime.assertDefined('entity.facing', e.facing); + TestRuntime.assertDefined('entity.nbt', e.nbt); + TestRuntime.assertDefined('entity.passengers', e.passengers); + TestRuntime.assertDefined('entity.scriptType', e.scriptType); + TestRuntime.assertDefined('entity.motionX', e.motionX); + TestRuntime.assertDefined('entity.motionY', e.motionY); + TestRuntime.assertDefined('entity.motionZ', e.motionZ); + }); }); function readBlock(b) { @@ -125,7 +143,30 @@ function readBlock(b) { b.toBlockStateString(); } -BlockEvents.broken(event => readBlock(event.block)); +function assertBlock(b) { + TestRuntime.check('kjs.block.reads', () => { + TestRuntime.assertDefined('block.id', b.id); + TestRuntime.assertDefined('block.block', b.block); + TestRuntime.assertDefined('block.blockState', b.blockState); + TestRuntime.assertDefined('block.properties', b.properties); + TestRuntime.assertDefined('block.dimension', b.dimension); + TestRuntime.assertDefined('block.dimensionKey', b.dimensionKey); + TestRuntime.assertDefined('block.biomeId', b.biomeId); + TestRuntime.assertDefined('block.x', b.x); + TestRuntime.assertDefined('block.y', b.y); + TestRuntime.assertDefined('block.z', b.z); + TestRuntime.assertDefined('block.centerX', b.centerX); + TestRuntime.assertDefined('block.up', b.up); + TestRuntime.assertDefined('block.down', b.down); + TestRuntime.assertDefined('block.north', b.north); + TestRuntime.assertDefined('block.toBlockStateString', b.toBlockStateString()); + }); +} + +BlockEvents.broken(event => { + readBlock(event.block); + assertBlock(event.block); +}); BlockEvents.placed(event => readBlock(event.block)); @@ -144,6 +185,14 @@ function readItem(item) { item.asIngredient(); } -ItemEvents.dropped(event => readItem(event.item)); +ItemEvents.dropped(event => { + readItem(event.item); + TestRuntime.check('kjs.item.reads', () => { + TestRuntime.assertDefined('item.id', event.item.id); + TestRuntime.assertDefined('item.mod', event.item.mod); + TestRuntime.assertDefined('item.idLocation', event.item.idLocation); + TestRuntime.assertDefined('item.enchantments', event.item.enchantments); + }); +}); ItemEvents.rightClicked(event => readItem(event.item)); From 4ed6a1d477682c312aba5005702d3e641582f005 Mon Sep 17 00:00:00 2001 From: hspragg Date: Thu, 9 Jul 2026 17:00:23 -0700 Subject: [PATCH 16/18] Rework test harness into JS-first categories Address review feedback by re-categorising the test suite so behaviour is exercised through JS scripts in real game-test scenarios rather than trivial Java unit tests: - Wrapper coverage now drives raw JS values across typed boundaries (TestRuntime.as*), exercising the registered type wrappers end to end. - Replace the kjs_reads getter fixtures with an entity-behaviour test: a script gives a spawned zombie a held item and moves a cow, verified Java-side. - Add JS binding tests (incl. JsonIO/NBTIO file creation) and syntax tests. - Assert exact event fire counts, and route Java assertions through AssertJ (GameAsserts) so failures carry actual-vs-expected messages. - Trim builder fixtures to a few actually integrated into game tests. - Drop the JUnit unittest package in favour of the JS-driven tests. --- build.gradle.kts | 5 +- .../mods/kubejs/testmod/GameAsserts.java | 40 ++++ .../mods/kubejs/testmod/TestRuntime.java | 93 +++++++- .../kubejs/testmod/binding/BindingTests.java | 50 +++++ .../kubejs/testmod/block/BlockEventTests.java | 47 +++-- .../kubejs/testmod/builder/BuilderTests.java | 64 ++++++ .../testmod/entity/EntityBehaviorTests.java | 49 +++++ .../testmod/entity/EntityEventTests.java | 22 +- .../kubejs/testmod/item/ItemEventTests.java | 20 +- .../mods/kubejs/testmod/kjs/KjsReadTests.java | 58 ----- .../kubejs/testmod/level/LevelEventTests.java | 13 +- .../testmod/player/PlayerEventTests.java | 4 +- .../testmod/server/ServerEventTests.java | 9 +- .../kubejs/testmod/syntax/SyntaxTests.java | 40 ++++ .../kubejs/testmod/wrapper/WrapperTests.java | 48 +++++ .../kubejs/unittest/BlockWrapperTest.java | 42 ---- .../kubejs/unittest/BooleanComponentTest.java | 58 ----- .../kubejs/unittest/BootstrapExtension.java | 26 --- .../unittest/CharacterComponentTest.java | 38 ---- .../kubejs/unittest/ColorWrapperTest.java | 37 ---- .../mods/kubejs/unittest/CountingMapTest.java | 39 ---- .../mods/kubejs/unittest/ErrorStackTest.java | 45 ---- .../latvian/mods/kubejs/unittest/IDTest.java | 56 ----- .../mods/kubejs/unittest/IntBoundsTest.java | 29 --- .../mods/kubejs/unittest/ItemWrapperTest.java | 33 --- .../mods/kubejs/unittest/JsonUtilsTest.java | 137 ------------ .../mods/kubejs/unittest/ListJSTest.java | 45 ---- .../kubejs/unittest/MapColorHelperTest.java | 27 --- .../kubejs/unittest/MiscWrappersTest.java | 102 --------- .../mods/kubejs/unittest/NBTUtilsTest.java | 92 -------- .../mods/kubejs/unittest/NBTWrapperTest.java | 177 ---------------- .../kubejs/unittest/NumberComponentTest.java | 107 ---------- .../kubejs/unittest/Object2LongEntryTest.java | 23 -- .../mods/kubejs/unittest/RegExpKJSTest.java | 49 ----- .../kubejs/unittest/StringComponentTest.java | 53 ----- .../mods/kubejs/unittest/TextWrapperTest.java | 105 ---------- .../kubejs/unittest/TickDurationTest.java | 44 ---- .../mods/kubejs/unittest/TimeJSTest.java | 37 ---- .../mods/kubejs/unittest/TinyMapTest.java | 28 --- .../mods/kubejs/unittest/TristateTest.java | 42 ---- .../mods/kubejs/unittest/UtilsJSTest.java | 86 -------- .../kubejs/server_scripts/binding_checks.js | 24 +++ .../kubejs/server_scripts/entity_behavior.js | 14 ++ src/test/kubejs/server_scripts/kjs_reads.js | 198 ------------------ .../kubejs/server_scripts/syntax_checks.js | 53 +++++ .../kubejs/server_scripts/wrapper_checks.js | 87 ++++++++ .../startup_scripts/builder_fixtures.js | 128 ----------- .../startup_scripts/builder_integrated.js | 11 + 48 files changed, 644 insertions(+), 1990 deletions(-) create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/GameAsserts.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/builder/BuilderTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityBehaviorTests.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/syntax/SyntaxTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/wrapper/WrapperTests.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/ListJSTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/MapColorHelperTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/MiscWrappersTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/NBTUtilsTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/NBTWrapperTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java delete mode 100644 src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java create mode 100644 src/test/kubejs/server_scripts/binding_checks.js create mode 100644 src/test/kubejs/server_scripts/entity_behavior.js delete mode 100644 src/test/kubejs/server_scripts/kjs_reads.js create mode 100644 src/test/kubejs/server_scripts/syntax_checks.js create mode 100644 src/test/kubejs/server_scripts/wrapper_checks.js delete mode 100644 src/test/kubejs/startup_scripts/builder_fixtures.js create mode 100644 src/test/kubejs/startup_scripts/builder_integrated.js diff --git a/build.gradle.kts b/build.gradle.kts index 404cbee33..4b0911924 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { } }) + testImplementation("org.mockito:mockito-core:5.18.0") testImplementation("org.assertj:assertj-core:3.27.3") } @@ -157,8 +158,8 @@ val coverageRequested = gradle.startParameter.taskNames.any { it.substringAfterLast(':').startsWith("coverage") } -// The test source set holds both JUnit unit tests (...unittest, run by `test`) and the game-test -// mod (...testmod, run by `runGametest`). Allow `test` to pass before any unit tests are present. +// The test source set is driven by the game-test mod (...testmod, run by `runGametest`). JUnit +// (`test`) is kept available as a last resort but currently holds no tests, so allow it to pass. tasks.named("test") { failOnNoDiscoveredTests = false if (coverageRequested) { diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/GameAsserts.java b/src/test/java/dev/latvian/mods/kubejs/testmod/GameAsserts.java new file mode 100644 index 000000000..c26dede25 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/GameAsserts.java @@ -0,0 +1,40 @@ +package dev.latvian.mods.kubejs.testmod; + +import net.minecraft.gametest.framework.GameTestAssertException; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.network.chat.Component; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Bridges AssertJ into the game-test framework so failures carry AssertJ's rich message (e.g. the +/// actual vs expected count). A raw [AssertionError] is an [Error] the framework never catches - it +/// crashes the server - so these rethrow any assertion failure as a [GameTestAssertException], which +/// the framework reports, and which `thenWaitUntil` retries against, like any native game-test assert. +public final class GameAsserts { + private GameAsserts() { + } + + /// Runs {@code assertions}, converting any [AssertionError] into a [GameTestAssertException]. + public static void assertj(GameTestHelper helper, Runnable assertions) { + try { + assertions.run(); + } catch (AssertionError error) { + throw new GameTestAssertException(Component.literal(String.valueOf(error.getMessage())), (int) helper.getTick()); + } + } + + /// Asserts {@code id} was reported at least once. + public static void assertFired(GameTestHelper helper, String id) { + assertj(helper, () -> assertThat(TestRuntime.passed(id)).as("%s should have fired", id).isTrue()); + } + + /// Asserts {@code id} was reported exactly {@code expected} times, naming the actual count on mismatch. + public static void assertCount(GameTestHelper helper, String id, int expected) { + assertj(helper, () -> assertThat(TestRuntime.count(id)).as("%s fire count", id).isEqualTo(expected)); + } + + /// Surfaces any script-side assertion captured under {@code id} (see [TestRuntime#check]). + public static void assertVerified(GameTestHelper helper, String id) { + assertj(helper, () -> TestRuntime.verify(id)); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java index 04f71d013..5504d5100 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java @@ -1,10 +1,20 @@ package dev.latvian.mods.kubejs.testmod; +import dev.latvian.mods.kubejs.color.KubeColor; import dev.latvian.mods.kubejs.entity.KubeEntityEvent; import dev.latvian.mods.kubejs.level.LevelBlock; import dev.latvian.mods.kubejs.testmod.assertion.KubeEntityEventAssert; import dev.latvian.mods.kubejs.testmod.assertion.LevelBlockAssert; +import dev.latvian.mods.kubejs.util.Tristate; import dev.latvian.mods.rhino.Undefined; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.resources.Identifier; +import net.minecraft.util.valueproviders.IntProvider; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.material.MapColor; +import net.minecraft.world.phys.Vec3; import org.assertj.core.api.AbstractBooleanAssert; import org.assertj.core.api.AbstractDoubleAssert; import org.assertj.core.api.AbstractStringAssert; @@ -13,9 +23,12 @@ import org.assertj.core.api.ObjectAssert; import org.jspecify.annotations.Nullable; +import java.time.Duration; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; /// Bound into scripts as {@code TestRuntime} so a script can report a passing condition back to /// the game test that drives it, e.g. {@code TestRuntime.pass('block.break.dirt')}. @@ -62,18 +75,34 @@ public static boolean passedStartup(String id) { return STARTUP.contains(id); } - /// Marks {@code id} reached and runs the script's assertions, capturing any [AssertionError] they - /// throw so it survives the event dispatcher (which otherwise swallows handler exceptions). + /// Marks {@code id} reached and runs the script's assertions, capturing any failure they throw so + /// it survives to [#verify]. Catches [Throwable] (not just [AssertionError]) because a failure at + /// script-load time would otherwise abort loading, and Rhino can surface an assertion wrapped in + /// its own exception type - [#asAssertionError] unwraps it back to the underlying [AssertionError]. public static void check(String id, Runnable assertions) { pass(id); try { assertions.run(); - } catch (AssertionError error) { - FAILURES.put(id, error); + } catch (Throwable error) { + FAILURES.put(id, asAssertionError(error)); } } + private static AssertionError asAssertionError(Throwable error) { + for (var current = error; current != null; current = current.getCause()) { + if (current instanceof AssertionError assertionError) { + return assertionError; + } + + if (current.getCause() == current) { + break; + } + } + + return new AssertionError(error.toString(), error); + } + /// Re-throws any assertion failure captured under {@code id}, so the game test fails on its own /// thread with AssertJ's message instead of merely timing out. public static void verify(String id) { @@ -119,4 +148,60 @@ public static IterableAssert assertThat(Iterable actual) { public static ObjectAssert assertThat(@Nullable Object actual) { return Assertions.assertThat(actual); } + + /// Boundaries for the JS-driven wrapper tests. Each declares a wrapped Java type, so passing a raw + /// JS value invokes the registered type wrapper and returns the coerced object for the script to + /// assert on - exercising the JS->Java conversion end to end, not a static `wrap` call. + + public static Vec3 asVec3(Vec3 value) { + return value; + } + + public static BlockPos asBlockPos(BlockPos value) { + return value; + } + + public static ItemStack asItemStack(ItemStack value) { + return value; + } + + public static MutableComponent asComponent(MutableComponent value) { + return value; + } + + public static CompoundTag asCompoundTag(CompoundTag value) { + return value; + } + + public static KubeColor asColor(KubeColor value) { + return value; + } + + public static Identifier asId(Identifier value) { + return value; + } + + public static UUID asUUID(UUID value) { + return value; + } + + public static Tristate asTristate(Tristate value) { + return value; + } + + public static Duration asDuration(Duration value) { + return value; + } + + public static Pattern asPattern(Pattern value) { + return value; + } + + public static IntProvider asIntProvider(IntProvider value) { + return value; + } + + public static MapColor asMapColor(MapColor value) { + return value; + } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java new file mode 100644 index 000000000..80c84d4fa --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java @@ -0,0 +1,50 @@ +package dev.latvian.mods.kubejs.testmod.binding; + +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; + +/// Category 3. Verifies that script bindings are reachable and work (`binding_checks.js`), including +/// that the file-writing bindings actually create files on disk. +@ForEachTest(groups = "kubejs.binding") +public class BindingTests { + @GameTest + @EmptyTemplate + @TestHolder(value = "binding_reachable", description = "ID/Text bindings are reachable from JS and behave as expected") + static void bindingReachable(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "binding.id"); + assertVerified(helper, "binding.id"); + assertFired(helper, "binding.text"); + assertVerified(helper, "binding.text"); + helper.succeed(); + }); + } + + @GameTest + @EmptyTemplate + @TestHolder(value = "binding_file_io", description = "JsonIO/NBTIO bindings write files that exist on disk afterwards") + static void bindingFileIo(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "binding.jsonio"); + assertVerified(helper, "binding.jsonio"); + assertj(helper, () -> assertThat(Files.isRegularFile(Path.of("kubejs/test_binding.json"))).as("JsonIO should have created the json file").isTrue()); + + assertFired(helper, "binding.nbtio"); + assertVerified(helper, "binding.nbtio"); + assertj(helper, () -> assertThat(Files.isRegularFile(Path.of("kubejs/test_binding.nbt"))).as("NBTIO should have created the nbt file").isTrue()); + + helper.succeed(); + }); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java index d54c4b42d..c8c3a53e9 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java @@ -22,6 +22,12 @@ import net.neoforged.testframework.gametest.EmptyTemplate; import net.neoforged.testframework.gametest.GameTest; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertCount; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; + @ForEachTest(groups = "kubejs.block.event") public class BlockEventTests { private static final BlockPos POS = new BlockPos(1, 2, 1); @@ -34,7 +40,8 @@ static void blockBrokenDirt(final DynamicTest test) { .thenExecute(() -> TestRuntime.clear("block.break.dirt")) .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.break.dirt"), "script did not report block.break.dirt")) + .thenWaitUntil(() -> assertFired(helper, "block.break.dirt")) + .thenExecute(() -> assertCount(helper, "block.break.dirt", 1)) .thenSucceed()); } @@ -46,8 +53,8 @@ static void blockBrokenAsserts(final DynamicTest test) { .thenExecute(() -> TestRuntime.clear("block.broken.assert")) .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.broken.assert"), "script did not assert on block.broken")) - .thenExecute(() -> TestRuntime.verify("block.broken.assert")) + .thenWaitUntil(() -> assertFired(helper, "block.broken.assert")) + .thenExecute(() -> assertVerified(helper, "block.broken.assert")) .thenSucceed()); } @@ -59,7 +66,8 @@ static void blockDropsDirt(final DynamicTest test) { .thenExecute(() -> TestRuntime.clear("block.drops.dirt")) .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.drops.dirt"), "script did not report block.drops.dirt")) + .thenWaitUntil(() -> assertFired(helper, "block.drops.dirt")) + .thenExecute(() -> assertCount(helper, "block.drops.dirt", 1)) .thenSucceed()); } @@ -77,7 +85,8 @@ static void blockPlaced(final DynamicTest test) { var hit = new BlockHitResult(Vec3.atCenterOf(abs).add(0, 0.5, 0), Direction.UP, abs, false); player.gameMode.useItemOn(player, (ServerLevel) player.level(), stack, InteractionHand.MAIN_HAND, hit); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.placed"), "script did not report block.placed")) + .thenWaitUntil(() -> assertFired(helper, "block.placed")) + .thenExecute(() -> assertCount(helper, "block.placed", 1)) .thenSucceed()); } @@ -95,8 +104,8 @@ static void blockPlacedAsserts(final DynamicTest test) { var hit = new BlockHitResult(Vec3.atCenterOf(abs).add(0, 0.5, 0), Direction.UP, abs, false); player.gameMode.useItemOn(player, (ServerLevel) player.level(), stack, InteractionHand.MAIN_HAND, hit); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.placed.assert"), "script did not assert on block.placed")) - .thenExecute(() -> TestRuntime.verify("block.placed.assert")) + .thenWaitUntil(() -> assertFired(helper, "block.placed.assert")) + .thenExecute(() -> assertVerified(helper, "block.placed.assert")) .thenSucceed()); } @@ -113,7 +122,7 @@ static void blockRightClicked(final DynamicTest test) { var hit = new BlockHitResult(Vec3.atCenterOf(abs).add(0, 0.5, 0), Direction.UP, abs, false); player.gameMode.useItemOn(player, (ServerLevel) player.level(), ItemStack.EMPTY, InteractionHand.MAIN_HAND, hit); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.rightClicked"), "script did not report block.rightClicked")) + .thenWaitUntil(() -> assertFired(helper, "block.rightClicked")) .thenSucceed()); } @@ -130,7 +139,7 @@ static void blockLeftClicked(final DynamicTest test) { Direction.UP, player.level().getMaxY(), 0)) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.leftClicked"), "script did not report block.leftClicked")) + .thenWaitUntil(() -> assertFired(helper, "block.leftClicked")) .thenSucceed()); } @@ -143,7 +152,7 @@ static void blockStartedFalling(final DynamicTest test) { .thenExecute(() -> helper.setBlock(new BlockPos(1, 2, 1), Blocks.AIR.defaultBlockState())) .thenExecute(() -> helper.setBlock(new BlockPos(1, 3, 1), Blocks.SAND.defaultBlockState())) .thenIdle(4) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.startedFalling"), "script did not report block.startedFalling")) + .thenWaitUntil(() -> assertFired(helper, "block.startedFalling")) .thenSucceed()); } @@ -156,7 +165,7 @@ static void blockStoppedFalling(final DynamicTest test) { .thenExecute(() -> helper.setBlock(new BlockPos(1, 2, 1), Blocks.AIR.defaultBlockState())) .thenExecute(() -> helper.setBlock(new BlockPos(1, 3, 1), Blocks.SAND.defaultBlockState())) .thenIdle(20) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.stoppedFalling"), "script did not report block.stoppedFalling")) + .thenWaitUntil(() -> assertFired(helper, "block.stoppedFalling")) .thenSucceed()); } @@ -169,7 +178,7 @@ static void blockFarmlandTrampled(final DynamicTest test) { .thenExecute(player -> ((GameRulesKJS) ((ServerLevel) player.level()).getGameRules()).kjs$set("mob_griefing", "true")) .thenExecute(() -> helper.setBlock(POS, Blocks.FARMLAND.defaultBlockState())) .thenExecute(() -> helper.spawnWithNoFreeWill(EntityType.GOAT, new BlockPos(1, 5, 1).getCenter())) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.farmlandTrampled"), "script did not report block.farmlandTrampled")) + .thenWaitUntil(() -> assertFired(helper, "block.farmlandTrampled")) .thenSucceed()); } @@ -185,7 +194,7 @@ static void blockRandomTick(final DynamicTest test) { var level = (ServerLevel) player.level(); helper.getBlockState(POS).randomTick(level, abs, level.getRandom()); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.randomTick.dirt"), "script did not report block.randomTick.dirt")) + .thenWaitUntil(() -> assertFired(helper, "block.randomTick.dirt")) .thenSucceed()); } @@ -198,8 +207,8 @@ static void blockDetectorPowered(final DynamicTest test) { .thenExecute(() -> helper.setBlock(POS, detector())) .thenExecute(() -> helper.setBlock(new BlockPos(2, 2, 1), Blocks.REDSTONE_BLOCK.defaultBlockState())) .thenIdle(4) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.detector.powered"), "script did not report block.detector.powered")) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.detector.changed"), "script did not report block.detector.changed")) + .thenWaitUntil(() -> assertFired(helper, "block.detector.powered")) + .thenWaitUntil(() -> assertFired(helper, "block.detector.changed")) .thenSucceed()); } @@ -214,7 +223,7 @@ static void blockDetectorUnpowered(final DynamicTest test) { .thenIdle(4) .thenExecute(() -> helper.setBlock(new BlockPos(2, 2, 1), Blocks.AIR.defaultBlockState())) .thenIdle(4) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.detector.unpowered"), "script did not report block.detector.unpowered")) + .thenWaitUntil(() -> assertFired(helper, "block.detector.unpowered")) .thenSucceed()); } @@ -226,7 +235,7 @@ static void blockEntityTick(final DynamicTest test) { .thenExecute(() -> TestRuntime.clear("block.blockEntityTick")) .thenExecute(() -> helper.setBlock(POS, block("kubejs:test_ticker"))) .thenIdle(5) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.count("block.blockEntityTick") >= 1, "script did not report block.blockEntityTick")) + .thenWaitUntil(() -> assertFired(helper, "block.blockEntityTick")) .thenSucceed()); } @@ -241,7 +250,7 @@ static void blockPicked(final DynamicTest test) { var abs = helper.absolutePos(POS); helper.getBlockState(POS).getCloneItemStack(abs, (ServerLevel) player.level(), true, player); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.picked"), "script did not report block.picked")) + .thenWaitUntil(() -> assertFired(helper, "block.picked")) .thenSucceed()); } @@ -250,7 +259,7 @@ static void blockPicked(final DynamicTest test) { @TestHolder(value = "block_modification", description = "KubeJS BlockEvents.modification fired during startup script load") static void blockModification(final DynamicTest test) { test.onGameTest(helper -> { - helper.assertTrue(TestRuntime.passedStartup("block.modification"), "script did not report block.modification"); + assertj(helper, () -> assertThat(TestRuntime.passedStartup("block.modification")).as("block.modification should have fired during startup").isTrue()); helper.succeed(); }); } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/builder/BuilderTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/builder/BuilderTests.java new file mode 100644 index 000000000..46d59309d --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/builder/BuilderTests.java @@ -0,0 +1,64 @@ +package dev.latvian.mods.kubejs.testmod.builder; + +import dev.latvian.mods.kubejs.testmod.TestRuntime; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.GameType; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertCount; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; + +/// Integration for the builders registered in `builder_integrated.js`: a builder callback fires when +/// its block is used, and a builder-configured food component is applied to the registered item. +@ForEachTest(groups = "kubejs.builder") +public class BuilderTests { + private static final BlockPos POS = new BlockPos(1, 2, 1); + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "builder_block_right_click", description = "A BlockBuilder rightClick callback fires when the block is right-clicked") + static void builderBlockRightClick(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) + .thenExecute(() -> TestRuntime.clear("builder.block.rightClicked")) + .thenExecute(() -> helper.setBlock(POS, BuiltInRegistries.BLOCK.getValue(Identifier.parse("kubejs:builder_block")).defaultBlockState())) + .thenExecute(player -> { + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + var abs = helper.absolutePos(POS); + var hit = new BlockHitResult(Vec3.atCenterOf(abs).add(0, 0.5, 0), Direction.UP, abs, false); + player.gameMode.useItemOn(player, (ServerLevel) player.level(), ItemStack.EMPTY, InteractionHand.MAIN_HAND, hit); + }) + .thenWaitUntil(() -> assertFired(helper, "builder.block.rightClicked")) + .thenExecute(() -> assertCount(helper, "builder.block.rightClicked", 1)) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate + @TestHolder(value = "builder_item_food", description = "An ItemBuilder food component is applied to the registered item") + static void builderItemFood(final DynamicTest test) { + test.onGameTest(helper -> { + var item = BuiltInRegistries.ITEM.getValue(Identifier.parse("kubejs:builder_food")); + var food = new ItemStack(item).get(DataComponents.FOOD); + assertj(helper, () -> { + assertThat(food).as("builder_food food component").isNotNull(); + assertThat(food.nutrition()).as("builder_food nutrition").isEqualTo(6); + }); + helper.succeed(); + }); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityBehaviorTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityBehaviorTests.java new file mode 100644 index 000000000..0c148eccf --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityBehaviorTests.java @@ -0,0 +1,49 @@ +package dev.latvian.mods.kubejs.testmod.entity; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.Items; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; + +/// Category 3. A KubeJS script reacts to spawned entities and manipulates them through the injected +/// *KJS bindings; this verifies the effects Java-side, proving those bindings work end to end. +@ForEachTest(groups = "kubejs.entity.behavior") +public class EntityBehaviorTests { + private static final BlockPos POS = new BlockPos(1, 2, 1); + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_behavior_zombie_held_item", description = "A script gives a spawned zombie a held item via LivingEntityKJS") + static void zombieHeldItem(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.spawnWithNoFreeWill(EntityType.ZOMBIE, POS)) + .thenWaitUntil(() -> assertFired(helper, "entity.behavior.zombie")) + .thenExecute(zombie -> assertj(helper, () -> assertThat(zombie.getItemInHand(InteractionHand.MAIN_HAND).getItem()) + .as("zombie main-hand item") + .isEqualTo(Items.IRON_SWORD))) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "entity_behavior_cow_moved", description = "A script moves a spawned cow 5 blocks south via EntityKJS") + static void cowMovedSouth(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence(() -> helper.spawnWithNoFreeWill(EntityType.COW, POS)) + .thenWaitUntil(() -> assertFired(helper, "entity.behavior.cow")) + .thenExecute(cow -> { + double blockZ = helper.absolutePos(POS).getZ(); + assertj(helper, () -> assertThat(cow.getZ()) + .as("cow z after being moved south") + .isBetween(blockZ + 5.0, blockZ + 6.0)); + }) + .thenSucceed()); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java index 200a14ccf..3092ee077 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java @@ -12,6 +12,10 @@ import net.neoforged.testframework.gametest.EmptyTemplate; import net.neoforged.testframework.gametest.GameTest; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertCount; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; + @ForEachTest(groups = "kubejs.entity.event") public class EntityEventTests { private static final BlockPos POS = new BlockPos(1, 2, 1); @@ -23,7 +27,8 @@ static void entitySpawned(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("entity.spawned")) .thenExecute(() -> helper.spawn(EntityType.PIG, POS)) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.spawned"), "script did not report entity.spawned")) + .thenWaitUntil(() -> assertFired(helper, "entity.spawned")) + .thenExecute(() -> assertCount(helper, "entity.spawned", 1)) .thenSucceed()); } @@ -38,7 +43,7 @@ static void entityBeforeHurt(final DynamicTest test) { var pig = helper.spawn(EntityType.PIG, POS); pig.hurtServer(level, level.damageSources().generic(), 1.0F); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.beforeHurt"), "script did not report entity.beforeHurt")) + .thenWaitUntil(() -> assertFired(helper, "entity.beforeHurt")) .thenSucceed()); } @@ -53,7 +58,7 @@ static void entityAfterHurt(final DynamicTest test) { var pig = helper.spawn(EntityType.PIG, POS); pig.hurtServer(level, level.damageSources().generic(), 1.0F); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.afterHurt"), "script did not report entity.afterHurt")) + .thenWaitUntil(() -> assertFired(helper, "entity.afterHurt")) .thenSucceed()); } @@ -64,7 +69,8 @@ static void entityDeath(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("entity.death")) .thenExecute(player -> helper.spawn(EntityType.PIG, POS).kill((ServerLevel) player.level())) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.death"), "script did not report entity.death")) + .thenWaitUntil(() -> assertFired(helper, "entity.death")) + .thenExecute(() -> assertCount(helper, "entity.death", 1)) .thenSucceed()); } @@ -75,8 +81,8 @@ static void entityDeathAsserts(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("entity.death.assert")) .thenExecute(player -> helper.spawn(EntityType.PIG, POS).kill((ServerLevel) player.level())) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.death.assert"), "script did not assert on entity.death")) - .thenExecute(() -> TestRuntime.verify("entity.death.assert")) + .thenWaitUntil(() -> assertFired(helper, "entity.death.assert")) + .thenExecute(() -> assertVerified(helper, "entity.death.assert")) .thenSucceed()); } @@ -87,7 +93,7 @@ static void entityDrops(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("entity.drops")) .thenExecute(player -> helper.spawn(EntityType.PIG, POS).kill((ServerLevel) player.level())) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.drops"), "script did not report entity.drops")) + .thenWaitUntil(() -> assertFired(helper, "entity.drops")) .thenSucceed()); } @@ -98,7 +104,7 @@ static void entityCheckSpawn(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("entity.checkSpawn")) .thenExecute(player -> EntityType.ZOMBIE.spawn((ServerLevel) player.level(), helper.absolutePos(POS), EntitySpawnReason.SPAWNER)) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("entity.checkSpawn"), "script did not report entity.checkSpawn")) + .thenWaitUntil(() -> assertFired(helper, "entity.checkSpawn")) .thenSucceed()); } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java index e95350dfa..6679f4528 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java @@ -15,6 +15,10 @@ import net.neoforged.testframework.gametest.EmptyTemplate; import net.neoforged.testframework.gametest.GameTest; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertCount; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; + @ForEachTest(groups = "kubejs.item.event") public class ItemEventTests { private static final BlockPos POS = new BlockPos(1, 2, 1); @@ -26,8 +30,9 @@ static void itemDropped(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("item.dropped")) .thenExecute(player -> player.drop(new ItemStack(Items.DIAMOND), false)) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.dropped"), "script did not assert on item.dropped")) - .thenExecute(() -> TestRuntime.verify("item.dropped")) + .thenWaitUntil(() -> assertFired(helper, "item.dropped")) + .thenExecute(() -> assertCount(helper, "item.dropped", 1)) + .thenExecute(() -> assertVerified(helper, "item.dropped")) .thenSucceed()); } @@ -42,7 +47,8 @@ static void itemRightClicked(final DynamicTest test) { player.setItemInHand(InteractionHand.MAIN_HAND, stack); player.gameMode.useItem(player, player.level(), stack, InteractionHand.MAIN_HAND); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.rightClicked"), "script did not report item.rightClicked")) + .thenWaitUntil(() -> assertFired(helper, "item.rightClicked")) + .thenExecute(() -> assertCount(helper, "item.rightClicked", 1)) .thenSucceed()); } @@ -57,7 +63,7 @@ static void itemEntityInteracted(final DynamicTest test) { var pig = helper.spawn(EntityType.PIG, POS); player.interactOn(pig, InteractionHand.MAIN_HAND, pig.position()); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.entityInteracted"), "script did not report item.entityInteracted")) + .thenWaitUntil(() -> assertFired(helper, "item.entityInteracted")) .thenSucceed()); } @@ -74,9 +80,9 @@ static void itemPickedUp(final DynamicTest test) { player.level().addFreshEntity(entity); entity.playerTouch(player); }) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.canPickUp"), "script did not assert on item.canPickUp")) - .thenExecute(() -> TestRuntime.verify("item.canPickUp")) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("item.pickedUp"), "script did not report item.pickedUp")) + .thenWaitUntil(() -> assertFired(helper, "item.canPickUp")) + .thenExecute(() -> assertVerified(helper, "item.canPickUp")) + .thenWaitUntil(() -> assertFired(helper, "item.pickedUp")) .thenSucceed()); } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java deleted file mode 100644 index a37d25e10..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/kjs/KjsReadTests.java +++ /dev/null @@ -1,58 +0,0 @@ -package dev.latvian.mods.kubejs.testmod.kjs; - -import dev.latvian.mods.kubejs.testmod.TestRuntime; -import net.minecraft.core.BlockPos; -import net.minecraft.world.entity.EntityType; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.Items; -import net.minecraft.world.level.GameType; -import net.minecraft.world.level.block.Blocks; -import net.neoforged.testframework.DynamicTest; -import net.neoforged.testframework.annotation.ForEachTest; -import net.neoforged.testframework.annotation.TestHolder; -import net.neoforged.testframework.gametest.EmptyTemplate; -import net.neoforged.testframework.gametest.GameTest; - -/// Drives the events the `kjs_reads.js` fixture listens on, then verifies its assertions (that the -/// *KJS getters resolve to defined values) surfaced no failure. -@ForEachTest(groups = "kubejs.kjs.reads") -public class KjsReadTests { - private static final BlockPos POS = new BlockPos(1, 2, 1); - - @GameTest - @EmptyTemplate(floor = true) - @TestHolder(value = "kjs_entity_reads", description = "EntityKJS/LivingEntityKJS getters resolve for a spawned entity") - static void entityReads(final DynamicTest test) { - test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) - .thenExecute(() -> TestRuntime.clear("kjs.entity.reads")) - .thenExecute(() -> helper.spawn(EntityType.PIG, POS)) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("kjs.entity.reads"), "entity read fixture did not run")) - .thenExecute(() -> TestRuntime.verify("kjs.entity.reads")) - .thenSucceed()); - } - - @GameTest - @EmptyTemplate(floor = true) - @TestHolder(value = "kjs_block_reads", description = "LevelBlock getters resolve for a broken block") - static void blockReads(final DynamicTest test) { - test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) - .thenExecute(() -> TestRuntime.clear("kjs.block.reads")) - .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) - .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("kjs.block.reads"), "block read fixture did not run")) - .thenExecute(() -> TestRuntime.verify("kjs.block.reads")) - .thenSucceed()); - } - - @GameTest - @EmptyTemplate(floor = true) - @TestHolder(value = "kjs_item_reads", description = "ItemStackKJS getters resolve for a dropped item") - static void itemReads(final DynamicTest test) { - test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) - .thenExecute(() -> TestRuntime.clear("kjs.item.reads")) - .thenExecute(player -> player.drop(new ItemStack(Items.DIAMOND), false)) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("kjs.item.reads"), "item read fixture did not run")) - .thenExecute(() -> TestRuntime.verify("kjs.item.reads")) - .thenSucceed()); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java index 28a5f78df..806daf800 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java @@ -10,6 +10,9 @@ import net.neoforged.testframework.gametest.EmptyTemplate; import net.neoforged.testframework.gametest.GameTest; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; + @ForEachTest(groups = "kubejs.level.event") public class LevelEventTests { private static final BlockPos POS = new BlockPos(1, 2, 1); @@ -21,7 +24,7 @@ static void levelTick(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("level.tick")) .thenIdle(2) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("level.tick"), "script did not report level.tick")) + .thenWaitUntil(() -> assertFired(helper, "level.tick")) .thenSucceed()); } @@ -33,10 +36,10 @@ static void levelExplosion(final DynamicTest test) { .thenExecute(() -> TestRuntime.clear("level.beforeExplosion", "level.afterExplosion")) .thenExecute(() -> helper.getLevel().explode(null, null, null, helper.absoluteVec(POS.getCenter()), 3.0F, false, Level.ExplosionInteraction.BLOCK)) .thenIdle(2) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("level.beforeExplosion"), "script did not assert on level.beforeExplosion")) - .thenExecute(() -> TestRuntime.verify("level.beforeExplosion")) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("level.afterExplosion"), "script did not assert on level.afterExplosion")) - .thenExecute(() -> TestRuntime.verify("level.afterExplosion")) + .thenWaitUntil(() -> assertFired(helper, "level.beforeExplosion")) + .thenExecute(() -> assertVerified(helper, "level.beforeExplosion")) + .thenWaitUntil(() -> assertFired(helper, "level.afterExplosion")) + .thenExecute(() -> assertVerified(helper, "level.afterExplosion")) .thenSucceed()); } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java index b809e59d8..6fdc691c0 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java @@ -8,6 +8,8 @@ import net.neoforged.testframework.gametest.EmptyTemplate; import net.neoforged.testframework.gametest.GameTest; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; + @ForEachTest(groups = "kubejs.player.event") public class PlayerEventTests { @GameTest @@ -17,7 +19,7 @@ static void playerTick(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("player.tick")) .thenIdle(2) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("player.tick"), "script did not report player.tick")) + .thenWaitUntil(() -> assertFired(helper, "player.tick")) .thenSucceed()); } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java index e57c42c70..503ccabcd 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java @@ -8,6 +8,9 @@ import net.neoforged.testframework.gametest.EmptyTemplate; import net.neoforged.testframework.gametest.GameTest; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; + @ForEachTest(groups = "kubejs.server.event") public class ServerEventTests { @GameTest @@ -17,7 +20,7 @@ static void serverTick(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("server.tick")) .thenIdle(2) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("server.tick"), "script did not report server.tick")) + .thenWaitUntil(() -> assertFired(helper, "server.tick")) .thenSucceed()); } @@ -28,8 +31,8 @@ static void serverCommand(final DynamicTest test) { test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) .thenExecute(() -> TestRuntime.clear("server.command")) .thenExecute(player -> helper.getLevel().getServer().getCommands().performPrefixedCommand(player.createCommandSourceStack(), "help")) - .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("server.command"), "script did not assert on server.command")) - .thenExecute(() -> TestRuntime.verify("server.command")) + .thenWaitUntil(() -> assertFired(helper, "server.command")) + .thenExecute(() -> assertVerified(helper, "server.command")) .thenSucceed()); } } diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/syntax/SyntaxTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/syntax/SyntaxTests.java new file mode 100644 index 000000000..83e21909c --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/syntax/SyntaxTests.java @@ -0,0 +1,40 @@ +package dev.latvian.mods.kubejs.testmod.syntax; + +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; + +/// Category 4. Verifies the JS-level syntaxes exercised by `syntax_checks.js` still evaluate under the +/// current Rhino, guarding against language regressions between versions. +@ForEachTest(groups = "kubejs.syntax") +public class SyntaxTests { + // syntax.default_params and syntax.spread_rest are disabled in syntax_checks.js - the current Rhino + // rejects default/rest parameters at parse time. Add their ids here if that support ever lands. + private static final String[] IDS = { + "syntax.arrow", + "syntax.destructuring", + "syntax.template_literals", + "syntax.for_of", + "syntax.array_methods", + "syntax.map_and_set", + }; + + @GameTest + @EmptyTemplate + @TestHolder(value = "syntax_features", description = "Core JS language features still evaluate under the current Rhino") + static void syntaxFeatures(final DynamicTest test) { + test.onGameTest(helper -> { + for (var id : IDS) { + assertFired(helper, id); + assertVerified(helper, id); + } + + helper.succeed(); + }); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/wrapper/WrapperTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/wrapper/WrapperTests.java new file mode 100644 index 000000000..bbf708f06 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/wrapper/WrapperTests.java @@ -0,0 +1,48 @@ +package dev.latvian.mods.kubejs.testmod.wrapper; + +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; + +/// Category 2. Verifies the JS->Java type-wrapper conversions asserted by the `wrapper_checks.js` +/// fixture, which runs its assertions across a typed boundary as server scripts load. +@ForEachTest(groups = "kubejs.wrapper") +public class WrapperTests { + private static final String[] IDS = { + "wrapper.vec3", + "wrapper.blockpos", + "wrapper.itemstack", + "wrapper.component", + "wrapper.nbt", + "wrapper.color", + "wrapper.id", + "wrapper.uuid", + "wrapper.tristate", + "wrapper.duration", + "wrapper.regexp", + "wrapper.int_provider", + "wrapper.map_color", + }; + + @GameTest + @EmptyTemplate + @TestHolder(value = "wrapper_conversions", description = "Raw JS values coerce to their Java types via the registered type wrappers") + static void wrapperConversions(final DynamicTest test) { + test.onGameTest(helper -> { + var sequence = helper.startSequence(); + + for (var id : IDS) { + sequence = sequence + .thenWaitUntil(() -> assertFired(helper, id)) + .thenExecute(() -> assertVerified(helper, id)); + } + + sequence.thenSucceed(); + }); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java deleted file mode 100644 index bf281b0c0..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/BlockWrapperTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.plugin.builtin.wrapper.BlockWrapper; -import net.minecraft.resources.Identifier; -import net.minecraft.world.level.block.Blocks; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(BootstrapExtension.class) -public class BlockWrapperTest { - @Test - void resolvesVanillaBlock() { - assertThat(BlockWrapper.getBlock(Identifier.parse("minecraft:stone"))).isSameAs(Blocks.STONE); - assertThat(BlockWrapper.getId(Blocks.STONE)).isEqualTo(Identifier.parse("minecraft:stone")); - } - - @Test - void facingMapIsPopulated() { - assertThat(BlockWrapper.getFacing()).isNotEmpty().containsKey("north"); - } - - @Test - void blockStateStringFormats() { - assertThat(BlockWrapper.toBlockStateString("minecraft:stone", Map.of())).isEqualTo("minecraft:stone"); - assertThat(BlockWrapper.toBlockStateString("minecraft:furnace", Map.of("lit", "true"))) - .contains("minecraft:furnace").contains("lit").contains("true"); - } - - @Test - void idPredicateBuilds() { - assertThat(BlockWrapper.id(Identifier.parse("minecraft:stone"))).isNotNull(); - } - - @Test - void typeListListsCustomTypes() { - assertThat(BlockWrapper.getTypeList()).isNotEmpty(); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java deleted file mode 100644 index 8ed1919fb..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/BooleanComponentTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import com.google.gson.JsonPrimitive; -import com.mojang.serialization.Codec; -import dev.latvian.mods.kubejs.recipe.component.BooleanComponent; -import dev.latvian.mods.rhino.type.TypeInfo; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -@ExtendWith(BootstrapExtension.class) -public class BooleanComponentTest { - @Test - void wrapReadsBooleanValues() { - assertThat(BooleanComponent.BOOLEAN.wrap(null, true)).isTrue(); - assertThat(BooleanComponent.BOOLEAN.wrap(null, false)).isFalse(); - } - - @Test - void wrapReadsJsonPrimitive() { - assertThat(BooleanComponent.BOOLEAN.wrap(null, new JsonPrimitive(true))).isTrue(); - assertThat(BooleanComponent.BOOLEAN.wrap(null, new JsonPrimitive(false))).isFalse(); - } - - @Test - void wrapParsesStrings() { - assertThat(BooleanComponent.BOOLEAN.wrap(null, "true")).isTrue(); - assertThat(BooleanComponent.BOOLEAN.wrap(null, "TRUE")).isTrue(); - assertThat(BooleanComponent.BOOLEAN.wrap(null, "false")).isFalse(); - assertThat(BooleanComponent.BOOLEAN.wrap(null, "yes")).isFalse(); - } - - @Test - void wrapRejectsOtherTypes() { - assertThatThrownBy(() -> BooleanComponent.BOOLEAN.wrap(null, 5)) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> BooleanComponent.BOOLEAN.wrap(null, null)) - .isInstanceOf(IllegalStateException.class); - } - - @Test - void hasPriorityOnlyForBooleans() { - assertThat(BooleanComponent.BOOLEAN.hasPriority(null, true)).isTrue(); - assertThat(BooleanComponent.BOOLEAN.hasPriority(null, new JsonPrimitive(true))).isTrue(); - assertThat(BooleanComponent.BOOLEAN.hasPriority(null, "true")).isFalse(); - assertThat(BooleanComponent.BOOLEAN.hasPriority(null, new JsonPrimitive("x"))).isFalse(); - assertThat(BooleanComponent.BOOLEAN.hasPriority(null, 5)).isFalse(); - } - - @Test - void metadataIsStable() { - assertThat(BooleanComponent.BOOLEAN.toString()).isEqualTo("boolean"); - assertThat(BooleanComponent.BOOLEAN.typeInfo()).isEqualTo(TypeInfo.BOOLEAN); - assertThat(BooleanComponent.BOOLEAN.codec()).isSameAs(Codec.BOOL); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java b/src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java deleted file mode 100644 index e292b1ed3..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/BootstrapExtension.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import net.minecraft.SharedConstants; -import net.minecraft.server.Bootstrap; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.ExtensionContext; - -/// JUnit 5 extension that boots Minecraft's registries once (process-wide) so unit tests can exercise -/// registry-backed script API - wrappers, builders, codecs - without standing up a game server. -/// Apply with {@code @ExtendWith(BootstrapExtension.class)}. -public class BootstrapExtension implements BeforeAllCallback { - private static volatile boolean bootstrapped = false; - - @Override - public void beforeAll(ExtensionContext context) { - if (!bootstrapped) { - synchronized (BootstrapExtension.class) { - if (!bootstrapped) { - SharedConstants.tryDetectVersion(); - Bootstrap.bootStrap(); - bootstrapped = true; - } - } - } - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java deleted file mode 100644 index e6f87ddcb..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/CharacterComponentTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import com.google.gson.JsonPrimitive; -import dev.latvian.mods.kubejs.recipe.component.CharacterComponent; -import dev.latvian.mods.rhino.type.TypeInfo; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(BootstrapExtension.class) -public class CharacterComponentTest { - @Test - void hasPriorityForCharacterLikeValues() { - assertThat(CharacterComponent.CHARACTER.hasPriority(null, 'a')).isTrue(); - assertThat(CharacterComponent.CHARACTER.hasPriority(null, "a")).isTrue(); - assertThat(CharacterComponent.CHARACTER.hasPriority(null, new JsonPrimitive("a"))).isTrue(); - assertThat(CharacterComponent.CHARACTER.hasPriority(null, 5)).isFalse(); - assertThat(CharacterComponent.CHARACTER.hasPriority(null, new JsonPrimitive(5))).isFalse(); - } - - @Test - void isEmptyOnlyForNullCharacter() { - assertThat(CharacterComponent.CHARACTER.isEmpty('\0')).isTrue(); - assertThat(CharacterComponent.CHARACTER.isEmpty('a')).isFalse(); - } - - @Test - void displayStringWrapsInSingleQuotes() { - assertThat(CharacterComponent.CHARACTER.toString(null, 'a')).isEqualTo("'a'"); - } - - @Test - void metadataIsStable() { - assertThat(CharacterComponent.CHARACTER.typeInfo()).isEqualTo(TypeInfo.CHARACTER); - assertThat(CharacterComponent.CHARACTER.toString()).contains("character"); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java deleted file mode 100644 index 6817a18c2..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/ColorWrapperTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.plugin.builtin.wrapper.ColorWrapper; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class ColorWrapperTest { - @Test - void nullAndBlankMapToNone() { - assertThat(ColorWrapper.wrap(null)).isSameAs(ColorWrapper.NONE); - assertThat(ColorWrapper.wrap("")).isSameAs(ColorWrapper.NONE); - assertThat(ColorWrapper.wrap("transparent")).isSameAs(ColorWrapper.NONE); - } - - @Test - void unknownStringMapsToNone() { - assertThat(ColorWrapper.wrap("this_is_not_a_color")).isSameAs(ColorWrapper.NONE); - } - - @Test - void hexStringMakesAColor() { - assertThat(ColorWrapper.wrap("#abcdef")).isNotSameAs(ColorWrapper.NONE); - assertThat(ColorWrapper.wrap("#80abcdef")).isNotSameAs(ColorWrapper.NONE); - } - - @Test - void nonZeroNumberMakesAColor() { - assertThat(ColorWrapper.wrap(0x123456)).isNotSameAs(ColorWrapper.NONE); - assertThat(ColorWrapper.wrap(0)).isSameAs(ColorWrapper.NONE); - } - - @Test - void rgbaBuildsColor() { - assertThat(ColorWrapper.rgba(255, 0, 0, 255)).isNotNull(); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java deleted file mode 100644 index 51c354d0f..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/CountingMapTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.CountingMap; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class CountingMapTest { - @Test - void missingKeyCountsAsZero() { - assertThat(new CountingMap().get("x")).isZero(); - } - - @Test - void addAccumulates() { - var map = new CountingMap(); - map.add("x", 3L); - map.add("x", 2L); - assertThat(map.get("x")).isEqualTo(5L); - } - - @Test - void settingToZeroRemovesKey() { - var map = new CountingMap(); - map.set("x", 4L); - map.set("x", 0L); - assertThat(map.getSize()).isZero(); - assertThat(map.get("x")).isZero(); - } - - @Test - void totalCountSumsValues() { - var map = new CountingMap(); - map.set("a", 2L); - map.set("b", 3L); - assertThat(map.getSize()).isEqualTo(2); - assertThat(map.getTotalCount()).isEqualTo(5L); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java deleted file mode 100644 index a1641fb1c..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/ErrorStackTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.ErrorStack; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class ErrorStackTest { - @Test - void emptyStackRendersNothing() { - var stack = new ErrorStack(); - assertThat(stack.toString()).isEmpty(); - assertThat(stack.atString()).isEmpty(); - } - - @Test - void nestedKeysRenderInOrder() { - var stack = new ErrorStack(); - stack.push("first"); - stack.setKey("a"); - stack.push("second"); - stack.setKey("b"); - assertThat(stack.toString()).isEqualTo("[a][b]"); - assertThat(stack.atString()).isEqualTo(" @ [a][b]"); - assertThat(stack.stringAt()).isEqualTo("[a][b] @ "); - } - - @Test - void poppingUnwindsTheStack() { - var stack = new ErrorStack(); - stack.push("first"); - stack.setKey("a"); - stack.push("second"); - stack.setKey("b"); - stack.pop(); - assertThat(stack.toString()).isEmpty(); - } - - @Test - void noneIsANoOp() { - ErrorStack.NONE.push("x"); - ErrorStack.NONE.setKey("y"); - assertThat(ErrorStack.NONE.toString()).isEmpty(); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java deleted file mode 100644 index d9ab92716..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/IDTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.ID; -import net.minecraft.resources.Identifier; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class IDTest { - @Test - void namespaceDefaultsToMinecraft() { - assertThat(ID.namespace("mod:thing")).isEqualTo("mod"); - assertThat(ID.namespace("thing")).isEqualTo("minecraft"); - assertThat(ID.namespace(null)).isEqualTo("minecraft"); - assertThat(ID.namespace("")).isEqualTo("minecraft"); - } - - @Test - void pathStripsNamespace() { - assertThat(ID.path("mod:thing")).isEqualTo("thing"); - assertThat(ID.path("thing")).isEqualTo("thing"); - assertThat(ID.path(null)).isEqualTo("air"); - } - - @Test - void stringQualifiesWithMinecraft() { - assertThat(ID.string("air")).isEqualTo("minecraft:air"); - assertThat(ID.string("mod:x")).isEqualTo("mod:x"); - assertThat(ID.string("")).isEmpty(); - } - - @Test - void kjsStringQualifiesWithKubeJS() { - assertThat(ID.kjsString("x")).isEqualTo("kubejs:x"); - assertThat(ID.kjsString("mod:x")).isEqualTo("mod:x"); - } - - @Test - void reduceDropsMinecraftNamespaceOnly() { - assertThat(ID.reduce(Identifier.parse("minecraft:stone"))).isEqualTo("stone"); - assertThat(ID.reduce(Identifier.parse("mod:stone"))).isEqualTo("mod:stone"); - } - - @Test - void resourcePathFlattensForeignNamespace() { - assertThat(ID.resourcePath(Identifier.parse("minecraft:stone"))).isEqualTo("stone"); - assertThat(ID.resourcePath(Identifier.parse("mod:stone"))).isEqualTo("mod/stone"); - } - - @Test - void isValidKeyRejectsMalformedStrings() { - assertThat(ID.isValidKey("minecraft:stone")).isTrue(); - assertThat(ID.isValidKey("bad id!")).isFalse(); - assertThat(ID.isValidKey(42)).isFalse(); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java deleted file mode 100644 index caf41bbf4..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/IntBoundsTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.IntBounds; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class IntBoundsTest { - @Test - void commonBoundsAreInterned() { - assertThat(IntBounds.of(1, Integer.MAX_VALUE)).isSameAs(IntBounds.DEFAULT); - assertThat(IntBounds.of(0, Integer.MAX_VALUE)).isSameAs(IntBounds.OPTIONAL); - } - - @Test - void otherBoundsAreDistinct() { - var bounds = IntBounds.of(2, 5); - assertThat(bounds).isNotSameAs(IntBounds.DEFAULT).isNotSameAs(IntBounds.OPTIONAL); - assertThat(bounds.min()).isEqualTo(2); - assertThat(bounds.max()).isEqualTo(5); - } - - @Test - void internedBoundsExposeExpectedValues() { - assertThat(IntBounds.DEFAULT.min()).isEqualTo(1); - assertThat(IntBounds.OPTIONAL.min()).isEqualTo(0); - assertThat(IntBounds.DEFAULT.max()).isEqualTo(Integer.MAX_VALUE); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java deleted file mode 100644 index c04a13142..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/ItemWrapperTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.plugin.builtin.wrapper.ItemWrapper; -import net.minecraft.resources.Identifier; -import net.minecraft.world.item.Items; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(BootstrapExtension.class) -public class ItemWrapperTest { - @Test - void findsVanillaItem() { - var result = ItemWrapper.findItem("minecraft:diamond"); - assertThat(result.result()).isPresent(); - assertThat(result.result().get()).isSameAs(Items.DIAMOND); - } - - @Test - void getIdRoundTrips() { - assertThat(ItemWrapper.getId(Items.DIAMOND)).isEqualTo(Identifier.parse("minecraft:diamond")); - assertThat(ItemWrapper.getItem(Identifier.parse("minecraft:diamond"))).isSameAs(Items.DIAMOND); - } - - @Test - void existsAndIsItem() { - assertThat(ItemWrapper.exists(Identifier.parse("minecraft:diamond"))).isTrue(); - assertThat(ItemWrapper.exists(Identifier.parse("minecraft:definitely_not_an_item"))).isFalse(); - assertThat(ItemWrapper.isItem("not an item object")).isFalse(); - assertThat(ItemWrapper.isItem(Items.DIAMOND)).isFalse(); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java deleted file mode 100644 index 7fd0e4143..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/JsonUtilsTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import com.google.gson.JsonArray; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; -import dev.latvian.mods.kubejs.util.JsonUtils; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -public class JsonUtilsTest { - @Test - void copyReturnsJsonNullForNull() { - assertThat(JsonUtils.copy(null)).isSameAs(JsonNull.INSTANCE); - assertThat(JsonUtils.copy(JsonNull.INSTANCE)).isSameAs(JsonNull.INSTANCE); - } - - @Test - void copyReturnsSamePrimitiveInstance() { - var prim = new JsonPrimitive(42); - assertThat(JsonUtils.copy(prim)).isSameAs(prim); - } - - @Test - void copyDeepCopiesContainersIndependently() { - var inner = new JsonArray(); - inner.add(1); - - var obj = new JsonObject(); - obj.add("list", inner); - - var copy = JsonUtils.copy(obj).getAsJsonObject(); - - assertThat(copy).isEqualTo(obj); - assertThat(copy).isNotSameAs(obj); - - inner.add(2); - - assertThat(copy.getAsJsonArray("list").size()).isEqualTo(1); - assertThat(obj.getAsJsonArray("list").size()).isEqualTo(2); - } - - @Test - void toObjectMapsNullAndJsonNullToNull() { - assertThat(JsonUtils.toObject(null)).isNull(); - assertThat(JsonUtils.toObject(JsonNull.INSTANCE)).isNull(); - } - - @Test - void toObjectConvertsObjectToMap() { - var obj = new JsonObject(); - obj.addProperty("a", 1); - obj.addProperty("b", "x"); - - var result = JsonUtils.toObject(obj); - - assertThat(result).isInstanceOf(Map.class); - var map = (Map) result; - assertThat(map).containsOnlyKeys("a", "b"); - assertThat(((Number) map.get("a")).intValue()).isEqualTo(1); - assertThat(map.get("b")).isEqualTo("x"); - } - - @Test - void toObjectConvertsArrayToList() { - var array = new JsonArray(); - array.add(1); - array.add(2); - - var result = JsonUtils.toObject(array); - - assertThat(result).isInstanceOf(List.class); - assertThat((List) result).hasSize(2); - } - - @Test - void toStringSerializesCompactAndKeepsHtmlChars() { - assertThat(JsonUtils.toString(JsonNull.INSTANCE)).isEqualTo("null"); - assertThat(JsonUtils.toString(new JsonPrimitive("a())).isTrue(); - assertThat(NBTWrapper.isTagCompound(new JsonObject())).isTrue(); - } - - @Test - void isTagCompoundRejectsOtherValues() { - assertThat(NBTWrapper.isTagCompound(5)).isFalse(); - assertThat(NBTWrapper.isTagCompound(new ListTag())).isFalse(); - assertThat(NBTWrapper.isTagCompound(IntTag.valueOf(1))).isFalse(); - } - - @Test - void isTagCollectionAcceptsCollectionLikeValues() { - assertThat(NBTWrapper.isTagCollection(null)).isTrue(); - assertThat(NBTWrapper.isTagCollection("a string")).isTrue(); - assertThat(NBTWrapper.isTagCollection(List.of(1, 2))).isTrue(); - assertThat(NBTWrapper.isTagCollection(new JsonArray())).isTrue(); - } - - @Test - void isTagCollectionRejectsOtherValues() { - assertThat(NBTWrapper.isTagCollection(new CompoundTag())).isFalse(); - assertThat(NBTWrapper.isTagCollection(5)).isFalse(); - assertThat(NBTWrapper.isTagCollection(new HashMap<>())).isFalse(); - } - - @Test - void fromTagHandlesNullAndEnd() { - assertThat(NBTWrapper.fromTag(null)).isNull(); - assertThat(NBTWrapper.fromTag(EndTag.INSTANCE)).isNull(); - } - - @Test - void fromTagUnwrapsScalars() { - assertThat(NBTWrapper.fromTag(StringTag.valueOf("hello"))).isEqualTo("hello"); - assertThat(NBTWrapper.fromTag(IntTag.valueOf(5))).isEqualTo(5); - } - - @Test - void fromTagUnwrapsEmptyCompoundAndList() { - assertThat(NBTWrapper.fromTag(new CompoundTag())).isEqualTo(Map.of()); - assertThat(NBTWrapper.fromTag(new ListTag())).isEqualTo(List.of()); - } - - @Test - void fromTagUnwrapsPopulatedCompound() { - var tag = new CompoundTag(); - tag.put("s", StringTag.valueOf("v")); - tag.put("n", IntTag.valueOf(7)); - - var result = NBTWrapper.fromTag(tag); - assertThat(result).isInstanceOf(Map.class); - assertThat((Map) result).containsEntry("s", "v").containsEntry("n", 7); - } - - @Test - void fromTagUnwrapsPopulatedList() { - var list = new ListTag(); - list.add(StringTag.valueOf("a")); - list.add(StringTag.valueOf("b")); - - var result = NBTWrapper.fromTag(list); - assertThat(result).isInstanceOf(List.class); - assertThat((List) result).containsExactly("a", "b"); - } - - @Test - void toTagIsIdentity() { - var tag = IntTag.valueOf(3); - assertThat(NBTWrapper.toTag(tag)).isSameAs(tag); - assertThat(NBTWrapper.toTag(null)).isNull(); - } - - @Test - void scalarBuildersProduceMatchingTags() { - assertThat(NBTWrapper.byteTag((byte) 5)).isInstanceOf(ByteTag.class).isEqualTo(ByteTag.valueOf((byte) 5)); - assertThat(NBTWrapper.b((byte) 5)).isEqualTo(ByteTag.valueOf((byte) 5)); - assertThat(NBTWrapper.shortTag((short) 6)).isInstanceOf(ShortTag.class).isEqualTo(ShortTag.valueOf((short) 6)); - assertThat(NBTWrapper.s((short) 6)).isEqualTo(ShortTag.valueOf((short) 6)); - assertThat(NBTWrapper.intTag(7)).isInstanceOf(IntTag.class).isEqualTo(IntTag.valueOf(7)); - assertThat(NBTWrapper.i(7)).isEqualTo(IntTag.valueOf(7)); - assertThat(NBTWrapper.longTag(8L)).isInstanceOf(LongTag.class).isEqualTo(LongTag.valueOf(8L)); - assertThat(NBTWrapper.l(8L)).isEqualTo(LongTag.valueOf(8L)); - assertThat(NBTWrapper.floatTag(1.5F)).isInstanceOf(FloatTag.class).isEqualTo(FloatTag.valueOf(1.5F)); - assertThat(NBTWrapper.f(1.5F)).isEqualTo(FloatTag.valueOf(1.5F)); - assertThat(NBTWrapper.doubleTag(2.5)).isInstanceOf(DoubleTag.class).isEqualTo(DoubleTag.valueOf(2.5)); - assertThat(NBTWrapper.d(2.5)).isEqualTo(DoubleTag.valueOf(2.5)); - assertThat(NBTWrapper.stringTag("x")).isInstanceOf(StringTag.class).isEqualTo(StringTag.valueOf("x")); - } - - @Test - void arrayBuildersProduceMatchingTags() { - assertThat(NBTWrapper.intArrayTag(new int[]{1, 2, 3})).isInstanceOf(IntArrayTag.class); - assertThat(NBTWrapper.ia(new int[]{1, 2, 3})).isInstanceOf(IntArrayTag.class); - assertThat(NBTWrapper.longArrayTag(new long[]{1L, 2L})).isInstanceOf(LongArrayTag.class); - assertThat(NBTWrapper.la(new long[]{1L, 2L})).isInstanceOf(LongArrayTag.class); - assertThat(NBTWrapper.byteArrayTag(new byte[]{1, 2})).isInstanceOf(ByteArrayTag.class); - assertThat(NBTWrapper.ba(new byte[]{1, 2})).isInstanceOf(ByteArrayTag.class); - } - - @Test - void emptyContainerBuilders() { - assertThat(NBTWrapper.compoundTag()).isInstanceOf(CompoundTag.class); - assertThat(NBTWrapper.listTag()).isInstanceOf(ListTag.class); - } - - @Test - void toJsonConvertsTags() { - assertThat(NBTWrapper.toJson(null)).isEqualTo(JsonNull.INSTANCE); - assertThat(NBTWrapper.toJson(EndTag.INSTANCE)).isEqualTo(JsonNull.INSTANCE); - - var stringJson = NBTWrapper.toJson(StringTag.valueOf("x")); - assertThat(stringJson).isInstanceOf(JsonPrimitive.class); - assertThat(stringJson.getAsString()).isEqualTo("x"); - - var intJson = NBTWrapper.toJson(IntTag.valueOf(5)); - assertThat(intJson).isInstanceOf(JsonPrimitive.class); - assertThat(intJson.getAsInt()).isEqualTo(5); - } - - @Test - void toJsonRoundTripsCompound() { - var tag = new CompoundTag(); - tag.put("k", StringTag.valueOf("v")); - - var json = NBTWrapper.toJson(tag); - assertThat(json).isInstanceOf(JsonObject.class); - assertThat(json.getAsJsonObject().get("k").getAsString()).isEqualTo("v"); - } - - @Test - void toJsonHandlesList() { - var list = new ListTag(); - list.add(IntTag.valueOf(1)); - list.add(IntTag.valueOf(2)); - - Tag tag = list; - var json = NBTWrapper.toJson(tag); - assertThat(json).isInstanceOf(JsonArray.class); - assertThat(json.getAsJsonArray()).hasSize(2); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java deleted file mode 100644 index 251a456d1..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/NumberComponentTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import com.google.gson.JsonPrimitive; -import dev.latvian.mods.kubejs.recipe.component.NumberComponent; -import dev.latvian.mods.rhino.type.TypeInfo; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -@ExtendWith(BootstrapExtension.class) -public class NumberComponentTest { - @Test - void constantsCoverFullRange() { - assertThat(NumberComponent.INT.min()).isEqualTo(Integer.MIN_VALUE); - assertThat(NumberComponent.INT.max()).isEqualTo(Integer.MAX_VALUE); - assertThat(NumberComponent.LONG.min()).isEqualTo(Long.MIN_VALUE); - assertThat(NumberComponent.LONG.max()).isEqualTo(Long.MAX_VALUE); - assertThat(NumberComponent.FLOAT.min()).isEqualTo(Float.NEGATIVE_INFINITY); - assertThat(NumberComponent.FLOAT.max()).isEqualTo(Float.POSITIVE_INFINITY); - assertThat(NumberComponent.DOUBLE.min()).isEqualTo(Double.NEGATIVE_INFINITY); - assertThat(NumberComponent.DOUBLE.max()).isEqualTo(Double.POSITIVE_INFINITY); - } - - @Test - void rangeFactoriesReuseSharedConstantsForFullRange() { - assertThat(NumberComponent.intRange(Integer.MIN_VALUE, Integer.MAX_VALUE)).isSameAs(NumberComponent.INT); - assertThat(NumberComponent.longRange(Long.MIN_VALUE, Long.MAX_VALUE)).isSameAs(NumberComponent.LONG); - assertThat(NumberComponent.floatRange(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY)).isSameAs(NumberComponent.FLOAT); - assertThat(NumberComponent.doubleRange(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY)).isSameAs(NumberComponent.DOUBLE); - } - - @Test - void intWrapClampsToBounds() { - var range = NumberComponent.intRange(0, 10); - assertThat(range.wrap(null, 5)).isEqualTo(5); - assertThat(range.wrap(null, 20)).isEqualTo(10); - assertThat(range.wrap(null, -3)).isEqualTo(0); - } - - @Test - void longWrapClampsToBounds() { - var range = NumberComponent.longRange(0L, 100L); - assertThat(range.wrap(null, 50L)).isEqualTo(50L); - assertThat(range.wrap(null, 200L)).isEqualTo(100L); - assertThat(range.wrap(null, -5L)).isEqualTo(0L); - } - - @Test - void floatWrapClampsToBounds() { - var range = NumberComponent.floatRange(0F, 1F); - assertThat(range.wrap(null, 0.5)).isEqualTo(0.5F); - assertThat(range.wrap(null, 2.0)).isEqualTo(1.0F); - assertThat(range.wrap(null, -1.0)).isEqualTo(0.0F); - } - - @Test - void doubleWrapClampsToBounds() { - var range = NumberComponent.doubleRange(0.0, 1.0); - assertThat(range.wrap(null, 0.5)).isEqualTo(0.5); - assertThat(range.wrap(null, 2.0)).isEqualTo(1.0); - assertThat(range.wrap(null, -1.0)).isEqualTo(0.0); - } - - @Test - void wrapParsesNumbersFromStringAndJson() { - assertThat(NumberComponent.INT.wrap(null, "7")).isEqualTo(7); - assertThat(NumberComponent.DOUBLE.wrap(null, "2.5")).isEqualTo(2.5); - assertThat(NumberComponent.INT.wrap(null, new JsonPrimitive(42))).isEqualTo(42); - } - - @Test - void wrapRejectsNonNumericValues() { - assertThatThrownBy(() -> NumberComponent.INT.wrap(null, new Object())) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> NumberComponent.INT.wrap(null, null)) - .isInstanceOf(IllegalStateException.class); - } - - @Test - void toStringReflectsBounds() { - assertThat(NumberComponent.INT.toString()).isEqualTo("int"); - assertThat(NumberComponent.LONG.toString()).isEqualTo("long"); - assertThat(NumberComponent.FLOAT.toString()).isEqualTo("float"); - assertThat(NumberComponent.DOUBLE.toString()).isEqualTo("double"); - assertThat(NumberComponent.intRange(0, 10).toString()).isEqualTo("int<0,10>"); - assertThat(NumberComponent.intRange(Integer.MIN_VALUE, 10).toString()).isEqualTo("int"); - assertThat(NumberComponent.intRange(0, Integer.MAX_VALUE).toString()).isEqualTo("int<0,max>"); - } - - @Test - void hasPriorityOnlyForNumbers() { - assertThat(NumberComponent.INT.hasPriority(null, 5)).isTrue(); - assertThat(NumberComponent.INT.hasPriority(null, new JsonPrimitive(5))).isTrue(); - assertThat(NumberComponent.INT.hasPriority(null, "5")).isFalse(); - assertThat(NumberComponent.INT.hasPriority(null, new JsonPrimitive("x"))).isFalse(); - } - - @Test - void typeInfoMatchesNumberKind() { - assertThat(NumberComponent.INT.typeInfo()).isEqualTo(TypeInfo.INT); - assertThat(NumberComponent.LONG.typeInfo()).isEqualTo(TypeInfo.LONG); - assertThat(NumberComponent.FLOAT.typeInfo()).isEqualTo(TypeInfo.FLOAT); - assertThat(NumberComponent.DOUBLE.typeInfo()).isEqualTo(TypeInfo.DOUBLE); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java deleted file mode 100644 index a885159e0..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/Object2LongEntryTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.Object2LongEntry; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class Object2LongEntryTest { - @Test - void higherValueSortsFirst() { - var high = new Object2LongEntry("a", 5L); - var low = new Object2LongEntry("b", 3L); - assertThat(high).isLessThan(low); - assertThat(low).isGreaterThan(high); - } - - @Test - void equalValuesTieByCaseInsensitiveKey() { - var apple = new Object2LongEntry("apple", 5L); - var banana = new Object2LongEntry("Banana", 5L); - assertThat(apple).isLessThan(banana); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java deleted file mode 100644 index 6720a3485..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/RegExpKJSTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.RegExpKJS; -import org.junit.jupiter.api.Test; - -import java.util.regex.Pattern; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class RegExpKJSTest { - @Test - void parsesPatternWithFlag() { - var pattern = RegExpKJS.ofString("/foo/i"); - assertNotNull(pattern); - assertEquals("foo", pattern.pattern()); - assertTrue((pattern.flags() & Pattern.CASE_INSENSITIVE) != 0); - } - - @Test - void rejectsNonRegExpStrings() { - assertNull(RegExpKJS.ofString("foo")); - assertNull(RegExpKJS.ofString("/x")); - } - - @Test - void roundTripsThroughString() { - assertEquals("/a.b/is", RegExpKJS.toRegExpString(RegExpKJS.ofString("/a.b/is"))); - assertEquals("/plain/", RegExpKJS.toRegExpString(RegExpKJS.ofString("/plain/"))); - } - - @Test - void getFlagsCombinesBits() { - assertEquals(Pattern.CASE_INSENSITIVE, RegExpKJS.getFlags("i")); - assertEquals( - Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL, - RegExpKJS.getFlags("ims") - ); - } - - @Test - void flagValidation() { - assertTrue(RegExpKJS.isValidFlag('i')); - assertFalse(RegExpKJS.isValidFlag('z')); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java deleted file mode 100644 index c57745890..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/StringComponentTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import com.google.gson.JsonPrimitive; -import dev.latvian.mods.kubejs.recipe.component.StringComponent; -import dev.latvian.mods.rhino.type.TypeInfo; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(BootstrapExtension.class) -public class StringComponentTest { - @Test - void hasPriorityForStringLikeValues() { - assertThat(StringComponent.STRING.hasPriority(null, "hello")).isTrue(); - assertThat(StringComponent.STRING.hasPriority(null, 'c')).isTrue(); - assertThat(StringComponent.STRING.hasPriority(null, new JsonPrimitive("x"))).isTrue(); - assertThat(StringComponent.STRING.hasPriority(null, 5)).isFalse(); - assertThat(StringComponent.STRING.hasPriority(null, new JsonPrimitive(5))).isFalse(); - } - - @Test - void isEmptyChecksLength() { - assertThat(StringComponent.STRING.isEmpty("")).isTrue(); - assertThat(StringComponent.STRING.isEmpty("a")).isFalse(); - } - - @Test - void spreadSplitsIntoCharacters() { - assertThat(StringComponent.STRING.spread("abc")).containsExactly('a', 'b', 'c'); - assertThat(StringComponent.STRING.spread("")).isEqualTo(List.of()); - } - - @Test - void displayStringIsEscapedAndWrapped() { - assertThat(StringComponent.STRING.toString(null, "hello")).isEqualTo("'hello'"); - assertThat(StringComponent.STRING.toString(null, "it's")).isEqualTo("\"it's\""); - } - - @Test - void allowEmptyFollowsVariant() { - assertThat(StringComponent.STRING.allowEmpty()).isFalse(); - assertThat(StringComponent.OPTIONAL_STRING.allowEmpty()).isTrue(); - } - - @Test - void metadataIsStable() { - assertThat(StringComponent.STRING.typeInfo()).isEqualTo(TypeInfo.STRING); - assertThat(StringComponent.STRING.toString()).contains("string"); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java deleted file mode 100644 index 2ae591d1e..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/TextWrapperTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.plugin.builtin.wrapper.TextWrapper; -import net.minecraft.nbt.IntTag; -import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.contents.KeybindContents; -import net.minecraft.network.chat.contents.TranslatableContents; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -public class TextWrapperTest { - @Test - void ofReturnsSameComponent() { - var component = Component.literal("hi"); - assertThat(TextWrapper.of(component)).isSameAs(component); - } - - @Test - void ofStringEmptyGivesEmptyComponent() { - var component = TextWrapper.ofString(""); - assertThat(TextWrapper.isEmpty(component)).isTrue(); - } - - @Test - void ofStringNonEmptyGivesLiteral() { - var component = TextWrapper.ofString("hello"); - assertThat(component.getString()).isEqualTo("hello"); - assertThat(TextWrapper.isEmpty(component)).isFalse(); - } - - @Test - void emptyIsEmpty() { - var component = TextWrapper.empty(); - assertThat(component.getString()).isEmpty(); - assertThat(TextWrapper.isEmpty(component)).isTrue(); - } - - @Test - void stringAndLiteralProduceLiterals() { - assertThat(TextWrapper.string("abc").getString()).isEqualTo("abc"); - assertThat(TextWrapper.literal("abc").getString()).isEqualTo("abc"); - } - - @Test - void translateProducesTranslatableContents() { - var contents = TextWrapper.translate("my.key").getContents(); - assertThat(contents).isInstanceOf(TranslatableContents.class); - assertThat(((TranslatableContents) contents).getKey()).isEqualTo("my.key"); - } - - @Test - void translatableProducesTranslatableContents() { - var contents = TextWrapper.translatable("my.key").getContents(); - assertThat(contents).isInstanceOf(TranslatableContents.class); - assertThat(((TranslatableContents) contents).getKey()).isEqualTo("my.key"); - } - - @Test - void translateWithFallbackKeepsKeyAndFallback() { - var contents = TextWrapper.translateWithFallback("my.key", "fallback").getContents(); - assertThat(contents).isInstanceOf(TranslatableContents.class); - assertThat(((TranslatableContents) contents).getKey()).isEqualTo("my.key"); - assertThat(((TranslatableContents) contents).getFallback()).isEqualTo("fallback"); - } - - @Test - void translatableWithFallbackKeepsKeyAndFallback() { - var contents = TextWrapper.translatableWithFallback("my.key", "fallback").getContents(); - assertThat(contents).isInstanceOf(TranslatableContents.class); - assertThat(((TranslatableContents) contents).getFallback()).isEqualTo("fallback"); - } - - @Test - void keybindProducesKeybindContents() { - var contents = TextWrapper.keybind("key.jump").getContents(); - assertThat(contents).isInstanceOf(KeybindContents.class); - assertThat(((KeybindContents) contents).getName()).isEqualTo("key.jump"); - } - - @Test - void joinVarargsConcatenates() { - var joined = TextWrapper.join(Component.literal("a"), Component.literal("b")); - assertThat(joined.getString()).isEqualTo("ab"); - } - - @Test - void joinWithSeparator() { - var joined = TextWrapper.join(Component.literal("-"), List.of(Component.literal("a"), Component.literal("b"))); - assertThat(joined.getString()).isEqualTo("a-b"); - } - - @Test - void loreKeepsLines() { - List lines = List.of(Component.literal("line1"), Component.literal("line2")); - assertThat(TextWrapper.lore(lines).lines()).isEqualTo(lines); - } - - @Test - void prettyPrintNbtReturnsComponent() { - assertThat(TextWrapper.prettyPrintNbt(IntTag.valueOf(5))).isNotNull(); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java deleted file mode 100644 index 84ad0e852..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/TickDurationTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.TickDuration; -import dev.latvian.mods.kubejs.util.TickTemporalUnit; -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; - -import static org.assertj.core.api.Assertions.assertThat; - -public class TickDurationTest { - @Test - void zeroIsInterned() { - assertThat(TickDuration.of(0L)).isSameAs(TickDuration.ZERO); - assertThat(TickDuration.of(5L).ticks()).isEqualTo(5L); - } - - @Test - void intTicksClampsToIntRange() { - assertThat(TickDuration.of(Long.MAX_VALUE).intTicks()).isEqualTo(Integer.MAX_VALUE); - assertThat(TickDuration.of(7L).intTicks()).isEqualTo(7); - } - - @Test - void getReturnsTicksOnlyForTickUnit() { - var d = TickDuration.of(5L); - assertThat(d.get(TickTemporalUnit.INSTANCE)).isEqualTo(5L); - assertThat(d.get(ChronoUnit.SECONDS)).isEqualTo(0L); - } - - @Test - void tickUnitIsFiftyMillis() { - assertThat(TickTemporalUnit.INSTANCE.getDuration()).isEqualTo(Duration.ofMillis(50L)); - assertThat(TickTemporalUnit.INSTANCE.isTimeBased()).isTrue(); - assertThat(TickTemporalUnit.INSTANCE.isDateBased()).isFalse(); - } - - @Test - void betweenDividesMillisByFifty() { - assertThat(TickTemporalUnit.INSTANCE.between(Instant.EPOCH, Instant.ofEpochMilli(1000L))).isEqualTo(20L); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java deleted file mode 100644 index eef14f5d1..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/TimeJSTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.TimeJS; -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.assertj.core.api.Assertions.assertThat; - -public class TimeJSTest { - @Test - void readsSimpleUnits() { - assertThat(TimeJS.readDuration("1s").result()).contains(Duration.ofSeconds(1)); - assertThat(TimeJS.readDuration("500ms").result()).contains(Duration.ofMillis(500)); - } - - @Test - void readsTicksAsFiftyMillisEach() { - assertThat(TimeJS.readDuration("20t").result()).contains(Duration.ofSeconds(1)); - } - - @Test - void readsCompoundDuration() { - assertThat(TimeJS.readDuration("1h30m").result()).contains(Duration.ofMinutes(90)); - } - - @Test - void rejectsGarbage() { - assertThat(TimeJS.readDuration("garbage").result()).isEmpty(); - } - - @Test - void msToStringSwitchesUnitAtOneSecond() { - assertThat(TimeJS.msToString(500L)).isEqualTo("500 ms"); - assertThat(TimeJS.msToString(1500L)).isEqualTo("1.500 s"); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java deleted file mode 100644 index 3b34e35a8..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/TinyMapTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.TinyMap; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -public class TinyMapTest { - @Test - void roundTripsThroughMap() { - var source = Map.of("a", 1, "b", 2); - assertThat(TinyMap.ofMap(source).toMap()).isEqualTo(source); - } - - @Test - void emptyMapIsEmpty() { - assertThat(TinyMap.ofMap(Map.of()).isEmpty()).isTrue(); - } - - @Test - void copyConstructorPreservesEntries() { - var original = TinyMap.ofMap(Map.of("a", 1, "b", 2)); - var copy = new TinyMap<>(original); - assertThat(copy.toMap()).isEqualTo(original.toMap()); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java deleted file mode 100644 index 264962b79..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/TristateTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.Tristate; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class TristateTest { - @Test - void wrapCoercesCommonValues() { - assertThat(Tristate.wrap(null)).isEqualTo(Tristate.DEFAULT); - assertThat(Tristate.wrap(true)).isEqualTo(Tristate.TRUE); - assertThat(Tristate.wrap(false)).isEqualTo(Tristate.FALSE); - assertThat(Tristate.wrap(Tristate.TRUE)).isEqualTo(Tristate.TRUE); - } - - @Test - void wrapParsesStringsCaseInsensitively() { - assertThat(Tristate.wrap("TRUE")).isEqualTo(Tristate.TRUE); - assertThat(Tristate.wrap("false")).isEqualTo(Tristate.FALSE); - assertThat(Tristate.wrap("garbage")).isEqualTo(Tristate.DEFAULT); - } - - @Test - void defaultAlwaysPasses() { - assertThat(Tristate.DEFAULT.test(true)).isTrue(); - assertThat(Tristate.DEFAULT.test(false)).isTrue(); - } - - @Test - void trueAndFalseMatchTheirState() { - assertThat(Tristate.TRUE.test(true)).isTrue(); - assertThat(Tristate.TRUE.test(false)).isFalse(); - assertThat(Tristate.FALSE.test(false)).isTrue(); - assertThat(Tristate.FALSE.test(true)).isFalse(); - } - - @Test - void serializedNameMatchesToken() { - assertThat(Tristate.DEFAULT.getSerializedName()).isEqualTo("default"); - } -} diff --git a/src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java b/src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java deleted file mode 100644 index f23c46a59..000000000 --- a/src/test/java/dev/latvian/mods/kubejs/unittest/UtilsJSTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package dev.latvian.mods.kubejs.unittest; - -import dev.latvian.mods.kubejs.util.UtilsJS; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -public class UtilsJSTest { - @SuppressWarnings("unused") - private static final class Fields { - List stringList; - Map stringIntMap; - List[] stringListArray; - List extendsNumber; - List superInteger; - List unbounded; - } - - @SuppressWarnings("unused") - private static final class Simple { - } - - @SuppressWarnings("unused") - private static final class BoundedSingle { - } - - @SuppressWarnings("unused") - private static final class BoundedMulti> { - } - - private static Type fieldType(String name) throws NoSuchFieldException { - return Fields.class.getDeclaredField(name).getGenericType(); - } - - @Test - void classRendersSimpleName() { - assertThat(UtilsJS.toMappedTypeString(String.class)).isEqualTo("String"); - assertThat(UtilsJS.toMappedTypeString(int.class)).isEqualTo("int"); - } - - @Test - void parameterizedTypeRendersGenerics() throws NoSuchFieldException { - assertThat(UtilsJS.toMappedTypeString(fieldType("stringList"))).isEqualTo("List"); - assertThat(UtilsJS.toMappedTypeString(fieldType("stringIntMap"))).isEqualTo("Map"); - } - - @Test - void genericArrayTypeAppendsBrackets() throws NoSuchFieldException { - assertThat(UtilsJS.toMappedTypeString(fieldType("stringListArray"))).isEqualTo("List[]"); - } - - @Test - void wildcardTypesRenderBounds() throws NoSuchFieldException { - assertThat(UtilsJS.toMappedTypeString(fieldType("extendsNumber"))).isEqualTo("List"); - assertThat(UtilsJS.toMappedTypeString(fieldType("superInteger"))).isEqualTo("List"); - assertThat(UtilsJS.toMappedTypeString(fieldType("unbounded"))).isEqualTo("List"); - } - - @Test - void typeVariableRendersNameAndBounds() { - assertThat(UtilsJS.toMappedTypeString(Simple.class.getTypeParameters()[0])).isEqualTo("T"); - assertThat(UtilsJS.toMappedTypeString(BoundedSingle.class.getTypeParameters()[0])).isEqualTo("T extends Number"); - assertThat(UtilsJS.toMappedTypeString(BoundedMulti.class.getTypeParameters()[0])).isEqualTo("T extends Number & Comparable"); - } - - @Test - void nullTypeThrows() { - assertThatThrownBy(() -> UtilsJS.toMappedTypeString(null)).isInstanceOf(IllegalArgumentException.class); - } - - @Test - void onMatchDoRunsConsumerOnlyOnMatch() { - var seen = new ArrayList(); - var predicate = UtilsJS.onMatchDo((Integer i) -> i > 2, seen::add); - - assertThat(predicate.test(5)).isTrue(); - assertThat(predicate.test(1)).isFalse(); - assertThat(seen).containsExactly(5); - } -} diff --git a/src/test/kubejs/server_scripts/binding_checks.js b/src/test/kubejs/server_scripts/binding_checks.js new file mode 100644 index 000000000..09611c57b --- /dev/null +++ b/src/test/kubejs/server_scripts/binding_checks.js @@ -0,0 +1,24 @@ +// Category 3 - JS-driven binding tests. Verify bindings are reachable from JS and behave, and that +// the file-writing bindings (JsonIO, NBTIO) actually create files. BindingTests verifies the +// captured assertions and that the files exist on disk after the run. + +TestRuntime.check('binding.id', () => { + TestRuntime.assertThat(ID.namespace('thing')).isEqualTo('minecraft'); + TestRuntime.assertThat(ID.string('air')).isEqualTo('minecraft:air'); + TestRuntime.assertThat(ID.kjsString('x')).isEqualTo('kubejs:x'); +}); + +TestRuntime.check('binding.text', () => { + TestRuntime.assertThat(Text.ofString('hello').getString()).isEqualTo('hello'); +}); + +TestRuntime.check('binding.jsonio', () => { + JsonIO.write('kubejs/test_binding.json', { hello: 'world', count: 5 }); + TestRuntime.assertThat(JsonIO.readString('kubejs/test_binding.json')).contains('world'); +}); + +TestRuntime.check('binding.nbtio', () => { + NBTIO.write('kubejs/test_binding.nbt', { a: 1 }); + let tag = NBTIO.read('kubejs/test_binding.nbt'); + TestRuntime.assertThat(tag.contains('a')).isTrue(); +}); diff --git a/src/test/kubejs/server_scripts/entity_behavior.js b/src/test/kubejs/server_scripts/entity_behavior.js new file mode 100644 index 000000000..4b0be3b3c --- /dev/null +++ b/src/test/kubejs/server_scripts/entity_behavior.js @@ -0,0 +1,14 @@ +// Category 3 - entity behavior. On spawn, the script acts on the entity through the injected *KJS +// bindings, branching on entity type: the zombie is given a held item (LivingEntityKJS), the cow is +// moved 5 blocks south / +Z (EntityKJS). EntityBehaviorTests spawns both and verifies Java-side. + +EntityEvents.spawned('minecraft:zombie', event => { + event.entity.setMainHandItem(Item.of('minecraft:iron_sword')); + TestRuntime.pass('entity.behavior.zombie'); +}); + +EntityEvents.spawned('minecraft:cow', event => { + let cow = event.entity; + cow.setPosition(cow.x, cow.y, cow.z + 5); + TestRuntime.pass('entity.behavior.cow'); +}); diff --git a/src/test/kubejs/server_scripts/kjs_reads.js b/src/test/kubejs/server_scripts/kjs_reads.js deleted file mode 100644 index 30139f97f..000000000 --- a/src/test/kubejs/server_scripts/kjs_reads.js +++ /dev/null @@ -1,198 +0,0 @@ -// Coverage + sanity fixtures for the vanilla-injected *KJS convenience getters (EntityKJS, -// LivingEntityKJS, PlayerKJS/ServerPlayerKJS, LevelBlock, ItemStackKJS). Each listener piggy-backs -// on an event the existing @GameTests already drive: it first reads a broad set of getters (so every -// property is exercised for coverage), then asserts - inside a TestRuntime.check block that the paired -// KjsReadTests verify - that the always-present properties actually resolve (not null / not JS -// undefined). Conditionally-null getters (profile on a non-player, block-entity on a plain block, ...) -// are read for coverage but not asserted. - -function readEntity(e) { - e.level; - e.server; - e.type; - e.username; - e.name; - e.displayName; - e.profile; - e.self; - e.player; - e.serverPlayer; - e.clientPlayer; - e.item; - e.frame; - e.living; - e.monster; - e.animal; - e.ambientCreature; - e.waterCreature; - e.peacefulCreature; - e.motionX; - e.motionY; - e.motionZ; - e.passengers; - e.teamId; - e.teamName; - e.onScoreboardTeam; - e.facing; - e.block; - e.nbt; - e.scriptType; - e.id; - e.rawPersistentData; -} - -function readLiving(e) { - e.undead; - e.potionEffects; - e.mainHandItem; - e.offHandItem; - e.headArmorItem; - e.chestArmorItem; - e.legsArmorItem; - e.feetArmorItem; - e.totalMovementSpeed; - e.defaultMovementSpeed; -} - -function readPlayer(e) { - e.fake; - e.stages; - e.stats; - e.miningBlock; - e.inventory; - e.selectedSlot; - e.mouseItem; - e.openInventory; - e.foodLevel; - e.saturation; - e.xp; - e.xpLevel; - e.reachDistance; -} - -function readServerPlayer(e) { - e.serverPlayer; - e.op; - e.spawnLocation; -} - -EntityEvents.spawned(event => { - let e = event.entity; - readEntity(e); - - if (e.living) { - readLiving(e); - } - - if (e.player) { - readPlayer(e); - } - - if (e.serverPlayer) { - readServerPlayer(e); - } - - TestRuntime.check('kjs.entity.reads', () => { - TestRuntime.assertDefined('entity.type', e.type); - TestRuntime.assertDefined('entity.name', e.name); - TestRuntime.assertDefined('entity.displayName', e.displayName); - TestRuntime.assertDefined('entity.username', e.username); - TestRuntime.assertDefined('entity.level', e.level); - TestRuntime.assertDefined('entity.block', e.block); - TestRuntime.assertDefined('entity.facing', e.facing); - TestRuntime.assertDefined('entity.nbt', e.nbt); - TestRuntime.assertDefined('entity.passengers', e.passengers); - TestRuntime.assertDefined('entity.scriptType', e.scriptType); - TestRuntime.assertDefined('entity.motionX', e.motionX); - TestRuntime.assertDefined('entity.motionY', e.motionY); - TestRuntime.assertDefined('entity.motionZ', e.motionZ); - }); -}); - -function readBlock(b) { - b.id; - b.block; - b.dimension; - b.dimensionKey; - b.x; - b.y; - b.z; - b.centerX; - b.centerY; - b.centerZ; - b.blockState; - b.properties; - b.entity; - b.entityId; - b.entityData; - b.light; - b.skyLight; - b.blockLight; - b.canSeeSky; - b.inventory; - b.item; - b.drops; - b.biomeId; - b.playersInRadius; - b.up; - b.down; - b.north; - b.south; - b.east; - b.west; - b.toBlockStateString(); -} - -function assertBlock(b) { - TestRuntime.check('kjs.block.reads', () => { - TestRuntime.assertDefined('block.id', b.id); - TestRuntime.assertDefined('block.block', b.block); - TestRuntime.assertDefined('block.blockState', b.blockState); - TestRuntime.assertDefined('block.properties', b.properties); - TestRuntime.assertDefined('block.dimension', b.dimension); - TestRuntime.assertDefined('block.dimensionKey', b.dimensionKey); - TestRuntime.assertDefined('block.biomeId', b.biomeId); - TestRuntime.assertDefined('block.x', b.x); - TestRuntime.assertDefined('block.y', b.y); - TestRuntime.assertDefined('block.z', b.z); - TestRuntime.assertDefined('block.centerX', b.centerX); - TestRuntime.assertDefined('block.up', b.up); - TestRuntime.assertDefined('block.down', b.down); - TestRuntime.assertDefined('block.north', b.north); - TestRuntime.assertDefined('block.toBlockStateString', b.toBlockStateString()); - }); -} - -BlockEvents.broken(event => { - readBlock(event.block); - assertBlock(event.block); -}); - -BlockEvents.placed(event => readBlock(event.block)); - -function readItem(item) { - item.id; - item.mod; - item.block; - item.idLocation; - item.key; - item.registry; - item.registryId; - item.enchantments; - item.harvestSpeed; - item.typeData; - item.asHolder(); - item.asIngredient(); -} - -ItemEvents.dropped(event => { - readItem(event.item); - TestRuntime.check('kjs.item.reads', () => { - TestRuntime.assertDefined('item.id', event.item.id); - TestRuntime.assertDefined('item.mod', event.item.mod); - TestRuntime.assertDefined('item.idLocation', event.item.idLocation); - TestRuntime.assertDefined('item.enchantments', event.item.enchantments); - }); -}); - -ItemEvents.rightClicked(event => readItem(event.item)); diff --git a/src/test/kubejs/server_scripts/syntax_checks.js b/src/test/kubejs/server_scripts/syntax_checks.js new file mode 100644 index 000000000..f2868c676 --- /dev/null +++ b/src/test/kubejs/server_scripts/syntax_checks.js @@ -0,0 +1,53 @@ +// Category 4 - syntax-driven tests. Assert the JS syntaxes KubeJS scripts rely on still parse and run +// under the current Rhino. SyntaxTests verifies the captured results. Syntaxes the current Rhino does +// not support are kept below, disabled, so they can be re-enabled the moment support lands (uncomment +// the block and add its id to SyntaxTests.IDS). + +TestRuntime.check('syntax.arrow', () => { + let add = (a, b) => a + b; + TestRuntime.assertThat(add(3, 4) === 7).isTrue(); +}); + +TestRuntime.check('syntax.destructuring', () => { + let [a, b] = [1, 2]; + let { x } = { x: 3 }; + TestRuntime.assertThat(a === 1 && b === 2 && x === 3).isTrue(); +}); + +TestRuntime.check('syntax.template_literals', () => { + let name = 'world'; + TestRuntime.assertThat(`hello ${name}`).isEqualTo('hello world'); +}); + +TestRuntime.check('syntax.for_of', () => { + let total = 0; + for (let n of [1, 2, 3, 4]) { + total += n; + } + TestRuntime.assertThat(total === 10).isTrue(); +}); + +TestRuntime.check('syntax.array_methods', () => { + let result = [1, 2, 3, 4].filter(n => n % 2 === 0).map(n => n * 10); + TestRuntime.assertThat(result.join(',')).isEqualTo('20,40'); +}); + +TestRuntime.check('syntax.map_and_set', () => { + let map = new Map(); + map.set('a', 1); + let set = new Set([1, 1, 2]); + TestRuntime.assertThat(map.get('a') === 1 && set.size === 2).isTrue(); +}); + +// Disabled - the current Rhino rejects these at parse time ("missing formal parameter"). Re-enable +// (and add the id to SyntaxTests.IDS) if default/rest parameter support is ever added. + +// TestRuntime.check('syntax.default_params', () => { +// let add = (a, b = 3) => a + b; +// TestRuntime.assertThat(add(4) === 7).isTrue(); +// }); + +// TestRuntime.check('syntax.spread_rest', () => { +// let sum = (...xs) => xs.reduce((total, n) => total + n, 0); +// TestRuntime.assertThat(sum(...[1, 2, 3], 4) === 10).isTrue(); +// }); diff --git a/src/test/kubejs/server_scripts/wrapper_checks.js b/src/test/kubejs/server_scripts/wrapper_checks.js new file mode 100644 index 000000000..9531e69d7 --- /dev/null +++ b/src/test/kubejs/server_scripts/wrapper_checks.js @@ -0,0 +1,87 @@ +// Category 2 - JS-driven wrapper tests. Each block passes a raw JS value across a typed Java +// boundary (TestRuntime.as*), forcing the registered type wrapper to run, then asserts the coerced +// object. These run once on the first server tick - not at script load - so registry-backed +// conversions (item components) are bound; WrapperTests waits for and verifies the captured results. + +let wrapperChecksRun = false; + +ServerEvents.tick(event => { + if (wrapperChecksRun) { + return; + } + + wrapperChecksRun = true; + + TestRuntime.check('wrapper.vec3', () => { + let v = TestRuntime.asVec3([1, 2, 3]); + TestRuntime.assertThat(v.x()).isEqualTo(1.0); + TestRuntime.assertThat(v.y()).isEqualTo(2.0); + TestRuntime.assertThat(v.z()).isEqualTo(3.0); + }); + + TestRuntime.check('wrapper.blockpos', () => { + let p = TestRuntime.asBlockPos([4, 5, 6]); + TestRuntime.assertThat(p.toShortString()).isEqualTo('4, 5, 6'); + }); + + TestRuntime.check('wrapper.itemstack', () => { + let stack = TestRuntime.asItemStack('minecraft:diamond'); + TestRuntime.assertThat(stack.id).isEqualTo('minecraft:diamond'); + }); + + TestRuntime.check('wrapper.component', () => { + let component = TestRuntime.asComponent('hello'); + TestRuntime.assertThat(component.getString()).isEqualTo('hello'); + }); + + TestRuntime.check('wrapper.nbt', () => { + let tag = TestRuntime.asCompoundTag({ a: 1, b: 'two' }); + TestRuntime.assertThat(tag.contains('a')).isTrue(); + TestRuntime.assertThat(tag.contains('b')).isTrue(); + }); + + TestRuntime.check('wrapper.color', () => { + let color = TestRuntime.asColor('#ff0000'); + TestRuntime.assertThat(color.toHexString()).isEqualTo('#FF0000'); + }); + + TestRuntime.check('wrapper.id', () => { + let id = TestRuntime.asId('kubejs:test'); + TestRuntime.assertThat(id.toString()).isEqualTo('kubejs:test'); + }); + + TestRuntime.check('wrapper.uuid', () => { + let uuid = TestRuntime.asUUID('12345678-1234-1234-1234-123456789abc'); + TestRuntime.assertThat(uuid.toString()).isEqualTo('12345678-1234-1234-1234-123456789abc'); + }); + + TestRuntime.check('wrapper.tristate', () => { + let tristate = TestRuntime.asTristate('true'); + TestRuntime.assertThat(tristate.getSerializedName()).isEqualTo('true'); + }); + + TestRuntime.check('wrapper.duration', () => { + let duration = TestRuntime.asDuration('20t'); + TestRuntime.assertThat(duration.toMillis() === 1000).isTrue(); + }); + + TestRuntime.check('wrapper.regexp', () => { + let pattern = TestRuntime.asPattern('/foo/i'); + TestRuntime.assertThat(pattern.pattern()).isEqualTo('foo'); + }); + + // Number-based wrappers: a bare number is a constant provider, a [min, max] list a uniform range. + TestRuntime.check('wrapper.int_provider', () => { + let constant = TestRuntime.asIntProvider(3); + TestRuntime.assertThat(constant.minInclusive() === 3 && constant.maxInclusive() === 3).isTrue(); + + let range = TestRuntime.asIntProvider([2, 8]); + TestRuntime.assertThat(range.minInclusive() === 2 && range.maxInclusive() === 8).isTrue(); + }); + + // Map-based wrapper: a color name resolves to that vanilla map color. + TestRuntime.check('wrapper.map_color', () => { + let color = TestRuntime.asMapColor('color_red'); + TestRuntime.assertThat(color.col).isEqualTo(10040115); + }); +}); diff --git a/src/test/kubejs/startup_scripts/builder_fixtures.js b/src/test/kubejs/startup_scripts/builder_fixtures.js deleted file mode 100644 index 133d6b885..000000000 --- a/src/test/kubejs/startup_scripts/builder_fixtures.js +++ /dev/null @@ -1,128 +0,0 @@ -// Startup fixtures that exercise a wide variety of builder code paths (BlockBuilder, the custom -// block builders, ItemBuilder and FluidBuilder) so that game-test coverage records them. These -// blocks/items/fluids only need to register successfully; no events assert on them. - -StartupEvents.registry('block', event => { - // Basic block hitting a broad spread of BlockBuilder setters. - event.create('cov_basic') - .hardness(2.5) - .resistance(6.0) - .lightLevel(0.5) - .opaque(false) - .fullBlock(true) - .requiresTool() - .slipperiness(0.9) - .speedFactor(1.1) - .jumpFactor(0.8) - .soundType('stone') - .mapColor('stone') - .noValidSpawns(true) - .suffocating(false) - .viewBlocking(false) - .redstoneConductor(false) - .transparent(true) - .waterlogged() - .box(0, 0, 0, 16, 8, 16) - .tagBoth('minecraft:mineable/pickaxe') - .randomTick(cb => {}) - .entityInside(cb => {}) - .steppedOn(cb => {}) - .fallenOn(cb => {}) - .afterFallenOn(cb => {}) - .exploded(cb => {}) - .rotateState(cb => {}) - .mirrorState(cb => {}) - .rightClick(cb => {}) - .defaultState(cb => {}) - .placementState(cb => {}) - .canBeReplaced(cb => true); - - // Helper methods and no-item / unbreakable paths. - event.create('cov_basic_unbreakable') - .unbreakable() - .defaultTranslucent() - .dynamicMapColor(state => 'stone') - .noDrops() - .noItem() - .tagBlock('minecraft:mineable/axe'); - - // Cutout helper, sound helpers, bounciness and drops-less item. - event.create('cov_basic_cutout') - .defaultCutout() - .woodSoundType() - .bounciness(0.5) - .color(0x44FF88); - - // Block that customises its item representation. - event.create('cov_basic_with_item') - .grassSoundType() - .item(i => i.maxStackSize(16).glow(true).rarity('epic').tooltip('A covered block')); - - // Block entity path (BasicKubeBlock.WithEntity). - event.create('cov_basic_entity').blockEntity(be => be.serverTicking()); - - // Custom block types. - event.create('cov_slab', 'slab').hardness(2.0).requiresTool(); - event.create('cov_stairs', 'stairs').hardness(2.0).stoneSoundType(); - event.create('cov_fence', 'fence').hardness(2.0); - event.create('cov_wall', 'wall').hardness(2.0).gravelSoundType(); - event.create('cov_fence_gate', 'fence_gate').hardness(2.0); - event.create('cov_pressure_plate', 'pressure_plate').hardness(0.5).behaviour('birch').ticksToStayPressed(40); - event.create('cov_button', 'button').behaviour('oak').ticksToStayPressed(30); - event.create('cov_falling', 'falling').sandSoundType().dustColor(0x807C7B); - event.create('cov_carpet', 'carpet').glassSoundType(); - event.create('cov_door', 'door').behaviour('spruce').wooden(); - event.create('cov_trapdoor', 'trapdoor').behaviour('oak'); - - // Cardinal / pillar with custom shapes to exercise their shape-rotation code. - event.create('cov_cardinal', 'cardinal').box(2, 0, 2, 14, 12, 14); - event.create('cov_cardinal_entity', 'cardinal').blockEntity(be => be.serverTicking()); - event.create('cov_pillar', 'pillar').box(1, 0, 1, 15, 16, 15).cropSoundType(); - event.create('cov_pillar_entity', 'pillar').blockEntity(be => be.serverTicking()); - - // Crop with a shaped age curve and grow callbacks. - event.create('cov_crop', 'crop') - .age(4, shapes => shapes.wheat()) - .noSeeds() - .farmersCanPlant() - .bonemeal(cb => 2) - .survive(cb => true) - .growTick(cb => 1.0); -}); - -StartupEvents.registry('item', event => { - // Stackable item with fuel, container, food, glow, tooltip, tags. - event.create('cov_item_stack') - .maxStackSize(16) - .glow(true) - .tooltip('Coverage item') - .fireResistant() - .containerItem('minecraft:bucket') - .burnTime(200) - .food(4, 0.3) - .disableRepair() - .rarity('rare') - .tag('minecraft:planks'); - - // Durability item with a rich food builder. - event.create('cov_item_durable') - .maxDamage(250) - .food(f => f.nutrition(6).saturation(0.6).alwaysEdible().fastToEat().eatSeconds(1.2)) - .unstackable(); -}); - -StartupEvents.registry('fluid', event => { - event.create('cov_fluid') - .slopeFindDistance(3) - .levelDecreasePerBlock(2) - .explosionResistance(50) - .tickRate(10) - .tint(0x3F76E4) - .bucketColor(0x3F76E4) - .translucent() - .type(t => {}); - - event.create('cov_fluid_bare') - .noBucket() - .noBlock(); -}); diff --git a/src/test/kubejs/startup_scripts/builder_integrated.js b/src/test/kubejs/startup_scripts/builder_integrated.js new file mode 100644 index 000000000..9c65730a0 --- /dev/null +++ b/src/test/kubejs/startup_scripts/builder_integrated.js @@ -0,0 +1,11 @@ +// Category 1/3 - builder integration. A block with a rightClick callback and a food item, each +// actually exercised by BuilderTests: the block is right-clicked (its callback must fire once) and +// the item's built food properties are asserted Java-side. + +StartupEvents.registry('block', event => { + event.create('builder_block').rightClick(e => TestRuntime.pass('builder.block.rightClicked')); +}); + +StartupEvents.registry('item', event => { + event.create('builder_food').food(f => f.nutrition(6).saturation(0.6)); +}); From cbcd0d865591a8beca09a5bc9296320dfe217bf4 Mon Sep 17 00:00:00 2001 From: hspragg Date: Thu, 9 Jul 2026 18:05:44 -0700 Subject: [PATCH 17/18] Add game tests running wiki recipe, tag, modification and builder examples --- CHANGELOG.md | 1 + .../testmod/builder/RegistryBuilderTests.java | 49 ++++++++++++++ .../modification/ModificationTests.java | 43 +++++++++++++ .../testmod/recipe/RecipeEventTests.java | 42 ++++++++++++ .../kubejs/testmod/tag/TagEventTests.java | 60 +++++++++++++++++ .../kubejs/server_scripts/recipe_events.js | 64 +++++++++++++++++++ src/test/kubejs/server_scripts/tag_events.js | 19 ++++++ .../startup_scripts/modification_fixtures.js | 21 ++++++ .../kubejs/startup_scripts/wiki_builders.js | 28 ++++++++ 9 files changed, 327 insertions(+) create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/builder/RegistryBuilderTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/modification/ModificationTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/recipe/RecipeEventTests.java create mode 100644 src/test/java/dev/latvian/mods/kubejs/testmod/tag/TagEventTests.java create mode 100644 src/test/kubejs/server_scripts/recipe_events.js create mode 100644 src/test/kubejs/server_scripts/tag_events.js create mode 100644 src/test/kubejs/startup_scripts/modification_fixtures.js create mode 100644 src/test/kubejs/startup_scripts/wiki_builders.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bc2b49f..62c405213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add typed script-side `TestRuntime.assertThat` entry points and game tests asserting on event objects (#1153) - Expand game-test and unit-test coverage: level/server/item/player events and util classes (#1153) - Add a bootstrapped JUnit harness and expand unit + game-test coverage of wrappers, recipe components, util, builders, and script-exposed `*KJS` getters (#1153) +- Add game tests running the wiki recipe, tag, item/block modification, and registry-builder example scripts (#1153) ## [8.0.3] - 2026-06-22 diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/builder/RegistryBuilderTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/builder/RegistryBuilderTests.java new file mode 100644 index 000000000..491eb2fbe --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/builder/RegistryBuilderTests.java @@ -0,0 +1,49 @@ +package dev.latvian.mods.kubejs.testmod.builder; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.item.ItemStack; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; + +/// Integration for the wiki registry example scripts (`wiki_builders.js`): the item, block, creative +/// tab and mob effect all register, and one wiki-set property or tag is verified for each. +@ForEachTest(groups = "kubejs.builder") +public class RegistryBuilderTests { + @GameTest + @EmptyTemplate + @TestHolder(value = "wiki_registry_builders", description = "The wiki registry examples register an item, block, creative tab and mob effect") + static void wikiRegistryBuilders(final DynamicTest test) { + test.onGameTest(helper -> { + var itemId = Identifier.parse("kubejs:wiki_item"); + var glowId = Identifier.parse("kubejs:wiki_item_glow"); + var blockId = Identifier.parse("kubejs:wiki_block"); + var tabId = Identifier.parse("kubejs:wiki_tab"); + var effectId = Identifier.parse("kubejs:wiki_effect"); + + var glowStack = new ItemStack(BuiltInRegistries.ITEM.getValue(glowId)); + var block = BuiltInRegistries.BLOCK.getValue(blockId); + var mobEffects = helper.getLevel().registryAccess().lookupOrThrow(Registries.MOB_EFFECT); + + assertj(helper, () -> { + assertThat(BuiltInRegistries.ITEM.containsKey(itemId)).as("wiki_item registered").isTrue(); + assertThat(glowStack.getMaxStackSize()).as("wiki_item_glow max stack size").isEqualTo(16); + assertThat(BuiltInRegistries.BLOCK.containsKey(blockId)).as("wiki_block registered").isTrue(); + assertThat(block.defaultBlockState().is(BlockTags.MINEABLE_WITH_PICKAXE)).as("wiki_block mineable with pickaxe").isTrue(); + assertThat(BuiltInRegistries.CREATIVE_MODE_TAB.containsKey(tabId)).as("wiki_tab registered").isTrue(); + assertThat(mobEffects.get(ResourceKey.create(Registries.MOB_EFFECT, effectId)).isPresent()).as("wiki_effect registered").isTrue(); + }); + + helper.succeed(); + }); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/modification/ModificationTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/modification/ModificationTests.java new file mode 100644 index 000000000..c62c843ae --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/modification/ModificationTests.java @@ -0,0 +1,43 @@ +package dev.latvian.mods.kubejs.testmod.modification; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +/// Integration for the wiki item/block modification examples (`modification_fixtures.js`), both +/// startup events, each asserted by its effect: the ender pearl's max stack size and stone's per-state +/// destroy speed are the modified values. `block.destroySpeed` writes the block state's `destroySpeed` +/// field (read by `BlockState#getDestroySpeed`), not the block's `Properties#destroyTime`. +@ForEachTest(groups = "kubejs.modification") +public class ModificationTests { + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "item_modification_wiki", description = "The wiki ItemEvents.modification example raises ender pearl max stack size") + static void itemModificationWiki(final DynamicTest test) { + test.onGameTest(helper -> { + assertj(helper, () -> assertThat(new ItemStack(Items.ENDER_PEARL).getMaxStackSize()).as("ender pearl max stack size").isEqualTo(64)); + helper.succeed(); + }); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "block_modification_wiki", description = "The wiki BlockEvents.modification example lowers stone destroy speed") + static void blockModificationWiki(final DynamicTest test) { + test.onGameTest(helper -> { + var destroySpeed = Blocks.STONE.defaultBlockState().getDestroySpeed(helper.getLevel(), BlockPos.ZERO); + assertj(helper, () -> assertThat(destroySpeed).as("stone destroy speed").isCloseTo(0.1f, offset(0.001f))); + helper.succeed(); + }); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/recipe/RecipeEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/recipe/RecipeEventTests.java new file mode 100644 index 000000000..a6d54859c --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/recipe/RecipeEventTests.java @@ -0,0 +1,42 @@ +package dev.latvian.mods.kubejs.testmod.recipe; + +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import java.util.stream.Collectors; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; + +/// Integration for the wiki tutorials/recipes example scripts (`recipe_events.js`): the whole example +/// runs at datapack load, and its effect is asserted against the live recipe manager - the added +/// shaped recipe is present, a control vanilla recipe survives, and the removed recipe is gone. +@ForEachTest(groups = "kubejs.recipe.event") +public class RecipeEventTests { + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "recipes_wiki", description = "The wiki recipe example scripts load and take effect on the recipe manager") + static void recipesWiki(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "recipes.wiki"); + assertVerified(helper, "recipes.wiki"); + + var ids = helper.getLevel().getServer().getRecipeManager().getRecipes().stream() + .map(holder -> holder.id().identifier().toString()) + .collect(Collectors.toSet()); + + assertj(helper, () -> { + assertThat(ids).as("added wiki shaped recipe").contains("kubejs:wiki_shaped"); + assertThat(ids).as("control vanilla recipe").contains("minecraft:diamond_block"); + assertThat(ids).as("removed glowstone recipe").doesNotContain("minecraft:glowstone"); + }); + + helper.succeed(); + }); + } +} diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/tag/TagEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/tag/TagEventTests.java new file mode 100644 index 000000000..2dc000134 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/tag/TagEventTests.java @@ -0,0 +1,60 @@ +package dev.latvian.mods.kubejs.testmod.tag; + +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.BlockTags; +import net.minecraft.tags.TagKey; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.testframework.DynamicTest; +import net.neoforged.testframework.annotation.ForEachTest; +import net.neoforged.testframework.annotation.TestHolder; +import net.neoforged.testframework.gametest.EmptyTemplate; +import net.neoforged.testframework.gametest.GameTest; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertVerified; +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertj; +import static org.assertj.core.api.Assertions.assertThat; + +/// Integration for the wiki tutorials/tags example scripts (`tag_events.js`): the tag edits run at +/// datapack load, and their effect is asserted against the bound tags - a fresh item tag gains and +/// loses members and nests another tag, and a vanilla block tag gains a member. +@ForEachTest(groups = "kubejs.tag.event") +public class TagEventTests { + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "tags_item_wiki", description = "The wiki item-tag example adds, removes and nests item tags") + static void tagsItemWiki(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "tags.item.wiki"); + assertVerified(helper, "tags.item.wiki"); + + var tag = TagKey.create(Registries.ITEM, Identifier.parse("kubejs:wiki_tag")); + var parent = TagKey.create(Registries.ITEM, Identifier.parse("kubejs:wiki_parent")); + + assertj(helper, () -> { + assertThat(new ItemStack(Items.DIAMOND).is(tag)).as("diamond added to wiki_tag").isTrue(); + assertThat(new ItemStack(Items.EMERALD).is(tag)).as("emerald removed from wiki_tag").isFalse(); + assertThat(new ItemStack(Items.DIAMOND).is(parent)).as("wiki_tag nested into wiki_parent").isTrue(); + }); + + helper.succeed(); + }); + } + + @GameTest + @EmptyTemplate(floor = true) + @TestHolder(value = "tags_block_wiki", description = "The wiki block-tag example adds cobblestone to the climbable tag") + static void tagsBlockWiki(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "tags.block.wiki"); + assertVerified(helper, "tags.block.wiki"); + + assertj(helper, () -> assertThat(Blocks.COBBLESTONE.defaultBlockState().is(BlockTags.CLIMBABLE)).as("cobblestone made climbable").isTrue()); + + helper.succeed(); + }); + } +} diff --git a/src/test/kubejs/server_scripts/recipe_events.js b/src/test/kubejs/server_scripts/recipe_events.js new file mode 100644 index 000000000..c69702560 --- /dev/null +++ b/src/test/kubejs/server_scripts/recipe_events.js @@ -0,0 +1,64 @@ +// Game-test fixtures for RecipeEventTests. Runs the wiki tutorials/recipes example scripts inside a +// single ServerEvents.recipes handler wrapped in TestRuntime.check, so any failure at datapack load +// surfaces to the paired @GameTest. The added `kubejs:wiki_shaped` and the removed `minecraft:glowstone` +// recipes are asserted Java-side against the live RecipeManager. Outputs are vanilla so the wiki +// snippets resolve without needing registered KubeJS items. + +ServerEvents.recipes(event => { + TestRuntime.check('recipes.wiki', () => { + event.shaped(Item.of('minecraft:stone', 3), [ + 'A B', + ' C ', + 'B A' + ], { + A: 'minecraft:andesite', + B: 'minecraft:diorite', + C: 'minecraft:granite' + }).id('kubejs:wiki_shaped'); + + event.shapeless(Item.of('minecraft:dandelion', 3), [ + 'minecraft:bone_meal', + 'minecraft:yellow_dye', + '3x minecraft:ender_pearl' + ]); + + event.smelting('3x minecraft:gravel', 'minecraft:stone'); + event.blasting('10x minecraft:iron_nugget', 'minecraft:iron_ingot'); + event.smoking('minecraft:tinted_glass', 'minecraft:glass').xp(0.35); + event.campfireCooking('minecraft:torch', 'minecraft:stick', 0.35, 600); + + event.stonecutting('3x minecraft:stick', '#minecraft:planks'); + + event.smithing( + 'minecraft:netherite_ingot', + 'minecraft:netherite_upgrade_smithing_template', + 'minecraft:iron_ingot', + 'minecraft:black_dye' + ); + + event.replaceInput({ input: 'minecraft:stick' }, 'minecraft:stick', Ingredient.of('#minecraft:saplings')); + + // Helper-function pattern from the wiki, with a vanilla output. + const potting = (output, pottedInput) => { + event.shaped(output, [ + 'BIB', + ' B ' + ], { + B: 'minecraft:brick', + I: pottedInput + }); + }; + + potting('minecraft:blast_furnace', 'minecraft:furnace'); + + // Looping pattern from the wiki, with vanilla outputs. + ['oak', 'spruce', 'birch'].forEach(wood => { + event.stonecutting(`4x minecraft:${wood}_button`, `minecraft:${wood}_planks`); + }); + + event.remove({ output: 'minecraft:stone_pickaxe' }); + event.remove({ output: '#minecraft:wool' }); + event.remove({ mod: 'farmersdelight' }); + event.remove({ id: 'minecraft:glowstone' }); + }); +}); diff --git a/src/test/kubejs/server_scripts/tag_events.js b/src/test/kubejs/server_scripts/tag_events.js new file mode 100644 index 000000000..6a30fb849 --- /dev/null +++ b/src/test/kubejs/server_scripts/tag_events.js @@ -0,0 +1,19 @@ +// Game-test fixtures for TagEventTests, from the wiki tutorials/tags examples. A fresh kubejs:wiki_tag +// exercises add / remove / tag-in-tag on the item registry, and the block example makes cobblestone +// climbable. Both are wrapped in TestRuntime.check so a load-time failure reaches the paired @GameTest, +// and the tag membership is asserted Java-side. + +ServerEvents.tags('item', event => { + TestRuntime.check('tags.item.wiki', () => { + event.add('kubejs:wiki_tag', 'minecraft:diamond'); + event.add('kubejs:wiki_tag', 'minecraft:emerald'); + event.remove('kubejs:wiki_tag', 'minecraft:emerald'); + event.add('kubejs:wiki_parent', '#kubejs:wiki_tag'); + }); +}); + +ServerEvents.tags('block', event => { + TestRuntime.check('tags.block.wiki', () => { + event.add('minecraft:climbable', 'minecraft:cobblestone'); + }); +}); diff --git a/src/test/kubejs/startup_scripts/modification_fixtures.js b/src/test/kubejs/startup_scripts/modification_fixtures.js new file mode 100644 index 000000000..fcf3acc91 --- /dev/null +++ b/src/test/kubejs/startup_scripts/modification_fixtures.js @@ -0,0 +1,21 @@ +// Startup fixtures for ModificationTests, from the wiki item/block modification tutorials. Both are +// startup events. The ender_pearl max-stack change and the stone hardness change are asserted +// Java-side; the marker (which survives per-test clears via passStartup) confirms the block +// modification handler ran. + +ItemEvents.modification(event => { + // The wiki's `item.fireResistant = true` is omitted: on 26.1 fireResistant takes a damage-type + // holder, not a boolean, so the old form throws. + event.modify('minecraft:ender_pearl', item => { + item.maxStackSize = 64; + item.rarity = 'UNCOMMON'; + }); +}); + +BlockEvents.modification(event => { + event.modify('minecraft:stone', block => { + block.destroySpeed = 0.1; + }); + + TestRuntime.passStartup('modification.block.wiki'); +}); diff --git a/src/test/kubejs/startup_scripts/wiki_builders.js b/src/test/kubejs/startup_scripts/wiki_builders.js new file mode 100644 index 000000000..8bd07af0a --- /dev/null +++ b/src/test/kubejs/startup_scripts/wiki_builders.js @@ -0,0 +1,28 @@ +// Startup fixtures for RegistryBuilderTests, from the wiki registry tutorials (item, block, +// creative-tab and mob-effect registries). Each built object is asserted Java-side: it resolves in +// its registry, and one property or tag from the wiki example is checked. + +StartupEvents.registry('item', event => { + event.create('wiki_item'); + event.create('wiki_item_glow').maxStackSize(16).glow(true); +}); + +StartupEvents.registry('block', event => { + event.create('wiki_block') + .displayName('My Custom Block') + .soundType('wool') + .hardness(1) + .resistance(1) + .tagBlock('minecraft:mineable/pickaxe') + .requiresTool(true); +}); + +StartupEvents.registry('creative_mode_tab', event => { + event.create('wiki_tab') + .icon(() => 'minecraft:dirt') + .content(() => ['minecraft:dirt', 'minecraft:grass_block']); +}); + +StartupEvents.registry('mob_effect', event => { + event.create('wiki_effect').color(0x00FF00).beneficial(); +}); From b94742c2a5de595f25709e1e34b290a35876fe38 Mon Sep 17 00:00:00 2001 From: hspragg Date: Thu, 9 Jul 2026 18:32:58 -0700 Subject: [PATCH 18/18] Add game tests for Item, Ingredient, Text, NBT and JsonUtils bindings --- CHANGELOG.md | 1 + .../kubejs/testmod/binding/BindingTests.java | 69 +++++++++++ .../kubejs/server_scripts/binding_checks.js | 112 ++++++++++++++++++ 3 files changed, 182 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c405213..fc3af2158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Expand game-test and unit-test coverage: level/server/item/player events and util classes (#1153) - Add a bootstrapped JUnit harness and expand unit + game-test coverage of wrappers, recipe components, util, builders, and script-exposed `*KJS` getters (#1153) - Add game tests running the wiki recipe, tag, item/block modification, and registry-builder example scripts (#1153) +- Add game tests covering the `Item`, `Ingredient`, `Text`, `NBT`, and `JsonUtils` script bindings (#1153) ## [8.0.3] - 2026-06-22 diff --git a/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java index 80c84d4fa..029a925b3 100644 --- a/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java @@ -16,6 +16,9 @@ /// Category 3. Verifies that script bindings are reachable and work (`binding_checks.js`), including /// that the file-writing bindings actually create files on disk. +/// +/// ID/Text/NBT/JsonUtils checks fire as the script loads, so they are asserted directly. Item and +/// Ingredient parsing is registry-backed and runs on the first server tick, so those are awaited. @ForEachTest(groups = "kubejs.binding") public class BindingTests { @GameTest @@ -31,6 +34,72 @@ static void bindingReachable(final DynamicTest test) { }); } + @GameTest + @EmptyTemplate + @TestHolder(value = "binding_text", description = "Text binding builders and styling behave as expected") + static void bindingText(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "binding.text.builders"); + assertVerified(helper, "binding.text.builders"); + assertFired(helper, "binding.text.styled"); + assertVerified(helper, "binding.text.styled"); + helper.succeed(); + }); + } + + @GameTest + @EmptyTemplate + @TestHolder(value = "binding_nbt", description = "NBT binding builds compound/typed/list tags and converts to JSON") + static void bindingNbt(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "binding.nbt.compound"); + assertVerified(helper, "binding.nbt.compound"); + assertFired(helper, "binding.nbt.typed"); + assertVerified(helper, "binding.nbt.typed"); + assertFired(helper, "binding.nbt.json"); + assertVerified(helper, "binding.nbt.json"); + helper.succeed(); + }); + } + + @GameTest + @EmptyTemplate + @TestHolder(value = "binding_json", description = "JsonUtils binding round-trips, builds and copies JSON") + static void bindingJson(final DynamicTest test) { + test.onGameTest(helper -> { + assertFired(helper, "binding.json.roundtrip"); + assertVerified(helper, "binding.json.roundtrip"); + assertFired(helper, "binding.json.build"); + assertVerified(helper, "binding.json.build"); + helper.succeed(); + }); + } + + @GameTest + @EmptyTemplate + @TestHolder(value = "binding_item", description = "Item binding parses ids, counts and metadata helpers") + static void bindingItem(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence() + .thenWaitUntil(() -> assertFired(helper, "binding.item.of")) + .thenExecute(() -> assertVerified(helper, "binding.item.of")) + .thenExecute(() -> assertVerified(helper, "binding.item.count")) + .thenExecute(() -> assertVerified(helper, "binding.item.meta")) + .thenSucceed()); + } + + @GameTest + @EmptyTemplate + @TestHolder(value = "binding_ingredient", description = "Ingredient binding matches items, tags, compounds and metadata helpers") + static void bindingIngredient(final DynamicTest test) { + test.onGameTest(helper -> helper.startSequence() + .thenWaitUntil(() -> assertFired(helper, "binding.ingredient.match")) + .thenExecute(() -> assertVerified(helper, "binding.ingredient.match")) + .thenExecute(() -> assertVerified(helper, "binding.ingredient.tag")) + .thenExecute(() -> assertVerified(helper, "binding.ingredient.compound")) + .thenExecute(() -> assertVerified(helper, "binding.ingredient.meta")) + .thenSucceed()); + } + @GameTest @EmptyTemplate @TestHolder(value = "binding_file_io", description = "JsonIO/NBTIO bindings write files that exist on disk afterwards") diff --git a/src/test/kubejs/server_scripts/binding_checks.js b/src/test/kubejs/server_scripts/binding_checks.js index 09611c57b..9b686db0b 100644 --- a/src/test/kubejs/server_scripts/binding_checks.js +++ b/src/test/kubejs/server_scripts/binding_checks.js @@ -1,6 +1,10 @@ // Category 3 - JS-driven binding tests. Verify bindings are reachable from JS and behave, and that // the file-writing bindings (JsonIO, NBTIO) actually create files. BindingTests verifies the // captured assertions and that the files exist on disk after the run. +// +// Registry-independent bindings (ID, Text, NBT, JsonUtils, JsonIO, NBTIO) are checked at script load. +// Item and Ingredient parsing is registry-backed, so those run on the first server tick (see the +// bottom of this file) - the same deferral wrapper_checks.js uses. TestRuntime.check('binding.id', () => { TestRuntime.assertThat(ID.namespace('thing')).isEqualTo('minecraft'); @@ -12,6 +16,60 @@ TestRuntime.check('binding.text', () => { TestRuntime.assertThat(Text.ofString('hello').getString()).isEqualTo('hello'); }); +TestRuntime.check('binding.text.builders', () => { + TestRuntime.assertThat(Text.string('plain').getString()).isEqualTo('plain'); + TestRuntime.assertThat(Text.literal('lit').getString()).isEqualTo('lit'); + TestRuntime.assertThat(Text.of('wrapped').getString()).isEqualTo('wrapped'); + TestRuntime.assertThat(Text.string('a').append(Text.string('b')).getString()).isEqualTo('ab'); + TestRuntime.assertThat(Text.isEmpty(Text.empty())).isTrue(); + // A translatable component falls back to its key when no translation is loaded (server side). + TestRuntime.assertThat(Text.translate('kubejs.test.missing.key').getString()).isEqualTo('kubejs.test.missing.key'); +}); + +TestRuntime.check('binding.text.styled', () => { + let styled = Text.of({ text: 'hi', bold: true, color: 'red' }); + TestRuntime.assertThat(styled.getString()).isEqualTo('hi'); + TestRuntime.assertThat(styled.getStyle().isBold()).isTrue(); + TestRuntime.assertThat(styled.getStyle().getColor() !== null).isTrue(); + // Color helpers apply a color to their input component. + TestRuntime.assertThat(Text.red(Text.string('x')).getStyle().getColor() !== null).isTrue(); +}); + +TestRuntime.check('binding.nbt.compound', () => { + let tag = NBT.compoundTag({ a: 1, b: 'two' }); + TestRuntime.assertThat(tag.contains('a')).isTrue(); + TestRuntime.assertThat(tag.contains('b')).isTrue(); + TestRuntime.assertThat(tag.size() === 2).isTrue(); + TestRuntime.assertThat(NBT.compoundTag().isEmpty()).isTrue(); +}); + +TestRuntime.check('binding.nbt.typed', () => { + TestRuntime.assertThat(NBT.intTag(5).toString()).isEqualTo('5'); + TestRuntime.assertThat(NBT.longTag(7).toString()).isEqualTo('7L'); + TestRuntime.assertThat(NBT.stringTag('hi').toString()).isEqualTo('"hi"'); + TestRuntime.assertThat(NBT.listTag(['a', 'b']).toString()).isEqualTo('["a","b"]'); +}); + +TestRuntime.check('binding.nbt.json', () => { + TestRuntime.assertThat(NBT.toJson(NBT.compoundTag({ k: 'v' })).toString()).isEqualTo('{"k":"v"}'); +}); + +TestRuntime.check('binding.json.roundtrip', () => { + TestRuntime.assertThat(JsonUtils.toString(JsonUtils.fromString('{"msg":"hello"}'))).isEqualTo('{"msg":"hello"}'); + TestRuntime.assertThat(JsonUtils.toObject(JsonUtils.fromString('{"msg":"hi"}')).get('msg') === 'hi').isTrue(); +}); + +TestRuntime.check('binding.json.build', () => { + TestRuntime.assertThat(JsonUtils.toString(JsonUtils.of({ msg: 'hi' }))).isEqualTo('{"msg":"hi"}'); + TestRuntime.assertThat(JsonUtils.toString(JsonUtils.arrayOf(['x', 'y']))).isEqualTo('["x","y"]'); + TestRuntime.assertThat(JsonUtils.toPrettyString(JsonUtils.fromString('{"a":"b"}')).contains('\n')).isTrue(); + + let original = JsonUtils.fromString('{"a":"b"}'); + let copy = JsonUtils.copy(original); + TestRuntime.assertThat(copy !== original).isTrue(); + TestRuntime.assertThat(JsonUtils.toString(copy)).isEqualTo('{"a":"b"}'); +}); + TestRuntime.check('binding.jsonio', () => { JsonIO.write('kubejs/test_binding.json', { hello: 'world', count: 5 }); TestRuntime.assertThat(JsonIO.readString('kubejs/test_binding.json')).contains('world'); @@ -22,3 +80,57 @@ TestRuntime.check('binding.nbtio', () => { let tag = NBTIO.read('kubejs/test_binding.nbt'); TestRuntime.assertThat(tag.contains('a')).isTrue(); }); + +let bindingChecksRun = false; + +ServerEvents.tick(event => { + if (bindingChecksRun) { + return; + } + + bindingChecksRun = true; + + TestRuntime.check('binding.item.of', () => { + let stack = Item.of('minecraft:diamond'); + TestRuntime.assertThat(stack.id).isEqualTo('minecraft:diamond'); + TestRuntime.assertThat(stack.getCount() === 1).isTrue(); + }); + + TestRuntime.check('binding.item.count', () => { + TestRuntime.assertThat(Item.of('minecraft:diamond', 4).getCount() === 4).isTrue(); + TestRuntime.assertThat(Item.of('4x minecraft:diamond').getCount() === 4).isTrue(); + }); + + TestRuntime.check('binding.item.meta', () => { + TestRuntime.assertThat(Item.getEmpty().isEmpty()).isTrue(); + TestRuntime.assertThat(Item.isItem(Item.of('minecraft:diamond'))).isTrue(); + TestRuntime.assertThat(Item.isItem('minecraft:diamond')).isFalse(); + TestRuntime.assertThat(Item.exists('minecraft:diamond')).isTrue(); + TestRuntime.assertThat(Item.exists('minecraft:totally_not_a_real_item')).isFalse(); + }); + + TestRuntime.check('binding.ingredient.match', () => { + let diamonds = Ingredient.of('minecraft:diamond'); + TestRuntime.assertThat(diamonds.test(Item.of('minecraft:diamond'))).isTrue(); + TestRuntime.assertThat(diamonds.test(Item.of('minecraft:dirt'))).isFalse(); + }); + + TestRuntime.check('binding.ingredient.tag', () => { + let planks = Ingredient.of('#minecraft:planks'); + TestRuntime.assertThat(planks.test(Item.of('minecraft:oak_planks'))).isTrue(); + TestRuntime.assertThat(planks.test(Item.of('minecraft:diamond'))).isFalse(); + }); + + TestRuntime.check('binding.ingredient.compound', () => { + let either = Ingredient.of(['minecraft:diamond', 'minecraft:dirt']); + TestRuntime.assertThat(either.test(Item.of('minecraft:diamond'))).isTrue(); + TestRuntime.assertThat(either.test(Item.of('minecraft:dirt'))).isTrue(); + TestRuntime.assertThat(either.test(Item.of('minecraft:stone'))).isFalse(); + }); + + TestRuntime.check('binding.ingredient.meta', () => { + TestRuntime.assertThat(Ingredient.isIngredient(Ingredient.of('minecraft:diamond'))).isTrue(); + TestRuntime.assertThat(Ingredient.isIngredient('minecraft:diamond')).isFalse(); + TestRuntime.assertThat(Ingredient.of('minecraft:diamond').first.id).isEqualTo('minecraft:diamond'); + }); +});