diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51ba599..009b22b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: jobs: - # 1) JVM/unit tests (core + compose) + # 1) JVM/unit tests (core + compose + test) unit: runs-on: ubuntu-latest steps: @@ -23,7 +23,8 @@ jobs: run: | ./gradlew clean \ :core:testDebugUnitTest \ - :compose:testDebugUnitTest + :compose:testDebugUnitTest \ + :test:testDebugUnitTest # 2) Instrumented tests on API 32 androidTest: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2a848a4..085c79d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,7 @@ jobs: fi - name: Assemble release artifacts (no tests) - run: ./gradlew --no-daemon :core:assembleRelease :compose:assembleRelease -x test -x check + run: ./gradlew --no-daemon :core:assembleRelease :compose:assembleRelease :test:assembleRelease -x test -x check - name: Publish to Maven Central env: diff --git a/README.md b/README.md index 4e2906e..eba77d1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Maven Central – core](https://img.shields.io/maven-central/v/io.github.dkmarkell/textresource-core?label=textresource-core)](https://central.sonatype.com/artifact/io.github.dkmarkell/textresource-core) [![Maven Central – compose](https://img.shields.io/maven-central/v/io.github.dkmarkell/textresource-compose?label=textresource-compose)](https://central.sonatype.com/artifact/io.github.dkmarkell/textresource-compose) +[![Maven Central – test](https://img.shields.io/maven-central/v/io.github.dkmarkell/textresource-test?label=textresource-test)](https://central.sonatype.com/artifact/io.github.dkmarkell.textresource/textresource-test) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![minSdk](https://img.shields.io/badge/minSdk-21-blue) ![Kotlin](https://img.shields.io/badge/Kotlin-1.9.25-blue) @@ -20,6 +21,31 @@ dependencies { } ``` +### Testing (Robolectric) + +```kotlin +// build.gradle.kts (module where your tests run) +dependencies { + testImplementation("io.github.dkmarkell.textresource:textresource-test:") + testImplementation("org.robolectric:robolectric:") +} + +android { + testOptions { unitTests.isIncludeAndroidResources = true } +} +``` + +#### Robolectric SDK cap (e.g., targetSdk = 36) + +If your app targets a newer SDK than Robolectric supports, set the test SDK so unit tests run: + +**Per test class** +```kotlin +@RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [35]) +class MyTest { /* ... */ } +``` + ## Quick Start ```kotlin @@ -39,7 +65,7 @@ Check out the [Sample app](./sample) for a complete demo. In a clean architecture, your ViewModel (or presenter) should decide what text is displayed, but not actually need to hold a Context to do it. With Android’s resource system, resolving strings usually requires a Context — which is either unavailable or awkward to inject. -TextResource solves this by: +**TextResource** solves this by: - Deferring resolution — store the definition of a string (e.g., resource ID + arguments) until it’s actually displayed. - Keeping formatting and pluralization logic out of presentation components — no need for Composables or Views to assemble text from parts. @@ -203,13 +229,37 @@ private fun HomeScreen( } ``` -## API Overview +## Testing with TextResourceTest -**Factories** -- `TextResource.raw(value: String)` -- `TextResource.simple(@StringRes resId: Int, vararg args: Any)` -- `TextResource.plural(@PluralsRes resId: Int, quantity: Int, vararg args: Any)` +`textresource-test` provides a tiny helper that resolves `TextResource` in **local unit tests** (Robolectric), so your specs don’t need to wire a `Context` each time. + +### Examples +```kotlin +// Basic +val tr = TextResource.simple(R.string.greeting, "Derek") +assertEquals("Hello, Derek", TextResourceTest.resolve(tr)) +// Force locale +val tr = TextResource.simple(R.string.greeting, "Derek") +assertEquals("Bonjour, Derek", TextResourceTest.resolve(tr, Locale.FRANCE)) + +// Plurals +val apples = TextResource.plural(R.plurals.apples_count, 2, 2) +assertEquals("2 apples", TextResourceTest.resolve(apples)) +``` + +## API Overview + +### Constructing +**Core** +- **Factories (value-based)** + - `TextResource.raw(value: String)` + - `TextResource.simple(@StringRes resId: Int, vararg args: Any)` + - `TextResource.plural(@PluralsRes resId: Int, quantity: Int, vararg args: Any)` +- **SAM initializer (functional interface)** + - `TextResource { context -> /* resolve to a String using context */ }` + +### Resolving **Core** ```kotlin fun TextResource.resolveString(context: Context): String @@ -219,6 +269,11 @@ fun TextResource.resolveString(context: Context): String ```kotlin @Composable fun TextResource.resolveString(): String +``` + +### Helpers +**Compose** +```kotlin @Composable fun rememberTextResource(factory: () -> TextResource): TextResource @Composable @@ -227,6 +282,19 @@ fun rememberTextResource(key1: Any?, factory: () -> TextResource): TextResource fun rememberTextResource(vararg keys: Any?, factory: () -> TextResource): TextResource ``` +### Testing +**Test** +```kotlin +object TextResourceTest { + @JvmStatic + fun resolve(tr: TextResource, locale: Locale = Locale.US): String +} +``` + +### Equality semantics (important) +- Factory-created instances compare by **value** (same inputs → `==` is `true`) +- SAM-created instances compare by **reference** (each lambda is a new object) + ## FAQ **Q: Should I use the factory functions or the functional interface (SAM) initializer?** @@ -243,11 +311,11 @@ The SAM initializer (`TextResource { ... }`) creates an anonymous object. Each c ```kotlin val a = TextResource.simple(R.string.greeting, "Derek") val b = TextResource.simple(R.string.greeting, "Derek") -println(a == b) // true ✅ value-based +println(a == b) // true -> value-based val x = TextResource { "Hello, Derek" } val y = TextResource { "Hello, Derek" } -println(x == y) // false ❌ reference-based +println(x == y) // false -> reference-based ``` ## License diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 462cb93..71211d8 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -10,11 +10,10 @@ android { namespace = "io.github.dkmarkell.textresource.compose" compileSdk = 36 - defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - defaultConfig { minSdk = 21 - consumerProguardFiles("proguard-rules.pro") // create at compose/proguard-rules.pro + consumerProguardFiles("proguard-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } publishing { @@ -24,8 +23,6 @@ android { } } - buildFeatures { compose = true } - buildFeatures { compose = true } @@ -114,8 +111,8 @@ dependencies { implementation(platform(libs.compose.bom)) implementation(libs.compose.runtime) implementation(libs.compose.ui) - implementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(platform(libs.compose.bom)) diff --git a/compose/src/main/res/drawable-v24/ic_launcher_foreground.xml b/compose/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d1..0000000 --- a/compose/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/compose/src/main/res/drawable/ic_launcher_background.xml b/compose/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9..0000000 --- a/compose/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/compose/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/compose/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6f3b755..0000000 --- a/compose/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/compose/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/compose/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 6f3b755..0000000 --- a/compose/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/compose/src/main/res/mipmap-hdpi/ic_launcher.webp b/compose/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78..0000000 Binary files a/compose/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/compose/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d..0000000 Binary files a/compose/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-mdpi/ic_launcher.webp b/compose/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d6..0000000 Binary files a/compose/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/compose/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611d..0000000 Binary files a/compose/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-xhdpi/ic_launcher.webp b/compose/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a307..0000000 Binary files a/compose/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/compose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a695..0000000 Binary files a/compose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/compose/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/compose/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/compose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50..0000000 Binary files a/compose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/compose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/compose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/compose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/compose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae3..0000000 Binary files a/compose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/compose/src/main/res/values/strings.xml b/compose/src/main/res/values/strings.xml deleted file mode 100644 index 40bd066..0000000 --- a/compose/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - TextResource Compose - \ No newline at end of file diff --git a/core/src/main/res/drawable-v24/ic_launcher_foreground.xml b/core/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d1..0000000 --- a/core/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/core/src/main/res/drawable/ic_launcher_background.xml b/core/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9..0000000 --- a/core/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/core/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6f3b755..0000000 --- a/core/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/core/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/core/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 6f3b755..0000000 --- a/core/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/core/src/main/res/mipmap-hdpi/ic_launcher.webp b/core/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78..0000000 Binary files a/core/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/core/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d..0000000 Binary files a/core/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-mdpi/ic_launcher.webp b/core/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d6..0000000 Binary files a/core/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/core/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611d..0000000 Binary files a/core/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-xhdpi/ic_launcher.webp b/core/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a307..0000000 Binary files a/core/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/core/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a695..0000000 Binary files a/core/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/core/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/core/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/core/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50..0000000 Binary files a/core/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/core/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/core/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/core/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/core/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae3..0000000 Binary files a/core/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d671226..360f2e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,10 @@ robolectric = "4.15.1" androidx-test-ext-junit = "1.3.0" androidx-test-core = "1.7.0" runner = "1.7.0" +core-ktx = "1.17.0" +espresso-core = "3.7.0" +appcompat = "1.7.1" +material = "1.12.0" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -36,3 +40,7 @@ androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "and compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 7db27e0..9bdd0a6 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -17,6 +17,8 @@ android { buildFeatures { compose = true } + testOptions { unitTests.isIncludeAndroidResources = true } + buildFeatures { compose = true } @@ -42,4 +44,9 @@ dependencies { debugImplementation(libs.compose.ui.tooling) // editor preview tooling in debug only implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.runtime.compose) + + testImplementation(project(":test")) + testImplementation(libs.robolectric) + testImplementation(libs.junit4) + testImplementation(kotlin("test")) } diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 65c0232..7476d25 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - TextResource Sample + TextResource Sample Hello, %1$s! It is %1$s diff --git a/sample/src/test/java/io/github/dkmarkell/textresource/sample/SampleTest.kt b/sample/src/test/java/io/github/dkmarkell/textresource/sample/SampleTest.kt new file mode 100644 index 0000000..c8f2364 --- /dev/null +++ b/sample/src/test/java/io/github/dkmarkell/textresource/sample/SampleTest.kt @@ -0,0 +1,33 @@ +package io.github.dkmarkell.textresource.sample + +import io.github.dkmarkell.test.TextResourceTest +import io.github.dkmarkell.textresource.sample.playground.HomeViewModel +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.Locale +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SampleTest { + + @Test + fun givenUnreadCount_whenResolveViaHelper_thenMatchesResources() { + // Given + val vm = HomeViewModel() + vm.onUnreadCountChanged(2) // sets title to TextResource.plural(..., 2, 2) + + // When + val tr = vm.title.value + val actual = TextResourceTest.resolveString(tr, Locale.US) + + // Then (compare with Android's own plural resolution) + val expected = RuntimeEnvironment.getApplication() + .resources.getQuantityString(R.plurals.unread_messages, 2, 2) + + assertEquals(expected, actual) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 4cc483a..c3dc44f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,4 +6,5 @@ dependencyResolutionManagement { repositories { google(); mavenCentral() } } rootProject.name = "TextResource" -include(":core", ":compose", ":sample") \ No newline at end of file +include(":core", ":compose", ":sample") +include(":test") diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/test/build.gradle.kts b/test/build.gradle.kts new file mode 100644 index 0000000..f8aafcf --- /dev/null +++ b/test/build.gradle.kts @@ -0,0 +1,113 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + `maven-publish` + signing + id("com.gradleup.nmcp") +} + +android { + namespace = "io.github.dkmarkell.textresource.test" + compileSdk = 36 + + defaultConfig { + minSdk = 21 + consumerProguardFiles("proguard-rules.pro") // create this file at core/proguard-rules.pro + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } + + afterEvaluate { + publishing { + publications { + val releasePub = (findByName("release") as? org.gradle.api.publish.maven.MavenPublication) + ?: create("release", org.gradle.api.publish.maven.MavenPublication::class.java) { + from(components["release"]) + } + + releasePub.groupId = (findProperty("GROUP") as String?) ?: project.group.toString() + releasePub.artifactId = "textresource-test" + releasePub.version = (findProperty("VERSION_NAME") as String?) ?: project.version.toString() + + releasePub.pom { + name.set("TextResource Test") + description.set( + "Robolectric-based test utilities for TextResource. Provides a simple resolver to evaluate string and plural resources with a chosen Locale from local unit tests, keeping Android boilerplate out of your specs. Requires Robolectric in the consuming project; no transitive dependencies are bundled." + ) + url.set("https://github.com/dkmarkell/textresource") + licenses { + license { + name.set("MIT License") + url.set("https://github.com/dkmarkell/textresource/blob/main/LICENSE") + distribution.set("repo") + } + } + developers { + developer { + id.set("dkmarkell") + name.set("Derek Markell") + url.set("https://github.com/dkmarkell") + } + } + scm { + url.set("https://github.com/dkmarkell/textresource") + connection.set("scm:git:https://github.com/dkmarkell/textresource.git") + developerConnection.set("scm:git:ssh://git@github.com/dkmarkell/textresource.git") + tag.set("v${releasePub.version}") + } + } + } + } + + val key = System.getenv("SIGNING_KEY") + val pass = System.getenv("SIGNING_KEY_PASSWORD") ?: "" + val hasKey = !key.isNullOrBlank() + val isCi = System.getenv("CI") == "true" + + signing { + isRequired = isCi && hasKey + + if (hasKey) { + useInMemoryPgpKeys(key, pass) + sign(publishing.publications) // runs only when a key is present + } + } + + // Skip sign tasks entirely when there's no key + tasks.withType(Sign::class.java).configureEach { + onlyIf { hasKey } + } + } + + buildFeatures { buildConfig = false } + + testBuildType = "debug" + testOptions { + unitTests.isIncludeAndroidResources = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +kotlin { + jvmToolchain(17) + explicitApi() +} + +dependencies { + api(project(":core")) + + compileOnly(libs.robolectric) + + testImplementation(libs.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(kotlin("test")) +} diff --git a/test/consumer-rules.pro b/test/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/test/proguard-rules.pro b/test/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/test/src/debug/res/values-fr/strings.xml b/test/src/debug/res/values-fr/strings.xml new file mode 100644 index 0000000..a980940 --- /dev/null +++ b/test/src/debug/res/values-fr/strings.xml @@ -0,0 +1,3 @@ + + Bonjour, %s + \ No newline at end of file diff --git a/test/src/debug/res/values/plurals.xml b/test/src/debug/res/values/plurals.xml new file mode 100644 index 0000000..71bebf8 --- /dev/null +++ b/test/src/debug/res/values/plurals.xml @@ -0,0 +1,6 @@ + + + %d apple + %d apples + + \ No newline at end of file diff --git a/test/src/debug/res/values/strings.xml b/test/src/debug/res/values/strings.xml new file mode 100644 index 0000000..c2aa5a5 --- /dev/null +++ b/test/src/debug/res/values/strings.xml @@ -0,0 +1,3 @@ + + Hello, %s + \ No newline at end of file diff --git a/test/src/main/java/io/github/dkmarkell/test/TextResourceTest.kt b/test/src/main/java/io/github/dkmarkell/test/TextResourceTest.kt new file mode 100644 index 0000000..d9aacde --- /dev/null +++ b/test/src/main/java/io/github/dkmarkell/test/TextResourceTest.kt @@ -0,0 +1,70 @@ +package io.github.dkmarkell.test + +import io.github.dkmarkell.textresource.TextResource +import java.util.Locale + +/** + * Test utilities for resolving a [TextResource] to a [String] in **local unit tests**. + * + * Centralizes the Robolectric boilerplate (application + localized configuration context), + * so your specs can assert on final strings without wiring up `Context` for every test. + * + * Use cases: + * - Verify string resources and formatting args + * - Verify plurals across quantities + * - Verify localization by forcing a specific [java.util.Locale] + * + * ### Examples + * ```kotlin + * // Simple string with args + * val tr = TextResource.simple(R.string.greeting, "Derek") + * assertEquals("Hello, Derek", TextResourceTest.resolve(tr)) + * + * // Force a different locale + * val tr = TextResource.simple(R.string.greeting, "Derek") + * assertEquals("Bonjour, Derek", + * TextResourceTest.resolve(tr, locale = Locale.FRANCE)) + * + * // Plurals + * val apples = TextResource.plural(R.plurals.apples_count, 2, 2) + * assertEquals("2 apples", TextResourceTest.resolve(apples)) + * ``` + * + * ### Requirements + * - Runs under **Robolectric** (JVM unit tests in `src/test`), not `androidTest`. + * - Ensure `testOptions.unitTests.isIncludeAndroidResources = true` in your module. + * - Reference your module’s generated `R` (e.g., `import my.pkg.test.R`), or alias it if multiple modules define `R`. + * + * ### Notes + * - This helper is for tests only; it is not required at runtime. + * - For Compose code in production, prefer the `:compose` extension `TextResource.resolveString()` inside composition. + */ +public object TextResourceTest { + /** + * Resolves the given [TextResource] to a [String] using a localized Robolectric [android.content.Context]. + * + * Internally: + * - Obtains the application context from Robolectric + * - Applies the requested [locale] to a new configuration context + * - Delegates to [TextResource.resolveString] with that context + * + * @param textResource The [TextResource] under test (raw, simple, or plural). + * @param locale The [java.util.Locale] to apply for resource lookup. Defaults to [java.util.Locale.US]. + * @return The resolved string for the given configuration. + * @throws android.content.res.Resources.NotFoundException if the resource ID is invalid for this module. + * + * ### Example + * ```kotlin + * val tr = TextResource.simple(R.string.greeting, "Derek") + * val result = TextResourceTest.resolve(tr, Locale.FRANCE) + * assertEquals("Bonjour, Derek", result) + * ``` + */ + @JvmStatic + public fun resolveString(textResource: TextResource, locale: Locale = Locale.US): String { + val app = org.robolectric.RuntimeEnvironment.getApplication() + val conf = app.resources.configuration.apply { setLocale(locale) } + val localized = app.createConfigurationContext(conf) + return textResource.resolveString(localized) + } +} \ No newline at end of file diff --git a/test/src/test/java/io/github/dkmarkell/test/TextResourceResolveTest.kt b/test/src/test/java/io/github/dkmarkell/test/TextResourceResolveTest.kt new file mode 100644 index 0000000..f8f0a90 --- /dev/null +++ b/test/src/test/java/io/github/dkmarkell/test/TextResourceResolveTest.kt @@ -0,0 +1,78 @@ +package io.github.dkmarkell.test + +import io.github.dkmarkell.textresource.TextResource +import io.github.dkmarkell.textresource.test.R +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +class TextResourceResolveTest { + @Test + fun givenRaw_whenResolve_thenReturnsValue() { + + // Given + val tr = TextResource.raw("Hi") + + // When + val actual = TextResourceTest.resolveString(tr) + + // Then + assertEquals("Hi", actual) + } + + @Test + fun givenSimpleString_whenResolveInEnglish_thenFormatsName() { + // Given + val tr = TextResource.simple(R.string.greeting, "Derek") + + // When + val actual = TextResourceTest.resolveString(tr, Locale.US) + + // Then + assertEquals("Hello, Derek", actual) + } + + @Test + fun givenSimpleString_whenResolveInFrench_thenFormatsName() { + // Given + val tr = TextResource.simple(R.string.greeting, "Derek") + + // When + val actual = TextResourceTest.resolveString(tr, Locale.FRANCE) + + // Then + assertEquals("Bonjour, Derek", actual) + } + + @Test + fun givenPlural_whenResolve_thenHandlesOneAndOther() { + // Given + val one = TextResource.plural(R.plurals.apples_count, 1, 1) + val many = TextResource.plural(R.plurals.apples_count, 2, 2) + + // When + val oneResult = TextResourceTest.resolveString(one) + val manyResult = TextResourceTest.resolveString(many) + + // Then + assertEquals("1 apple", oneResult) + assertEquals("2 apples", manyResult) + } + + @Test + fun givenSamInitializer_whenResolve_thenUsesCustomLogic() { + // Given + val tr = TextResource { ctx -> + ctx.getString(R.string.greeting, "World") + } + + // When + val actual = TextResourceTest.resolveString(tr) + + // Then + assertEquals("Hello, World", actual) + } +} \ No newline at end of file