diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..0c53cfa
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,72 @@
+version: 2
+updates:
+ # Maven dependencies
+ - package-ecosystem: "maven"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ time: "06:00"
+ timezone: "Europe/Berlin"
+ open-pull-requests-limit: 10
+ reviewers:
+ - "aether-framework/maintainers"
+ labels:
+ - "dependencies"
+ - "java"
+ commit-message:
+ prefix: "deps"
+ include: "scope"
+ groups:
+ jackson:
+ patterns:
+ - "com.fasterxml.jackson*"
+ update-types:
+ - "minor"
+ - "patch"
+ spring:
+ patterns:
+ - "org.springframework*"
+ update-types:
+ - "minor"
+ - "patch"
+ testing:
+ patterns:
+ - "org.junit*"
+ - "org.assertj*"
+ update-types:
+ - "minor"
+ - "patch"
+ maven-plugins:
+ patterns:
+ - "org.apache.maven.plugins:maven-*"
+ - "org.codehaus.mojo:*"
+ update-types:
+ - "minor"
+ - "patch"
+ build-plugins:
+ patterns:
+ - "org.sonatype.central:*"
+ - "org.owasp:*"
+ - "org.cyclonedx:*"
+ - "org.jacoco:*"
+ - "com.github.spotbugs:*"
+ update-types:
+ - "minor"
+ - "patch"
+
+ # GitHub Actions
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ time: "06:00"
+ timezone: "Europe/Berlin"
+ open-pull-requests-limit: 5
+ labels:
+ - "dependencies"
+ - "github-actions"
+ commit-message:
+ prefix: "ci"
+ include: "scope"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b421524
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,148 @@
+name: CI
+
+on:
+ push:
+ branches: [ main, develop, 'feature/**' ]
+ pull_request:
+ branches: [ main, develop ]
+
+permissions:
+ contents: read
+ checks: write
+ pull-requests: 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 test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: test-results-java-${{ matrix.java }}
+ path: |
+ **/target/surefire-reports/
+ **/target/failsafe-reports/
+ retention-days: 7
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ if: matrix.java == '21'
+ with:
+ name: coverage-report
+ path: |
+ **/target/site/jacoco/
+ **/target/jacoco.exec
+ 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
+ needs: build
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Install artifacts for analysis
+ 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
+ runs-on: ubuntu-latest
+ needs: build
+ env:
+ NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Cache Dependency-Check DB
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2/repository/org/owasp/dependency-check-data
+ key: depcheck-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ depcheck-${{ runner.os }}-
+
+ - 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
+ if: always()
+ with:
+ name: dependency-check-report
+ path: |
+ target/dependency-check-report.html
+ retention-days: 30
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..5233757
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,47 @@
+name: CodeQL Security Analysis
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+ branches: [ main, develop ]
+ schedule:
+ - cron: '0 0 * * 1' # Monday 00:00 UTC
+
+permissions:
+ contents: read
+ security-events: write
+ actions: read
+
+jobs:
+ analyze:
+ name: Analyze (java-kotlin)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: java-kotlin
+ build-mode: manual
+ queries: security-extended,security-and-quality
+
+ - name: Build with Maven
+ run: mvn -B clean compile -DskipTests
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:java-kotlin"
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 0000000..1dbab60
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,28 @@
+name: Dependency Review
+
+on:
+ pull_request:
+ branches: [ main, develop ]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ dependency-review:
+ name: Dependency Review
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Dependency Review
+ uses: actions/dependency-review-action@v4
+ with:
+ fail-on-severity: high
+ deny-licenses: GPL-3.0-only, GPL-3.0-or-later, AGPL-3.0-only, AGPL-3.0-or-later
+ comment-summary-in-pr: always
+ warn-only: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..007c2ae
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,197 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Release version (e.g., 1.0.0)'
+ required: true
+ type: string
+ dry_run:
+ description: 'Dry run (skip actual deployment)'
+ required: false
+ type: boolean
+ default: false
+
+# Minimal global permissions - jobs request additional permissions as needed
+permissions:
+ contents: read
+
+env:
+ JAVA_VERSION: '21'
+
+jobs:
+ validate:
+ name: Validate Release
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ should_deploy: ${{ steps.deploy-check.outputs.should_deploy }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Determine version
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" == "push" ]; then
+ VERSION="${GITHUB_REF#refs/tags/v}"
+ else
+ VERSION="${{ github.event.inputs.version }}"
+ fi
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "Release version: $VERSION"
+
+ - name: Check deployment condition
+ id: deploy-check
+ run: |
+ # Tag push: always deploy
+ # Manual dispatch: only if dry_run is not true
+ if [ "${{ github.event_name }}" == "push" ]; then
+ echo "should_deploy=true" >> $GITHUB_OUTPUT
+ echo "Deployment: enabled (tag push)"
+ elif [ "${{ github.event.inputs.dry_run }}" != "true" ]; then
+ echo "should_deploy=true" >> $GITHUB_OUTPUT
+ echo "Deployment: enabled (manual trigger, dry_run=false)"
+ else
+ echo "should_deploy=false" >> $GITHUB_OUTPUT
+ echo "Deployment: disabled (dry run mode)"
+ fi
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Validate build
+ run: mvn -B clean verify -DskipTests
+
+ test:
+ name: Run Tests
+ runs-on: ubuntu-latest
+ needs: validate
+
+ strategy:
+ matrix:
+ java: [ '17', '21' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ matrix.java }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ matrix.java }}
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Run tests
+ run: mvn -B clean test
+
+ deploy:
+ name: Deploy to Maven Central
+ runs-on: ubuntu-latest
+ needs: [ validate, test ]
+ if: needs.validate.outputs.should_deploy == 'true'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ env:
+ CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }}
+ GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: 'temurin'
+ cache: 'maven'
+ server-id: central
+ server-username: CENTRAL_USERNAME
+ server-password: CENTRAL_TOKEN
+ gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+ gpg-passphrase: GPG_PASSPHRASE
+
+ - name: Deploy to Maven Central
+ run: mvn -B clean deploy -Prelease -DskipTests -Dgpg.useAgent=false
+
+ sbom:
+ name: Generate SBOM
+ runs-on: ubuntu-latest
+ needs: [ validate, test ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Generate SBOM
+ run: mvn -B cyclonedx:makeAggregateBom -Pqa
+
+ - name: Upload SBOM artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: sbom
+ path: target/bom.*
+ retention-days: 90
+
+ github-release:
+ name: Create GitHub Release
+ runs-on: ubuntu-latest
+ needs: [ validate, deploy, sbom ]
+ if: needs.validate.outputs.should_deploy == 'true' && needs.deploy.result == 'success'
+
+ # Only this job needs write access to create the release
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Download SBOM
+ uses: actions/download-artifact@v4
+ with:
+ name: sbom
+ path: sbom/
+
+ - name: Read release notes
+ id: release-notes
+ run: |
+ if [ ! -f "RELEASE.md" ]; then
+ echo "Error: RELEASE.md not found"
+ exit 1
+ fi
+ echo "body<> $GITHUB_OUTPUT
+ cat RELEASE.md >> $GITHUB_OUTPUT
+ echo "EOF" >> $GITHUB_OUTPUT
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: v${{ needs.validate.outputs.version }}
+ name: Release v${{ needs.validate.outputs.version }}
+ body: ${{ steps.release-notes.outputs.body }}
+ files: |
+ sbom/bom.json
+ sbom/bom.xml
+ draft: false
+ prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
+ generate_release_notes: false
diff --git a/.well-known/security.txt b/.well-known/security.txt
new file mode 100644
index 0000000..0a75a54
--- /dev/null
+++ b/.well-known/security.txt
@@ -0,0 +1,10 @@
+# Aether Datafixers Security Contact
+# This file follows RFC 9116 (https://www.rfc-editor.org/rfc/rfc9116)
+
+Contact: mailto:security@splatgames.de
+Contact: https://github.com/aether-framework/aether-datafixers/security/advisories/new
+Expires: 2035-01-01T00:00:00.000Z
+Preferred-Languages: en, de
+Canonical: https://raw.githubusercontent.com/aether-framework/aether-datafixers/main/.well-known/security.txt
+Policy: https://github.com/aether-framework/aether-datafixers/blob/main/SECURITY.md
+Acknowledgments: https://github.com/aether-framework/aether-datafixers/blob/main/SECURITY.md#security-audits
diff --git a/KEYS b/KEYS
new file mode 100644
index 0000000..8e79d7e
--- /dev/null
+++ b/KEYS
@@ -0,0 +1,52 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBGlj2M8BEADdr0h5IJcIQ5USEP4rFPY+/91QLlwuFfI92Rcfsj0aGUXpsK+B
+UiCKeYgbyZJeFEQU4UHtP6QUfb58od8irVjAmv5eKAylHYy8vN8botNd4owzVNhz
+225NFtXjovs8GAV3phbXoW8D2IlnFt8lf0RohAeF+P/Ved1n7/5cHZXfQ92Aye82
+whA1pXdrqubmOmW+yHaw90BCksExwhOY5VzDXEPITeTj7yzhDY+fMofvnD7dAq98
+zA+vYDPTOZCWV3k38Re7xpvQcfl5F/lAqN9xtRqaMVIwzE35mhwLIPm7E9qPAOr6
+LI1boCRe5u3ei0gV8/OAkaZOWOQTF57g/G6J7TkB/GOhbkufR7XLGOK5DeU95xAI
+5d960k/6pa7LCIVMR1lIGHGbjS7i9rofDp4gpyL8B/qU9X8PRjguYYGPUv+6K5GT
+pIT7LCV4lDv+bR/H1htCzWZCCwUVVwOgvcXzec0R+Qd2WrHnGOoW4x3XCENvmsAL
+6/hHSxcpaOWJH5VK1iVWChjNZJqY95cu1dHV1jj7qeXb8xsDKQDuBq9B/X4RB/SK
+Suw7MRk1/EgHiZLQunsSKxCzfwKcpo0rI8TAFgm5Y30/12sTG6lt8ePQPFQUywQI
+YIV1+CYEMsDLdN5kul9o58P+PkwdfAq7YYDi32LrVSw0Z3/28+yoDJcg2QARAQAB
+tEFTcGxhdGdhbWVzLmRlIFNvZnR3YXJlIENJIFJlbGVhc2UgU2lnbmluZyA8cmVs
+ZWFzZUBzcGxhdGdhbWVzLmRlPokCUQQTAQgAOxYhBMa+Jb8qRjmmekkevTe1m5Pc
+dW7oBQJpY9jPAhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEDe1m5Pc
+dW7ooZwQAJUz8PfFO3/vbmWUD4a4I11ipWuOT/C8xArmhH7RkxSysgHspdBq+vUT
+B7VXdx4IJNWps77IajJsMIA6bC3B1V4YZBnxKYKdvhNlkoDddmXGFcyBpTGSfzwD
+qPOHrWa8LDg6naJ22PSgCh5SIy9BPQ+Vr0ES9R+HE3dFPNJgrocZk5/M1lV0gGk+
+o64F9VYl9r06QKwn5zEKyl6CKdpAlW9xHLobPW85vCEiEaTQlWDWzTH4uM+7JeM4
+JnmRVE/vWxA3O+hCoQJNdr2EhnyleXWgrkLIRggfgquB77LBN53JSs1ABby2J/fC
+JHNigGF/FvPhwsGQ4ateRfgK4fkeitLeNH+ozbrs7V+HdA2MDpftVI9LnEX48T2R
+wEAJQq3V9pnAHbnXa9v7Y8eOnEcaGhgOtvtBaamYR1/PNHSp9osh1w1g/KSRtb3j
+vp28hfLZoId+A2Q8KT6/2u4liiDt9hqueuOJe1i9A1Y8vXoaFd0J5LGTCJBsQ/00
+ZZdehcvNvSZPfzy/tbTB6lHqnNGU1w+n1wLA4mKPQKH3TkujvSpuTRWSrPl2vgFj
+GUHjep/MI9ie6lTF8sQ20C1skJtZQiOEwTwNq5QDUOJB/QeDlfhUV3G62Vxd/vS6
+u49bs2j+7hiRXNMfuOMjSOMp47QzEMBvHx0FTMFx/NKI6BEzaUBFuQINBGlj2M8B
+EACxDR6BLawJA3lLd41lXhauatOEYzaURGLjmvoYRQwv4k/RkduXcOcHkHGRKhfe
+7jBOtlkG1RUMe6Jeori5ErNHhqCefDpAI4rCrHXt9cS7STFRlmMznsEZ3AI1rTgG
++AhFNmDMWpC/ImynvGgcd/P4ZJx0oZlUkELJBELU2XShvQMlJP6R7wTOSv9OWeda
+V/MJSdgRuDiJJxG7z1Ono98YOs5kOu2nBOLcthyaox5TZZssaUBKtkcRMiiEOtvv
+nJUmC9mN4Cx9w73xxVeibdgW+2ZGr1qwI/1sXcPPCOTJ4ena9Sy4+KK4JzWaWIsl
+pBqS+p2ngKWTx/kDyuu5CHnyXmZLie0shj6OjiO2XWyMRQ4pG/wsZYV0kEZk/b8C
+BOpFbMgVLN6jqZ1DiIFDGzFdi2sf7LE+MrDIOQa1IY/bmEUl0/vE49j+Nwqq8SeT
+/B8DOoMtyTly84Mbzf0Mg+RIZTMZ+7GvNhTVIirSc6xPsaZ1E4JstXDofDazVLd9
+Z15dtelbJeGbDJI2YpPS+7ISyFEUAbUobGXdeKgOh4J2c04VarO60u02Ed0/wWXZ
+ey6VXhXCmMzGwrSgnvuLiCD7F4ZM/5+nSi/GkeXEs8qXuYpmdQ9tSz4R9miax+iu
+j+UxfzdMlQfZL7VJq3RPa+RJunTb15SNwPevPilpZCBZdwARAQABiQI2BBgBCAAg
+FiEExr4lvypGOaZ6SR69N7Wbk9x1bugFAmlj2M8CGwwACgkQN7Wbk9x1bugd2w//
+T7vwqH87ovqEG3BkCPWBzftMbhzg5S/Qn8yyjTyMCao8Lm0ZK/0UmXJtwJ7hxNOk
+RRb3rIYdbbwWhLb0MgD87AYmg2DL604X7GpnBXvzwbDZlm0vNe8qB7v2JluDPZWN
+uw8kX9owInNn6pb3tChsVsQ50vgVa3yfPQ/8MhOqe/NzKbqeCBPDqbRhmODmC0+x
+yRYgGo4cVwMc6NFHMcl/CfEK5c1In7BYMAKnJ76LADzdgyeYZtGlJjPBvbM9JSJk
+E7uvPy6WcpDAqZN+EmJ36DbPPquGyuieUMwHmi8hGp0Vid/bKas/NZU3XYBVCXXA
+kn2KdvMT/ctqIE2NCs06keX7ojnt+kUxXeUmpTSCJ4J3zXb3bAEyTTL47BT8lnBB
+AejZndNaBHjnAUoW9VpiHDHHtDdOzELL3c+/sROLZLLLq3sq0+cUYwG2mKYXakXO
+1cygMAvFdrWYR+1ezLhG6IqDKwENUq7EQZv4itLlsPb//UrVwnKBd052CsIbGPRn
+zwZfIlCCdpL0/jRNgOh8lxm90U6P++o6m9SRCO+epFRVNKZBFW9QcYUT20HiTsVO
+txvN20Zz88ID9dyRywF236cYES0wdAGC0hgFHAI0vmjKzvuKspRhcJ8accjSLyF6
+XJ48wxLgq6MyEpefywMP3znLq0PGEwl8EmjsZc2bdCk=
+=3smd
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/SECURITY.md b/SECURITY.md
index 34eab22..e39fe80 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,39 +4,147 @@
We provide security updates for the following versions:
-| Version | Supported |
-|---------|---------------------|
-| 1.0.x | ❌ Not supported yet |
-| 0.4.x | ✅ Active Support |
-| 0.3.x | ❌ Not supported |
-| 0.2.x | ❌ Not supported |
-| 0.1.x | ❌ Not supported |
+| Version | Support Status | End of Support |
+|--------|--------------------|----------------|
+| 1.0.x | 🔜 Planned LTS | TBD (1 year) |
+| 0.5.x | ✅ Active Support | February 2026 |
+| 0.4.x | ❌ End of Life | - |
+| 0.3.x | ❌ End of Life | - |
+| 0.2.x | ❌ End of Life | - |
+| 0.1.x | ❌ End of Life | - |
-If you are using an older version, we **strongly** recommend upgrading to the latest stable release.
+If you are using an older version, we **strongly recommend upgrading** to the latest stable release.
+
+---
+
+## Security Features
+
+### Automated Security Scanning
+
+This project uses multiple automated security tools:
+
+- **GitHub CodeQL** – Static Application Security Testing (SAST)
+- **OWASP Dependency-Check** – Known vulnerability detection in dependencies
+- **GitHub Dependency Review** – Pull request dependency analysis
+- **Dependabot** – Automated dependency updates
+
+All scans are executed automatically in CI pipelines on every pull request and release build.
+
+---
+
+## Supply Chain Security
+
+### Artifact Integrity & Signing
+
+All official release artifacts of **Aether Datafixers** are **cryptographically signed** to guarantee integrity and authenticity.
+
+- All release artifacts are **GPG signed**
+- Signatures are generated during the release pipeline
+- Each published artifact is accompanied by a corresponding `.asc` signature file
+- Consumers can verify artifacts before usage
+
+Example verification flow:
+
+```
+gpg --verify artifact.jar.asc artifact.jar
+```
+
+Unsigned or modified artifacts **must not be trusted**.
+
+---
+
+### Signing Keys
+
+- A **dedicated GPG key** is used for automated GitHub releases and deployments
+- Release signing keys are **separate from personal developer keys**
+- Private key material is **never committed** to the repository
+- Keys are stored securely using CI secret management
+
+The signing process is fully automated and enforced during release builds.
+
+---
## Reporting a Vulnerability
-If you find a security vulnerability in Aether Datafixers, please report it **privately**.
-We take security issues seriously and will respond as soon as possible.
+If you discover a security vulnerability in **Aether Datafixers**, please report it **privately**.
-### 📬 Contact
+### Contact
-- **Email:** security@splatgames.de
-- **GitHub Issues:** Do **not** report security vulnerabilities in public issues.
+- **Email:** `security@splatgames.de`
+- **GitHub Security Advisories:**
+ https://github.com/aether-framework/aether-datafixers/security/advisories/new
+- **GitHub Issues:**
+ Do **not** report security vulnerabilities in public issues.
-### 🔒 Disclosure Process
+---
-1. Report the issue privately via **security@splatgames.de**.
-2. Our team will acknowledge receipt within **48 hours**.
-3. We will investigate and provide a **fix timeline**.
-4. Once resolved, we will issue a **security advisory**.
+## Disclosure Process
+
+1. Report the issue privately
+2. Acknowledgment within **48 hours**
+3. Fix timeline provided within **7 days**
+4. Critical vulnerabilities (CVSS ≥ 9.0): patch within **72 hours**
+5. High severity (CVSS ≥ 7.0): patch within **14 days**
+6. Security advisory published after resolution
+
+---
+
+## Response Time SLA
+
+| Severity | Acknowledgment | Fix Timeline |
+|--------------------------|----------------|--------------|
+| Critical (CVSS 9.0–10.0) | 24 hours | 72 hours |
+| High (CVSS 7.0–8.9) | 48 hours | 14 days |
+| Medium (CVSS 4.0–6.9) | 48 hours | 30 days |
+| Low (CVSS 0.1–3.9) | 72 hours | Next release |
+
+---
## Security Best Practices
-To keep your application secure when using Aether Datafixers:
+- Always use the **latest stable version**
+- Verify **GPG signatures** of all downloaded artifacts
+- Enable automated dependency updates
+- Validate input data at system boundaries
+- Use appropriate `DynamicOps` implementations for untrusted data
+- Avoid sensitive data in logs
+- Review the attached **SBOM** for dependency transparency
+
+---
+
+## Vulnerability Disclosure Policy
+
+We follow a **coordinated disclosure** process:
+
+1. Private disclosure
+2. Fix development
+3. Advisory preparation
+4. Coordinated release
+5. Public disclosure after a grace period
+
+---
+
+## Security Audits
+
+Security audits are welcome.
+
+- Contact `security@splatgames.de` before starting
+- Follow responsible disclosure practices
+- Researchers may be credited with permission
+
+---
+
+## PGP Key
+
+For encrypted communication and release verification:
+
+- **Key Purpose:** Release artifact signing
+- **Key ID:** 37B59B93DC756EE8
+- **Fingerprint:** C6BE25BF2A4639A67A491EBD37B59B93DC756EE8
+- **Accessable in repository:** `KEYS`
+
+Contact: **security@splatgames.de**
-- Always use the **latest stable version**.
-- Validate event data properly.
-- Do not expose sensitive logging in production.
+---
-🚀 Thank you for helping us keep Aether Datafixers secure!
+Thank you for helping keep **Aether Datafixers** secure.
\ No newline at end of file
diff --git a/aether-datafixers-api/pom.xml b/aether-datafixers-api/pom.xml
index bdee772..a4c7edd 100644
--- a/aether-datafixers-api/pom.xml
+++ b/aether-datafixers-api/pom.xml
@@ -15,11 +15,20 @@
Aether Datafixers :: APIAPI interfaces and classes for Aether Datafixers.
+
+
+ 0.65
+
+
org.jetbrainsannotations
+
+ com.github.spotbugs
+ spotbugs-annotations
+ com.google.guavaguava
diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java
index cf97d68..6f7f9bd 100644
--- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java
+++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java
@@ -74,8 +74,7 @@ public final class DataVersion implements Comparable {
* The internal numeric representation of this data version.
*
*
This value is guaranteed to be non-negative and represents the version number
- * in a monotonically increasing sequence. Higher values indicate newer versions of
- * the data schema.
+ * in a monotonically increasing sequence. Higher values indicate newer versions of the data schema.
*/
private final int version;
@@ -136,9 +135,8 @@ public int getVersion() {
* }
*
* @param o the data version to compare against; must not be {@code null}
- * @return a negative integer if this version is less than the specified version,
- * zero if they are equal, or a positive integer if this version is
- * greater than the specified version
+ * @return a negative integer if this version is less than the specified version, zero if they are equal, or a al,
+ * or a positive integer if this version is greater than the specified version
* @throws NullPointerException if the specified data version is {@code null}
*/
@Override
@@ -150,8 +148,8 @@ public int compareTo(@NotNull final DataVersion o) {
* Indicates whether some other object is "equal to" this data version.
*
*
Two {@code DataVersion} instances are considered equal if and only if they
- * have the same numeric version value. This method adheres to the general contract
- * of {@link Object#equals(Object)}, providing:
+ * have the same numeric version value. This method adheres to the general contract of
+ * {@link Object#equals(Object)}, providing:
*
*
Reflexivity: For any non-null {@code DataVersion x}, {@code x.equals(x)}
* returns {@code true}
@@ -169,8 +167,7 @@ public int compareTo(@NotNull final DataVersion o) {
*
*
* @param obj the reference object with which to compare; may be {@code null}
- * @return {@code true} if this data version is equal to the specified object;
- * {@code false} otherwise
+ * @return {@code true} if this data version is equal to the specified object; {@code false} otherwise
* @see #hashCode()
*/
@Override
@@ -213,8 +210,8 @@ public int hashCode() {
* Returns a string representation of this data version.
*
*
The returned string follows the format {@code "DataVersion{version=N}"} where
- * {@code N} is the numeric version value. This format is intended for debugging and
- * logging purposes and should not be parsed programmatically.
+ * {@code N} is the numeric version value. This format is intended for debugging and logging purposes and should not
+ * be parsed programmatically.
*
*
Example output:
*
{@code
@@ -222,8 +219,7 @@ public int hashCode() {
* new DataVersion(0).toString() // Returns "DataVersion{version=0}"
* }
*
- * @return a string representation of this data version in the format
- * {@code "DataVersion{version=N}"}
+ * @return a string representation of this data version in the format {@code "DataVersion{version=N}"}
*/
@Override
public String toString() {
diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java
index 15fa8f7..5aaedb9 100644
--- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java
+++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java
@@ -78,8 +78,8 @@ public final class TypeReference {
* The unique string identifier for this type reference.
*
*
This identifier is used as a key for type lookups in registries and serves as
- * the canonical name for the data type throughout the data fixing system. The value
- * is guaranteed to be non-null and non-empty.
+ * the canonical name for the data type throughout the data fixing system. The value is guaranteed to be non-null
+ * and non-empty.
*
*
By convention, type identifiers use lowercase letters with underscores to
* separate words (e.g., "player", "block_entity", "world_data").
@@ -104,8 +104,8 @@ public TypeReference(@NotNull final String id) {
* Returns the unique identifier for this type reference.
*
*
The returned identifier is the canonical name used to look up type definitions
- * in a {@link TypeRegistry} and to associate {@link DataFix} instances with this
- * data type. The identifier is guaranteed to be non-null and non-empty.
+ * in a {@link TypeRegistry} and to associate {@link DataFix} instances with this data type. The identifier is
+ * guaranteed to be non-null and non-empty.
*
*
Example usage:
*
{@code
@@ -127,8 +127,7 @@ public String getId() {
* Returns a hash code value for this type reference.
*
*
The hash code is computed solely based on the string identifier. This
- * implementation satisfies the general contract of {@link Object#hashCode()},
- * ensuring that:
+ * implementation satisfies the general contract of {@link Object#hashCode()}, ensuring that:
*
*
If two {@code TypeReference} objects are equal according to the
* {@link #equals(Object)} method, then calling {@code hashCode()} on each
@@ -153,8 +152,8 @@ public int hashCode() {
* Indicates whether some other object is "equal to" this type reference.
*
*
Two {@code TypeReference} instances are considered equal if and only if they
- * have the same string identifier (case-sensitive comparison). This method adheres
- * to the general contract of {@link Object#equals(Object)}, providing:
+ * have the same string identifier (case-sensitive comparison). This method adheres to the general contract of
+ * {@link Object#equals(Object)}, providing:
*
*
Reflexivity: For any non-null {@code TypeReference x},
* {@code x.equals(x)} returns {@code true}
@@ -180,8 +179,7 @@ public int hashCode() {
* }
*
* @param obj the reference object with which to compare; may be {@code null}
- * @return {@code true} if this type reference is equal to the specified object;
- * {@code false} otherwise
+ * @return {@code true} if this type reference is equal to the specified object; {@code false} otherwise
* @see #hashCode()
*/
@Override
@@ -199,8 +197,8 @@ public boolean equals(final Object obj) {
* Returns a string representation of this type reference.
*
*
The returned string follows the format {@code "TypeReference{id=''}"}.
- * This format is intended for debugging and logging purposes and provides a clear,
- * human-readable representation of the type reference.
+ * This format is intended for debugging and logging purposes and provides a clear, human-readable representation of
+ * the type reference.
*
*
Note: The format of this string is not guaranteed to remain stable across
- * versions and should not be parsed programmatically. Use {@link #getId()} to retrieve
- * the identifier for programmatic use.
+ * versions and should not be parsed programmatically. Use {@link #getId()} to retrieve the identifier for
+ * programmatic use.
*
* @return a string representation of this type reference
* @see #getId()
diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/bootstrap/DataFixerBootstrap.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/bootstrap/DataFixerBootstrap.java
index 6073b83..cc8fba4 100644
--- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/bootstrap/DataFixerBootstrap.java
+++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/bootstrap/DataFixerBootstrap.java
@@ -88,8 +88,8 @@ public interface DataFixerBootstrap {
* Registers all schemas with the provided schema registry.
*
*
This method is invoked during data fixer initialization to populate the schema
- * registry with {@link Schema} instances for each supported data version. Schemas define
- * the structure and types available at each version of the data model.
+ * registry with {@link Schema} instances for each supported data version. Schemas define the structure and types
+ * available at each version of the data model.
*
*
Implementation Guidelines
*
When implementing this method, consider the following best practices:
@@ -127,8 +127,7 @@ public interface DataFixerBootstrap {
* Implementations do not need to be thread-safe, but they should not retain references
* to the registry after the method returns.
*
- * @param schemas the schema registry to populate with version-specific schemas;
- * must not be {@code null}
+ * @param schemas the schema registry to populate with version-specific schemas; must not be {@code null}
* @throws NullPointerException if {@code schemas} is {@code null}
* @see Schema
* @see SchemaRegistry
@@ -139,9 +138,8 @@ public interface DataFixerBootstrap {
* Registers all data fixes with the provided fix registrar.
*
*
This method is invoked during data fixer initialization to register all
- * {@link DataFix} instances that handle migrations between data versions. Each fix
- * defines a transformation from one version to another for a specific type or set
- * of types.
+ * {@link DataFix} instances that handle migrations between data versions. Each fix defines a transformation from
+ * one version to another for a specific type or set of types.
*
*
Implementation Guidelines
*
When implementing this method, adhere to these best practices:
@@ -179,8 +177,7 @@ public interface DataFixerBootstrap {
* Implementations do not need to be thread-safe, but they should not retain references
* to the registrar after the method returns.
*
- * @param fixes the fix registrar to populate with data migration fixes;
- * must not be {@code null}
+ * @param fixes the fix registrar to populate with data migration fixes; must not be {@code null}
* @throws NullPointerException if {@code fixes} is {@code null}
* @see DataFix
* @see FixRegistrar
diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/CodecRegistry.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/CodecRegistry.java
index a956109..8d78a6f 100644
--- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/CodecRegistry.java
+++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/CodecRegistry.java
@@ -66,9 +66,9 @@ public interface CodecRegistry {
* Registers a codec for the given type reference.
*
*
This method associates a {@link Codec} with a {@link TypeReference}, enabling
- * later retrieval via {@link #get(TypeReference)} or {@link #require(TypeReference)}.
- * If a codec is already registered for the given reference, the behavior depends on
- * the implementation (it may replace the existing codec or throw an exception).
+ * later retrieval via {@link #get(TypeReference)} or {@link #require(TypeReference)}. If a codec is already
+ * registered for the given reference, the behavior depends on the implementation (it may replace the existing codec
+ * or throw an exception).
*
*
Example Usage
*
{@code
@@ -97,8 +97,8 @@ public interface CodecRegistry {
* Retrieves a codec by its type reference.
*
*
This method performs a lookup in the registry and returns the codec associated
- * with the given type reference, or {@code null} if no codec has been registered for
- * that reference. For a non-null guarantee, use {@link #require(TypeReference)} instead.
+ * with the given type reference, or {@code null} if no codec has been registered for that reference. For a non-null
+ * guarantee, use {@link #require(TypeReference)} instead.
*
*
*
* @param ref the type reference to look up; must not be {@code null}
- * @return the codec associated with the given reference, or {@code null} if no codec
- * is registered for that reference
+ * @return the codec associated with the given reference, or {@code null} if no codec is registered for that
+ * reference
* @throws NullPointerException if {@code ref} is {@code null}
* @see #has(TypeReference)
* @see #require(TypeReference)
@@ -123,9 +123,8 @@ public interface CodecRegistry {
* Checks whether a codec is registered for the given type reference.
*
*
This method provides a way to verify the existence of a codec registration
- * without actually retrieving the codec. It is more efficient than calling
- * {@link #get(TypeReference)} and checking for {@code null} if you only need to
- * test for presence.
+ * without actually retrieving the codec. It is more efficient than calling {@link #get(TypeReference)} and checking
+ * for {@code null} if you only need to test for presence.
*
*
*
* @param ref the type reference to check for registration; must not be {@code null}
- * @return {@code true} if a codec is registered for the given reference;
- * {@code false} otherwise
+ * @return {@code true} if a codec is registered for the given reference; {@code false} otherwise
* @throws NullPointerException if {@code ref} is {@code null}
* @see #get(TypeReference)
* @see #require(TypeReference)
@@ -151,9 +149,8 @@ public interface CodecRegistry {
* Retrieves a codec by its type reference, throwing an exception if not found.
*
*
This method is similar to {@link #get(TypeReference)} but guarantees a non-null
- * return value. If no codec is registered for the given reference, an
- * {@link IllegalStateException} is thrown. Use this method when the absence of a
- * codec indicates a programming error or misconfiguration.
+ * return value. If no codec is registered for the given reference, an {@link IllegalStateException} is thrown. Use
+ * this method when the absence of a codec indicates a programming error or misconfiguration.
*
*
Example Usage
*
{@code
@@ -191,9 +188,9 @@ default Codec> require(@NotNull final TypeReference ref) {
* Freezes this registry, making it immutable.
*
*
After freezing, any attempt to modify the registry (e.g., via
- * {@link #register(TypeReference, Codec)}) will throw an {@link IllegalStateException}.
- * This is useful for ensuring thread-safety after the initialization phase is complete,
- * as an immutable registry can be safely shared across threads without synchronization.
+ * {@link #register(TypeReference, Codec)}) will throw an {@link IllegalStateException}. This is useful for ensuring
+ * thread-safety after the initialization phase is complete, as an immutable registry can be safely shared across
+ * threads without synchronization.
*
*
Idempotency
*
This method is idempotent - calling it multiple times has no additional effect
@@ -228,8 +225,7 @@ default void freeze() {
* Returns whether this registry has been frozen and is now immutable.
*
*
A frozen registry cannot accept new codec registrations. Any call to
- * {@link #register(TypeReference, Codec)} on a frozen registry will throw an
- * {@link IllegalStateException}.
+ * {@link #register(TypeReference, Codec)} on a frozen registry will throw an {@link IllegalStateException}.
*
*
The default implementation returns {@code false}, indicating that the registry
* is always mutable. Implementations that support freezing should override this method.
*
- * @return {@code true} if this registry has been frozen and is immutable;
- * {@code false} if it is still mutable and accepts new registrations
+ * @return {@code true} if this registry has been frozen and is immutable; {@code false} if it is still mutable and
+ * accepts new registrations
* @see #freeze()
*/
default boolean isFrozen() {
diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/RecordCodecBuilder.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/RecordCodecBuilder.java
index a3da06e..2a4ee4b 100644
--- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/RecordCodecBuilder.java
+++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/codec/RecordCodecBuilder.java
@@ -958,9 +958,8 @@ public DataResult decode(@NotNull final DynamicOps ops,
* Internal tuple for accumulating 3 decoded values before applying the constructor.
*
*
This record is used internally during the decoding process to collect
- * intermediate results when decoding records with 3 or more fields. The values
- * are accumulated using {@link DataResult#apply2} before being passed to the
- * final constructor function.
Null Representation: YAML's {@code null} and {@code ~} values are
- * represented as Java {@code null}. The {@link #empty()} method returns {@code null}.
+ * represented by the {@link #NULL} sentinel object. Use {@link #wrap(Object)} after parsing
+ * YAML with SnakeYAML to convert Java {@code null} to the sentinel, and {@link #unwrap(Object)}
+ * before serializing to convert back.
*
Number Types: SnakeYAML preserves the specific numeric type from the
* YAML source (e.g., integers vs. floats), which is maintained in this implementation.
*
Key Types: While YAML supports complex keys, this implementation
@@ -228,6 +230,41 @@ public final class SnakeYamlOps implements DynamicOps {
*/
public static final SnakeYamlOps INSTANCE = new SnakeYamlOps();
+ /**
+ * Sentinel object representing the YAML null value.
+ *
+ *
This singleton instance represents the absence of a value in YAML, corresponding to
+ * YAML's explicit {@code null} or {@code ~} values. Unlike using Java's {@code null} directly,
+ * this sentinel allows the {@link DynamicOps} contract to be fulfilled (which requires
+ * {@link #empty()} to return a non-null value).
+ *
+ *
Usage
+ *
{@code
+ * // Check if a value is the YAML null sentinel
+ * if (value == SnakeYamlOps.NULL) {
+ * // Handle null case
+ * }
+ *
+ * // Create an explicit null value
+ * Object nullValue = SnakeYamlOps.NULL;
+ * }
+ *
+ *
Serialization Note
+ *
When serializing data containing this sentinel to YAML text using SnakeYAML, you should
+ * convert the sentinel back to Java {@code null} before serialization. Use
+ * {@link #unwrap(Object)} for this purpose:
+ *
{@code
+ * Object data = ...; // May contain YamlNull.INSTANCE
+ * Object unwrapped = SnakeYamlOps.unwrap(data);
+ * String yaml = new Yaml().dump(unwrapped);
+ * }
Returns {@code null} which represents the absence of a value in YAML. This is the
- * canonical "empty" value for the SnakeYAML format and corresponds to YAML's explicit
- * {@code null} or {@code ~} values.
+ *
Returns the {@link #NULL} sentinel which represents the absence of a value in YAML.
+ * This corresponds to YAML's explicit {@code null} or {@code ~} values.
*
- *
Unlike JSON-based implementations that return a null object (e.g., {@code JsonNull}),
- * SnakeYAML uses Java's {@code null} directly. This is used when:
- *
- *
A field has no value
- *
A conversion cannot determine the appropriate type
The sentinel object is used instead of Java's {@code null} to satisfy the
+ * {@link DynamicOps} contract which requires this method to return a non-null value.
+ * Use {@link #isNull(Object)} to check if a value is the null sentinel, or compare
+ * directly with {@code == SnakeYamlOps.NULL}.
+ *
+ *
When serializing data to YAML text format, use {@link #unwrap(Object)} to convert
+ * the sentinel back to Java {@code null} before passing to SnakeYAML.
*
- * @return {@code null}, representing the absence of a value
+ * @return the {@link #NULL} sentinel representing the absence of a value; never {@code null}
+ * @see #NULL
+ * @see #isNull(Object)
+ * @see #unwrap(Object)
*/
- @Nullable
+ @NotNull
@Override
public Object empty() {
- return null;
+ return YamlNull.INSTANCE;
}
// ==================== Type Check Operations ====================
@@ -702,20 +740,18 @@ public DataResult> getList(@NotNull final Object input) {
*
Success Conditions
*
*
Input list is a {@link List} instance
- *
Input list is {@code null} (treated as empty list)
*
*
*
Failure Conditions
*
- *
Input list is not a list or null (e.g., Map, primitive)
+ *
Input list is not a {@link List} instance (e.g., Map, primitive)
*
*
*
Immutability: The original list is never modified. A new
* {@link ArrayList} is created from the original elements, and the value is deep-copied
* before being appended to ensure nested structures are also copied.
*
- * @param list the list to append to; must not be {@code null}; may be {@code null}
- * (treated as empty list)
+ * @param list the list to append to; must not be {@code null}
* @param value the value to append; must not be {@code null}
* @return a {@link DataResult} containing the new list with the appended value,
* or an error message if the list is not valid; never {@code null}
@@ -727,10 +763,10 @@ public DataResult mergeToList(@NotNull final Object list,
@NotNull final Object value) {
Preconditions.checkNotNull(list, "list must not be null");
Preconditions.checkNotNull(value, "value must not be null");
- if (list != null && !(list instanceof List)) {
+ if (!(list instanceof List)) {
return DataResult.error("Not a list: " + list);
}
- final List result = list == null ? new ArrayList<>() : new ArrayList<>((List) list);
+ final List result = new ArrayList<>((List) list);
result.add(deepCopy(value));
return DataResult.success(result);
}
@@ -848,13 +884,12 @@ public DataResult>> getMapEntries(@NotNull final Obj
*
Success Conditions
*
*
Input map is a {@link Map} instance
- *
Input map is {@code null} (treated as empty map)
*
Key is a {@link String}
*
*
*
Failure Conditions
*
- *
Input map is not a map or null (e.g., List, primitive)
+ *
Input map is not a {@link Map} instance (e.g., List, primitive)
*
Key is not a {@link String}
*
*
@@ -862,8 +897,7 @@ public DataResult>> getMapEntries(@NotNull final Obj
* {@link LinkedHashMap} is created from the original entries, and the value is
* deep-copied before being added.
*
- * @param map the map to add the entry to; must not be {@code null}; may be {@code null}
- * (treated as empty map)
+ * @param map the map to add the entry to; must not be {@code null}
* @param key the key for the entry; must not be {@code null}; must be a {@link String}
* @param value the value for the entry; must not be {@code null}
* @return a {@link DataResult} containing the new map with the added entry,
@@ -878,15 +912,13 @@ public DataResult mergeToMap(@NotNull final Object map,
Preconditions.checkNotNull(map, "map must not be null");
Preconditions.checkNotNull(key, "key must not be null");
Preconditions.checkNotNull(value, "value must not be null");
- if (map != null && !(map instanceof Map)) {
+ if (!(map instanceof Map)) {
return DataResult.error("Not a map: " + map);
}
if (!(key instanceof String)) {
return DataResult.error("Key is not a string: " + key);
}
- final Map result = map == null
- ? new LinkedHashMap<>()
- : new LinkedHashMap<>((Map) map);
+ final Map result = new LinkedHashMap<>((Map) map);
result.put((String) key, deepCopy(value));
return DataResult.success(result);
}
@@ -900,13 +932,12 @@ public DataResult mergeToMap(@NotNull final Object map,
*
Success Conditions
*
*
Both inputs are {@link Map} instances
- *
Either input may be {@code null} (treated as empty map)
*
*
*
Failure Conditions
*
- *
First input is not a map or null
- *
Second input is not a map or null
+ *
First input is not a {@link Map} instance
+ *
Second input is not a {@link Map} instance
*
*
*
Merge Behavior
@@ -920,10 +951,8 @@ public DataResult mergeToMap(@NotNull final Object map,
* {@link LinkedHashMap} is created, and all values from the second map are deep-copied
* before being added.
*
- * @param map the base map; must not be {@code null}; may be {@code null}
- * (treated as empty map)
- * @param other the map to merge into the base; must not be {@code null}; may be
- * {@code null} (treated as empty map)
+ * @param map the base map; must not be {@code null}
+ * @param other the map to merge into the base; must not be {@code null}
* @return a {@link DataResult} containing the merged map, or an error message
* if either input is invalid; never {@code null}
*/
@@ -934,19 +963,15 @@ public DataResult mergeToMap(@NotNull final Object map,
@NotNull final Object other) {
Preconditions.checkNotNull(map, "map must not be null");
Preconditions.checkNotNull(other, "other must not be null");
- if (map != null && !(map instanceof Map)) {
+ if (!(map instanceof Map)) {
return DataResult.error("First argument is not a map: " + map);
}
- if (other != null && !(other instanceof Map)) {
+ if (!(other instanceof Map)) {
return DataResult.error("Second argument is not a map: " + other);
}
- final Map result = map == null
- ? new LinkedHashMap<>()
- : new LinkedHashMap<>((Map) map);
- if (other != null) {
- for (final Map.Entry entry : ((Map) other).entrySet()) {
- result.put(entry.getKey(), deepCopy(entry.getValue()));
- }
+ final Map result = new LinkedHashMap<>((Map) map);
+ for (final Map.Entry entry : ((Map) other).entrySet()) {
+ result.put(entry.getKey(), deepCopy(entry.getValue()));
}
return DataResult.success(result);
}
@@ -1121,20 +1146,20 @@ public boolean has(@NotNull final Object input,
* creates an {@link ArrayList} with recursively converted elements
*
Map: If {@link DynamicOps#getMapEntries} succeeds,
* creates a {@link LinkedHashMap} with recursively converted entries
- *
Fallback: Returns {@code null} if no type matches
+ *
Fallback: Returns the {@link #NULL} sentinel if no type matches
*
*
*
Edge Cases
*
*
Map entries with {@code null} keys are skipped
- *
Map entries with {@code null} values are converted to {@code null}
+ *
Map entries with {@code null} values are converted to the {@link #NULL} sentinel
*
Empty collections are preserved as empty ArrayList/LinkedHashMap
*
*
*
Format-Specific Notes
*
- *
Gson's {@code JsonNull} is converted to Java {@code null}
- *
Jackson's {@code NullNode} is converted to Java {@code null}
+ *
Gson's {@code JsonNull} is converted to the {@link #NULL} sentinel
+ *
Jackson's {@code NullNode} is converted to the {@link #NULL} sentinel
*
Numeric types are preserved where the source format supports them
*
*
@@ -1142,10 +1167,10 @@ public boolean has(@NotNull final Object input,
* {@code null}
* @param input the value to convert from the source format; must not be {@code null}
* @param the type parameter of the source format
- * @return the converted value as a SnakeYAML native type; may return {@code null}
- * for empty/null source values
+ * @return the converted value as a SnakeYAML native type; returns the {@link #NULL}
+ * sentinel for empty/null source values; never {@code null}
*/
- @Nullable
+ @NotNull
@Override
public Object convertTo(@NotNull final DynamicOps sourceOps,
@NotNull final U input) {
@@ -1193,7 +1218,7 @@ public Object convertTo(@NotNull final DynamicOps sourceOps,
);
}
- // Fallback: return null for unknown/empty types
+ // Fallback: return the NULL sentinel for unknown/empty types
return empty();
}
@@ -1210,6 +1235,7 @@ public Object convertTo(@NotNull final DynamicOps sourceOps,
*
Copy Behavior by Type
*
*
null: Returns {@code null}
+ *
YamlNull sentinel: Returns the sentinel as-is (it's a singleton)
*
Map: Creates a new {@link LinkedHashMap} with recursively
* deep-copied values (keys are assumed to be immutable strings)
*
List: Creates a new {@link ArrayList} with recursively
@@ -1224,7 +1250,7 @@ public Object convertTo(@NotNull final DynamicOps sourceOps,
* Consider using batch operations ({@link #createMap(Stream)}, {@link #createList(Stream)})
* to minimize the number of copy operations.
*
- * @param value the value to copy; may be {@code null}
+ * @param value the value to copy; may be {@code null} or the {@link #NULL} sentinel
* @return a deep copy of the value, or the value itself if it is immutable;
* {@code null} if the input is {@code null}
*/
@@ -1234,6 +1260,9 @@ private Object deepCopy(@Nullable final Object value) {
if (value == null) {
return null;
}
+ if (value == YamlNull.INSTANCE) {
+ return YamlNull.INSTANCE;
+ }
if (value instanceof Map) {
final Map original = (Map) value;
final Map copy = new LinkedHashMap<>();
@@ -1268,4 +1297,169 @@ private Object deepCopy(@Nullable final Object value) {
public String toString() {
return "SnakeYamlOps";
}
+
+ // ==================== Static Utility Methods ====================
+
+ /**
+ * Checks whether the given value is the YAML null sentinel.
+ *
+ *
This method provides a convenient way to check if a value represents YAML's null
+ * without directly comparing to {@link #NULL}.
+ *
+ * @param value the value to check; may be {@code null}
+ * @return {@code true} if the value is the YAML null sentinel, {@code false} otherwise
+ */
+ public static boolean isNull(@Nullable final Object value) {
+ return value == YamlNull.INSTANCE;
+ }
+
+ /**
+ * Recursively converts the YAML null sentinel back to Java {@code null}.
+ *
+ *
This method should be used before serializing data to YAML text format using
+ * SnakeYAML, as SnakeYAML expects Java {@code null} for null values, not the sentinel.
+ *
+ *
Conversion Behavior
+ *
+ *
{@link #NULL} sentinel is converted to Java {@code null}
+ *
{@link Map} instances are recursively processed (values only, keys are preserved)
+ *
{@link List} instances are recursively processed
+ *
All other values are returned unchanged
+ *
+ *
+ *
Example
+ *
{@code
+ * // Data structure with sentinel values
+ * Map data = new LinkedHashMap<>();
+ * data.put("name", "Alice");
+ * data.put("nickname", SnakeYamlOps.NULL);
+ *
+ * // Convert for serialization
+ * Object unwrapped = SnakeYamlOps.unwrap(data);
+ *
+ * // Now safe to serialize with SnakeYAML
+ * String yaml = new Yaml().dump(unwrapped);
+ * // Output: {name: Alice, nickname: null}
+ * }
+ *
+ * @param value the value to unwrap; may be {@code null}
+ * @return the value with all sentinel instances replaced by Java {@code null}
+ */
+ @Nullable
+ @SuppressWarnings("unchecked")
+ public static Object unwrap(@Nullable final Object value) {
+ if (value == null || value == YamlNull.INSTANCE) {
+ return null;
+ }
+ if (value instanceof Map) {
+ final Map original = (Map) value;
+ final Map result = new LinkedHashMap<>();
+ for (final Map.Entry entry : original.entrySet()) {
+ result.put(entry.getKey(), unwrap(entry.getValue()));
+ }
+ return result;
+ }
+ if (value instanceof List) {
+ final List original = (List) value;
+ final List result = new ArrayList<>();
+ for (final Object element : original) {
+ result.add(unwrap(element));
+ }
+ return result;
+ }
+ return value;
+ }
+
+ /**
+ * Recursively converts Java {@code null} values to the YAML null sentinel.
+ *
+ *
This method should be used after parsing YAML with SnakeYAML to ensure all null
+ * values are represented by the sentinel, making the data safe to use with
+ * {@link DynamicOps} methods that require non-null values.
+ *
+ *
Conversion Behavior
+ *
+ *
Java {@code null} is converted to {@link #NULL} sentinel
+ *
{@link Map} instances are recursively processed (values only, keys are preserved)
+ *
{@link List} instances are recursively processed
+ *
All other values are returned unchanged
+ *
+ *
+ *
Example
+ *
{@code
+ * // Parse YAML with SnakeYAML
+ * Yaml yaml = new Yaml();
+ * Object parsed = yaml.load("name: Alice\nnickname: null");
+ *
+ * // Wrap null values for use with DynamicOps
+ * Object wrapped = SnakeYamlOps.wrap(parsed);
+ *
+ * // Now safe to use with Dynamic
+ * Dynamic dynamic = new Dynamic<>(SnakeYamlOps.INSTANCE, wrapped);
+ * }
+ *
+ * @param value the value to wrap; may be {@code null}
+ * @return the value with all Java {@code null} instances replaced by the sentinel;
+ * never {@code null} (returns {@link #NULL} if input is {@code null})
+ */
+ @NotNull
+ @SuppressWarnings("unchecked")
+ public static Object wrap(@Nullable final Object value) {
+ if (value == null) {
+ return YamlNull.INSTANCE;
+ }
+ if (value instanceof Map) {
+ final Map original = (Map) value;
+ final Map result = new LinkedHashMap<>();
+ for (final Map.Entry entry : original.entrySet()) {
+ result.put(entry.getKey(), wrap(entry.getValue()));
+ }
+ return result;
+ }
+ if (value instanceof List) {
+ final List original = (List) value;
+ final List result = new ArrayList<>();
+ for (final Object element : original) {
+ result.add(wrap(element));
+ }
+ return result;
+ }
+ return value;
+ }
+
+ // ==================== Inner Classes ====================
+
+ /**
+ * Sentinel class representing the YAML null value.
+ *
+ *
This is a singleton class used to represent YAML's null value in a way that
+ * satisfies the {@link DynamicOps} contract (which requires non-null return values).
+ * The single instance is accessible via {@link SnakeYamlOps#NULL}.
+ *
+ *
This class is intentionally package-private and should not be instantiated
+ * or subclassed outside of {@link SnakeYamlOps}.
+ */
+ static final class YamlNull {
+ /**
+ * The singleton instance of the YAML null sentinel.
+ */
+ static final YamlNull INSTANCE = new YamlNull();
+
+ /**
+ * Private constructor to enforce singleton pattern.
+ */
+ private YamlNull() {
+ // Singleton
+ }
+
+ /**
+ * Returns a string representation of this null sentinel.
+ *
+ * @return the string {@code "null"}
+ */
+ @Override
+ public String toString() {
+ return "null";
+ }
+ }
}
diff --git a/aether-datafixers-codec/src/test/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOpsTest.java b/aether-datafixers-codec/src/test/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOpsTest.java
index 69b8167..32f895a 100644
--- a/aether-datafixers-codec/src/test/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOpsTest.java
+++ b/aether-datafixers-codec/src/test/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOpsTest.java
@@ -73,9 +73,10 @@ void toStringReturnsSnakeYamlOps() {
class EmptyValues {
@Test
- @DisplayName("empty() returns null")
- void emptyReturnsNull() {
- assertThat(ops.empty()).isNull();
+ @DisplayName("empty() returns NULL sentinel")
+ void emptyReturnsNullSentinel() {
+ assertThat(ops.empty()).isSameAs(SnakeYamlOps.NULL);
+ assertThat(SnakeYamlOps.isNull(ops.empty())).isTrue();
}
@Test
@@ -97,6 +98,138 @@ void emptyMapReturnsEmptyLinkedHashMap() {
}
}
+ @Nested
+ @DisplayName("Null Sentinel Utilities")
+ class NullSentinelUtilities {
+
+ @Test
+ @DisplayName("isNull() returns true for NULL sentinel")
+ void isNullReturnsTrueForSentinel() {
+ assertThat(SnakeYamlOps.isNull(SnakeYamlOps.NULL)).isTrue();
+ assertThat(SnakeYamlOps.isNull(ops.empty())).isTrue();
+ }
+
+ @Test
+ @DisplayName("isNull() returns false for other values")
+ void isNullReturnsFalseForOtherValues() {
+ assertThat(SnakeYamlOps.isNull(null)).isFalse();
+ assertThat(SnakeYamlOps.isNull("test")).isFalse();
+ assertThat(SnakeYamlOps.isNull(42)).isFalse();
+ assertThat(SnakeYamlOps.isNull(new LinkedHashMap<>())).isFalse();
+ }
+
+ @Test
+ @DisplayName("wrap() converts null to sentinel")
+ void wrapConvertsNullToSentinel() {
+ assertThat(SnakeYamlOps.wrap(null)).isSameAs(SnakeYamlOps.NULL);
+ }
+
+ @Test
+ @DisplayName("wrap() preserves non-null values")
+ void wrapPreservesNonNullValues() {
+ assertThat(SnakeYamlOps.wrap("test")).isEqualTo("test");
+ assertThat(SnakeYamlOps.wrap(42)).isEqualTo(42);
+ }
+
+ @Test
+ @DisplayName("wrap() recursively converts nulls in maps")
+ void wrapRecursivelyConvertsMaps() {
+ final Map input = new LinkedHashMap<>();
+ input.put("name", "Alice");
+ input.put("nickname", null);
+
+ final Object wrapped = SnakeYamlOps.wrap(input);
+
+ assertThat(wrapped).isInstanceOf(Map.class);
+ @SuppressWarnings("unchecked")
+ final Map result = (Map) wrapped;
+ assertThat(result.get("name")).isEqualTo("Alice");
+ assertThat(result.get("nickname")).isSameAs(SnakeYamlOps.NULL);
+ }
+
+ @Test
+ @DisplayName("wrap() recursively converts nulls in lists")
+ void wrapRecursivelyConvertsLists() {
+ final List input = new ArrayList<>();
+ input.add("first");
+ input.add(null);
+ input.add("third");
+
+ final Object wrapped = SnakeYamlOps.wrap(input);
+
+ assertThat(wrapped).isInstanceOf(List.class);
+ @SuppressWarnings("unchecked")
+ final List result = (List) wrapped;
+ assertThat(result.get(0)).isEqualTo("first");
+ assertThat(result.get(1)).isSameAs(SnakeYamlOps.NULL);
+ assertThat(result.get(2)).isEqualTo("third");
+ }
+
+ @Test
+ @DisplayName("unwrap() converts sentinel to null")
+ void unwrapConvertsSentinelToNull() {
+ assertThat(SnakeYamlOps.unwrap(SnakeYamlOps.NULL)).isNull();
+ }
+
+ @Test
+ @DisplayName("unwrap() preserves non-sentinel values")
+ void unwrapPreservesNonSentinelValues() {
+ assertThat(SnakeYamlOps.unwrap("test")).isEqualTo("test");
+ assertThat(SnakeYamlOps.unwrap(42)).isEqualTo(42);
+ assertThat(SnakeYamlOps.unwrap(null)).isNull();
+ }
+
+ @Test
+ @DisplayName("unwrap() recursively converts sentinels in maps")
+ void unwrapRecursivelyConvertsMaps() {
+ final Map input = new LinkedHashMap<>();
+ input.put("name", "Alice");
+ input.put("nickname", SnakeYamlOps.NULL);
+
+ final Object unwrapped = SnakeYamlOps.unwrap(input);
+
+ assertThat(unwrapped).isInstanceOf(Map.class);
+ @SuppressWarnings("unchecked")
+ final Map result = (Map) unwrapped;
+ assertThat(result.get("name")).isEqualTo("Alice");
+ assertThat(result.get("nickname")).isNull();
+ }
+
+ @Test
+ @DisplayName("unwrap() recursively converts sentinels in lists")
+ void unwrapRecursivelyConvertsLists() {
+ final List input = new ArrayList<>();
+ input.add("first");
+ input.add(SnakeYamlOps.NULL);
+ input.add("third");
+
+ final Object unwrapped = SnakeYamlOps.unwrap(input);
+
+ assertThat(unwrapped).isInstanceOf(List.class);
+ @SuppressWarnings("unchecked")
+ final List result = (List) unwrapped;
+ assertThat(result.get(0)).isEqualTo("first");
+ assertThat(result.get(1)).isNull();
+ assertThat(result.get(2)).isEqualTo("third");
+ }
+
+ @Test
+ @DisplayName("wrap() and unwrap() are inverse operations")
+ void wrapAndUnwrapAreInverse() {
+ final Map original = new LinkedHashMap<>();
+ original.put("name", "Alice");
+ original.put("nickname", null);
+ original.put("nested", new LinkedHashMap<>() {{
+ put("value", null);
+ }});
+
+ final Object wrapped = SnakeYamlOps.wrap(original);
+ final Object unwrapped = SnakeYamlOps.unwrap(wrapped);
+
+ assertThat(unwrapped).isEqualTo(original);
+ }
+ }
+
@Nested
@DisplayName("Type Checks")
class TypeChecks {
diff --git a/aether-datafixers-core/pom.xml b/aether-datafixers-core/pom.xml
index a85bc96..3c27b24 100644
--- a/aether-datafixers-core/pom.xml
+++ b/aether-datafixers-core/pom.xml
@@ -20,6 +20,12 @@
de.splatgames.aether.datafixersaether-datafixers-api
+
+
+
+ com.github.spotbugs
+ spotbugs-annotations
+ org.junit.jupiterjunit-jupiter
diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/bootstrap/DataFixerRuntimeFactory.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/bootstrap/DataFixerRuntimeFactory.java
index 4fdb681..3ecc882 100644
--- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/bootstrap/DataFixerRuntimeFactory.java
+++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/bootstrap/DataFixerRuntimeFactory.java
@@ -25,7 +25,6 @@
import com.google.common.base.Preconditions;
import de.splatgames.aether.datafixers.api.DataVersion;
import de.splatgames.aether.datafixers.api.bootstrap.DataFixerBootstrap;
-import de.splatgames.aether.datafixers.api.schema.SchemaRegistry;
import de.splatgames.aether.datafixers.core.AetherDataFixer;
import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder;
import de.splatgames.aether.datafixers.core.schema.SimpleSchemaRegistry;
diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticContextImpl.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticContextImpl.java
index 33f28ae..a7ca08f 100644
--- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticContextImpl.java
+++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticContextImpl.java
@@ -26,6 +26,7 @@
import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext;
import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions;
import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -90,6 +91,10 @@ public boolean isDiagnosticEnabled() {
@Override
@NotNull
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Builder exposure is intentional API design for report construction."
+ )
public MigrationReport.Builder reportBuilder() {
return this.reportBuilder;
}
@@ -261,7 +266,7 @@ public enum LogLevel {
*
* @param level the log level
* @param message the message format string
- * @param args the arguments
+ * @param args the arguments (defensively copied)
*/
public record LogEntry(
@NotNull LogLevel level,
@@ -269,6 +274,24 @@ public record LogEntry(
@Nullable Object[] args
) {
+ /**
+ * Compact constructor that defensively copies the args array.
+ */
+ public LogEntry {
+ args = args != null ? args.clone() : null;
+ }
+
+ /**
+ * Returns a defensive copy of the arguments array.
+ *
+ * @return a copy of the arguments, or {@code null} if no arguments
+ */
+ @Override
+ @Nullable
+ public Object[] args() {
+ return args != null ? args.clone() : null;
+ }
+
/**
* Returns the formatted message with placeholders replaced.
*
diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java
index b50dc22..3856ae8 100644
--- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java
+++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/MigrationReportImpl.java
@@ -35,7 +35,6 @@
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/schema/SimpleSchemaRegistry.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/schema/SimpleSchemaRegistry.java
index fec5a76..fe9ead5 100644
--- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/schema/SimpleSchemaRegistry.java
+++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/schema/SimpleSchemaRegistry.java
@@ -110,7 +110,9 @@ public Schema require(@NotNull final DataVersion version) {
Preconditions.checkNotNull(version, "version must not be null");
final Schema schema = this.get(version);
- Preconditions.checkState(schema != null, "No schema found for version: %s", version);
+ if (schema == null) {
+ throw new IllegalStateException("No schema found for version: " + version);
+ }
return schema;
}
diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/AetherDataFixerTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/AetherDataFixerTest.java
new file mode 100644
index 0000000..4d73772
--- /dev/null
+++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/AetherDataFixerTest.java
@@ -0,0 +1,358 @@
+/*
+ * 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.core;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+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.dynamic.TaggedDynamic;
+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.api.schema.Schema;
+import de.splatgames.aether.datafixers.api.type.Type;
+import de.splatgames.aether.datafixers.codec.json.gson.GsonOps;
+import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder;
+import de.splatgames.aether.datafixers.core.schema.SimpleSchemaRegistry;
+import de.splatgames.aether.datafixers.core.type.SimpleTypeRegistry;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link AetherDataFixer}.
+ */
+@DisplayName("AetherDataFixer")
+class AetherDataFixerTest {
+
+ private static final TypeReference PLAYER = new TypeReference("player");
+ private static final DataVersion VERSION_1 = new DataVersion(1);
+ private static final DataVersion VERSION_2 = new DataVersion(2);
+
+ private SimpleSchemaRegistry schemaRegistry;
+ private DataFixer dataFixer;
+ private AetherDataFixer aetherDataFixer;
+
+ @BeforeEach
+ void setUp() {
+ schemaRegistry = new SimpleSchemaRegistry();
+
+ SimpleTypeRegistry types1 = new SimpleTypeRegistry();
+ types1.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ Schema schemaV1 = new Schema(VERSION_1, types1);
+ schemaRegistry.register(schemaV1);
+
+ SimpleTypeRegistry types2 = new SimpleTypeRegistry();
+ types2.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ Schema schemaV2 = new Schema(VERSION_2, types2);
+ schemaRegistry.register(schemaV2);
+
+ schemaRegistry.freeze();
+
+ dataFixer = new DataFixerBuilder(VERSION_2)
+ .addFix(PLAYER, createIdentityFix("v1_to_v2", 1, 2))
+ .build();
+
+ aetherDataFixer = new AetherDataFixer(VERSION_2, schemaRegistry, dataFixer);
+ }
+
+ @Nested
+ @DisplayName("Constructor")
+ class Constructor {
+
+ @Test
+ @DisplayName("creates instance with valid arguments")
+ void createsInstanceWithValidArguments() {
+ AetherDataFixer fixer = new AetherDataFixer(VERSION_1, schemaRegistry, dataFixer);
+
+ assertThat(fixer.currentVersion()).isEqualTo(VERSION_1);
+ }
+
+ @Test
+ @DisplayName("rejects null currentVersion")
+ void rejectsNullCurrentVersion() {
+ assertThatThrownBy(() -> new AetherDataFixer(null, schemaRegistry, dataFixer))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("currentVersion");
+ }
+
+ @Test
+ @DisplayName("rejects null schemaRegistry")
+ void rejectsNullSchemaRegistry() {
+ assertThatThrownBy(() -> new AetherDataFixer(VERSION_1, null, dataFixer))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("schemaRegistry");
+ }
+
+ @Test
+ @DisplayName("rejects null dataFixer")
+ void rejectsNullDataFixer() {
+ assertThatThrownBy(() -> new AetherDataFixer(VERSION_1, schemaRegistry, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("dataFixer");
+ }
+ }
+
+ @Nested
+ @DisplayName("currentVersion()")
+ class CurrentVersion {
+
+ @Test
+ @DisplayName("returns configured version")
+ void returnsConfiguredVersion() {
+ assertThat(aetherDataFixer.currentVersion()).isEqualTo(VERSION_2);
+ }
+
+ @Test
+ @DisplayName("returns different version when configured differently")
+ void returnsDifferentVersionWhenConfiguredDifferently() {
+ AetherDataFixer fixer = new AetherDataFixer(VERSION_1, schemaRegistry, dataFixer);
+ assertThat(fixer.currentVersion()).isEqualTo(VERSION_1);
+ }
+ }
+
+ @Nested
+ @DisplayName("update() with TaggedDynamic")
+ class UpdateTaggedDynamic {
+
+ @Test
+ @DisplayName("updates tagged dynamic between versions")
+ void updatesTaggedDynamicBetweenVersions() {
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("name", "Bob");
+ inputObj.addProperty("level", 5);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result.type()).isEqualTo(PLAYER);
+ assertThat(result.value().get("name").asString().result()).contains("Bob");
+ }
+
+ @Test
+ @DisplayName("returns same data when versions are equal")
+ void returnsSameDataWhenVersionsAreEqual() {
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("name", "Alice");
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_1);
+
+ assertThat(result.value().get("name").asString().result()).contains("Alice");
+ }
+
+ @Test
+ @DisplayName("rejects null input")
+ void rejectsNullInput() {
+ assertThatThrownBy(() -> aetherDataFixer.update(null, VERSION_1, VERSION_2))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("input");
+ }
+
+ @Test
+ @DisplayName("rejects null fromVersion")
+ void rejectsNullFromVersion() {
+ JsonObject obj = new JsonObject();
+ TaggedDynamic input = new TaggedDynamic(PLAYER, new Dynamic<>(GsonOps.INSTANCE, obj));
+
+ assertThatThrownBy(() -> aetherDataFixer.update(input, null, VERSION_2))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("fromVersion");
+ }
+
+ @Test
+ @DisplayName("rejects null toVersion")
+ void rejectsNullToVersion() {
+ JsonObject obj = new JsonObject();
+ TaggedDynamic input = new TaggedDynamic(PLAYER, new Dynamic<>(GsonOps.INSTANCE, obj));
+
+ assertThatThrownBy(() -> aetherDataFixer.update(input, VERSION_1, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("toVersion");
+ }
+ }
+
+ @Nested
+ @DisplayName("Edge cases")
+ class EdgeCases {
+
+ @Test
+ @DisplayName("handles empty JSON object")
+ void handlesEmptyJsonObject() {
+ JsonObject emptyObj = new JsonObject();
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, emptyObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result).isNotNull();
+ }
+
+ @Test
+ @DisplayName("preserves nested structures during update")
+ void preservesNestedStructuresDuringUpdate() {
+ JsonObject nested = new JsonObject();
+ nested.addProperty("x", 10);
+ nested.addProperty("y", 20);
+
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("name", "Player");
+ inputObj.add("position", nested);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result.value().get("position").get("x").asInt().result()).contains(10);
+ assertThat(result.value().get("position").get("y").asInt().result()).contains(20);
+ }
+
+ @Test
+ @DisplayName("handles array values in input")
+ void handlesArrayValuesInInput() {
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("name", "Player");
+ JsonArray items = new JsonArray();
+ items.add("sword");
+ items.add("shield");
+ inputObj.add("inventory", items);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result.value().get("inventory").asListStream().result())
+ .isPresent();
+ }
+
+ @Test
+ @DisplayName("handles numeric values with different types")
+ void handlesNumericValuesWithDifferentTypes() {
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("intValue", 42);
+ inputObj.addProperty("longValue", 9876543210L);
+ inputObj.addProperty("doubleValue", 3.14159);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result.value().get("intValue").asInt().result()).contains(42);
+ assertThat(result.value().get("longValue").asLong().result()).contains(9876543210L);
+ assertThat(result.value().get("doubleValue").asDouble().result()).contains(3.14159);
+ }
+
+ @Test
+ @DisplayName("handles boolean values")
+ void handlesBooleanValues() {
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("active", true);
+ inputObj.addProperty("deleted", false);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result.value().get("active").asBoolean().result()).contains(true);
+ assertThat(result.value().get("deleted").asBoolean().result()).contains(false);
+ }
+
+ @Test
+ @DisplayName("handles null-like values gracefully")
+ void handlesNullLikeValuesGracefully() {
+ JsonObject inputObj = new JsonObject();
+ inputObj.add("nullField", null);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result).isNotNull();
+ }
+
+ @Test
+ @DisplayName("handles deeply nested structures")
+ void handlesDeeplyNestedStructures() {
+ JsonObject level3 = new JsonObject();
+ level3.addProperty("value", "deep");
+
+ JsonObject level2 = new JsonObject();
+ level2.add("level3", level3);
+
+ JsonObject level1 = new JsonObject();
+ level1.add("level2", level2);
+
+ JsonObject inputObj = new JsonObject();
+ inputObj.add("level1", level1);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = aetherDataFixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result.value()
+ .get("level1")
+ .get("level2")
+ .get("level3")
+ .get("value")
+ .asString().result()).contains("deep");
+ }
+ }
+
+ private DataFix createIdentityFix(String name, int from, int to) {
+ return new DataFix<>() {
+ @Override
+ public @NotNull String name() {
+ return name;
+ }
+
+ @Override
+ public @NotNull DataVersion fromVersion() {
+ return new DataVersion(from);
+ }
+
+ @Override
+ public @NotNull DataVersion toVersion() {
+ return new DataVersion(to);
+ }
+
+ @Override
+ public @NotNull Dynamic apply(
+ @NotNull TypeReference type,
+ @NotNull Dynamic input,
+ @NotNull DataFixerContext context
+ ) {
+ return input;
+ }
+ };
+ }
+}
diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/bootstrap/DataFixerRuntimeFactoryTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/bootstrap/DataFixerRuntimeFactoryTest.java
new file mode 100644
index 0000000..d5e88f7
--- /dev/null
+++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/bootstrap/DataFixerRuntimeFactoryTest.java
@@ -0,0 +1,256 @@
+/*
+ * 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.core.bootstrap;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import de.splatgames.aether.datafixers.api.DataVersion;
+import de.splatgames.aether.datafixers.api.TypeReference;
+import de.splatgames.aether.datafixers.api.bootstrap.DataFixerBootstrap;
+import de.splatgames.aether.datafixers.api.dynamic.Dynamic;
+import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic;
+import de.splatgames.aether.datafixers.api.fix.DataFix;
+import de.splatgames.aether.datafixers.api.fix.DataFixerContext;
+import de.splatgames.aether.datafixers.api.fix.FixRegistrar;
+import de.splatgames.aether.datafixers.api.schema.Schema;
+import de.splatgames.aether.datafixers.api.schema.SchemaRegistry;
+import de.splatgames.aether.datafixers.api.type.Type;
+import de.splatgames.aether.datafixers.codec.json.gson.GsonOps;
+import de.splatgames.aether.datafixers.core.AetherDataFixer;
+import de.splatgames.aether.datafixers.core.type.SimpleTypeRegistry;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link DataFixerRuntimeFactory}.
+ */
+@DisplayName("DataFixerRuntimeFactory")
+class DataFixerRuntimeFactoryTest {
+
+ private static final TypeReference PLAYER = new TypeReference("player");
+ private static final DataVersion VERSION_1 = new DataVersion(1);
+ private static final DataVersion VERSION_2 = new DataVersion(2);
+
+ private DataFixerRuntimeFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ factory = new DataFixerRuntimeFactory();
+ }
+
+ @Nested
+ @DisplayName("create()")
+ class Create {
+
+ @Test
+ @DisplayName("creates AetherDataFixer with correct current version")
+ void createsAetherDataFixerWithCorrectCurrentVersion() {
+ DataFixerBootstrap bootstrap = new TestBootstrap();
+
+ AetherDataFixer fixer = factory.create(VERSION_2, bootstrap);
+
+ assertThat(fixer.currentVersion()).isEqualTo(VERSION_2);
+ }
+
+ @Test
+ @DisplayName("creates functional fixer that can update data")
+ void createsFunctionalFixerThatCanUpdate() {
+ DataFixerBootstrap bootstrap = new TestBootstrapWithFix();
+
+ AetherDataFixer fixer = factory.create(VERSION_2, bootstrap);
+
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("name", "Bob");
+ inputObj.addProperty("level", 5);
+ Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+ TaggedDynamic input = new TaggedDynamic(PLAYER, dynamic);
+
+ TaggedDynamic result = fixer.update(input, VERSION_1, VERSION_2);
+
+ assertThat(result.type()).isEqualTo(PLAYER);
+ }
+
+ @Test
+ @DisplayName("creates fixer with frozen schema registry")
+ void createsFixerWithFrozenSchemaRegistry() {
+ DataFixerBootstrap bootstrap = new TestBootstrap();
+
+ AetherDataFixer fixer = factory.create(VERSION_2, bootstrap);
+
+ // The fixer should work correctly, indicating the schema registry was frozen
+ assertThat(fixer.currentVersion()).isEqualTo(VERSION_2);
+ }
+
+ @Test
+ @DisplayName("rejects null currentVersion")
+ void rejectsNullCurrentVersion() {
+ assertThatThrownBy(() -> factory.create(null, new TestBootstrap()))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("currentVersion");
+ }
+
+ @Test
+ @DisplayName("rejects null bootstrap")
+ void rejectsNullBootstrap() {
+ assertThatThrownBy(() -> factory.create(VERSION_1, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("bootstrap");
+ }
+ }
+
+ @Nested
+ @DisplayName("Bootstrap integration")
+ class BootstrapIntegration {
+
+ @Test
+ @DisplayName("invokes registerSchemas on bootstrap")
+ void invokesRegisterSchemasOnBootstrap() {
+ TrackingBootstrap bootstrap = new TrackingBootstrap();
+
+ factory.create(VERSION_2, bootstrap);
+
+ assertThat(bootstrap.schemasRegistered).isTrue();
+ }
+
+ @Test
+ @DisplayName("invokes registerFixes on bootstrap")
+ void invokesRegisterFixesOnBootstrap() {
+ TrackingBootstrap bootstrap = new TrackingBootstrap();
+
+ factory.create(VERSION_2, bootstrap);
+
+ assertThat(bootstrap.fixesRegistered).isTrue();
+ }
+
+ @Test
+ @DisplayName("calls registerSchemas before registerFixes")
+ void callsRegisterSchemasBeforeRegisterFixes() {
+ OrderTrackingBootstrap bootstrap = new OrderTrackingBootstrap();
+
+ factory.create(VERSION_2, bootstrap);
+
+ assertThat(bootstrap.callOrder).containsExactly("schemas", "fixes");
+ }
+ }
+
+ private static class TestBootstrap implements DataFixerBootstrap {
+ @Override
+ public void registerSchemas(@NotNull SchemaRegistry schemas) {
+ SimpleTypeRegistry types1 = new SimpleTypeRegistry();
+ types1.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ schemas.register(new Schema(VERSION_1, types1));
+
+ SimpleTypeRegistry types2 = new SimpleTypeRegistry();
+ types2.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ schemas.register(new Schema(VERSION_2, types2));
+ }
+
+ @Override
+ public void registerFixes(@NotNull FixRegistrar fixes) {
+ // No fixes for basic test
+ }
+ }
+
+ private static class TestBootstrapWithFix implements DataFixerBootstrap {
+ @Override
+ public void registerSchemas(@NotNull SchemaRegistry schemas) {
+ SimpleTypeRegistry types1 = new SimpleTypeRegistry();
+ types1.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ schemas.register(new Schema(VERSION_1, types1));
+
+ SimpleTypeRegistry types2 = new SimpleTypeRegistry();
+ types2.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ schemas.register(new Schema(VERSION_2, types2));
+ }
+
+ @Override
+ public void registerFixes(@NotNull FixRegistrar fixes) {
+ fixes.register(PLAYER, new DataFix() {
+ @Override
+ public @NotNull String name() {
+ return "test_fix";
+ }
+
+ @Override
+ public @NotNull DataVersion fromVersion() {
+ return VERSION_1;
+ }
+
+ @Override
+ public @NotNull DataVersion toVersion() {
+ return VERSION_2;
+ }
+
+ @Override
+ public @NotNull Dynamic apply(
+ @NotNull TypeReference type,
+ @NotNull Dynamic input,
+ @NotNull DataFixerContext context
+ ) {
+ return input;
+ }
+ });
+ }
+ }
+
+ private static class TrackingBootstrap implements DataFixerBootstrap {
+ boolean schemasRegistered = false;
+ boolean fixesRegistered = false;
+
+ @Override
+ public void registerSchemas(@NotNull SchemaRegistry schemas) {
+ schemasRegistered = true;
+ SimpleTypeRegistry types = new SimpleTypeRegistry();
+ schemas.register(new Schema(VERSION_1, types));
+ schemas.register(new Schema(VERSION_2, new SimpleTypeRegistry()));
+ }
+
+ @Override
+ public void registerFixes(@NotNull FixRegistrar fixes) {
+ fixesRegistered = true;
+ }
+ }
+
+ private static class OrderTrackingBootstrap implements DataFixerBootstrap {
+ java.util.List callOrder = new java.util.ArrayList<>();
+
+ @Override
+ public void registerSchemas(@NotNull SchemaRegistry schemas) {
+ callOrder.add("schemas");
+ SimpleTypeRegistry types = new SimpleTypeRegistry();
+ schemas.register(new Schema(VERSION_1, types));
+ schemas.register(new Schema(VERSION_2, new SimpleTypeRegistry()));
+ }
+
+ @Override
+ public void registerFixes(@NotNull FixRegistrar fixes) {
+ callOrder.add("fixes");
+ }
+ }
+}
diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapperTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapperTest.java
new file mode 100644
index 0000000..8fbd527
--- /dev/null
+++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapperTest.java
@@ -0,0 +1,349 @@
+/*
+ * 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.core.diagnostic;
+
+import de.splatgames.aether.datafixers.api.TypeReference;
+import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions;
+import de.splatgames.aether.datafixers.api.rewrite.Rules;
+import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule;
+import de.splatgames.aether.datafixers.api.type.Type;
+import de.splatgames.aether.datafixers.api.type.Typed;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link DiagnosticRuleWrapper}.
+ */
+@DisplayName("DiagnosticRuleWrapper")
+class DiagnosticRuleWrapperTest {
+
+ private static final TypeReference PLAYER = new TypeReference("player");
+
+ private TypeRewriteRule delegateRule;
+ private DiagnosticContextImpl context;
+ private Type testType;
+ private Typed testInput;
+
+ @BeforeEach
+ void setUp() {
+ delegateRule = Rules.noop();
+
+ context = new DiagnosticContextImpl(
+ DiagnosticOptions.builder()
+ .captureRuleDetails(true)
+ .build()
+ );
+
+ testType = Type.STRING;
+ testInput = new Typed<>(testType, "Alice");
+ }
+
+ @Nested
+ @DisplayName("Constructor")
+ class Constructor {
+
+ @Test
+ @DisplayName("creates wrapper with valid arguments")
+ void createsWrapperWithValidArguments() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThat(wrapper).isNotNull();
+ }
+
+ @Test
+ @DisplayName("rejects null delegate")
+ void rejectsNullDelegate() {
+ assertThatThrownBy(() -> new DiagnosticRuleWrapper(null, context))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("delegate");
+ }
+
+ @Test
+ @DisplayName("rejects null context")
+ void rejectsNullContext() {
+ assertThatThrownBy(() -> new DiagnosticRuleWrapper(delegateRule, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("context");
+ }
+ }
+
+ @Nested
+ @DisplayName("rewrite()")
+ class Rewrite {
+
+ @Test
+ @DisplayName("delegates to underlying rule")
+ void delegatesToUnderlyingRule() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ Optional> result = wrapper.rewrite(testType, testInput);
+
+ assertThat(result).isPresent();
+ }
+
+ @Test
+ @DisplayName("rejects null type")
+ void rejectsNullType() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.rewrite(null, testInput))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("type");
+ }
+
+ @Test
+ @DisplayName("rejects null input")
+ void rejectsNullInput() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.rewrite(testType, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("input");
+ }
+
+ @Test
+ @DisplayName("skips recording when captureRuleDetails is false")
+ void skipsRecordingWhenCaptureRuleDetailsIsFalse() {
+ DiagnosticContextImpl noDetailsContext = new DiagnosticContextImpl(
+ DiagnosticOptions.builder()
+ .captureRuleDetails(false)
+ .build()
+ );
+
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, noDetailsContext);
+
+ Optional> result = wrapper.rewrite(testType, testInput);
+
+ assertThat(result).isPresent();
+ }
+ }
+
+ @Nested
+ @DisplayName("apply()")
+ class Apply {
+
+ @Test
+ @DisplayName("applies rule and returns result")
+ void appliesRuleAndReturnsResult() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ Typed> result = wrapper.apply(testInput);
+
+ assertThat(result).isNotNull();
+ }
+
+ @Test
+ @DisplayName("rejects null input")
+ void rejectsNullInput() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.apply(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("input");
+ }
+ }
+
+ @Nested
+ @DisplayName("applyOrThrow()")
+ class ApplyOrThrow {
+
+ @Test
+ @DisplayName("applies rule when it matches")
+ void appliesRuleWhenItMatches() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ Typed> result = wrapper.applyOrThrow(testInput);
+
+ assertThat(result).isNotNull();
+ }
+
+ @Test
+ @DisplayName("rejects null input")
+ void rejectsNullInput() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.applyOrThrow(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("input");
+ }
+ }
+
+ @Nested
+ @DisplayName("Combinator Methods")
+ class CombinatorMethods {
+
+ @Test
+ @DisplayName("andThen() returns wrapped composition")
+ void andThenReturnsWrappedComposition() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+ TypeRewriteRule nextRule = Rules.noop();
+
+ TypeRewriteRule result = wrapper.andThen(nextRule);
+
+ assertThat(result).isInstanceOf(DiagnosticRuleWrapper.class);
+ }
+
+ @Test
+ @DisplayName("andThen() rejects null")
+ void andThenRejectsNull() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.andThen(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("next");
+ }
+
+ @Test
+ @DisplayName("orElse() returns wrapped composition")
+ void orElseReturnsWrappedComposition() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+ TypeRewriteRule fallback = Rules.noop();
+
+ TypeRewriteRule result = wrapper.orElse(fallback);
+
+ assertThat(result).isInstanceOf(DiagnosticRuleWrapper.class);
+ }
+
+ @Test
+ @DisplayName("orElse() rejects null")
+ void orElseRejectsNull() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.orElse(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("fallback");
+ }
+
+ @Test
+ @DisplayName("orKeep() returns wrapped rule")
+ void orKeepReturnsWrappedRule() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ TypeRewriteRule result = wrapper.orKeep();
+
+ assertThat(result).isInstanceOf(DiagnosticRuleWrapper.class);
+ }
+
+ @Test
+ @DisplayName("ifType() returns wrapped rule")
+ void ifTypeReturnsWrappedRule() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ TypeRewriteRule result = wrapper.ifType(testType);
+
+ assertThat(result).isInstanceOf(DiagnosticRuleWrapper.class);
+ }
+
+ @Test
+ @DisplayName("ifType() rejects null")
+ void ifTypeRejectsNull() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.ifType(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("targetType");
+ }
+
+ @Test
+ @DisplayName("named() returns wrapped rule")
+ void namedReturnsWrappedRule() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ TypeRewriteRule result = wrapper.named("testName");
+
+ assertThat(result).isInstanceOf(DiagnosticRuleWrapper.class);
+ }
+
+ @Test
+ @DisplayName("named() rejects null")
+ void namedRejectsNull() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ assertThatThrownBy(() -> wrapper.named(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("name");
+ }
+ }
+
+ @Nested
+ @DisplayName("toString()")
+ class ToStringMethod {
+
+ @Test
+ @DisplayName("delegates to underlying rule")
+ void delegatesToUnderlyingRule() {
+ TypeRewriteRule namedRule = Rules.noop().named("testRule");
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(namedRule, context);
+
+ String result = wrapper.toString();
+
+ assertThat(result).contains("testRule");
+ }
+ }
+
+ @Nested
+ @DisplayName("wrap()")
+ class Wrap {
+
+ @Test
+ @DisplayName("wraps non-wrapper rule")
+ void wrapsNonWrapperRule() {
+ TypeRewriteRule result = DiagnosticRuleWrapper.wrap(delegateRule, context);
+
+ assertThat(result).isInstanceOf(DiagnosticRuleWrapper.class);
+ }
+
+ @Test
+ @DisplayName("returns same instance if already wrapped")
+ void returnsSameInstanceIfAlreadyWrapped() {
+ DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context);
+
+ TypeRewriteRule result = DiagnosticRuleWrapper.wrap(wrapper, context);
+
+ assertThat(result).isSameAs(wrapper);
+ }
+
+ @Test
+ @DisplayName("rejects null rule")
+ void rejectsNullRule() {
+ assertThatThrownBy(() -> DiagnosticRuleWrapper.wrap(null, context))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("rule");
+ }
+
+ @Test
+ @DisplayName("rejects null context")
+ void rejectsNullContext() {
+ assertThatThrownBy(() -> DiagnosticRuleWrapper.wrap(delegateRule, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("context");
+ }
+ }
+}
diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/fix/SchemaDataFixTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/fix/SchemaDataFixTest.java
new file mode 100644
index 0000000..500c296
--- /dev/null
+++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/fix/SchemaDataFixTest.java
@@ -0,0 +1,227 @@
+/*
+ * 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.core.fix;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+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.rewrite.Rules;
+import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule;
+import de.splatgames.aether.datafixers.api.schema.Schema;
+import de.splatgames.aether.datafixers.api.type.Type;
+import de.splatgames.aether.datafixers.codec.json.gson.GsonOps;
+import de.splatgames.aether.datafixers.core.schema.SimpleSchemaRegistry;
+import de.splatgames.aether.datafixers.core.type.SimpleTypeRegistry;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link SchemaDataFix}.
+ */
+@DisplayName("SchemaDataFix")
+class SchemaDataFixTest {
+
+ private static final TypeReference PLAYER = new TypeReference("player");
+ private static final DataVersion VERSION_1 = new DataVersion(1);
+ private static final DataVersion VERSION_2 = new DataVersion(2);
+
+ private SimpleSchemaRegistry schemaRegistry;
+
+ @BeforeEach
+ void setUp() {
+ schemaRegistry = new SimpleSchemaRegistry();
+
+ SimpleTypeRegistry types1 = new SimpleTypeRegistry();
+ types1.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ Schema schemaV1 = new Schema(VERSION_1, types1);
+ schemaRegistry.register(schemaV1);
+
+ SimpleTypeRegistry types2 = new SimpleTypeRegistry();
+ types2.register(Type.named(PLAYER.getId(), Type.PASSTHROUGH));
+ Schema schemaV2 = new Schema(VERSION_2, types2);
+ schemaRegistry.register(schemaV2);
+
+ schemaRegistry.freeze();
+ }
+
+ @Nested
+ @DisplayName("Constructor")
+ class Constructor {
+
+ @Test
+ @DisplayName("creates fix with valid arguments")
+ void createsFixWithValidArguments() {
+ SchemaDataFix fix = new TestSchemaDataFix("test_fix", VERSION_1, VERSION_2, schemaRegistry);
+
+ assertThat(fix.name()).isEqualTo("test_fix");
+ assertThat(fix.fromVersion()).isEqualTo(VERSION_1);
+ assertThat(fix.toVersion()).isEqualTo(VERSION_2);
+ }
+
+ @Test
+ @DisplayName("rejects null name")
+ void rejectsNullName() {
+ assertThatThrownBy(() -> new TestSchemaDataFix(null, VERSION_1, VERSION_2, schemaRegistry))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("name");
+ }
+
+ @Test
+ @DisplayName("rejects null from version")
+ void rejectsNullFromVersion() {
+ assertThatThrownBy(() -> new TestSchemaDataFix("test", null, VERSION_2, schemaRegistry))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("from");
+ }
+
+ @Test
+ @DisplayName("rejects null to version")
+ void rejectsNullToVersion() {
+ assertThatThrownBy(() -> new TestSchemaDataFix("test", VERSION_1, null, schemaRegistry))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("to");
+ }
+
+ @Test
+ @DisplayName("rejects null schema registry")
+ void rejectsNullSchemaRegistry() {
+ assertThatThrownBy(() -> new TestSchemaDataFix("test", VERSION_1, VERSION_2, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("schemas");
+ }
+ }
+
+ @Nested
+ @DisplayName("Accessor methods")
+ class AccessorMethods {
+
+ @Test
+ @DisplayName("name() returns configured name")
+ void nameReturnsConfiguredName() {
+ SchemaDataFix fix = new TestSchemaDataFix("my_fix", VERSION_1, VERSION_2, schemaRegistry);
+ assertThat(fix.name()).isEqualTo("my_fix");
+ }
+
+ @Test
+ @DisplayName("fromVersion() returns configured from version")
+ void fromVersionReturnsConfiguredFromVersion() {
+ SchemaDataFix fix = new TestSchemaDataFix("test", VERSION_1, VERSION_2, schemaRegistry);
+ assertThat(fix.fromVersion()).isEqualTo(VERSION_1);
+ }
+
+ @Test
+ @DisplayName("toVersion() returns configured to version")
+ void toVersionReturnsConfiguredToVersion() {
+ SchemaDataFix fix = new TestSchemaDataFix("test", VERSION_1, VERSION_2, schemaRegistry);
+ assertThat(fix.toVersion()).isEqualTo(VERSION_2);
+ }
+ }
+
+ @Nested
+ @DisplayName("apply()")
+ class Apply {
+
+ @Test
+ @DisplayName("applies rule to input data")
+ void appliesRuleToInputData() {
+ SchemaDataFix fix = new TestSchemaDataFix("test_fix", VERSION_1, VERSION_2, schemaRegistry);
+
+ JsonObject inputObj = new JsonObject();
+ inputObj.addProperty("name", "Alice");
+ Dynamic input = new Dynamic<>(GsonOps.INSTANCE, inputObj);
+
+ @SuppressWarnings("unchecked")
+ Dynamic objInput = (Dynamic) (Dynamic>) input;
+
+ Dynamic> result = fix.apply(PLAYER, objInput, SimpleSystemDataFixerContext.INSTANCE);
+
+ assertThat(result).isNotNull();
+ }
+
+ @Test
+ @DisplayName("rejects null type")
+ void rejectsNullType() {
+ SchemaDataFix fix = new TestSchemaDataFix("test_fix", VERSION_1, VERSION_2, schemaRegistry);
+ Dynamic input = new Dynamic<>(GsonOps.INSTANCE, new JsonObject());
+
+ @SuppressWarnings("unchecked")
+ Dynamic objInput = (Dynamic) (Dynamic>) input;
+
+ assertThatThrownBy(() -> fix.apply(null, objInput, SimpleSystemDataFixerContext.INSTANCE))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("type");
+ }
+
+ @Test
+ @DisplayName("rejects null input")
+ void rejectsNullInput() {
+ SchemaDataFix fix = new TestSchemaDataFix("test_fix", VERSION_1, VERSION_2, schemaRegistry);
+
+ assertThatThrownBy(() -> fix.apply(PLAYER, null, SimpleSystemDataFixerContext.INSTANCE))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("input");
+ }
+
+ @Test
+ @DisplayName("rejects null context")
+ void rejectsNullContext() {
+ SchemaDataFix fix = new TestSchemaDataFix("test_fix", VERSION_1, VERSION_2, schemaRegistry);
+ Dynamic input = new Dynamic<>(GsonOps.INSTANCE, new JsonObject());
+
+ @SuppressWarnings("unchecked")
+ Dynamic objInput = (Dynamic) (Dynamic>) input;
+
+ assertThatThrownBy(() -> fix.apply(PLAYER, objInput, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("context");
+ }
+ }
+
+ private static class TestSchemaDataFix extends SchemaDataFix {
+
+ protected TestSchemaDataFix(
+ String name,
+ DataVersion from,
+ DataVersion to,
+ SimpleSchemaRegistry schemas
+ ) {
+ super(name, from, to, schemas);
+ }
+
+ @Override
+ protected @NotNull TypeRewriteRule makeRule(
+ @NotNull Schema inputSchema,
+ @NotNull Schema outputSchema
+ ) {
+ return Rules.noop();
+ }
+ }
+}
diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/fix/Slf4jDataFixerContextTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/fix/Slf4jDataFixerContextTest.java
new file mode 100644
index 0000000..e62ad72
--- /dev/null
+++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/fix/Slf4jDataFixerContextTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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.core.fix;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link Slf4jDataFixerContext}.
+ */
+@DisplayName("Slf4jDataFixerContext")
+class Slf4jDataFixerContextTest {
+
+ @Nested
+ @DisplayName("Constructor - Default")
+ class DefaultConstructor {
+
+ @Test
+ @DisplayName("creates context with default logger")
+ void createsContextWithDefaultLogger() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ assertThat(context.getLogger()).isNotNull();
+ // Note: Logger name may be "NOP" if no SLF4J binding is present
+ }
+ }
+
+ @Nested
+ @DisplayName("Constructor - Logger Name")
+ class LoggerNameConstructor {
+
+ @Test
+ @DisplayName("creates context with custom logger name")
+ void createsContextWithCustomLoggerName() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext("custom.logger.name");
+
+ assertThat(context.getLogger()).isNotNull();
+ // Note: Logger name may be "NOP" if no SLF4J binding is present
+ }
+
+ @Test
+ @DisplayName("rejects null logger name")
+ void rejectsNullLoggerName() {
+ assertThatThrownBy(() -> new Slf4jDataFixerContext((String) null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("loggerName");
+ }
+ }
+
+ @Nested
+ @DisplayName("Constructor - Logger Instance")
+ class LoggerInstanceConstructor {
+
+ @Test
+ @DisplayName("creates context with provided logger")
+ void createsContextWithProvidedLogger() {
+ Logger logger = LoggerFactory.getLogger("test.logger");
+
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext(logger);
+
+ assertThat(context.getLogger()).isSameAs(logger);
+ }
+
+ @Test
+ @DisplayName("rejects null logger")
+ void rejectsNullLogger() {
+ assertThatThrownBy(() -> new Slf4jDataFixerContext((Logger) null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("logger");
+ }
+ }
+
+ @Nested
+ @DisplayName("info()")
+ class InfoMethod {
+
+ @Test
+ @DisplayName("does not throw with valid message")
+ void doesNotThrowWithValidMessage() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ // Should not throw
+ context.info("Test message");
+ }
+
+ @Test
+ @DisplayName("does not throw with format arguments")
+ void doesNotThrowWithFormatArguments() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ // Should not throw
+ context.info("Test message with %s and %d", "string", 42);
+ }
+
+ @Test
+ @DisplayName("does not throw with null arguments")
+ void doesNotThrowWithNullArguments() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ // Should not throw
+ context.info("Test message");
+ context.info("Test message", (Object[]) null);
+ }
+
+ @Test
+ @DisplayName("rejects null message")
+ void rejectsNullMessage() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ assertThatThrownBy(() -> context.info(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("message");
+ }
+ }
+
+ @Nested
+ @DisplayName("warn()")
+ class WarnMethod {
+
+ @Test
+ @DisplayName("does not throw with valid message")
+ void doesNotThrowWithValidMessage() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ // Should not throw
+ context.warn("Test warning");
+ }
+
+ @Test
+ @DisplayName("does not throw with format arguments")
+ void doesNotThrowWithFormatArguments() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ // Should not throw
+ context.warn("Test warning with %s and %d", "string", 42);
+ }
+
+ @Test
+ @DisplayName("does not throw with null arguments")
+ void doesNotThrowWithNullArguments() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ // Should not throw
+ context.warn("Test warning");
+ context.warn("Test warning", (Object[]) null);
+ }
+
+ @Test
+ @DisplayName("rejects null message")
+ void rejectsNullMessage() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ assertThatThrownBy(() -> context.warn(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("message");
+ }
+ }
+
+ @Nested
+ @DisplayName("getLogger()")
+ class GetLoggerMethod {
+
+ @Test
+ @DisplayName("returns non-null logger")
+ void returnsNonNullLogger() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ assertThat(context.getLogger()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("returns same logger instance on multiple calls")
+ void returnsSameLoggerInstance() {
+ Slf4jDataFixerContext context = new Slf4jDataFixerContext();
+
+ Logger logger1 = context.getLogger();
+ Logger logger2 = context.getLogger();
+
+ assertThat(logger1).isSameAs(logger2);
+ }
+ }
+}
diff --git a/aether-datafixers-examples/pom.xml b/aether-datafixers-examples/pom.xml
index d09af88..d8fe91a 100644
--- a/aether-datafixers-examples/pom.xml
+++ b/aether-datafixers-examples/pom.xml
@@ -17,6 +17,7 @@
true
+ true
diff --git a/aether-datafixers-schema-tools/pom.xml b/aether-datafixers-schema-tools/pom.xml
index 1e21e44..4a62f3e 100644
--- a/aether-datafixers-schema-tools/pom.xml
+++ b/aether-datafixers-schema-tools/pom.xml
@@ -33,6 +33,12 @@
annotations
+
+
+ com.github.spotbugs
+ spotbugs-annotations
+
+
com.google.guava
diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/FixCoverage.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/FixCoverage.java
index 56815c8..f2f1ff5 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/FixCoverage.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/FixCoverage.java
@@ -32,7 +32,6 @@
import java.util.HashSet;
import java.util.List;
import java.util.Set;
-import java.util.stream.Collectors;
/**
* The result of analyzing DataFix coverage for schema changes.
diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationPath.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationPath.java
index 6407eb9..8ca95c0 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationPath.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationPath.java
@@ -32,7 +32,6 @@
import java.util.List;
import java.util.Optional;
import java.util.Set;
-import java.util.stream.Collectors;
/**
* The result of analyzing a migration path between versions.
diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationStep.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationStep.java
index c94550b..2912493 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationStep.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/analysis/MigrationStep.java
@@ -27,6 +27,7 @@
import de.splatgames.aether.datafixers.api.TypeReference;
import de.splatgames.aether.datafixers.api.fix.DataFix;
import de.splatgames.aether.datafixers.schematools.diff.SchemaDiff;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -401,6 +402,10 @@ public Builder fix(@Nullable final DataFix> fix) {
* @param diff the schema diff, may be {@code null}
* @return this builder for chaining
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP2",
+ justification = "SchemaDiff is immutable; storing it directly is safe."
+ )
@NotNull
public Builder schemaDiff(@Nullable final SchemaDiff diff) {
this.schemaDiff = diff;
diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/diff/TypeDiff.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/diff/TypeDiff.java
index e26d429..c48c7e0 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/diff/TypeDiff.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/diff/TypeDiff.java
@@ -25,6 +25,7 @@
import com.google.common.base.Preconditions;
import de.splatgames.aether.datafixers.api.TypeReference;
import de.splatgames.aether.datafixers.api.type.Type;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import java.util.List;
@@ -151,6 +152,10 @@ public TypeReference reference() {
*
* @return the source type, never {@code null}
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Type> is immutable; returning it directly is safe."
+ )
@NotNull
public Type> sourceType() {
return this.sourceType;
@@ -161,6 +166,10 @@ public Type> sourceType() {
*
* @return the target type, never {@code null}
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Type> is immutable; returning it directly is safe."
+ )
@NotNull
public Type> targetType() {
return this.targetType;
@@ -190,7 +199,7 @@ public List addedFields() {
/**
* Returns only the removed fields.
- *
+ *f
* @return a list of field diffs with kind REMOVED, never {@code null}
*/
@NotNull
diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/FieldInfo.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/FieldInfo.java
index d0b6b7a..c29b195 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/FieldInfo.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/introspection/FieldInfo.java
@@ -24,6 +24,7 @@
import com.google.common.base.Preconditions;
import de.splatgames.aether.datafixers.api.type.Type;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
@@ -194,6 +195,10 @@ public boolean isOptional() {
*
* @return the field type, never {@code null}
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Type> is immutable; returning it directly is safe."
+ )
@NotNull
public Type> fieldType() {
return this.fieldType;
diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/ValidationResult.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/ValidationResult.java
index 67378e1..f6609c5 100644
--- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/ValidationResult.java
+++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/ValidationResult.java
@@ -26,9 +26,7 @@
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
-import java.util.stream.Collectors;
/**
* The result of a schema validation operation.
diff --git a/aether-datafixers-spring-boot-starter/pom.xml b/aether-datafixers-spring-boot-starter/pom.xml
index 59965d4..9db523b 100644
--- a/aether-datafixers-spring-boot-starter/pom.xml
+++ b/aether-datafixers-spring-boot-starter/pom.xml
@@ -123,6 +123,12 @@
annotations
+
+
+ com.github.spotbugs
+ spotbugs-annotations
+
+
org.springframework.boot
diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java
index 2aaaa99..c609e00 100644
--- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java
+++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java
@@ -25,6 +25,7 @@
import com.google.common.base.Preconditions;
import de.splatgames.aether.datafixers.spring.config.DataFixerDomainProperties;
import de.splatgames.aether.datafixers.spring.config.DynamicOpsFormat;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -235,6 +236,10 @@ public void setDefaultCurrentVersion(@Nullable final Integer defaultCurrentVersi
*
* @return the mutable domain configuration map, never {@code null}
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Spring @ConfigurationProperties requires mutable getters for property binding."
+ )
@NotNull
public Map getDomains() {
return this.domains;
@@ -258,6 +263,10 @@ public void setDomains(@NotNull final Map dom
*
* @return the actuator properties, never {@code null}
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Spring @ConfigurationProperties requires mutable getters for property binding."
+ )
@NotNull
public ActuatorProperties getActuator() {
return this.actuator;
@@ -281,6 +290,10 @@ public void setActuator(@NotNull final ActuatorProperties actuator) {
*
* @return the metrics properties, never {@code null}
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Spring @ConfigurationProperties requires mutable getters for property binding."
+ )
@NotNull
public MetricsProperties getMetrics() {
return this.metrics;
diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java
index 7900904..05e29ec 100644
--- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java
+++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java
@@ -284,6 +284,13 @@ public DomainDetails domainDetails(@Selector final String domain) {
* @since 0.4.0
*/
public record DataFixersSummary(Map domains) {
+
+ /**
+ * Compact constructor that creates a defensive copy of the domains map.
+ */
+ public DataFixersSummary {
+ domains = domains != null ? Map.copyOf(domains) : Map.of();
+ }
}
/**
diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/DataFixerAutoConfiguration.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/DataFixerAutoConfiguration.java
index 82c8e62..ffa37f6 100644
--- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/DataFixerAutoConfiguration.java
+++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/DataFixerAutoConfiguration.java
@@ -28,10 +28,10 @@
import de.splatgames.aether.datafixers.core.bootstrap.DataFixerRuntimeFactory;
import de.splatgames.aether.datafixers.spring.AetherDataFixersProperties;
import de.splatgames.aether.datafixers.spring.config.DataFixerDomainProperties;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -351,6 +351,10 @@ private static AetherDataFixer createFixer(
* @return the resolved DataVersion
* @throws IllegalStateException if version cannot be determined from any source
*/
+ @SuppressFBWarnings(
+ value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
+ justification = "Null check for getCurrentVersion() is performed before access on line 363."
+ )
@NotNull
private static DataVersion resolveVersion(
@NotNull final DataFixerBootstrap bootstrap,
diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java
index 0adcd70..088f7bb 100644
--- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java
+++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java
@@ -29,6 +29,7 @@
import de.splatgames.aether.datafixers.core.AetherDataFixer;
import de.splatgames.aether.datafixers.spring.autoconfigure.DataFixerRegistry;
import de.splatgames.aether.datafixers.spring.metrics.MigrationMetrics;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
@@ -103,6 +104,7 @@
* @see MigrationMetrics
* @since 0.4.0
*/
+@SuppressWarnings("checkstyle:JavadocType")
public class DefaultMigrationService implements MigrationService {
/**
@@ -296,7 +298,12 @@ private class DefaultMigrationRequestBuilder implements MigrationRequestBuilder
/**
* Optional custom DynamicOps implementation.
+ * Reserved for future use when custom ops support is implemented.
*/
+ @SuppressFBWarnings(
+ value = "URF_UNREAD_FIELD",
+ justification = "Field is part of the public API (withOps method); implementation pending."
+ )
@Nullable
private DynamicOps> ops;
diff --git a/aether-datafixers-testkit/pom.xml b/aether-datafixers-testkit/pom.xml
index 8adc223..9a6721d 100644
--- a/aether-datafixers-testkit/pom.xml
+++ b/aether-datafixers-testkit/pom.xml
@@ -36,6 +36,12 @@
annotations
+
+
+ com.github.spotbugs
+ spotbugs-annotations
+
+
com.google.guava
diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/TestDataListBuilder.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/TestDataListBuilder.java
index c4f8a0e..560b493 100644
--- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/TestDataListBuilder.java
+++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/TestDataListBuilder.java
@@ -30,7 +30,6 @@
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
-import java.util.stream.Stream;
/**
* A fluent builder for creating {@link Dynamic} list values.
diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssert.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssert.java
index f65919d..c3784b9 100644
--- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssert.java
+++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssert.java
@@ -23,6 +23,7 @@
package de.splatgames.aether.datafixers.testkit.assertion;
import de.splatgames.aether.datafixers.api.dynamic.Dynamic;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.assertj.core.api.AbstractAssert;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -283,6 +284,10 @@ public DynamicAssert hasOnlyFields(@NotNull final String... keys) {
* @param expected the expected value
* @return this assertion for chaining
*/
+ @SuppressFBWarnings(
+ value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
+ justification = "Objects.equals() safely handles null values"
+ )
@NotNull
public DynamicAssert hasStringField(@NotNull final String key, @NotNull final String expected) {
this.hasField(key);
@@ -302,6 +307,10 @@ public DynamicAssert hasStringField(@NotNull final String key, @NotNull final
* @param expected the expected value
* @return this assertion for chaining
*/
+ @SuppressFBWarnings(
+ value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
+ justification = "Objects.equals() safely handles null values"
+ )
@NotNull
public DynamicAssert hasIntField(@NotNull final String key, final int expected) {
this.hasField(key);
@@ -321,6 +330,10 @@ public DynamicAssert hasIntField(@NotNull final String key, final int expecte
* @param expected the expected value
* @return this assertion for chaining
*/
+ @SuppressFBWarnings(
+ value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
+ justification = "Objects.equals() safely handles null values"
+ )
@NotNull
public DynamicAssert hasLongField(@NotNull final String key, final long expected) {
this.hasField(key);
@@ -341,6 +354,10 @@ public DynamicAssert hasLongField(@NotNull final String key, final long expec
* @param epsilon the tolerance
* @return this assertion for chaining
*/
+ @SuppressFBWarnings(
+ value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
+ justification = "Null check via short-circuit evaluation prevents NPE"
+ )
@NotNull
public DynamicAssert hasDoubleField(@NotNull final String key, final double expected, final double epsilon) {
this.hasField(key);
@@ -360,6 +377,10 @@ public DynamicAssert hasDoubleField(@NotNull final String key, final double e
* @param expected the expected value
* @return this assertion for chaining
*/
+ @SuppressFBWarnings(
+ value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
+ justification = "Objects.equals() safely handles null values"
+ )
@NotNull
public DynamicAssert hasBooleanField(@NotNull final String key, final boolean expected) {
this.hasField(key);
@@ -499,7 +520,7 @@ public DynamicAssert atPath(@NotNull final String dotPath) {
dotPath, part, currentPath, this.fieldsOf(current));
}
current = current.get(part);
- if (currentPath.length() > 0) {
+ if (!currentPath.isEmpty()) {
currentPath.append(".");
}
currentPath.append(part);
@@ -673,11 +694,21 @@ private String pathInfo() {
}
private String describeActual() {
- if (this.actual.isMap()) return "map";
- if (this.actual.isList()) return "list";
- if (this.actual.isString()) return "string: " + this.actual.asString().orElse("?");
- if (this.actual.isNumber()) return "number: " + this.actual.asNumber().orElse(null);
- if (this.actual.isBoolean()) return "boolean: " + this.actual.asBoolean().orElse(null);
+ if (this.actual.isMap()) {
+ return "map";
+ }
+ if (this.actual.isList()) {
+ return "list";
+ }
+ if (this.actual.isString()) {
+ return "string: " + this.actual.asString().orElse("?");
+ }
+ if (this.actual.isNumber()) {
+ return "number: " + this.actual.asNumber().orElse(null);
+ }
+ if (this.actual.isBoolean()) {
+ return "boolean: " + this.actual.asBoolean().orElse(null);
+ }
return "unknown: " + this.actual.value();
}
diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/context/RecordingContext.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/context/RecordingContext.java
index 5cd0cda..2f3e8b4 100644
--- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/context/RecordingContext.java
+++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/context/RecordingContext.java
@@ -228,7 +228,7 @@ public enum LogLevel {
*
* @param level the log level
* @param message the message format string
- * @param args the arguments (may be null)
+ * @param args the arguments (defensively copied)
*/
public record LogEntry(
@NotNull LogLevel level,
@@ -236,9 +236,24 @@ public record LogEntry(
@Nullable Object[] args
) {
+ /**
+ * Compact constructor that defensively copies the args array.
+ */
public LogEntry {
Preconditions.checkNotNull(level, "level must not be null");
Preconditions.checkNotNull(message, "message must not be null");
+ args = args != null ? args.clone() : null;
+ }
+
+ /**
+ * Returns a defensive copy of the arguments array.
+ *
+ * @return a copy of the arguments, or {@code null} if no arguments
+ */
+ @Override
+ @Nullable
+ public Object[] args() {
+ return args != null ? args.clone() : null;
}
/**
diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/DataFixTester.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/DataFixTester.java
index 03dcf28..4b86255 100644
--- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/DataFixTester.java
+++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/DataFixTester.java
@@ -29,6 +29,7 @@
import de.splatgames.aether.datafixers.api.fix.DataFixerContext;
import de.splatgames.aether.datafixers.testkit.context.AssertingContext;
import de.splatgames.aether.datafixers.testkit.context.RecordingContext;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -320,6 +321,10 @@ public Dynamic result() {
*
* @return the RecordingContext, or null if not using recording
*/
+ @SuppressFBWarnings(
+ value = "EI_EXPOSE_REP",
+ justification = "Context exposure is intentional API design for test verification."
+ )
@Nullable
public RecordingContext context() {
return this.context;
diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/MigrationTester.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/MigrationTester.java
index 726a30f..63140a6 100644
--- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/MigrationTester.java
+++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/MigrationTester.java
@@ -26,12 +26,10 @@
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.dynamic.DynamicOps;
import de.splatgames.aether.datafixers.api.fix.DataFix;
import de.splatgames.aether.datafixers.api.fix.DataFixer;
import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder;
import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import java.util.function.Consumer;
diff --git a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/SchemaTester.java b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/SchemaTester.java
index 1f7e1df..33b3343 100644
--- a/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/SchemaTester.java
+++ b/aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/harness/SchemaTester.java
@@ -27,6 +27,7 @@
import de.splatgames.aether.datafixers.api.TypeReference;
import de.splatgames.aether.datafixers.api.schema.Schema;
import de.splatgames.aether.datafixers.api.type.Type;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -348,6 +349,10 @@ public SchemaTester parentHasVersion(final int parentVersion) {
* @return this tester for chaining (allows further assertions)
* @throws AssertionError if any validation fails
*/
+ @SuppressFBWarnings(
+ value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
+ justification = "Parent null check is performed via hasParent boolean before access."
+ )
@NotNull
public SchemaTester verify() {
// Validate version
diff --git a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/TestDataTest.java b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/TestDataTest.java
index a493ab5..7f03785 100644
--- a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/TestDataTest.java
+++ b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/TestDataTest.java
@@ -22,9 +22,12 @@
package de.splatgames.aether.datafixers.testkit;
+import com.fasterxml.jackson.databind.JsonNode;
import com.google.gson.JsonElement;
import de.splatgames.aether.datafixers.api.dynamic.Dynamic;
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.snakeyaml.SnakeYamlOps;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -245,4 +248,354 @@ void worksWithGsonOps() {
assertThat(dynamic.ops()).isSameAs(GsonOps.INSTANCE);
}
}
+
+ @Nested
+ @DisplayName("Format-specific factory methods")
+ class FormatSpecificFactoryMethods {
+
+ @Test
+ @DisplayName("jacksonJson() creates Jackson JSON builder")
+ void jacksonJsonCreatesJacksonJsonBuilder() {
+ final Dynamic dynamic = TestData.jacksonJson().object()
+ .put("key", "value")
+ .build();
+
+ assertThat(dynamic.get("key").asString().result()).hasValue("value");
+ assertThat(dynamic.ops()).isSameAs(JacksonJsonOps.INSTANCE);
+ }
+
+ @Test
+ @DisplayName("jackson() creates Jackson JSON builder (deprecated)")
+ @SuppressWarnings("deprecation")
+ void jacksonCreatesJacksonJsonBuilder() {
+ final Dynamic dynamic = TestData.jackson().object()
+ .put("key", "value")
+ .build();
+
+ assertThat(dynamic.get("key").asString().result()).hasValue("value");
+ }
+
+ @Test
+ @DisplayName("snakeYaml() creates SnakeYAML builder")
+ void snakeYamlCreatesSnakeYamlBuilder() {
+ final Dynamic dynamic = TestData.snakeYaml().object()
+ .put("key", "value")
+ .build();
+
+ assertThat(dynamic.get("key").asString().result()).hasValue("value");
+ assertThat(dynamic.ops()).isSameAs(SnakeYamlOps.INSTANCE);
+ }
+
+ @Test
+ @DisplayName("jacksonYaml() creates Jackson YAML builder")
+ void jacksonYamlCreatesJacksonYamlBuilder() {
+ final Dynamic dynamic = TestData.jacksonYaml().object()
+ .put("key", "value")
+ .build();
+
+ assertThat(dynamic.get("key").asString().result()).hasValue("value");
+ }
+
+ @Test
+ @DisplayName("jacksonToml() creates Jackson TOML builder")
+ void jacksonTomlCreatesJacksonTomlBuilder() {
+ final Dynamic dynamic = TestData.jacksonToml().object()
+ .put("key", "value")
+ .build();
+
+ assertThat(dynamic.get("key").asString().result()).hasValue("value");
+ }
+
+ @Test
+ @DisplayName("jacksonXml() creates Jackson XML builder")
+ void jacksonXmlCreatesJacksonXmlBuilder() {
+ final Dynamic dynamic = TestData.jacksonXml().object()
+ .put("key", "value")
+ .build();
+
+ assertThat(dynamic.get("key").asString().result()).hasValue("value");
+ }
+ }
+
+ @Nested
+ @DisplayName("Static primitive helpers with explicit ops")
+ class StaticPrimitiveHelpersWithOps {
+
+ @Test
+ @DisplayName("string() with ops creates string")
+ void stringWithOpsCreatesString() {
+ final Dynamic dynamic = TestData.string(GsonOps.INSTANCE, "hello");
+
+ assertThat(dynamic.asString().result()).hasValue("hello");
+ }
+
+ @Test
+ @DisplayName("integer() with ops creates integer")
+ void integerWithOpsCreatesInteger() {
+ final Dynamic dynamic = TestData.integer(GsonOps.INSTANCE, 42);
+
+ assertThat(dynamic.asInt().result()).hasValue(42);
+ }
+
+ @Test
+ @DisplayName("longValue() with ops creates long")
+ void longValueWithOpsCreatesLong() {
+ final Dynamic dynamic = TestData.longValue(GsonOps.INSTANCE, 9876543210L);
+
+ assertThat(dynamic.asLong().result()).hasValue(9876543210L);
+ }
+
+ @Test
+ @DisplayName("doubleValue() with ops creates double")
+ void doubleValueWithOpsCreatesDouble() {
+ final Dynamic dynamic = TestData.doubleValue(GsonOps.INSTANCE, 3.14159);
+
+ assertThat(dynamic.asDouble().result()).hasValue(3.14159);
+ }
+
+ @Test
+ @DisplayName("bool() with ops creates boolean")
+ void boolWithOpsCreatesBoolean() {
+ final Dynamic trueValue = TestData.bool(GsonOps.INSTANCE, true);
+ final Dynamic falseValue = TestData.bool(GsonOps.INSTANCE, false);
+
+ assertThat(trueValue.asBoolean().result()).hasValue(true);
+ assertThat(falseValue.asBoolean().result()).hasValue(false);
+ }
+
+ @Test
+ @DisplayName("emptyMap() with ops creates empty map")
+ void emptyMapWithOpsCreatesEmptyMap() {
+ final Dynamic dynamic = TestData.emptyMap(GsonOps.INSTANCE);
+
+ assertThat(dynamic.value()).isEqualTo(GsonOps.INSTANCE.emptyMap());
+ }
+
+ @Test
+ @DisplayName("emptyList() with ops creates empty list")
+ void emptyListWithOpsCreatesEmptyList() {
+ final Dynamic dynamic = TestData.emptyList(GsonOps.INSTANCE);
+
+ assertThat(dynamic.value()).isEqualTo(GsonOps.INSTANCE.emptyList());
+ }
+ }
+
+ @Nested
+ @DisplayName("TestDataListBuilder additional methods")
+ class TestDataListBuilderMethods {
+
+ @Test
+ @DisplayName("add(long) adds long values")
+ void addLongAddsLongValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add(9999999999L)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asLong().result().orElse(0L))
+ .toList();
+ assertThat(values).containsExactly(9999999999L);
+ }
+
+ @Test
+ @DisplayName("add(double) adds double values")
+ void addDoubleAddsDoubleValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add(3.14)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asDouble().result().orElse(0.0))
+ .toList();
+ assertThat(values).hasSize(1);
+ assertThat(values.get(0)).isCloseTo(3.14, org.assertj.core.data.Offset.offset(0.001));
+ }
+
+ @Test
+ @DisplayName("add(boolean) adds boolean values")
+ void addBoolAddsBooleanValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add(true)
+ .add(false)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asBoolean().result().orElse(null))
+ .toList();
+ assertThat(values).containsExactly(true, false);
+ }
+
+ @Test
+ @DisplayName("addList() nests lists")
+ void addListNestsLists() {
+ final Dynamic dynamic = TestData.gson().list()
+ .addList(inner -> inner.add(1).add(2).add(3))
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .toList();
+ assertThat(values).hasSize(1);
+
+ final var innerValues = values.get(0).asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asInt().result().orElse(0))
+ .toList();
+ assertThat(innerValues).containsExactly(1, 2, 3);
+ }
+
+ @Test
+ @DisplayName("add() with Dynamic inlines values")
+ void addWithDynamicInlinesValues() {
+ final Dynamic existing = TestData.gson().object()
+ .put("nested", "value")
+ .build();
+
+ final Dynamic dynamic = TestData.gson().list()
+ .add(existing)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .toList();
+ assertThat(values).hasSize(1);
+ assertThat(values.get(0).get("nested").asString().result()).hasValue("value");
+ }
+
+ @Test
+ @DisplayName("add(float) adds float values")
+ void addFloatAddsFloatValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add(2.5f)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asDouble().result().orElse(0.0))
+ .toList();
+ assertThat(values).hasSize(1);
+ assertThat(values.get(0)).isCloseTo(2.5, org.assertj.core.data.Offset.offset(0.001));
+ }
+
+ @Test
+ @DisplayName("add(byte) adds byte values")
+ void addByteAddsByteValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add((byte) 127)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asInt().result().orElse(0))
+ .toList();
+ assertThat(values).containsExactly(127);
+ }
+
+ @Test
+ @DisplayName("add(short) adds short values")
+ void addShortAddsShortValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add((short) 32000)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asInt().result().orElse(0))
+ .toList();
+ assertThat(values).containsExactly(32000);
+ }
+
+ @Test
+ @DisplayName("addAll(long...) adds multiple long values")
+ void addAllLongsAddsMultipleLongValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .addAll(1L, 2L, 3L)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asLong().result().orElse(0L))
+ .toList();
+ assertThat(values).containsExactly(1L, 2L, 3L);
+ }
+
+ @Test
+ @DisplayName("addAll(double...) adds multiple double values")
+ void addAllDoublesAddsMultipleDoubleValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .addAll(1.0, 2.0, 3.0)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asDouble().result().orElse(0.0))
+ .toList();
+ assertThat(values).containsExactly(1.0, 2.0, 3.0);
+ }
+
+ @Test
+ @DisplayName("addAll(boolean...) adds multiple boolean values")
+ void addAllBooleansAddsMultipleBooleanValues() {
+ final Dynamic dynamic = TestData.gson().list()
+ .addAll(true, false, true)
+ .build();
+
+ final var values = dynamic.asListStream().result()
+ .orElse(java.util.stream.Stream.empty())
+ .map(d -> d.asBoolean().result().orElse(null))
+ .toList();
+ assertThat(values).containsExactly(true, false, true);
+ }
+
+ @Test
+ @DisplayName("size() returns correct count")
+ void sizeReturnsCorrectCount() {
+ final var builder = new TestDataListBuilder<>(GsonOps.INSTANCE);
+ assertThat(builder.size()).isZero();
+
+ builder.add("one");
+ assertThat(builder.size()).isEqualTo(1);
+
+ builder.add(2);
+ assertThat(builder.size()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("isEmpty() returns correct state")
+ void isEmptyReturnsCorrectState() {
+ final var builder = new TestDataListBuilder<>(GsonOps.INSTANCE);
+ assertThat(builder.isEmpty()).isTrue();
+
+ builder.add("value");
+ assertThat(builder.isEmpty()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("TestDataBuilder additional methods")
+ class TestDataBuilderMethods {
+
+ @Test
+ @DisplayName("put() with long value")
+ void putWithLongValue() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("timestamp", 1234567890123L)
+ .build();
+
+ assertThat(dynamic.get("timestamp").asLong().result()).hasValue(1234567890123L);
+ }
+
+ @Test
+ @DisplayName("put() with double value")
+ void putWithDoubleValue() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("score", 99.99)
+ .build();
+
+ assertThat(dynamic.get("score").asDouble().result()).hasValue(99.99);
+ }
+ }
}
diff --git a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/assertion/DataResultAssertTest.java b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/assertion/DataResultAssertTest.java
new file mode 100644
index 0000000..9413848
--- /dev/null
+++ b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/assertion/DataResultAssertTest.java
@@ -0,0 +1,363 @@
+/*
+ * 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.testkit.assertion;
+
+import de.splatgames.aether.datafixers.api.result.DataResult;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("DataResultAssert")
+class DataResultAssertTest {
+
+ @Nested
+ @DisplayName("Status Assertions")
+ class StatusAssertions {
+
+ @Test
+ @DisplayName("isSuccess passes for success result")
+ void isSuccessPassesForSuccessResult() {
+ final DataResult result = DataResult.success("value");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(assertion::isSuccess).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("isSuccess fails for error result")
+ void isSuccessFailsForErrorResult() {
+ final DataResult result = DataResult.error("error message");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(assertion::isSuccess)
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Expected success")
+ .hasMessageContaining("error message");
+ }
+
+ @Test
+ @DisplayName("isError passes for error result")
+ void isErrorPassesForErrorResult() {
+ final DataResult result = DataResult.error("error message");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(assertion::isError).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("isError fails for success result")
+ void isErrorFailsForSuccessResult() {
+ final DataResult result = DataResult.success("value");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(assertion::isError)
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Expected error")
+ .hasMessageContaining("value");
+ }
+
+ @Test
+ @DisplayName("hasPartialResult passes when partial exists")
+ void hasPartialResultPassesWhenPartialExists() {
+ final DataResult result = DataResult.error("error", "partial");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(assertion::hasPartialResult).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasPartialResult fails when no partial")
+ void hasPartialResultFailsWhenNoPartial() {
+ final DataResult result = DataResult.error("error");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(assertion::hasPartialResult)
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Expected partial result");
+ }
+
+ @Test
+ @DisplayName("hasNoPartialResult passes when no partial")
+ void hasNoPartialResultPassesWhenNoPartial() {
+ final DataResult result = DataResult.error("error");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(assertion::hasNoPartialResult).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasNoPartialResult fails when partial exists")
+ void hasNoPartialResultFailsWhenPartialExists() {
+ final DataResult result = DataResult.error("error", "partial");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(assertion::hasNoPartialResult)
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Expected no partial result");
+ }
+ }
+
+ @Nested
+ @DisplayName("Value Assertions")
+ class ValueAssertions {
+
+ @Test
+ @DisplayName("hasValue passes for matching value")
+ void hasValuePassesForMatchingValue() {
+ final DataResult result = DataResult.success("expected");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasValue("expected"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasValue fails for non-matching value")
+ void hasValueFailsForNonMatchingValue() {
+ final DataResult result = DataResult.success("actual");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(() -> assertion.hasValue("expected"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("expected")
+ .hasMessageContaining("actual");
+ }
+
+ @Test
+ @DisplayName("hasValueSatisfying passes when condition met")
+ void hasValueSatisfyingPassesWhenConditionMet() {
+ final DataResult result = DataResult.success(42);
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasValueSatisfying(v ->
+ assertThat(v).isGreaterThan(0)))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasPartialValue passes for matching partial")
+ void hasPartialValuePassesForMatchingPartial() {
+ final DataResult result = DataResult.error("error", "partial");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasPartialValue("partial"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasPartialValue fails for non-matching partial")
+ void hasPartialValueFailsForNonMatchingPartial() {
+ final DataResult result = DataResult.error("error", "actual");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(() -> assertion.hasPartialValue("expected"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("expected")
+ .hasMessageContaining("actual");
+ }
+ }
+
+ @Nested
+ @DisplayName("Error Message Assertions")
+ class ErrorMessageAssertions {
+
+ @Test
+ @DisplayName("hasErrorMessage passes for exact match")
+ void hasErrorMessagePassesForExactMatch() {
+ final DataResult result = DataResult.error("exact error");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasErrorMessage("exact error"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasErrorMessage fails for mismatch")
+ void hasErrorMessageFailsForMismatch() {
+ final DataResult result = DataResult.error("actual error");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(() -> assertion.hasErrorMessage("expected error"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("expected error")
+ .hasMessageContaining("actual error");
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageContaining passes when substring found")
+ void hasErrorMessageContainingPassesWhenSubstringFound() {
+ final DataResult result = DataResult.error("full error message");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasErrorMessageContaining("error"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageContaining fails when substring not found")
+ void hasErrorMessageContainingFailsWhenSubstringNotFound() {
+ final DataResult result = DataResult.error("full message");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(() -> assertion.hasErrorMessageContaining("error"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("contain")
+ .hasMessageContaining("error");
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageStartingWith passes for matching prefix")
+ void hasErrorMessageStartingWithPassesForMatchingPrefix() {
+ final DataResult result = DataResult.error("Error: something happened");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasErrorMessageStartingWith("Error:"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageStartingWith fails for non-matching prefix")
+ void hasErrorMessageStartingWithFailsForNonMatchingPrefix() {
+ final DataResult result = DataResult.error("Something happened");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(() -> assertion.hasErrorMessageStartingWith("Error:"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("start with");
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageEndingWith passes for matching suffix")
+ void hasErrorMessageEndingWithPassesForMatchingSuffix() {
+ final DataResult result = DataResult.error("Field 'name' is required");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasErrorMessageEndingWith("required"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageEndingWith fails for non-matching suffix")
+ void hasErrorMessageEndingWithFailsForNonMatchingSuffix() {
+ final DataResult result = DataResult.error("Field is missing");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(() -> assertion.hasErrorMessageEndingWith("required"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("end with");
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageMatching passes for matching regex")
+ void hasErrorMessageMatchingPassesForMatchingRegex() {
+ final DataResult result = DataResult.error("Error code 404");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.hasErrorMessageMatching(".*\\d{3}.*"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasErrorMessageMatching fails for non-matching regex")
+ void hasErrorMessageMatchingFailsForNonMatchingRegex() {
+ final DataResult result = DataResult.error("Error occurred");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatThrownBy(() -> assertion.hasErrorMessageMatching(".*\\d{3}.*"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("match pattern");
+ }
+ }
+
+ @Nested
+ @DisplayName("Extraction Assertions")
+ class ExtractionAssertions {
+
+ @Test
+ @DisplayName("extractingValue allows further assertions")
+ void extractingValueAllowsFurtherAssertions() {
+ final DataResult result = DataResult.success("test value");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.extractingValue()
+ .isEqualTo("test value"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("extractingError allows further assertions")
+ void extractingErrorAllowsFurtherAssertions() {
+ final DataResult result = DataResult.error("test error");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.extractingError()
+ .contains("test"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("extractingPartial allows further assertions")
+ void extractingPartialAllowsFurtherAssertions() {
+ final DataResult result = DataResult.error("error", "partial value");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.extractingPartial()
+ .isEqualTo("partial value"))
+ .doesNotThrowAnyException();
+ }
+ }
+
+ @Nested
+ @DisplayName("Chaining")
+ class Chaining {
+
+ @Test
+ @DisplayName("assertions can be chained")
+ void assertionsCanBeChained() {
+ final DataResult result = DataResult.success("value");
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion
+ .isSuccess()
+ .hasValue("value")
+ .hasNoPartialResult())
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("satisfies allows custom validation")
+ void satisfiesAllowsCustomValidation() {
+ final DataResult result = DataResult.success(42);
+ final DataResultAssert assertion = new DataResultAssert<>(result);
+
+ assertThatCode(() -> assertion.satisfies(r -> {
+ assertThat(r.isSuccess()).isTrue();
+ assertThat(r.result().orElse(0)).isEqualTo(42);
+ })).doesNotThrowAnyException();
+ }
+ }
+}
diff --git a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssertTest.java b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssertTest.java
index 48c4d65..53c71c7 100644
--- a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssertTest.java
+++ b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/assertion/DynamicAssertTest.java
@@ -340,4 +340,261 @@ void multipleAssertionsCanBeChained() {
.hasSize(2);
}
}
+
+ @Nested
+ @DisplayName("Failure paths")
+ class FailurePaths {
+
+ @Test
+ @DisplayName("isList fails for non-lists")
+ void isListFailsForNonLists() {
+ final Dynamic dynamic = TestData.gson().string("hello");
+
+ assertThatThrownBy(() -> assertThat(dynamic).isList())
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("isString fails for non-strings")
+ void isStringFailsForNonStrings() {
+ final Dynamic dynamic = TestData.gson().integer(42);
+
+ assertThatThrownBy(() -> assertThat(dynamic).isString())
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("isNumber fails for non-numbers")
+ void isNumberFailsForNonNumbers() {
+ final Dynamic dynamic = TestData.gson().string("hello");
+
+ assertThatThrownBy(() -> assertThat(dynamic).isNumber())
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("isBoolean fails for non-booleans")
+ void isBooleanFailsForNonBooleans() {
+ final Dynamic dynamic = TestData.gson().string("hello");
+
+ assertThatThrownBy(() -> assertThat(dynamic).isBoolean())
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("doesNotHaveField fails when field exists")
+ void doesNotHaveFieldFailsWhenFieldExists() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("name", "Alice")
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).doesNotHaveField("name"))
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("hasStringField fails for wrong value")
+ void hasStringFieldFailsForWrongValue() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("name", "Bob")
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).hasStringField("name", "Alice"))
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("hasIntField fails for wrong value")
+ void hasIntFieldFailsForWrongValue() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("age", 25)
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).hasIntField("age", 30))
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("hasSize fails for wrong size")
+ void hasSizeFailsForWrongSize() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add(1).add(2)
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).hasSize(5))
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("isNotEmpty fails for empty list")
+ void isNotEmptyFailsForEmptyList() {
+ final Dynamic dynamic = TestData.gson().list().build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).isNotEmpty())
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("atIndex fails for out of bounds")
+ void atIndexFailsForOutOfBounds() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add("one")
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).atIndex(5))
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("atPath fails for missing path")
+ void atPathFailsForMissingPath() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("name", "Alice")
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).atPath("missing.path"))
+ .isInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ @DisplayName("isEqualTo fails for different values")
+ void isEqualToFailsForDifferentValues() {
+ final Dynamic d1 = TestData.gson().object()
+ .put("key", "value1")
+ .build();
+ final Dynamic d2 = TestData.gson().object()
+ .put("key", "value2")
+ .build();
+
+ assertThatThrownBy(() -> assertThat(d1).isEqualTo(d2))
+ .isInstanceOf(AssertionError.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("Additional field assertions")
+ class AdditionalFieldAssertions {
+
+ @Test
+ @DisplayName("hasFields passes when all fields exist")
+ void hasFieldsPassesWhenAllFieldsExist() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("name", "Alice")
+ .put("age", 30)
+ .put("active", true)
+ .build();
+
+ assertThat(dynamic).hasFields("name", "age", "active");
+ }
+
+ @Test
+ @DisplayName("hasOnlyFields passes for exact match")
+ void hasOnlyFieldsPassesForExactMatch() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("name", "Alice")
+ .put("age", 30)
+ .build();
+
+ assertThat(dynamic).hasOnlyFields("name", "age");
+ }
+
+ @Test
+ @DisplayName("hasOnlyFields fails for extra fields")
+ void hasOnlyFieldsFailsForExtraFields() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("name", "Alice")
+ .put("age", 30)
+ .put("extra", true)
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).hasOnlyFields("name", "age"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("extra");
+ }
+
+ @Test
+ @DisplayName("hasLongField validates long value")
+ void hasLongFieldValidatesLongValue() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("timestamp", 1234567890123L)
+ .build();
+
+ assertThat(dynamic).hasLongField("timestamp", 1234567890123L);
+ }
+
+ @Test
+ @DisplayName("hasDoubleField validates double value")
+ void hasDoubleFieldValidatesDoubleValue() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("score", 95.5)
+ .build();
+
+ assertThat(dynamic).hasDoubleField("score", 95.5, 0.01);
+ }
+
+ @Test
+ @DisplayName("hasLongValue validates long content")
+ void hasLongValueValidatesLongContent() {
+ final Dynamic dynamic = TestData.gson().longValue(9876543210L);
+
+ assertThat(dynamic).hasLongValue(9876543210L);
+ }
+ }
+
+ @Nested
+ @DisplayName("List value assertions")
+ class ListValueAssertions {
+
+ @Test
+ @DisplayName("containsIntValues validates integer contents")
+ void containsIntValuesValidatesIntegerContents() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add(1).add(2).add(3)
+ .build();
+
+ assertThat(dynamic).containsIntValues(1, 2, 3);
+ }
+
+ @Test
+ @DisplayName("containsStringValues fails when value missing")
+ void containsStringValuesFailsWhenValueMissing() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add("a").add("b")
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).containsStringValues("c"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("contain");
+ }
+
+ @Test
+ @DisplayName("containsIntValues fails when value missing")
+ void containsIntValuesFailsWhenValueMissing() {
+ final Dynamic dynamic = TestData.gson().list()
+ .add(1).add(2)
+ .build();
+
+ assertThatThrownBy(() -> assertThat(dynamic).containsIntValues(5))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("contain");
+ }
+ }
+
+ @Nested
+ @DisplayName("Custom validation")
+ class CustomValidation {
+
+ @Test
+ @DisplayName("satisfies allows custom assertions")
+ void satisfiesAllowsCustomAssertions() {
+ final Dynamic dynamic = TestData.gson().object()
+ .put("count", 5)
+ .build();
+
+ assertThat(dynamic).satisfies(d -> {
+ final int count = d.get("count").asInt().orElse(0);
+ org.assertj.core.api.Assertions.assertThat(count).isGreaterThan(0);
+ });
+ }
+ }
}
diff --git a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/context/AssertingContextTest.java b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/context/AssertingContextTest.java
new file mode 100644
index 0000000..9def248
--- /dev/null
+++ b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/context/AssertingContextTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.testkit.context;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("AssertingContext")
+class AssertingContextTest {
+
+ @Nested
+ @DisplayName("Fail-On-Warn Mode")
+ class FailOnWarnMode {
+
+ @Test
+ @DisplayName("info does not throw")
+ void infoDoesNotThrow() {
+ final AssertingContext context = AssertingContext.failOnWarn();
+
+ assertThatCode(() -> context.info("Info message"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("warn throws immediately")
+ void warnThrowsImmediately() {
+ final AssertingContext context = AssertingContext.failOnWarn();
+
+ assertThatThrownBy(() -> context.warn("Warning message"))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Unexpected warning")
+ .hasMessageContaining("Warning message");
+ }
+
+ @Test
+ @DisplayName("warn formats message with arguments")
+ void warnFormatsMessage() {
+ final AssertingContext context = AssertingContext.failOnWarn();
+
+ assertThatThrownBy(() -> context.warn("Error code: {}", 404))
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Error code: 404");
+ }
+
+ @Test
+ @DisplayName("does not collect warnings")
+ void doesNotCollectWarnings() {
+ final AssertingContext context = AssertingContext.failOnWarn();
+
+ assertThat(context.warnings()).isEmpty();
+ assertThat(context.hasWarnings()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Collecting Mode")
+ class CollectingMode {
+
+ @Test
+ @DisplayName("info does not throw")
+ void infoDoesNotThrow() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ assertThatCode(() -> context.info("Info message"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("warn does not throw immediately")
+ void warnDoesNotThrowImmediately() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ assertThatCode(() -> context.warn("Warning message"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("collects warnings")
+ void collectsWarnings() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ context.warn("Warning 1");
+ context.warn("Warning 2");
+
+ assertThat(context.warnings()).containsExactly("Warning 1", "Warning 2");
+ assertThat(context.warningCount()).isEqualTo(2);
+ assertThat(context.hasWarnings()).isTrue();
+ }
+
+ @Test
+ @DisplayName("formats warnings with arguments")
+ void formatsWarningsWithArguments() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ context.warn("Value is {} and {}", 42, "hello");
+
+ assertThat(context.warnings()).containsExactly("Value is 42 and hello");
+ }
+
+ @Test
+ @DisplayName("assertNoWarnings passes when no warnings")
+ void assertNoWarningsPassesWhenEmpty() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ assertThatCode(context::assertNoWarnings)
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("assertNoWarnings fails when warnings exist")
+ void assertNoWarningsFailsWhenWarningsExist() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+ context.warn("Warning 1");
+ context.warn("Warning 2");
+
+ assertThatThrownBy(context::assertNoWarnings)
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Expected no warnings")
+ .hasMessageContaining("Warning 1")
+ .hasMessageContaining("Warning 2");
+ }
+
+ @Test
+ @DisplayName("clear removes collected warnings")
+ void clearRemovesWarnings() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+ context.warn("Warning");
+
+ context.clear();
+
+ assertThat(context.warnings()).isEmpty();
+ assertThat(context.hasWarnings()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Silent Mode")
+ class SilentMode {
+
+ @Test
+ @DisplayName("info does not throw")
+ void infoDoesNotThrow() {
+ final AssertingContext context = AssertingContext.silent();
+
+ assertThatCode(() -> context.info("Info message"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("warn does not throw")
+ void warnDoesNotThrow() {
+ final AssertingContext context = AssertingContext.silent();
+
+ assertThatCode(() -> context.warn("Warning message"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("does not collect warnings")
+ void doesNotCollectWarnings() {
+ final AssertingContext context = AssertingContext.silent();
+ context.warn("Warning");
+
+ assertThat(context.warnings()).isEmpty();
+ assertThat(context.hasWarnings()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Immutability")
+ class Immutability {
+
+ @Test
+ @DisplayName("warnings returns unmodifiable list")
+ void warningsReturnsUnmodifiableList() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+ context.warn("Warning");
+
+ assertThatThrownBy(() -> context.warnings().clear())
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("Edge Cases")
+ class EdgeCases {
+
+ @Test
+ @DisplayName("handles null argument array in warn")
+ void handlesNullArgumentArray() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ context.warn("Message", (Object[]) null);
+
+ assertThat(context.warnings()).containsExactly("Message");
+ }
+
+ @Test
+ @DisplayName("handles empty argument array in warn")
+ void handlesEmptyArgumentArray() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ context.warn("Message");
+
+ assertThat(context.warnings()).containsExactly("Message");
+ }
+
+ @Test
+ @DisplayName("handles null argument in array")
+ void handlesNullArgumentInArray() {
+ final AssertingContext context = AssertingContext.collectingWarns();
+
+ context.warn("Value is {}", (Object) null);
+
+ assertThat(context.warnings()).containsExactly("Value is null");
+ }
+ }
+}
diff --git a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/context/RecordingContextTest.java b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/context/RecordingContextTest.java
new file mode 100644
index 0000000..22f3123
--- /dev/null
+++ b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/context/RecordingContextTest.java
@@ -0,0 +1,307 @@
+/*
+ * 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.testkit.context;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("RecordingContext")
+class RecordingContextTest {
+
+ private RecordingContext context;
+
+ @BeforeEach
+ void setUp() {
+ context = new RecordingContext();
+ }
+
+ @Nested
+ @DisplayName("Basic Logging")
+ class BasicLogging {
+
+ @Test
+ @DisplayName("records info messages")
+ void recordsInfoMessages() {
+ context.info("Test message");
+
+ assertThat(context.allLogs()).hasSize(1);
+ assertThat(context.infoLogs()).hasSize(1);
+ assertThat(context.warnLogs()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("records warn messages")
+ void recordsWarnMessages() {
+ context.warn("Warning message");
+
+ assertThat(context.allLogs()).hasSize(1);
+ assertThat(context.warnLogs()).hasSize(1);
+ assertThat(context.infoLogs()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("records multiple messages")
+ void recordsMultipleMessages() {
+ context.info("Info 1");
+ context.warn("Warn 1");
+ context.info("Info 2");
+
+ assertThat(context.allLogs()).hasSize(3);
+ assertThat(context.infoLogs()).hasSize(2);
+ assertThat(context.warnLogs()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("formats messages with arguments")
+ void formatsMessagesWithArguments() {
+ context.info("Value is {} and {}", 42, "hello");
+
+ assertThat(context.infoLogs().get(0).formattedMessage())
+ .isEqualTo("Value is 42 and hello");
+ }
+
+ @Test
+ @DisplayName("handles null arguments gracefully")
+ void handlesNullArguments() {
+ context.info("Value is {}", (Object) null);
+
+ assertThat(context.infoLogs().get(0).formattedMessage())
+ .isEqualTo("Value is null");
+ }
+
+ @Test
+ @DisplayName("handles no arguments")
+ void handlesNoArguments() {
+ context.info("Simple message");
+
+ assertThat(context.infoLogs().get(0).formattedMessage())
+ .isEqualTo("Simple message");
+ }
+ }
+
+ @Nested
+ @DisplayName("Query Methods")
+ class QueryMethods {
+
+ @Test
+ @DisplayName("hasInfo returns true when substring found")
+ void hasInfoReturnsTrue() {
+ context.info("Migration completed successfully");
+
+ assertThat(context.hasInfo("completed")).isTrue();
+ assertThat(context.hasInfo("Migration")).isTrue();
+ }
+
+ @Test
+ @DisplayName("hasInfo returns false when substring not found")
+ void hasInfoReturnsFalse() {
+ context.info("Migration completed");
+
+ assertThat(context.hasInfo("error")).isFalse();
+ }
+
+ @Test
+ @DisplayName("hasWarn returns true when substring found")
+ void hasWarnReturnsTrue() {
+ context.warn("Deprecated field found");
+
+ assertThat(context.hasWarn("Deprecated")).isTrue();
+ }
+
+ @Test
+ @DisplayName("hasWarn returns false when substring not found")
+ void hasWarnReturnsFalse() {
+ context.warn("Some warning");
+
+ assertThat(context.hasWarn("error")).isFalse();
+ }
+
+ @Test
+ @DisplayName("hasLog finds in any level")
+ void hasLogFindsInAnyLevel() {
+ context.info("Info message");
+ context.warn("Warn message");
+
+ assertThat(context.hasLog("Info")).isTrue();
+ assertThat(context.hasLog("Warn")).isTrue();
+ assertThat(context.hasLog("message")).isTrue();
+ assertThat(context.hasLog("missing")).isFalse();
+ }
+
+ @Test
+ @DisplayName("size returns correct count")
+ void sizeReturnsCorrectCount() {
+ assertThat(context.size()).isZero();
+
+ context.info("One");
+ assertThat(context.size()).isEqualTo(1);
+
+ context.warn("Two");
+ assertThat(context.size()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("isEmpty returns true when empty")
+ void isEmptyWhenEmpty() {
+ assertThat(context.isEmpty()).isTrue();
+ }
+
+ @Test
+ @DisplayName("isEmpty returns false when not empty")
+ void isEmptyWhenNotEmpty() {
+ context.info("test");
+
+ assertThat(context.isEmpty()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Assertion Helpers")
+ class AssertionHelpers {
+
+ @Test
+ @DisplayName("assertNoWarnings passes when no warnings")
+ void assertNoWarningsPassesWhenNoWarnings() {
+ context.info("Info is fine");
+
+ assertThatCode(() -> context.assertNoWarnings())
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("assertNoWarnings fails when warnings exist")
+ void assertNoWarningsFailsWhenWarningsExist() {
+ context.warn("Warning 1");
+ context.warn("Warning 2");
+
+ assertThatThrownBy(() -> context.assertNoWarnings())
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Expected no warnings")
+ .hasMessageContaining("Warning 1")
+ .hasMessageContaining("Warning 2");
+ }
+
+ @Test
+ @DisplayName("assertNoLogs passes when empty")
+ void assertNoLogsPassesWhenEmpty() {
+ assertThatCode(() -> context.assertNoLogs())
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("assertNoLogs fails when logs exist")
+ void assertNoLogsFailsWhenLogsExist() {
+ context.info("Info message");
+
+ assertThatThrownBy(() -> context.assertNoLogs())
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("Expected no logs")
+ .hasMessageContaining("Info message");
+ }
+
+ @Test
+ @DisplayName("clear removes all logs")
+ void clearRemovesAllLogs() {
+ context.info("Info");
+ context.warn("Warn");
+
+ context.clear();
+
+ assertThat(context.isEmpty()).isTrue();
+ assertThat(context.allLogs()).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("LogEntry")
+ class LogEntryTests {
+
+ @Test
+ @DisplayName("toString includes level and message")
+ void toStringIncludesLevelAndMessage() {
+ context.info("Test message");
+
+ assertThat(context.infoLogs().get(0).toString())
+ .isEqualTo("[INFO] Test message");
+ }
+
+ @Test
+ @DisplayName("toString formats arguments")
+ void toStringFormatsArguments() {
+ context.warn("Error code: {}", 404);
+
+ assertThat(context.warnLogs().get(0).toString())
+ .isEqualTo("[WARN] Error code: 404");
+ }
+
+ @Test
+ @DisplayName("level returns correct level")
+ void levelReturnsCorrectLevel() {
+ context.info("Info");
+ context.warn("Warn");
+
+ assertThat(context.allLogs().get(0).level())
+ .isEqualTo(RecordingContext.LogLevel.INFO);
+ assertThat(context.allLogs().get(1).level())
+ .isEqualTo(RecordingContext.LogLevel.WARN);
+ }
+
+ @Test
+ @DisplayName("message returns raw message")
+ void messageReturnsRawMessage() {
+ context.info("Value is {}", 42);
+
+ assertThat(context.infoLogs().get(0).message())
+ .isEqualTo("Value is {}");
+ }
+
+ @Test
+ @DisplayName("args returns arguments")
+ void argsReturnsArguments() {
+ context.info("Values: {} and {}", 1, 2);
+
+ assertThat(context.infoLogs().get(0).args())
+ .containsExactly(1, 2);
+ }
+ }
+
+ @Nested
+ @DisplayName("Immutability")
+ class Immutability {
+
+ @Test
+ @DisplayName("allLogs returns unmodifiable list")
+ void allLogsReturnsUnmodifiableList() {
+ context.info("test");
+
+ assertThatThrownBy(() -> context.allLogs().clear())
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+ }
+}
diff --git a/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/harness/SchemaTesterTest.java b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/harness/SchemaTesterTest.java
new file mode 100644
index 0000000..b4c1931
--- /dev/null
+++ b/aether-datafixers-testkit/src/test/java/de/splatgames/aether/datafixers/testkit/harness/SchemaTesterTest.java
@@ -0,0 +1,400 @@
+/*
+ * 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.testkit.harness;
+
+import de.splatgames.aether.datafixers.api.DataVersion;
+import de.splatgames.aether.datafixers.api.TypeReference;
+import de.splatgames.aether.datafixers.api.type.Type;
+import de.splatgames.aether.datafixers.testkit.factory.MockSchemas;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("SchemaTester")
+class SchemaTesterTest {
+
+ private static final TypeReference PLAYER = new TypeReference("player");
+ private static final TypeReference WORLD = new TypeReference("world");
+ private static final TypeReference ENTITY = new TypeReference("entity");
+
+ @Nested
+ @DisplayName("Version Validation")
+ class VersionValidation {
+
+ @Test
+ @DisplayName("hasVersion passes when version matches (int)")
+ void hasVersionPassesWithInt() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .hasVersion(100)
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasVersion passes when version matches (DataVersion)")
+ void hasVersionPassesWithDataVersion() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .hasVersion(new DataVersion(100))
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasVersion fails when version does not match")
+ void hasVersionFailsOnMismatch() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .hasVersion(200)
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("version 100")
+ .hasMessageContaining("expected 200");
+ }
+ }
+
+ @Nested
+ @DisplayName("Type Validation")
+ class TypeValidation {
+
+ @Test
+ @DisplayName("containsType passes when type exists")
+ void containsTypePassesWhenExists() {
+ final var schema = MockSchemas.builder(100)
+ .withType(PLAYER, Type.PASSTHROUGH)
+ .build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .containsType(PLAYER)
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("containsType passes with string type id")
+ void containsTypePassesWithStringId() {
+ final var schema = MockSchemas.builder(100)
+ .withType(PLAYER, Type.PASSTHROUGH)
+ .build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .containsType("player")
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("containsType fails when type does not exist")
+ void containsTypeFailsWhenMissing() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .containsType(PLAYER)
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("does not contain type")
+ .hasMessageContaining("player");
+ }
+
+ @Test
+ @DisplayName("containsTypes passes when all types exist")
+ void containsTypesPassesWhenAllExist() {
+ final var schema = MockSchemas.builder(100)
+ .withType(PLAYER, Type.PASSTHROUGH)
+ .withType(WORLD, Type.PASSTHROUGH)
+ .build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .containsTypes(PLAYER, WORLD)
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("containsTypes fails when some types missing")
+ void containsTypesFailsWhenSomeMissing() {
+ final var schema = MockSchemas.builder(100)
+ .withType(PLAYER, Type.PASSTHROUGH)
+ .build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .containsTypes(PLAYER, WORLD, ENTITY)
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("missing types")
+ .hasMessageContaining("world")
+ .hasMessageContaining("entity");
+ }
+
+ @Test
+ @DisplayName("doesNotContainType passes when type missing")
+ void doesNotContainTypePassesWhenMissing() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .doesNotContainType(PLAYER)
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("doesNotContainType passes with string type id")
+ void doesNotContainTypePassesWithStringId() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .doesNotContainType("player")
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("doesNotContainType fails when type exists")
+ void doesNotContainTypeFailsWhenExists() {
+ final var schema = MockSchemas.builder(100)
+ .withType(PLAYER, Type.PASSTHROUGH)
+ .build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .doesNotContainType(PLAYER)
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("unexpectedly contains type")
+ .hasMessageContaining("player");
+ }
+
+ @Test
+ @DisplayName("typeForReference validates type")
+ void typeForReferenceValidatesType() {
+ final var schema = MockSchemas.builder(100)
+ .withType(PLAYER, Type.PASSTHROUGH)
+ .build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .typeForReference(PLAYER, type -> {
+ assertThat(type).isNotNull();
+ })
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("typeForReference fails when type missing")
+ void typeForReferenceFailsWhenMissing() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .typeForReference(PLAYER, type -> {})
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("does not contain type");
+ }
+ }
+
+ @Nested
+ @DisplayName("Parent Validation")
+ class ParentValidation {
+
+ @Test
+ @DisplayName("hasParent passes when parent exists")
+ void hasParentPassesWhenExists() {
+ final var parent = MockSchemas.builder(100).build();
+ final var schema = MockSchemas.builder(200).withParent(parent).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .hasParent()
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasParent fails when no parent")
+ void hasParentFailsWhenNoParent() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .hasParent()
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("has no parent");
+ }
+
+ @Test
+ @DisplayName("hasNoParent passes when no parent")
+ void hasNoParentPassesWhenNoParent() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .hasNoParent()
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("hasNoParent fails when parent exists")
+ void hasNoParentFailsWhenParentExists() {
+ final var parent = MockSchemas.builder(100).build();
+ final var schema = MockSchemas.builder(200).withParent(parent).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .hasNoParent()
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("has a parent");
+ }
+
+ @Test
+ @DisplayName("inheritsFrom passes when correct parent")
+ void inheritsFromPassesWhenCorrect() {
+ final var parent = MockSchemas.builder(100).build();
+ final var schema = MockSchemas.builder(200).withParent(parent).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .inheritsFrom(parent)
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("inheritsFrom fails when no parent")
+ void inheritsFromFailsWhenNoParent() {
+ final var parent = MockSchemas.builder(100).build();
+ final var schema = MockSchemas.builder(200).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .inheritsFrom(parent)
+ .verify()
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("has no parent");
+ }
+
+ @Test
+ @DisplayName("parentHasVersion passes when correct")
+ void parentHasVersionPassesWhenCorrect() {
+ final var parent = MockSchemas.builder(100).build();
+ final var schema = MockSchemas.builder(200).withParent(parent).build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .parentHasVersion(100)
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("parentHasVersion fails when no parent")
+ void parentHasVersionFailsWhenNoParent() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .parentHasVersion(50)
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("has no parent");
+ }
+
+ @Test
+ @DisplayName("parentHasVersion fails when wrong version")
+ void parentHasVersionFailsWhenWrongVersion() {
+ final var parent = MockSchemas.builder(100).build();
+ final var schema = MockSchemas.builder(200).withParent(parent).build();
+
+ assertThatThrownBy(() ->
+ SchemaTester.forSchema(schema)
+ .parentHasVersion(50)
+ ).isInstanceOf(AssertionError.class)
+ .hasMessageContaining("expected version 50");
+ }
+ }
+
+ @Nested
+ @DisplayName("Fluent API")
+ class FluentApi {
+
+ @Test
+ @DisplayName("schema() returns the schema being tested")
+ void schemaReturnsSchema() {
+ final var schema = MockSchemas.builder(100).build();
+
+ assertThat(SchemaTester.forSchema(schema).schema()).isSameAs(schema);
+ }
+
+ @Test
+ @DisplayName("verify() returns this for chaining")
+ void verifyReturnsThis() {
+ final var schema = MockSchemas.builder(100).build();
+ final var tester = SchemaTester.forSchema(schema);
+
+ assertThat(tester.verify()).isSameAs(tester);
+ }
+
+ @Test
+ @DisplayName("supports method chaining")
+ void supportsMethodChaining() {
+ final var parent = MockSchemas.builder(100).withType(PLAYER, Type.PASSTHROUGH).build();
+ final var schema = MockSchemas.builder(200)
+ .withParent(parent)
+ .withType(PLAYER, Type.PASSTHROUGH)
+ .withType(WORLD, Type.PASSTHROUGH)
+ .build();
+
+ assertThatCode(() ->
+ SchemaTester.forSchema(schema)
+ .hasVersion(200)
+ .containsType(PLAYER)
+ .containsTypes(PLAYER, WORLD)
+ .doesNotContainType(ENTITY)
+ .hasParent()
+ .parentHasVersion(100)
+ .verify()
+ ).doesNotThrowAnyException();
+ }
+ }
+}
diff --git a/checkstyle.xml b/checkstyle.xml
new file mode 100644
index 0000000..add9228
--- /dev/null
+++ b/checkstyle.xml
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dependency-check-suppressions.xml b/dependency-check-suppressions.xml
new file mode 100644
index 0000000..424b8da
--- /dev/null
+++ b/dependency-check-suppressions.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
diff --git a/pom.xml b/pom.xml
index 5e3ca7f..f769ba1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,7 @@
UTF-8UTF-817
+ true26.0.2
@@ -33,6 +34,7 @@
5.13.43.27.62.0.17
+ 4.9.83.11.0
@@ -44,6 +46,17 @@
3.1.23.5.1
+
+ 0.8.14
+ 4.9.8.2
+ 3.6.0
+ 12.2.0
+ 2.9.1
+
+
+ false
+ 0.75
+
4.7.6
@@ -188,6 +201,13 @@
jackson-dataformat-xml${jackson.version}
+
+
+ com.github.spotbugs
+ spotbugs-annotations
+ ${spotbugs.version}
+ provided
+
@@ -319,11 +339,156 @@
sign
- true
+ ${gpg.useAgent}
+
+ --pinentry-mode
+ loopback
+
+
+
+
+
+
+
+
+
+
+
+ qa
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${plugin.jacoco.version}
+
+
+ prepare-agent
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+ check
+
+ check
+
+
+ ${jacoco.skip}
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ ${jacoco.coverage.minimum}
+
+
+
+
+
+
+
+ com.github.spotbugs
+ spotbugs-maven-plugin
+ ${plugin.spotbugs.version}
+
+ Max
+ Medium
+ true
+ false
+
+
+
+
+ check
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+ ${plugin.checkstyle.version}
+
+ ${maven.multiModuleProjectDirectory}/checkstyle.xml
+ true
+ false
+ false
+ warning
+ false
+ false
+
+
+
+ validate
+ validate
+
+ check
+
+
+
+
+
+
+
+ org.owasp
+ dependency-check-maven
+ ${plugin.owasp.version}
+
+ ${env.NVD_API_KEY}
+ 7
+
+ ${maven.multiModuleProjectDirectory}/dependency-check-suppressions.xml
+
+
+ HTML
+ JSON
+
+
+
+
+
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+ ${plugin.cyclonedx.version}
+
+
+ package
+
+ makeAggregateBom
+
+
+
+
+ library
+ 1.5
+ true
+ true
+ true
+ true
+ false
+ false
+ false
+ all
+
+