diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index 716c21c..0d9ade5 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -5,6 +5,7 @@ on: tags: - "broccolium-*" - "peripheralium-*" + - "testiarium-*" - "tweakium-*" permissions: @@ -26,7 +27,7 @@ jobs: run: | set -euo pipefail - if [[ ! "$GITHUB_REF_NAME" =~ ^(broccolium|peripheralium|tweakium)-([0-9]+\.[0-9]+)-(.+)$ ]]; then + if [[ ! "$GITHUB_REF_NAME" =~ ^(broccolium|peripheralium|testiarium|tweakium)-([0-9]+\.[0-9]+)-(.+)$ ]]; then echo "::error::Tag must match --" exit 1 fi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..248578e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# AGENTS.md + +## Project overview + +Minecraft 1.20.1 libraries written primarily in Kotlin. The build publishes three libraries for both Forge and Fabric: + +- `broccolium`: platform and storage abstractions. +- `tweakium`: ComputerCraft pocket and gameplay utilities; depends on Broccolium. +- `peripheralium`: ComputerCraft peripherals; depends on Tweakium and Broccolium. + +Each library has a shared `*-core` module plus `*-forge` and `*-fabric` loader adapters. Keep loader-neutral code and APIs in `*-core`; put Forge or Fabric API calls, entrypoints, mixins, access transformers, and loader metadata in the matching adapter module. + +## Layout + +- `projects/-core`: common code and shared assets. +- `projects/-forge`: Forge implementation; metadata is in `src/main/resources/META-INF/mods.toml`. +- `projects/-fabric`: Fabric implementation; metadata is in `src/main/resources/fabric.mod.json`. +- `gradle/libs.versions.toml`: dependency and plugin versions. +- `gradle.properties`: Minecraft and library versions. + +Shared modules use access wideners. Forge modules use access transformers and may use mixins; Fabric modules may use mixins. Update the relevant loader configuration and metadata when changing either integration boundary. + +## Build and test + +Use the checked-in Gradle wrapper: + +```sh +./gradlew test +./gradlew build +./gradlew :broccolium-core:test +./gradlew :broccolium-forge:test +./gradlew :broccolium-fabric:test +``` + +Run the smallest affected module test task while iterating, then `./gradlew test` for changes spanning modules or loaders. Tests use JUnit 5 and live under `src/test`; common test fixtures are provided by the Broccolium and Tweakium core modules. + +Minecraft client GameTests require a virtual display and an explicit timeout in headless environments: + +```sh +timeout --foreground 180s xvfb-run --auto-servernum ./gradlew :testiarium-forge:runClientGameTest --no-daemon +timeout --foreground 180s xvfb-run --auto-servernum ./gradlew :testiarium-fabric:runClientGameTest --no-daemon +``` + +## Code conventions + +- Write Kotlin using the official Kotlin style configured in `gradle.properties`: four-space indentation and trailing commas in multiline declarations. +- Preserve the existing `site.siredvin.` package structure and use Kotlin `object` singletons for mod entrypoints and shared registries where established. +- Use the existing platform abstraction instead of importing Forge or Fabric classes into core code. +- Keep Fabric client-only initialization isolated from common/server code. +- Do not edit `src/generated` resources by hand; change the applicable data generator instead. +- Keep version changes centralized in `gradle.properties` or `gradle/libs.versions.toml`. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ad6ed79 --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +Testiarium includes adaptations of these CC:Tweaked `mc-1.20.x` sources at +`6f16cd6b0e4b74afff5462d463bedba65764970e`, licensed under MPL-2.0: + +- projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt +- projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt +- projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt +- projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt diff --git a/build.gradle.kts b/build.gradle.kts index 4ee71fd..e67eeb6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,7 @@ subprojectShaking { val setupSubproject = subprojectShaking::setupSubproject val broccoliumVersion: String by project.extra +val testiariumVersion: String by project.extra subprojects { setupSubproject(this) diff --git a/gradle.properties b/gradle.properties index 3eaaf3f..65cd0f7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,7 @@ org.gradle.daemon=false minecraftVersion=1.21.1 # Mod Properties broccoliumVersion = 1.4.6 +testiariumVersion = 0.1.0 tweakiumVersion = 1.4.6 peripheraliumVersion = 1.4.6 projectGroup = site.siredvin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9b43a5..08d8094 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,7 +72,7 @@ fabric-config = {module = "fuzs.forgeconfigapiport:forgeconfigapiport-fabric", v fabric-junit = { module = "net.fabricmc:fabric-loader-junit", version.ref = "fabric-loader" } cc-tweaked-fabric-api = {module = "cc.tweaked:cc-tweaked-1.21.1-fabric-api", version.ref = "cc-tweaked"} cc-tweaked-fabric = {module = "cc.tweaked:cc-tweaked-1.21.1-fabric", version.ref = "cc-tweaked"} -modmenu = {module = "com.terraformersmc:modmenu", version.ref="modmenu"} +modmenu = {module = "maven.modrinth:modmenu", version.ref="modmenu"} teamreborn-energy = {module = "teamreborn:energy", version.ref = "teamreborn-energy"} # Forge mod dependencies diff --git a/openspec/changes/add-tweakium-peripheral-tests/.openspec.yaml b/openspec/changes/add-tweakium-peripheral-tests/.openspec.yaml new file mode 100644 index 0000000..64105fc --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-14 diff --git a/openspec/changes/add-tweakium-peripheral-tests/design.md b/openspec/changes/add-tweakium-peripheral-tests/design.md new file mode 100644 index 0000000..2e71dd6 --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/design.md @@ -0,0 +1,80 @@ +## Context + +Tweakium's Creative Filler is its concrete block peripheral, but its Lua-facing behavior is untested in a loaded Minecraft and CC:Tweaked environment. Testiarium supplies explicit GameTest registration, loader adapters, tags, structures, and JUnit XML reporting, but its optional CC:Tweaked adapter only verifies that the public API loads. + +CC:Tweaked's testmod has 109 annotated tests across component, computer, CraftOS, disk, disk drive, inventory, modem, monitor, pocket computer, printer, printout, recipe, relay, speaker, turtle, and loot behavior. Its reusable framework comprises server/client runners, managed computers, Lua fixtures, typed assertions, fixture import/export, commands, mixins, and loader hooks. This change provides functional parity for those facilities, not a copy of CC:Tweaked's own 109 product behavior tests. Tweakium is the first consumer and validates the resulting Lua peripheral workflow. + +Testiarium core is a complete CC:Tweaked-independent Minecraft GameTest engine. The optional `site.siredvin.testiarium.cct` package is a cohesive extension of that engine, not a minimal adapter or a consumer-local test utility. It carries every CC:Tweaked-dependent type and implementation required to write tests in the style of CC:Tweaked's own suite. + +## Goals / Non-Goals + +**Goals:** + +- Run the same Creative Filler peripheral GameTests against Forge and Fabric 1.20.1. +- Provide reusable generic server and client GameTest helpers equivalent to CC:Tweaked's testmod facilities. +- Provide an optional CC:Tweaked harness for Kotlin computer actions, Lua execution, peripheral/component assertions, and testmod commands. +- Verify a fixture-provided computer can discover the Creative Filler and fill a compatible target storage from Lua. +- Cover invalid mode, missing target, incompatible target, and unknown resource errors as Lua failures. +- Produce loader-specific JUnit XML reports through Testiarium's normal GameTest runs. + +**Non-Goals:** + +- Test unrelated Tweakium modules, pocket or turtle upgrades, or every storage backend. +- Add Testiarium or CC:Tweaked as a published Tweakium runtime dependency. +- Generalize CC:Tweaked internals into Testiarium's base API or support arbitrary external computer implementations. +- Copy CC:Tweaked's product-specific test classes, registries, loot data, upgrades, or component behavior into Testiarium. + +## Decisions + +### Make Testiarium core the complete Minecraft GameTest engine + +Core owns all functionality that can operate without CC:Tweaked: registration, tags, server lifecycle, reporting, fail-fast sequence behavior, world assertions, SNBT fixture workflow, import/export tooling, deterministic client execution, screenshots, and loader hooks. It exposes these features as consumer APIs rather than keeping them as Testiarium-internal test utilities. + +Moving generic logic into the CC:Tweaked package is rejected because it would make ordinary Minecraft GameTest consumers depend on a CC:Tweaked installation. + +### Add `site.siredvin.testiarium.cct` as the full CC:Tweaked testing package + +The `site.siredvin.testiarium.cct` package extends core with every CC:Tweaked-dependent facility required by the upstream test style: managed computers, Kotlin computer actions, Lua test-file execution, completion reporting, component/peripheral assertions, CC fixture support, and testmod commands. The package may use CC:Tweaked internals because it is optional and version-specific; it will retain MPL-2.0 notices and provenance for adapted source. + +Copying CC:Tweaked's own product test cases, registries, or fixture data is rejected. Directly calling peripheral methods with mocks is rejected because it does not validate the Lua contract. + +### Port reusable server, fixture, and assertion facilities + +Testiarium core will add fail-fast sequences and typed helpers for block entities, containers, entities, block state, items, and recipes. Its testmod tooling will load consumer SNBT fixtures, support import/export commands for test resources, and preserve compact fixtures by restoring omitted air blocks at load time. CC:Tweaked peripheral and computer-specific assertions live exclusively in `site.siredvin.testiarium.cct`. + +Testiarium's testmod owns the required version-sensitive mixins and registers `/testiarium import`, `/testiarium export`, `/testiarium regen-structures`, and `/testiarium marker`, adapted from CC:Tweaked's `cctest` workflow. The generic commands import/export configured fixture resources and regenerate every registered structure. CCT-specific computer-file import/export and fixture computer creation live below `/testiarium cct`, preserving the core package's CC-free boundary. + +Reimplementing each consumer's assertions and fixture handling is rejected because it would duplicate the same version-sensitive GameTest behavior across every testmod. + +### Add an opt-in client GameTest runner + +Testiarium will provide a client-only runner that creates a deterministic test world, executes registered client tests on the client thread, waits for rendering to stabilize, supports player/menu assertions and screenshots, writes JUnit XML, and terminates with the test result. Client hooks and mixins remain loader- and environment-isolated. + +Making client behavior part of the normal dedicated-server test path is rejected because it would require client classes and rendering initialization on servers. + +### Add isolated Tweakium testmod source sets and fixtures + +Put shared GameTest cases, Lua test files, and SNBT structure fixtures in Tweakium testmod sources, with Forge and Fabric entrypoints that use Testiarium's loader adapters. Each fixture will place and label a computer alongside the Creative Filler, its network connection, and an inventory target. Loader builds will add Testiarium and the matching CC:Tweaked runtime only to testmod/GameTest configurations. + +Reusing Testiarium's own adapter testmod was rejected because it only proves CC:Tweaked's public API is available, not Tweakium's peripheral behavior. Duplicating the framework setup was rejected because Testiarium already owns registration and reporting. + +### Test the Creative Filler from Lua + +The test will enqueue the labeled fixture computer and execute a Lua file that discovers the Creative Filler and calls its exposed method. Kotlin GameTest assertions will check the target inventory after Lua reports success. The Lua test will assert errors for invalid requests so failures surface through Testiarium's GameTest and JUnit reporting. + +Direct unit tests of `FillerStrategy` were rejected because they bypass block exposure, loader storage lookup, and CC:Tweaked peripheral integration. + +### Follow Testiarium's existing GameTest report contract + +The new Forge and Fabric GameTest launch configurations will enable the Tweakium test namespace, set the shared structure and report paths, enable assertions, and write a JUnit XML report under each module's build directory. Testiarium core initializes generic lifecycle and fixture services; `site.siredvin.testiarium.cct` initializes computer and Lua services before tests run. Tweakium supplies named SNBT fixtures instead of relying on the empty template. + +Custom reporting or Gradle test-task integration was rejected because the GameTest process and Testiarium reporter already expose CI-compatible results. + +## Risks / Trade-offs + +- [CC:Tweaked test internals change independently] -> Adapt only the harness components required by the consumer API, preserve MPL-2.0 provenance, and keep them isolated from Testiarium's base artifacts. +- [Client test runs are graphics-driver dependent] -> Use deterministic client setup and explicit render-idle checks; retain opt-in execution and screenshots as diagnostics. +- [Structure import/export is version-sensitive] -> Keep commands and mixins in testmod sources and validate compact SNBT fixtures on both loaders. +- [Fixture import or managed computers fail to become idle] -> Surface Lua completion and failures through the test API and make the GameTest wait for the managed computer result. +- [Test runtime dependencies leak into publication] -> Restrict Testiarium and CC:Tweaked dependencies to testmod configurations and verify normal production builds remain unchanged. +- [Structure fixtures are loader-sensitive] -> Use a shared SNBT fixture with only common block states and validate it on both loaders. diff --git a/openspec/changes/add-tweakium-peripheral-tests/proposal.md b/openspec/changes/add-tweakium-peripheral-tests/proposal.md new file mode 100644 index 0000000..a78b2f9 --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/proposal.md @@ -0,0 +1,27 @@ +## Why + +Tweakium exposes ComputerCraft peripheral behavior but has no automated in-game coverage for it. Testiarium provides only part of CC:Tweaked's GameTest framework, so consumers cannot reproduce its computer, Lua, fixture, assertion, client-render, or testmod workflow coverage. + +## What Changes + +- Put the complete loader-neutral Minecraft GameTest engine in Testiarium core: registration, lifecycle, structures, assertions, fixtures, commands, client runner, screenshots, reporting, and Forge/Fabric runs. +- Add `site.siredvin.testiarium.cct` as Testiarium's optional CC:Tweaked package, with functional parity for CC:Tweaked computer, Lua, peripheral, component, and testmod features. +- Add a Tweakium testmod that uses the harness with a per-test SNBT structure containing the computer, Creative Filler, network, and target inventory. +- Exercise the Creative Filler from Lua, including successful item transfer and invalid-target failures. +- Configure the loader GameTest runs to include Tweakium, Testiarium, and the matching CC:Tweaked runtime and emit JUnit XML reports. + +## Capabilities + +### New Capabilities +- `tweakium-peripheral-gametesting`: Automated Forge and Fabric integration coverage for Tweakium ComputerCraft peripherals. +- `gametest-assertion-helpers`: Reusable GameTest sequencing and world-state assertions. +- `gametest-structure-tooling`: Consumer fixture loading, import, export, and compact structure support. +- `client-gametest-runner`: Opt-in deterministic client GameTest execution and screenshot assertions. + +### Modified Capabilities + +- `optional-cct-peripheral-testing`: Reusable CC:Tweaked computer and Lua GameTest support. + +## Impact + +Affected modules: Testiarium core, its `site.siredvin.testiarium.cct` optional package, loader integration, mixins, commands, and run configurations, plus `tweakium-core`, `tweakium-forge`, and `tweakium-fabric` testmods and Gradle configurations. The test-only runtime gains Testiarium and CC:Tweaked; published Tweakium APIs and runtime dependencies remain unchanged. diff --git a/openspec/changes/add-tweakium-peripheral-tests/specs/client-gametest-runner/spec.md b/openspec/changes/add-tweakium-peripheral-tests/specs/client-gametest-runner/spec.md new file mode 100644 index 0000000..406cd91 --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/specs/client-gametest-runner/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Opt-in client GameTest execution +Testiarium SHALL provide an opt-in Forge and Fabric client GameTest runner that initializes a deterministic test world, executes registered client tests on the client thread, writes JUnit XML results, and exits with a status that reflects test success or failure. + +#### Scenario: Run client tests successfully +- **WHEN** a developer launches the configured client GameTest run +- **THEN** Testiarium executes the registered client tests and writes a passing JUnit XML report before exit + +#### Scenario: Report a client test failure +- **WHEN** a client test assertion fails +- **THEN** the runner records the failure in JUnit XML and exits unsuccessfully + +### Requirement: Client interaction and rendering assertions +The client runner SHALL provide client-thread actions, render-idle waiting, player reset and positioning, open-menu assertions, and screenshot capture for registered client tests. Client-only classes and hooks MUST NOT load on dedicated servers. + +#### Scenario: Capture a stable screenshot +- **WHEN** a client test waits for rendering to become idle and requests a screenshot +- **THEN** Testiarium captures the rendered test state at the configured output location + +#### Scenario: Start a dedicated server +- **WHEN** a dedicated server loads Testiarium +- **THEN** client runner classes and client-only mixins are not initialized diff --git a/openspec/changes/add-tweakium-peripheral-tests/specs/gametest-assertion-helpers/spec.md b/openspec/changes/add-tweakium-peripheral-tests/specs/gametest-assertion-helpers/spec.md new file mode 100644 index 0000000..cea415b --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/specs/gametest-assertion-helpers/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Server GameTest sequence and assertion helpers +Testiarium SHALL provide reusable server-side GameTest sequence operations that fail the owning test for action errors and helpers to assert block state, block entities, containers, entities, item stacks, and recipe results within a fixture. + +#### Scenario: Assert a fixture container +- **WHEN** a consumer asserts a fixture container's expected item stacks +- **THEN** a mismatch fails the GameTest with the fixture position and expected and actual contents + +#### Scenario: Fail a sequence action +- **WHEN** a consumer sequence action throws an assertion or runtime error +- **THEN** the owning GameTest fails rather than crashing the server process + +### Requirement: CC-free generic API +Generic assertion and sequence helpers SHALL NOT reference CC:Tweaked classes or internals. + +#### Scenario: Use generic helpers without CC:Tweaked +- **WHEN** a testmod without CC:Tweaked uses Testiarium's generic assertion helpers +- **THEN** it compiles and runs without CC:Tweaked installed diff --git a/openspec/changes/add-tweakium-peripheral-tests/specs/gametest-structure-tooling/spec.md b/openspec/changes/add-tweakium-peripheral-tests/specs/gametest-structure-tooling/spec.md new file mode 100644 index 0000000..e9ef017 --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/specs/gametest-structure-tooling/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Consumer SNBT fixture workflow +Testiarium SHALL load consumer-provided named SNBT GameTest fixtures and provide test-only tooling to import fixture resources, export a fixture, and regenerate configured structures. Compact fixtures with omitted air blocks MUST load with their intended air volume restored. + +The Testiarium testmod SHALL own the required version-sensitive mixins and register `/testiarium import`, `/testiarium export`, `/testiarium regen-structures`, and `/testiarium marker`. The import and export operations SHALL synchronize the configured consumer fixture source; `regen-structures` SHALL re-import and export every registered GameTest structure; and `marker` SHALL mark the nearest test fixture position. + +#### Scenario: Load a compact named fixture +- **WHEN** a consumer GameTest names a compact SNBT fixture +- **THEN** the GameTest loads the fixture with omitted positions treated as air + +#### Scenario: Export a fixture +- **WHEN** a developer invokes the configured fixture export operation for a named test area +- **THEN** Testiarium writes the fixture to the configured consumer structure source + +### Requirement: Test-only fixture operations +Fixture commands and version-sensitive mixins SHALL be isolated to Testiarium testmod execution and MUST NOT affect normal mod runtime behavior. + +#### Scenario: Run a normal production server +- **WHEN** a production server loads a mod that depends on Testiarium's published artifacts +- **THEN** test fixture commands and structure hooks are not registered diff --git a/openspec/changes/add-tweakium-peripheral-tests/specs/optional-cct-peripheral-testing/spec.md b/openspec/changes/add-tweakium-peripheral-tests/specs/optional-cct-peripheral-testing/spec.md new file mode 100644 index 0000000..6871aa9 --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/specs/optional-cct-peripheral-testing/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Full CC:Tweaked testing package +Testiarium SHALL provide `site.siredvin.testiarium.cct` as an optional cohesive package with functional parity for CC:Tweaked GameTest development. The package SHALL contain all CC:Tweaked-dependent computer, Lua, peripheral, component, fixture, and testmod operations. Testiarium core SHALL contain the complete CC:Tweaked-independent Minecraft GameTest engine and MUST NOT reference CC:Tweaked types or internals. + +#### Scenario: Use Testiarium core without CC:Tweaked +- **WHEN** a Minecraft testmod uses Testiarium core without adding the CCT package +- **THEN** it can use Testiarium's complete generic GameTest engine without CC:Tweaked installed + +#### Scenario: Use the CCT package +- **WHEN** a CC:Tweaked testmod adds `site.siredvin.testiarium.cct` +- **THEN** it can write GameTests using the full CC:Tweaked computer, Lua, peripheral, component, fixture, and testmod feature set + +### Requirement: CC:Tweaked managed computer GameTest harness +The CCT package SHALL provide reusable GameTest sequence operations that execute Kotlin actions on a labeled in-game computer and report completion or failure to the owning GameTest. + +#### Scenario: Execute Kotlin action on a fixture computer +- **WHEN** an adapter consumer schedules a Kotlin action for a labeled computer in a GameTest fixture +- **THEN** the action executes in that computer's CC:Tweaked runtime and its success or failure completes the GameTest sequence + +#### Scenario: Run without CC:Tweaked +- **WHEN** Testiarium runs without CC:Tweaked installed +- **THEN** standalone Testiarium GameTests remain available and the managed computer harness is not loaded + +### Requirement: CC:Tweaked Lua test-file execution +The CCT package SHALL import consumer-provided Lua test resources and execute the Lua file selected by a fixture computer's label. Lua completion and Lua errors MUST be reported to the owning GameTest. + +#### Scenario: Execute a Lua test file +- **WHEN** a labeled fixture computer is started for a GameTest with a matching imported Lua test file +- **THEN** the computer executes that file and successful completion marks the scheduled test action complete + +#### Scenario: Report a Lua test failure +- **WHEN** an imported Lua test file raises an error or explicitly reports failure +- **THEN** its owning GameTest fails with the Lua error details + +### Requirement: CC:Tweaked fixture support and provenance +The CCT package SHALL support consumer-provided SNBT GameTest fixtures containing CC:Tweaked computers and peripherals. Any Testiarium source adapted from CC:Tweaked's test-only harness MUST preserve MPL-2.0 licensing notices and record the inspected upstream source revision. + +#### Scenario: Load a computer fixture on both loaders +- **WHEN** Forge and Fabric launch a GameTest that names a consumer-provided SNBT computer fixture +- **THEN** the fixture is available to the test and its labeled computer can execute a managed action + +#### Scenario: Inspect adapted test harness source +- **WHEN** a maintainer reviews adapted CC:Tweaked harness source +- **THEN** each adapted source records its MPL-2.0 license and upstream provenance + +### Requirement: CC:Tweaked component GameTest helpers +The CCT package SHALL provide the fixture setup, assertions, and sequence operations needed for consumer GameTests equivalent to CC:Tweaked's component test families: component/peripheral exposure, computers and redstone, CraftOS, disks, disk drives, inventories, wired modems, monitors, pocket computers, printers, printouts, recipes, relays, speakers, turtles, and loot. These helpers MUST validate consumer-owned content and MUST NOT copy CC:Tweaked's component behavior tests, registries, or test datapack content into Testiarium. + +#### Scenario: Test a CC:Tweaked peripheral component +- **WHEN** a consumer supplies an SNBT fixture with a computer and a CC:Tweaked peripheral component +- **THEN** the adapter provides operations to invoke it from the fixture computer and assert the resulting world, inventory, event, or peripheral state + +#### Scenario: Test a CC:Tweaked turtle or pocket upgrade +- **WHEN** a consumer supplies an SNBT fixture with a turtle or pocket computer and a consumer-owned upgrade +- **THEN** the adapter provides computer execution and state assertions needed to verify the upgrade behavior + +#### Scenario: Test a CC:Tweaked client component +- **WHEN** a consumer registers a client GameTest for a monitor, pocket computer, printout, or turtle rendering case +- **THEN** the optional adapter interoperates with Testiarium's client runner to execute the case and capture its screenshot assertion + +### Requirement: CC:Tweaked testmod operations +The CCT package SHALL provide `/testiarium cct import`, `/testiarium cct export`, and `/testiarium cct give-computer` test-only commands. The import and export operations SHALL synchronize consumer computer files, and `give-computer` SHALL create a labeled fixture computer. Generic structure import, export, regeneration, and marking commands belong to Testiarium core's testmod. These commands MUST be unavailable from Testiarium's published runtime artifacts. + +#### Scenario: Import consumer Lua fixtures +- **WHEN** a developer runs the adapter's import operation for a consumer testmod +- **THEN** the consumer Lua files are available to labeled fixture computers for the next GameTest run + +#### Scenario: Export a structure fixture +- **WHEN** a developer runs the adapter's structure export operation for a named test +- **THEN** it writes an SNBT fixture suitable for the consumer's configured test structure source diff --git a/openspec/changes/add-tweakium-peripheral-tests/specs/tweakium-peripheral-gametesting/spec.md b/openspec/changes/add-tweakium-peripheral-tests/specs/tweakium-peripheral-gametesting/spec.md new file mode 100644 index 0000000..f54b064 --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/specs/tweakium-peripheral-gametesting/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Cross-loader Tweakium peripheral GameTest runtime +Tweakium SHALL provide Forge and Fabric GameTest testmods that load Tweakium, Testiarium, and the compatible CC:Tweaked runtime without adding either test dependency to published Tweakium artifacts. Each loader run MUST register the Tweakium peripheral tests and write a JUnit XML result report. + +#### Scenario: Run Tweakium peripheral GameTests on Forge +- **WHEN** a developer launches Tweakium's Forge peripheral GameTest configuration +- **THEN** the Tweakium tests execute with Forge CC:Tweaked and produce a Forge JUnit XML report + +#### Scenario: Run Tweakium peripheral GameTests on Fabric +- **WHEN** a developer launches Tweakium's Fabric peripheral GameTest configuration +- **THEN** the Tweakium tests execute with Fabric CC:Tweaked and produce a Fabric JUnit XML report + +#### Scenario: Build published Tweakium artifacts +- **WHEN** Tweakium's normal production artifacts are built +- **THEN** Testiarium and CC:Tweaked testmod dependencies are not published as Tweakium runtime dependencies + +### Requirement: Creative Filler Lua behavior coverage +The Tweakium peripheral GameTests SHALL use a named SNBT fixture that places and labels a computer, Creative Filler, its network connection, and a compatible target inventory. The fixture computer MUST execute a Lua test file through Testiarium's CC:Tweaked harness. The Lua test MUST verify successful item filling into the target and failures for an invalid mode, a missing target, an incompatible target, and an unknown item identifier. + +#### Scenario: Fill a compatible inventory from Lua +- **WHEN** the fixture computer discovers the Creative Filler and its Lua test requests an item fill for the compatible target inventory +- **THEN** the target inventory contains the requested item up to the applicable stack limit + +#### Scenario: Reject invalid fill requests from Lua +- **WHEN** the Lua test sends the Creative Filler an invalid mode, missing target, incompatible target, or unknown item identifier +- **THEN** the Lua call fails with the corresponding error and does not mutate the target inventory diff --git a/openspec/changes/add-tweakium-peripheral-tests/tasks.md b/openspec/changes/add-tweakium-peripheral-tests/tasks.md new file mode 100644 index 0000000..0c09aa9 --- /dev/null +++ b/openspec/changes/add-tweakium-peripheral-tests/tasks.md @@ -0,0 +1,35 @@ +## 1. Testiarium Core Minecraft GameTest Engine + +- [x] 1.1 Add generic fail-fast GameTest sequences and typed block, block-entity, container, entity, item, and recipe assertion helpers adapted from CC:Tweaked where applicable, with MPL-2.0 provenance. +- [x] 1.2 Add consumer SNBT fixture discovery plus testmod import/export commands and compact-air structure handling. +- [x] 1.3 Add server lifecycle hooks for deterministic world setup, stale-test cleanup, assertion failure conversion, and report completion, exposed through Testiarium core APIs. + +## 2. Client GameTest Parity + +- [x] 2.1 Add the opt-in client runner, deterministic client-world setup, client-thread sequencing, and result-driven exit on Forge and Fabric. +- [x] 2.2 Add render-idle waiting, player/menu assertions, screenshot capture, and client test resource cleanup. +- [x] 2.3 Isolate required client mixins and loader hooks from dedicated-server startup. + +## 3. `site.siredvin.testiarium.cct` Package Parity + +- [x] 3.1 Create the optional `site.siredvin.testiarium.cct` package and isolate its CC:Tweaked dependencies from Testiarium core. +- [x] 3.2 Adapt the MPL-2.0 managed-computer factory, completion-reporting Lua API, and CC fixture-import components into the CCT package, with provenance notices. +- [x] 3.3 Expose `thenStartComputer`, `thenOnComputer`, computer-completion operations, and labeled Lua test-file discovery, execution, assertion, and error reporting from the CCT package. +- [x] 3.4 Add CCT package helpers needed for computer, disk, disk drive, inventory, modem, monitor, pocket computer, printer, printout, relay, speaker, turtle, recipe, CraftOS, and loot GameTests. +- [x] 3.5 Add CCT package testmod commands and Forge/Fabric run configuration that initialize CCT services, import fixtures, wait for computer work, and run optional client cases. + +## 4. Tweakium Lua Peripheral GameTest + +- [x] 4.1 Add Tweakium core testmod sources that register Creative Filler GameTests and include a named SNBT fixture with a labeled computer, networked Creative Filler, and target inventory. +- [x] 4.2 Add the Lua test file that discovers Creative Filler, verifies item filling, and verifies invalid mode, missing target, incompatible target, and unknown item failures. +- [x] 4.3 Add Kotlin GameTest assertions that start the fixture computer through Testiarium and verify the target inventory after Lua completion. +- [x] 4.4 Configure Forge and Fabric Tweakium testmods, CC:Tweaked and Testiarium test dependencies, GameTest launches, namespaces, structures, assertions, and JUnit XML report paths. +- [x] 4.5 Add Forge and Fabric testmod entrypoints that register the shared tests through Testiarium's loader adapters. + +## 5. Validation + +- [x] 5.1 Add Testiarium fixtures that exercise every parity category: server assertions, structures, client rendering, managed computers, Lua, and all CC component helper families. +- [x] 5.2 Run the Forge server and client GameTest configurations and verify JUnit XML reports and screenshots. +- [x] 5.3 Run the Fabric server and client GameTest configurations and verify JUnit XML reports and screenshots. +- [x] 5.4 Run the Tweakium Forge and Fabric peripheral GameTest configurations and verify their JUnit XML reports. +- [x] 5.5 Run the affected normal build or test tasks and confirm published Testiarium and Tweakium dependency metadata excludes test-only CC:Tweaked dependencies. diff --git a/openspec/changes/publish-project-on-tag/.openspec.yaml b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/.openspec.yaml similarity index 100% rename from openspec/changes/publish-project-on-tag/.openspec.yaml rename to openspec/changes/archive/2026-07-13-add-testiarium-test-framework/.openspec.yaml diff --git a/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/design.md b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/design.md new file mode 100644 index 0000000..6992665 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/design.md @@ -0,0 +1,65 @@ +## Context + +The existing libraries are built together in this repository, and Testiarium will follow that established core, Forge, and Fabric subproject layout. It remains a separate production artifact family with no Broccolium production dependency. CC:Tweaked implements its test framework as a `testMod` source set: loader bootstrap registers GameTests; shared code replaces the GameTest reporter with a JUnit XML writer; Gradle launch profiles configure structures, tags, assertion status, and reports. Its test classes, computer scheduler, peripheral helpers, file importer, and client interactions are CC:Tweaked-specific. + +The initial upstream baseline is CC:Tweaked `mc-1.20.x` at `6f16cd6b0e4b74afff5462d463bedba65764970e`. Its testing sources use MPL-2.0. The initial Testiarium release targets Minecraft 1.20.1 on Forge and Fabric. Minecraft 1.21 and NeoForge are deferred to a subsequent change. + +## Goals / Non-Goals + +**Goals:** + +- Ship core, Forge, and Fabric Testiarium artifacts for Minecraft 1.20.1. +- Provide a `testMod` source set with explicit GameTest class registration, tag filtering, helper utilities, loader bootstrap, and JUnit XML result output. +- Preserve the ability to run a separate CC:Tweaked adapter that tests its public peripheral APIs. +- Preserve MPL-2.0 notices and record provenance for every adapted CC:Tweaked source or resource. + +**Non-Goals:** + +- Making Testiarium a Broccolium subproject or runtime dependency. +- Recreating CC:Tweaked's computer scheduler, file importer, commands, client UI automation, or test suite. +- Requiring CC:Tweaked for Testiarium compilation, testmod startup, ordinary GameTests, or the generic helper API. +- Supporting Minecraft versions outside 1.20.1 in the initial release. + +## Decisions + +### Follow the repository's core and loader subproject layout + +Testiarium will use the repository's Gradle wrapper, version catalog, version properties, publication configuration, and core/Forge/Fabric subproject layout used by Broccolium. It will provide a `testMod` source set for Minecraft 1.20.1, with Forge and Fabric loader modules resolving the compatible mappings, loader dependencies, and GameTest launches. + +Making Testiarium a Broccolium module was rejected because it must remain a separately published artifact with no Broccolium production dependency. + +### Adapt only the generic CC:Tweaked testmod components + +The common testmod code will adapt the JUnit reporter, multi-reporter, tag filtering, `ClientGameTest` annotation, generic GameTest sequence/helper utilities, and only the GameTest mixins required by those utilities. It will expose explicit test-class registration rather than copying CC:Tweaked's fixed list of its own test classes. Forge and Fabric modules will register that API with their native GameTest event or registry. + +Copying CC:Tweaked's complete `TestHooks`, `TestExtensions`, commands, and test classes was rejected because they directly depend on CC:Tweaked internals such as `ComputerCraftAPI`, `ServerContext`, `ManagedComputers`, `IPeripheral`, and platform helpers. + +### Keep CC:Tweaked integration optional and loader-specific + +The base common API will not reference `dan200.computercraft` types. A dedicated optional testmod adapter will compile against CC:Tweaked's public API and register only when CC:Tweaked is present. Forge and Fabric development runs that exercise peripherals will include the matching CC:Tweaked runtime artifact; ordinary Testiarium runs will not. + +Using a required CC:Tweaked dependency was rejected because it prevents testing mods that do not use CC:Tweaked and couples Testiarium releases to CC:Tweaked availability. + +### Use JUnit XML as the Gradle-facing test result interface + +Testiarium will use the platform GameTest runner as the execution authority. Its testmod initialization will replace the global reporter with a multi-reporter that preserves log output and writes JUnit XML to the configured system-property path. Gradle game-test launch profiles will set the structure source directory, enabled tags, assertions, loader-specific GameTest flag where required, and result path. Test fixtures will validate a passing test, a required failing test captured in XML, and a non-required failure reported as skipped. + +A separate custom test runner was rejected because GameTest already provides world setup, scheduling, commands, and CI-friendly process status. + +## Risks / Trade-offs + +- [CC:Tweaked test code changes independently] -> Record the inspected upstream revision and keep adapted MPL-2.0 code limited to the generic source set. +- [Minecraft or loader APIs differ in future release lines] -> Isolate the 1.20.1 Forge/Fabric bootstrap and adapters; add later version-specific modules in a separate change. +- [CC:Tweaked is absent or differs from the tested version] -> Guard integration loading by mod presence and declare compatible API/runtime versions in the integration test configuration. +- [A GameTest failure is hard to consume in CI] -> Write JUnit XML with test identifiers, error messages, and stack traces while retaining standard GameTest logs. + +## Migration Plan + +1. Create and publish Testiarium as its own artifact family without modifying Broccolium's production dependencies. +2. Add a Broccolium testmod that consumes the Testiarium artifacts and migrate its in-game checks incrementally. +3. Remove any temporary Broccolium-local GameTest scaffolding only after the Testiarium testmod runs on both supported release lines. + +## Open Questions + +- Which generic helper extensions and mixins are required for the initial API after excluding every CC:Tweaked-dependent helper? +- Which CC:Tweaked peripheral scenarios are required for the first optional adapter? diff --git a/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/proposal.md b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/proposal.md new file mode 100644 index 0000000..4c4ea36 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/proposal.md @@ -0,0 +1,30 @@ +## Why + +Minecraft library changes need repeatable in-game verification on both maintained release lines. Testiarium will provide a standalone testmod framework so Broccolium and other projects can run and inspect GameTests without depending on CC:Tweaked, while still allowing CC:T peripheral tests when it is present. + +## What Changes + +- Create the Testiarium library family alongside the existing libraries with core, Forge, and Fabric projects for Minecraft 1.20. Minecraft 1.21 support will be added separately. +- Adapt CC:Tweaked's MPL-2.0 testmod components for test registration, tags, GameTest reporting, and launch configuration. +- Provide a testmod source-set API that lets downstream mods explicitly register GameTest classes, filter test groups, and emit JUnit XML results. +- Make CC:Tweaked computer and peripheral helpers an optional integration with no CC:Tweaked dependency for ordinary Testiarium consumers. +- Keep Testiarium's production artifacts independent from Broccolium while following the repository's established multi-project build and publication conventions. + +## Capabilities + +### New Capabilities + +- `testiarium-project-layout`: Provide core, Forge, and Fabric Testiarium artifacts for Minecraft 1.20. +- `testmod-gametest-framework`: Let mod developers register, filter, execute, and report cross-loader GameTests without CC:Tweaked. +- `optional-cct-peripheral-testing`: Let testmods use CC:Tweaked peripherals when CC:Tweaked is installed, without making it required for other Testiarium users. + +### Modified Capabilities + +None. + +## Impact + +- Adds Testiarium core, Forge, and Fabric projects, loader metadata, and testmod sources for Minecraft 1.20. +- Establishes public testmod registration, tagging, helper, and JUnit result-reporting APIs for downstream mod testmods. +- Adapts selected CC:Tweaked testing sources under MPL-2.0 with retained notices and source provenance. +- Does not add Testiarium as a Broccolium dependency; Broccolium will consume it only from its own testmod setup. diff --git a/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/optional-cct-peripheral-testing/spec.md b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/optional-cct-peripheral-testing/spec.md new file mode 100644 index 0000000..984dc88 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/optional-cct-peripheral-testing/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Optional CC:Tweaked peripheral integration +Testiarium SHALL provide an optional adapter testmod for scenarios that use CC:Tweaked public peripheral APIs when a compatible CC:Tweaked installation is present. The base Testiarium artifacts and generic helper API MUST NOT require CC:Tweaked. + +#### Scenario: Run without CC:Tweaked +- **WHEN** a Testiarium testmod runs without CC:Tweaked installed +- **THEN** standalone Testiarium GameTests run and the CC:Tweaked adapter does not register its integration tests + +#### Scenario: Run with CC:Tweaked +- **WHEN** a compatible CC:Tweaked installation is present with the Testiarium adapter testmod +- **THEN** the adapter can execute GameTests against CC:Tweaked public peripheral APIs + +### Requirement: Loader-correct CC:Tweaked test runtime +The Forge and Fabric peripheral integration test configurations MUST resolve the corresponding CC:Tweaked API and runtime artifacts for Minecraft 1.20.1. + +#### Scenario: Launch peripheral test on Fabric +- **WHEN** a developer launches the Fabric peripheral integration test configuration +- **THEN** it uses Fabric-compatible CC:Tweaked artifacts and executes the integration tests + +#### Scenario: Launch peripheral test on Forge +- **WHEN** a developer launches the Forge peripheral integration test configuration +- **THEN** it uses Forge-compatible CC:Tweaked artifacts and executes the integration tests diff --git a/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/testiarium-project-layout/spec.md b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/testiarium-project-layout/spec.md new file mode 100644 index 0000000..f9ccc43 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/testiarium-project-layout/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Independent multi-loader project +Testiarium SHALL be a separately publishable artifact family with no Broccolium production dependency. It MUST follow the repository's core, Forge, and Fabric subproject layout and provide a `testMod` source set for Minecraft 1.20.1. + +#### Scenario: Build the Minecraft 1.20 release line +- **WHEN** a developer builds Testiarium for Minecraft 1.20 +- **THEN** the core, Forge, and Fabric artifacts and testmod launch configuration are produced without requiring a Broccolium production dependency + +### Requirement: Upstream provenance +Testiarium MUST record the upstream CC:Tweaked source path and revision for every adapted source file or resource and MUST retain its MPL-2.0 notices and other attribution obligations. + +#### Scenario: Review copied framework source +- **WHEN** a maintainer reviews a copied CC:Tweaked-derived file +- **THEN** the repository identifies its upstream source and revision and includes its MPL-2.0 notices diff --git a/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/testmod-gametest-framework/spec.md b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/testmod-gametest-framework/spec.md new file mode 100644 index 0000000..e30785f --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/specs/testmod-gametest-framework/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: CC:Tweaked-free testmod bootstrap +Testiarium SHALL provide a core `testMod` API and Forge and Fabric bootstrap support that lets a downstream mod explicitly register and run GameTest classes without CC:Tweaked being present at compile time or runtime. + +#### Scenario: Run a standalone testmod +- **WHEN** a testmod using only Testiarium registers GameTest classes and is loaded on its supported loader +- **THEN** its registered GameTests are runnable without CC:Tweaked installed + +### Requirement: Test group filtering +Testiarium MUST let a game-test launch enable explicitly named test groups through a system property and MUST register only tests whose group is enabled. + +#### Scenario: Run a client test group +- **WHEN** a client game-test launch enables the `client` and `common` groups +- **THEN** Testiarium registers tests in those groups and excludes tests in other groups + +### Requirement: Actionable GameTest results +Testiarium MUST retain GameTest log reporting and write a JUnit XML report to its configured output path. The report MUST include each executed test's identifier and outcome, plus an error message and stack trace for a required failed test. + +#### Scenario: Report a failed assertion +- **WHEN** a Testiarium GameTest assertion fails +- **THEN** the GameTest output identifies the test and the JUnit XML report contains its failure message and stack trace + +#### Scenario: Report a passing test +- **WHEN** a Testiarium GameTest completes successfully +- **THEN** the GameTest runner reports the test as passed and the JUnit XML report contains a passing testcase + +#### Scenario: Report a non-required failure +- **WHEN** a non-required Testiarium GameTest fails +- **THEN** the JUnit XML report records the testcase as skipped with its failure message diff --git a/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/tasks.md b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/tasks.md new file mode 100644 index 0000000..c2845bc --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-testiarium-test-framework/tasks.md @@ -0,0 +1,28 @@ +## 1. Independent Project Setup + +- [x] 1.1 Create Testiarium core, Forge, and Fabric subprojects with a `testMod` source set, version properties, and publication configuration following the Broccolium project layout. +- [x] 1.2 Configure Minecraft 1.20.1 Forge/Fabric with the required Java toolchain, mappings, loader dependencies, and development launch configurations. +- [x] 1.3 Add loader metadata and minimal standalone testmods for Forge and Fabric. + +## 2. CC:Tweaked Framework Extraction + +- [x] 2.1 Add MPL-2.0 notices and provenance for the inspected CC:Tweaked 1.20 source paths selected for adaptation. +- [x] 2.2 Port the generic tag filter, client-test annotation, helper/sequence utilities, multi-reporter, and JUnit XML reporter into Testiarium without `dan200.computercraft` references. +- [x] 2.3 Define explicit test-class registration and implement Forge and Fabric adapters that register those tests through each loader's GameTest support. + +## 3. Standalone GameTest Verification + +- [x] 3.1 Configure game-test launch profiles with assertion status, structure source path, enabled test groups, loader-required GameTest flags, and JUnit XML result paths. +- [x] 3.2 Add passing, required-failure, and non-required-failure fixtures that verify explicit registration, group filtering, logs, and JUnit XML output without CC:Tweaked. +- [x] 3.3 Run the standalone testmods on Forge/Fabric 1.20.1 and verify JUnit XML result semantics. + +## 4. Optional CC:Tweaked Integration + +- [x] 4.1 Add isolated Forge and Fabric adapter test configurations with matching CC:Tweaked public API and runtime artifacts. +- [x] 4.2 Implement the CC:Tweaked adapter's peripheral helpers and guard registration so its absence does not affect standalone Testiarium testmods. +- [x] 4.3 Add and run a peripheral GameTest using only CC:Tweaked public APIs on Forge/Fabric 1.20.1. + +## 5. Consumer Validation + +- [x] 5.1 Add concise consumer documentation showing how an external mod declares standalone and optional CC:Tweaked GameTests. +- [x] 5.2 Validate Testiarium from a Broccolium testmod dependency without adding a Broccolium production dependency. diff --git a/openspec/changes/archive/2026-07-14-publish-project-on-tag/.openspec.yaml b/openspec/changes/archive/2026-07-14-publish-project-on-tag/.openspec.yaml new file mode 100644 index 0000000..b119b63 --- /dev/null +++ b/openspec/changes/archive/2026-07-14-publish-project-on-tag/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/publish-project-on-tag/design.md b/openspec/changes/archive/2026-07-14-publish-project-on-tag/design.md similarity index 100% rename from openspec/changes/publish-project-on-tag/design.md rename to openspec/changes/archive/2026-07-14-publish-project-on-tag/design.md diff --git a/openspec/changes/publish-project-on-tag/proposal.md b/openspec/changes/archive/2026-07-14-publish-project-on-tag/proposal.md similarity index 100% rename from openspec/changes/publish-project-on-tag/proposal.md rename to openspec/changes/archive/2026-07-14-publish-project-on-tag/proposal.md diff --git a/openspec/changes/publish-project-on-tag/specs/tagged-maven-publishing/spec.md b/openspec/changes/archive/2026-07-14-publish-project-on-tag/specs/tagged-maven-publishing/spec.md similarity index 100% rename from openspec/changes/publish-project-on-tag/specs/tagged-maven-publishing/spec.md rename to openspec/changes/archive/2026-07-14-publish-project-on-tag/specs/tagged-maven-publishing/spec.md diff --git a/openspec/changes/publish-project-on-tag/tasks.md b/openspec/changes/archive/2026-07-14-publish-project-on-tag/tasks.md similarity index 100% rename from openspec/changes/publish-project-on-tag/tasks.md rename to openspec/changes/archive/2026-07-14-publish-project-on-tag/tasks.md diff --git a/openspec/config.yaml b/openspec/config.yaml index fd70229..f92c87b 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -9,12 +9,12 @@ schema: spec-driven # We use conventional commits # Domain: e-commerce platform -Per-artifact rules (optional) -Add custom rules for specific artifacts. -Example: - rules: - proposal: - - Keep proposals under 500 words +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +rules: + proposal: + - Keep proposals under 500 words # - Always include a "Non-goals" section # tasks: # - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/optional-cct-peripheral-testing/spec.md b/openspec/specs/optional-cct-peripheral-testing/spec.md new file mode 100644 index 0000000..990d6a8 --- /dev/null +++ b/openspec/specs/optional-cct-peripheral-testing/spec.md @@ -0,0 +1,27 @@ +## Purpose + +Define optional CC:Tweaked peripheral integration testing for Testiarium. + +## Requirements + +### Requirement: Optional CC:Tweaked peripheral integration +Testiarium SHALL provide an optional adapter testmod for scenarios that use CC:Tweaked public peripheral APIs when a compatible CC:Tweaked installation is present. The base Testiarium artifacts and generic helper API MUST NOT require CC:Tweaked. + +#### Scenario: Run without CC:Tweaked +- **WHEN** a Testiarium testmod runs without CC:Tweaked installed +- **THEN** standalone Testiarium GameTests run and the CC:Tweaked adapter does not register its integration tests + +#### Scenario: Run with CC:Tweaked +- **WHEN** a compatible CC:Tweaked installation is present with the Testiarium adapter testmod +- **THEN** the adapter can execute GameTests against CC:Tweaked public peripheral APIs + +### Requirement: Loader-correct CC:Tweaked test runtime +The Forge and Fabric peripheral integration test configurations MUST resolve the corresponding CC:Tweaked API and runtime artifacts for Minecraft 1.20.1. + +#### Scenario: Launch peripheral test on Fabric +- **WHEN** a developer launches the Fabric peripheral integration test configuration +- **THEN** it uses Fabric-compatible CC:Tweaked artifacts and executes the integration tests + +#### Scenario: Launch peripheral test on Forge +- **WHEN** a developer launches the Forge peripheral integration test configuration +- **THEN** it uses Forge-compatible CC:Tweaked artifacts and executes the integration tests diff --git a/openspec/specs/tagged-maven-publishing/spec.md b/openspec/specs/tagged-maven-publishing/spec.md new file mode 100644 index 0000000..0ee8434 --- /dev/null +++ b/openspec/specs/tagged-maven-publishing/spec.md @@ -0,0 +1,60 @@ +# tagged-maven-publishing Specification + +## Purpose +TBD - created by archiving change publish-project-on-tag. Update Purpose after archive. +## Requirements +### Requirement: Project-specific release tags +The release workflow SHALL run for tags matching `broccolium--`, `peripheralium--`, or `tweakium--` and SHALL derive all three values from the tag. The Minecraft release SHALL be the major/minor release line, such as `1.20` for configured Minecraft version `1.20.1`. + +#### Scenario: Supported project tag is pushed +- **WHEN** a `tweakium-1.20-1.4.6` tag is pushed +- **THEN** the workflow selects Tweakium for Minecraft release `1.20` and library version `1.4.6` + +#### Scenario: Unrelated tag is pushed +- **WHEN** a tag outside the three supported project prefixes is pushed +- **THEN** the Maven publishing workflow does not run + +### Requirement: Minecraft-compatible Java +The release workflow SHALL use Java 17 for Minecraft release line `1.20` and Java 21 for Minecraft release line `1.21`. + +#### Scenario: Minecraft 1.20 release +- **WHEN** a valid tag targets Minecraft release line `1.20` +- **THEN** the workflow configures Java 17 before invoking Gradle + +#### Scenario: Minecraft 1.21 release +- **WHEN** a valid tag targets Minecraft release line `1.21` +- **THEN** the workflow configures Java 21 before invoking Gradle + +### Requirement: Release version validation +The release workflow MUST verify before invoking any publish task that the tag's Minecraft release exactly matches the first two components of `minecraftVersion` and that its library version exactly matches the selected library's version in `gradle.properties`. + +#### Scenario: Tag and configured version match +- **WHEN** `broccolium-1.20-1.4.6` is pushed, `minecraftVersion` is `1.20.1`, and `broccoliumVersion` is `1.4.6` +- **THEN** the workflow proceeds to publication + +#### Scenario: Tag and configured library version differ +- **WHEN** `broccolium-1.20-1.4.6` is pushed and `broccoliumVersion` is not `1.4.6` +- **THEN** the workflow fails before invoking any publish task + +#### Scenario: Tag and configured Minecraft release differ +- **WHEN** `broccolium-1.20-1.4.6` is pushed and `minecraftVersion` does not belong to release line `1.20` +- **THEN** the workflow fails before invoking any publish task + +### Requirement: Isolated family publication +The release workflow SHALL publish the selected library family's core, Forge, and Fabric subprojects and SHALL NOT invoke publication tasks for either unselected family. + +#### Scenario: Peripheralium release +- **WHEN** a valid `peripheralium--` tag is processed +- **THEN** only `peripheralium-core`, `peripheralium-forge`, and `peripheralium-fabric` publication tasks are invoked + +### Requirement: Protected Maven credentials +The release workflow MUST obtain Maven credentials from GitHub Actions secrets and MUST use only read access to repository contents. + +#### Scenario: Publishing with configured credentials +- **WHEN** a valid project tag is processed and the required repository secrets are available +- **THEN** the credentials are supplied to the existing Gradle publishing configuration without being stored in the repository or printed in logs + +#### Scenario: Credentials are unavailable +- **WHEN** a valid project tag is processed without the required repository secrets +- **THEN** publication fails without exposing credential values + diff --git a/openspec/specs/testiarium-project-layout/spec.md b/openspec/specs/testiarium-project-layout/spec.md new file mode 100644 index 0000000..a4398fa --- /dev/null +++ b/openspec/specs/testiarium-project-layout/spec.md @@ -0,0 +1,19 @@ +## Purpose + +Define Testiarium's independent multi-loader project structure and upstream attribution requirements. + +## Requirements + +### Requirement: Independent multi-loader project +Testiarium SHALL be a separately publishable artifact family with no Broccolium production dependency. It MUST follow the repository's core, Forge, and Fabric subproject layout and provide a `testMod` source set for Minecraft 1.20.1. + +#### Scenario: Build the Minecraft 1.20 release line +- **WHEN** a developer builds Testiarium for Minecraft 1.20 +- **THEN** the core, Forge, and Fabric artifacts and testmod launch configuration are produced without requiring a Broccolium production dependency + +### Requirement: Upstream provenance +Testiarium MUST record the upstream CC:Tweaked source path and revision for every adapted source file or resource and MUST retain its MPL-2.0 notices and other attribution obligations. + +#### Scenario: Review copied framework source +- **WHEN** a maintainer reviews a copied CC:Tweaked-derived file +- **THEN** the repository identifies its upstream source and revision and includes its MPL-2.0 notices diff --git a/openspec/specs/testmod-gametest-framework/spec.md b/openspec/specs/testmod-gametest-framework/spec.md new file mode 100644 index 0000000..a83d716 --- /dev/null +++ b/openspec/specs/testmod-gametest-framework/spec.md @@ -0,0 +1,34 @@ +## Purpose + +Define Testiarium's CC:Tweaked-free GameTest bootstrap, filtering, and result reporting. + +## Requirements + +### Requirement: CC:Tweaked-free testmod bootstrap +Testiarium SHALL provide a core `testMod` API and Forge and Fabric bootstrap support that lets a downstream mod explicitly register and run GameTest classes without CC:Tweaked being present at compile time or runtime. + +#### Scenario: Run a standalone testmod +- **WHEN** a testmod using only Testiarium registers GameTest classes and is loaded on its supported loader +- **THEN** its registered GameTests are runnable without CC:Tweaked installed + +### Requirement: Test group filtering +Testiarium MUST let a game-test launch enable explicitly named test groups through a system property and MUST register only tests whose group is enabled. + +#### Scenario: Run a client test group +- **WHEN** a client game-test launch enables the `client` and `common` groups +- **THEN** Testiarium registers tests in those groups and excludes tests in other groups + +### Requirement: Actionable GameTest results +Testiarium MUST retain GameTest log reporting and write a JUnit XML report to its configured output path. The report MUST include each executed test's identifier and outcome, plus an error message and stack trace for a required failed test. + +#### Scenario: Report a failed assertion +- **WHEN** a Testiarium GameTest assertion fails +- **THEN** the GameTest output identifies the test and the JUnit XML report contains its failure message and stack trace + +#### Scenario: Report a passing test +- **WHEN** a Testiarium GameTest completes successfully +- **THEN** the GameTest runner reports the test as passed and the JUnit XML report contains a passing testcase + +#### Scenario: Report a non-required failure +- **WHEN** a non-required Testiarium GameTest fails +- **THEN** the JUnit XML report records the testcase as skipped with its failure message diff --git a/projects/broccolium-fabric/build.gradle.kts b/projects/broccolium-fabric/build.gradle.kts index bd18075..aacc937 100644 --- a/projects/broccolium-fabric/build.gradle.kts +++ b/projects/broccolium-fabric/build.gradle.kts @@ -35,15 +35,25 @@ repositories { } } maven { - name = "ModMenu maven" - url = uri("https://maven.terraformersmc.com/releases") + name = "Modrinth maven" + url = uri("https://api.modrinth.com/maven") content { - includeGroup("com.terraformersmc") + includeGroup("maven.modrinth") } } } sourceSets { + create("testMod") { + compileClasspath += main.get().compileClasspath + compileClasspath += main.get().output + compileClasspath += project(":testiarium-core").sourceSets.main.get().output + compileClasspath += project(":testiarium-fabric").sourceSets.main.get().output + runtimeClasspath += main.get().runtimeClasspath + runtimeClasspath += main.get().output + runtimeClasspath += project(":testiarium-core").sourceSets.main.get().output + runtimeClasspath += project(":testiarium-fabric").sourceSets.main.get().output + } test { compileClasspath += project(":broccolium-core").sourceSets["testFixtures"].output runtimeClasspath += project(":broccolium-core").sourceSets["testFixtures"].output diff --git a/projects/broccolium-fabric/src/testMod/kotlin/site/siredvin/broccolium/testmod/BroccoliumTestiariumFabricTestMod.kt b/projects/broccolium-fabric/src/testMod/kotlin/site/siredvin/broccolium/testmod/BroccoliumTestiariumFabricTestMod.kt new file mode 100644 index 0000000..fbc2d71 --- /dev/null +++ b/projects/broccolium-fabric/src/testMod/kotlin/site/siredvin/broccolium/testmod/BroccoliumTestiariumFabricTestMod.kt @@ -0,0 +1,21 @@ +package site.siredvin.broccolium.testmod + +import net.fabricmc.api.ModInitializer +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import site.siredvin.testiarium.FabricTestiarium +import site.siredvin.testiarium.Testiarium + +object BroccoliumTestiariumFabricTestMod : ModInitializer { + override fun onInitialize() { + Testiarium.register(GameTests::class.java) + FabricTestiarium.registerTests() + } +} + +class GameTests { + @GameTest(template = "empty") + fun loads(helper: GameTestHelper) { + helper.succeed() + } +} diff --git a/projects/broccolium-fabric/src/testMod/resources/fabric.mod.json b/projects/broccolium-fabric/src/testMod/resources/fabric.mod.json new file mode 100644 index 0000000..3899241 --- /dev/null +++ b/projects/broccolium-fabric/src/testMod/resources/fabric.mod.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "broccolium_testmod", + "version": "1.0", + "name": "Broccolium Testiarium Testmod", + "environment": "*", + "entrypoints": { + "main": [ + { + "adapter": "kotlin", + "value": "site.siredvin.broccolium.testmod.BroccoliumTestiariumFabricTestMod" + } + ] + }, + "depends": { + "fabricloader": "*", + "broccolium": "*", + "testiarium": "*" + } +} diff --git a/projects/broccolium-forge/build.gradle.kts b/projects/broccolium-forge/build.gradle.kts index fe18010..748b41e 100644 --- a/projects/broccolium-forge/build.gradle.kts +++ b/projects/broccolium-forge/build.gradle.kts @@ -37,6 +37,16 @@ repositories { } sourceSets { + create("testMod") { + compileClasspath += main.get().compileClasspath + compileClasspath += main.get().output + compileClasspath += project(":testiarium-core").sourceSets.main.get().output + compileClasspath += project(":testiarium-forge").sourceSets.main.get().output + runtimeClasspath += main.get().runtimeClasspath + runtimeClasspath += main.get().output + runtimeClasspath += project(":testiarium-core").sourceSets.main.get().output + runtimeClasspath += project(":testiarium-forge").sourceSets.main.get().output + } test { compileClasspath += sourceSets["main"].compileClasspath + sourceSets["main"].output runtimeClasspath += sourceSets["main"].runtimeClasspath + sourceSets["main"].output diff --git a/projects/broccolium-forge/src/testMod/java/site/siredvin/broccolium/testmod/BroccoliumTestiariumForgeTestMod.java b/projects/broccolium-forge/src/testMod/java/site/siredvin/broccolium/testmod/BroccoliumTestiariumForgeTestMod.java new file mode 100644 index 0000000..c166500 --- /dev/null +++ b/projects/broccolium-forge/src/testMod/java/site/siredvin/broccolium/testmod/BroccoliumTestiariumForgeTestMod.java @@ -0,0 +1,20 @@ +package site.siredvin.broccolium.testmod; + +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.neoforged.fml.common.Mod; +import site.siredvin.testiarium.Testiarium; + +@Mod("broccolium_testmod") +public final class BroccoliumTestiariumForgeTestMod { + public BroccoliumTestiariumForgeTestMod() { + Testiarium.register(GameTests.class); + } + + public static final class GameTests { + @GameTest(template = "empty") + public void loads(GameTestHelper helper) { + helper.succeed(); + } + } +} diff --git a/projects/broccolium-forge/src/testMod/resources/META-INF/neoforge.mods.toml b/projects/broccolium-forge/src/testMod/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..da579ce --- /dev/null +++ b/projects/broccolium-forge/src/testMod/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,8 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="MIT" + +[[mods]] +modId="broccolium_testmod" +version="1.0" +displayName="Broccolium Testiarium Testmod" diff --git a/projects/peripheralium-fabric/build.gradle.kts b/projects/peripheralium-fabric/build.gradle.kts index 5f7167d..26128b3 100644 --- a/projects/peripheralium-fabric/build.gradle.kts +++ b/projects/peripheralium-fabric/build.gradle.kts @@ -48,10 +48,10 @@ repositories { } } maven { - name = "ModMenu maven" - url = uri("https://maven.terraformersmc.com/releases") + name = "Modrinth maven" + url = uri("https://api.modrinth.com/maven") content { - includeGroup("com.terraformersmc") + includeGroup("maven.modrinth") } } } diff --git a/projects/testiarium-core/README.md b/projects/testiarium-core/README.md new file mode 100644 index 0000000..3ebfc5d --- /dev/null +++ b/projects/testiarium-core/README.md @@ -0,0 +1,24 @@ +# Testiarium + +Testiarium is a Minecraft 1.20.1 GameTest helper for Forge and Fabric. + +Register the classes that contain your tests during your testmod initialization: + +```kotlin +Testiarium.register(MyGameTests::class.java) +``` + +Then register the collected tests through the current loader: + +```kotlin +// Forge +ForgeTestiarium.registerTests() + +// Fabric +FabricTestiarium.registerTests() +``` + +Use `-Dtestiarium.tags=common,client` to enable groups declared with `@TestGroup`. +Use `-Dtestiarium.gametest-report=build/test-results/gametest.xml` to write JUnit XML. + +CC:Tweaked is optional. Add its matching loader artifact only to your `testMod` classpath, and keep all code using its public APIs in that testmod source set. diff --git a/projects/testiarium-core/build.gradle.kts b/projects/testiarium-core/build.gradle.kts new file mode 100644 index 0000000..38a8768 --- /dev/null +++ b/projects/testiarium-core/build.gradle.kts @@ -0,0 +1,44 @@ +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("site.siredvin.vanilla") + id("site.siredvin.publishing") +} + +val testiariumVersion: String by extra + +baseShaking { + projectPart.set("core") + projectName.set("testiarium") + projectVersion.set(testiariumVersion) + shake() +} + +vanillaShaking { + shake() +} + +sourceSets.create("testMod") { + compileClasspath += sourceSets.main.get().compileClasspath + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output +} + +val cctTestMod = sourceSets.create("cctTestMod") { + compileClasspath += sourceSets["testMod"].compileClasspath + compileClasspath += sourceSets["testMod"].output + runtimeClasspath += sourceSets["testMod"].runtimeClasspath + runtimeClasspath += sourceSets["testMod"].output +} + +dependencies { + implementation(libs.bundles.kotlin) + add(sourceSets["testMod"].implementationConfigurationName, files(sourceSets.main.get().output)) + add(sourceSets["testMod"].compileOnlyConfigurationName, libs.mixin) + add(cctTestMod.implementationConfigurationName, files(sourceSets.main.get().output)) + add(cctTestMod.compileOnlyConfigurationName, libs.bundles.cccommon) +} + +publishingShaking { + projectVersion.set(testiariumVersion) + shake() +} diff --git a/projects/testiarium-core/src/cctTestMod/java/site/siredvin/testiarium/cct/mixin/GameTestServerMixin.java b/projects/testiarium-core/src/cctTestMod/java/site/siredvin/testiarium/cct/mixin/GameTestServerMixin.java new file mode 100644 index 0000000..2a8b8e7 --- /dev/null +++ b/projects/testiarium-core/src/cctTestMod/java/site/siredvin/testiarium/cct/mixin/GameTestServerMixin.java @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 + +package site.siredvin.testiarium.cct.mixin; + +import com.mojang.datafixers.DataFixer; +import net.minecraft.gametest.framework.GameTestServer; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.Services; +import net.minecraft.server.WorldStem; +import net.minecraft.server.level.progress.ChunkProgressListenerFactory; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.Shadow; +import site.siredvin.testiarium.cct.CctComputers; + +import java.net.Proxy; +import java.util.concurrent.locks.LockSupport; + +@Mixin(GameTestServer.class) +abstract class GameTestServerMixin extends MinecraftServer { + GameTestServerMixin(Thread serverThread, LevelStorageSource.LevelStorageAccess storageSource, PackRepository packRepository, WorldStem worldStem, Proxy proxy, DataFixer fixerUpper, Services services, ChunkProgressListenerFactory progressListenerFactory) { + super(serverThread, storageSource, packRepository, worldStem, proxy, fixerUpper, services, progressListenerFactory); + } + + @Overwrite + @Override + public void waitUntilNextTick() { + while (true) { + runAllTasks(); + if (!haveTestsStarted() || CctComputers.areComputersIdle(this)) break; + LockSupport.parkNanos(100_000); + } + } + + @Shadow + private boolean haveTestsStarted() { + throw new AssertionError("Stub."); + } +} diff --git a/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctAssertions.kt b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctAssertions.kt new file mode 100644 index 0000000..c1dd4f7 --- /dev/null +++ b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctAssertions.kt @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked 1.113.0 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt. + +package site.siredvin.testiarium.cct + +import net.minecraft.core.BlockPos +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.ItemStack +import site.siredvin.testiarium.api.assertContainer + +fun GameTestHelper.assertCctBlock(pos: BlockPos, path: String) { + val actual = BuiltInRegistries.BLOCK.getKey(getBlockState(pos).block) + if (actual.namespace != "computercraft" || actual.path != path) { + throw GameTestAssertException("Expected computercraft:$path at $pos, found $actual") + } +} + +fun GameTestHelper.assertComputer(pos: BlockPos) = assertCctBlock(pos, "computer_normal") +fun GameTestHelper.assertDiskDrive(pos: BlockPos) = assertCctBlock(pos, "disk_drive") +fun GameTestHelper.assertModem(pos: BlockPos) = assertCctBlock(pos, "wireless_modem_normal") +fun GameTestHelper.assertMonitor(pos: BlockPos) = assertCctBlock(pos, "monitor_normal") +fun GameTestHelper.assertPrinter(pos: BlockPos) = assertCctBlock(pos, "printer") +fun GameTestHelper.assertSpeaker(pos: BlockPos) = assertCctBlock(pos, "speaker") +fun GameTestHelper.assertTurtle(pos: BlockPos) = assertCctBlock(pos, "turtle_normal") +fun GameTestHelper.assertRelay(pos: BlockPos) = assertCctBlock(pos, "wired_modem_full") +fun GameTestHelper.assertInventory(pos: BlockPos, expected: List) = assertContainer(pos, expected) +fun GameTestHelper.assertCraftOsFile(label: String) = require(label.isNotBlank()) { "Computer label must select a Lua test file" } +fun GameTestHelper.assertPocketComputer(stack: ItemStack) = assertCctItem(stack, "pocket_computer_normal") +fun GameTestHelper.assertPrintout(stack: ItemStack) = assertCctItem(stack, "printed_page") +fun GameTestHelper.assertDisk(stack: ItemStack) = assertCctItem(stack, "disk") +fun GameTestHelper.assertLoot(stack: ItemStack, expectedPath: String) = assertCctItem(stack, expectedPath) +fun GameTestHelper.assertRecipeResult(stack: ItemStack, expectedPath: String) = assertCctItem(stack, expectedPath) + +private fun GameTestHelper.assertCctItem(stack: ItemStack, path: String) { + val actual = BuiltInRegistries.ITEM.getKey(stack.item) + if (actual.namespace != "computercraft" || actual.path != path) { + throw GameTestAssertException("Expected computercraft:$path, found $actual") + } +} diff --git a/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctComputers.kt b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctComputers.kt new file mode 100644 index 0000000..7200bbe --- /dev/null +++ b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctComputers.kt @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked 1.113.0 projects/common/src/testMod/{java/kotlin}/dan200/computercraft/gametest/core/{TestAPI,ManagedComputers}.{java,kt}. + +package site.siredvin.testiarium.cct + +import dan200.computercraft.api.ComputerCraftAPI +import dan200.computercraft.api.lua.IComputerSystem +import dan200.computercraft.api.lua.ILuaAPI +import dan200.computercraft.api.lua.LuaException +import dan200.computercraft.api.lua.LuaFunction +import dan200.computercraft.core.ComputerContext +import dan200.computercraft.core.computer.computerthread.ComputerThread +import dan200.computercraft.core.lua.CobaltLuaMachine +import dan200.computercraft.core.lua.ILuaMachine +import dan200.computercraft.core.lua.MachineEnvironment +import dan200.computercraft.shared.computer.core.ServerContext +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.server.MinecraftServer +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.lang.invoke.MethodHandles +import java.nio.file.Files +import java.nio.file.Path +import java.util.Optional +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.atomic.AtomicInteger + +typealias CctComputerAction = IComputerSystem.() -> Unit + +object CctComputers { + private val logger = LoggerFactory.getLogger(CctComputers::class.java) + private val actions = ConcurrentHashMap>() + + fun initialize() { + // This must be set before CC:Tweaked creates the server context. + ServerContext.luaMachine = CctLuaMachineFactory + ComputerCraftAPI.registerAPIFactory(::CctTestApi) + } + + fun reset() { + actions.clear() + CctComputerState.reset() + } + + @JvmStatic + fun areComputersIdle(server: MinecraftServer): Boolean = ComputerThreadReflection.isFullyIdle(ServerContext.get(server)) + + fun enqueue(server: MinecraftServer, label: String, action: CctComputerAction): CctComputerMonitor { + val queued = QueuedAction(action) + actions.computeIfAbsent(label) { ConcurrentLinkedDeque() }.add(queued) + ServerContext.get(server).registry().computers.firstOrNull { it.label == label }?.apply { + turnOn() + queueEvent("test_wakeup") + } + return CctComputerMonitor(label, queued) + } + + internal fun run(label: String, computer: IComputerSystem) { + val queued = actions[label]?.poll() ?: return + try { + queued.action.invoke(computer) + queued.result = Result.success(Unit) + } catch (error: Throwable) { + logger.error("Computer $label failed", error) + queued.result = Result.failure(error) + } + } + + internal class QueuedAction(val action: CctComputerAction) { + @Volatile var result: Result? = null + } + + class CctComputerMonitor internal constructor(private val label: String, private val action: QueuedAction) { + val isFinished get() = action.result != null + + fun check() { + action.result?.getOrThrow() ?: throw GameTestAssertException("Computer '$label' did not finish") + } + } +} + +private object ComputerThreadReflection { + private val lookup = MethodHandles.lookup() + private val computerContext = lookup.unreflectGetter( + ServerContext::class.java.getDeclaredField("context").also { it.isAccessible = true }, + ) + private val computerQueueSize = lookup.unreflect( + ComputerThread::class.java.getDeclaredMethod("computerQueueSize").also { it.isAccessible = true }, + ) + private val workerCount = lookup.unreflect( + ComputerThread::class.java.getDeclaredMethod("workerCount").also { it.isAccessible = true }, + ) + private val idleWorkers = lookup.unreflectGetter( + ComputerThread::class.java.getDeclaredField("idleWorkers").also { it.isAccessible = true }, + ) + + fun isFullyIdle(context: ServerContext): Boolean { + val computer = computerContext.invokeExact(context) as ComputerContext + val thread = computer.computerScheduler() as ComputerThread + val queued = computerQueueSize.invokeExact(thread) as Int + val workers = workerCount.invokeExact(thread) as Int + val idle = idleWorkers.invokeExact(thread) as AtomicInteger + return queued == 0 && idle.get() >= workers + } +} + +/** Keeps the machine hook explicit while test actions are dispatched by [CctTestApi]. */ +private object CctLuaMachineFactory : ILuaMachine.Factory { + override fun create(environment: MachineEnvironment, bios: InputStream): ILuaMachine = CobaltLuaMachine(environment, bios) +} + +object CctLuaTests { + fun file(label: String): Path? = System.getProperty("testiarium.cct-fixtures") + ?.let(Path::of) + ?.resolve("tests/$label.lua") + + fun require(label: String) { + val file = file(label) ?: return + require(Files.isRegularFile(file)) { "No Lua test file for computer '$label': $file" } + } +} + +object CctComputerState { + const val DONE = "DONE" + + private val states = ConcurrentHashMap() + + fun get(label: String): State? = states[label] + + fun reset() = states.clear() + + class State internal constructor() { + private val markers = ConcurrentHashMap.newKeySet() + + @Volatile private var error: String? = null + + fun isDone(marker: String) = marker in markers + + fun check(marker: String) { + check(isDone(marker)) { "Computer has not reached $marker" } + error?.let { throw GameTestAssertException(it) } + } + + internal fun ok(marker: String) = markers.add(marker) + internal fun fail(message: String) { + markers.add(DONE) + error = message + } + } + + internal fun start(label: String): State = State().also { states[label] = it } +} + +private class CctTestApi(private val computer: IComputerSystem) : ILuaAPI { + private lateinit var label: String + private lateinit var state: CctComputerState.State + + override fun startup() { + label = computer.label ?: "#${computer.id}" + state = CctComputerState.start(label) + CctComputers.run(label, computer) + } + + override fun shutdown() = Unit + + override fun getNames() = arrayOf("test") + + @LuaFunction + fun fail(message: String): Nothing { + state.fail(message) + throw LuaException(message) + } + + @LuaFunction + fun ok(marker: Optional) { + val actual = marker.orElse(CctComputerState.DONE) + if (!state.ok(actual)) throw LuaException("Cannot call test.ok twice for $actual") + } + + @LuaFunction + fun log(message: String) = LoggerFactory.getLogger(CctTestApi::class.java).info("[Computer '{}'] {}", label, message) +} diff --git a/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctFixtureCommands.kt b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctFixtureCommands.kt new file mode 100644 index 0000000..509044d --- /dev/null +++ b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctFixtureCommands.kt @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked 1.113.0 projects/common/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java. + +package site.siredvin.testiarium.cct + +import com.mojang.brigadier.CommandDispatcher +import net.minecraft.commands.CommandBuildContext +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands.argument +import net.minecraft.commands.Commands.literal +import net.minecraft.commands.arguments.item.ItemArgument +import net.minecraft.commands.arguments.item.ItemInput +import net.minecraft.core.component.DataComponents +import net.minecraft.network.chat.Component +import net.minecraft.server.MinecraftServer +import net.minecraft.world.level.storage.LevelResource +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isDirectory +import kotlin.io.path.relativeTo + +object CctFixtureCommands { + fun register(dispatcher: CommandDispatcher, context: CommandBuildContext) { + dispatcher.register( + literal("testiarium").then( + literal("cct") + .then( + literal("import").executes { + importFiles(it.source.server) + 1 + }, + ) + .then( + literal("export").executes { + exportFiles(it.source.server) + 1 + }, + ) + .then( + literal("give-computer").then( + argument("item", ItemArgument.item(context)).executes { + val stack = it.getArgument("item", ItemInput::class.java).createItemStack(1, false) + stack.set(DataComponents.CUSTOM_NAME, Component.literal("testiarium.cct")) + it.source.playerOrException.addItem(stack) + 1 + }, + ), + ), + ), + ) + } + + fun importFiles(server: MinecraftServer) = source()?.let { sync(it, destination(server)) } + + private fun exportFiles(server: MinecraftServer) = source()?.let { sync(destination(server), it) } + + private fun source(): Path? = System.getProperty("testiarium.cct-fixtures")?.let(Path::of) + + private fun destination(server: MinecraftServer): Path = server.getWorldPath(LevelResource.ROOT).resolve("computercraft/computer/1") + + private fun sync(from: Path, to: Path) { + require(from.isDirectory()) { "CCT fixture directory does not exist: $from" } + Files.walk(from).use { paths -> + paths.forEach { path -> + val target = to.resolve(path.relativeTo(from).toString()) + if (Files.isDirectory(path)) { + Files.createDirectories(target) + } else { + Files.createDirectories(target.parent) + Files.copy(path, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } + } + } +} diff --git a/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctGameTestExtensions.kt b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctGameTestExtensions.kt new file mode 100644 index 0000000..827fba9 --- /dev/null +++ b/projects/testiarium-core/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/CctGameTestExtensions.kt @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked 1.113.0 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt. + +package site.siredvin.testiarium.cct + +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.GameTestInfo +import net.minecraft.gametest.framework.GameTestSequence +import site.siredvin.testiarium.api.thenExecuteFailFast + +fun GameTestSequence.thenStartComputer(name: String? = null, action: CctComputerAction): GameTestSequence { + val test = testInfo() + return thenExecuteFailFast { CctComputers.enqueue(test.level.server, test.label(name), action) } +} + +fun GameTestSequence.thenOnComputer(name: String? = null, action: CctComputerAction): GameTestSequence { + val test = testInfo() + val label = test.label(name) + lateinit var monitor: CctComputers.CctComputerMonitor + thenExecuteFailFast { monitor = CctComputers.enqueue(test.level.server, label, action) } + thenWaitUntil { if (!monitor.isFinished) throw GameTestAssertException("Computer '$label' has not finished yet") } + return thenExecuteFailFast { monitor.check() } +} + +fun GameTestSequence.thenComputerOk(name: String? = null, marker: String = CctComputerState.DONE): GameTestSequence { + val label = testInfo().label(name) + thenWaitUntil { + if (CctComputerState.get(label)?.isDone(marker) != true) { + throw GameTestAssertException("Computer '$label' has not reached $marker yet") + } + } + return thenExecuteFailFast { CctComputerState.get(label)?.check(marker) ?: error("Computer '$label' disappeared") } +} + +fun GameTestHelper.thenLua(label: String = testInfo().structureName): GameTestSequence { + CctLuaTests.require(label) + return startSequence().thenComputerOkLabel(label) +} + +private fun GameTestSequence.thenComputerOkLabel(label: String, marker: String = CctComputerState.DONE): GameTestSequence { + thenWaitUntil { + if (CctComputerState.get(label)?.isDone(marker) != true) { + throw GameTestAssertException("Computer '$label' has not reached $marker yet") + } + } + return thenExecuteFailFast { CctComputerState.get(label)?.check(marker) ?: error("Computer '$label' disappeared") } +} + +private fun GameTestInfo.label(name: String?) = testName + (name?.let { ".$it" } ?: "") + +private fun GameTestSequence.testInfo(): GameTestInfo = javaClass.getDeclaredField("parent").let { + it.isAccessible = true + it.get(this) as GameTestInfo +} + +private fun GameTestHelper.testInfo(): GameTestInfo = javaClass.getDeclaredField("testInfo").let { + it.isAccessible = true + it.get(this) as GameTestInfo +} diff --git a/projects/testiarium-core/src/cctTestMod/resources/computer/startup.lua b/projects/testiarium-core/src/cctTestMod/resources/computer/startup.lua new file mode 100644 index 0000000..ecbc360 --- /dev/null +++ b/projects/testiarium-core/src/cctTestMod/resources/computer/startup.lua @@ -0,0 +1,14 @@ +-- SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +-- SPDX-FileCopyrightText: 2026 SirEdvin +-- SPDX-License-Identifier: MPL-2.0 +-- Adapted from CC:Tweaked 1.113.0 projects/common/src/testMod/resources/data/cctest/computer/startup.lua. + +local label = os.getComputerLabel() +if label == nil then return test.fail("Label a computer to use it.") end + +local fn, err = loadfile("tests/" .. label .. ".lua", nil, _ENV) +if not fn then return test.fail(err) end + +local ok, result = pcall(fn) +if not ok then return test.fail(result) end +test.ok() diff --git a/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/Testiarium.kt b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/Testiarium.kt new file mode 100644 index 0000000..67d14a0 --- /dev/null +++ b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/Testiarium.kt @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt + +package site.siredvin.testiarium + +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestRegistry +import net.minecraft.gametest.framework.TestFunction +import net.minecraft.server.MinecraftServer +import net.minecraft.world.level.GameRules +import site.siredvin.testiarium.api.ClientGameTest +import site.siredvin.testiarium.api.TestGroup +import site.siredvin.testiarium.api.TestTags +import site.siredvin.testiarium.report.JunitTestReporter +import site.siredvin.testiarium.report.MultiTestReporter +import java.io.File +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.function.Consumer + +object Testiarium { + const val MOD_ID = "testiarium" + + private val testClasses = linkedSetOf>() + + @JvmStatic + fun register(vararg classes: Class<*>) { + testClasses += classes + } + + @JvmStatic + fun init() { + System.getProperty("testiarium.structures")?.let { + net.minecraft.gametest.framework.StructureUtils.testStructuresDir = it + } + System.getProperty("testiarium.gametest-report")?.let { output -> + net.minecraft.gametest.framework.GlobalTestReporter.replaceWith( + MultiTestReporter( + JunitTestReporter(File(output)), + net.minecraft.gametest.framework.LogTestReporter(), + ), + ) + } + } + + @JvmStatic + fun onServerStarted(server: MinecraftServer) { + server.gameRules.getRule(GameRules.RULE_DAYLIGHT).set(false, server) + server.overworld().dayTime = 6000 + val level = server.overworld() + net.minecraft.gametest.framework.StructureUtils.findStructureBlocks(getTestOrigin(server), 200, level).forEach { pos -> + val structure = level.getBlockEntity(pos) as? net.minecraft.world.level.block.entity.StructureBlockEntity ?: return@forEach + net.minecraft.gametest.framework.StructureUtils.clearSpaceForStructure( + net.minecraft.gametest.framework.StructureUtils.getStructureBoundingBox(structure), + level, + ) + } + } + + @JvmStatic + fun getTestOrigin(server: MinecraftServer): BlockPos { + val spawn = server.overworld().sharedSpawnPos + return BlockPos(spawn.x, -59, spawn.z) + } + + @JvmStatic + fun onServerStopped() { + net.minecraft.gametest.framework.GlobalTestReporter.finish() + } + + @JvmStatic + fun loadTests(fallbackRegister: Consumer) { + testClasses.forEach { testClass -> + testClass.declaredMethods.forEach { method -> registerTest(testClass, method, fallbackRegister) } + } + } + + private fun registerTest(testClass: Class<*>, method: Method, fallbackRegister: Consumer) { + val group = method.getAnnotation(TestGroup::class.java)?.value + ?: testClass.getAnnotation(TestGroup::class.java)?.value + ?: if (method.isAnnotationPresent(ClientGameTest::class.java)) TestTags.CLIENT else TestTags.COMMON + if (!TestTags.isEnabled(group)) return + + val testName = "${testClass.simpleName}.${method.name}" + method.getAnnotation(GameTest::class.java)?.let { test -> + GameTestRegistry.getAllTestFunctions().add( + TestFunction( + test.batch, + testName, + test.template.ifEmpty { testName }, + net.minecraft.gametest.framework.StructureUtils.getRotationForRotationSteps(test.rotationSteps), + test.timeoutTicks, + test.setupTicks, + test.required, + test.manualOnly, + test.attempts, + test.requiredSuccesses, + test.skyAccess, + ) { helper -> invoke(method, helper) }, + ) + GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) + return + } + method.getAnnotation(ClientGameTest::class.java)?.let { test -> + GameTestRegistry.getAllTestFunctions().add( + TestFunction(testName, testName, test.template.ifEmpty { testName }, test.timeoutTicks, 0, true) { helper -> + invoke(method, helper) + }, + ) + GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) + return + } + fallbackRegister.accept(method) + } + + private fun invoke(method: Method, argument: Any) { + try { + val instance = if (Modifier.isStatic(method.modifiers)) null else method.declaringClass.getConstructor().newInstance() + method.invoke(instance, argument) + } catch (exception: InvocationTargetException) { + throw (exception.cause as? RuntimeException ?: RuntimeException(exception.cause)) + } catch (exception: ReflectiveOperationException) { + throw RuntimeException(exception) + } + } +} diff --git a/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/api/Assertions.kt b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/api/Assertions.kt new file mode 100644 index 0000000..86d35b9 --- /dev/null +++ b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/api/Assertions.kt @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt + +package site.siredvin.testiarium.api + +import net.minecraft.core.BlockPos +import net.minecraft.core.NonNullList +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.GameTestSequence +import net.minecraft.world.Container +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.EntityType +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.crafting.CraftingInput +import net.minecraft.world.item.crafting.RecipeType +import net.minecraft.world.level.block.entity.BlockEntity +import net.minecraft.world.level.block.entity.BlockEntityType +import net.minecraft.world.level.block.state.BlockState +import net.minecraft.world.level.block.state.properties.Property + +/** Run a sequence action without allowing later steps to overwrite its failure. */ +fun GameTestSequence.thenExecuteFailFast(action: () -> Unit): GameTestSequence = thenExecute(action).thenWaitUntil { + val parent = javaClass.getDeclaredField("parent").also { it.isAccessible = true }.get(this) as net.minecraft.gametest.framework.GameTestInfo + parent.error?.let { throw it } +} + +fun GameTestHelper.assertBlock(pos: BlockPos, predicate: (BlockState) -> Boolean, message: String = "") { + val state = getBlockState(pos) + if (!predicate(state)) failAt(pos, message.ifEmpty { "Unexpected block state $state" }) +} + +fun > GameTestHelper.assertBlockProperty( + pos: BlockPos, + property: Property, + expected: T, + message: String = "", +) { + val state = getBlockState(pos) + when { + !state.hasProperty(property) -> failAt(pos, message.ifEmpty { "${state.block} has no ${property.name} property" }) + state.getValue(property) != expected -> failAt(pos, message.ifEmpty { "${property.name} is ${state.getValue(property)}, expected $expected" }) + } +} + +fun GameTestHelper.getContainer(pos: BlockPos): Container = when (val blockEntity: BlockEntity = getBlockEntity(pos)) { + is Container -> blockEntity + else -> failAt(pos, "Expected a container, found ${blockEntity.type}") +} + +fun GameTestHelper.assertContainer(pos: BlockPos, expected: List) { + val container = getContainer(pos) + val actual = List(container.containerSize, container::getItem) + val mismatch = actual.indices.firstOrNull { index -> + !ItemStack.matches(actual[index], expected.getOrElse(index) { ItemStack.EMPTY }) + } + if (mismatch != null) failAt(pos, "Container differs at slot $mismatch. Expected $expected, got $actual") +} + +fun GameTestHelper.getBlockEntity(pos: BlockPos, type: BlockEntityType): T { + val blockEntity: BlockEntity = getBlockEntity(pos) + if (blockEntity.type != type) { + failAt(pos, "Expected $type, got ${blockEntity.type}") + } + @Suppress("UNCHECKED_CAST") + return blockEntity as T +} + +fun GameTestHelper.getEntity(type: EntityType): T { + val entities = getEntities(type, BlockPos.ZERO, 64.0) + if (entities.size != 1) throw GameTestAssertException("Expected one $type, found ${entities.size}") + return entities.single() +} + +fun GameTestHelper.assertItemCount(item: Item, expected: Int) { + val actual = getEntities(EntityType.ITEM, BlockPos.ZERO, 64.0).sumOf { stack -> if (stack.item.`is`(item)) stack.item.count else 0 } + if (actual != expected) throw GameTestAssertException("Expected $expected ${item.description.string}, found $actual") +} + +fun GameTestHelper.assertCraftable(items: List, expected: ItemStack) { + val stacks = NonNullList.withSize(9, ItemStack.EMPTY) + items.forEachIndexed { index, item -> stacks[index] = item } + val input = CraftingInput.of(3, 3, stacks) + val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level) + .orElseThrow { GameTestAssertException("No recipe matches $items") } + val actual = recipe.value.assemble(input, level.registryAccess()) + if (!ItemStack.matches(actual, expected)) { + throw GameTestAssertException("Expected $items to craft $expected, got $actual") + } +} + +private fun GameTestHelper.failAt(pos: BlockPos, message: String): Nothing = throw GameTestAssertException("$message at $pos") diff --git a/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/api/GameTests.kt b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/api/GameTests.kt new file mode 100644 index 0000000..2f08a72 --- /dev/null +++ b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/api/GameTests.kt @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt and TestTags.kt + +package site.siredvin.testiarium.api + +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.GameTestSequence + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ClientGameTest( + val template: String = "", + val timeoutTicks: Int = Timeouts.DEFAULT, +) + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class TestGroup(val value: String) + +object TestTags { + const val COMMON = "common" + const val CLIENT = "client" + + private val enabled = System.getProperty("testiarium.tags", COMMON).split(',').toSet() + + fun isEnabled(tag: String) = tag in enabled +} + +object Timeouts { + const val SECOND = 20 + const val DEFAULT = SECOND * 5 +} + +fun GameTestHelper.sequence(actions: GameTestSequence.() -> Unit) { + startSequence().apply(actions).thenSucceed() +} + +fun GameTestHelper.immediate(action: () -> Unit) { + action() + succeed() +} diff --git a/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/report/TestReporters.kt b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/report/TestReporters.kt new file mode 100644 index 0000000..c63308f --- /dev/null +++ b/projects/testiarium-core/src/main/kotlin/site/siredvin/testiarium/report/TestReporters.kt @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt + +package site.siredvin.testiarium.report + +import net.minecraft.gametest.framework.GameTestInfo +import net.minecraft.gametest.framework.TestReporter +import org.w3c.dom.Element +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.nio.file.Files +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +class MultiTestReporter(private vararg val reporters: TestReporter) : TestReporter { + override fun onTestFailed(test: GameTestInfo) = reporters.forEach { it.onTestFailed(test) } + + override fun onTestSuccess(test: GameTestInfo) = reporters.forEach { it.onTestSuccess(test) } + + override fun finish() = reporters.forEach(TestReporter::finish) +} + +class JunitTestReporter(private val destination: File) : TestReporter { + private val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() + private val suite = document.createElement("testsuite").also { document.appendChild(it) } + private val results = mutableListOf() + private var successful = 0 + private var failed = 0 + private var skipped = 0 + private var duration = 0L + private var finished = false + + override fun onTestFailed(test: GameTestInfo) { + duration += test.runTime + val error = requireNotNull(test.error) + val result = document.createElement(if (test.isRequired) "failure" else "skipped") + result.setAttribute("message", error.message.orEmpty()) + if (test.isRequired) { + failed++ + result.setAttribute("type", error.javaClass.name) + result.textContent = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString() + } else { + skipped++ + } + results += TestResult(test, if (test.isRequired) Status.FAILED else Status.SKIPPED, error) + testCase(test).appendChild(result) + } + + override fun onTestSuccess(test: GameTestInfo) { + successful++ + duration += test.runTime + results += TestResult(test, Status.PASSED) + testCase(test) + } + + override fun finish() { + if (finished) return + finished = true + val total = successful + failed + skipped + suite.setAttribute("tests", total.toString()) + suite.setAttribute("failures", failed.toString()) + suite.setAttribute("skipped", skipped.toString()) + suite.setAttribute("time", (duration.toDouble() / 1000).toString()) + destination.parentFile?.toPath()?.let(Files::createDirectories) + TransformerFactory.newInstance().newTransformer().transform(DOMSource(document), StreamResult(destination)) + val htmlDestination = destination.toPath().resolveSibling("${destination.nameWithoutExtension}.html").toFile() + htmlDestination.writeText(html(total)) + println( + "JUnit report: $total tests, $successful passed, $failed failed, $skipped skipped\n" + + " XML: ${destination.absolutePath}\n" + + " HTML: ${htmlDestination.absolutePath}", + ) + } + + private fun testCase(test: GameTestInfo): Element = document.createElement("testcase").also { + it.setAttribute("name", test.testName) + it.setAttribute("classname", test.structureName) + it.setAttribute("time", (test.runTime.toDouble() / 1000).toString()) + suite.appendChild(it) + } + + private fun html(total: Int) = """ + + + + + + GameTest report + + + +

