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/CHANGELOG.md b/CHANGELOG.md index a29fe95eb..fc3af2158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## Unreleased - 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 (#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) +- Add game tests covering the `Item`, `Ingredient`, `Text`, `NBT`, and `JsonUtils` script bindings (#1153) ## [8.0.3] - 2026-06-22 diff --git a/build.gradle.kts b/build.gradle.kts index fce39b29f..4b0911924 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("com.almostreliable.almostgradle") version "2.2.0" id("idea") + jacoco // id("me.shedaniel.unified-publishing") version "0.1.+" } @@ -23,7 +27,10 @@ almostgradle.setup { withAccessTransformerValidation = !runningInCI tests { - + testMod = true + gameTests = true + testFramework = true + jUnit = true } recipeViewers { @@ -119,6 +126,119 @@ dependencies { prefer(batVersion) } }) + + testImplementation("org.mockito:mockito-core:5.18.0") + testImplementation("org.assertj:assertj-core:3.27.3") +} + +// 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) +} + +// 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 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) { + outputs.upToDateWhen { false } + } + extensions.configure { + includes = coverageIncludes + } +} + +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 } + } + 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/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 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/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/KubeJSGameTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java new file mode 100644 index 000000000..69ceb6c91 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java @@ -0,0 +1,24 @@ +package dev.latvian.mods.kubejs.testmod; + +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.testframework.conf.FrameworkConfiguration; + +/// 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, ModContainer container) { + var framework = FrameworkConfiguration + .builder(Identifier.fromNamespaceAndPath(MOD_ID, "tests")) + .build() + .create(); + + framework.init(modBus, container); + } +} 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..5504d5100 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java @@ -0,0 +1,207 @@ +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; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.IterableAssert; +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')}. +/// +/// 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. +/// +/// 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); + } + + public static boolean passed(String id) { + return COUNTS.containsKey(id); + } + + public static int count(String id) { + return COUNTS.getOrDefault(id, 0); + } + + public static void clear(String... ids) { + for (var id : ids) { + COUNTS.remove(id); + FAILURES.remove(id); + } + } + + public static void passStartup(String id) { + STARTUP.add(id); + } + + public static boolean passedStartup(String id) { + return STARTUP.contains(id); + } + + /// 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 (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) { + var error = FAILURES.get(id); + + if (error != null) { + throw error; + } + } + + /// 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); + } + + 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 IterableAssert assertThat(Iterable actual) { + return Assertions.assertThat(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/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/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/binding/BindingTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java new file mode 100644 index 000000000..029a925b3 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/binding/BindingTests.java @@ -0,0 +1,119 @@ +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. +/// +/// 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 + @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_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") + 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 new file mode 100644 index 000000000..c8c3a53e9 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/block/BlockEventTests.java @@ -0,0 +1,274 @@ +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; +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); + + @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.clear("block.break.dirt")) + .thenExecute(() -> helper.setBlock(POS, Blocks.DIRT.defaultBlockState())) + .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(POS))) + .thenWaitUntil(() -> assertFired(helper, "block.break.dirt")) + .thenExecute(() -> assertCount(helper, "block.break.dirt", 1)) + .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(() -> assertFired(helper, "block.broken.assert")) + .thenExecute(() -> assertVerified(helper, "block.broken.assert")) + .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(() -> assertFired(helper, "block.drops.dirt")) + .thenExecute(() -> assertCount(helper, "block.drops.dirt", 1)) + .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(() -> assertFired(helper, "block.placed")) + .thenExecute(() -> assertCount(helper, "block.placed", 1)) + .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(() -> assertFired(helper, "block.placed.assert")) + .thenExecute(() -> assertVerified(helper, "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") + 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(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "block.detector.powered")) + .thenWaitUntil(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "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 -> { + assertj(helper, () -> assertThat(TestRuntime.passedStartup("block.modification")).as("block.modification should have fired during startup").isTrue()); + 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/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/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/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 new file mode 100644 index 000000000..3092ee077 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/entity/EntityEventTests.java @@ -0,0 +1,110 @@ +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; + +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); + + @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(() -> assertFired(helper, "entity.spawned")) + .thenExecute(() -> assertCount(helper, "entity.spawned", 1)) + .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(() -> assertFired(helper, "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(() -> assertFired(helper, "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(() -> assertFired(helper, "entity.death")) + .thenExecute(() -> assertCount(helper, "entity.death", 1)) + .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(() -> assertFired(helper, "entity.death.assert")) + .thenExecute(() -> assertVerified(helper, "entity.death.assert")) + .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(() -> assertFired(helper, "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(() -> 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 new file mode 100644 index 000000000..6679f4528 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/item/ItemEventTests.java @@ -0,0 +1,88 @@ +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; + +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); + + @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(() -> assertFired(helper, "item.dropped")) + .thenExecute(() -> assertCount(helper, "item.dropped", 1)) + .thenExecute(() -> assertVerified(helper, "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(() -> assertFired(helper, "item.rightClicked")) + .thenExecute(() -> assertCount(helper, "item.rightClicked", 1)) + .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(() -> assertFired(helper, "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(() -> 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/level/LevelEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java new file mode 100644 index 000000000..806daf800 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/level/LevelEventTests.java @@ -0,0 +1,45 @@ +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; + +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); + + @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(() -> assertFired(helper, "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(() -> 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/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/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; 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..6fdc691c0 --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/player/PlayerEventTests.java @@ -0,0 +1,25 @@ +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; + +import static dev.latvian.mods.kubejs.testmod.GameAsserts.assertFired; + +@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(() -> assertFired(helper, "player.tick")) + .thenSucceed()); + } +} 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/server/ServerEventTests.java b/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java new file mode 100644 index 000000000..503ccabcd --- /dev/null +++ b/src/test/java/dev/latvian/mods/kubejs/testmod/server/ServerEventTests.java @@ -0,0 +1,38 @@ +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; + +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 + @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(() -> assertFired(helper, "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(() -> 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/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/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/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(); + }); +}); 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..9b686db0b --- /dev/null +++ b/src/test/kubejs/server_scripts/binding_checks.js @@ -0,0 +1,136 @@ +// 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'); + 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.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'); +}); + +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(); +}); + +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'); + }); +}); 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_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/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/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/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/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'); + }); +}); 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/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/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/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')); 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)); +}); 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(); +}); 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