diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-pr.yml
similarity index 55%
rename from .github/workflows/ci.yml
rename to .github/workflows/ci-pr.yml
index b421524..91961ff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci-pr.yml
@@ -1,8 +1,6 @@
-name: CI
+name: CI (pull_request)
on:
- push:
- branches: [ main, develop, 'feature/**' ]
pull_request:
branches: [ main, develop ]
@@ -36,33 +34,52 @@ jobs:
- name: Build and test with Maven
run: mvn -B clean verify -Pqa -Ddependency-check.skip=true
- - name: Upload test results
+ # Upload XMLs ONLY once (Java 21) so the report doesn't double-count
+ - name: Upload unit test XMLs (Java 21 only)
uses: actions/upload-artifact@v4
- if: always()
+ if: always() && matrix.java == '21'
with:
- name: test-results-java-${{ matrix.java }}
+ name: unit-xml
path: |
- **/target/surefire-reports/
- **/target/failsafe-reports/
+ **/target/surefire-reports/TEST-*.xml
+ **/target/*-reports/TEST-*.xml
retention-days: 7
- - name: Upload coverage report
+ integration-tests:
+ name: Integration & E2E Tests (Java ${{ matrix.java }})
+ runs-on: ubuntu-latest
+ needs: build
+ strategy:
+ fail-fast: false
+ matrix:
+ java: [ '17', '21' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Set up JDK ${{ matrix.java }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ matrix.java }}
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Run integration tests
+ run: mvn -B clean verify -Pit -Ddependency-check.skip=true
+
+ # Upload XMLs ONLY once (Java 21) so the report doesn't double-count
+ - name: Upload IT test XMLs (Java 21 only)
uses: actions/upload-artifact@v4
- if: matrix.java == '21'
+ if: always() && matrix.java == '21'
with:
- name: coverage-report
+ name: it-xml
path: |
- **/target/site/jacoco/
- **/target/jacoco.exec
+ **/target/failsafe-reports/TEST-*.xml
retention-days: 7
- - name: Publish Test Report
- uses: mikepenz/action-junit-report@v4
- if: always()
- with:
- report_paths: '**/target/*-reports/TEST-*.xml'
- check_name: Test Report (Java ${{ matrix.java }})
-
quality:
name: Code Quality Analysis
runs-on: ubuntu-latest
@@ -81,32 +98,14 @@ jobs:
distribution: 'temurin'
cache: 'maven'
- - name: Install artifacts for analysis
+ - name: Install artifacts for analysis (skip tests)
run: mvn -B -Ddependency-check.skip=true clean install -Pqa -DskipTests
- name: Run SpotBugs analysis
run: mvn -B spotbugs:check -Pqa -Ddependency-check.skip=true
- continue-on-error: true
- name: Run Checkstyle analysis
run: mvn -B checkstyle:check -Pqa -Ddependency-check.skip=true
- continue-on-error: true
-
- - name: Upload SpotBugs report
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: spotbugs-report
- path: '**/target/spotbugsXml.xml'
- retention-days: 7
-
- - name: Upload Checkstyle report
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: checkstyle-report
- path: '**/target/checkstyle-result.xml'
- retention-days: 7
dependency-check:
name: OWASP Dependency Check
@@ -136,13 +135,41 @@ jobs:
- name: Run OWASP Dependency Check
run: mvn -B dependency-check:aggregate -Pqa
- continue-on-error: true
- - name: Upload Dependency Check report
- uses: actions/upload-artifact@v4
+ reports:
+ name: Test Reports
+ runs-on: ubuntu-latest
+ needs: [ build, integration-tests ]
+ if: always()
+
+ permissions:
+ contents: read
+ checks: write
+ pull-requests: write
+
+ steps:
+ - name: Download unit XMLs (Java 21 only)
+ uses: actions/download-artifact@v4
+ with:
+ name: unit-xml
+ path: reports/unit
+
+ - name: Download IT XMLs (Java 21 only)
+ uses: actions/download-artifact@v4
+ with:
+ name: it-xml
+ path: reports/it
+
+ - name: Publish Unit Test Report
+ uses: mikepenz/action-junit-report@v4
if: always()
with:
- name: dependency-check-report
- path: |
- target/dependency-check-report.html
- retention-days: 30
+ report_paths: 'reports/unit/**/TEST-*.xml'
+ check_name: Unit Test Report
+
+ - name: Publish IT Test Report
+ uses: mikepenz/action-junit-report@v4
+ if: always()
+ with:
+ report_paths: 'reports/it/**/TEST-*.xml'
+ check_name: IT Test Report
diff --git a/.github/workflows/ci-push.yml b/.github/workflows/ci-push.yml
new file mode 100644
index 0000000..ddd4e2a
--- /dev/null
+++ b/.github/workflows/ci-push.yml
@@ -0,0 +1,69 @@
+name: CI (push)
+
+on:
+ push:
+ branches: [ main, develop, 'feature/**' ]
+
+permissions:
+ contents: read
+ checks: write
+
+jobs:
+ build:
+ name: Build & Test (Java ${{ matrix.java }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ java: [ '17', '21' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Set up JDK ${{ matrix.java }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ matrix.java }}
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Build and test with Maven
+ run: mvn -B clean verify -Pqa -Ddependency-check.skip=true
+
+ - name: Upload unit test XMLs
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: unit-xml-java-${{ matrix.java }}
+ path: |
+ **/target/surefire-reports/TEST-*.xml
+ **/target/*-reports/TEST-*.xml
+ retention-days: 7
+
+ unit-report:
+ name: Unit Test Report
+ runs-on: ubuntu-latest
+ needs: build
+ if: always()
+
+ permissions:
+ contents: read
+ checks: write
+
+ steps:
+ - name: Download unit XMLs (all Java versions)
+ uses: actions/download-artifact@v4
+ with:
+ pattern: unit-xml-java-*
+ merge-multiple: true
+ path: reports/unit
+
+ - name: Publish Unit Test Report
+ uses: mikepenz/action-junit-report@v4
+ if: always()
+ with:
+ report_paths: 'reports/unit/**/TEST-*.xml'
+ check_name: Unit Test Report
diff --git a/aether-datafixers-functional-tests/pom.xml b/aether-datafixers-functional-tests/pom.xml
new file mode 100644
index 0000000..0accc6a
--- /dev/null
+++ b/aether-datafixers-functional-tests/pom.xml
@@ -0,0 +1,176 @@
+
+
+
+ 4.0.0
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers
+ 0.5.0-SNAPSHOT
+
+
+ aether-datafixers-functional-tests
+ jar
+
+ Aether Datafixers :: Functional Tests
+ Functional tests (E2E and integration) for Aether Datafixers
+
+
+
+ true
+ true
+
+ true
+
+ 3.4.1
+
+
+
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers-api
+ test
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers-core
+ test
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers-codec
+ test
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers-testkit
+ test
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers-cli
+ test
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers-schema-tools
+ test
+
+
+ de.splatgames.aether.datafixers
+ aether-datafixers-spring-boot-starter
+ test
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ ${spring.boot.version}
+ test
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+ com.google.code.gson
+ gson
+ test
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ test
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-yaml
+ test
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-toml
+ test
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-xml
+ test
+
+
+
+
+ org.yaml
+ snakeyaml
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IT.java
+ **/*E2E.java
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+ ${skipITs}
+
+ **/*IT.java
+ **/*E2E.java
+
+
+
+
+
+
diff --git a/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/error/ErrorRecoveryIT.java b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/error/ErrorRecoveryIT.java
new file mode 100644
index 0000000..781a7de
--- /dev/null
+++ b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/error/ErrorRecoveryIT.java
@@ -0,0 +1,373 @@
+/*
+ * Copyright (c) 2025 Splatgames.de Software and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package de.splatgames.aether.datafixers.functional.error;
+
+import com.google.gson.JsonElement;
+import de.splatgames.aether.datafixers.api.DataVersion;
+import de.splatgames.aether.datafixers.api.TypeReference;
+import de.splatgames.aether.datafixers.api.dynamic.Dynamic;
+import de.splatgames.aether.datafixers.api.fix.DataFix;
+import de.splatgames.aether.datafixers.api.fix.DataFixer;
+import de.splatgames.aether.datafixers.api.fix.DataFixerContext;
+import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder;
+import de.splatgames.aether.datafixers.testkit.TestData;
+import de.splatgames.aether.datafixers.testkit.factory.QuickFix;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import static de.splatgames.aether.datafixers.testkit.assertion.AetherAssertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Integration tests for error handling and recovery scenarios.
+ */
+@DisplayName("Error Recovery IT")
+@Tag("integration")
+class ErrorRecoveryIT {
+
+ private static final TypeReference TEST_TYPE = new TypeReference("test_entity");
+
+ @Nested
+ @DisplayName("Fix Throws Exception")
+ class FixThrowsException {
+
+ @Test
+ @DisplayName("wraps fix exception with metadata")
+ void wrapsFixExceptionWithMetadata() {
+ final DataFix failingFix = new DataFix<>() {
+ @Override
+ public @NotNull String name() {
+ return "failing_fix";
+ }
+
+ @Override
+ public @NotNull DataVersion fromVersion() {
+ return new DataVersion(1);
+ }
+
+ @Override
+ public @NotNull DataVersion toVersion() {
+ return new DataVersion(2);
+ }
+
+ @Override
+ public @NotNull Dynamic apply(
+ @NotNull final TypeReference type,
+ @NotNull final Dynamic input,
+ @NotNull final DataFixerContext context
+ ) {
+ throw new RuntimeException("Intentional failure for testing");
+ }
+ };
+
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .addFix(TEST_TYPE, failingFix)
+ .build();
+
+ final Dynamic input = TestData.gson().object()
+ .put("field", "value")
+ .build();
+
+ assertThatThrownBy(() -> fixer.update(
+ TEST_TYPE, input,
+ new DataVersion(1), new DataVersion(2)
+ ))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Intentional failure");
+ }
+ }
+
+ @Nested
+ @DisplayName("Missing Data Handling")
+ class MissingDataHandling {
+
+ @Test
+ @DisplayName("handles null field access gracefully")
+ void handlesNullFieldAccessGracefully() {
+ final Dynamic input = TestData.gson().object()
+ .put("existingField", "value")
+ .build();
+
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .addFix(TEST_TYPE, QuickFix.simple("safe_access", 1, 2, d -> {
+ // Try to get a non-existent field
+ final Dynamic missing = d.get("nonExistentField");
+ if (missing == null) {
+ // Handle gracefully by adding a default
+ return d.set("nonExistentField", d.createString("default"));
+ }
+ return d;
+ }))
+ .build();
+
+ final Dynamic result = fixer.update(
+ TEST_TYPE, input,
+ new DataVersion(1), new DataVersion(2)
+ );
+
+ assertThat(result).hasStringField("existingField", "value");
+ assertThat(result).hasStringField("nonExistentField", "default");
+ }
+
+ @Test
+ @DisplayName("handles empty object migration")
+ void handlesEmptyObjectMigration() {
+ final Dynamic input = TestData.gson().object().build();
+
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .addFix(TEST_TYPE, QuickFix.simple("empty_handler", 1, 2, d -> {
+ // Check if empty and add defaults
+ if (!d.has("id")) {
+ return d.set("id", d.createInt(0))
+ .set("name", d.createString("unknown"));
+ }
+ return d;
+ }))
+ .build();
+
+ final Dynamic result = fixer.update(
+ TEST_TYPE, input,
+ new DataVersion(1), new DataVersion(2)
+ );
+
+ assertThat(result).hasIntField("id", 0);
+ assertThat(result).hasStringField("name", "unknown");
+ }
+ }
+
+ @Nested
+ @DisplayName("Type Conversion Errors")
+ class TypeConversionErrors {
+
+ @Test
+ @DisplayName("handles invalid number format gracefully")
+ void handlesInvalidNumberFormatGracefully() {
+ final Dynamic input = TestData.gson().object()
+ .put("score", "not_a_number")
+ .build();
+
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .addFix(TEST_TYPE, QuickFix.simple("safe_parse", 1, 2, d -> {
+ final Dynamic> score = d.get("score");
+ if (score != null) {
+ // Try to parse as int, fallback to 0 if fails
+ final int value = score.asInt().result().orElse(0);
+ return d.set("score", d.createInt(value));
+ }
+ return d;
+ }))
+ .build();
+
+ final Dynamic result = fixer.update(
+ TEST_TYPE, input,
+ new DataVersion(1), new DataVersion(2)
+ );
+
+ // Should fallback to 0 since "not_a_number" can't be parsed
+ assertThat(result).hasIntField("score", 0);
+ }
+
+ @Test
+ @DisplayName("handles boolean parsing with fallback")
+ void handlesBooleanParsingWithFallback() {
+ final Dynamic input = TestData.gson().object()
+ .put("enabled", "yes") // String, not boolean
+ .build();
+
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .addFix(TEST_TYPE, QuickFix.simple("parse_bool", 1, 2, d -> {
+ final Dynamic> enabled = d.get("enabled");
+ if (enabled != null) {
+ // Try boolean, then string comparison
+ final boolean value = enabled.asBoolean().result()
+ .orElseGet(() -> {
+ final String str = enabled.asString().result().orElse("");
+ return "yes".equalsIgnoreCase(str) || "true".equalsIgnoreCase(str);
+ });
+ return d.set("enabled", d.createBoolean(value));
+ }
+ return d;
+ }))
+ .build();
+
+ final Dynamic result = fixer.update(
+ TEST_TYPE, input,
+ new DataVersion(1), new DataVersion(2)
+ );
+
+ assertThat(result).hasBooleanField("enabled", true);
+ }
+ }
+
+ @Nested
+ @DisplayName("Chain Recovery")
+ class ChainRecovery {
+
+ @Test
+ @DisplayName("continues chain after successful fix handles bad data")
+ void continuesChainAfterSuccessfulRecovery() {
+ final Dynamic input = TestData.gson().object()
+ .put("name", "test")
+ .put("badValue", "invalid")
+ .build();
+
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(3))
+ .addFix(TEST_TYPE, QuickFix.simple("fix_bad_value", 1, 2, d -> {
+ // Fix the bad value by replacing it
+ return d.set("badValue", d.createInt(0));
+ }))
+ .addFix(TEST_TYPE, QuickFix.simple("use_fixed_value", 2, 3, d -> {
+ // This fix depends on the fixed value
+ final int value = d.get("badValue").asInt().result().orElse(-1);
+ return d.set("processedValue", d.createInt(value * 2));
+ }))
+ .build();
+
+ final Dynamic result = fixer.update(
+ TEST_TYPE, input,
+ new DataVersion(1), new DataVersion(3)
+ );
+
+ assertThat(result).hasIntField("badValue", 0);
+ assertThat(result).hasIntField("processedValue", 0);
+ }
+ }
+
+ @Nested
+ @DisplayName("Validation Errors")
+ class ValidationErrors {
+
+ @Test
+ @DisplayName("fix can add validation warnings via context")
+ void fixCanAddValidationWarningsViaContext() {
+ final java.util.List warnings = new java.util.ArrayList<>();
+
+ final DataFixerContext trackingContext = new DataFixerContext() {
+ @Override
+ public void info(@NotNull final String message, final Object... args) {
+ // Ignore info
+ }
+
+ @Override
+ public void warn(@NotNull final String message, final Object... args) {
+ warnings.add(String.format(message, args));
+ }
+ };
+
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .withDefaultContext(trackingContext)
+ .addFix(TEST_TYPE, new DataFix() {
+ @Override
+ public @NotNull String name() {
+ return "warning_fix";
+ }
+
+ @Override
+ public @NotNull DataVersion fromVersion() {
+ return new DataVersion(1);
+ }
+
+ @Override
+ public @NotNull DataVersion toVersion() {
+ return new DataVersion(2);
+ }
+
+ @Override
+ public @NotNull Dynamic apply(
+ @NotNull final TypeReference type,
+ @NotNull final Dynamic input,
+ @NotNull final DataFixerContext context
+ ) {
+ if (!input.has("requiredField")) {
+ context.warn("Missing required field 'requiredField', using default");
+ }
+ return input.set("requiredField",
+ input.get("requiredField") != null
+ ? input.get("requiredField")
+ : input.createString("default"));
+ }
+ })
+ .build();
+
+ final Dynamic input = TestData.gson().object()
+ .put("otherField", "value")
+ .build();
+
+ fixer.update(TEST_TYPE, input, new DataVersion(1), new DataVersion(2));
+
+ org.assertj.core.api.Assertions.assertThat(warnings).hasSize(1);
+ org.assertj.core.api.Assertions.assertThat(warnings.get(0))
+ .contains("Missing required field");
+ }
+ }
+
+ @Nested
+ @DisplayName("Idempotent Fixes")
+ class IdempotentFixes {
+
+ @Test
+ @DisplayName("fix is idempotent when applied multiple times")
+ void fixIsIdempotentWhenAppliedMultipleTimes() {
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .addFix(TEST_TYPE, QuickFix.simple("idempotent_fix", 1, 2, d -> {
+ // Only add field if not present
+ if (d.has("addedField")) {
+ return d;
+ }
+ return d.set("addedField", d.createInt(1));
+ }))
+ .build();
+
+ final Dynamic input = TestData.gson().object()
+ .put("original", "value")
+ .build();
+
+ // First application
+ final Dynamic first = fixer.update(
+ TEST_TYPE, input,
+ new DataVersion(1), new DataVersion(2)
+ );
+
+ // "Second application" (simulated by creating new fixer that allows this)
+ final DataFixer secondFixer = new DataFixerBuilder(new DataVersion(3))
+ .addFix(TEST_TYPE, QuickFix.simple("idempotent_fix_2", 2, 3, d -> {
+ if (d.has("addedField")) {
+ return d;
+ }
+ return d.set("addedField", d.createInt(1));
+ }))
+ .build();
+
+ final Dynamic second = secondFixer.update(
+ TEST_TYPE, first,
+ new DataVersion(2), new DataVersion(3)
+ );
+
+ // Field should still have value 1, not incremented or changed
+ assertThat(second).hasIntField("addedField", 1);
+ }
+ }
+}
diff --git a/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/format/CrossFormatMigrationIT.java b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/format/CrossFormatMigrationIT.java
new file mode 100644
index 0000000..3c6133f
--- /dev/null
+++ b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/format/CrossFormatMigrationIT.java
@@ -0,0 +1,321 @@
+/*
+ * Copyright (c) 2025 Splatgames.de Software and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package de.splatgames.aether.datafixers.functional.format;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.gson.JsonElement;
+import de.splatgames.aether.datafixers.api.DataVersion;
+import de.splatgames.aether.datafixers.api.TypeReference;
+import de.splatgames.aether.datafixers.api.dynamic.Dynamic;
+import de.splatgames.aether.datafixers.api.fix.DataFixer;
+import de.splatgames.aether.datafixers.codec.json.gson.GsonOps;
+import de.splatgames.aether.datafixers.codec.json.jackson.JacksonJsonOps;
+import de.splatgames.aether.datafixers.codec.yaml.jackson.JacksonYamlOps;
+import de.splatgames.aether.datafixers.codec.yaml.snakeyaml.SnakeYamlOps;
+import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder;
+import de.splatgames.aether.datafixers.testkit.TestData;
+import de.splatgames.aether.datafixers.testkit.factory.QuickFix;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for migrations across different data formats.
+ */
+@DisplayName("Cross-Format Migration IT")
+@Tag("integration")
+class CrossFormatMigrationIT {
+
+ private static final TypeReference PLAYER = new TypeReference("player");
+
+ @Nested
+ @DisplayName("Gson Format")
+ class GsonFormat {
+
+ @Test
+ @DisplayName("completes full migration chain with Gson")
+ void completesFullMigrationChainWithGson() {
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(3))
+ .addFix(PLAYER, QuickFix.renameField(
+ GsonOps.INSTANCE, "rename_name", 1, 2, "playerName", "name"))
+ .addFix(PLAYER, QuickFix.renameField(
+ GsonOps.INSTANCE, "rename_xp", 2, 3, "xp", "experience"))
+ .build();
+
+ final Dynamic input = TestData.gson().object()
+ .put("playerName", "GsonPlayer")
+ .put("xp", 1000)
+ .put("level", 10)
+ .build();
+
+ final Dynamic result = fixer.update(
+ PLAYER, input,
+ new DataVersion(1), new DataVersion(3)
+ );
+
+ assertThat(result.get("name").asString().result()).contains("GsonPlayer");
+ assertThat(result.get("experience").asInt().result()).contains(1000);
+ assertThat(result.get("level").asInt().result()).contains(10);
+ }
+
+ @Test
+ @DisplayName("handles partial migration with Gson")
+ void handlesPartialMigrationWithGson() {
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(3))
+ .addFix(PLAYER, QuickFix.renameField(
+ GsonOps.INSTANCE, "rename_v1_v2", 1, 2, "oldField", "midField"))
+ .addFix(PLAYER, QuickFix.renameField(
+ GsonOps.INSTANCE, "rename_v2_v3", 2, 3, "midField", "newField"))
+ .build();
+
+ // Start from v2, should only apply v2->v3
+ final Dynamic input = TestData.gson().object()
+ .put("midField", "value")
+ .build();
+
+ final Dynamic result = fixer.update(
+ PLAYER, input,
+ new DataVersion(2), new DataVersion(3)
+ );
+
+ assertThat(result.get("newField").asString().result()).contains("value");
+ assertThat(result.get("midField")).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("Jackson JSON Format")
+ class JacksonJsonFormat {
+
+ @Test
+ @DisplayName("completes full migration chain with Jackson JSON")
+ void completesFullMigrationChainWithJacksonJson() {
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(3))
+ .addFix(PLAYER, QuickFix.renameField(
+ JacksonJsonOps.INSTANCE, "rename_name", 1, 2, "playerName", "name"))
+ .addFix(PLAYER, QuickFix.renameField(
+ JacksonJsonOps.INSTANCE, "rename_xp", 2, 3, "xp", "experience"))
+ .build();
+
+ final Dynamic input = TestData.jacksonJson().object()
+ .put("playerName", "JacksonPlayer")
+ .put("xp", 2000)
+ .put("score", 500)
+ .build();
+
+ final Dynamic result = fixer.update(
+ PLAYER, input,
+ new DataVersion(1), new DataVersion(3)
+ );
+
+ assertThat(result.get("name").asString().result()).contains("JacksonPlayer");
+ assertThat(result.get("experience").asInt().result()).contains(2000);
+ assertThat(result.get("score").asInt().result()).contains(500);
+ }
+ }
+
+ @Nested
+ @DisplayName("Jackson YAML Format")
+ class JacksonYamlFormat {
+
+ @Test
+ @DisplayName("completes full migration chain with Jackson YAML")
+ void completesFullMigrationChainWithJacksonYaml() {
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(3))
+ .addFix(PLAYER, QuickFix.renameField(
+ JacksonYamlOps.INSTANCE, "rename_name", 1, 2, "playerName", "name"))
+ .addFix(PLAYER, QuickFix.addIntField(
+ JacksonYamlOps.INSTANCE, "add_level", 2, 3, "level", 1))
+ .build();
+
+ final Dynamic input = TestData.jacksonYaml().object()
+ .put("playerName", "YamlPlayer")
+ .put("score", 3000)
+ .build();
+
+ final Dynamic result = fixer.update(
+ PLAYER, input,
+ new DataVersion(1), new DataVersion(3)
+ );
+
+ assertThat(result.get("name").asString().result()).contains("YamlPlayer");
+ assertThat(result.get("score").asInt().result()).contains(3000);
+ assertThat(result.get("level").asInt().result()).contains(1);
+ }
+ }
+
+ @Nested
+ @DisplayName("SnakeYAML Format")
+ class SnakeYamlFormat {
+
+ @Test
+ @DisplayName("completes migration with SnakeYAML")
+ void completesMigrationWithSnakeYaml() {
+ final DataFixer fixer = new DataFixerBuilder(new DataVersion(2))
+ .addFix(PLAYER, QuickFix.renameField(
+ SnakeYamlOps.INSTANCE, "rename_name", 1, 2, "playerName", "name"))
+ .build();
+
+ final Dynamic