diff --git a/TICKETS.md b/TICKETS.md index dd6767ef..d31222c2 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -172,11 +172,11 @@ External multimodule OSS validation of meta-build axioms (structure over configu type-owned plugins, attrs-only call sites, closed matrix). **Not** another in-repo sample. Open coding: **F-115** (`in_progress`, **`priority: now`**, **cron may continue**). **F-094** Portal remains `blocked` (human). -4h workers: pick F-115 phase C remainder (Proto DataStore / flavors) while gated; if no ticket has `priority: now` / `cron may continue`, respond `[SILENT]` (audit 2026-07-29 gate — interactive “Go ahead” alone is not enough). +4h workers: pick F-115 phase C remainder (full designsystem) while gated; if no ticket has `priority: now` / `cron may continue`, respond `[SILENT]` (audit 2026-07-29 gate — interactive “Go ahead” alone is not enough). | ID | Status | Title | Notes | |----|--------|-------|-------| -| F-115 | in_progress · **priority: now** · **cron may continue** | Dogfood Now in Android under Forma | Primary OSS target [`android/nowinandroid`](https://github.com/android/nowinandroid). Design: [`docs/DOGFOOD-NIA.md`](docs/DOGFOOD-NIA.md). **Phase A+B+C through Proto DataStore done:** Hilt Path A + Room Path B + Firebase Path A + Preferences→**Proto** DataStore + ForYou/Bookmarks/Search/Settings + WorkManager sync + **`core-datastore-proto-library`** (`protobufLibrary` Path B). Findings F16–F26. **Next phase C:** flavors / full designsystem. No `androidLibrary`. | +| F-115 | in_progress · **priority: now** · **cron may continue** | Dogfood Now in Android under Forma | Primary OSS target [`android/nowinandroid`](https://github.com/android/nowinandroid). Design: [`docs/DOGFOOD-NIA.md`](docs/DOGFOOD-NIA.md). **Phase A+B+C through binary flavors done:** Hilt Path A + Room Path B + Firebase Path A + Proto DataStore + ForYou/Bookmarks/Search/Settings + WorkManager + **`FormaProductFlavor` demo/prod** on binary. Findings F16–F27. **Next phase C:** full designsystem. No `androidLibrary`. | ## Backlog (lower priority / historical GitHub) diff --git a/docs/CALL-SITE-SURFACE.md b/docs/CALL-SITE-SURFACE.md index da3e531b..db27e96b 100644 --- a/docs/CALL-SITE-SURFACE.md +++ b/docs/CALL-SITE-SURFACE.md @@ -32,7 +32,7 @@ All return **`Unit`**. | `api` | `api` | `packageName`, `dependencies` | JVM contracts; no Android UI | | `impl` | `impl` | deps, **`viewBinding`**, **`compose`**, test runners, `buildConfiguration` | Feature impl; no → other `impl` | | `androidApp` | `app` | deps, **`compose`**, `buildConfiguration`, … | Composition **library** (not APK) — **no** `versionCode`/`Name` | -| `androidBinary` | `binary` | **`versionCode`/`versionName` (required)**, deps, **`compose`**, **`signingConfigs` / `buildTypeSigning`**, … | APK composition root; version + signing are **per-binary only** (F-092 / F-097) | +| `androidBinary` | `binary` | **`versionCode`/`versionName` (required)**, deps, **`compose`**, **`signingConfigs` / `buildTypeSigning`**, **`productFlavors`**, … | APK composition root; version + signing + flavors are **per-binary only** (F-092 / F-097 / F-115) | | `library` | `library` | deps (pure JVM inside Android plugin) | Distinct from removed `androidLibrary` | | `util` | `util` | deps | JVM helpers | | `androidUtil` | `android-util` | deps, **`compose`** | Android helpers, no res content | @@ -147,8 +147,46 @@ env or `gradle.properties` (local, gitignored) — never from the repo. `assembl must stay green without secrets (omit `buildTypeSigning` for `debug` or leave AGP default). -**Not in scope:** product flavors, Play App Signing backend integration, library -AAR signing APIs. +**Not in scope:** Play App Signing backend integration, library AAR signing APIs. + +## Product flavors (F-115 / NiA F7) + +| Concern | Owner | Notes | +|---------|--------|--------| +| Flavor dimensions + product flavors | **`androidBinary` call site only** (`productFlavors: List`) | Wired to AGP `ApplicationExtension.flavorDimensions` + `productFlavors` | +| Build types | **`BuildConfiguration`** (unchanged) | Flavors are **not** stuffed into build-type map | +| Library / `impl` / `androidApp` flavor API | **None (v1)** | Unflavored libraries resolve against flavored APKs; multi-module `demoImplementation` is a documented gap | +| Raw `android { productFlavors { … } }` | **Rejected as happy path** | Escape hatch only | + +**Call-site shape (NiA `demo` / `prod`):** + +```kotlin +import tools.forma.android.utils.FormaProductFlavor + +androidBinary( + packageName = "com.example.app", + versionCode = 1, + versionName = "0.1.0", + productFlavors = listOf( + FormaProductFlavor( + name = "demo", + dimension = "contentType", + applicationIdSuffix = ".demo", + ), + FormaProductFlavor( + name = "prod", + dimension = "contentType", + ), + ), + dependencies = deps(/* … */), +) +// Tasks: assembleDemoDebug / assembleProdDebug (not bare assembleDebug) +``` + +**Empty list (default)** keeps a single unflavored APK so existing samples stay +`assembleDebug`. Optional attrs on [FormaProductFlavor]: `versionNameSuffix`, +`matchingFallbacks`, `manifestPlaceholders`, `buildConfigFields` (BuildConfig +fields apply only when project-global `buildFeatures.buildConfig` is already on). ## AGP BuildFeatures (F-091 / GH #88) diff --git a/docs/DOGFOOD-NIA.md b/docs/DOGFOOD-NIA.md index 591e3b12..69e0a157 100644 --- a/docs/DOGFOOD-NIA.md +++ b/docs/DOGFOOD-NIA.md @@ -1,6 +1,6 @@ # Dogfood: Now in Android → Forma (F-115) -**Status:** `in_progress` — phase C Hilt + Room + Firebase + **Proto DataStore** + features + Settings + WorkManager green (flavors / full designsystem next) +**Status:** `in_progress` — phase C through **binary product flavors** green (full designsystem next) **Upstream:** [android/nowinandroid](https://github.com/android/nowinandroid) (Apache-2.0) **Pinned checkout (local):** `/Users/claw/work/nowinandroid` @ `7d45eae` (main tip when cloned 2026-07-29) **Dogfood fork:** `/Users/claw/work/nowinandroid-forma` @@ -156,7 +156,8 @@ These are the **value** of dogfooding — expected friction, not blockers to ign ### F7 — Product flavors `demo` / `prod` - Affects source sets and dependency variants (`prodImplementation` FCM on sync). -- **Gap:** document whether Forma `buildConfiguration` covers flavor dimensions or temporary raw AGP remains behind binary type only. +- **Resolved (binary v1, 2026-07-31):** `FormaProductFlavor` + `androidBinary(productFlavors=…)` — binary-only; `BuildConfiguration` stays build-types. NiA spike: `demo`/`prod` on `contentType`; `assembleDemoDebug` / `assembleProdDebug`. +- **Still gap (F27):** library-level productFlavors + `demoImplementation`/`prodImplementation` edges (e.g. sync FCM) — not in v1; unflavored libraries resolve against flavored APK. ## Phased execution plan @@ -339,7 +340,21 @@ Workspace: same **`forma-spike`** external tree. | **F26** | Generated proto types expose lite supers → consumers need `protobuf-kotlin-lite` on compile classpath | | Verify | `forma-spike` `./gradlew :binary:assembleDebug` → **BUILD SUCCESSFUL** (493 tasks); APK ~14 MB | -**Still open in phase C:** flavors, full designsystem port. +**Still open in phase C (pre-flavors):** full designsystem port. + +### Phase C — Binary product flavors ✅ (2026-07-31) + +| Step | Result | +|------|--------| +| Engine | `FormaProductFlavor` + pure `resolveProductFlavorPlan` + `ApplicationExtension.applyProductFlavors`; binary-only attr on `androidBinary` / feature config | +| Keep separate | `BuildConfiguration` = build-types only (not flavors) | +| Spike binary | `hiltFirebaseBinary(productFlavors = demo+prod contentType)`; demo `applicationIdSuffix = ".demo"` | +| Tasks | `:binary:assembleDemoDebug` + `:binary:assembleProdDebug` (bare `assembleDebug` gone once flavored) | +| **F7** | closed for APK root | +| **F27** | multi-module library flavors / `prodImplementation` FCM still deferred — unflavored libs OK against flavored app | +| Verify | `forma-spike` assembleDemoDebug + assembleProdDebug green after `publish-local 0.1.3-NIA` | + +**Still open in phase C:** full designsystem / core.ui port. ### Phase D — Case study write-up @@ -374,6 +389,7 @@ Workspace: same **`forma-spike`** external tree. | F24 | 2026-07-31 | `deps()` overload mix | Cannot pass `.ksp` NamedDependency and `target()` FormaTarget in one `deps(...)` call (distinct overloads). Compose `transitiveDeps + deps(ksp) + deps(target)`. | | F25 | 2026-07-31 | Protobuf needs type-owned apply on JVM library | NiA `datastore-proto` is pure JVM + `com.google.protobuf`. Engine needed `libraryTarget` (like `androidUtilTarget`) so Path B `protobufLibrary` can pass derived type. Call sites stay attrs-only. | | F26 | 2026-07-31 | Proto lite supers on consumers | `UserPreferences` / enums extend protobuf lite; androidUtil consumer must `transitiveDeps(protobuf-kotlin-lite)` even when proto module already depends on it (project dep does not re-export non-api runtime the same way as NiA `api(libs.protobuf…)`). | +| F27 | 2026-07-31 | Library product flavors / variant deps | Binary-only `FormaProductFlavor` closes F7 for APK. NiA also flavors libraries + `prodImplementation(FCM)` on sync. v1 leaves libraries unflavored (AGP resolves). Future: optional library flavor attrs or type-owned missingDimensionStrategy — do **not** put free-form `android { productFlavors }` on every module. | ## Local reference commands diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b67d8e3f..fc7d7ea1 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,30 @@ Newest entries first. +## 2026-07-31 — F-115: NiA dogfood binary product flavors (F7) + +- **Ticket:** F-115 still `in_progress` · `priority: now` · `cron may continue` (full designsystem next) +- **Skills/modes:** Hermes direct dogfood + mechanical binary-only flavor API (F-092/F-097 class; no Grok Build this slice) +- **Engine (forma repo):** + - `FormaProductFlavor` + pure `resolveProductFlavorPlan` / `applyProductFlavors` + - `androidBinary(productFlavors=…)` + `AndroidBinaryFeatureConfiguration.productFlavors` + - `BuildConfiguration` stays build-types only; empty flavors = unflavored default + - Unit tests `FormaProductFlavorTest`; thin wrappers (`hiltBinary`/`hiltFirebaseBinary`/`firebaseBinary`) forward attr +- **External tree:** `/Users/claw/work/nowinandroid-forma/forma-spike` + - binary: demo/prod on `contentType`; demo `applicationIdSuffix=.demo` + - dummy `google-services.json` clients for base + `.demo` packages + - version `0.12.0-nia-forma-flavors` + - **F7** closed for APK root; **F27** library flavors / `prodImplementation` deferred +- **Verify (real host):** + - `plugins/` `./gradlew :android:test jacocoHappyPathCoverageVerification` → **BUILD SUCCESSFUL** + - `bash scripts/publish-local.sh 0.1.3-NIA` → ok + - `forma-spike` `./gradlew :binary:assembleDemoDebug :binary:assembleProdDebug` → **BUILD SUCCESSFUL** (538 tasks) + - APKs under `binary/build/outputs/apk/{demo,prod}/debug/` +- **Docs:** `docs/DOGFOOD-NIA.md` phase C flavors + F7/F27; `CALL-SITE-SURFACE` product flavors; TICKETS F-115 notes; spike README +- **Commits/PRs:** this branch → PR base `v2` +- **Blockers:** none product; F-094 Portal still human-blocked +- **Next:** full designsystem / core.ui port + ## 2026-07-31 — F-115: NiA dogfood Proto DataStore - **Ticket:** F-115 still `in_progress` · `priority: now` · `cron may continue` (flavors / full designsystem next) diff --git a/examples/android/14-google-firebase/forma-defs/src/main/kotlin/FirebaseBinary.kt b/examples/android/14-google-firebase/forma-defs/src/main/kotlin/FirebaseBinary.kt index 51478271..7e7c25d8 100644 --- a/examples/android/14-google-firebase/forma-defs/src/main/kotlin/FirebaseBinary.kt +++ b/examples/android/14-google-firebase/forma-defs/src/main/kotlin/FirebaseBinary.kt @@ -3,6 +3,7 @@ import tools.forma.android.target.AndroidTargetTypes import tools.forma.android.target.registerTargetPlugin import tools.forma.android.target.targetPlugin import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaProductFlavor import tools.forma.android.utils.FormaSigningConfig import tools.forma.android.visibility.Public import tools.forma.android.visibility.Visibility @@ -56,6 +57,7 @@ fun Project.firebaseBinary( buildConfiguration: BuildConfiguration = BuildConfiguration(), signingConfigs: Map = emptyMap(), buildTypeSigning: Map = emptyMap(), + productFlavors: List = emptyList(), ) { FirebasePluginBindings androidBinary( @@ -67,5 +69,6 @@ fun Project.firebaseBinary( buildConfiguration = buildConfiguration, signingConfigs = signingConfigs, buildTypeSigning = buildTypeSigning, + productFlavors = productFlavors, ) } diff --git a/plugins/android/src/main/java/androidBinary.kt b/plugins/android/src/main/java/androidBinary.kt index d3bb312d..f84bfbe9 100644 --- a/plugins/android/src/main/java/androidBinary.kt +++ b/plugins/android/src/main/java/androidBinary.kt @@ -6,6 +6,7 @@ import tools.forma.android.feature.applyFeatures import tools.forma.android.target.AndroidTargetRegistry import tools.forma.android.target.AndroidTargetTypes import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaProductFlavor import tools.forma.android.utils.FormaSigningConfig import tools.forma.android.validation.disallowResources import tools.forma.deps.core.FormaDependency @@ -39,6 +40,11 @@ import tools.forma.validation.validate * take a signing API. Do not use raw module-level `android { signingConfigs }` as * the supported path. * + * **Product flavors (F-115 / NiA F7):** [productFlavors] are **binary-only** attrs + * ([FormaProductFlavor]). [BuildConfiguration] stays build-types only. Empty list + * (default) keeps a single unflavored APK. v1 does **not** put flavors on library + * targets — multi-module `demoImplementation` edges stay a documented gap. + * * @param packageName Application package name / `applicationId` + namespace * @param owner owner of the target, team responsible for maintenance * @param versionCode Android `versionCode` for this APK (required; per-binary) @@ -47,6 +53,7 @@ import tools.forma.validation.validate * @param buildConfiguration Android Gradle Plugin build-type configuration DSL * @param signingConfigs named [FormaSigningConfig] entries for this APK (optional) * @param buildTypeSigning map of build type name → signing config name (optional) + * @param productFlavors product flavors for this APK (optional; empty = unflavored) * @param testInstrumentationRunner class name used for instrumentation tests execution * @param consumerMinificationFiles Proguard/R8 minification files list * @param manifestPlaceholders placeholders to be injected in manifest @@ -60,6 +67,7 @@ fun Project.androidBinary( buildConfiguration: BuildConfiguration = BuildConfiguration(), signingConfigs: Map = emptyMap(), buildTypeSigning: Map = emptyMap(), + productFlavors: List = emptyList(), testInstrumentationRunner: String = androidJunitRunner, consumerMinificationFiles: Set = emptySet(), manifestPlaceholders: Map = emptyMap(), @@ -83,6 +91,7 @@ fun Project.androidBinary( manifestPlaceholders = manifestPlaceholders, signingConfigs = signingConfigs, buildTypeSigning = buildTypeSigning, + productFlavors = productFlavors, selfValidator = selfV, compose = compose, ) diff --git a/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt b/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt index 0f0cb65c..af0073e4 100644 --- a/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt +++ b/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt @@ -5,10 +5,12 @@ package tools.forma.android.feature import com.android.build.api.dsl.ApplicationExtension import tools.forma.android.target.BinaryTargetTemplate import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaProductFlavor import tools.forma.android.utils.FormaSigningConfig import tools.forma.android.utils.applyBuildTypeSigning import tools.forma.android.utils.applyCoreLibraryDesugaring import tools.forma.android.utils.applyFrom +import tools.forma.android.utils.applyProductFlavors import tools.forma.android.utils.applySigningConfigs import tools.forma.validation.Validator import tools.forma.validation.validator @@ -21,6 +23,9 @@ import tools.forma.validation.validator * * [signingConfigs] / [buildTypeSigning] are **per-binary** (F-097 / GH #51) — * APK signing identity lives only on the composition root, not on library shells. + * + * [productFlavors] are **per-binary** (F-115 / NiA F7) — flavor dimensions live + * on the APK root only in v1; [BuildConfiguration] stays build-types only. */ data class AndroidBinaryFeatureConfiguration( val packageName: String, @@ -37,6 +42,11 @@ data class AndroidBinaryFeatureConfiguration( * so call sites do not need AGP `SigningConfig` references inside build-type lambdas. */ val buildTypeSigning: Map = emptyMap(), + /** + * Product flavors for this APK (optional). Empty = unflavored binary (default). + * See [FormaProductFlavor]. + */ + val productFlavors: List = emptyList(), /** Enable Jetpack Compose for the application (APK) target. */ val compose: Boolean = false, val selfValidator: Validator = validator(BinaryTargetTemplate) @@ -73,6 +83,8 @@ fun androidBinaryFeatureDefinition( signingConfigs = signingConfigs, buildTypeSigning = configuration.buildTypeSigning, ) + // Product flavors after build types (binary-only; empty = unflavored). + applyProductFlavors(configuration.productFlavors) compileOptions.applyFrom(formaConfiguration) applyCoreLibraryDesugaring(project, formaConfiguration) diff --git a/plugins/android/src/main/java/tools/forma/android/utils/FormaProductFlavor.kt b/plugins/android/src/main/java/tools/forma/android/utils/FormaProductFlavor.kt new file mode 100644 index 00000000..18ce00d2 --- /dev/null +++ b/plugins/android/src/main/java/tools/forma/android/utils/FormaProductFlavor.kt @@ -0,0 +1,131 @@ +package tools.forma.android.utils + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.ApplicationProductFlavor +import com.android.build.api.dsl.ProductFlavor + +/** + * Typed product-flavor identity for `androidBinary` (F-115 / NiA F7 dogfood). + * + * **Binary-only (v1):** product flavors are composition-root config — same placement + * rationale as [FormaSigningConfig] (F-097) and versionCode/Name (F-092). They are + * **not** stuffed into [BuildConfiguration] (build-types only) and are **not** a + * library/impl shopping API. + * + * Multi-module flavor dimensions (library `productFlavors` + `demoImplementation` + * edges) stay out of v1: AGP accepts unflavored library deps against a flavored + * application. Document gaps rather than invent dual happy paths. + * + * @param name flavor name (`demo`, `prod`, …) + * @param dimension flavor dimension name (`contentType`, …) + * @param applicationIdSuffix optional APK id suffix (e.g. `".demo"`); null = none + * @param versionNameSuffix optional versionName suffix; null = none + * @param matchingFallbacks optional AGP matchingFallbacks for this flavor + * @param manifestPlaceholders extra placeholders merged onto this flavor + * @param buildConfigFields optional BuildConfig field map (name → value expression) + * applied only when project-global `buildFeatures.buildConfig` is already on — + * Forma does not silently enable BuildConfig from flavors + */ +data class FormaProductFlavor( + val name: String, + val dimension: String, + val applicationIdSuffix: String? = null, + val versionNameSuffix: String? = null, + val matchingFallbacks: List = emptyList(), + val manifestPlaceholders: Map = emptyMap(), + val buildConfigFields: Map = emptyMap(), +) { + init { + require(name.isNotBlank()) { "FormaProductFlavor.name must not be blank" } + require(dimension.isNotBlank()) { "FormaProductFlavor.dimension must not be blank" } + } +} + +/** Typed BuildConfig field for [FormaProductFlavor.buildConfigFields]. */ +data class BuildConfigField( + val type: String, + val value: String, +) { + init { + require(type.isNotBlank()) { "BuildConfigField.type must not be blank" } + require(value.isNotBlank()) { "BuildConfigField.value must not be blank" } + } +} + +/** + * Pure plan for AGP apply: ordered unique dimensions + flavors. + * + * @throws IllegalArgumentException on blank names, duplicate flavor names, or + * empty non-null lists that would create a half-configured dimension set + */ +fun resolveProductFlavorPlan( + flavors: List, +): ProductFlavorPlan { + if (flavors.isEmpty()) return ProductFlavorPlan.EMPTY + val names = mutableSetOf() + val dimensions = linkedSetOf() + flavors.forEach { flavor -> + require(flavor.name !in names) { + "Duplicate product flavor name '${flavor.name}' " + + "(each name may appear once on androidBinary)" + } + names += flavor.name + dimensions += flavor.dimension + } + return ProductFlavorPlan( + dimensions = dimensions.toList(), + flavors = flavors, + ) +} + +/** Ordered dimension names + flavor list ready for AGP containers. */ +data class ProductFlavorPlan( + val dimensions: List, + val flavors: List, +) { + companion object { + val EMPTY = ProductFlavorPlan(emptyList(), emptyList()) + } +} + +/** + * Applies [ProductFlavorPlan] onto AGP [ApplicationExtension] flavor containers. + * + * No-op when the plan is empty (default `androidBinary` call sites). + */ +fun ApplicationExtension.applyProductFlavors(plan: ProductFlavorPlan) { + if (plan.flavors.isEmpty()) return + flavorDimensions.clear() + flavorDimensions.addAll(plan.dimensions) + plan.flavors.forEach { forma -> + val existing = productFlavors.findByName(forma.name) + val flavor = existing ?: productFlavors.create(forma.name) + applyFormaProductFlavor(flavor, forma) + } +} + +/** Writes one [FormaProductFlavor] onto an AGP [ProductFlavor] / application flavor. */ +fun applyFormaProductFlavor(target: ProductFlavor, forma: FormaProductFlavor) { + target.dimension = forma.dimension + if (target is ApplicationProductFlavor) { + forma.applicationIdSuffix?.let { target.applicationIdSuffix = it } + forma.versionNameSuffix?.let { target.versionNameSuffix = it } + } + if (forma.matchingFallbacks.isNotEmpty()) { + target.setMatchingFallbacks(forma.matchingFallbacks) + } + if (forma.manifestPlaceholders.isNotEmpty()) { + target.manifestPlaceholders.putAll(forma.manifestPlaceholders) + } + forma.buildConfigFields.forEach { (fieldName, field) -> + target.buildConfigField(field.type, fieldName, field.value) + } +} + +/** + * Convenience: resolve + apply in one step for binary feature definition. + * Empty [flavors] is a no-op (keeps unflavored APKs working). + */ +fun ApplicationExtension.applyProductFlavors(flavors: List) { + applyProductFlavors(resolveProductFlavorPlan(flavors)) +} diff --git a/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaProductFlavorTest.kt b/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaProductFlavorTest.kt new file mode 100644 index 00000000..1e44c99e --- /dev/null +++ b/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaProductFlavorTest.kt @@ -0,0 +1,112 @@ +package tools.forma.android.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Pure unit coverage for binary product-flavor model (F-115 / NiA F7). + * No Gradle Project / AGP containers. + */ +class FormaProductFlavorTest { + + @Test + fun `resolveProductFlavorPlan empty is EMPTY`() { + assertEquals(ProductFlavorPlan.EMPTY, resolveProductFlavorPlan(emptyList())) + assertTrue(ProductFlavorPlan.EMPTY.dimensions.isEmpty()) + assertTrue(ProductFlavorPlan.EMPTY.flavors.isEmpty()) + } + + @Test + fun `resolveProductFlavorPlan keeps call-site order and unique dimensions`() { + val plan = resolveProductFlavorPlan( + listOf( + FormaProductFlavor(name = "demo", dimension = "contentType", applicationIdSuffix = ".demo"), + FormaProductFlavor(name = "prod", dimension = "contentType"), + ), + ) + assertEquals(listOf("contentType"), plan.dimensions) + assertEquals(listOf("demo", "prod"), plan.flavors.map { it.name }) + assertEquals(".demo", plan.flavors[0].applicationIdSuffix) + assertEquals(null, plan.flavors[1].applicationIdSuffix) + } + + @Test + fun `resolveProductFlavorPlan preserves multiple dimensions in first-seen order`() { + val plan = resolveProductFlavorPlan( + listOf( + FormaProductFlavor(name = "demo", dimension = "contentType"), + FormaProductFlavor(name = "free", dimension = "store"), + FormaProductFlavor(name = "prod", dimension = "contentType"), + FormaProductFlavor(name = "paid", dimension = "store"), + ), + ) + assertEquals(listOf("contentType", "store"), plan.dimensions) + assertEquals(listOf("demo", "free", "prod", "paid"), plan.flavors.map { it.name }) + } + + @Test + fun `resolveProductFlavorPlan rejects duplicate flavor names`() { + val error = assertFailsWith { + resolveProductFlavorPlan( + listOf( + FormaProductFlavor(name = "demo", dimension = "contentType"), + FormaProductFlavor(name = "demo", dimension = "other"), + ), + ) + } + assertTrue(error.message!!.contains("Duplicate product flavor name 'demo'")) + } + + @Test + fun `FormaProductFlavor rejects blank name or dimension`() { + assertFailsWith { + FormaProductFlavor(name = " ", dimension = "contentType") + } + assertFailsWith { + FormaProductFlavor(name = "demo", dimension = "") + } + } + + @Test + fun `BuildConfigField rejects blank type or value`() { + assertFailsWith { + BuildConfigField(type = "", value = "\"x\"") + } + assertFailsWith { + BuildConfigField(type = "String", value = " ") + } + } + + @Test + fun `NiA-shaped demo prod plan matches upstream contentType dimension`() { + val plan = resolveProductFlavorPlan( + listOf( + FormaProductFlavor( + name = "demo", + dimension = "contentType", + applicationIdSuffix = ".demo", + buildConfigFields = mapOf( + "NIA_CONTENT_TYPE" to BuildConfigField("String", "\"demo\""), + ), + ), + FormaProductFlavor( + name = "prod", + dimension = "contentType", + buildConfigFields = mapOf( + "NIA_CONTENT_TYPE" to BuildConfigField("String", "\"prod\""), + ), + ), + ), + ) + assertEquals(listOf("contentType"), plan.dimensions) + assertEquals("demo", plan.flavors[0].name) + assertEquals("prod", plan.flavors[1].name) + assertEquals(".demo", plan.flavors[0].applicationIdSuffix) + assertEquals( + BuildConfigField("String", "\"demo\""), + plan.flavors[0].buildConfigFields["NIA_CONTENT_TYPE"], + ) + } +}