diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72917b1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +# Every pull request runs the unit tests, the builder checks and the server game tests +# (`./gradlew check`), then the client GUI smoke test with and without JEI. +on: + pull_request: + push: + branches: [master-2.0-1.21.1-neo] + +jobs: + check: + name: Unit tests, checks and game tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v6 + + - name: Check + run: ./gradlew check --stacktrace + + - name: Upload test reports and the game test log + if: always() + uses: actions/upload-artifact@v7 + with: + name: check-reports + path: | + build/reports/tests/ + build/gametest/logs/ + if-no-files-found: ignore + + gui-smoke: + name: Client GUI smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v6 + + # No display: Xvfb and Mesa's software OpenGL render the client off screen. + - name: Install Xvfb and Mesa + run: | + sudo apt-get update + sudo apt-get install -y xvfb libgl1-mesa-dri + + - name: GUI smoke test with JEI + timeout-minutes: 20 + run: xvfb-run -a ./gradlew runClient -I scripts/gui-smoke.init.gradle + + - name: GUI smoke test without JEI + timeout-minutes: 20 + run: xvfb-run -a ./gradlew runClient -PwithoutJei -I scripts/gui-smoke.init.gradle + + - name: Upload the client logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: gui-smoke-logs + path: | + build/gui-smoke/logs/ + build/gui-smoke-without-jei/logs/ + if-no-files-found: ignore diff --git a/build.gradle b/build.gradle index 51abddf..21c5508 100644 --- a/build.gradle +++ b/build.gradle @@ -68,7 +68,7 @@ tasks.configureEach { task -> || task.name.startsWith('test') || task.name in ['compileTestJava', 'compileBuilderTestJava', 'compileRuntimeTestJava', 'writeMinecraftClasspathJunit', 'writeMinecraftClasspathClient', 'writeMinecraftClasspathServer', - 'runClient', 'runServer']) { + 'runClient', 'runServer', 'runGameTestServer']) { task.mustRunAfter(refreshDataResources) } } diff --git a/docs/vanilla-extensions.md b/docs/vanilla-extensions.md index 0c8319b..38f8c67 100644 --- a/docs/vanilla-extensions.md +++ b/docs/vanilla-extensions.md @@ -141,21 +141,18 @@ checks every planned item/block has its packaged resources, verifies no unwanted vanilla tool/armor duplicates, and round-trips the runtime references through JSON/NBT and MaterialBuilder. -An opt-in, self-stopping server smoke test exercises real registration, block -placement, ammunition stack recovery after entity save/load, fluid placement and -bucket recovery, actual mining tags, and packaged armor texture references: +Server game tests exercise real registration, block placement, ammunition stack +recovery after entity save/load, fluid placement and bucket recovery, actual mining +tags, and packaged armor texture references. They are part of `./gradlew check`, or +run them alone: ```sh -./gradlew runServer --offline --stacktrace -I scripts/runtime-smoke.init.gradle -./gradlew build check --offline --stacktrace +./gradlew runGameTestServer ``` -The smoke run uses `build/runtime-smoke/server`, loopback networking, and a random -port. It stops itself and the Gradle task fails if the success marker is absent. -Its entrypoint lives in `src/runtimeTest/java`, outside release sources; `./gradlew check` -compiles it to prevent API drift. The second command restores regular -main class output after the opt-in test run. The release JAR must contain no -`zone/moddev/mc/lib/smoke` classes. +Each check is its own game test in `MMDLibGameTests` (`src/runtimeTest/java`, outside +release sources), and the task fails naming every test that failed. The release JAR +must contain no `zone/moddev/mc/lib/smoke` classes. Verified on a dedicated development server: all 50 block items placed, all twelve arrow/bolt types preserved the correct pickup item and components across entity diff --git a/gradle/verification.gradle b/gradle/verification.gradle index 2e48d76..844d990 100644 --- a/gradle/verification.gradle +++ b/gradle/verification.gradle @@ -13,6 +13,32 @@ sourceSets { } tasks.named('check') { dependsOn tasks.named('compileRuntimeTestJava') } +// Server game tests in src/runtimeTest (MMDLibGameTests): `./gradlew runGameTestServer`, part of +// `check`. The run loads main and runtimeTest as one mod, in a fresh world each time, and exits +// with the number of failed tests. Release jars never contain runtimeTest (verifyReleaseClasses). +def gameTestDir = layout.buildDirectory.dir('gametest') +runs { + gameTestServer { + workingDirectory gameTestDir + modSource project.sourceSets.runtimeTest + systemProperty 'neoforge.enabledGameTestNamespaces', 'mmdlib' + } +} +// NeoGradle creates the run task late, so it is found by name. +def gameTestTask = tasks.matching { it.name == 'runGameTestServer' } +gameTestTask.configureEach { it.doFirst { project.delete(gameTestDir); gameTestDir.get().asFile.mkdirs() } } +tasks.named('check') { dependsOn gameTestTask } + +// Unit tests in src/test: JUnit 5, no running game. `./gradlew test` (part of `check`). +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.12.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +tasks.named('test', Test) { + useJUnitPlatform() + testLogging { events 'failed'; exceptionFormat 'full' } +} + // Standalone checks in src/builderTest, run as plain Java programs: task name -> main class // (in zone.moddev.mc.lib.common.builders), plus any system properties the check reads. def resources = file('src/main/resources').absolutePath @@ -32,7 +58,6 @@ def builderChecks = [ verifyMethodEvents : ['MethodEventsTest'], verifyDefinitionLoader : ['DefinitionLoaderTest'], verifyScreens : ['zone.moddev.mc.lib.gui.ScreenLoaderTest'], - verifyMachineCore : ['zone.moddev.mc.lib.machine.MachineCoreTest'], verifyVanillaExamples : ['VanillaExamplesTest', ['mmdlib.resources': resources, 'mmdlib.generated': file('src/generated/resources').absolutePath]], ] @@ -52,8 +77,9 @@ def verifyReleaseClasses = tasks.register('verifyReleaseClasses') { dependsOn tasks.named('jar') doLast { new java.util.zip.ZipFile(tasks.named('jar').get().archiveFile.get().asFile).withCloseable { archive -> - def leaked = archive.entries().findAll { entry -> entry.name.startsWith('zone/moddev/mc/lib/smoke/') } - if (!leaked.isEmpty()) throw new GradleException('Runtime smoke-test classes must not be packaged: ' + leaked) + def testOnly = ['zone/moddev/mc/lib/smoke/', 'data/mmdlib_smoke/', 'data/mmdlib/structure/'] + def leaked = archive.entries().findAll { entry -> testOnly.any { entry.name.startsWith(it) } } + if (!leaked.isEmpty()) throw new GradleException('Test classes and data must not be packaged: ' + leaked.collect { it.name }) } } } diff --git a/scripts/runtime-smoke.init.gradle b/scripts/runtime-smoke.init.gradle deleted file mode 100644 index 5cb642f..0000000 --- a/scripts/runtime-smoke.init.gradle +++ /dev/null @@ -1,36 +0,0 @@ -// Run: ./gradlew runServer --offline -I scripts/runtime-smoke.init.gradle -// Run a normal ./gradlew build afterward to restore regular main output. -allprojects { - afterEvaluate { - if (extensions.findByName('runs') != null) { - sourceSets.main.java.srcDir(file('src/runtimeTest/java')) - def smokeDir = file('build/runtime-smoke/server') - smokeDir.mkdirs() - new File(smokeDir, 'server.properties').text = '''server-ip=127.0.0.1 -server-port=0 -online-mode=true -level-name=runtime-smoke -spawn-protection=0 -''' - // A real datapack in the smoke world, so reload listeners read files the way packs ship them. - def datapack = new File(smokeDir, 'runtime-smoke/datapacks/mmdlib_smoke') - project.delete(datapack) - project.copy { - from file('src/runtimeTest/datapack') - into datapack - } - runs.server.workingDirectory = smokeDir - // NeoGradle creates run tasks in a later evaluation callback. - tasks.configureEach { task -> - if (task.name == 'runServer') { - task.doLast { - def report = new File(smokeDir, 'logs/latest.log') - if (!report.exists() || !report.text.contains('MMDLIB_RUNTIME_SMOKE_PASS') || report.text.contains('MMDLIB_RUNTIME_SMOKE_FAIL')) { - throw new GradleException('MMDLib runtime smoke test failed; inspect ' + report) - } - } - } - } - } - } -} diff --git a/src/builderTest/java/zone/moddev/mc/lib/machine/MachineCoreTest.java b/src/builderTest/java/zone/moddev/mc/lib/machine/MachineCoreTest.java deleted file mode 100644 index 3c35f44..0000000 --- a/src/builderTest/java/zone/moddev/mc/lib/machine/MachineCoreTest.java +++ /dev/null @@ -1,485 +0,0 @@ -package zone.moddev.mc.lib.machine; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalDouble; -import java.util.OptionalInt; -import java.util.Set; -import java.util.function.Function; - -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.google.gson.JsonPrimitive; - -import com.mojang.serialization.JsonOps; - -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.entity.ai.attributes.AttributeModifier; - -import zone.moddev.mc.lib.common.types.MaterialProperties; -import zone.moddev.mc.lib.machine.expr.Expr; -import zone.moddev.mc.lib.machine.ingredient.AmountMatch; -import zone.moddev.mc.lib.machine.internals.InternalsDefinition; -import zone.moddev.mc.lib.machine.internals.MachineParts; -import zone.moddev.mc.lib.machine.internals.PartDefinition; -import zone.moddev.mc.lib.machine.internals.PartSubject; -import zone.moddev.mc.lib.machine.internals.SwapRule; -import zone.moddev.mc.lib.machine.recipe.InputClaims; -import zone.moddev.mc.lib.machine.stat.MachineStats; -import zone.moddev.mc.lib.machine.stat.StatDefinition; -import zone.moddev.mc.lib.machine.stat.StatModifier; - -/** Checks the machine module's core types, which need no running game. */ -public final class MachineCoreTest { - public static void main(String[] args) { - expressions(); - stats(); - internalsDefinition(); - parts(); - amountMatch(); - inputClaims(); - System.out.println( - "Machine expressions, stats, internals definitions, part files, amount matching and input" - + " claims passed"); - } - - private static void expressions() { - require( - near(Expr.parse("0.08 * ($material.hardness - 3)").eval(v("material.hardness", 6)), .24)); - require(Expr.parse("-2 + 3 * 4").eval(none()) == 10); - require(Expr.parse("(1 + 2) / 4").eval(none()) == .75); - require(Expr.parse("10 - 4 - 3").eval(none()) == 3); - require(Expr.parse("2 * -(1 + 1)").eval(none()) == -4); - require(Expr.parse("$a.b").variables().equals(Set.of("a.b"))); - require(Expr.parse("1 + 2").variables().isEmpty()); - require(Double.isInfinite(Expr.parse("1 / $x").eval(v("x", 0)))); - require(Expr.constant(3).eval(none()) == 3); - expectFailure(() -> Expr.parse("2 *")); - expectFailure(() -> Expr.parse("2 $")); - expectFailure(() -> Expr.parse("(1")); - expectFailure(() -> Expr.parse("")); - expectFailure(() -> Expr.parse("$missing").eval(none())); - expectFailure(() -> Expr.parse("$a..b")); - expectFailure(() -> Expr.parse("$a.")); - expectFailure(() -> Expr.parse("$.a")); - require( - Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(12)).getOrThrow().eval(none()) == 12); - require( - Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive("$x * 2")).getOrThrow().eval(v("x", 4)) - == 8); - require(Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive("2 *")).error().isPresent()); - // Encoding round-trips, including numbers Double.toString would write in E notation. - var big = Expr.parse("10000000 * $x + 0.0001"); - var encoded = Expr.CODEC.encodeStart(JsonOps.INSTANCE, big).getOrThrow(); - require( - near( - Expr.CODEC.parse(JsonOps.INSTANCE, encoded).getOrThrow().eval(v("x", 2)), - 20000000.0001)); - } - - private static void stats() { - var defs = - Map.of( - "speed", new StatDefinition(1, OptionalDouble.of(.25), OptionalDouble.of(8)), - "energy_per_tick", - new StatDefinition(40, OptionalDouble.of(1), OptionalDouble.empty())); - // The worked table in machine-ui.md, with example hardness iron 4 and invar 6. - var iron = List.of(base("speed", .08), base("energy_per_tick", .12)); - var invar = List.of(base("speed", .24), base("energy_per_tick", .18)); - var motor = List.of(total("speed", .15), value("energy_per_tick", 8)); - var upgrade = List.of(total("speed", .5), total("energy_per_tick", .5)); - var twoIron = MachineStats.compute(defs, concat(iron, iron)); - require(near(twoIron.get("speed"), 1.16) && near(twoIron.get("energy_per_tick"), 49.6)); - var twoInvar = MachineStats.compute(defs, concat(invar, invar)); - require(near(twoInvar.get("speed"), 1.48) && near(twoInvar.get("energy_per_tick"), 54.4)); - var withMotor = MachineStats.compute(defs, concat(invar, invar, motor)); - require(near(withMotor.get("speed"), 1.702) && near(withMotor.get("energy_per_tick"), 65.28)); - var all = concat(invar, invar, motor, upgrade); - var full = MachineStats.compute(defs, all); - require(near(full.get("speed"), 2.553) && near(full.get("energy_per_tick"), 97.92)); - var shuffled = new ArrayList<>(all); - Collections.reverse(shuffled); - require(MachineStats.compute(defs, shuffled).values().equals(full.values())); - require(MachineStats.compute(defs, List.of()).get("speed") == 1); - require(MachineStats.compute(defs, List.of(total("speed", 10))).get("speed") == 8); - require(MachineStats.compute(defs, List.of(value("speed", -5))).get("speed") == .25); - var undeclared = MachineStats.compute(defs, List.of(value("max_temperature", 400))); - require(undeclared.values().size() == 2 && undeclared.get("speed") == 1); - expectFailure(() -> MachineStats.EMPTY.get("speed")); - expectFailure(() -> value("speed", Double.NaN)); - var parsed = parseStat("{\"base\":1,\"min\":0.25,\"max\":8}"); - require(parsed.equals(defs.get("speed"))); - require(parseStat("{\"base\":900}").min().isEmpty()); - expectFailure(() -> parseStat("{\"base\":1,\"min\":5,\"max\":2}")); - expectFailure(() -> parseStat("{\"min\":1}")); - } - - /** The internals example from machine-ui.md, "Sockets and stats". */ - static final String INTERNALS = - """ - { - "open_with": "#c:tools/wrench", - "swap": "when_idle", - "sockets": { - "gears": { "count": 2, "accepts": "#c:gears" }, - "motor": { "count": 1, "accepts": "#example:motors" }, - "coil": { "count": 1, "accepts": "#example:heating_coils", "optional": true } - }, - "stats": { - "speed": { "base": 1.0, "min": 0.25, "max": 8 }, - "energy_per_tick": { "base": 40, "min": 1 }, - "max_temperature": { "base": 900 } - } - } - """; - - private static void internalsDefinition() { - var internals = parseInternals(INTERNALS); - require(internals.swap() == SwapRule.WHEN_IDLE); - require(List.copyOf(internals.sockets().keySet()).equals(List.of("coil", "gears", "motor"))); - require(internals.slotCount() == 4); - require(internals.slots("coil").equals(List.of(0))); - require(internals.slots("gears").equals(List.of(1, 2))); - require(internals.slots("motor").equals(List.of(3))); - require( - internals.sockets().get("coil").optional() && !internals.sockets().get("gears").optional()); - require( - internals - .openWith() - .orElseThrow() - .value() - .left() - .orElseThrow() - .location() - .equals(ResourceLocation.parse("c:tools/wrench"))); - require(internals.stats().get("max_temperature").base() == 900); - var plain = - parseInternals("{\"sockets\":{\"motor\":{\"accepts\":\"example:motor\"}},\"stats\":{}}"); - require(plain.swap() == SwapRule.WHEN_IDLE && plain.openWith().isEmpty()); - require(plain.sockets().get("motor").count() == 1); - require( - plain - .sockets() - .get("motor") - .accepts() - .orElseThrow() - .value() - .right() - .orElseThrow() - .equals(ResourceLocation.parse("example:motor"))); - require(SwapRule.WHEN_IDLE.allows(false) && !SwapRule.WHEN_IDLE.allows(true)); - require(SwapRule.ALWAYS.allows(true) && !SwapRule.NEVER.allows(false)); - expectFailure(() -> parseInternals(INTERNALS.replace("\"count\": 2", "\"count\": 0"))); - expectFailure(() -> parseInternals(INTERNALS.replace("when_idle", "sometimes"))); - expectFailure(() -> parseInternals(INTERNALS.replace("#c:gears", "#"))); - expectFailure(() -> parseInternals(INTERNALS.replace("#c:gears", "Not An Id"))); - expectFailure(() -> internals.slots("pump")); - require(internals.socketAt(0).equals("coil") && internals.socketAt(3).equals("motor")); - expectFailure(() -> internals.socketAt(-1)); - expectFailure(() -> internals.socketAt(4)); - } - - /** The gear template from machine-ui.md, "Part files". */ - static final String GEAR_TEMPLATE = - """ -{ - "part": "mmdlib:gear", - "socket": "gears", - "modifiers": [ - { "stat": "speed", "op": "add_multiplied_base", "amount": "0.08 * ($material.hardness - 3)" }, - { "stat": "energy_per_tick", "op": "add_multiplied_base", "amount": "0.03 * $material.hardness" } - ], - "durability": "500 * $material.hardness" -} -"""; - - private static void parts() { - var subjects = - Map.of( - id("test:iron_gear"), gear("test:iron_gear", 4, Map.of()), - id("test:invar_gear"), gear("test:invar_gear", 6, Map.of("grip", 2.0)), - id("test:soft_gear"), gear("test:soft_gear", 0, Map.of()), - id("test:diamond_coated_gear"), gear("test:diamond_coated_gear", 10, Map.of()), - id("test:plain_rod"), - new PartSubject( - id("test:plain_rod"), Optional.of(id("mmdlib:rod")), Optional.empty()), - id("test:plain_pipe"), - new PartSubject(id("test:plain_pipe"), Optional.empty(), Optional.empty())); - var files = new HashMap(); - files.put(id("test:gear"), JsonParser.parseString(GEAR_TEMPLATE)); - files.put( - id("test:diamond_coated_gear"), - JsonParser.parseString( - """ - { - "item": "test:diamond_coated_gear", - "socket": "gears", - "modifiers": [ - { "stat": "speed", "op": "add_multiplied_total", "amount": 0.4 }, - { "stat": "energy_per_tick", "op": "add_value", "amount": 12 } - ] - } - """)); - // A second template for the same part: the lower resource ID (test:gear) wins. - files.put( - id("test:zz_gear"), - JsonParser.parseString(GEAR_TEMPLATE.replace("\"gears\"", "\"other\""))); - // An unknown variable: logged and skipped, the rest still load. - files.put( - id("test:broken"), - JsonParser.parseString( - GEAR_TEMPLATE - .replace("mmdlib:gear", "mmdlib:plate") - .replace("$material.hardness - 3", "$material.colour"))); - files.put( - id("test:pipe"), - JsonParser.parseString( - """ -{"item": "test:plain_pipe", "socket": "pipes", - "modifiers": [{"stat": "speed", "op": "add_value", "amount": "$material.special.grip + 1"}]} -""")); - files.put( - id("test:grip"), - JsonParser.parseString( - """ - {"item": "test:invar_gear", "socket": "gears", - "modifiers": [{"stat": "grip", "op": "add_value", - "amount": "$material.special.grip + $material.special.none"}]} - """)); - var parts = MachineParts.parse(files, item -> Optional.ofNullable(subjects.get(item))); - var iron = parts.resolve(id("test:iron_gear")).orElseThrow(); - require(iron.socket().equals("gears")); - require(near(iron.modifiers().get(0).amount(), .08)); - require(near(iron.modifiers().get(1).amount(), .12)); - require(iron.modifiers().get(0).op() == AttributeModifier.Operation.ADD_MULTIPLIED_BASE); - require(iron.durability().equals(OptionalInt.of(2000))); - // An item file beats the template; missing special keys count as 0. - var invar = parts.resolve(id("test:invar_gear")).orElseThrow(); - require(invar.modifiers().size() == 1 && invar.modifiers().get(0).amount() == 2); - require(invar.durability().isEmpty()); - var coated = parts.resolve(id("test:diamond_coated_gear")).orElseThrow(); - require(coated.modifiers().get(0).op() == AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL); - require(coated.modifiers().get(0).amount() == .4 && coated.durability().isEmpty()); - // Hardness 0: the template's durability is 0, so this gear is not a part. - require(parts.resolve(id("test:soft_gear")).isEmpty()); - // An item without a material can't use $material. - require(parts.resolve(id("test:plain_pipe")).isEmpty()); - require(parts.resolve(id("test:plain_rod")).isEmpty()); - require(parts.resolve(id("test:unknown")).isEmpty()); - require(parts.resolve(id("test:iron_gear")).orElseThrow() == iron); // cached - // A non-finite amount (10 / hardness 0) makes the item not a part. - var rods = - MachineParts.parse( - Map.of( - id("test:rod"), - JsonParser.parseString( - """ -{"part": "mmdlib:rod", "socket": "shafts", - "modifiers": [{"stat": "speed", "op": "add_value", "amount": "10 / $material.hardness"}]} -""")), - item -> - Optional.of( - new PartSubject( - item, - Optional.of(id("mmdlib:rod")), - gear(item.toString(), item.getPath().equals("zero_rod") ? 0 : 5, Map.of()) - .material()))); - require(rods.resolve(id("test:zero_rod")).isEmpty()); - require(rods.resolve(id("test:steel_rod")).orElseThrow().modifiers().get(0).amount() == 2); - expectFailure(() -> parsePart("{\"socket\":\"gears\",\"modifiers\":[]}")); - expectFailure( - () -> - parsePart( - "{\"part\":\"mmdlib:gear\",\"item\":\"test:x\",\"socket\":\"gears\",\"modifiers\":[]}")); - expectFailure(() -> parsePart(GEAR_TEMPLATE.replace("$material.hardness - 3", "$speed"))); - // A misspelt key is an error, not a part that quietly never wears. - expectFailure(() -> parsePart(GEAR_TEMPLATE.replace("\"durability\"", "\"durabilty\""))); - expectFailure(() -> parsePart(GEAR_TEMPLATE.replace("\"stat\"", "\"note\": 1, \"stat\""))); - checkParts(); - int generation = MachineParts.current().generation(); - MachineParts.install(parts); - require(MachineParts.current() == parts && parts.generation() > generation); - MachineParts.install(MachineParts.NONE); - } - - /** Files that decode but can't do anything are reported by check, which also resolves early. */ - private static void checkParts() { - var files = new HashMap(); - files.put(id("test:gear"), JsonParser.parseString(GEAR_TEMPLATE)); - files.put( - id("test:typo_part"), - JsonParser.parseString(GEAR_TEMPLATE.replace("mmdlib:gear", "mmdlib:gaer"))); - files.put( - id("test:typo_item"), - JsonParser.parseString( - "{\"item\": \"test:iron_geer\", \"socket\": \"gears\", \"modifiers\": []}")); - files.put( - id("test:special"), - JsonParser.parseString( - """ - {"item": "test:invar_gear", "socket": "gears", - "modifiers": [{"stat": "grip", "op": "add_value", - "amount": "$material.special.grip + $material.special.gripp"}]} - """)); - var subjects = - Map.of( - id("test:iron_gear"), gear("test:iron_gear", 4, Map.of()), - id("test:invar_gear"), gear("test:invar_gear", 6, Map.of("grip", 2.0)), - id("test:soft_gear"), gear("test:soft_gear", 0, Map.of())); - var lookups = new int[1]; - var parts = - MachineParts.parse( - files, - item -> { - lookups[0]++; - return Optional.ofNullable(subjects.get(item)); - }); - var problems = parts.check(subjects.keySet()); - require(problems.size() == 3); - require(mentions(problems, "test:typo_part", "mmdlib:gaer")); - require(mentions(problems, "test:typo_item", "test:iron_geer")); - require(mentions(problems, "test:special", "$material.special.gripp")); - // Every item was resolved, so template errors are already logged and nothing looks up again. - int after = lookups[0]; - require(parts.resolve(id("test:iron_gear")).isPresent()); - require(parts.resolve(id("test:soft_gear")).isEmpty()); - require(lookups[0] == after); - } - - private static boolean mentions(List problems, String... words) { - return problems.stream() - .anyMatch(problem -> List.of(words).stream().allMatch(problem::contains)); - } - - private static void amountMatch() { - require(AmountMatch.EXACT.test(5, 5) && !AmountMatch.EXACT.test(5, 6)); - require(AmountMatch.AT_LEAST.test(5, 6) && AmountMatch.AT_LEAST.test(5, 5)); - require(!AmountMatch.AT_LEAST.test(5, 4)); - require(AmountMatch.ANY_AMOUNT.test(5, 0)); - require(AmountMatch.AT_LEAST.test(3_000_000_000L, 3_000_000_001L)); - require(!AmountMatch.AT_LEAST.test(3_000_000_000L, 2_999_999_999L)); - } - - private static void inputClaims() { - // A broad part listed first moves off the only entry a later part accepts. - var two = new InputClaims(new long[] {1, 1}); - require(two.claim(1, any -> true) == 0); - require(two.claim(1, entry -> entry == 0) == 0); - require(two.claimed(0) == 1 && two.claimed(1) == 1); - // Listing them the other way round gives the same answer. - var swapped = new InputClaims(new long[] {1, 1}); - require(swapped.claim(1, entry -> entry == 0) == 0); - require(swapped.claim(1, any -> true) == 0); - require(swapped.claimed(0) == 1 && swapped.claimed(1) == 1); - // A chain: the third part moves the second, which moves the first. - var chain = new InputClaims(new long[] {1, 1, 1}); - require(chain.claim(1, entry -> entry != 1) == 0); - require(chain.claim(1, entry -> entry != 2) == 0); - require(chain.claim(1, entry -> entry == 0) == 0); - require(chain.claimed(0) == 1 && chain.claimed(1) == 1 && chain.claimed(2) == 1); - // Part of a stack moves, and the rest stays. - var stacks = new InputClaims(new long[] {3, 3}); - require(stacks.claim(4, any -> true) == 0); - require(stacks.claim(2, entry -> entry == 0) == 0); - require(stacks.claimed(0) == 3 && stacks.claimed(1) == 3); - // Nothing counts twice, and a claim that can't be met reports how much it's short. - var shared = new InputClaims(new long[] {3}); - require(shared.claim(2, any -> true) == 0); - require(shared.claim(2, any -> true) == 1); - require(shared.claimed(0) == 3); - // Two parts that only accept one entry can't both be met by moving. - var stuck = new InputClaims(new long[] {1, 1}); - require(stuck.claim(1, entry -> entry == 0) == 0); - require(stuck.claim(1, entry -> entry == 0) == 1); - require(stuck.claimed(0) == 1 && stuck.claimed(1) == 0); - // Empty entries aren't asked about, so an ingredient never tests an empty stack. - var empty = new InputClaims(new long[] {0, 2}); - require( - empty.claim( - 1, - entry -> { - require(entry == 1); - return true; - }) - == 0); - // Energy amounts don't fit an int. - var energy = new InputClaims(new long[] {3_000_000_000L}); - require(energy.claim(2_999_999_999L, any -> true) == 0); - require(energy.claim(2, any -> true) == 1); - } - - private static PartSubject gear(String item, double hardness, Map special) { - return new PartSubject( - id(item), - Optional.of(id("mmdlib:gear")), - Optional.of(new MaterialProperties("metal", -1, hardness, 1, 0, 0, Set.of(), special))); - } - - private static PartDefinition parsePart(String json) { - return PartDefinition.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow(); - } - - private static ResourceLocation id(String value) { - return ResourceLocation.parse(value); - } - - static InternalsDefinition parseInternals(String json) { - return InternalsDefinition.CODEC - .parse(JsonOps.INSTANCE, JsonParser.parseString(json)) - .getOrThrow(); - } - - private static StatDefinition parseStat(String json) { - return StatDefinition.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow(); - } - - private static StatModifier value(String stat, double amount) { - return new StatModifier(stat, AttributeModifier.Operation.ADD_VALUE, amount); - } - - private static StatModifier base(String stat, double amount) { - return new StatModifier(stat, AttributeModifier.Operation.ADD_MULTIPLIED_BASE, amount); - } - - private static StatModifier total(String stat, double amount) { - return new StatModifier(stat, AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL, amount); - } - - @SafeVarargs - private static List concat(List... lists) { - var all = new ArrayList(); - for (var list : lists) all.addAll(list); - return all; - } - - private static Function v(String name, double value) { - var values = Map.of(name, value); - return key -> - values.containsKey(key) ? OptionalDouble.of(values.get(key)) : OptionalDouble.empty(); - } - - private static Function none() { - return name -> OptionalDouble.empty(); - } - - static boolean near(double a, double b) { - return Math.abs(a - b) < 1e-9; - } - - static void require(boolean condition) { - if (!condition) throw new AssertionError("Machine core assertion failed"); - } - - static void expectFailure(Runnable action) { - try { - action.run(); - } catch (IllegalArgumentException | IllegalStateException ex) { - return; - } - throw new AssertionError("Invalid machine input accepted"); - } -} diff --git a/src/runtimeTest/datapack/pack.mcmeta b/src/runtimeTest/datapack/pack.mcmeta deleted file mode 100644 index c98bb4b..0000000 --- a/src/runtimeTest/datapack/pack.mcmeta +++ /dev/null @@ -1,6 +0,0 @@ -{ - "pack": { - "pack_format": 48, - "description": "MMDLib runtime smoke test data" - } -} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContentSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContentSmoke.java new file mode 100644 index 0000000..95de728 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContentSmoke.java @@ -0,0 +1,211 @@ +package zone.moddev.mc.lib.smoke; + +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.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.projectile.AbstractArrow; +import net.minecraft.world.item.*; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.block.*; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +import net.neoforged.neoforge.common.CommonHooks; +import net.neoforged.neoforge.common.util.FakePlayerFactory; + +import zone.moddev.mc.lib.MMDLib; +import zone.moddev.mc.lib.common.recipes.CrushingRecipe; +import zone.moddev.mc.lib.common.runtime.*; + +/** + * Checks MMDLib's own material content on a running server: registration, block placement, + * ammunition recovery, buckets, crushing, mining tiers and armor textures. + */ +final class ContentSmoke { + private ContentSmoke() {} + + /** The materials, the standalone detector plate and the item count. */ + static void registered() { + if (MMDLib.loadedMaterials().size() != 15) throw new AssertionError("Missing materials"); + var detector = BuiltInRegistries.BLOCK.get(ResourceLocation.parse("mmdlib:human_detector")); + if (MMDLib.standaloneBlocks().size() != 1 + || !(detector instanceof StandardBlocks.DetectorPressurePlate)) + throw new AssertionError("Standalone player-only plate missing: " + detector); + if (registrar().items().size() != 234) + throw new AssertionError("Wrong runtime item count: " + registrar().items().size()); + } + + /** Every block item places its block. */ + static void blocksPlace(ServerLevel level) { + var player = FakePlayerFactory.getMinecraft(level); + int placed = 0; + for (var entry : registrar().blocks().entrySet()) { + var itemHolder = registrar().items().get(entry.getKey()); + if (itemHolder == null || !(itemHolder.get() instanceof BlockItem item)) continue; + var pos = new BlockPos((placed % 8) * 4, level.getMaxBuildHeight() - 12, (placed / 8) * 4); + level.getChunkAt(pos); + level.setBlock(pos, Blocks.STONE.defaultBlockState(), 3); + level.setBlock(pos.above(), Blocks.AIR.defaultBlockState(), 3); + level.setBlock(pos.above(2), Blocks.AIR.defaultBlockState(), 3); + player.setPos(pos.getX() + .5, pos.getY() + 1, pos.getZ() + 2.5); + player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item)); + var hit = new BlockHitResult(Vec3.atCenterOf(pos).add(0, .5, 0), Direction.UP, pos, false); + var result = item.useOn(new UseOnContext(player, InteractionHand.MAIN_HAND, hit)); + if (!result.consumesAction() + || level.getBlockState(pos.above()).getBlock() != entry.getValue().get()) + throw new AssertionError("Placement failed: " + entry.getKey() + " result " + result); + placed++; + } + if (placed != 59) throw new AssertionError("Wrong placed block count: " + placed); + } + + /** Arrows and bolts keep their pickup item and its components across save and load. */ + static void ammunitionRecovers(ServerLevel level) { + var player = FakePlayerFactory.getMinecraft(level); + int projectiles = 0; + for (var entry : registrar().projectiles().entrySet()) { + var ammo = (MaterialAmmoItem) registrar().items().get(entry.getKey()).get(); + var stack = new ItemStack(ammo, 12); + stack.set(DataComponents.CUSTOM_NAME, Component.literal("Recovery smoke test")); + var projectile = ammo.createArrow(level, stack, player, null); + if (projectile.pickup != AbstractArrow.Pickup.ALLOWED + || projectile.getType() != entry.getValue().get()) + throw new AssertionError("Wrong projectile type/pickup"); + checkStack(stack.copyWithCount(1), projectile.getPickupItemStackOrigin()); + if (stack.getCount() != 12) + throw new AssertionError("Ammunition consumed during construction"); + var tag = projectile.saveWithoutId(new CompoundTag()); + var restored = entry.getValue().get().create(level); + restored.load(tag); + checkStack(stack.copyWithCount(1), restored.getPickupItemStackOrigin()); + projectiles++; + } + if (projectiles != 14) throw new AssertionError("Wrong projectile count: " + projectiles); + } + + /** Every bucket places its fluid and picks it back up. */ + static void bucketsPlaceAndRecover(ServerLevel level) { + var player = FakePlayerFactory.getMinecraft(level); + int fluids = 0; + for (var family : registrar().fluidFamilies()) { + var pos = + new BlockPos((fluids % 8) * 4, level.getMaxBuildHeight() - 12, 48 + (fluids / 8) * 4); + level.getChunkAt(pos); + level.setBlock(pos.below(), Blocks.STONE.defaultBlockState(), 3); + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + var bucket = family.bucket.get(); + var stack = new ItemStack(bucket); + if (!bucket.emptyContents(player, level, pos, null, stack)) + throw new AssertionError("Bucket placement failed: " + family.definition.name()); + var state = level.getBlockState(pos); + if (level.getFluidState(pos).getType() != family.source.get()) + throw new AssertionError("Wrong fluid placed"); + var recovered = ((BucketPickup) state.getBlock()).pickupBlock(player, level, pos, state); + if (recovered.getItem() != bucket) throw new AssertionError("Wrong fluid bucket recovered"); + fluids++; + } + if (fluids != 15) throw new AssertionError("Wrong bucket count: " + fluids); + } + + /** A real dropped-item crushing recipe, not just recipe decoding. */ + static void crackhammerCrushesDrops(ServerLevel level, BlockPos floor) { + var player = FakePlayerFactory.getMinecraft(level); + level.getChunkAt(floor); + level.setBlock(floor, Blocks.STONE.defaultBlockState(), 3); + level + .getEntitiesOfClass(ItemEntity.class, new AABB(floor.above()).inflate(2)) + .forEach(Entity::discard); + var input = + new ItemEntity( + level, + floor.getX() + .5, + floor.getY() + 1.1, + floor.getZ() + .5, + new ItemStack(Items.IRON_INGOT, 2)); + level.addFreshEntity(input); + var hammer = new ItemStack(item("mmdlib:iron_crackhammer")); + player.getAbilities().instabuild = false; + player.setShiftKeyDown(false); + player.setItemInHand(InteractionHand.MAIN_HAND, hammer); + // Aim horizontally at a floating drop: there is no clicked block + // to accidentally exercise only the old useOn path. + player.setPos(input.getX(), input.getY() + .1 - player.getEyeHeight(), input.getZ() - 2); + player.setYRot(0); + player.setXRot(0); + // Crushing is a data-bound tool-right-click handler, so fire the real NeoForge event the + // bridge listens to. + CommonHooks.onItemRightClick(player, InteractionHand.MAIN_HAND); + var dust = item("mmdlib:iron_dust"); + var outputs = + level.getEntitiesOfClass( + ItemEntity.class, + new AABB(floor.above()), + entity -> entity.getItem().getItem() == dust); + // The default server config crushes up to 8 per click, 1 wear each: both ingots go. + if (input.isAlive() || outputs.isEmpty() || hammer.getDamageValue() != 2) + throw new AssertionError( + "Crackhammer failed: input=" + + input.getItem().getCount() + + ", outputs=" + + outputs.size() + + ", wear=" + + hammer.getDamageValue() + + ", recipes=" + + level.getRecipeManager().getAllRecipesFor(CrushingRecipe.TYPE.get()).size()); + } + + /** Tag-based mining tiers and packaged armor textures. */ + static void toolsAndArmor() throws Exception { + var emerald = new ItemStack(item("mmdlib:emerald_pickaxe")); + var quartz = new ItemStack(item("mmdlib:quartz_pickaxe")); + if (!emerald.isCorrectToolForDrops(Blocks.OBSIDIAN.defaultBlockState()) + || quartz.isCorrectToolForDrops(Blocks.OBSIDIAN.defaultBlockState())) + throw new AssertionError("Incorrect tag-based mining tiers"); + var helmet = new ItemStack(item("mmdlib:emerald_helmet")); + if (helmet.get(DataComponents.DYED_COLOR) != null) + throw new AssertionError("Precolored armor must not be tinted twice"); + var texture = + ((ArmorItem) helmet.getItem()).getMaterial().value().layers().getFirst().texture(false); + if (!texture.getNamespace().equals("mmdlib")) + throw new AssertionError("Missing custom armor layer"); + requireResource(texture, "Armor texture"); + var horse = (AnimalArmorItem) item("mmdlib:emerald_horse_armor"); + requireResource(horse.getTexture(), "Horse armor texture"); + } + + /** MMDLib's own registrar; the machine smoke registrar is skipped. */ + private static RuntimeContentRegistrar registrar() { + var own = + RuntimeContentRegistrar.instances().stream() + .filter(registrar -> registrar != MachineSmoke.registrar()) + .toList(); + if (own.size() != 1) throw new AssertionError("Expected one MMDLib registrar, got " + own); + return own.getFirst(); + } + + private static Item item(String id) { + return registrar().items().get(ResourceLocation.parse(id)).get(); + } + + private static void requireResource(ResourceLocation texture, String what) throws Exception { + try (var stream = + ContentSmoke.class.getResourceAsStream( + "/assets/" + texture.getNamespace() + "/" + texture.getPath())) { + if (stream == null) throw new AssertionError(what + " not packaged: " + texture); + } + } + + private static void checkStack(ItemStack expected, ItemStack actual) { + if (actual.getCount() != 1 || !ItemStack.isSameItemSameComponents(expected, actual)) + throw new AssertionError("Wrong projectile recovery stack"); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java new file mode 100644 index 0000000..fb62048 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -0,0 +1,94 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; + +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** + * MMDLib's server game tests, run by {@code ./gradlew runGameTestServer}. Each one runs a check in + * an empty 5×5×5 structure ({@code mmdlib:empty}) and passes when the check returns; a thrown error + * fails it with that error's message. The server exits with the number of failed tests. + */ +@GameTestHolder("mmdlib") +@PrefixGameTestTemplate(false) +public final class MMDLibGameTests { + private static final String EMPTY = "empty"; + + private MMDLibGameTests() {} + + @GameTest(template = EMPTY) + public static void contentRegistered(GameTestHelper helper) { + ContentSmoke.registered(); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void blocksPlace(GameTestHelper helper) { + ContentSmoke.blocksPlace(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void ammunitionRecovers(GameTestHelper helper) { + ContentSmoke.ammunitionRecovers(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void bucketsPlaceAndRecover(GameTestHelper helper) { + ContentSmoke.bucketsPlaceAndRecover(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void crackhammerCrushesDrops(GameTestHelper helper) { + ContentSmoke.crackhammerCrushesDrops( + helper.getLevel(), helper.absolutePos(new BlockPos(2, 1, 2))); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void toolsAndArmor(GameTestHelper helper) throws Exception { + ContentSmoke.toolsAndArmor(); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void capabilities(GameTestHelper helper) { + CapabilitySmoke.check(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void standaloneMachine(GameTestHelper helper) { + MachineSmoke.check(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machinePartsLoad(GameTestHelper helper) { + MachinePartsLoaderSmoke.check(); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machineInternals(GameTestHelper helper) { + MachineInternalsSmoke.check(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machineInventory(GameTestHelper helper) { + MachineInventorySmoke.check(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machineRecipes(GameTestHelper helper) { + MachineRecipeSmoke.check(helper.getLevel()); + helper.succeed(); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachinePartsLoaderSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachinePartsLoaderSmoke.java index 06bc4b3..ff1d663 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachinePartsLoaderSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachinePartsLoaderSmoke.java @@ -8,8 +8,8 @@ import zone.moddev.mc.lib.machine.internals.MachineParts; /** - * Checks that part files in a real datapack reach {@link MachineParts#current()} through the reload - * listener. The smoke init script copies {@code src/runtimeTest/datapack} into the smoke world. + * Checks that part files shipped as resources ({@code src/runtimeTest/resources/data/mmdlib_smoke}) + * reach {@link MachineParts#current()} through the reload listener. */ final class MachinePartsLoaderSmoke { private MachinePartsLoaderSmoke() {} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RuntimeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RuntimeSmoke.java deleted file mode 100644 index 22ec14b..0000000 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RuntimeSmoke.java +++ /dev/null @@ -1,221 +0,0 @@ -package zone.moddev.mc.lib.smoke; - -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.core.component.DataComponents; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.InteractionHand; -import net.minecraft.world.entity.projectile.AbstractArrow; -import net.minecraft.world.item.*; -import net.minecraft.world.item.context.UseOnContext; -import net.minecraft.world.level.block.*; -import net.minecraft.world.phys.BlockHitResult; -import net.minecraft.world.phys.Vec3; - -import net.neoforged.bus.api.SubscribeEvent; -import net.neoforged.fml.common.EventBusSubscriber; -import net.neoforged.neoforge.common.util.FakePlayerFactory; -import net.neoforged.neoforge.event.server.ServerStartedEvent; - -import zone.moddev.mc.lib.MMDLib; -import zone.moddev.mc.lib.common.runtime.*; - -/** Injected into a dedicated development run only; excluded from the released JAR. */ -@EventBusSubscriber(modid = "mmdlib") -public final class RuntimeSmoke { - @SubscribeEvent - public static void started(ServerStartedEvent event) { - if (Boolean.getBoolean("mmdlib.guiSmoke")) return; - var server = event.getServer(); - try { - var level = server.overworld(); - var player = FakePlayerFactory.getMinecraft(level); - int placed = 0, projectiles = 0, fluids = 0; - if (MMDLib.loadedMaterials().size() != 15) throw new AssertionError("Missing materials"); - var detector = - net.minecraft.core.registries.BuiltInRegistries.BLOCK.get( - net.minecraft.resources.ResourceLocation.parse("mmdlib:human_detector")); - if (MMDLib.standaloneBlocks().size() != 1 - || !(detector - instanceof zone.moddev.mc.lib.common.runtime.StandardBlocks.DetectorPressurePlate)) - throw new AssertionError("Standalone player-only plate missing: " + detector); - for (var registrar : RuntimeContentRegistrar.instances()) { - if (registrar == MachineSmoke.registrar()) continue; - if (registrar.items().size() != 234) - throw new AssertionError("Wrong runtime item count: " + registrar.items().size()); - for (var entry : registrar.blocks().entrySet()) { - var itemHolder = registrar.items().get(entry.getKey()); - if (itemHolder == null || !(itemHolder.get() instanceof BlockItem item)) continue; - var pos = - new BlockPos((placed % 8) * 4, level.getMaxBuildHeight() - 12, (placed / 8) * 4); - level.getChunkAt(pos); - level.setBlock(pos, Blocks.STONE.defaultBlockState(), 3); - level.setBlock(pos.above(), Blocks.AIR.defaultBlockState(), 3); - level.setBlock(pos.above(2), Blocks.AIR.defaultBlockState(), 3); - player.setPos(pos.getX() + .5, pos.getY() + 1, pos.getZ() + 2.5); - player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item)); - var hit = - new BlockHitResult(Vec3.atCenterOf(pos).add(0, .5, 0), Direction.UP, pos, false); - var result = item.useOn(new UseOnContext(player, InteractionHand.MAIN_HAND, hit)); - if (!result.consumesAction() - || level.getBlockState(pos.above()).getBlock() != entry.getValue().get()) - throw new AssertionError("Placement failed: " + entry.getKey() + " result " + result); - placed++; - } - for (var entry : registrar.projectiles().entrySet()) { - var ammo = (MaterialAmmoItem) registrar.items().get(entry.getKey()).get(); - var stack = new ItemStack(ammo, 12); - stack.set(DataComponents.CUSTOM_NAME, Component.literal("Recovery smoke test")); - var projectile = ammo.createArrow(level, stack, player, null); - if (projectile.pickup != AbstractArrow.Pickup.ALLOWED - || projectile.getType() != entry.getValue().get()) - throw new AssertionError("Wrong projectile type/pickup"); - checkStack(stack.copyWithCount(1), projectile.getPickupItemStackOrigin()); - if (stack.getCount() != 12) - throw new AssertionError("Ammunition consumed during construction"); - var tag = projectile.saveWithoutId(new CompoundTag()); - var restored = entry.getValue().get().create(level); - restored.load(tag); - checkStack(stack.copyWithCount(1), restored.getPickupItemStackOrigin()); - projectiles++; - } - for (var family : registrar.fluidFamilies()) { - var pos = - new BlockPos((fluids % 8) * 4, level.getMaxBuildHeight() - 12, 48 + (fluids / 8) * 4); - level.getChunkAt(pos); - level.setBlock(pos.below(), Blocks.STONE.defaultBlockState(), 3); - level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); - var bucket = family.bucket.get(); - var stack = new ItemStack(bucket); - if (!bucket.emptyContents(player, level, pos, null, stack)) - throw new AssertionError("Bucket placement failed: " + family.definition.name()); - var state = level.getBlockState(pos); - if (level.getFluidState(pos).getType() != family.source.get()) - throw new AssertionError("Wrong fluid placed"); - var recovered = ((BucketPickup) state.getBlock()).pickupBlock(player, level, pos, state); - if (recovered.getItem() != bucket) - throw new AssertionError("Wrong fluid bucket recovered"); - fluids++; - } - // Exercise an actual dropped-item crushing recipe, not just recipe decoding. - var floor = level.getSharedSpawnPos().above(8); - level.getChunkAt(floor); - level.setBlock(floor, Blocks.STONE.defaultBlockState(), 3); - level - .getEntitiesOfClass( - net.minecraft.world.entity.item.ItemEntity.class, - new net.minecraft.world.phys.AABB(floor.above()).inflate(2)) - .forEach(net.minecraft.world.entity.Entity::discard); - var input = - new net.minecraft.world.entity.item.ItemEntity( - level, - floor.getX() + .5, - floor.getY() + 1.1, - floor.getZ() + .5, - new ItemStack(Items.IRON_INGOT, 2)); - level.addFreshEntity(input); - var hammer = - new ItemStack( - registrar.items().get(ResourceLocation.parse("mmdlib:iron_crackhammer")).get()); - player.getAbilities().instabuild = false; - player.setShiftKeyDown(false); - player.setItemInHand(InteractionHand.MAIN_HAND, hammer); - // Aim horizontally at a floating drop: there is no clicked block - // to accidentally exercise only the old useOn path. - player.setPos(input.getX(), input.getY() + .1 - player.getEyeHeight(), input.getZ() - 2); - player.setYRot(0); - player.setXRot(0); - // Crushing is a data-bound tool-right-click handler, so fire the real NeoForge event the - // bridge listens to. - net.neoforged.neoforge.common.CommonHooks.onItemRightClick( - player, InteractionHand.MAIN_HAND); - var hammerResult = "event"; - var dust = registrar.items().get(ResourceLocation.parse("mmdlib:iron_dust")).get(); - var outputs = - level.getEntitiesOfClass( - net.minecraft.world.entity.item.ItemEntity.class, - new net.minecraft.world.phys.AABB(floor.above()), - entity -> entity.getItem().getItem() == dust); - // The default server config crushes up to 8 per click, 1 wear each: both ingots go. - if (input.isAlive() || outputs.isEmpty() || hammer.getDamageValue() != 2) - throw new AssertionError( - "Crackhammer failed: result=" - + hammerResult - + ", input=" - + input.getItem().getCount() - + ", outputs=" - + outputs.size() - + ", wear=" - + hammer.getDamageValue() - + ", recipes=" - + level - .getRecipeManager() - .getAllRecipesFor(zone.moddev.mc.lib.common.recipes.CrushingRecipe.TYPE.get()) - .size()); - var emerald = - new ItemStack( - registrar.items().get(ResourceLocation.parse("mmdlib:emerald_pickaxe")).get()); - var quartz = - new ItemStack( - registrar.items().get(ResourceLocation.parse("mmdlib:quartz_pickaxe")).get()); - if (!emerald.isCorrectToolForDrops(Blocks.OBSIDIAN.defaultBlockState()) - || quartz.isCorrectToolForDrops(Blocks.OBSIDIAN.defaultBlockState())) - throw new AssertionError("Incorrect tag-based mining tiers"); - var helmet = - new ItemStack( - registrar.items().get(ResourceLocation.parse("mmdlib:emerald_helmet")).get()); - if (helmet.get(DataComponents.DYED_COLOR) != null) - throw new AssertionError("Precolored armor must not be tinted twice"); - var texture = - ((ArmorItem) helmet.getItem()).getMaterial().value().layers().getFirst().texture(false); - if (!texture.getNamespace().equals("mmdlib")) - throw new AssertionError("Missing custom armor layer"); - try (var stream = - RuntimeSmoke.class.getResourceAsStream( - "/assets/" + texture.getNamespace() + "/" + texture.getPath())) { - if (stream == null) throw new AssertionError("Armor texture not packaged: " + texture); - } - var horse = - (AnimalArmorItem) - registrar.items().get(ResourceLocation.parse("mmdlib:emerald_horse_armor")).get(); - try (var stream = - RuntimeSmoke.class.getResourceAsStream( - "/assets/" - + horse.getTexture().getNamespace() - + "/" - + horse.getTexture().getPath())) { - if (stream == null) - throw new AssertionError("Horse armor texture not packaged: " + horse.getTexture()); - } - } - CapabilitySmoke.check(level); - MachineSmoke.check(level); - MachinePartsLoaderSmoke.check(); - MachineInternalsSmoke.check(level); - MachineInventorySmoke.check(level); - MachineRecipeSmoke.check(level); - if (projectiles != 14 || fluids != 15 || placed != 59) - throw new AssertionError( - "Wrong smoke counts: " + placed + "/" + projectiles + "/" + fluids); - MMDLib.LOGGER.info( - "MMDLIB_RUNTIME_SMOKE_PASS: {} blocks placed; {} arrow/bolt component-preserving save" - + " round trips; {} buckets placed/recovered; tag-based tiers, authored armor" - + " textures, dropped-item crushing, capability wrappers and a standalone ticking" - + " machine and item verified", - placed, - projectiles, - fluids); - } catch (Throwable ex) { - MMDLib.LOGGER.error("MMDLIB_RUNTIME_SMOKE_FAIL", ex); - } finally { - server.halt(false); - } - } - - private static void checkStack(ItemStack expected, ItemStack actual) { - if (actual.getCount() != 1 || !ItemStack.isSameItemSameComponents(expected, actual)) - throw new AssertionError("Wrong projectile recovery stack"); - } -} diff --git a/src/runtimeTest/resources/data/mmdlib/structure/empty.nbt b/src/runtimeTest/resources/data/mmdlib/structure/empty.nbt new file mode 100644 index 0000000..0a14490 Binary files /dev/null and b/src/runtimeTest/resources/data/mmdlib/structure/empty.nbt differ diff --git a/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/broken.json b/src/runtimeTest/resources/data/mmdlib_smoke/mmdlib/machine_part/broken.json similarity index 100% rename from src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/broken.json rename to src/runtimeTest/resources/data/mmdlib_smoke/mmdlib/machine_part/broken.json diff --git a/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/gear.json b/src/runtimeTest/resources/data/mmdlib_smoke/mmdlib/machine_part/gear.json similarity index 100% rename from src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/gear.json rename to src/runtimeTest/resources/data/mmdlib_smoke/mmdlib/machine_part/gear.json diff --git a/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/unknown_item.json b/src/runtimeTest/resources/data/mmdlib_smoke/mmdlib/machine_part/unknown_item.json similarity index 100% rename from src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/unknown_item.json rename to src/runtimeTest/resources/data/mmdlib_smoke/mmdlib/machine_part/unknown_item.json diff --git a/src/test/java/zone/moddev/mc/lib/machine/MachineAssertions.java b/src/test/java/zone/moddev/mc/lib/machine/MachineAssertions.java new file mode 100644 index 0000000..2485c06 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineAssertions.java @@ -0,0 +1,47 @@ +package zone.moddev.mc.lib.machine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Map; +import java.util.OptionalDouble; +import java.util.function.Function; + +import org.junit.jupiter.api.function.Executable; + +/** Shared assertions and inputs for the machine module's unit tests. */ +public final class MachineAssertions { + private MachineAssertions() {} + + /** Asserts {@code a} and {@code b} agree to 1e-9. */ + public static void assertNear(double expected, double actual) { + assertEquals(expected, actual, 1e-9); + } + + /** + * Asserts {@code action} refuses its input the way machine code does: with an {@link + * IllegalArgumentException}, or the {@link IllegalStateException} of a failed {@code getOrThrow}. + */ + public static void assertRejected(Executable action) { + try { + action.execute(); + } catch (IllegalArgumentException | IllegalStateException expected) { + return; + } catch (Throwable other) { + fail("Expected the input to be refused, got " + other, other); + } + fail("Expected the input to be refused, but it was accepted"); + } + + /** Expression variables with one value. */ + public static Function vars(String name, double value) { + var values = Map.of(name, value); + return key -> + values.containsKey(key) ? OptionalDouble.of(values.get(key)) : OptionalDouble.empty(); + } + + /** Expression variables with no values. */ + public static Function noVars() { + return name -> OptionalDouble.empty(); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/expr/ExprTest.java b/src/test/java/zone/moddev/mc/lib/machine/expr/ExprTest.java new file mode 100644 index 0000000..24ea349 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/expr/ExprTest.java @@ -0,0 +1,86 @@ +package zone.moddev.mc.lib.machine.expr; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static zone.moddev.mc.lib.machine.MachineAssertions.assertNear; +import static zone.moddev.mc.lib.machine.MachineAssertions.assertRejected; +import static zone.moddev.mc.lib.machine.MachineAssertions.noVars; +import static zone.moddev.mc.lib.machine.MachineAssertions.vars; + +import java.util.Set; + +import com.google.gson.JsonPrimitive; + +import com.mojang.serialization.JsonOps; + +import org.junit.jupiter.api.Test; + +class ExprTest { + @Test + void evaluatesMaterialVariables() { + assertNear( + .24, Expr.parse("0.08 * ($material.hardness - 3)").eval(vars("material.hardness", 6))); + } + + @Test + void followsPrecedenceAndAssociativity() { + assertEquals(10, Expr.parse("-2 + 3 * 4").eval(noVars())); + assertEquals(.75, Expr.parse("(1 + 2) / 4").eval(noVars())); + assertEquals(3, Expr.parse("10 - 4 - 3").eval(noVars())); + assertEquals(-4, Expr.parse("2 * -(1 + 1)").eval(noVars())); + assertEquals(3, Expr.constant(3).eval(noVars())); + } + + @Test + void listsItsVariables() { + assertEquals(Set.of("a.b"), Expr.parse("$a.b").variables()); + assertTrue(Expr.parse("1 + 2").variables().isEmpty()); + } + + @Test + void divisionByZeroIsInfiniteNotAnError() { + assertTrue(Double.isInfinite(Expr.parse("1 / $x").eval(vars("x", 0)))); + } + + @Test + void refusesMalformedText() { + assertRejected(() -> Expr.parse("2 *")); + assertRejected(() -> Expr.parse("2 $")); + assertRejected(() -> Expr.parse("(1")); + assertRejected(() -> Expr.parse("")); + } + + @Test + void refusesMalformedVariableNames() { + assertRejected(() -> Expr.parse("$a..b")); + assertRejected(() -> Expr.parse("$a.")); + assertRejected(() -> Expr.parse("$.a")); + } + + @Test + void refusesAMissingVariable() { + assertRejected(() -> Expr.parse("$missing").eval(noVars())); + } + + @Test + void decodesNumbersAndStrings() { + assertEquals( + 12, Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(12)).getOrThrow().eval(noVars())); + assertEquals( + 8, + Expr.CODEC + .parse(JsonOps.INSTANCE, new JsonPrimitive("$x * 2")) + .getOrThrow() + .eval(vars("x", 4))); + assertTrue(Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive("2 *")).error().isPresent()); + } + + // Double.toString would write these in E notation, which the parser doesn't read. + @Test + void roundTripsLargeAndSmallNumbers() { + var encoded = + Expr.CODEC.encodeStart(JsonOps.INSTANCE, Expr.parse("10000000 * $x + 0.0001")).getOrThrow(); + assertNear( + 20000000.0001, Expr.CODEC.parse(JsonOps.INSTANCE, encoded).getOrThrow().eval(vars("x", 2))); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/ingredient/AmountMatchTest.java b/src/test/java/zone/moddev/mc/lib/machine/ingredient/AmountMatchTest.java new file mode 100644 index 0000000..050bda8 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/ingredient/AmountMatchTest.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.lib.machine.ingredient; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class AmountMatchTest { + @Test + void exactNeedsTheSameAmount() { + assertTrue(AmountMatch.EXACT.test(5, 5)); + assertFalse(AmountMatch.EXACT.test(5, 6)); + } + + @Test + void atLeastAcceptsMore() { + assertTrue(AmountMatch.AT_LEAST.test(5, 6)); + assertTrue(AmountMatch.AT_LEAST.test(5, 5)); + assertFalse(AmountMatch.AT_LEAST.test(5, 4)); + } + + @Test + void anyAmountAcceptsNothing() { + assertTrue(AmountMatch.ANY_AMOUNT.test(5, 0)); + } + + @Test + void amountsAbove2To31() { + assertTrue(AmountMatch.AT_LEAST.test(3_000_000_000L, 3_000_000_001L)); + assertFalse(AmountMatch.AT_LEAST.test(3_000_000_000L, 2_999_999_999L)); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/internals/InternalsDefinitionTest.java b/src/test/java/zone/moddev/mc/lib/machine/internals/InternalsDefinitionTest.java new file mode 100644 index 0000000..a748c33 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/internals/InternalsDefinitionTest.java @@ -0,0 +1,111 @@ +package zone.moddev.mc.lib.machine.internals; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static zone.moddev.mc.lib.machine.MachineAssertions.assertRejected; + +import java.util.List; + +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.Test; + +class InternalsDefinitionTest { + /** The internals example from machine-ui.md, "Sockets and stats". */ + private static final String INTERNALS = + """ + { + "open_with": "#c:tools/wrench", + "swap": "when_idle", + "sockets": { + "gears": { "count": 2, "accepts": "#c:gears" }, + "motor": { "count": 1, "accepts": "#example:motors" }, + "coil": { "count": 1, "accepts": "#example:heating_coils", "optional": true } + }, + "stats": { + "speed": { "base": 1.0, "min": 0.25, "max": 8 }, + "energy_per_tick": { "base": 40, "min": 1 }, + "max_temperature": { "base": 900 } + } + } + """; + + @Test + void decodesTheDocsExample() { + var internals = parse(INTERNALS); + assertEquals(SwapRule.WHEN_IDLE, internals.swap()); + assertEquals( + ResourceLocation.parse("c:tools/wrench"), + internals.openWith().orElseThrow().value().left().orElseThrow().location()); + assertTrue(internals.sockets().get("coil").optional()); + assertFalse(internals.sockets().get("gears").optional()); + assertEquals(900, internals.stats().get("max_temperature").base()); + } + + @Test + void laysOutSlotsSocketBySocketInNameOrder() { + var internals = parse(INTERNALS); + assertEquals(List.of("coil", "gears", "motor"), List.copyOf(internals.sockets().keySet())); + assertEquals(4, internals.slotCount()); + assertEquals(List.of(0), internals.slots("coil")); + assertEquals(List.of(1, 2), internals.slots("gears")); + assertEquals(List.of(3), internals.slots("motor")); + } + + @Test + void fillsInDefaults() { + var plain = parse("{\"sockets\":{\"motor\":{\"accepts\":\"example:motor\"}},\"stats\":{}}"); + assertEquals(SwapRule.WHEN_IDLE, plain.swap()); + assertTrue(plain.openWith().isEmpty()); + assertEquals(1, plain.sockets().get("motor").count()); + assertEquals( + ResourceLocation.parse("example:motor"), + plain.sockets().get("motor").accepts().orElseThrow().value().right().orElseThrow()); + } + + @Test + void swapRules() { + assertTrue(SwapRule.WHEN_IDLE.allows(false)); + assertFalse(SwapRule.WHEN_IDLE.allows(true)); + assertTrue(SwapRule.ALWAYS.allows(true)); + assertFalse(SwapRule.NEVER.allows(false)); + } + + @Test + void refusesBadDefinitions() { + assertRejected(() -> parse(INTERNALS.replace("\"count\": 2", "\"count\": 0"))); + assertRejected(() -> parse(INTERNALS.replace("when_idle", "sometimes"))); + assertRejected(() -> parse(INTERNALS.replace("#c:gears", "#"))); + assertRejected(() -> parse(INTERNALS.replace("#c:gears", "Not An Id"))); + } + + @Test + void refusesAnUnknownSocket() { + assertRejected(() -> parse(INTERNALS).slots("pump")); + } + + @Test + void findsTheSocketOfASlot() { + var internals = parse(INTERNALS); + assertEquals("coil", internals.socketAt(0)); + assertEquals("motor", internals.socketAt(3)); + } + + @Test + void refusesASlotOutOfRange() { + var internals = parse(INTERNALS); + assertRejected(() -> internals.socketAt(-1)); + assertRejected(() -> internals.socketAt(4)); + } + + private static InternalsDefinition parse(String json) { + return InternalsDefinition.CODEC + .parse(JsonOps.INSTANCE, JsonParser.parseString(json)) + .getOrThrow(); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/internals/MachinePartsTest.java b/src/test/java/zone/moddev/mc/lib/machine/internals/MachinePartsTest.java new file mode 100644 index 0000000..4f54fd9 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/internals/MachinePartsTest.java @@ -0,0 +1,274 @@ +package zone.moddev.mc.lib.machine.internals; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static zone.moddev.mc.lib.machine.MachineAssertions.assertNear; +import static zone.moddev.mc.lib.machine.MachineAssertions.assertRejected; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; +import java.util.function.Function; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.lib.common.types.MaterialProperties; + +class MachinePartsTest { + /** The gear template from machine-ui.md, "Part files". */ + private static final String GEAR_TEMPLATE = + """ +{ + "part": "mmdlib:gear", + "socket": "gears", + "modifiers": [ + { "stat": "speed", "op": "add_multiplied_base", "amount": "0.08 * ($material.hardness - 3)" }, + { "stat": "energy_per_tick", "op": "add_multiplied_base", "amount": "0.03 * $material.hardness" } + ], + "durability": "500 * $material.hardness" +} +"""; + + private static final Map SUBJECTS = + Map.of( + id("test:iron_gear"), gear("test:iron_gear", 4, Map.of()), + id("test:invar_gear"), gear("test:invar_gear", 6, Map.of("grip", 2.0)), + id("test:soft_gear"), gear("test:soft_gear", 0, Map.of()), + id("test:diamond_coated_gear"), gear("test:diamond_coated_gear", 10, Map.of()), + id("test:plain_rod"), + new PartSubject( + id("test:plain_rod"), Optional.of(id("mmdlib:rod")), Optional.empty()), + id("test:plain_pipe"), + new PartSubject(id("test:plain_pipe"), Optional.empty(), Optional.empty())); + + @AfterEach + void uninstall() { + MachineParts.install(MachineParts.NONE); + } + + @Test + void templateAppliesWithTheMaterial() { + var iron = parts().resolve(id("test:iron_gear")).orElseThrow(); + assertEquals("gears", iron.socket()); + assertNear(.08, iron.modifiers().get(0).amount()); + assertNear(.12, iron.modifiers().get(1).amount()); + assertEquals(AttributeModifier.Operation.ADD_MULTIPLIED_BASE, iron.modifiers().get(0).op()); + assertEquals(OptionalInt.of(2000), iron.durability()); + } + + @Test + void itemFileBeatsTheTemplate() { + var coated = parts().resolve(id("test:diamond_coated_gear")).orElseThrow(); + assertEquals(AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL, coated.modifiers().get(0).op()); + assertEquals(.4, coated.modifiers().get(0).amount()); + assertTrue(coated.durability().isEmpty()); + } + + @Test + void missingSpecialKeysCountAsZero() { + var invar = parts().resolve(id("test:invar_gear")).orElseThrow(); + assertEquals(1, invar.modifiers().size()); + assertEquals(2, invar.modifiers().get(0).amount()); + assertTrue(invar.durability().isEmpty()); + } + + @Test + void durabilityBelowOneIsNotAPart() { + assertTrue(parts().resolve(id("test:soft_gear")).isEmpty()); + } + + @Test + void itemsWithoutAMaterialCantUseMaterialVariables() { + var parts = parts(); + assertTrue(parts.resolve(id("test:plain_pipe")).isEmpty()); + assertTrue(parts.resolve(id("test:plain_rod")).isEmpty()); + assertTrue(parts.resolve(id("test:unknown")).isEmpty()); + } + + @Test + void resolvedPartsAreCached() { + var parts = parts(); + assertSame( + parts.resolve(id("test:iron_gear")).orElseThrow(), + parts.resolve(id("test:iron_gear")).orElseThrow()); + } + + @Test + void nonFiniteAmountIsNotAPart() { + var rods = + MachineParts.parse( + Map.of( + id("test:rod"), + JsonParser.parseString( + """ +{"part": "mmdlib:rod", "socket": "shafts", + "modifiers": [{"stat": "speed", "op": "add_value", "amount": "10 / $material.hardness"}]} +""")), + item -> + Optional.of( + new PartSubject( + item, + Optional.of(id("mmdlib:rod")), + gear(item.toString(), item.getPath().equals("zero_rod") ? 0 : 5, Map.of()) + .material()))); + assertTrue(rods.resolve(id("test:zero_rod")).isEmpty()); + assertEquals(2, rods.resolve(id("test:steel_rod")).orElseThrow().modifiers().get(0).amount()); + } + + @Test + void refusesBadFiles() { + assertRejected(() -> parsePart("{\"socket\":\"gears\",\"modifiers\":[]}")); + assertRejected( + () -> + parsePart( + "{\"part\":\"mmdlib:gear\",\"item\":\"test:x\",\"socket\":\"gears\",\"modifiers\":[]}")); + assertRejected(() -> parsePart(GEAR_TEMPLATE.replace("$material.hardness - 3", "$speed"))); + } + + // A misspelt key is an error, not a part that quietly never wears. + @Test + void refusesUnknownKeys() { + assertRejected(() -> parsePart(GEAR_TEMPLATE.replace("\"durability\"", "\"durabilty\""))); + assertRejected(() -> parsePart(GEAR_TEMPLATE.replace("\"stat\"", "\"note\": 1, \"stat\""))); + } + + @Test + void checkReportsFilesThatDecodeButCantDoAnything() { + var problems = + filesToCheck(item -> Optional.ofNullable(SUBJECTS.get(item))).check(SUBJECTS.keySet()); + assertEquals(3, problems.size(), problems.toString()); + assertMentioned(problems, "test:typo_part", "mmdlib:gaer"); + assertMentioned(problems, "test:typo_item", "test:iron_geer"); + assertMentioned(problems, "test:special", "$material.special.gripp"); + } + + // Template errors are logged when a datapack loads, not when an item is first used. + @Test + void checkResolvesEveryItemUpFront() { + var lookups = new int[1]; + var parts = + filesToCheck( + item -> { + lookups[0]++; + return Optional.ofNullable(SUBJECTS.get(item)); + }); + parts.check(SUBJECTS.keySet()); + int after = lookups[0]; + assertTrue(parts.resolve(id("test:iron_gear")).isPresent()); + assertTrue(parts.resolve(id("test:soft_gear")).isEmpty()); + assertEquals(after, lookups[0]); + } + + @Test + void installingBumpsTheGeneration() { + var parts = parts(); + int generation = MachineParts.current().generation(); + MachineParts.install(parts); + assertSame(parts, MachineParts.current()); + assertTrue(parts.generation() > generation); + } + + /** + * The gear template, an item file, a second template for the same part (the lower resource ID, + * test:gear, wins), a file with an unknown variable (skipped, the rest still load) and two more + * item files. + */ + private static MachineParts parts() { + var files = new HashMap(); + files.put(id("test:gear"), JsonParser.parseString(GEAR_TEMPLATE)); + files.put( + id("test:diamond_coated_gear"), + JsonParser.parseString( + """ + { + "item": "test:diamond_coated_gear", + "socket": "gears", + "modifiers": [ + { "stat": "speed", "op": "add_multiplied_total", "amount": 0.4 }, + { "stat": "energy_per_tick", "op": "add_value", "amount": 12 } + ] + } + """)); + files.put( + id("test:zz_gear"), + JsonParser.parseString(GEAR_TEMPLATE.replace("\"gears\"", "\"other\""))); + files.put( + id("test:broken"), + JsonParser.parseString( + GEAR_TEMPLATE + .replace("mmdlib:gear", "mmdlib:plate") + .replace("$material.hardness - 3", "$material.colour"))); + files.put( + id("test:pipe"), + JsonParser.parseString( + """ +{"item": "test:plain_pipe", "socket": "pipes", + "modifiers": [{"stat": "speed", "op": "add_value", "amount": "$material.special.grip + 1"}]} +""")); + files.put( + id("test:grip"), + JsonParser.parseString( + """ + {"item": "test:invar_gear", "socket": "gears", + "modifiers": [{"stat": "grip", "op": "add_value", + "amount": "$material.special.grip + $material.special.none"}]} + """)); + return MachineParts.parse(files, item -> Optional.ofNullable(SUBJECTS.get(item))); + } + + private static MachineParts filesToCheck( + Function> subjects) { + var files = new HashMap(); + files.put(id("test:gear"), JsonParser.parseString(GEAR_TEMPLATE)); + files.put( + id("test:typo_part"), + JsonParser.parseString(GEAR_TEMPLATE.replace("mmdlib:gear", "mmdlib:gaer"))); + files.put( + id("test:typo_item"), + JsonParser.parseString( + "{\"item\": \"test:iron_geer\", \"socket\": \"gears\", \"modifiers\": []}")); + files.put( + id("test:special"), + JsonParser.parseString( + """ + {"item": "test:invar_gear", "socket": "gears", + "modifiers": [{"stat": "grip", "op": "add_value", + "amount": "$material.special.grip + $material.special.gripp"}]} + """)); + return MachineParts.parse(files, subjects::apply); + } + + private static void assertMentioned(List problems, String... words) { + assertTrue( + problems.stream().anyMatch(problem -> List.of(words).stream().allMatch(problem::contains)), + () -> "No problem mentions " + List.of(words) + " in " + problems); + } + + private static PartSubject gear(String item, double hardness, Map special) { + return new PartSubject( + id(item), + Optional.of(id("mmdlib:gear")), + Optional.of(new MaterialProperties("metal", -1, hardness, 1, 0, 0, Set.of(), special))); + } + + private static PartDefinition parsePart(String json) { + return PartDefinition.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow(); + } + + private static ResourceLocation id(String value) { + return ResourceLocation.parse(value); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/recipe/InputClaimsTest.java b/src/test/java/zone/moddev/mc/lib/machine/recipe/InputClaimsTest.java new file mode 100644 index 0000000..2d97e14 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/recipe/InputClaimsTest.java @@ -0,0 +1,84 @@ +package zone.moddev.mc.lib.machine.recipe; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class InputClaimsTest { + @Test + void broadClaimMovesOffTheOnlyEntryALaterClaimAccepts() { + var claims = new InputClaims(new long[] {1, 1}); + assertEquals(0, claims.claim(1, any -> true)); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claimed(0)); + assertEquals(1, claims.claimed(1)); + } + + @Test + void orderOfTheClaimsDoesntMatter() { + var claims = new InputClaims(new long[] {1, 1}); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(0, claims.claim(1, any -> true)); + assertEquals(1, claims.claimed(0)); + assertEquals(1, claims.claimed(1)); + } + + @Test + void movesFollowAChain() { + var claims = new InputClaims(new long[] {1, 1, 1}); + assertEquals(0, claims.claim(1, entry -> entry != 1)); + assertEquals(0, claims.claim(1, entry -> entry != 2)); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claimed(0)); + assertEquals(1, claims.claimed(1)); + assertEquals(1, claims.claimed(2)); + } + + @Test + void partOfAStackMovesAndTheRestStays() { + var claims = new InputClaims(new long[] {3, 3}); + assertEquals(0, claims.claim(4, any -> true)); + assertEquals(0, claims.claim(2, entry -> entry == 0)); + assertEquals(3, claims.claimed(0)); + assertEquals(3, claims.claimed(1)); + } + + @Test + void nothingCountsTwiceAndAShortClaimReportsWhatsMissing() { + var claims = new InputClaims(new long[] {3}); + assertEquals(0, claims.claim(2, any -> true)); + assertEquals(1, claims.claim(2, any -> true)); + assertEquals(3, claims.claimed(0)); + } + + @Test + void claimsOnTheSameSingleEntryCantBothBeMet() { + var claims = new InputClaims(new long[] {1, 1}); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claimed(0)); + assertEquals(0, claims.claimed(1)); + } + + // So an ingredient never tests an empty stack. + @Test + void emptyEntriesAreNotAskedAbout() { + var claims = new InputClaims(new long[] {0, 2}); + assertEquals( + 0, + claims.claim( + 1, + entry -> { + assertEquals(1, entry); + return true; + })); + } + + // Energy amounts don't fit an int. + @Test + void amountsLargerThanAnInt() { + var claims = new InputClaims(new long[] {3_000_000_000L}); + assertEquals(0, claims.claim(2_999_999_999L, any -> true)); + assertEquals(1, claims.claim(2, any -> true)); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/stat/MachineStatsTest.java b/src/test/java/zone/moddev/mc/lib/machine/stat/MachineStatsTest.java new file mode 100644 index 0000000..ca6107f --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/stat/MachineStatsTest.java @@ -0,0 +1,133 @@ +package zone.moddev.mc.lib.machine.stat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static zone.moddev.mc.lib.machine.MachineAssertions.assertNear; +import static zone.moddev.mc.lib.machine.MachineAssertions.assertRejected; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.OptionalDouble; + +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.world.entity.ai.attributes.AttributeModifier; + +import org.junit.jupiter.api.Test; + +class MachineStatsTest { + private static final Map DEFS = + Map.of( + "speed", new StatDefinition(1, OptionalDouble.of(.25), OptionalDouble.of(8)), + "energy_per_tick", new StatDefinition(40, OptionalDouble.of(1), OptionalDouble.empty())); + + // The worked table in machine-ui.md, with example hardness iron 4 and invar 6. + private static final List IRON = + List.of(base("speed", .08), base("energy_per_tick", .12)); + private static final List INVAR = + List.of(base("speed", .24), base("energy_per_tick", .18)); + private static final List MOTOR = + List.of(total("speed", .15), value("energy_per_tick", 8)); + private static final List UPGRADE = + List.of(total("speed", .5), total("energy_per_tick", .5)); + + @Test + void twoIronGears() { + var stats = MachineStats.compute(DEFS, concat(IRON, IRON)); + assertNear(1.16, stats.get("speed")); + assertNear(49.6, stats.get("energy_per_tick")); + } + + @Test + void twoInvarGears() { + var stats = MachineStats.compute(DEFS, concat(INVAR, INVAR)); + assertNear(1.48, stats.get("speed")); + assertNear(54.4, stats.get("energy_per_tick")); + } + + @Test + void invarGearsAndAMotor() { + var stats = MachineStats.compute(DEFS, concat(INVAR, INVAR, MOTOR)); + assertNear(1.702, stats.get("speed")); + assertNear(65.28, stats.get("energy_per_tick")); + } + + @Test + void invarGearsAMotorAndAnUpgrade() { + var stats = MachineStats.compute(DEFS, concat(INVAR, INVAR, MOTOR, UPGRADE)); + assertNear(2.553, stats.get("speed")); + assertNear(97.92, stats.get("energy_per_tick")); + } + + @Test + void orderDoesNotMatter() { + var all = concat(INVAR, INVAR, MOTOR, UPGRADE); + var reversed = new ArrayList<>(all); + Collections.reverse(reversed); + assertEquals( + MachineStats.compute(DEFS, all).values(), MachineStats.compute(DEFS, reversed).values()); + } + + @Test + void noModifiersGiveTheBase() { + assertEquals(1, MachineStats.compute(DEFS, List.of()).get("speed")); + } + + @Test + void clampsToMinAndMax() { + assertEquals(8, MachineStats.compute(DEFS, List.of(total("speed", 10))).get("speed")); + assertEquals(.25, MachineStats.compute(DEFS, List.of(value("speed", -5))).get("speed")); + } + + @Test + void ignoresUndeclaredStats() { + var stats = MachineStats.compute(DEFS, List.of(value("max_temperature", 400))); + assertEquals(2, stats.values().size()); + assertEquals(1, stats.get("speed")); + } + + @Test + void refusesUnknownStatsAndNaN() { + assertRejected(() -> MachineStats.EMPTY.get("speed")); + assertRejected(() -> value("speed", Double.NaN)); + } + + @Test + void decodesDefinitions() { + assertEquals(DEFS.get("speed"), parse("{\"base\":1,\"min\":0.25,\"max\":8}")); + assertTrue(parse("{\"base\":900}").min().isEmpty()); + } + + @Test + void refusesBadDefinitions() { + assertRejected(() -> parse("{\"base\":1,\"min\":5,\"max\":2}")); + assertRejected(() -> parse("{\"min\":1}")); + } + + private static StatDefinition parse(String json) { + return StatDefinition.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow(); + } + + private static StatModifier value(String stat, double amount) { + return new StatModifier(stat, AttributeModifier.Operation.ADD_VALUE, amount); + } + + private static StatModifier base(String stat, double amount) { + return new StatModifier(stat, AttributeModifier.Operation.ADD_MULTIPLIED_BASE, amount); + } + + private static StatModifier total(String stat, double amount) { + return new StatModifier(stat, AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL, amount); + } + + @SafeVarargs + private static List concat(List... lists) { + var all = new ArrayList(); + for (var list : lists) all.addAll(list); + return all; + } +}