diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 985f6d5..cb6a4c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,24 +2,21 @@ name: CI on: push: - branches: [ main, develop ] + branches: [ "main" ] pull_request: - types: [opened, synchronize, reopened] - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + branches: [ "main" ] jobs: - lint: + build-and-test: + name: Build and Test runs-on: ubuntu-latest + permissions: + contents: read + checks: write + steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Checkout Code + uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -28,27 +25,23 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} - - name: Run ktlintCheck + - name: Run Lint Check run: ./gradlew ktlintCheck - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Run Unit Tests + run: ./gradlew test --stacktrace - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' + - name: Build App (Main Branch Only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: ./gradlew :app:assembleDebug - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Build - run: ./gradlew build + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-results + path: '**/build/test-results/test/TEST-*.xml' diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0246b14..4d4246d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,8 +32,9 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + val javaVersion = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion } } diff --git a/build.gradle.kts b/build.gradle.kts index 41aa8a7..227ecaf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SonatypeHost import org.jlleitschuh.gradle.ktlint.KtlintExtension plugins { @@ -35,8 +37,8 @@ subprojects { if (name != "app") { apply(plugin = "com.vanniktech.maven.publish") - extensions.configure { - publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) + extensions.configure { + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) signAllPublications() coordinates("io.github.angrypodo", name, "0.1.0") diff --git a/gradle.properties b/gradle.properties index 132244e..ed52742 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,9 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86ec710..7c3a7c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,32 +1,36 @@ [versions] # Build & Publish -androidGradlePlugin = "8.13.0" +androidGradlePlugin = "8.13.2" vanniktechMavenPublish = "0.30.0" -ksp = "2.3.0" +ksp = "2.3.4" ktlint = "11.5.1" # Kotlin -kotlin = "2.2.21" +kotlin = "2.3.0" kotlinxSerialization = "1.9.0" kotlinpoet = "2.2.0" # Android SDK compileSdk = "36" minSdk = "28" -jvmTarget = "11" +jvmTarget = "17" # AndroidX & Compose coreKtx = "1.17.0" appcompat = "1.7.1" -activityCompose = "1.11.0" -composeBom = "2025.10.01" -composeNavigation = "2.9.5" +activityCompose = "1.12.2" +composeBom = "2025.12.01" +composeNavigation = "2.9.6" material = "1.13.0" # Test junit = "6.0.1" junitVersion = "1.3.0" espressoCore = "3.7.0" +robolectric = "4.16" +junit4 = "4.13.2" +mockito = "5.21.0" +mockitoKotlin = "6.1.0" [libraries] # --- Build Tools & Plugins --- @@ -67,6 +71,12 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +# --- Unit Test Libraries --- +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +junit4 = { module = "junit:junit", version.ref = "junit4" } + [plugins] # Android android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/wisp-annotations/build.gradle.kts b/wisp-annotations/build.gradle.kts index 010aecd..8b1dfa6 100644 --- a/wisp-annotations/build.gradle.kts +++ b/wisp-annotations/build.gradle.kts @@ -1,3 +1,7 @@ plugins { alias(libs.plugins.kotlin.jvm) } + +kotlin { + jvmToolchain(libs.versions.jvmTarget.get().toInt()) +} diff --git a/wisp-processor/build.gradle.kts b/wisp-processor/build.gradle.kts index 2b4608d..f4e4351 100644 --- a/wisp-processor/build.gradle.kts +++ b/wisp-processor/build.gradle.kts @@ -2,6 +2,10 @@ plugins { alias(libs.plugins.kotlin.jvm) } +kotlin { + jvmToolchain(libs.versions.jvmTarget.get().toInt()) +} + dependencies { implementation(project(":wisp-annotations")) implementation(libs.ksp.api) @@ -11,4 +15,7 @@ dependencies { testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.platform.launcher) + + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.kotlin) } diff --git a/wisp-processor/src/test/java/com/angrypodo/wisp/mapper/RouteInfoMapperTest.kt b/wisp-processor/src/test/java/com/angrypodo/wisp/mapper/RouteInfoMapperTest.kt new file mode 100644 index 0000000..67fad90 --- /dev/null +++ b/wisp-processor/src/test/java/com/angrypodo/wisp/mapper/RouteInfoMapperTest.kt @@ -0,0 +1,101 @@ +package com.angrypodo.wisp.mapper + +import com.angrypodo.wisp.model.ClassRouteInfo +import com.angrypodo.wisp.model.ObjectRouteInfo +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSName +import com.google.devtools.ksp.symbol.KSValueArgument +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +class RouteInfoMapperTest { + + @Test + fun `toRouteInfo returns null if @Wisp annotation is missing`() { + val classDeclaration = mock() + whenever(classDeclaration.annotations).thenReturn(emptySequence()) + + val result = classDeclaration.toRouteInfo() + + assertNull(result) + } + + @Test + fun `toRouteInfo returns ObjectRouteInfo for object declaration`() { + val mockPackageName = mock() + whenever(mockPackageName.asString()).thenReturn("com.example") + + val mockSimpleName = mock() + whenever(mockSimpleName.asString()).thenReturn("HomeScreen") + + val mockQualifiedName = mock() + whenever(mockQualifiedName.asString()).thenReturn("com.example.HomeScreen") + + val classDeclaration = mock() + whenever(classDeclaration.classKind).thenReturn(ClassKind.OBJECT) + whenever(classDeclaration.packageName).thenReturn(mockPackageName) + whenever(classDeclaration.simpleName).thenReturn(mockSimpleName) + whenever(classDeclaration.qualifiedName).thenReturn(mockQualifiedName) + + val mockAnnotation = createMockWispAnnotation("home") + whenever(classDeclaration.annotations).thenReturn(sequenceOf(mockAnnotation)) + + val result = classDeclaration.toRouteInfo() + + assertNotNull(result) + assertTrue(result is ObjectRouteInfo) + assertEquals("home", result?.wispPath) + } + + @Test + fun `toRouteInfo returns ClassRouteInfo for class declaration`() { + val mockPackageName = mock() + whenever(mockPackageName.asString()).thenReturn("com.example") + + val mockSimpleName = mock() + whenever(mockSimpleName.asString()).thenReturn("ProfileScreen") + + val mockQualifiedName = mock() + whenever(mockQualifiedName.asString()).thenReturn("com.example.ProfileScreen") + + val classDeclaration = mock() + whenever(classDeclaration.classKind).thenReturn(ClassKind.CLASS) + whenever(classDeclaration.packageName).thenReturn(mockPackageName) + whenever(classDeclaration.simpleName).thenReturn(mockSimpleName) + whenever(classDeclaration.qualifiedName).thenReturn(mockQualifiedName) + whenever(classDeclaration.primaryConstructor).thenReturn(null) + + val mockAnnotation = createMockWispAnnotation("profile") + whenever(classDeclaration.annotations).thenReturn(sequenceOf(mockAnnotation)) + + val result = classDeclaration.toRouteInfo() + + assertNotNull(result) + assertTrue(result is ClassRouteInfo) + } + + private fun createMockWispAnnotation(path: String): KSAnnotation { + val annotation = mock() + + val shortName = mock() + whenever(shortName.asString()).thenReturn("Wisp") + whenever(annotation.shortName).thenReturn(shortName) + + val arg = mock() + val argName = mock() + whenever(argName.asString()).thenReturn("path") + whenever(arg.name).thenReturn(argName) + whenever(arg.value).thenReturn(path) + + whenever(annotation.arguments).thenReturn(listOf(arg)) + + return annotation + } +} diff --git a/wisp-processor/src/test/java/com/angrypodo/wisp/validation/WispValidatorTest.kt b/wisp-processor/src/test/java/com/angrypodo/wisp/validation/WispValidatorTest.kt new file mode 100644 index 0000000..cfacc5d --- /dev/null +++ b/wisp-processor/src/test/java/com/angrypodo/wisp/validation/WispValidatorTest.kt @@ -0,0 +1,46 @@ +package com.angrypodo.wisp.validation + +import com.angrypodo.wisp.model.ObjectRouteInfo +import com.squareup.kotlinpoet.ClassName +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WispValidatorTest { + + @Test + fun `validateDuplicatePaths returns Success when there are no duplicates`() { + val routes = listOf( + createMockRouteInfo("home", "HomeScreen"), + createMockRouteInfo("profile", "ProfileScreen"), + createMockRouteInfo("settings", "SettingsScreen") + ) + + val result = WispValidator.validateDuplicatePaths(routes) + + assertTrue(result is WispValidator.ValidationResult.Success) + } + + @Test + fun `validateDuplicatePaths returns Failure when there are duplicates`() { + val routes = listOf( + createMockRouteInfo("home", "HomeScreen"), + createMockRouteInfo("home", "AnotherHomeScreen") + ) + + val result = WispValidator.validateDuplicatePaths(routes) + + assertTrue(result is WispValidator.ValidationResult.Failure) + val failure = result as WispValidator.ValidationResult.Failure + assertEquals(1, failure.errors.size) + assertTrue(failure.errors[0].contains("path 'home' is already used by multiple routes")) + } + + private fun createMockRouteInfo(path: String, className: String): ObjectRouteInfo { + return ObjectRouteInfo( + routeClassName = ClassName("com.example", className), + factoryClassName = ClassName("com.example", "${className}Factory"), + wispPath = path + ) + } +} diff --git a/wisp-runtime/build.gradle.kts b/wisp-runtime/build.gradle.kts index 0323544..623ec64 100644 --- a/wisp-runtime/build.gradle.kts +++ b/wisp-runtime/build.gradle.kts @@ -27,8 +27,9 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + val javaVersion = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion } } @@ -51,4 +52,8 @@ dependencies { testRuntimeOnly(libs.junit.platform.launcher) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + + testImplementation(libs.robolectric) + testImplementation(libs.junit4) + testImplementation(libs.androidx.junit) } diff --git a/wisp-runtime/src/test/java/com/angrypodo/wisp/runtime/parser/DefaultWispUriParserTest.kt b/wisp-runtime/src/test/java/com/angrypodo/wisp/runtime/parser/DefaultWispUriParserTest.kt new file mode 100644 index 0000000..3bef796 --- /dev/null +++ b/wisp-runtime/src/test/java/com/angrypodo/wisp/runtime/parser/DefaultWispUriParserTest.kt @@ -0,0 +1,41 @@ +package com.angrypodo.wisp.runtime.parser + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultWispUriParserTest { + + private val parser = DefaultWispUriParser() + + @Test + fun parse_valid_path_returns_list_of_segments() { + val uri = Uri.parse("app://wisp/home/product") + val result = parser.parse(uri) + assertEquals(listOf("home", "product"), result) + } + + @Test + fun parse_empty_path_returns_empty_list() { + val uri = Uri.parse("app://wisp") + val result = parser.parse(uri) + assertEquals(emptyList(), result) + } + + @Test + fun parse_trailing_slash_returns_empty_list_for_root() { + val uri = Uri.parse("app://wisp/") + val result = parser.parse(uri) + assertEquals(emptyList(), result) + } + + @Test + fun parse_path_with_query_parameters_ignores_query() { + val uri = Uri.parse("app://wisp/product?id=123") + val result = parser.parse(uri) + assertEquals(listOf("product"), result) + } +}