TESTIARIUM / GAMETEST

Test report

${duration / 1000.0} seconds total

+
+
$totaltests
+
$successfulpassed
+
$failedfailed
+
$skippedskipped
+
+ + ${results.joinToString("\n") { result -> result.html() }} +
TestDurationStatus
+ + + """.trimIndent() + + private fun TestResult.html() = """ + + ${test.testName.escapeHtml()}${error?.let { "
${it.message.orEmpty().escapeHtml()}
${it.stackTraceToString().escapeHtml()}
" }.orEmpty()} + ${test.runTime / 1000.0}s + ${status.name} + + """.trimIndent() + + private fun String.escapeHtml() = replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + + private data class TestResult(val test: GameTestInfo, val status: Status, val error: Throwable? = null) + + private enum class Status { PASSED, FAILED, SKIPPED } +} diff --git a/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/client/MinecraftExtensions.java b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/client/MinecraftExtensions.java new file mode 100644 index 0000000..dbe2883 --- /dev/null +++ b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/client/MinecraftExtensions.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked commit 6f16cd6b0e4b74afff5462d463bedba65764970e, +// projects/common/src/testMod/java/dan200/computercraft/gametest/core/MinecraftExtensions.java + +package site.siredvin.testiarium.fixture.client; + +public interface MinecraftExtensions { + boolean testiarium$isRenderingStable(); +} diff --git a/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/GameTestSequenceMixin.java b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/GameTestSequenceMixin.java new file mode 100644 index 0000000..488c5f0 --- /dev/null +++ b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/GameTestSequenceMixin.java @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 + +package site.siredvin.testiarium.fixture.mixin; + +import net.minecraft.gametest.framework.GameTestAssertException; +import net.minecraft.gametest.framework.GameTestInfo; +import net.minecraft.gametest.framework.GameTestSequence; +import org.slf4j.LoggerFactory; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.Shadow; + +@Mixin(GameTestSequence.class) +class GameTestSequenceMixin { + @Shadow @Final GameTestInfo parent; + + @Overwrite + public void tickAndContinue(long ticks) { + try { + tick(ticks); + } catch (GameTestAssertException ignored) { + } catch (AssertionError error) { + parent.fail(error); + } catch (Exception | LinkageError | VirtualMachineError error) { + LoggerFactory.getLogger(GameTestSequenceMixin.class).error("{} threw unexpected exception", parent.getTestName(), error); + parent.fail(error); + } + } + + @Shadow + private void tick(long tick) { + } +} diff --git a/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/StructureTemplateAccessor.java b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/StructureTemplateAccessor.java new file mode 100644 index 0000000..f2594a0 --- /dev/null +++ b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/StructureTemplateAccessor.java @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/StructureTemplateAccessor.java + +package site.siredvin.testiarium.fixture.mixin; + +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import java.util.List; + +@Mixin(StructureTemplate.class) +public interface StructureTemplateAccessor { + @Accessor + List getPalettes(); +} diff --git a/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/StructureTemplateManagerMixin.java b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/StructureTemplateManagerMixin.java new file mode 100644 index 0000000..cd81914 --- /dev/null +++ b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/StructureTemplateManagerMixin.java @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 + +package site.siredvin.testiarium.fixture.mixin; + +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +@Mixin(StructureTemplateManager.class) +class StructureTemplateManagerMixin { + @Redirect(method = "", at = @At(value = "FIELD", target = "Lnet/minecraft/SharedConstants;IS_RUNNING_IN_IDE:Z")) + private boolean getRunningInIde() { + return true; + } + + @Inject(method = "loadFromTestStructures", at = @At("RETURN")) + private void loadFromTestStructures(ResourceLocation id, CallbackInfoReturnable> callback) { + callback.getReturnValue().ifPresent(StructureTemplateManagerMixin::addMissingAir); + } + + private static void addMissingAir(StructureTemplate template) { + var size = template.getSize(); + var palette = ((StructureTemplateAccessor) template).getPalettes().get(0); + Set positions = new HashSet<>(); + for (var x = 0; x < size.getX(); x++) { + for (var y = 0; y < size.getY(); y++) { + for (var z = 0; z < size.getZ(); z++) positions.add(new BlockPos(x, y, z)); + } + } + for (var block : palette.blocks()) positions.remove(block.pos()); + for (var pos : positions) { + palette.blocks().add(new StructureTemplate.StructureBlockInfo(pos, Blocks.AIR.defaultBlockState(), null)); + } + } +} diff --git a/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/client/MinecraftMixin.java b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/client/MinecraftMixin.java new file mode 100644 index 0000000..aa7eb7b --- /dev/null +++ b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/client/MinecraftMixin.java @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked commit 6f16cd6b0e4b74afff5462d463bedba65764970e, +// projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/client/MinecraftMixin.java + +package site.siredvin.testiarium.fixture.mixin.client; + +import java.util.concurrent.atomic.AtomicBoolean; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.LevelRenderer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import site.siredvin.testiarium.fixture.client.MinecraftExtensions; + +@Mixin(Minecraft.class) +class MinecraftMixin implements MinecraftExtensions { + @Final @Shadow public LevelRenderer levelRenderer; + @Shadow public ClientLevel level; + @Shadow public LocalPlayer player; + @Unique private final AtomicBoolean testiarium$isStable = new AtomicBoolean(false); + + @Inject(method = "runTick", at = @At("TAIL")) + private void testiarium$updateStable(boolean render, CallbackInfo callback) { + testiarium$isStable.set( + level != null && player != null + && levelRenderer.isSectionCompiled(player.blockPosition()) + && levelRenderer.countRenderedSections() > 10 + && levelRenderer.hasRenderedAllSections() + ); + } + + @Override + public boolean testiarium$isRenderingStable() { + return testiarium$isStable.get(); + } +} diff --git a/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/client/WorldOpenFlowsMixin.java b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/client/WorldOpenFlowsMixin.java new file mode 100644 index 0000000..f523b05 --- /dev/null +++ b/projects/testiarium-core/src/testMod/java/site/siredvin/testiarium/fixture/mixin/client/WorldOpenFlowsMixin.java @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked commit 6f16cd6b0e4b74afff5462d463bedba65764970e, +// projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/client/WorldOpenFlowsMixin.java + +package site.siredvin.testiarium.fixture.mixin.client; + +import net.minecraft.client.gui.screens.worldselection.WorldOpenFlows; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; + +@Mixin(WorldOpenFlows.class) +class WorldOpenFlowsMixin { + /** Never show a backup prompt during an unattended test run. */ + @Overwrite + private void askForBackup(LevelStorageSource.LevelStorageAccess access, boolean customised, Runnable load, Runnable cancel) { + load.run(); + } +} diff --git a/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/FixtureCommands.kt b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/FixtureCommands.kt new file mode 100644 index 0000000..ca0985c --- /dev/null +++ b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/FixtureCommands.kt @@ -0,0 +1,91 @@ +package site.siredvin.testiarium.fixture + +import com.mojang.brigadier.CommandDispatcher +import net.minecraft.commands.CommandBuildContext +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands.literal +import net.minecraft.gametest.framework.GameTestRegistry +import net.minecraft.gametest.framework.StructureUtils +import net.minecraft.nbt.CompoundTag +import net.minecraft.network.chat.Component +import net.minecraft.world.entity.EntityType +import net.minecraft.world.level.block.entity.StructureBlockEntity +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isDirectory +import kotlin.io.path.relativeTo + +/** Testmod-only fixture commands. */ +object FixtureCommands { + fun register(dispatcher: CommandDispatcher, buildContext: CommandBuildContext) { + dispatcher.register( + literal("testiarium") + .then( + literal("import").executes { + sync(source(), destination()) + 1 + }, + ) + .then( + literal("export").executes { + sync(destination(), source()) + 1 + }, + ) + .then( + literal("regen-structures").executes { context -> + sync(source(), destination()) + GameTestRegistry.getAllTestFunctions().forEach { test -> + dispatcher.execute("test export ${test.structureName}", context.source) + } + sync(destination(), source()) + 1 + }, + ) + .then( + literal("marker").executes { context -> + val player = context.source.playerOrException + val position = StructureUtils.findNearestStructureBlock(player.blockPosition(), 15, player.serverLevel()) + .orElse(null) ?: return@executes 0 + val structure = player.level().getBlockEntity(position) as? StructureBlockEntity ?: return@executes 0 + val test = GameTestRegistry.getTestFunction(structure.metaData) + player.serverLevel().getEntities(EntityType.ARMOR_STAND) { it.isAlive && it.name.string == test.testName } + .forEach { it.kill() } + EntityType.ARMOR_STAND.create(player.level())?.apply { + readAdditionalSaveData( + CompoundTag().apply { + putBoolean("Marker", true) + putBoolean("Invisible", true) + }, + ) + moveTo(player.x, player.y, player.z, player.yRot, player.xRot) + customName = Component.literal(test.testName) + player.level().addFreshEntity(this) + } + 1 + }, + ), + ) + } + + private fun source(): Path = Path.of(requireProperty("testiarium.fixture-source")) + + private fun destination(): Path = Path.of(requireProperty("testiarium.structures")) + + private fun requireProperty(name: String): String = System.getProperty(name) ?: error("Set -D$name to use Testiarium fixture commands") + + private fun sync(from: Path, to: Path) { + require(from.isDirectory()) { "Fixture directory does not exist: $from" } + Files.walk(from).use { paths -> + paths.forEach { path -> + val target = to.resolve(path.relativeTo(from).toString()) + if (Files.isDirectory(path)) { + Files.createDirectories(target) + } else { + Files.createDirectories(target.parent) + Files.copy(path, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } + } + } +} diff --git a/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/StandaloneGameTests.kt b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/StandaloneGameTests.kt new file mode 100644 index 0000000..19bbebf --- /dev/null +++ b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/StandaloneGameTests.kt @@ -0,0 +1,37 @@ +package site.siredvin.testiarium.fixture + +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import site.siredvin.testiarium.api.ClientGameTest +import site.siredvin.testiarium.api.TestGroup +import site.siredvin.testiarium.api.TestTags +import site.siredvin.testiarium.api.sequence +import site.siredvin.testiarium.api.thenExecuteFailFast +import site.siredvin.testiarium.fixture.client.thenOnClient +import site.siredvin.testiarium.fixture.client.thenScreenshot + +class StandaloneGameTests { + @GameTest(template = "empty") + fun passes(helper: GameTestHelper) { + helper.succeed() + } + + @GameTest(template = "empty", required = false) + fun requiredFailure(helper: GameTestHelper): Unit = throw GameTestAssertException("required failure fixture") + + @GameTest(template = "empty", required = false) + fun optionalFailure(helper: GameTestHelper): Unit = throw GameTestAssertException("optional failure fixture") + + @GameTest(template = "empty") + fun failFastSequence(helper: GameTestHelper) = helper.sequence { + thenExecuteFailFast { check(true) } + } + + @ClientGameTest(template = "empty") + @TestGroup(TestTags.CLIENT) + fun clientFixture(helper: GameTestHelper) = helper.sequence { + thenOnClient { check(minecraft.player != null) } + thenScreenshot("fixture") + } +} diff --git a/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/ClientTestExtensions.kt b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/ClientTestExtensions.kt new file mode 100644 index 0000000..8dbd04b --- /dev/null +++ b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/ClientTestExtensions.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked commit 6f16cd6b0e4b74afff5462d463bedba65764970e, +// projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt + +package site.siredvin.testiarium.fixture.client + +import net.minecraft.client.Minecraft +import net.minecraft.client.Screenshot +import net.minecraft.client.gui.screens.inventory.MenuAccess +import net.minecraft.core.BlockPos +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.GameTestSequence +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.EntityType +import net.minecraft.world.inventory.AbstractContainerMenu +import net.minecraft.world.inventory.MenuType +import site.siredvin.testiarium.api.getEntity +import java.io.File +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean + +fun Minecraft.isRenderingStable(): Boolean = (this as MinecraftExtensions).`testiarium$isRenderingStable`() + +fun GameTestSequence.thenOnClient(task: ClientTestHelper.() -> Unit): GameTestSequence { + var future: CompletableFuture? = null + thenExecute { future = Minecraft.getInstance().submit { task(ClientTestHelper()) } } + thenWaitUntil { if (!future!!.isDone) throw GameTestAssertException("Client task has not completed") } + thenExecute { + try { + future!!.get() + } catch (error: ExecutionException) { + throw error.cause ?: error + } + } + return this +} + +fun GameTestSequence.thenRenderIdle(ticks: Int = 20): GameTestSequence { + var idleTicks = 0 + thenWaitUntil { + if (Minecraft.getInstance().isRenderingStable()) { + if (++idleTicks <= ticks) throw GameTestAssertException("Rendering has only been idle for $idleTicks ticks") + } else { + idleTicks = 0 + throw GameTestAssertException("Waiting for the client to finish rendering") + } + } + return this +} + +fun GameTestSequence.thenScreenshot(name: String? = null, showGui: Boolean = false): GameTestSequence { + val screenshotName = "${name ?: "screenshot"}.png" + val captured = AtomicBoolean() + thenRenderIdle() + thenOnClient { minecraft.options.hideGui = !showGui } + thenIdle(2) + thenOnClient { screenshot(screenshotName) { captured.set(true) } } + thenWaitUntil { if (!captured.get()) throw GameTestAssertException("Screenshot was not captured") } + thenOnClient { minecraft.options.hideGui = false } + return this +} + +fun ServerPlayer.setupForTest() { + if (containerMenu != inventoryMenu) closeContainer() +} + +fun GameTestHelper.positionAtArmorStand() { + val stand = getEntity(EntityType.ARMOR_STAND) + val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") + player.setupForTest() + player.connection.teleport(stand.x, stand.y, stand.z, stand.yRot, stand.xRot) +} + +fun GameTestHelper.positionAt(pos: BlockPos, yRot: Float = 0.0f, xRot: Float = 0.0f) { + val absolutePos = absolutePos(pos) + val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") + player.setupForTest() + player.connection.teleport(absolutePos.x + 0.5, absolutePos.y + 0.5, absolutePos.z + 0.5, yRot, xRot) +} + +class ClientTestHelper { + val minecraft: Minecraft = Minecraft.getInstance() + + fun screenshot(name: String, callback: () -> Unit = {}) { + val directory = File(System.getProperty("testiarium.screenshots", minecraft.gameDirectory.absolutePath)) + Screenshot.grab(directory, name, minecraft.mainRenderTarget) { callback() } + } + + fun getOpenMenu(type: MenuType): T { + val screen = minecraft.screen + val name = BuiltInRegistries.MENU.getKey(type) + @Suppress("UNCHECKED_CAST") + return when { + screen == null -> throw GameTestAssertException("Expected a $name menu, but no screen is open") + screen !is MenuAccess<*> -> throw GameTestAssertException("Expected a $name menu, but $screen is open") + screen.menu.type != type -> throw GameTestAssertException("Expected a $name menu, but ${BuiltInRegistries.MENU.getKey(screen.menu.type)} is open") + else -> screen.menu as T + } + } +} diff --git a/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/ClientTestHooks.kt b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/ClientTestHooks.kt new file mode 100644 index 0000000..583f2b6 --- /dev/null +++ b/projects/testiarium-core/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/ClientTestHooks.kt @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked commit 6f16cd6b0e4b74afff5462d463bedba65764970e, +// projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt + +package site.siredvin.testiarium.fixture.client + +import net.minecraft.client.CloudStatus +import net.minecraft.client.Minecraft +import net.minecraft.client.ParticleStatus +import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.TitleScreen +import net.minecraft.client.tutorial.TutorialSteps +import net.minecraft.core.registries.Registries +import net.minecraft.gametest.framework.GameTestBatchFactory +import net.minecraft.gametest.framework.GameTestInfo +import net.minecraft.gametest.framework.GameTestListener +import net.minecraft.gametest.framework.GameTestRegistry +import net.minecraft.gametest.framework.GameTestRunner +import net.minecraft.gametest.framework.GlobalTestReporter +import net.minecraft.gametest.framework.MultipleTestTracker +import net.minecraft.gametest.framework.StructureGridSpawner +import net.minecraft.server.MinecraftServer +import net.minecraft.server.level.ServerPlayer +import net.minecraft.sounds.SoundSource +import net.minecraft.world.Difficulty +import net.minecraft.world.level.GameRules +import net.minecraft.world.level.GameType +import net.minecraft.world.level.LevelSettings +import net.minecraft.world.level.WorldDataConfiguration +import net.minecraft.world.level.levelgen.WorldOptions +import net.minecraft.world.level.levelgen.presets.WorldPresets +import org.slf4j.LoggerFactory +import site.siredvin.testiarium.Testiarium +import site.siredvin.testiarium.api.Timeouts +import kotlin.system.exitProcess + +object ClientTestHooks { + private const val LEVEL_NAME = "testiarium-client" + private const val STARTUP_DELAY = 5 * Timeouts.SECOND + private const val WORLD_SEED = 0L + private val log = LoggerFactory.getLogger(ClientTestHooks::class.java) + private val enabled = System.getProperty("testiarium.client") != null + private var loadedWorld = false + private var tracker: MultipleTestTracker? = null + private var startupDelay = STARTUP_DELAY + private var finished = false + + @JvmStatic + fun onOpenScreen(screen: Screen): Boolean { + if (!enabled || loadedWorld || screen !is TitleScreen && screen !is AccessibilityOnboardingScreen) return false + loadedWorld = true + openWorld(screen) + return true + } + + @JvmStatic + fun onServerTick(server: MinecraftServer) { + if (!enabled || finished) return + val tests = tracker ?: startTests(server) ?: return + if (server.overworld().gameTime % 20L == 0L) log.info(tests.progressBar) + if (!tests.isDone) return + + finished = true + GlobalTestReporter.finish() + val exitCode = when { + tests.totalCount == 0 -> 1 + tests.hasFailedRequired() -> 2 + else -> 0 + } + Minecraft.getInstance().execute { + val minecraft = Minecraft.getInstance() + minecraft.level?.disconnect() + minecraft.disconnect() + minecraft.stop() + exitProcess(exitCode) + } + } + + private fun openWorld(screen: Screen) { + val minecraft = Minecraft.getInstance() + minecraft.options.autoJump().set(false) + minecraft.options.cloudStatus().set(CloudStatus.OFF) + minecraft.options.particles().set(ParticleStatus.MINIMAL) + minecraft.options.tutorialStep = TutorialSteps.NONE + minecraft.options.pauseOnLostFocus = false + minecraft.options.renderDistance().set(6) + minecraft.options.gamma().set(1.0) + minecraft.options.getSoundSourceOptionInstance(SoundSource.MUSIC).set(0.0) + minecraft.options.getSoundSourceOptionInstance(SoundSource.AMBIENT).set(0.0) + if (minecraft.levelSource.levelExists(LEVEL_NAME)) { + minecraft.createWorldOpenFlows().openWorld(LEVEL_NAME) { minecraft.setScreen(screen) } + return + } + val rules = GameRules() + rules.getRule(GameRules.RULE_DOMOBSPAWNING).set(false, null) + rules.getRule(GameRules.RULE_DAYLIGHT).set(false, null) + rules.getRule(GameRules.RULE_WEATHER_CYCLE).set(false, null) + minecraft.createWorldOpenFlows().createFreshLevel( + LEVEL_NAME, + LevelSettings("Testiarium Client Tests", GameType.CREATIVE, false, Difficulty.EASY, true, rules, WorldDataConfiguration.DEFAULT), + WorldOptions(WORLD_SEED, false, false), + { it.registryOrThrow(Registries.WORLD_PRESET).getOrThrow(WorldPresets.FLAT).createWorldDimensions() }, + screen, + ) + } + + private fun startTests(server: MinecraftServer): MultipleTestTracker? { + if (server.overworld().players().isEmpty()) return null + server.overworld().players().forEach { + it.abilities.flying = true + it.onUpdateAbilities() + it.connection.teleport(0.0, -30.0, 0.0, 0.0f, 90.0f) + it.inventory.clearContent() + } + if (!Minecraft.getInstance().isRenderingStable()) return null + if (startupDelay-- >= 0) return null + val tests = GameTestRunner.Builder.fromBatches( + GameTestBatchFactory.fromTestFunction(GameTestRegistry.getAllTestFunctions(), server.overworld()), + server.overworld(), + ) + .newStructureSpawner(StructureGridSpawner(Testiarium.getTestOrigin(server), 8, false)) + .build() + return MultipleTestTracker(tests.testInfos).also { + it.addListener(object : GameTestListener { + private fun cleanup() = server.playerList.players.forEach(ServerPlayer::setupForTest) + + override fun testPassed(test: GameTestInfo, runner: GameTestRunner) = cleanup() + + override fun testFailed(test: GameTestInfo, runner: GameTestRunner) = cleanup() + + override fun testStructureLoaded(test: GameTestInfo) = Unit + + override fun testAddedForRerun(test: GameTestInfo, newTest: GameTestInfo, runner: GameTestRunner) = Unit + }) + tests.start() + tracker = it + } + } +} diff --git a/projects/testiarium-core/src/testMod/resources/gameteststructures/empty.snbt b/projects/testiarium-core/src/testMod/resources/gameteststructures/empty.snbt new file mode 100644 index 0000000..ce42f22 --- /dev/null +++ b/projects/testiarium-core/src/testMod/resources/gameteststructures/empty.snbt @@ -0,0 +1,11 @@ +{ + DataVersion: 3120, + size: [1, 1, 1], + data: [ + {pos: [0, 0, 0], state: "minecraft:polished_andesite"} + ], + entities: [], + palette: [ + "minecraft:polished_andesite" + ] +} diff --git a/projects/testiarium-core/src/testMod/resources/pack.mcmeta b/projects/testiarium-core/src/testMod/resources/pack.mcmeta new file mode 100644 index 0000000..f81dc32 --- /dev/null +++ b/projects/testiarium-core/src/testMod/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 15, + "description": "Testiarium test fixtures" + } +} diff --git a/projects/testiarium-fabric/build.gradle.kts b/projects/testiarium-fabric/build.gradle.kts new file mode 100644 index 0000000..4b47d6f --- /dev/null +++ b/projects/testiarium-fabric/build.gradle.kts @@ -0,0 +1,115 @@ +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("site.siredvin.fabric") + id("site.siredvin.publishing") +} + +val testiariumVersion: String by extra + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + +baseShaking { + projectPart.set("fabric") + projectName.set("testiarium") + integrationRepositories.set(true) + projectVersion.set(testiariumVersion) + shake() +} + +fabricShaking { + commonProjectName.set("testiarium-core") + projectName.set("testiarium") + shake() +} + +val testMod = sourceSets.create("testMod") { + resources.srcDir(project(":testiarium-core").file("src/testMod/resources")) + compileClasspath += sourceSets.main.get().compileClasspath + compileClasspath += sourceSets.main.get().output + compileClasspath += project(":testiarium-core").sourceSets["testMod"].output + runtimeClasspath += sourceSets.main.get().runtimeClasspath + runtimeClasspath += sourceSets.main.get().output + runtimeClasspath += project(":testiarium-core").sourceSets["testMod"].output +} + +net.fabricmc.loom.configuration.RemapConfigurations.setupForSourceSet(project, testMod) + +val cctTestMod = sourceSets.create("cctTestMod") { + compileClasspath += testMod.compileClasspath + compileClasspath += testMod.output + runtimeClasspath += testMod.runtimeClasspath + runtimeClasspath += testMod.output + compileClasspath += project(":testiarium-core").sourceSets["cctTestMod"].output + runtimeClasspath += project(":testiarium-core").sourceSets["cctTestMod"].output +} + +net.fabricmc.loom.configuration.RemapConfigurations.setupForSourceSet(project, cctTestMod) + +dependencies { + implementation(libs.bundles.kotlin) + modImplementation(libs.bundles.fabric.core) + modImplementation(libs.bundles.fabric) + add("modTestModImplementation", libs.bundles.kotlin) + add("modTestModImplementation", libs.bundles.fabric.core) + add("modTestModImplementation", libs.bundles.fabric) + add("modCctTestModImplementation", libs.bundles.kotlin) + add("modCctTestModImplementation", libs.bundles.fabric.core) + add("modCctTestModImplementation", libs.bundles.fabric) + add("modCctTestModImplementation", libs.bundles.ccfabric) +} + +loom { + mods { + register("testiarium-testmod") { + sourceSet(sourceSets["testMod"]) + sourceSet(project(":testiarium-core").sourceSets["testMod"]) + } + register("testiarium-cct-testmod") { + sourceSet(cctTestMod) + sourceSet(project(":testiarium-core").sourceSets["cctTestMod"]) + } + } + runs { + named("server") { + source(sourceSets["testMod"]) + property("fabric-api.gametest", "true") + property("fabric.debug.loadLate", "testiarium_testmod") + property("testiarium.tags", "common") + property("testiarium.structures", layout.buildDirectory.dir("resources/testMod/gameteststructures").get().asFile.absolutePath) + property("testiarium.fixture-source", project(":testiarium-core").file("src/testMod/resources/gameteststructures").absolutePath) + property("testiarium.cct-fixtures", project(":testiarium-core").file("src/cctTestMod/resources/computer").absolutePath) + property("testiarium.gametest-report", layout.buildDirectory.file("test-results/gametest.xml").get().asFile.absolutePath) + vmArg("-ea") + runDir("run/gametest") + } + create("cctGameTest") { + server() + source(cctTestMod) + property("fabric-api.gametest", "true") + property("fabric.debug.loadLate", "testiarium_testmod") + property("testiarium.tags", "common") + property("testiarium.structures", layout.buildDirectory.dir("resources/testMod/gameteststructures").get().asFile.absolutePath) + property("testiarium.fixture-source", project(":testiarium-core").file("src/testMod/resources/gameteststructures").absolutePath) + property("testiarium.cct-fixtures", project(":testiarium-core").file("src/cctTestMod/resources/computer").absolutePath) + property("testiarium.gametest-report", layout.buildDirectory.file("test-results/cct-gametest.xml").get().asFile.absolutePath) + vmArg("-ea") + runDir("run/cct-gametest") + } + create("clientGameTest") { + client() + source(testMod) + property("testiarium.client", "true") + property("testiarium.tags", "client") + property("testiarium.structures", layout.buildDirectory.dir("resources/testMod/gameteststructures").get().asFile.absolutePath) + property("testiarium.gametest-report", layout.buildDirectory.file("test-results/client-gametest.xml").get().asFile.absolutePath) + property("testiarium.screenshots", layout.buildDirectory.get().asFile.absolutePath) + vmArg("-ea") + runDir("run/client-gametest") + } + } +} + +publishingShaking { + projectVersion.set(testiariumVersion) + shake() +} diff --git a/projects/testiarium-fabric/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/FabricCctTestMod.kt b/projects/testiarium-fabric/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/FabricCctTestMod.kt new file mode 100644 index 0000000..73dbaa0 --- /dev/null +++ b/projects/testiarium-fabric/src/cctTestMod/kotlin/site/siredvin/testiarium/cct/FabricCctTestMod.kt @@ -0,0 +1,20 @@ +package site.siredvin.testiarium.cct + +import net.fabricmc.api.ModInitializer +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents +import net.fabricmc.loader.api.FabricLoader +import site.siredvin.testiarium.FabricTestiarium + +object FabricCctTestMod : ModInitializer { + override fun onInitialize() { + if (!FabricLoader.getInstance().isModLoaded("computercraft")) return + CctComputers.initialize() + CommandRegistrationCallback.EVENT.register { dispatcher, context, _ -> CctFixtureCommands.register(dispatcher, context) } + ServerLifecycleEvents.SERVER_STARTING.register { + CctComputers.reset() + CctFixtureCommands.importFiles(it) + } + FabricTestiarium.registerTests() + } +} diff --git a/projects/testiarium-fabric/src/cctTestMod/resources/fabric.mod.json b/projects/testiarium-fabric/src/cctTestMod/resources/fabric.mod.json new file mode 100644 index 0000000..6d29ac0 --- /dev/null +++ b/projects/testiarium-fabric/src/cctTestMod/resources/fabric.mod.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "testiarium_cct_testmod", + "version": "1.0", + "name": "Testiarium CC:Tweaked Testmod", + "environment": "*", + "entrypoints": { + "main": [ + { + "adapter": "kotlin", + "value": "site.siredvin.testiarium.cct.FabricCctTestMod" + } + ] + }, + "depends": { + "fabricloader": "*", + "testiarium": "*", + "computercraft": "*" + } +} diff --git a/projects/testiarium-fabric/src/main/java/site/siredvin/testiarium/TestiariumFabricMarker.java b/projects/testiarium-fabric/src/main/java/site/siredvin/testiarium/TestiariumFabricMarker.java new file mode 100644 index 0000000..2a83bc4 --- /dev/null +++ b/projects/testiarium-fabric/src/main/java/site/siredvin/testiarium/TestiariumFabricMarker.java @@ -0,0 +1,6 @@ +package site.siredvin.testiarium; + +final class TestiariumFabricMarker { + private TestiariumFabricMarker() { + } +} diff --git a/projects/testiarium-fabric/src/main/kotlin/site/siredvin/testiarium/FabricTestiarium.kt b/projects/testiarium-fabric/src/main/kotlin/site/siredvin/testiarium/FabricTestiarium.kt new file mode 100644 index 0000000..4ff66c3 --- /dev/null +++ b/projects/testiarium-fabric/src/main/kotlin/site/siredvin/testiarium/FabricTestiarium.kt @@ -0,0 +1,22 @@ +package site.siredvin.testiarium + +import net.fabricmc.api.ModInitializer +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents +import net.minecraft.gametest.framework.GameTestRegistry + +object FabricTestiarium : ModInitializer { + private var testsRegistered = false + + override fun onInitialize() { + Testiarium.init() + ServerLifecycleEvents.SERVER_STARTED.register(Testiarium::onServerStarted) + ServerLifecycleEvents.SERVER_STOPPING.register { Testiarium.onServerStopped() } + } + + @JvmStatic + fun registerTests() { + if (testsRegistered) return + testsRegistered = true + Testiarium.loadTests(GameTestRegistry::register) + } +} diff --git a/projects/testiarium-fabric/src/main/resources/fabric.mod.json b/projects/testiarium-fabric/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..1104cae --- /dev/null +++ b/projects/testiarium-fabric/src/main/resources/fabric.mod.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "testiarium", + "version": "${version}", + "name": "Testiarium", + "description": "Cross-loader GameTest framework.", + "authors": ["SirEdvin"], + "contact": { + "homepage": "https://github.com/SirEdvin/Minecraft-Modding-Libs", + "sources": "https://github.com/SirEdvin/Minecraft-Modding-Libs" + }, + "license": "MPL-2.0", + "environment": "*", + "entrypoints": { + "main": [ + { + "adapter": "kotlin", + "value": "site.siredvin.testiarium.FabricTestiarium" + } + ] + }, + "depends": { + "fabricloader": "*", + "fabric-language-kotlin": "*", + "minecraft": "~1.21.1", + "java": ">=21" + } +} diff --git a/projects/testiarium-fabric/src/testMod/kotlin/site/siredvin/testiarium/fixture/FabricTestiariumTestMod.kt b/projects/testiarium-fabric/src/testMod/kotlin/site/siredvin/testiarium/fixture/FabricTestiariumTestMod.kt new file mode 100644 index 0000000..37a6508 --- /dev/null +++ b/projects/testiarium-fabric/src/testMod/kotlin/site/siredvin/testiarium/fixture/FabricTestiariumTestMod.kt @@ -0,0 +1,14 @@ +package site.siredvin.testiarium.fixture + +import net.fabricmc.api.ModInitializer +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback +import site.siredvin.testiarium.FabricTestiarium +import site.siredvin.testiarium.Testiarium + +object FabricTestiariumTestMod : ModInitializer { + override fun onInitialize() { + Testiarium.register(StandaloneGameTests::class.java) + FabricTestiarium.registerTests() + CommandRegistrationCallback.EVENT.register { dispatcher, context, _ -> FixtureCommands.register(dispatcher, context) } + } +} diff --git a/projects/testiarium-fabric/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/FabricClientTestHooks.kt b/projects/testiarium-fabric/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/FabricClientTestHooks.kt new file mode 100644 index 0000000..17de367 --- /dev/null +++ b/projects/testiarium-fabric/src/testMod/kotlin/site/siredvin/testiarium/fixture/client/FabricClientTestHooks.kt @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked commit 6f16cd6b0e4b74afff5462d463bedba65764970e, +// projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java + +package site.siredvin.testiarium.fixture.client + +import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents + +object FabricClientTestHooks : ClientModInitializer { + override fun onInitializeClient() { + ServerTickEvents.START_SERVER_TICK.register(ClientTestHooks::onServerTick) + ScreenEvents.AFTER_INIT.register { _, screen, _, _ -> ClientTestHooks.onOpenScreen(screen) } + } +} diff --git a/projects/testiarium-fabric/src/testMod/resources/fabric.mod.json b/projects/testiarium-fabric/src/testMod/resources/fabric.mod.json new file mode 100644 index 0000000..bf8f84d --- /dev/null +++ b/projects/testiarium-fabric/src/testMod/resources/fabric.mod.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "testiarium_testmod", + "version": "1.0", + "name": "Testiarium Testmod", + "environment": "*", + "entrypoints": { + "main": [ + { + "adapter": "kotlin", + "value": "site.siredvin.testiarium.fixture.FabricTestiariumTestMod" + } + ], + "client": [ + { + "adapter": "kotlin", + "value": "site.siredvin.testiarium.fixture.client.FabricClientTestHooks" + } + ] + }, + "mixins": [ + "testiarium-testmod.mixins.json" + ], + "depends": { + "fabricloader": "*", + "testiarium": "*" + } +} diff --git a/projects/testiarium-fabric/src/testMod/resources/testiarium-testmod.mixins.json b/projects/testiarium-fabric/src/testMod/resources/testiarium-testmod.mixins.json new file mode 100644 index 0000000..6362355 --- /dev/null +++ b/projects/testiarium-fabric/src/testMod/resources/testiarium-testmod.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "package": "site.siredvin.testiarium.fixture.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "GameTestSequenceMixin", + "StructureTemplateAccessor", + "StructureTemplateManagerMixin" + ], + "client": [ + "client.MinecraftMixin", + "client.WorldOpenFlowsMixin" + ] +} diff --git a/projects/testiarium-forge/build.gradle.kts b/projects/testiarium-forge/build.gradle.kts new file mode 100644 index 0000000..d85d564 --- /dev/null +++ b/projects/testiarium-forge/build.gradle.kts @@ -0,0 +1,107 @@ +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("site.siredvin.publishing") + id("site.siredvin.mod-publishing") + id("site.siredvin.neoforge") +} + +val testiariumVersion: String by extra +val cctTests = providers.gradleProperty("testiarium.cct").isPresent + +repositories { + maven { + name = "Kotlin for Forge" + url = uri("https://thedarkcolour.github.io/KotlinForForge/") + content { + includeGroup("thedarkcolour") + } + } +} + +baseShaking { + projectPart.set("forge") + projectName.set("testiarium") + projectVersion.set(testiariumVersion) + shake() +} + +neoforgeShaking { + commonProjectName.set("testiarium-core") + projectName.set("testiarium") + useAT.set(true) + useRawJar.set(true) + shake() +} + +sourceSets.create("testMod") { + resources.srcDir(project(":testiarium-core").file("src/testMod/resources")) + compileClasspath += sourceSets.main.get().compileClasspath + compileClasspath += sourceSets.main.get().output + compileClasspath += project(":testiarium-core").sourceSets["testMod"].output + runtimeClasspath += sourceSets.main.get().runtimeClasspath + runtimeClasspath += sourceSets.main.get().output + runtimeClasspath += project(":testiarium-core").sourceSets["testMod"].output +} + +val cctTestMod = sourceSets.create("cctTestMod") { + compileClasspath += sourceSets["testMod"].compileClasspath + compileClasspath += sourceSets["testMod"].output + runtimeClasspath += sourceSets["testMod"].runtimeClasspath + runtimeClasspath += sourceSets["testMod"].output + compileClasspath += project(":testiarium-core").sourceSets["cctTestMod"].output + runtimeClasspath += project(":testiarium-core").sourceSets["cctTestMod"].output +} + +dependencies { + implementation(libs.bundles.kotlin) + implementation(libs.bundles.forge.raw) + libs.bundles.forge.base.get().map { implementation(it) } + libs.bundles.forge.cc.get().map { add(cctTestMod.compileOnlyConfigurationName, it) } + libs.bundles.forge.cc.get().map { add(cctTestMod.runtimeOnlyConfigurationName, it) } +} + +neoForge { + val testiarium = mods.named("testiarium") + val testMod by mods.registering { + sourceSet(sourceSets["testMod"]) + sourceSet(project(":testiarium-core").sourceSets["testMod"]) + } + val cctMod by mods.registering { + sourceSet(cctTestMod) + sourceSet(project(":testiarium-core").sourceSets["cctTestMod"]) + } + runs { + register("gameTestServer") { + type = "gameTestServer" + gameDirectory = file("run/gametest") + systemProperty("testiarium.tags", "common") + systemProperty("testiarium.structures", layout.buildDirectory.dir("resources/testMod/gameteststructures").get().asFile.absolutePath) + systemProperty("testiarium.fixture-source", project.project(":testiarium-core").file("src/testMod/resources/gameteststructures").absolutePath) + systemProperty("testiarium.cct-fixtures", project.project(":testiarium-core").file("src/cctTestMod/resources/computer").absolutePath) + systemProperty("testiarium.gametest-report", layout.buildDirectory.file(if (cctTests) "test-results/cct-gametest.xml" else "test-results/gametest.xml").get().asFile.absolutePath) + jvmArgument("-ea") + programArgument("--nogui") + loadedMods.add(testiarium.get()) + loadedMods.add(testMod.get()) + if (cctTests) loadedMods.add(cctMod.get()) + } + register("clientGameTest") { + client() + gameDirectory = file("run/client-gametest") + systemProperty("testiarium.client", "true") + systemProperty("testiarium.tags", "client") + systemProperty("testiarium.structures", layout.buildDirectory.dir("resources/testMod/gameteststructures").get().asFile.absolutePath) + systemProperty("testiarium.gametest-report", layout.buildDirectory.file("test-results/client-gametest.xml").get().asFile.absolutePath) + systemProperty("testiarium.screenshots", layout.buildDirectory.get().asFile.absolutePath) + jvmArgument("-ea") + programArgument("--mixin.config=testiarium-testmod.mixins.json") + loadedMods.add(testiarium.get()) + loadedMods.add(testMod.get()) + } + } +} + +publishingShaking { + projectVersion.set(testiariumVersion) + shake() +} diff --git a/projects/testiarium-forge/src/cctTestMod/java/site/siredvin/testiarium/cct/ForgeCctTestMod.java b/projects/testiarium-forge/src/cctTestMod/java/site/siredvin/testiarium/cct/ForgeCctTestMod.java new file mode 100644 index 0000000..7031c18 --- /dev/null +++ b/projects/testiarium-forge/src/cctTestMod/java/site/siredvin/testiarium/cct/ForgeCctTestMod.java @@ -0,0 +1,20 @@ +package site.siredvin.testiarium.cct; + +import net.neoforged.fml.ModList; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.server.ServerStartingEvent; + +@Mod("testiarium_cct_testmod") +public final class ForgeCctTestMod { + public ForgeCctTestMod() { + if (!ModList.get().isLoaded("computercraft")) return; + CctComputers.INSTANCE.initialize(); + NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> CctFixtureCommands.INSTANCE.register(event.getDispatcher(), event.getBuildContext())); + NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) -> { + CctComputers.INSTANCE.reset(); + CctFixtureCommands.INSTANCE.importFiles(event.getServer()); + }); + } +} diff --git a/projects/testiarium-forge/src/cctTestMod/resources/META-INF/neoforge.mods.toml b/projects/testiarium-forge/src/cctTestMod/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..8e9e5f6 --- /dev/null +++ b/projects/testiarium-forge/src/cctTestMod/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,9 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="MPL-2.0" + +[[mods]] +modId="testiarium_cct_testmod" +version="1.0" +displayName="Testiarium CC:Tweaked Testmod" +description="Optional CC:Tweaked GameTest fixtures." diff --git a/projects/testiarium-forge/src/main/java/site/siredvin/testiarium/TestiariumForgeMarker.java b/projects/testiarium-forge/src/main/java/site/siredvin/testiarium/TestiariumForgeMarker.java new file mode 100644 index 0000000..02316bf --- /dev/null +++ b/projects/testiarium-forge/src/main/java/site/siredvin/testiarium/TestiariumForgeMarker.java @@ -0,0 +1,6 @@ +package site.siredvin.testiarium; + +final class TestiariumForgeMarker { + private TestiariumForgeMarker() { + } +} diff --git a/projects/testiarium-forge/src/main/kotlin/site/siredvin/testiarium/ForgeTestiarium.kt b/projects/testiarium-forge/src/main/kotlin/site/siredvin/testiarium/ForgeTestiarium.kt new file mode 100644 index 0000000..1b76d20 --- /dev/null +++ b/projects/testiarium-forge/src/main/kotlin/site/siredvin/testiarium/ForgeTestiarium.kt @@ -0,0 +1,18 @@ +package site.siredvin.testiarium + +import net.neoforged.bus.api.IEventBus +import net.neoforged.fml.common.Mod +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.RegisterGameTestsEvent +import net.neoforged.neoforge.event.server.ServerStartedEvent +import net.neoforged.neoforge.event.server.ServerStoppingEvent + +@Mod(Testiarium.MOD_ID) +class ForgeTestiarium(modBus: IEventBus) { + init { + Testiarium.init() + NeoForge.EVENT_BUS.addListener { event: ServerStartedEvent -> Testiarium.onServerStarted(event.server) } + NeoForge.EVENT_BUS.addListener { _: ServerStoppingEvent -> Testiarium.onServerStopped() } + modBus.addListener { Testiarium.loadTests(it::register) } + } +} diff --git a/projects/testiarium-forge/src/main/resources/META-INF/accesstransformer.cfg b/projects/testiarium-forge/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..b0e7a75 --- /dev/null +++ b/projects/testiarium-forge/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1 @@ +# Testiarium does not currently need access transformations. diff --git a/projects/testiarium-forge/src/main/resources/META-INF/neoforge.mods.toml b/projects/testiarium-forge/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..30669f2 --- /dev/null +++ b/projects/testiarium-forge/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,18 @@ +modLoader="kotlinforforge" +loaderVersion="[5,)" +license="MPL-2.0" + +[[mods]] +modId="testiarium" +version="${file.jarVersion}" +displayName="Testiarium" +displayURL="https://github.com/SirEdvin/Minecraft-Modding-Libs" +authors="SirEdvin" +description='''Cross-loader GameTest framework.''' + +[[dependencies.testiarium]] +modId="neoforge" +type="required" +versionRange="[${neoforgeVersion},)" +ordering="NONE" +side="BOTH" diff --git a/projects/testiarium-forge/src/main/resources/pack.mcmeta b/projects/testiarium-forge/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..518e29a --- /dev/null +++ b/projects/testiarium-forge/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 15, + "description": "Testiarium" + } +} diff --git a/projects/testiarium-forge/src/testMod/java/site/siredvin/testiarium/fixture/ForgeTestiariumTestMod.java b/projects/testiarium-forge/src/testMod/java/site/siredvin/testiarium/fixture/ForgeTestiariumTestMod.java new file mode 100644 index 0000000..7275e97 --- /dev/null +++ b/projects/testiarium-forge/src/testMod/java/site/siredvin/testiarium/fixture/ForgeTestiariumTestMod.java @@ -0,0 +1,18 @@ +package site.siredvin.testiarium.fixture; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.loading.FMLEnvironment; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import site.siredvin.testiarium.Testiarium; +import site.siredvin.testiarium.fixture.client.ForgeClientTestHooks; + +@Mod("testiarium_testmod") +public final class ForgeTestiariumTestMod { + public ForgeTestiariumTestMod() { + Testiarium.register(StandaloneGameTests.class); + if (FMLEnvironment.dist == Dist.CLIENT) ForgeClientTestHooks.register(); + NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> FixtureCommands.INSTANCE.register(event.getDispatcher(), event.getBuildContext())); + } +} diff --git a/projects/testiarium-forge/src/testMod/java/site/siredvin/testiarium/fixture/client/ForgeClientTestHooks.java b/projects/testiarium-forge/src/testMod/java/site/siredvin/testiarium/fixture/client/ForgeClientTestHooks.java new file mode 100644 index 0000000..f4dae89 --- /dev/null +++ b/projects/testiarium-forge/src/testMod/java/site/siredvin/testiarium/fixture/client/ForgeClientTestHooks.java @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// SPDX-FileCopyrightText: 2026 SirEdvin +// SPDX-License-Identifier: MPL-2.0 +// Adapted from CC:Tweaked commit 6f16cd6b0e4b74afff5462d463bedba65764970e, +// projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java + +package site.siredvin.testiarium.fixture.client; + +import net.neoforged.neoforge.client.event.ScreenEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +public final class ForgeClientTestHooks { + private ForgeClientTestHooks() { + } + + public static void register() { + NeoForge.EVENT_BUS.addListener(ForgeClientTestHooks::onServerTick); + NeoForge.EVENT_BUS.addListener(ForgeClientTestHooks::onOpenScreen); + } + + public static void onServerTick(ServerTickEvent.Pre event) { + ClientTestHooks.onServerTick(event.getServer()); + } + + public static void onOpenScreen(ScreenEvent.Opening event) { + if (ClientTestHooks.onOpenScreen(event.getScreen())) event.setCanceled(true); + } +} diff --git a/projects/testiarium-forge/src/testMod/resources/META-INF/neoforge.mods.toml b/projects/testiarium-forge/src/testMod/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..b2b9d86 --- /dev/null +++ b/projects/testiarium-forge/src/testMod/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,12 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="MPL-2.0" + +[[mods]] +modId="testiarium_testmod" +version="1.0" +displayName="Testiarium Testmod" +description="Standalone Testiarium GameTest fixtures." + +[[mixins]] +config="testiarium-testmod.mixins.json" diff --git a/projects/testiarium-forge/src/testMod/resources/testiarium-testmod.mixins.json b/projects/testiarium-forge/src/testMod/resources/testiarium-testmod.mixins.json new file mode 100644 index 0000000..6362355 --- /dev/null +++ b/projects/testiarium-forge/src/testMod/resources/testiarium-testmod.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "package": "site.siredvin.testiarium.fixture.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "GameTestSequenceMixin", + "StructureTemplateAccessor", + "StructureTemplateManagerMixin" + ], + "client": [ + "client.MinecraftMixin", + "client.WorldOpenFlowsMixin" + ] +} diff --git a/projects/tweakium-core/build.gradle.kts b/projects/tweakium-core/build.gradle.kts index 1d43391..9b1bec2 100644 --- a/projects/tweakium-core/build.gradle.kts +++ b/projects/tweakium-core/build.gradle.kts @@ -25,6 +25,15 @@ vanillaShaking { } sourceSets { + create("testMod") { + compileClasspath += main.get().compileClasspath + compileClasspath += main.get().output + runtimeClasspath += main.get().output + compileClasspath += project(":testiarium-core").sourceSets["testMod"].output + runtimeClasspath += project(":testiarium-core").sourceSets["testMod"].output + compileClasspath += project(":testiarium-core").sourceSets["cctTestMod"].output + runtimeClasspath += project(":testiarium-core").sourceSets["cctTestMod"].output + } create("testFixtures") { compileClasspath += main.get().compileClasspath compileClasspath += main.get().output @@ -47,6 +56,8 @@ dependencies { add(sourceSets["testFixtures"].compileOnlyConfigurationName, kotlin("test")) add(sourceSets["testFixtures"].compileOnlyConfigurationName, libs.bundles.test) + add(sourceSets["testMod"].compileOnlyConfigurationName, project(":testiarium-core")) + add(sourceSets["testMod"].compileOnlyConfigurationName, libs.bundles.cccommon) testImplementation(kotlin("test")) testImplementation(libs.bundles.test) diff --git a/projects/tweakium-core/src/main/kotlin/site/siredvin/tweakium/modules/minecraft/computercraft/CreativeFillerPeripheral.kt b/projects/tweakium-core/src/main/kotlin/site/siredvin/tweakium/modules/minecraft/computercraft/CreativeFillerPeripheral.kt index 5625dad..9f4db10 100644 --- a/projects/tweakium-core/src/main/kotlin/site/siredvin/tweakium/modules/minecraft/computercraft/CreativeFillerPeripheral.kt +++ b/projects/tweakium-core/src/main/kotlin/site/siredvin/tweakium/modules/minecraft/computercraft/CreativeFillerPeripheral.kt @@ -5,6 +5,8 @@ import dan200.computercraft.api.lua.LuaException import dan200.computercraft.api.lua.LuaFunction import dan200.computercraft.api.peripheral.IComputerAccess import net.minecraft.resources.ResourceLocation +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items import site.siredvin.broccolium.modules.platform.PlatformRegistries import site.siredvin.broccolium.modules.storage.energy.AgnosticEnergySinkLookup import site.siredvin.broccolium.modules.storage.energy.AgnosticEnergyStack @@ -46,9 +48,10 @@ class CreativeFillerPeripheral(owner: IPeripheralOwner) : OwnedPeripheral = diff --git a/projects/tweakium-forge/src/testMod/java/site/siredvin/tweakium/testmod/ForgeTweakiumTestMod.java b/projects/tweakium-forge/src/testMod/java/site/siredvin/tweakium/testmod/ForgeTweakiumTestMod.java new file mode 100644 index 0000000..71ba5ec --- /dev/null +++ b/projects/tweakium-forge/src/testMod/java/site/siredvin/tweakium/testmod/ForgeTweakiumTestMod.java @@ -0,0 +1,20 @@ +package site.siredvin.tweakium.testmod; + +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.server.ServerStartingEvent; +import site.siredvin.testiarium.Testiarium; +import site.siredvin.testiarium.cct.CctComputers; +import site.siredvin.testiarium.cct.CctFixtureCommands; + +@Mod("tweakium_testmod") +public final class ForgeTweakiumTestMod { + public ForgeTweakiumTestMod() { + CctComputers.INSTANCE.initialize(); + NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) -> { + CctComputers.INSTANCE.reset(); + CctFixtureCommands.INSTANCE.importFiles(event.getServer()); + }); + Testiarium.register(CreativeFillerGameTests.class); + } +} diff --git a/projects/tweakium-forge/src/testMod/resources/META-INF/neoforge.mods.toml b/projects/tweakium-forge/src/testMod/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..8b096fe --- /dev/null +++ b/projects/tweakium-forge/src/testMod/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,9 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="MPL-2.0" + +[[mods]] +modId="tweakium_testmod" +version="1.0" +displayName="Tweakium Testmod" +description="Tweakium peripheral GameTests." diff --git a/settings.gradle.kts b/settings.gradle.kts index f786bd5..5edadb7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,9 @@ rootProject.name = "Modding libs $minecraftVersion" include(":broccolium-core") include(":broccolium-forge") include(":broccolium-fabric") +include(":testiarium-core") +include(":testiarium-forge") +include(":testiarium-fabric") include(":tweakium-core") include(":tweakium-forge") include(":tweakium-fabric")