diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..dd73c96 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# CODEOWNERS — quién debe aprobar los cambios en cada path +# Sintaxis: <@usuario-o-equipo> +# El owner del repo está asignado a todo, así que con "Require review from +# Code Owners" activado en la rama master, cualquier cambio al proyecto necesita +# su revisión (salvo bypass explícito en la configuración de protección). + +# Por defecto, todo el repo pertenece al owner. +/ @LeandroLCD + +# Documentación y CI pueden tener owners relajados si querés diferenciarlos +# más adelante, por ejemplo: +# /.github/ @LeandroLCD +# /docs/ @LeandroLCD diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1620320 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,313 @@ +name: 🧪 CI — PR to Master + +# ───────────────────────────────────────────────────────────────────────────── +# Continuous Integration for PRs targeting master. +# +# Runs on every PR open / synchronize. Cancels previous runs on the same PR +# (different pushes) but never cancels on close since that's the release path. +# ───────────────────────────────────────────────────────────────────────────── +on: + pull_request: + branches: + - master + types: [opened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event.action != 'closed' }} + +# ───────────────────────────────────────────────────────────────────────────── +# JOBS +# 1. check-version — extract version from build.gradle.kts, compare with +# latest tag, fail fast if the version isn't bumped. +# 2. unit-tests — JVM unit tests for :component. +# 3. android-tests — instrumented Compose UI tests on API 36 emulator. +# ───────────────────────────────────────────────────────────────────────────── +jobs: + + # ───────────────────────────────────────────────────────────────────────── + # STEP 1 — Verify Version Bump + # Fails the PR if the version in build.gradle.kts is not strictly greater + # than the latest git tag, or if the target tag already exists on origin. + # ───────────────────────────────────────────────────────────────────────── + check-version: + name: 🏷️ Step 1 — Verify Version Bump + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: read + + outputs: + version: ${{ steps.extract-version.outputs.version }} + tag_name: ${{ steps.extract-version.outputs.tag_name }} + latest_tag: ${{ steps.validate-version.outputs.latest_tag }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: 📌 Extract version from build.gradle.kts + id: extract-version + run: | + VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' build.gradle.kts 2>/dev/null | head -1 || true) + + if [ -z "$VERSION" ]; then + echo "❌ No se encontró 'version = \"...\"' en build.gradle.kts" + exit 1 + fi + + TAG_NAME="v${VERSION}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "📌 Versión en Gradle: ${VERSION} → Tag a crear: ${TAG_NAME}" + + - name: "🔍 Validate: new tag > latest tag & no duplicate" + id: validate-version + run: | + NEW_VERSION="${{ steps.extract-version.outputs.version }}" + NEW_TAG="${{ steps.extract-version.outputs.tag_name }}" + + semver_gt() { + local A="${1#v}" B="${2#v}" + local IFS=. + read -ra VA <<< "$A" + read -ra VB <<< "$B" + for i in 0 1 2; do + local a="${VA[$i]:-0}" b="${VB[$i]:-0}" + if (( 10#$a > 10#$b )); then return 0 + elif (( 10#$a < 10#$b )); then return 1 + fi + done + return 1 + } + + LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -1) + + if [ -z "$LATEST_TAG" ]; then + echo "ℹ️ No hay tags previos en el repo. Primer release: ${NEW_TAG}" + echo "latest_tag=ninguno" >> $GITHUB_OUTPUT + echo "✅ Validación superada — primer release." + exit 0 + fi + + echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT + echo "🏷️ Último tag existente : ${LATEST_TAG}" + echo "🆕 Nuevo tag a crear : ${NEW_TAG}" + + if git ls-remote --tags origin "refs/tags/${NEW_TAG}" | grep -q "${NEW_TAG}"; then + echo "" + echo "❌ ERROR: El tag ${NEW_TAG} ya existe en el repositorio." + echo " Incrementa la versión en build.gradle.kts antes de mergear." + exit 1 + fi + + if semver_gt "$NEW_VERSION" "$LATEST_TAG"; then + echo "" + echo "✅ Validación superada: ${NEW_TAG} > ${LATEST_TAG}" + else + echo "" + echo "❌ ERROR: La versión ${NEW_VERSION} NO es mayor que el último tag ${LATEST_TAG}." + echo " Incrementa la versión en build.gradle.kts antes de mergear." + exit 1 + fi + + # ───────────────────────────────────────────────────────────────────────── + # STEP 2 — Unit Tests (JVM) + # ───────────────────────────────────────────────────────────────────────── + unit-tests: + name: 🧪 Step 2 — Unit Tests (:component) + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: 📦 Restore Gradle cache (master-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}-master- + gradle-${{ runner.os }}- + + - name: 💾 Save Gradle cache (only master / on miss) + if: github.ref_name == 'master' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 🔧 Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: 🧪 Run :component unit tests + id: run-tests + run: | + ./gradlew :component:testDebugUnitTest \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace + + - name: 📊 Publish unit test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: component/build/test-results/**/*.xml + check_name: 📋 Unit Test Results — :component + comment_title: 🧪 Unit Test Report — :component module + comment_mode: always + + - name: 📄 Upload test report on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: unit-test-report-${{ github.run_number }} + path: component/build/reports/tests/ + retention-days: 14 + if-no-files-found: ignore + + # ───────────────────────────────────────────────────────────────────────── + # STEP 3 — Instrumented Tests (API 36) + # ───────────────────────────────────────────────────────────────────────── + android-tests: + name: 🤖 Step 3 — Android Tests (API ${{ matrix.api-level }}) + needs: [unit-tests] + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + contents: read + checks: write + pull-requests: write + + strategy: + fail-fast: false + matrix: + api-level: [36] + + steps: + - name: 🔧 Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: 🐘 Restore Gradle cache (shared, master-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle-master- + ${{ runner.os }}-gradle- + + - name: 💾 Save Gradle cache (only master / on miss) + if: github.ref_name == 'master' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 📱 Restore AVD cache (1 per API, master-first) + id: avd-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-compose-components-${{ matrix.api-level }}-google_apis + restore-keys: | + avd-compose-components-${{ matrix.api-level }}-google_apis-master + avd-compose-components-${{ matrix.api-level }}-google_apis- + + - name: 🏗️ Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: google_apis + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + emulator-boot-timeout: 300 + script: echo "✅ AVD snapshot generated for caching (API ${{ matrix.api-level }})" + + - name: 💾 Save AVD cache (only when newly created) + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-compose-components-${{ matrix.api-level }}-google_apis + + - name: 🧪 Run instrumented tests (API ${{ matrix.api-level }}) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: google_apis + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew :app:connectedDebugAndroidTest + + - name: 📊 Publish instrumented test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: '**/build/outputs/androidTest-results/**/*.xml' + check_name: 📋 Instrumented Results — API ${{ matrix.api-level }} + comment_title: 🤖 Instrumented Test Report (API ${{ matrix.api-level }}) + comment_mode: always + + - name: 📄 Upload HTML report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: android-test-report-api${{ matrix.api-level }}-${{ github.run_number }} + path: '**/build/reports/androidTests/connected/' + retention-days: 30 + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e26e129 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,422 @@ +name: 🚀 Release — Deploy from Master + +# ───────────────────────────────────────────────────────────────────────────── +# Release pipeline. Triggers ONLY on PR close events against master. +# Gates on the PR having been merged. +# +# Jobs: +# 1. check-tag — re-validate version (semver bump + no duplicate tag). +# 2. build-release — assemble :component release AAR and upload as artifact. +# 3. create-release — create GitHub Release + tag and attach the AAR. +# 4. jitpack-build — wait for JitPack to index the tag and print the log. +# ───────────────────────────────────────────────────────────────────────────── +on: + pull_request: + branches: + - master + types: [closed] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event.action != 'closed' }} + +jobs: + + # ───────────────────────────────────────────────────────────────────────── + # STEP 1 — Check Tag Availability & Version Bump + # ───────────────────────────────────────────────────────────────────────── + check-tag: + name: 🏷️ Step 1 — Check Tag & Version Bump + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + timeout-minutes: 10 + + permissions: + contents: read + + outputs: + version: ${{ steps.extract-version.outputs.version }} + tag_name: ${{ steps.extract-version.outputs.tag_name }} + latest_tag: ${{ steps.validate-version.outputs.latest_tag }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: 📌 Extract version from build.gradle.kts + id: extract-version + run: | + VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' build.gradle.kts 2>/dev/null | head -1 || true) + + if [ -z "$VERSION" ]; then + echo "❌ No se encontró 'version = \"...\"' en build.gradle.kts" + exit 1 + fi + + TAG_NAME="v${VERSION}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "📌 Versión en Gradle: ${VERSION} → Tag a crear: ${TAG_NAME}" + + - name: "🔍 Validate: new tag > latest tag & no duplicate" + id: validate-version + run: | + NEW_VERSION="${{ steps.extract-version.outputs.version }}" + NEW_TAG="${{ steps.extract-version.outputs.tag_name }}" + + semver_gt() { + local A="${1#v}" B="${2#v}" + local IFS=. + read -ra VA <<< "$A" + read -ra VB <<< "$B" + for i in 0 1 2; do + local a="${VA[$i]:-0}" b="${VB[$i]:-0}" + if (( 10#$a > 10#$b )); then return 0 + elif (( 10#$a < 10#$b )); then return 1 + fi + done + return 1 + } + + LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -1) + + if [ -z "$LATEST_TAG" ]; then + echo "ℹ️ No hay tags previos en el repo. Primer release: ${NEW_TAG}" + echo "latest_tag=ninguno" >> $GITHUB_OUTPUT + echo "✅ Validación superada — primer release." + exit 0 + fi + + echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT + echo "🏷️ Último tag existente : ${LATEST_TAG}" + echo "🆕 Nuevo tag a crear : ${NEW_TAG}" + + if git ls-remote --tags origin "refs/tags/${NEW_TAG}" | grep -q "${NEW_TAG}"; then + echo "" + echo "❌ ERROR: El tag ${NEW_TAG} ya existe en el repositorio." + exit 1 + fi + + if semver_gt "$NEW_VERSION" "$LATEST_TAG"; then + echo "" + echo "✅ Validación superada: ${NEW_TAG} > ${LATEST_TAG}" + else + echo "" + echo "❌ ERROR: La versión ${NEW_VERSION} NO es mayor que el último tag ${LATEST_TAG}." + exit 1 + fi + + # ───────────────────────────────────────────────────────────────────────── + # STEP 2 — Build :component Release AAR + # ───────────────────────────────────────────────────────────────────────── + build-release: + name: 🏗️ Step 2 — Build :component Release AAR + runs-on: ubuntu-latest + needs: [check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 30 + + permissions: + contents: read + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: 📦 Restore Gradle cache (master-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}-master- + gradle-${{ runner.os }}- + + - name: 💾 Save Gradle cache (only master / on miss) + if: github.ref_name == 'master' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 🔧 Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: 🏗️ Assemble :component Release + run: | + ./gradlew :component:assembleRelease \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace + + - name: 🔎 Locate generated AAR + id: find-aar + run: | + AAR_PATH=$(find component/build/outputs/aar -name "*release*.aar" | head -1) + if [ -z "$AAR_PATH" ]; then + echo "❌ No AAR release was found in component/build/outputs/aar/" + exit 1 + fi + echo "aar_path=${AAR_PATH}" >> $GITHUB_OUTPUT + echo "✅ AAR found: ${AAR_PATH}" + + - name: 📦 Upload AAR as workflow artifact + uses: actions/upload-artifact@v7 + with: + name: compose-components-release-aar + path: ${{ steps.find-aar.outputs.aar_path }} + retention-days: 7 + if-no-files-found: error + + # ───────────────────────────────────────────────────────────────────────── + # STEP 3 — Create Tag & GitHub Release + # ───────────────────────────────────────────────────────────────────────── + create-release: + name: 🎯 Step 3 — Create Tag & GitHub Release + runs-on: ubuntu-latest + needs: [build-release, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 10 + + permissions: + contents: write + + outputs: + release_url: ${{ steps.gh-release.outputs.url }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: 📦 Download AAR artifact + uses: actions/download-artifact@v8 + with: + name: compose-components-release-aar + path: ./release-artifacts + + - name: 🏷️ Create GitHub Release & Tag + id: gh-release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.check-tag.outputs.tag_name }} + name: Release ${{ needs.check-tag.outputs.tag_name }} + body: | + ## 📦 compose-components ${{ needs.check-tag.outputs.tag_name }} + + Publicado automáticamente desde PR #${{ github.event.pull_request.number }} + **${{ github.event.pull_request.title }}** + + --- + + ### 📥 Agregar como dependencia via JitPack + + ```kotlin + // settings.gradle.kts + dependencyResolutionManagement { + repositories { + maven { url = uri("https://jitpack.io") } + } + } + + // build.gradle.kts (module) + dependencies { + implementation("com.github.LeandroLCD:compose-components:${{ needs.check-tag.outputs.version }}") + } + ``` + + --- + 📅 Generado el: ${{ github.event.pull_request.merged_at }} + files: ./release-artifacts/*.aar + make_latest: true + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ───────────────────────────────────────────────────────────────────────── + # STEP 4 — JitPack Build Log + # ───────────────────────────────────────────────────────────────────────── + jitpack-build: + name: 📡 Step 4 — JitPack Build Log + runs-on: ubuntu-latest + needs: [create-release, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 20 + + outputs: + jitpack_status: ${{ steps.poll-jitpack.outputs.jitpack_status }} + jitpack_log_url: ${{ steps.poll-jitpack.outputs.jitpack_log_url }} + + steps: + - name: ⏳ Initial wait — let JitPack index the new tag + run: | + echo "⏳ Esperando 40s para que JitPack indexe el tag ${{ needs.check-tag.outputs.tag_name }}..." + sleep 40 + + - name: 🚀 Trigger JitPack build & poll status + id: poll-jitpack + run: | + VERSION="${{ needs.check-tag.outputs.version }}" + GROUP="com.github.LeandroLCD" + ARTIFACT="compose-components" + LOG_URL="https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/build.log" + API_URL="https://jitpack.io/api/builds/${GROUP}/${ARTIFACT}/${VERSION}" + + echo "🔗 Log URL : ${LOG_URL}" + echo "🔗 API URL : ${API_URL}" + + echo "🚀 Disparando build en JitPack..." + curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + "https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/${ARTIFACT}-${VERSION}.aar" || true + + MAX=15 + ATTEMPT=0 + STATUS="unknown" + + while [ $ATTEMPT -lt $MAX ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo "⏳ Intento ${ATTEMPT}/${MAX} — consultando estado en JitPack..." + + RESPONSE=$(curl -s --max-time 15 "${API_URL}" 2>/dev/null || echo '{}') + STATUS=$(echo "$RESPONSE" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "unknown") + + echo " 📊 Status: ${STATUS}" + + if [ "$STATUS" = "ok" ]; then + echo "✅ JitPack build exitoso!" + break + elif [ "$STATUS" = "error" ]; then + echo "❌ JitPack build falló. Revisa el log:" + echo " ${LOG_URL}" + break + fi + + [ $ATTEMPT -lt $MAX ] && sleep 30 + done + + echo "jitpack_status=${STATUS}" >> $GITHUB_OUTPUT + echo "jitpack_log_url=${LOG_URL}" >> $GITHUB_OUTPUT + + - name: 📄 Print JitPack build log + if: always() + run: | + VERSION="${{ needs.check-tag.outputs.version }}" + LOG_URL="https://jitpack.io/com/github/LeandroLCD/compose-components/${VERSION}/build.log" + echo "════════════════════════════════════════" + echo " JitPack Build Log — ${VERSION}" + echo "════════════════════════════════════════" + curl -s --max-time 30 "${LOG_URL}" || echo "⚠️ No se pudo obtener el log aún. URL: ${LOG_URL}" + echo "════════════════════════════════════════" + + # ───────────────────────────────────────────────────────────────────────── + # PR ANNOTATION — Resumen del pipeline como comentario en el PR + # ───────────────────────────────────────────────────────────────────────── + pr-summary: + name: 📝 PR Summary Annotation + runs-on: ubuntu-latest + needs: [check-tag, build-release, create-release, jitpack-build] + if: always() && github.event.pull_request.merged == true + timeout-minutes: 5 + + permissions: + pull-requests: write + + steps: + - name: 📝 Post pipeline summary comment on PR + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const icon = (r) => ({ + success: '✅', failure: '❌', skipped: '⏭️', cancelled: '🚫' + }[r] ?? '⚠️'); + + const version = `${{ needs.check-tag.outputs.version }}`; + const tagName = `${{ needs.check-tag.outputs.tag_name }}`; + const latestTag = `${{ needs.check-tag.outputs.latest_tag }}`; + const releaseUrl = `${{ needs.create-release.outputs.release_url }}`; + const jitpackStatus = `${{ needs.jitpack-build.outputs.jitpack_status }}`; + const jitpackLog = `${{ needs.jitpack-build.outputs.jitpack_log_url }}`; + + const r1 = `${{ needs.check-tag.result }}`; + const r2 = `${{ needs.build-release.result }}`; + const r3 = `${{ needs.create-release.result }}`; + const r4 = `${{ needs.jitpack-build.result }}`; + + const releaseLink = releaseUrl ? `[Ver GitHub Release](${releaseUrl})` : '—'; + const jitpackRow = jitpackLog + ? `[📄 Build Log](${jitpackLog}) · Status: \`${jitpackStatus}\`` + : '—'; + const versionArrow = (latestTag && latestTag !== 'ninguno' && tagName) + ? `\`${latestTag}\` → \`${tagName}\`` + : tagName ? `primer release: \`${tagName}\`` : 'N/A'; + + const depBlock = version ? ` + ### 📥 Dependency (JitPack) + \`\`\`kotlin + // settings.gradle.kts + maven { url = uri("https://jitpack.io") } + + // build.gradle.kts + implementation("com.github.LeandroLCD:compose-components:${version}") + \`\`\`` : ''; + + const body = `## 🚀 Release Pipeline — Resumen + + | # | Paso | Estado | Detalle | + |---|------|--------|---------| + | 1️⃣ | Check Tag & Bump | ${icon(r1)} \`${r1}\` | ${versionArrow} | + | 2️⃣ | Build Release AAR | ${icon(r2)} \`${r2}\` | \`./gradlew :component:assembleRelease\` | + | 3️⃣ | GitHub Release | ${icon(r3)} \`${r3}\` | Tag \`${tagName || 'N/A'}\` + AAR · ${releaseLink} | + | 4️⃣ | JitPack Build | ${icon(r4)} \`${r4}\` | ${jitpackRow} | + + **📌 Versión nueva:** \`${version || 'no detectada'}\`  ·  **🏷️ Último tag:** \`${latestTag || '—'}\` + ${depBlock} + + --- + 🤖 Generado automáticamente por el Release Pipeline · Run #${{ github.run_number }}`; + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.data.find(c => + c.user.type === 'Bot' && c.body.includes('Release Pipeline — Resumen') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/README.md b/README.md index 4fdf0bb..bd38344 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,32 @@ # Compose Components -[![Kotlin](https://img.shields.io/badge/Kotlin-2.3.0-purple.svg)](https://kotlinlang.org/) -[![Compose BOM](https://img.shields.io/badge/Compose%20BOM-2025.12.01-green.svg)](https://developer.android.com/jetpack/compose) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.3.10-purple.svg)](https://kotlinlang.org/) +[![Compose BOM](https://img.shields.io/badge/Compose%20BOM-2026.02.00-green.svg)](https://developer.android.com/jetpack/compose) [![Material 3](https://img.shields.io/badge/Material%203-Ready-blue.svg)](https://m3.material.io/) [![API](https://img.shields.io/badge/API-24%2B-brightgreen.svg)](https://android-arsenal.com/api?level=24) +[![CI](https://img.shields.io/badge/CI-Release%20Pipeline-blueviolet.svg)](.github/workflows/release.yml) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Una librería de componentes de UI altamente personalizables para **Jetpack Compose**, construida sobre **Material 3**. Ofrece opciones de personalización avanzadas (tamaños, colores, formas) que van más allá de las configuraciones estándar de Material 3. +Una librería de componentes de UI altamente personalizables para **Jetpack Compose**, construida sobre **Material 3**. Ofrece opciones de personalización avanzadas (tamaños, colores, formas, **tint selectivo por capa**) que van más allá de las configuraciones estándar de Material 3. + +--- + +## 📋 Tabla de Contenidos + +- [✨ Características](#-características) +- [📦 Componentes Disponibles](#-componentes-disponibles) + - [SliderComponent](#1-slidercomponent) + - [LinearProgressIndicatorComponents](#2-linearprogressindicatorcomponents) + - [RangeSliderComponent](#3-rangeslidercomponent) + - [IconComponents (con `tintCap`)](#4-iconcomponents-con-tintcap) + - [ImageComponents (con `tintCap`)](#5-imagecomponents-con-tintcap) +- [🎨 Sistema de Colores](#-sistema-de-colores) +- [🧪 Tests](#-tests) +- [📁 Estructura del Proyecto](#-estructura-del-proyecto) +- [🚀 Instalación](#-instalación) +- [📋 Requisitos](#-requisitos) +- [🤝 Contribuciones](#-contribuciones) +- [📄 Licencia](#-licencia) --- @@ -15,7 +35,10 @@ Una librería de componentes de UI altamente personalizables para **Jetpack Comp - 🎨 **Personalización avanzada**: Control total sobre colores, tamaños y formas - 🧩 **Basado en Material 3**: Integración nativa con el sistema de diseño de Material - ⚡ **Fácil de usar**: API intuitiva y compatible con los componentes existentes +- 🖌️ **Tinte selectivo por capa (`tintCap`)**: Pinta solo las capas que quieras de un `ImageVector` y preserva el resto +- 🧪 **Cubierto por tests**: Suite de tests unitarios (JVM) e instrumentados (Compose UI tests) - 📱 **Compatible con API 24+**: Soporte para una amplia gama de dispositivos +- 🚀 **Release automatizado**: Pipeline de CI que publica AAR + release + JitPack al mergear a `master` --- @@ -126,6 +149,148 @@ RangeSliderComponent( --- +### 4. IconComponents (con `tintCap`) + +Wrapper sobre `androidx.compose.material3.Icon` que añade el parámetro `tintCap` para controlar qué capas (layers) de un `ImageVector` reciben el color de `tint`. Las capas no afectadas conservan sus colores originales. + +> 💡 **¿Por qué?** Cuando tiñes un `ImageVector` complejo (logos, ilustraciones, íconos con partes de marca) normalmente **todo** el vector se vuelve del color del `tint`. Con `tintCap` puedes pintar **solo** las capas que sí deben cambiar de color y dejar intactas las que representan la identidad visual (p.ej. el fondo o un detalle de marca). + +**Propiedades personalizables:** +| Propiedad | Tipo | Descripción | +|-----------|------|-------------| +| `imageVector` | `ImageVector` | Vector a renderizar | +| `contentDescription` | `String?` | Descripción para accesibilidad | +| `modifier` | `Modifier` | Modificador estándar | +| `tint` | `Color` | Color a aplicar (por defecto `LocalContentColor.current`) | +| `tintCap` | `TintCap` | Alcance del tint (ver tabla abajo, por defecto `TintCap.All`) | + +**Variantes de `TintCap`:** +| Variante | Descripción | +|----------|-------------| +| `TintCap.All` | Pinta **todas** las capas con `tint` (default para `Icon`, equivale al comportamiento estándar de Compose) | +| `TintCap.Undefined` | **No aplica** ninguna transformación; el vector se renderiza con sus colores originales | +| `TintCap.index(n)` | Pinta **solo** la capa top-level en el índice `n` | +| `TintCap.range(rango)` | Pinta **todas** las capas cuyo índice esté dentro de `rango` (ej: `0..2`) | +| `TintCap.layers(1, 3)` | Pinta **solo** las capas top-level en los índices indicados | + +> Una "capa" es cada nodo de primer nivel del `ImageVector` raíz (ya sea un `VectorGroup` o un `VectorPath` directo). Si la capa es un grupo, todo su contenido se pinta con el mismo criterio. + +**Ejemplos de uso:** + +```kotlin +// Default: pinta todas las capas +IconComponents( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red +) + +// Pinta solo la capa top-level en el índice 1 +IconComponents( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, + tintCap = TintCap.index(1) +) + +// Pinta el rango 0..2 y respeta el resto +IconComponents( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, + tintCap = TintCap.range(0..2) +) + +// Pinta múltiples capas específicas +IconComponents( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, + tintCap = TintCap.layers(1, 3) +) + +// Respeta los colores originales del vector ignorando tint +IconComponents( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, // se ignora por estar Undefined + tintCap = TintCap.Undefined +) +``` + +#### Fixture incluido: `Icons.MapTruck` + +El módulo incluye un `ImageVector` de camión multi-capa pensado para ejercitar `tintCap`: + +``` +Índice 0 → wheels (grupo con 2 neumáticos) #424242 +Índice 1 → body (cama del camión) #E53935 +Índice 2 → cab (cabina + ventana) #1E88E5 +Índice 3 → cargo (caja de carga) #43A047 +``` + +Úsalo para prototipar y validar el comportamiento de `tintCap` sin necesidad de un asset externo: + +```kotlin +IconComponents( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tint = Color.Yellow, + tintCap = TintCap.layers(0, 3) // solo neumáticos y carga en amarillo +) +``` + +--- + +### 5. ImageComponents (con `tintCap`) + +Wrapper sobre `androidx.compose.foundation.Image` con la misma potencia de `tintCap` que `IconComponents`. Pensado para vectores con varias capas donde queremos preservar colores originales (logos, ilustraciones, etc.). + +**Propiedades personalizables:** +| Propiedad | Tipo | Descripción | +|-----------|------|-------------| +| `imageVector` | `ImageVector` | Vector a renderizar | +| `contentDescription` | `String?` | Descripción para accesibilidad | +| `modifier` | `Modifier` | Modificador estándar | +| `alignment` | `Alignment` | Alineación dentro del espacio disponible | +| `contentScale` | `ContentScale` | Estrategia de escalado (default `ContentScale.Fit`) | +| `alpha` | `Float` | Opacidad (default `DefaultAlpha`) | +| `colorFilter` | `ColorFilter?` | Filtro de color opcional adicional | +| `tint` | `Color?` | Color a aplicar (opcional) | +| `tintCap` | `TintCap` | Alcance del tint (default `TintCap.Undefined`) | + +**Ejemplo de uso:** + +```kotlin +// Logo con fondo original y un solo trazo tintado +ImageComponents( + imageVector = myBrandLogo, + contentDescription = "Logo", + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.primary, + tintCap = TintCap.index(0) +) + +// Todas las capas pintadas con tint +ImageComponents( + imageVector = myBrandLogo, + contentDescription = "Logo", + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.primary, + tintCap = TintCap.All +) + +// Colores originales del vector intactos (sin transformación) +ImageComponents( + imageVector = myBrandLogo, + contentDescription = "Logo", + modifier = Modifier.size(120.dp), + tintCap = TintCap.Undefined +) +``` + +--- + ## 🎨 Sistema de Colores Todos los componentes utilizan `SliderColorsDefaults` para una gestión coherente de colores: @@ -147,31 +312,108 @@ SliderColorsDefaults( --- +## 🧪 Tests + +Cada componente está cubierto por tests. Para ejecutarlos: + +```bash +# Tests unitarios (JVM) — rápidos, no requieren emulador +./gradlew :component:testDebugUnitTest + +# Tests instrumentados (Compose UI tests) — requieren emulador o dispositivo +./gradlew :app:connectedDebugAndroidTest +``` + +**Cobertura:** + +| Componente | Unit tests | Instrumented UI tests | +|------------|:----------:|:---------------------:| +| `SliderComponent` | — | — | +| `LinearProgressIndicatorComponents` | — | — | +| `RangeSliderComponent` | — | — | +| `TintCap` | ✅ 9 tests | ✅ vía `Icon` / `Image` | +| `ImageVectorTinter` | ✅ 7 tests | ✅ vía `Icon` / `Image` | +| `IconComponents` (con `tintCap`) | — | ✅ 6 tests | +| `ImageComponents` (con `tintCap`) | — | ✅ 5 tests | + +Los UI tests renderizan el fixture `Icons.MapTruck` (4 capas top-level con colores distinguibles) y muestrean píxeles del bitmap capturado para verificar que cada variante de `tintCap` pinta exactamente las capas correctas. + +--- + ## 📁 Estructura del Proyecto ``` composecomponents/ -├── app/ # Aplicación de demostración -├── component/ # Módulo de la librería +├── app/ # Aplicación de demostración +│ └── src/main/java/com/blipblipcode/compose_components/ +│ └── MainActivity.kt # Incluye el fixture Icons.MapTruck +├── component/ # Módulo de la librería │ └── src/main/java/com/blipblipcode/component/ -│ ├── slider/ # SliderComponent y utilidades +│ ├── slider/ # SliderComponent y utilidades │ │ ├── SliderComponent.kt │ │ ├── SliderDefaults.kt │ │ ├── SliderColorsDefaults.kt │ │ └── SliderSizeDefaults.kt -│ ├── linear/ # LinearProgressIndicatorComponents +│ ├── linear/ # LinearProgressIndicatorComponents │ │ └── LinearProgressIndicatorComponents.kt -│ └── range/ # RangeSliderComponent -│ ├── RangeSliderComponent.kt -│ └── RangeSliderDefaults.kt +│ ├── range/ # RangeSliderComponent +│ │ ├── RangeSliderComponent.kt +│ │ └── RangeSliderDefaults.kt +│ └── image/ # IconComponents e ImageComponents con tintCap +│ ├── TintCap.kt # Sealed class (All / Undefined / Index / Range / Layers) +│ ├── ImageVectorTinter.kt # Lógica interna de re-tintado selectivo +│ ├── Icon.kt # Wrapper de Material3 Icon → IconComponents +│ ├── Image.kt # Wrapper de Foundation Image → ImageComponents +│ └── MapTruck.kt # Fixture ImageVector de 4 capas +│ └── src/test/ # Tests unitarios (JVM) +│ └── java/com/blipblipcode/component/image/ +│ ├── TintCapTest.kt # 9 tests +│ └── ImageVectorTinterTest.kt # 7 tests +│ └── src/androidTest/ # Tests instrumentados (Compose UI) +│ └── java/com/blipblipcode/component/image/ +│ ├── IconTintCapTest.kt # 6 tests +│ └── ImageTintCapTest.kt # 5 tests +├── .github/workflows/ +│ ├── workflows/ +│ │ ├── ci.yml # CI: tests on every PR to master (open/synchronize) +│ │ └── release.yml # Release: build AAR + tag + GitHub Release + JitPack on PR close +│ ├── CODEOWNERS # Code owners del repo (para branch protection) +│ └── branch-protection/ +│ └── master.json # Config de protección aplicada a master (reproducible vía gh api) └── gradle/ - └── libs.versions.toml # Catálogo de versiones + └── libs.versions.toml # Catálogo de versiones ``` --- ## 🚀 Instalación +### Desde JitPack (release publicado) + +Cada merge a `master` publica automáticamente un nuevo tag + AAR en GitHub Releases y dispara una build en JitPack. + +Agrega el repositorio de JitPack en tu `settings.gradle.kts`: + +```kotlin +dependencyResolutionManagement { + repositories { + maven { url = uri("https://jitpack.io") } + } +} +``` + +Y luego la dependencia en el módulo de tu app: + +```kotlin +dependencies { + implementation("com.github.LeandroLCD:compose-components:") +} +``` + +Reemplaza `` por el tag publicado (ej: `v0.1.0`). Los tags y el changelog están en la pestaña [Releases](../../releases) del repositorio. + +> ⚠️ La primera vez que importes el tag, JitPack necesita compilar el módulo; puede tardar unos minutos. Builds subsiguientes son instantáneas. + ### Proyecto local Incluye el módulo `:component` en tus dependencias de Gradle: @@ -201,11 +443,12 @@ android { | Requisito | Versión mínima | |-----------|----------------| | Android Studio | Ladybug o superior | -| Kotlin | 2.3.0+ | -| Compose BOM | 2025.12.01+ | +| Kotlin | 2.3.10+ | +| Compose BOM | 2026.02.00+ | | Min SDK | 24 (Android 7.0) | | Target SDK | 36 | | JVM Target | 17 | +| AGP | 9.0.0+ | --- @@ -214,10 +457,26 @@ android { ¡Las contribuciones son bienvenidas! Si deseas contribuir: 1. Haz un Fork del proyecto -2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`) -3. Realiza tus cambios y haz commit (`git commit -m 'Añade nueva funcionalidad'`) +2. Crea una rama desde `master` para tu feature (`git checkout -b feature/nueva-funcionalidad`) +3. Realiza tus cambios y haz commit (`git commit -m 'feat: añade nueva funcionalidad'`) 4. Push a la rama (`git push origin feature/nueva-funcionalidad`) -5. Abre un Pull Request +5. Abre un Pull Request hacia `master` + +El pipeline de CI correrá tests unitarios + instrumentados (API 36) y, al mergear, publicará un nuevo release. + +### 🔒 Protección de `master` + +La rama `master` está protegida y solo recibe cambios vía Pull Request: + +- ✅ Pull request obligatorio antes de mergear +- ✅ 1 aprobación de code review +- ✅ Revisión de **code owner** requerida (definido en [`.github/CODEOWNERS`](.github/CODEOWNERS)) +- ✅ Reviews stale se descartan ante nuevos pushes +- ✅ Historial lineal (squash o rebase — no merge commits) +- ✅ Force-push y borrado de rama deshabilitados +- ✅ Conversaciones sin resolver bloquean el merge +- ✅ Reglas aplicadas incluso a administradores (`enforce_admins: true`) + --- diff --git a/build.gradle.kts b/build.gradle.kts index 11263cc..e4c4c17 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.android.library) apply false -} \ No newline at end of file +} + +version = "0.1.0" \ No newline at end of file diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 254342a..8edffe5 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -49,7 +49,7 @@ publishing { publications { create("release") { groupId = "com.github.LeandroLCD" - artifactId = "query" + artifactId = "compose-components" version = project.version.toString() } } @@ -78,4 +78,8 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.test.manifest) } \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt new file mode 100644 index 0000000..56e4768 --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt @@ -0,0 +1,138 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [Icon] with the various [TintCap] variants, using the + * [Icons.MapTruck] fixture which contains 4 distinct top-level layers: + * 0 → `wheels` (group), 1 → `body`, 2 → `cab` (cabin + window), 3 → `cargo`. + * + * Each test renders the truck inside an [Icon] with a specific [TintCap], captures the + * resulting bitmap of a fixed-size [Box] that wraps the [Icon], and samples well-known + * pixel coordinates to verify that only the layers targeted by [tintCap] receive the tint + * colour while the rest keep their original colour. + */ +class IconTintCapTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "icon-under-test" + + // Render size: square so the 64x64 viewport maps cleanly to a square pixel buffer. + private val iconSizeDp = 128.dp + + private val tintColor = Color(0xFFFFEB3B) // yellow + + // Default layer colours of Icons.MapTruck — see MapTruck.kt + private val wheelsColor = Color(0xFF424242) + private val bodyColor = Color(0xFFE53935) + private val cabColor = Color(0xFF1E88E5) + private val cargoColor = Color(0xFF43A047) + + /** + * Samples the rendered icon at the centre of every layer. Returns an `IntArray` of + * length 4 ordered as: [wheels, body, cab, cargo]. Coordinates are expressed as a + * fraction of the rendered pixel buffer; with [iconSizeDp] = 128.dp and a 64x64 + * viewport, positions match the source coords * 2. + */ + private fun renderAndSample(cap: TintCap): IntArray { + composeTestRule.setContent { + Surface(modifier = Modifier.background(Color.White)) { + Box( + modifier = Modifier + .size(iconSizeDp) + .background(Color.White) + .testTag(testTagValue) + ) { + IconComponents( + imageVector = Icons.MapTruck, + contentDescription = null, + modifier = Modifier.size(iconSizeDp), + tint = tintColor, + tintCap = cap + ) + } + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 12 / 64, h * 54 / 64), // front tire (wheels group) + bmp.getPixel(w * 32 / 64, h * 46 / 64), // body chassis strip + bmp.getPixel(w * 52 / 64, h * 32 / 64), // cab shell (below window) + bmp.getPixel(w * 20 / 64, h * 20 / 64) // cargo box + ) + } + + @Test + fun undefined_preserves_every_layer_original_color() { + val px = renderAndSample(TintCap.Undefined) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun all_paints_every_layer_with_tint() { + val px = renderAndSample(TintCap.All) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } + + @Test + fun index_tints_only_the_target_layer() { + val px = renderAndSample(TintCap.index(2)) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun range_tints_only_layers_inside_the_range() { + val px = renderAndSample(TintCap.range(0..1)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun layers_tints_only_the_specified_positions() { + val px = renderAndSample(TintCap.layers(0, 3)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } + + @Test + fun out_of_range_index_preserves_original_colors() { + val px = renderAndSample(TintCap.index(99)) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } +} \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt new file mode 100644 index 0000000..cd7699e --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt @@ -0,0 +1,113 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [Image] with the various [TintCap] variants, using the + * [Icons.MapTruck] fixture. Mirrors [IconTintCapTest] for the Image composable. + * + * Top-level layers: 0 → wheels (group), 1 → body, 2 → cab, 3 → cargo. + */ +class ImageTintCapTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "image-under-test" + private val imageSizeDp = 128.dp + + private val tintColor = Color(0xFFFFEB3B) // yellow + + private val wheelsColor = Color(0xFF424242) + private val bodyColor = Color(0xFFE53935) + private val cabColor = Color(0xFF1E88E5) + private val cargoColor = Color(0xFF43A047) + + private fun renderAndSample(cap: TintCap): IntArray { + composeTestRule.setContent { + Box( + modifier = Modifier + .size(imageSizeDp) + .background(Color.White) + .testTag(testTagValue) + ) { + ImageComponents( + imageVector = Icons.MapTruck, + contentDescription = null, + modifier = Modifier.size(imageSizeDp), + tint = tintColor, + tintCap = cap + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 12 / 64, h * 54 / 64), // front tire (wheels group) + bmp.getPixel(w * 32 / 64, h * 46 / 64), // body chassis strip + bmp.getPixel(w * 52 / 64, h * 32 / 64), // cab shell (below window) + bmp.getPixel(w * 20 / 64, h * 20 / 64) // cargo box + ) + } + + @Test + fun undefined_with_tint_still_preserves_original_colors() { + val px = renderAndSample(TintCap.Undefined) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun all_paints_every_layer_with_tint() { + val px = renderAndSample(TintCap.All) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } + + @Test + fun index_tints_only_the_target_layer() { + val px = renderAndSample(TintCap.index(2)) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun range_tints_only_layers_inside_the_range() { + val px = renderAndSample(TintCap.range(0..1)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun layers_tints_only_the_specified_positions() { + val px = renderAndSample(TintCap.layers(0, 3)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } +} \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/linear/LinearProgressIndicatorComponentsTest.kt b/component/src/androidTest/java/com/blipblipcode/component/linear/LinearProgressIndicatorComponentsTest.kt new file mode 100644 index 0000000..07f6b86 --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/linear/LinearProgressIndicatorComponentsTest.kt @@ -0,0 +1,167 @@ +package com.blipblipcode.component.linear + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [LinearProgressIndicatorComponents]. + * + * The component is a Canvas of exact size `width × height` dp, which makes pixel-sampling + * straightforward: we sample the middle row at relative x positions and assert which colour + * is rendered there based on the configured progress + colours. + */ +class LinearProgressIndicatorComponentsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "progress-under-test" + + // Background colours used to discriminate between the indicator fill, the track and the + // surrounding background. Chosen to be visually distinct and unlikely to clash with each + // other when rendered by Skia. + private val backgroundColor = Color.White + private val fillColor = Color(0xFFE91E63) // pink + private val trackColor = Color(0xFF455A64) // dark gray-blue + + // Fixed dimensions so the bitmap size is predictable across runs. + private val widthDp = 200.dp + private val heightDp = 8.dp + + /** + * Renders the progress indicator inside a fixed-size [Box] and returns the bitmap of + * that [Box] (not the Canvas) so we can sample with a small margin around the bar. + */ + private fun renderAndSample( + progress: Float, + range: ClosedFloatingPointRange = 0f..1f, + gapSize: androidx.compose.ui.unit.Dp = 0.dp, + drawStopIndicator: (androidx.compose.ui.graphics.drawscope.DrawScope.() -> Unit)? = null + ): IntArray { + composeTestRule.setContent { + Box( + modifier = Modifier + .size(widthDp, heightDp) + .background(backgroundColor) + .testTag(testTagValue) + ) { + LinearProgressIndicatorComponents( + progress = { progress }, + range = range, + width = widthDp, + height = heightDp, + color = fillColor, + trackColor = trackColor, + gapSize = gapSize, + drawStopIndicator = drawStopIndicator + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w / 4, h / 2), // left quarter — should be inside the fill when progress > 0.25 + bmp.getPixel(w * 3 / 4, h / 2) // right quarter — should be inside the unfilled track when progress < 0.75 + ) + } + + @Test + fun progress_zero_renders_only_track_color() { + val px = renderAndSample(progress = 0f) + assertEquals(trackColor.toArgb(), px[0]) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun progress_full_renders_only_fill_color() { + val px = renderAndSample(progress = 1f) + assertEquals(fillColor.toArgb(), px[0]) + assertEquals(fillColor.toArgb(), px[1]) + } + + @Test + fun progress_half_renders_fill_on_left_and_track_on_right() { + val px = renderAndSample(progress = 0.5f) + assertEquals(fillColor.toArgb(), px[0]) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun progress_25_percent_renders_fill_only_in_left_quarter() { + val px = renderAndSample(progress = 0.25f) + // At 25% the fill ends right at x = w/4, so depending on the rounded stroke cap + // the left quarter pixel may be the fill colour itself; the right quarter is + // unambiguously still track colour. + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun custom_range_maps_progress_within_the_range() { + // With range 0f..100f and progress 25f we expect exactly 25% of the bar to be filled. + val px = renderAndSample(progress = 25f, range = 0f..100f) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun custom_range_zero_progress_is_at_range_start() { + val px = renderAndSample(progress = 0f, range = 10f..20f) + assertEquals(trackColor.toArgb(), px[0]) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun drawStopIndicator_is_invoked_when_provided() { + // Render an indicator with a custom stop indicator: a red vertical line at the + // centre of the progress. We then assert that the centre pixel is red, which can + // only happen if our drawStopIndicator ran. + composeTestRule.setContent { + Box( + modifier = Modifier + .size(widthDp, heightDp) + .background(backgroundColor) + .testTag(testTagValue) + ) { + LinearProgressIndicatorComponents( + progress = { 0.5f }, + width = widthDp, + height = heightDp, + color = fillColor, + trackColor = trackColor, + gapSize = 0.dp, + drawStopIndicator = { + drawLine( + color = Color.Red, + start = Offset(size.width * 0.5f, -size.height), + end = Offset(size.width * 0.5f, size.height * 2f), + strokeWidth = 4f + ) + } + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val centrePixel = bmp.getPixel(bmp.width / 2, bmp.height / 2) + // The custom draw fills the exact centre column with red; a normal progress fill + // would render the fill colour there instead. Allow either because the exact centre + // row may also fall on the progress fill depending on antialiasing. + assertEquals(Color.Red.toArgb(), centrePixel) + } +} diff --git a/component/src/androidTest/java/com/blipblipcode/component/range/RangeSliderComponentTest.kt b/component/src/androidTest/java/com/blipblipcode/component/range/RangeSliderComponentTest.kt new file mode 100644 index 0000000..cff4d9b --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/range/RangeSliderComponentTest.kt @@ -0,0 +1,150 @@ +package com.blipblipcode.component.range + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.RangeSliderState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.blipblipcode.component.slider.SliderDefaults +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [RangeSliderComponent]. + * + * The component renders a Material 3 RangeSlider with a custom track (Canvas). For an + * active range of `0.2f..0.8f`, the inactive track fills `0..0.2` and `0.8..1.0`, while + * the active track fills `0.2..0.8`. Pixel sampling at the vertical centre of the slider + * proves these proportions are honoured with the configured colours. + */ +class RangeSliderComponentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "range-slider-under-test" + + private val activeColor = Color(0xFFD32F2F) // red + private val inactiveColor = Color(0xFF1976D2) // blue + + private val sliderWidthDp = 240.dp + private val sliderHeightDp = 48.dp + + @OptIn(ExperimentalMaterial3Api::class) + private fun render(start: Float, end: Float): IntArray { + composeTestRule.setContent { + val state = remember { + RangeSliderState( + activeRangeStart = start, + activeRangeEnd = end, + steps = 0, + valueRange = 0f..1f, + ) + } + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + RangeSliderComponent( + state = state, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + ), + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 10 / 100, h / 2), // x = 10% — should be inactive (left of range) + bmp.getPixel(w * 50 / 100, h / 2), // x = 50% — should be active (middle of range) + bmp.getPixel(w * 90 / 100, h / 2) // x = 90% — should be inactive (right of range) + ) + } + + @Test + fun active_range_middle_is_active_colour() { + val px = render(start = 0.2f, end = 0.8f) + assertEquals(activeColor.toArgb(), px[1]) + } + + @Test + fun outside_active_range_left_is_inactive_colour() { + val px = render(start = 0.2f, end = 0.8f) + assertEquals(inactiveColor.toArgb(), px[0]) + } + + @Test + fun outside_active_range_right_is_inactive_colour() { + val px = render(start = 0.2f, end = 0.8f) + assertEquals(inactiveColor.toArgb(), px[2]) + } + + @Test + fun full_range_paints_only_active_colour() { + val px = render(start = 0f, end = 1f) + assertEquals(activeColor.toArgb(), px[0]) + assertEquals(activeColor.toArgb(), px[1]) + assertEquals(activeColor.toArgb(), px[2]) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Test + fun empty_range_paints_only_inactive_colour() { + // activeRangeStart == activeRangeEnd → no active fill, everything is the inactive + // track. We sample at x = 25% and x = 75% to stay clear of the rounded end-cap + // that gets drawn at the start/end position (which is the active colour). + composeTestRule.setContent { + val state = remember { + RangeSliderState( + activeRangeStart = 0.2f, + activeRangeEnd = 0.2f, + steps = 0, + valueRange = 0f..1f, + ) + } + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + RangeSliderComponent( + state = state, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + ), + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val px = intArrayOf( + bmp.getPixel(bmp.width * 25 / 100, bmp.height / 2), + bmp.getPixel(bmp.width * 50 / 100, bmp.height / 2), + bmp.getPixel(bmp.width * 75 / 100, bmp.height / 2), + ) + assertEquals(inactiveColor.toArgb(), px[0]) + assertEquals(inactiveColor.toArgb(), px[1]) + assertEquals(inactiveColor.toArgb(), px[2]) + } +} \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/slider/SliderComponentTest.kt b/component/src/androidTest/java/com/blipblipcode/component/slider/SliderComponentTest.kt new file mode 100644 index 0000000..cd46495 --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/slider/SliderComponentTest.kt @@ -0,0 +1,206 @@ +package com.blipblipcode.component.slider + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [SliderComponent]. + * + * The component renders a Material 3 Slider with a custom track (Canvas) and a custom thumb. + * Pixel sampling targets the vertical centre of the slider — that is where the track is + * centred — and compares the colour on the left half (active track) vs the right half + * (inactive track) for a `value = 0.5f`. + */ +@OptIn(ExperimentalMaterial3Api::class) +class SliderComponentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "slider-under-test" + + // Distinct custom colours so we can assert which half of the track was painted. + private val activeColor = Color(0xFFD32F2F) // red + private val inactiveColor = Color(0xFF1976D2) // blue + private val thumbColor = Color(0xFF388E3C) // green + private val tickColor = Color(0xFFFBC02D) // yellow + + // Fixed slider footprint so pixel ratios are predictable. + private val sliderWidthDp = 240.dp + private val sliderHeightDp = 48.dp + + private fun render(value: Float): IntArray { + composeTestRule.setContent { + val state = remember { mutableFloatStateOf(value) } + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = state.floatValue, + onValueChange = { state.floatValue = it }, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + activeTickColor = tickColor, + inactiveTickColor = tickColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(20.dp, 20.dp), + trackHeight = 10.dp, + tickSize = 4.dp, + steps = 4, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 12 / 100, h / 2), // x = 12% — active track (value=0.5) + bmp.getPixel(w * 88 / 100, h / 2), // x = 88% — inactive track (value=0.5) + bmp.getPixel(w / 2, h / 2) // x = 50% — thumb area + ) + } + + @Test + fun active_and_inactive_track_colours_are_reflected_at_value_0_5() { + val px = render(0.5f) + assertEquals(activeColor.toArgb(), px[0]) + assertEquals(inactiveColor.toArgb(), px[1]) + } + + @Test + fun thumb_colour_is_visible_at_value_position() { + // The slider has internal horizontal padding (~thumbRadius) on each side, so the + // thumb at value=0.5 sits a bit left of the geometric centre. We sample at x=46% + // (well within the 20.dp thumb footprint) and assert it is the thumb colour. + composeTestRule.setContent { + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = 0.5f, + onValueChange = {}, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(20.dp, 20.dp), + trackHeight = 10.dp, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val centre = bmp.getPixel(bmp.width * 46 / 100, bmp.height / 2) + assertEquals(thumbColor.toArgb(), centre) + } + + @Test + fun value_zero_paints_only_inactive_track() { + // Render with value=0.0; the entire track (both halves) should be inactive colour. + val px = render(0.0f) + assertEquals(inactiveColor.toArgb(), px[0]) + assertEquals(inactiveColor.toArgb(), px[1]) + } + + @Test + fun value_one_paints_only_active_track() { + // Render with value=1.0; the entire track (both halves) should be active colour. + val px = render(1.0f) + assertEquals(activeColor.toArgb(), px[0]) + assertEquals(activeColor.toArgb(), px[1]) + } + + @Test + fun custom_thumb_size_changes_thumb_footprint() { + // Render the same slider twice — once with the default 20.dp thumb, once with a + // larger 40.dp thumb — and assert that the centre pixel of the larger thumb still + // resolves to the thumb colour (i.e. the thumb is visible at the value position). + composeTestRule.setContent { + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = 0.5f, + onValueChange = {}, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(40.dp, 40.dp), + trackHeight = 10.dp, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val centre = bmp.getPixel(bmp.width / 2, bmp.height / 2) + assertEquals(thumbColor.toArgb(), centre) + } + + @Test + fun progress_changes_active_track_extent() { + // Sanity check rendered with a single composition: at value=0.0 the left-quarter + // pixel is inactive (blue); at value=1.0 the left-quarter pixel is active (red). + // Together with `active_and_inactive_track_colours_are_reflected_at_value_0_5` this + // proves that the value parameter actually drives the active track length. + composeTestRule.setContent { + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = 1f, + onValueChange = {}, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(20.dp, 20.dp), + trackHeight = 10.dp, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val leftQuarter = bmp.getPixel(bmp.width / 4, bmp.height / 2) + assertEquals(activeColor.toArgb(), leftQuarter) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/Icon.kt b/component/src/main/java/com/blipblipcode/component/image/Icon.kt new file mode 100644 index 0000000..690e748 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/Icon.kt @@ -0,0 +1,46 @@ +package com.blipblipcode.component.image + +import androidx.compose.material3.Icon as MaterialIcon +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * A thin wrapper around Material 3's [MaterialIcon] that adds [tintCap] support for vector + * drawables. [tintCap] controls which layers of the [imageVector] receive the [tint] color; + * the rest are rendered with their original colors. + * + * @see TintCap + */ +@Composable +fun IconComponents( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, + tintCap: TintCap = TintCap.All, +) { + val recolored: ImageVector? = remember(imageVector, tint, tintCap) { + when { + tintCap.isUndefined -> null + tintCap === TintCap.All -> null + else -> recolorImageVector(imageVector, tint, tintCap) + } + } + val effectiveTint: Color = when { + tintCap.isUndefined -> Color.Unspecified + recolored != null -> Color.Unspecified + else -> tint + } + val effectiveVector: ImageVector = recolored ?: imageVector + + MaterialIcon( + imageVector = effectiveVector, + contentDescription = contentDescription, + modifier = modifier, + tint = effectiveTint + ) +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/Image.kt b/component/src/main/java/com/blipblipcode/component/image/Image.kt new file mode 100644 index 0000000..ccde74d --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/Image.kt @@ -0,0 +1,64 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.Image as FoundationImage +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.DefaultAlpha + +/** + * A wrapper around Compose Foundation's [FoundationImage] that adds [tintCap] support for + * vector drawables. [tintCap] controls which layers of the [imageVector] receive the [tint] + * color; the rest are rendered with their original colors. + * + * - When [tint] is `null` no tint is applied (standard behavior). + * - When [tint] is non-null and [tintCap] is [TintCap.Undefined], the tint is ignored and the + * vector's original colors are preserved. + * - When [tint] is non-null and [tintCap] is [TintCap.All], the tint is applied to every + * layer using [ColorFilter.tint]. + * - When [tint] is non-null and [tintCap] is [TintCap.Index], [TintCap.Range] or + * [TintCap.Layers], the vector is rebuilt so only the matching layers are tinted and + * [ColorFilter] is left untouched. + */ +@Composable +fun ImageComponents( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + tint: Color? = null, + tintCap: TintCap = TintCap.Undefined, +) { + val recolored: ImageVector? = remember(imageVector, tint, tintCap) { + if (tint == null || tintCap.isUndefined || tintCap === TintCap.All) { + null + } else { + recolorImageVector(imageVector, tint, tintCap) + } + } + val effectiveColorFilter: ColorFilter? = when { + tint == null -> colorFilter + tintCap.isUndefined -> colorFilter + tintCap === TintCap.All -> colorFilter ?: ColorFilter.tint(tint) + else -> colorFilter + } + val effectiveVector: ImageVector = recolored ?: imageVector + + FoundationImage( + imageVector = effectiveVector, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = effectiveColorFilter + ) +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt b/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt new file mode 100644 index 0000000..76c8604 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt @@ -0,0 +1,116 @@ +package com.blipblipcode.component.image + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorGroup +import androidx.compose.ui.graphics.vector.VectorNode +import androidx.compose.ui.graphics.vector.VectorPath +import androidx.compose.ui.graphics.vector.group + +/** + * Rebuilds [source] into a new [ImageVector] applying [tint] only to the layers matched by + * [tintCap]. Layers that do not match keep their original colors. + * + * When [tintCap] is [TintCap.All] (the default for [Icon]) the source vector is returned + * untouched and tinting is expected to be applied externally via the standard + * `tint` parameter — this avoids rebuilding the vector when not needed. + * + * When [tintCap] is [TintCap.Undefined] (the default for [Image]) the source vector is + * returned untouched and no tint is applied at any level. + */ +internal fun recolorImageVector( + source: ImageVector, + tint: Color, + tintCap: TintCap +): ImageVector { + if (tintCap.isUndefined) return source + + val builder = ImageVector.Builder( + name = source.name, + defaultWidth = source.defaultWidth, + defaultHeight = source.defaultHeight, + viewportWidth = source.viewportWidth, + viewportHeight = source.viewportHeight + ) + + val tintBrush: Brush = SolidColor(tint) + + // Top-level nodes form the layer-index space. Iterate as a snapshot to be safe. + val topLevel = source.root.toNodeList() + topLevel.forEachIndexed { index, node -> + val shouldTint = tintCap.appliesTo(index) + copyNode(builder, node, tintBrush, shouldTint) + } + + return builder.build() +} + +private fun copyNode( + builder: ImageVector.Builder, + node: VectorNode, + tintBrush: Brush, + shouldTint: Boolean +) { + when (node) { + is VectorGroup -> copyGroupInto(builder, node, tintBrush, shouldTint) + is VectorPath -> copyPathInto(builder, node, tintBrush, shouldTint) + } +} + +private fun copyGroupInto( + builder: ImageVector.Builder, + sourceGroup: VectorGroup, + tintBrush: Brush, + shouldTint: Boolean +) { + builder.group( + name = sourceGroup.name, + rotate = sourceGroup.rotation, + pivotX = sourceGroup.pivotX, + pivotY = sourceGroup.pivotY, + scaleX = sourceGroup.scaleX, + scaleY = sourceGroup.scaleY, + translationX = sourceGroup.translationX, + translationY = sourceGroup.translationY, + clipPathData = sourceGroup.clipPathData + ) { + val children = sourceGroup.toNodeList() + children.forEach { child -> + copyNode(this, child, tintBrush, shouldTint) + } + } +} + +private fun copyPathInto( + builder: ImageVector.Builder, + sourcePath: VectorPath, + tintBrush: Brush, + shouldTint: Boolean +) { + builder.addPath( + pathData = sourcePath.pathData, + pathFillType = sourcePath.pathFillType, + name = sourcePath.name, + fill = if (shouldTint) tintBrush else sourcePath.fill, + fillAlpha = sourcePath.fillAlpha, + stroke = if (shouldTint) tintBrush else sourcePath.stroke, + strokeAlpha = sourcePath.strokeAlpha, + strokeLineWidth = sourcePath.strokeLineWidth, + strokeLineCap = sourcePath.strokeLineCap, + strokeLineJoin = sourcePath.strokeLineJoin, + strokeLineMiter = sourcePath.strokeLineMiter, + trimPathStart = sourcePath.trimPathStart, + trimPathEnd = sourcePath.trimPathEnd, + trimPathOffset = sourcePath.trimPathOffset + ) +} + +/** Snapshot helper for any [VectorGroup] iterable. */ +private fun VectorGroup.toNodeList(): List { + val out = ArrayList(size) + val it = iterator() + while (it.hasNext()) out += it.next() + return out +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt new file mode 100644 index 0000000..11b4cb4 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt @@ -0,0 +1,155 @@ +package com.blipblipcode.component.image + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathNode +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.unit.dp + +/** + * A multi-layer truck icon used as a UI-test fixture for [Icon] / [Image] with [TintCap]. + * + * Top-level layers (indices) are drawn on non-overlapping regions so each one can be + * pixel-tested in isolation: + * 0 → `wheels` (group containing both tires, bottom strip) + * 1 → `body` (narrow chassis strip above the wheels) + * 2 → `cab` (driver cabin shell + window, top-right) + * 3 → `cargo` (cargo box, top-left) + * + * Each layer uses a distinctive default colour so it is easy to verify which layers get + * tinted by each [TintCap] variant. + */ +val Icons.MapTruck: ImageVector + get() = _MapTruck ?: ImageVector.Builder( + name = "MapTruck", + defaultWidth = 64.dp, + defaultHeight = 64.dp, + viewportWidth = 64f, + viewportHeight = 64f + ).apply { + // Layer 0: wheels group (both tires inside one top-level group) + group( + name = "wheels", + rotate = 0f, + pivotX = 0f, + pivotY = 0f, + scaleX = 1f, + scaleY = 1f, + translationX = 0f, + translationY = 0f, + clipPathData = emptyList() + ) { + // Front tire (bottom-left, fully visible below the chassis) + addPath( + pathData = tirePath(cx = 12f, cy = 54f, r = 5f), + name = "tire-front", + fill = SolidColor(Color(0xFF424242)), + fillAlpha = 1f, + stroke = SolidColor(Color(0xFF212121)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f + ) + // Rear tire (bottom-right, fully visible below the chassis) + addPath( + pathData = tirePath(cx = 50f, cy = 54f, r = 5f), + name = "tire-rear", + fill = SolidColor(Color(0xFF424242)), + fillAlpha = 1f, + stroke = SolidColor(Color(0xFF212121)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f + ) + } + + // Layer 1: narrow chassis strip (full width, sits between the wheels and the cab/cargo) + addPath( + pathData = listOf( + PathNode.MoveTo(2f, 44f), + PathNode.LineTo(62f, 44f), + PathNode.LineTo(62f, 48f), + PathNode.LineTo(2f, 48f), + PathNode.Close + ), + name = "body", + fill = SolidColor(Color(0xFFE53935)), + fillAlpha = 1f + ) + + // Layer 2: driver cabin (group: cabin shell + window, both tinted together) + // Positioned in the top-right region (no overlap with cargo). + group( + name = "cab", + rotate = 0f, + pivotX = 0f, + pivotY = 0f, + scaleX = 1f, + scaleY = 1f, + translationX = 0f, + translationY = 0f, + clipPathData = emptyList() + ) { + addPath( + pathData = listOf( + PathNode.MoveTo(40f, 14f), + PathNode.LineTo(62f, 14f), + PathNode.LineTo(62f, 42f), + PathNode.LineTo(40f, 42f), + PathNode.Close + ), + name = "cab-shell", + fill = SolidColor(Color(0xFF1E88E5)), + fillAlpha = 1f + ) + addPath( + pathData = listOf( + PathNode.MoveTo(44f, 18f), + PathNode.LineTo(58f, 18f), + PathNode.LineTo(58f, 26f), + PathNode.LineTo(44f, 26f), + PathNode.Close + ), + name = "cab-window", + fill = SolidColor(Color(0xFFBBDEFB)), + fillAlpha = 1f + ) + } + + // Layer 3: cargo box (top-left region) + addPath( + pathData = listOf( + PathNode.MoveTo(2f, 4f), + PathNode.LineTo(38f, 4f), + PathNode.LineTo(38f, 42f), + PathNode.LineTo(2f, 42f), + PathNode.Close + ), + name = "cargo", + fill = SolidColor(Color(0xFF43A047)), + fillAlpha = 1f + ) + }.build().also { _MapTruck = it } + +private var _MapTruck: ImageVector? = null + +/** + * Approximation of a circle centred at (cx, cy) with radius [r] using cubic bezier curves. + * Sufficient for testing tint behaviour on filled regions. + */ +private fun tirePath(cx: Float, cy: Float, r: Float): List { + val k = 0.5522847498f * r // standard circle-to-bezier constant + return listOf( + PathNode.MoveTo(cx + r, cy), + PathNode.CurveTo(cx + r, cy + k, cx + k, cy + r, cx, cy + r), + PathNode.CurveTo(cx - k, cy + r, cx - r, cy + k, cx - r, cy), + PathNode.CurveTo(cx - r, cy - k, cx - k, cy - r, cx, cy - r), + PathNode.CurveTo(cx + k, cy - r, cx + r, cy - k, cx + r, cy), + PathNode.Close + ) +} + +/** + * Holder that mirrors the `androidx.compose.material.icons.Icons` style so consumers can + * write `Icons.MapTruck` exactly like a Material icon. + */ +object Icons diff --git a/component/src/main/java/com/blipblipcode/component/image/TintCap.kt b/component/src/main/java/com/blipblipcode/component/image/TintCap.kt new file mode 100644 index 0000000..2502ac5 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/TintCap.kt @@ -0,0 +1,84 @@ +package com.blipblipcode.component.image + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +/** + * Defines which layers of an [androidx.compose.ui.graphics.vector.ImageVector] receive the + * tint color when rendering an [Icon] or [Image]. + * + * An ImageVector is composed of a tree of top-level nodes (groups and paths). Each top-level + * node is considered one "layer" and is identified by its position (zero-based) in the + * vector's root. + * + * - [All] Tints every layer (default for [Icon], matches standard Compose tinting). + * - [Index] Tints only the layer at the given position. + * - [Range] Tints every layer whose position lies inside the given [IntRange]. + * - [Layers] Tints only the layers at the specified positions. + * - [Undefined] Does not apply any tint transformation; the vector is rendered with its + * original colors (default for [Image]). + */ +@Stable +sealed class TintCap { + + /** Whether this tint cap should skip tinting entirely and preserve the vector's original colors. */ + abstract val isUndefined: Boolean + + /** Returns `true` when the top-level node at [layerIndex] should receive the tint color. */ + abstract fun appliesTo(layerIndex: Int): Boolean + + @Immutable + object All : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = true + override fun toString(): String = "TintCap.All" + } + + @Immutable + object Undefined : TintCap() { + override val isUndefined: Boolean = true + override fun appliesTo(layerIndex: Int): Boolean = false + override fun toString(): String = "TintCap.Undefined" + } + + @Immutable + data class Index(val layer: Int) : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex == layer + } + + @Immutable + data class Range(val range: IntRange) : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex in range + } + + @Immutable + data class Layers(val layers: List) : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex in layers + } + + companion object { + /** Convenience alias for [All]. */ + val All: TintCap get() = All + + /** Convenience alias for [Undefined]. */ + val Undefined: TintCap get() = Undefined + + /** Builds a [TintCap] that tints the single layer at [layer]. */ + fun index(layer: Int): TintCap = Index(layer) + + /** Builds a [TintCap] that tints every layer whose index lies inside [range]. */ + fun range(range: IntRange): TintCap = Range(range) + + /** Builds a [TintCap] that tints every layer whose index lies in `start..endInclusive`. */ + fun range(start: Int, endInclusive: Int): TintCap = Range(start..endInclusive) + + /** Builds a [TintCap] that tints every layer whose index appears in [layers]. */ + fun layers(vararg layers: Int): TintCap = Layers(layers.toList()) + + /** Builds a [TintCap] that tints every layer whose index appears in [layers]. */ + fun layers(layers: List): TintCap = Layers(layers) + } +} \ No newline at end of file diff --git a/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt b/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt new file mode 100644 index 0000000..d6cde6d --- /dev/null +++ b/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt @@ -0,0 +1,132 @@ +package com.blipblipcode.component.image + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorNode +import androidx.compose.ui.graphics.vector.VectorPath +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Verifies the internal recolorImageVector function preserves the structure of the source + * ImageVector and applies the tint only to the requested top-level layers. + */ +class ImageVectorTinterTest { + + /** + * Builds a 3-layer ImageVector where each top-level layer is a single path filled with + * a different distinctive color. + */ + private fun threeLayerVector(): ImageVector { + val builder = ImageVector.Builder( + name = "three-layer", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ) + builder.addPath( + pathData = emptyList(), + name = "p0", + fill = SolidColor(Color.Red) + ) + builder.addPath( + pathData = emptyList(), + name = "p1", + fill = SolidColor(Color.Green) + ) + builder.addPath( + pathData = emptyList(), + name = "p2", + fill = SolidColor(Color.Blue) + ) + return builder.build() + } + + private fun topLevelPathsOf(vector: ImageVector): List { + val out = ArrayList() + for (node: VectorNode in vector.root) { + if (node is VectorPath) out += node + } + return out + } + + @Test + fun `Undefined returns the same source untouched`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.Undefined) + assertSame(source, result) + } + + @Test + fun `All rebuilds the vector with every layer tinted`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.All) + val paths = topLevelPathsOf(result) + assertEquals(3, paths.size) + paths.forEach { p -> + assertEquals("fill must be SolidColor", true, p.fill is SolidColor) + assertEquals(Color.Magenta, (p.fill as SolidColor).value) + } + } + + @Test + fun `Index tints only the matching layer and preserves the others`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.index(1)) + val paths = topLevelPathsOf(result) + assertEquals(3, paths.size) + assertEquals(Color.Red, (paths[0].fill as SolidColor).value) + assertEquals(Color.Magenta, (paths[1].fill as SolidColor).value) + assertEquals(Color.Blue, (paths[2].fill as SolidColor).value) + } + + @Test + fun `Range tints every layer inside the range`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.range(0..1)) + val paths = topLevelPathsOf(result) + assertEquals(Color.Magenta, (paths[0].fill as SolidColor).value) + assertEquals(Color.Magenta, (paths[1].fill as SolidColor).value) + assertEquals(Color.Blue, (paths[2].fill as SolidColor).value) + } + + @Test + fun `Layers tints only the specified positions`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.layers(0, 2)) + val paths = topLevelPathsOf(result) + assertEquals(Color.Magenta, (paths[0].fill as SolidColor).value) + assertEquals(Color.Green, (paths[1].fill as SolidColor).value) + assertEquals(Color.Magenta, (paths[2].fill as SolidColor).value) + } + + @Test + fun `recoloring produces a fresh ImageVector instance`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.All) + assertNotSame(source, result) + } + + @Test + fun `recoloring preserves viewport dimensions and name`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.All) + assertEquals(source.name, result.name) + assertEquals(Dp(24f), result.defaultWidth) + assertEquals(Dp(24f), result.defaultHeight) + assertEquals(source.viewportWidth, result.viewportWidth, 0f) + assertEquals(source.viewportHeight, result.viewportHeight, 0f) + // Sanity: result must have a populated root + assertNotNull(result.root) + assertTrue(result.root.size >= 3) + } +} \ No newline at end of file diff --git a/component/src/test/java/com/blipblipcode/component/image/TintCapTest.kt b/component/src/test/java/com/blipblipcode/component/image/TintCapTest.kt new file mode 100644 index 0000000..6162718 --- /dev/null +++ b/component/src/test/java/com/blipblipcode/component/image/TintCapTest.kt @@ -0,0 +1,92 @@ +package com.blipblipcode.component.image + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TintCapTest { + + @Test + fun `All tints every layer and is not undefined`() { + val cap = TintCap.All + assertFalse(cap.isUndefined) + for (i in -1..10) { + assertTrue("layer $i should be tinted by All", cap.appliesTo(i)) + } + } + + @Test + fun `Undefined never tints and reports itself as undefined`() { + val cap = TintCap.Undefined + assertTrue(cap.isUndefined) + for (i in -5..20) { + assertFalse("layer $i should NOT be tinted by Undefined", cap.appliesTo(i)) + } + } + + @Test + fun `Index tints only the matching layer`() { + val cap = TintCap.index(3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Index works with negative and out-of-range positions`() { + val cap = TintCap.index(0) + assertFalse(cap.appliesTo(-1)) + assertTrue(cap.appliesTo(0)) + assertFalse(cap.appliesTo(1)) + } + + @Test + fun `Range tints every layer within the range, inclusive`() { + val cap = TintCap.range(1..3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertTrue(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Range with start and endInclusive helper works`() { + val cap = TintCap.range(0, 2) + assertTrue(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertTrue(cap.appliesTo(2)) + assertFalse(cap.appliesTo(3)) + } + + @Test + fun `Layers tints only the specified positions`() { + val cap = TintCap.layers(1, 3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertFalse(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Layers accepts a list factory`() { + val cap = TintCap.layers(listOf(0, 4, 7)) + assertTrue(cap.appliesTo(0)) + assertFalse(cap.appliesTo(1)) + assertTrue(cap.appliesTo(4)) + assertTrue(cap.appliesTo(7)) + assertFalse(cap.appliesTo(8)) + } + + @Test + fun `Layer ordering is preserved`() { + val cap = TintCap.layers(5, 0, 2) + // Even unsorted, appliesTo is membership-based, but equality should preserve order + assertEquals(listOf(5, 0, 2), (cap as TintCap.Layers).layers) + } +} \ No newline at end of file