diff --git a/.gitignore b/.gitignore index 1375c4c..04bfdc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle/ +.kotlin/ .idea/ .worktrees/ build/ @@ -14,3 +15,15 @@ local.properties website/node_modules/ website/dist/ output/playwright/ + +# Local coding-agent instructions and state +.codex/ +.claude/ +.superpowers/ +AGENTS.md +CLAUDE.md +GEMINI.md +/docs/superpowers/ + +# Locally generated verification and performance reports +/docs/testing/ diff --git a/README.md b/README.md index b76b3fa..c833300 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ Composium is an Android Jetpack Compose library for building an in-app UI catalo One of the core ideas of the library is that it gives you a ready-to-embed `ComposiumScreen()` composable. You can place this screen anywhere in your app: in a debug-only route, a separate activity, an internal tools section, or any custom navigation graph. `ComposiumScreen()` will render the scenes that you described in your project and turn them into a browsable interactive catalog. -Version: `1.3.0-alpha03` (pre-release) +Version: `1.3.0-alpha04` (pre-release) Artifacts: -- `io.github.oleginvoke:composium:1.3.0-alpha03` -- `io.github.oleginvoke:composium-processor:1.3.0-alpha03` +- `io.github.oleginvoke:composium:1.3.0-alpha04` +- `io.github.oleginvoke:composium-processor:1.3.0-alpha04` It is useful for: - design systems and component libraries; @@ -101,8 +101,8 @@ plugins { } dependencies { - implementation("io.github.oleginvoke:composium:1.3.0-alpha03") - ksp("io.github.oleginvoke:composium-processor:1.3.0-alpha03") + implementation("io.github.oleginvoke:composium:1.3.0-alpha04") + ksp("io.github.oleginvoke:composium-processor:1.3.0-alpha04") } ``` @@ -116,7 +116,7 @@ Automatic discovery collects scenes declared in that showcase module; it does no ```kotlin dependencies { - implementation("io.github.oleginvoke:composium:1.3.0-alpha03") + implementation("io.github.oleginvoke:composium:1.3.0-alpha04") } ``` @@ -712,6 +712,25 @@ For referential values such as `Painter`, names act as the stable identity for e If explicit option names collide inside the same parameter, Composium will append numeric suffixes automatically until every name becomes unique. +### Updating named options + +Use stable, unique option names when values depend on a theme or other changing scene state: + +```kotlin +val background: Color by param( + listOf( + MaterialTheme.colorScheme.background named "colorScheme.background", + MaterialTheme.colorScheme.surface named "colorScheme.surface", + ), +) +``` + +Selecting `colorScheme.surface` preserves that choice even when both colors are equal. When the scene recomposes with a new color scheme and supplies updated options, the parameter follows the current value of `colorScheme.surface` without another click. The initial default can be implicit or unnamed; a default matching an option by value uses that option's name. + +Compute theme-dependent options inside the themed composition so they receive current values. A list cached without the relevant theme keys, or mutated in place without notifying Compose, cannot provide this update. Generated names that change with the values do not establish a stable choice across themes. + +Programmatic assignments should use values from the declared options. A changed assignment identifies the option to follow; assigning an equal value remains a no-op. Assignments outside the options do not throw, but their retention across option updates is not guaranteed. + ### Nullable parameters Nullable parameters get an extra checkbox that controls whether the value is currently `null`. @@ -748,6 +767,8 @@ What happens in the UI: - unchecked means the parameter is currently `null`; - checked restores the value and shows its regular control. +For mutable nullable parameters, assigning `null` from scene code also unchecks the checkbox; assigning a non-null value checks it. `false` and an empty string are non-null values. Disabling preserves the last non-null value or named choice, including when code assigns a value and then `null` before the next composition. Re-enabling a named choice uses its current option value, so a disabled color parameter can follow the latest theme when restored. Empty choices without a restoration value remain null and unchecked. + For nullable parameters with explicit options, do not include `null` in the list yourself. Pass only non-null values and let Composium manage the null-state toggle. If you declare a nullable parameter with `param(options = ...)` and do not provide an explicit `default`, Composium uses the first option from the list as the initial value (including `null` if it is first). diff --git a/gradle.properties b/gradle.properties index 78ccee0..1843bc6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,7 +28,7 @@ org.gradle.caching=true # Publishing (used by com.vanniktech.maven.publish) GROUP=io.github.oleginvoke -VERSION_NAME=1.3.0-alpha03 +VERSION_NAME=1.3.0-alpha04 # Maven Central release credentials must not be committed. # Provide them via ~/.gradle/gradle.properties or ORG_GRADLE_PROJECT_* env vars: diff --git a/macrobenchmark/build.gradle.kts b/macrobenchmark/build.gradle.kts index dd37557..eaaf03c 100644 --- a/macrobenchmark/build.gradle.kts +++ b/macrobenchmark/build.gradle.kts @@ -18,6 +18,14 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildTypes { + create("benchmark") { + isDebuggable = true + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + } + } + targetProjectPath = ":sample" experimentalProperties["android.experimental.self-instrumenting"] = true diff --git a/macrobenchmark/src/main/kotlin/com/example/app/macrobenchmark/ComposiumMacrobenchmark.kt b/macrobenchmark/src/main/kotlin/com/example/app/macrobenchmark/ComposiumMacrobenchmark.kt index 89ccb49..bf7cf6f 100644 --- a/macrobenchmark/src/main/kotlin/com/example/app/macrobenchmark/ComposiumMacrobenchmark.kt +++ b/macrobenchmark/src/main/kotlin/com/example/app/macrobenchmark/ComposiumMacrobenchmark.kt @@ -151,6 +151,101 @@ class ComposiumMacrobenchmark { device.waitForIdle() } + @Test + fun reactiveParameterThemeSwitching() = benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf(FrameTimingMetric()), + iterations = 5, + startupMode = StartupMode.WARM, + setupBlock = { + pressHome() + startActivityAndWait() + openReactiveParametersScene() + }, + ) { + var status = reactiveParameterStatus() + repeat(8) { + clickObject(By.desc("Switch product theme"), "product theme button") + status = status.copy( + isProductB = !status.isProductB, + themeSwitches = status.themeSwitches + 1, + ) + waitForReactiveParameterStatus(status) + } + } + + @Test + fun reactiveParameterUnrelatedRecomposition() = benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf(FrameTimingMetric()), + iterations = 5, + startupMode = StartupMode.WARM, + setupBlock = { + pressHome() + startActivityAndWait() + openReactiveParametersScene() + }, + ) { + var status = reactiveParameterStatus() + repeat(10) { + clickObject(By.desc("Recompose scene"), "unrelated recomposition button") + status = status.copy(unrelatedRecompositions = status.unrelatedRecompositions + 1) + waitForReactiveParameterStatus(status) + } + } + + private fun openReactiveParametersScene() { + // A warm activity can retain the previous iteration's scene. Reopen it to reset counters. + device.findObject(By.desc("Return to catalog"))?.click() + waitForMainScreen() + focusSearchField() + device.findObject(By.desc("Clear search"))?.click() + device.executeShellCommand("input text Reactive") + waitForObject(By.text(REACTIVE_PARAMETERS_SCENE_NAME), "reactive parameters scene result") + device.pressBack() // Dismiss the search keyboard before entering the scene. + clickObject(By.text(REACTIVE_PARAMETERS_SCENE_NAME), "reactive parameters scene") + val status = reactiveParameterStatus() + check(status.themeSwitches == 0 && status.unrelatedRecompositions == 0) { + "Reactive parameter scene counters did not reset: $status" + } + device.waitForIdle() + } + + private fun reactiveParameterStatus(): ReactiveParameterStatus { + val text = checkNotNull( + waitForObject( + By.desc("Reactive parameter status"), + "reactive parameter status", + ).text, + ) { "Reactive parameter status has no text" } + val match = checkNotNull(REACTIVE_PARAMETER_STATUS_PATTERN.matchEntire(text)) { + "Unexpected reactive parameter status: $text" + } + return ReactiveParameterStatus( + isProductB = match.groupValues[1] == "B", + themeSwitches = match.groupValues[2].toInt(), + unrelatedRecompositions = match.groupValues[3].toInt(), + ) + } + + private fun waitForReactiveParameterStatus(status: ReactiveParameterStatus) { + // Wait for the product theme and event counters, never for reactive color updates. + // This permits measurements against the baseline runtime where selected colors stay stale. + waitForObject(By.text(status.text), "reactive parameter status ${status.text}") + device.waitForIdle() + } + + private data class ReactiveParameterStatus( + val isProductB: Boolean, + val themeSwitches: Int, + val unrelatedRecompositions: Int, + ) { + val text: String + get() = "Theme: product ${if (isProductB) "B" else "A"}; " + + "theme switches: $themeSwitches; " + + "unrelated recompositions: $unrelatedRecompositions; params: 100" + } + private fun waitForMainScreen() { waitForObject(By.text("Composium"), "main screen title") waitForObject(By.textContains("Search scenes"), "search field") @@ -205,6 +300,10 @@ class ComposiumMacrobenchmark { companion object { private const val TARGET_PACKAGE = "com.example.ComposiumSample" private const val BENCHMARK_SCENE_NAME = "Benchmark 1" + private const val REACTIVE_PARAMETERS_SCENE_NAME = "Reactive parameters" private const val WAIT_TIMEOUT_MS = 5_000L + private val REACTIVE_PARAMETER_STATUS_PATTERN = Regex( + "Theme: product (A|B); theme switches: (\\d+); unrelated recompositions: (\\d+); params: 100", + ) } } diff --git a/runtime/src/main/kotlin/composium/ParamOptionUpdates.kt b/runtime/src/main/kotlin/composium/ParamOptionUpdates.kt new file mode 100644 index 0000000..66e4558 --- /dev/null +++ b/runtime/src/main/kotlin/composium/ParamOptionUpdates.kt @@ -0,0 +1,56 @@ +package oleginvoke.com.composium + +/** Resolve a valid assignment while retaining the existing preference between equal values. */ +internal fun assignedParamOptionName( + options: List>, + preferredName: String?, + value: Any?, +): String? { + if (value == null) return null + var firstMatch: String? = null + var index = 0 + while (index < options.size) { + val option = options[index++] + if (option.value == value) { + if (option.name == preferredName) return option.name + if (firstMatch == null) firstMatch = option.name + } + } + return firstMatch +} + +/** A single scan on changed options; ordinary delegate reads never call this function. */ +internal fun updatedParamOption( + options: List>, + intendedName: String?, + preferredValueName: String?, + value: Any, + preferAssignedValue: Boolean, + defaultName: String?, +): ParamOption? { + var namedOption: ParamOption? = null + var firstValueMatch: ParamOption? = null + var preferredValueMatch: ParamOption? = null + var defaultOption: ParamOption? = null + var index = 0 + while (index < options.size) { + val option = options[index++] + if (option.name == intendedName) { + if (!preferAssignedValue) return option + namedOption = option + } + if (option.value == value) { + if (firstValueMatch == null) firstValueMatch = option + if (option.name == preferredValueName) { + if (preferAssignedValue) return option + preferredValueMatch = option + } + } + if (option.name == defaultName) defaultOption = option + } + return if (preferAssignedValue) { + preferredValueMatch ?: firstValueMatch ?: namedOption ?: defaultOption ?: options.firstOrNull() + } else { + namedOption ?: preferredValueMatch ?: firstValueMatch ?: defaultOption ?: options.firstOrNull() + } +} diff --git a/runtime/src/main/kotlin/composium/ParamProperty.kt b/runtime/src/main/kotlin/composium/ParamProperty.kt index cfbd64c..6ca4c17 100644 --- a/runtime/src/main/kotlin/composium/ParamProperty.kt +++ b/runtime/src/main/kotlin/composium/ParamProperty.kt @@ -1,11 +1,19 @@ package oleginvoke.com.composium import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import kotlin.properties.ReadWriteProperty import kotlin.reflect.KClass import kotlin.reflect.KProperty import kotlin.reflect.KType +/** Identity distinguishes consecutive writes, including writes back to an earlier value. */ +internal class ParamValueAssignment( + val optionName: String?, + val lastNonNullValue: Any?, + val hasNonNullAssignment: Boolean, +) + /** * Property delegate returned by [SceneScope.param]. * Obtain instances through [SceneScope.param] so controls registration is configured. @@ -29,6 +37,20 @@ class ParamProperty internal constructor( internal var bindingSignature: Any? = null internal var lastRegisteredName: String? = null + internal var objectOptions: List>? = null + internal var unregisteredOptions: List>? = null + internal var intendedOptionName: String? = null + internal var activationState: MutableState? = null + internal var onValueAssigned: ((Any?) -> Unit)? = null + internal val valueAssignmentState = mutableStateOf(null) + internal var acknowledgedValueAssignment: ParamValueAssignment? = null + internal val hasPendingValueAssignment: Boolean + get() = valueAssignmentState.value !== acknowledgedValueAssignment + + internal fun acknowledgeValueAssignment() { + acknowledgedValueAssignment = valueAssignmentState.value + } + internal val resolvedName: String? get() = explicitName ?: delegatedName @@ -62,12 +84,19 @@ class ParamProperty internal constructor( /** * Updates parameter value. + * Nullable assignments synchronize activity and preserve the last non-null selection. * * @param thisRef Owner object of delegated property. * @param property Delegated property metadata. * @param value New value. */ override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + // Preserve Compose's no-op policy for equal writes, including SideEffect writes. + val requestedActivity = value != null + val activityChanged = activationState?.value?.let { it != requestedActivity } == true + if (state.value == value && !activityChanged) return state.value = value + if (activityChanged) activationState?.value = requestedActivity + onValueAssigned?.invoke(value) } } diff --git a/runtime/src/main/kotlin/composium/SceneScope.kt b/runtime/src/main/kotlin/composium/SceneScope.kt index 6fc3c90..0696da1 100644 --- a/runtime/src/main/kotlin/composium/SceneScope.kt +++ b/runtime/src/main/kotlin/composium/SceneScope.kt @@ -99,6 +99,7 @@ class SceneScope internal constructor() { val name: String val typeName: String val activationState: MutableState? + val state: MutableState fun toDescriptor(): ParamDescriptor fun snapshot(): ParamStateSnapshot @@ -112,30 +113,11 @@ class SceneScope internal constructor() { val selectedName: String? = null, ) - private data class ParamOptionsSignature( - val size: Int, - val contentHash: Int, - ) - - private fun optionsSignature(options: List>?): ParamOptionsSignature? { - if (options == null) return null - - var hash = 17 - options.forEach { option -> - hash = 31 * hash + (option.value?.hashCode() ?: 0) - hash = 31 * hash + option.name.hashCode() - } - return ParamOptionsSignature( - size = options.size, - contentHash = hash, - ) - } - private data class ParamBindingSignature( val name: String, val defaultValue: Any?, val defaultName: String?, - val optionsSignature: ParamOptionsSignature?, + val options: List>?, val delegatedType: KType?, val delegatedKClass: KClass<*>?, val delegatedNullable: Boolean?, @@ -204,7 +186,7 @@ class SceneScope internal constructor() { override val name: String, override val typeName: String, override val activationState: MutableState?, - val state: MutableState, + override val state: MutableState, val lastNonNullState: MutableState, val defaultRestoreValue: Boolean, ) : ParamBinding { @@ -244,7 +226,7 @@ class SceneScope internal constructor() { override val name: String, override val typeName: String, override val activationState: MutableState?, - val state: MutableState, + override val state: MutableState, val lastNonNullState: MutableState, val defaultRestoreValue: String, ) : ParamBinding { @@ -284,7 +266,7 @@ class SceneScope internal constructor() { override val name: String, override val typeName: String, override val activationState: MutableState?, - val state: MutableState, + override val state: MutableState, val lastNonNullState: MutableState, val selectedNameState: MutableState, val lastNonNullSelectedNameState: MutableState, @@ -308,9 +290,13 @@ class SceneScope internal constructor() { return options.isEmpty() || options.any { option -> option.name == optionName } } - private fun resolvedSelectedName(currentValue: Any?): String? { + fun resolvedSelectedName(currentValue: Any?): String? { if (currentValue == null) return null + matchedOptionByName(selectedNameState.value) + ?.takeIf { it.value == currentValue } + ?.let { return it.name } + matchedOptionByValue(currentValue)?.let { matched -> return matched.name } @@ -405,15 +391,34 @@ class SceneScope internal constructor() { val binding = paramBindings[intent.paramName] ?: return val activationState = binding.activationState ?: return if (activationState.value != intent.isActive) { - activationState.value = intent.isActive - binding.onActivationChanged(intent.isActive) + val owner = paramBindingOwners[intent.paramName] + val pending = owner?.valueAssignmentState?.value + ?.takeIf { owner.hasPendingValueAssignment && it.hasNonNullAssignment } + if (intent.isActive && binding.state.value == null && pending?.lastNonNullValue != null) { + // A new choice may belong to options queued for this same composition. + // Preserve its pending value priority until those options are registered. + binding.state.value = pending.lastNonNullValue + if (binding is ObjectParamBinding) binding.selectedNameState.value = pending.optionName + } else { + binding.onActivationChanged(intent.isActive) + } + activationState.value = intent.isActive && binding.state.value != null + if (binding is ObjectParamBinding) { + owner?.takeIf { pending == null }?.let { owner -> + owner.intendedOptionName = binding.resolvedSelectedName(binding.state.value) + ?: binding.selectedNameState.value + owner.acknowledgeValueAssignment() + } + } refreshDescriptorIfStateChanged(intent.paramName) } } is SceneParamIntent.BooleanParamChanged -> { val binding = paramBindings[intent.paramName] as? BooleanParamBinding ?: return - if ((binding.state.value as? Boolean) != intent.value) { + paramBindingOwners[intent.paramName]?.acknowledgeValueAssignment() + if ((binding.state.value as? Boolean) != intent.value || binding.activationState?.value == false) { + binding.activationState?.value = true binding.state.value = intent.value binding.lastNonNullState.value = intent.value refreshDescriptorIfStateChanged(intent.paramName) @@ -422,7 +427,9 @@ class SceneScope internal constructor() { is SceneParamIntent.StringParamChanged -> { val binding = paramBindings[intent.paramName] as? StringParamBinding ?: return - if ((binding.state.value as? String) != intent.value) { + paramBindingOwners[intent.paramName]?.acknowledgeValueAssignment() + if ((binding.state.value as? String) != intent.value || binding.activationState?.value == false) { + binding.activationState?.value = true binding.state.value = intent.value binding.lastNonNullState.value = intent.value refreshDescriptorIfStateChanged(intent.paramName) @@ -431,7 +438,15 @@ class SceneScope internal constructor() { is SceneParamIntent.ObjectParamChanged -> { val binding = paramBindings[intent.paramName] as? ObjectParamBinding ?: return - if (binding.state.value != intent.option.value || binding.selectedNameState.value != intent.option.name) { + // A repeated click still supersedes an explicit assignment made before it. + paramBindingOwners[intent.paramName]?.let { owner -> + owner.intendedOptionName = intent.option.name + owner.acknowledgeValueAssignment() + } + if (binding.state.value != intent.option.value || binding.selectedNameState.value != intent.option.name || + binding.activationState?.value == false + ) { + binding.activationState?.value = true binding.state.value = intent.option.value binding.lastNonNullState.value = intent.option.value binding.selectedNameState.value = intent.option.name @@ -450,6 +465,15 @@ class SceneScope internal constructor() { "Use a different property name or pass a unique name to param()." } + if (binding is ObjectParamBinding) { + updateObjectOptions(binding, owner) + } else { + owner.objectOptions = null + owner.intendedOptionName = null + owner.acknowledgeValueAssignment() + } + owner.unregisteredOptions = null + // Validate the new name before releasing the previous registration. owner.lastRegisteredName?.takeIf { it != binding.name }?.let { previousName -> unregisterBinding(previousName, owner) @@ -460,6 +484,130 @@ class SceneScope internal constructor() { upsertDescriptor(binding.toDescriptor()) } + private fun updateObjectOptions(binding: ObjectParamBinding, owner: ParamProperty<*>) { + val previousOptions = owner.objectOptions + val value = binding.state.value + if (owner.hasPendingValueAssignment && value != null) { + owner.intendedOptionName = owner.valueAssignmentState.value?.optionName + } + if (previousOptions == null) { + owner.intendedOptionName = binding.resolvedSelectedName(value) ?: binding.selectedNameState.value + } else if (previousOptions != binding.options) { + if (binding.options.isEmpty() && previousOptions.isNotEmpty() && binding.activationState != null) { + if (value != null) binding.state.value = null + if (binding.lastNonNullState.value != null) binding.lastNonNullState.value = null + if (binding.activationState.value) binding.activationState.value = false + } else if (value != null) { + val intendedName = owner.intendedOptionName + val option = updatedParamOption( + options = binding.options, + intendedName = intendedName, + preferredValueName = binding.selectedNameState.value, + value = value, + preferAssignedValue = owner.hasPendingValueAssignment, + defaultName = binding.defaultRestoreName, + ) + if (option != null) { + val renamedSelection = !owner.hasPendingValueAssignment && intendedName != null && + option.name != intendedName && option.value == value && + previousOptions.any { it.name == intendedName } && + previousOptions.none { it.name == option.name } + if (owner.hasPendingValueAssignment || intendedName == null || + option.name == intendedName || renamedSelection + ) { + owner.intendedOptionName = option.name + if (binding.selectedNameState.value != option.name) binding.selectedNameState.value = option.name + if (binding.lastNonNullSelectedNameState.value != option.name) { + binding.lastNonNullSelectedNameState.value = option.name + } + } + // Keep a missing intended name for a later reintroduction, but expose a valid value now. + if (binding.state.value != option.value) binding.state.value = option.value + if (binding.lastNonNullState.value != option.value) binding.lastNonNullState.value = option.value + } + } + } + if (binding.state.value == null && binding.activationState != null && + owner.hasPendingValueAssignment && !(binding.options.isEmpty() && previousOptions?.isNotEmpty() == true) + ) { + reconcileNullAssignment(binding, owner) + } + owner.objectOptions = binding.options + owner.acknowledgeValueAssignment() + } + + private fun reconcileNullAssignment(binding: ObjectParamBinding, owner: ParamProperty<*>) { + val assignment = owner.valueAssignmentState.value ?: return + val value = assignment.lastNonNullValue ?: return + if (binding.options.isEmpty()) { + owner.intendedOptionName = assignment.optionName ?: owner.intendedOptionName + return + } + val option = updatedParamOption( + options = binding.options, + intendedName = assignment.optionName ?: owner.intendedOptionName, + preferredValueName = binding.selectedNameState.value, + value = value, + preferAssignedValue = assignment.hasNonNullAssignment, + defaultName = binding.defaultRestoreName, + ) ?: return + if (assignment.hasNonNullAssignment || assignment.optionName == null || option.name == assignment.optionName) { + owner.intendedOptionName = option.name + if (binding.lastNonNullSelectedNameState.value != option.name) { + binding.lastNonNullSelectedNameState.value = option.name + } + } + if (binding.lastNonNullState.value != option.value) binding.lastNonNullState.value = option.value + } + + private fun initializeValueAssignments( + owner: ParamProperty<*>, + lastBoolean: MutableState, + lastString: MutableState, + lastObject: MutableState, + selectedName: MutableState, + lastObjectName: MutableState, + ) { + owner.onValueAssigned = onAssigned@{ assigned -> + val options = owner.objectOptions ?: owner.unregisteredOptions + val nullable = owner.activationState != null + if (!nullable && options.isNullOrEmpty()) return@onAssigned + val resolvedClass = (owner.delegatedType?.classifier as? KClass<*>) + ?: owner.delegatedKClass ?: owner.declaredKClass + val boolean = options == null && resolvedClass == Boolean::class + val string = options == null && resolvedClass == String::class + val pendingNonNull = owner.hasPendingValueAssignment && + owner.valueAssignmentState.value?.hasNonNullAssignment == true + val name = if (assigned != null) { + if (options.isNullOrEmpty()) selectedName.value + else assignedParamOptionName(options, selectedName.value, assigned) + } else { + lastObjectName.value + } + if (nullable && assigned != null) { + when { + boolean -> lastBoolean.value = assigned as Boolean + string -> lastString.value = assigned as String + else -> { + lastObject.value = assigned + lastObjectName.value = name + } + } + } + val lastValue = assigned ?: when { + boolean -> lastBoolean.value + string -> lastString.value + else -> lastObject.value + } + // Value, activity, history, and pending intent share the caller's snapshot. + owner.valueAssignmentState.value = ParamValueAssignment( + optionName = name, + lastNonNullValue = lastValue, + hasNonNullAssignment = assigned != null || pendingNonNull, + ) + } + } + private fun unregisterBinding(paramName: String, owner: ParamProperty<*>) { if (paramBindingOwners[paramName] !== owner) return paramBindingOwners.remove(paramName) @@ -536,9 +684,25 @@ class SceneScope internal constructor() { it.explicitName = name it.declaredKClass = declaredKClass it.declaredNullable = declaredNullable + it.activationState = isActive + @Suppress("UNCHECKED_CAST") + val initialOptions = explicitOptionsResolution?.options as? List> + it.unregisteredOptions = initialOptions + initializeValueAssignments( + owner = it, + lastBoolean = lastNonNullBooleanState, + lastString = lastNonNullStringState, + lastObject = lastNonNullObjectState, + selectedName = selectedObjectNameState, + lastObjectName = lastNonNullSelectedObjectNameState, + ) } } + // Only changed choice assignments publish a record. Reading it here + // refreshes registration when the delegate is only read by a child. + property.valueAssignmentState.value + // Release renamed bindings during disposal, before any SideEffect registers // this composition's names. This also allows two declarations to swap names. DisposableEffect(property, name) { @@ -566,17 +730,13 @@ class SceneScope internal constructor() { SideEffect { property.explicitName = name } val explicitOptionItems: List>? = explicitOptionsResolution?.options - val explicitOptionsSignature = remember(explicitOptionItems) { - optionsSignature(explicitOptionItems) - } - SideEffect { val resolvedName = property.resolvedName ?: return@SideEffect val signature = ParamBindingSignature( name = resolvedName, defaultValue = default.value as Any?, defaultName = default.name, - optionsSignature = explicitOptionsSignature, + options = explicitOptionItems, delegatedType = property.delegatedType, delegatedKClass = property.delegatedKClass, delegatedNullable = property.delegatedNullable, @@ -588,7 +748,23 @@ class SceneScope internal constructor() { val previousName = property.lastRegisteredName if (property.bindingSignature == signature && previousName == resolvedName) { - refreshDescriptorIfStateChanged(resolvedName) + if (property.hasPendingValueAssignment) { + val binding = paramBindings[resolvedName] + if (binding is ObjectParamBinding) { + if (state.value != null) { + property.intendedOptionName = property.valueAssignmentState.value?.optionName + } else if (binding.activationState != null) { + reconcileNullAssignment(binding, property) + } + } + property.acknowledgeValueAssignment() + if (binding != null) { + paramStateSnapshots[resolvedName] = binding.snapshot() + upsertDescriptor(binding.toDescriptor()) + } + } else { + refreshDescriptorIfStateChanged(resolvedName) + } return@SideEffect } @@ -872,6 +1048,8 @@ class SceneScope internal constructor() { /** * Declares a scene parameter from explicit [options]. + * The selected option follows new values under its name when options change. + * Use stable option names and assign values from the available options. * * @param options Allowed values with names. * @param name Optional explicit parameter name. If `null`, delegated property name is used. @@ -894,6 +1072,8 @@ class SceneScope internal constructor() { /** * Declares a scene parameter with explicit [default] and [options]. + * The selected option follows new values under its name when options change. + * Use stable option names and assign values from the available options. * * @param default Initial parameter value. * @param options Allowed values with names. @@ -917,6 +1097,8 @@ class SceneScope internal constructor() { /** * Declares a scene parameter with named [default] and explicit [options]. + * The selected option follows new values under its name when options change. + * Use stable option names and assign values from the available options. * * @param default Initial parameter value together with a selection name. * @param options Allowed values with names. diff --git a/runtime/src/test/kotlin/composium/ParamDefaultResolutionTest.kt b/runtime/src/test/kotlin/composium/ParamDefaultResolutionTest.kt new file mode 100644 index 0000000..6314702 --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamDefaultResolutionTest.kt @@ -0,0 +1,246 @@ +package oleginvoke.com.composium + +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertFailsWith + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ParamDefaultResolutionTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun rawDefaultWithoutOptionsUsesItsGeneratedName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(16) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(16, "16", options = emptyList(), typeName = "Object") + } + + @Test + fun namedDefaultWithoutOptionsKeepsItsLabelSeparateFromTheParameterName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(16 named "Comfortable", name = "Spacing") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(16, "Comfortable", options = emptyList(), paramName = "Spacing") + } + + @Test + fun optionsOnlyUsesTheFirstOptionWithoutSorting() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(24 named "Large", 8 named "Small")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(24, "Large", options = listOf(24 to "Large", 8 to "Small")) + } + + @Test + fun rawDefaultMatchingANamedOptionUsesThatOptionsName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 24, options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(24, "Large", options = listOf(8 to "Small", 24 to "Large")) + } + + @Test + fun nullableOptionsOnlyWithANonNullFirstValueStartsActive() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(8, "Small", isActive = true, typeName = "Object?") + } + + @Test + fun rawDefaultMissingFromOptionsIsPrependedWithAnAutomaticName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 16, options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(16, "16", options = listOf(16 to "16", 8 to "Small", 24 to "Large")) + } + + @Test + fun namedDefaultMissingFromOptionsIsPrependedWithItsExplicitName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 16 named "Medium", options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(16, "Medium", options = listOf(16 to "Medium", 8 to "Small", 24 to "Large")) + } + + @Test + fun namedDefaultMatchingByValueCurrentlyUsesTheOptionsNameInsteadOfItsOwn() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 24 named "Default label", options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(24, "Large", options = listOf(8 to "Small", 24 to "Large")) + } + + @Test + fun namedDefaultMatchingOnlyByNameCurrentlyTakesTheOptionsValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 16 named "Large", options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(24, "Large", options = listOf(8 to "Small", 24 to "Large")) + } + + @Test + fun defaultValueMatchCurrentlyWinsOverAConflictingNameMatch() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8 named "Large", options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(8, "Small", options = listOf(8 to "Small", 24 to "Large")) + } + + @Test + fun generatedDefaultNameMatchingAnOptionCurrentlyReplacesTheRawDefaultValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 16, options = listOf(8 named "16", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(8, "16", options = listOf(8 to "16", 24 to "Large")) + } + + @Test + fun rawDefaultWithEqualOptionValuesCurrentlySelectsTheFirstName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8, options = listOf(8 named "First", 8 named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(8, "First", options = listOf(8 to "First", 8 to "Second")) + } + + @Test + fun namedDefaultPointingAtTheSecondEqualValueCurrentlySelectsTheFirstName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8 named "Second", options = listOf(8 named "First", 8 named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(8, "First", options = listOf(8 to "First", 8 to "Second")) + } + + @Test + fun duplicateOptionNamesAreDisambiguatedWithoutLosingTheDefaultValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 24, options = listOf(8 named "Size", 24 named "Size")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(24, "Size1", options = listOf(8 to "Size", 24 to "Size1")) + driver.select("Size") + driver.assertObject(8, "Size") + driver.select("Size1") + driver.assertObject(24, "Size1") + } + + @Test + fun defaultNameMatchingDuplicatedLabelsCurrentlyUsesTheFirstValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 16 named "Size", options = listOf(8 named "Size", 24 named "Size")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(8, "Size", options = listOf(8 to "Size", 24 to "Size1")) + } + + @Test + fun explicitEmptyOptionsWithARawDefaultCurrentlyProduceASingleChoice() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 16, options = emptyList()) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(16, "16", options = listOf(16 to "16")) + } + + @Test + fun explicitEmptyOptionsWithANamedDefaultCurrentlyProduceASingleNamedChoice() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 16 named "Medium", options = emptyList()) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(16, "Medium", options = listOf(16 to "Medium")) + } + + @Test + fun optionsOnlyRejectsAnEmptyListAtRuntime() { + val driver = ParamTestDriver(composeRule) + assertFailsWith { + driver.setContent { + var choice: Int by param(emptyList()) + driver.Observe(choice, { choice }, { choice = it }) + } + composeRule.waitForIdle() + } + } + + @Test + fun explicitStringOptionsUseObjectSelectionInsteadOfAFreeTextControl() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String by param(default = "a", options = listOf("a" named "Alpha", "b" named "Beta")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject("a", "Alpha", typeName = "Object") + driver.select("Beta") + driver.assertObject("b", "Beta") + } + + @Test + fun explicitBooleanOptionsUseObjectSelectionInsteadOfASwitch() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Boolean by param(default = false, options = listOf(false named "Off", true named "On")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(false, "Off", typeName = "Object") + driver.select("On") + driver.assertObject(true, "On") + } + + @Test + fun toParamOptionsProvidesGeneratedNamesWhenLabelsAreNotSpecified() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 24, options = listOf(8, 16, 24).toParamOptions()) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(24, "24", options = listOf(8 to "8", 16 to "16", 24 to "24")) + } + + @Test + fun toParamOptionsSupportsCustomLabels() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(8, 24).toParamOptions { "$it dp" }) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(8, "8 dp", options = listOf(8 to "8 dp", 24 to "24 dp")) + } +} diff --git a/runtime/src/test/kotlin/composium/ParamInferenceTest.kt b/runtime/src/test/kotlin/composium/ParamInferenceTest.kt new file mode 100644 index 0000000..74d4522 --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamInferenceTest.kt @@ -0,0 +1,181 @@ +package oleginvoke.com.composium + +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ParamInferenceTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun inferredEnumsUseDeclarationOrderAndEnumConstantNames() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestTone by param(TestTone.Alpha) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestTone.Alpha, "Alpha", typeName = "Enum", + options = listOf(TestTone.Zulu to "Zulu", TestTone.Alpha to "Alpha")) + driver.select("Zulu") + driver.assertObject(TestTone.Zulu, "Zulu") + } + + @Test + fun namedEnumDefaultWithoutExplicitOptionsCurrentlyUsesTheEnumConstantName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestTone by param(TestTone.Alpha named "Default alias") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestTone.Alpha, "Alpha", typeName = "Enum") + } + + @Test + fun explicitEnumOptionsUseTheirLabelsAndOrderInsteadOfInference() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestTone by param(listOf(TestTone.Alpha named "First alias", TestTone.Zulu named "Second alias")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestTone.Alpha, "First alias", typeName = "Enum", + options = listOf(TestTone.Alpha to "First alias", TestTone.Zulu to "Second alias")) + driver.select("Second alias") + driver.assertObject(TestTone.Zulu, "Second alias", typeName = "Enum") + } + + @Test + fun enumDefaultMissingFromExplicitOptionsCurrentlyUsesTheClassNameForItsInsertedLabel() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestTone by param(default = TestTone.Alpha, options = listOf(TestTone.Zulu named "Zed")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestTone.Alpha, "TestTone", typeName = "Enum", + options = listOf(TestTone.Alpha to "TestTone", TestTone.Zulu to "Zed")) + } + + @Test + fun nullableEnumWithNullDefaultStartsInactiveAndRestoresTheFirstConstant() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestTone? by param(null) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Zulu", selectedValue = TestTone.Zulu, isActive = false, typeName = "Enum?") + driver.activate(true) + driver.assertObject(TestTone.Zulu, "Zulu", isActive = true) + } + + @Test + fun inferredSealedObjectsUseNaturalNameOrdering() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestSize by param(TestSize.Item10) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestSize.Item10, "Item10", options = listOf( + TestSize.Item2 to "Item2", TestSize.Item10 to "Item10", + )) + driver.select("Item2") + driver.assertObject(TestSize.Item2, "Item2") + } + + @Test + fun sealedOptionsCanBeReorderedWithoutChangingTheDefaultSelection() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestSize by param(TestSize.Item2) { it.reversed() } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestSize.Item2, "Item2", options = listOf( + TestSize.Item10 to "Item10", TestSize.Item2 to "Item2", + )) + } + + @Test + fun namedSealedDefaultUsesItsLabelInTheInferredOptions() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestSize by param(TestSize.Item10 named "Preferred") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestSize.Item10, "Preferred", options = listOf( + TestSize.Item2 to "Item2", TestSize.Item10 to "Preferred", + )) + } + + @Test + fun aSealedDefaultRemovedByTheOrderTransformCurrentlyRemainsTheDelegatedValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestSize by param(TestSize.Item10) { listOf(null, TestSize.Item2) } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestSize.Item10, "Item10", options = listOf(TestSize.Item2 to "Item2")) + } + + @Test + fun explicitSealedOptionsSuppressAutomaticDiscovery() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestSize by param(listOf(TestSize.Item10 named "Only choice")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestSize.Item10, "Only choice", options = listOf(TestSize.Item10 to "Only choice")) + } + + @Test + fun nullableSealedNullDefaultRestoresTheFirstNaturallyOrderedObject() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestSize? by param(null) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Item2", selectedValue = TestSize.Item2, isActive = false) + driver.activate(true) + driver.assertObject(TestSize.Item2, "Item2", isActive = true) + } + + @Test + fun aDeclaredNestedSealedRootExcludesObjectsFromOtherBranches() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestFamily.Sizes by param(TestFamily.Sizes.Large) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestFamily.Sizes.Large, "Large", options = listOf( + TestFamily.Sizes.Large to "Large", TestFamily.Sizes.Small to "Small", + )) + } + + @Test + fun enumOrderTransformCurrentlyDoesNotChangeDeclarationOrder() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: TestTone by param(TestTone.Alpha) { it.reversed() } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(TestTone.Alpha, "Alpha", options = listOf(TestTone.Zulu to "Zulu", TestTone.Alpha to "Alpha")) + } + + enum class TestTone { Zulu, Alpha } + + sealed interface TestSize { + data object Item10 : TestSize + data object Item2 : TestSize + } + + sealed interface TestFamily { + sealed interface Sizes : TestFamily { + data object Small : Sizes + data object Large : Sizes + } + data object Other : TestFamily + } +} diff --git a/runtime/src/test/kotlin/composium/ParamNullableAssignmentTest.kt b/runtime/src/test/kotlin/composium/ParamNullableAssignmentTest.kt new file mode 100644 index 0000000..a29a384 --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamNullableAssignmentTest.kt @@ -0,0 +1,484 @@ +package oleginvoke.com.composium + +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ParamNullableAssignmentTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun programmaticChoiceThenNullRestoresTheLatestAssignedChoice() { + val driver = intChoices() + driver.assign(24) + driver.assign(null) + driver.activate(false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun severalWritesBeforeRecompositionPreserveTheLastNonNullChoice() { + val driver = intChoices() + composeRule.runOnIdle { + driver.assignNow(24) + driver.assignNow(null) + } + driver.assertObject(null, "Small", selectedValue = 8, isActive = false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun falseIsAnActiveNonNullBooleanValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Boolean? by param(null) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(false) + assertBoolean(driver, false, true) + driver.assign(null) + assertBoolean(driver, null, false, preview = false) + driver.activate(true) + assertBoolean(driver, false, true) + } + + @Test + fun anEmptyStringIsAnActiveNonNullStringValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String? by param(null) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign("") + assertString(driver, "", true) + driver.assign(null) + assertString(driver, null, false, preview = "") + driver.activate(true) + assertString(driver, "", true) + } + + @Test + fun aProgrammaticStringIsRememberedAcrossNull() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String? by param("initial") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign("updated") + driver.assign(null) + driver.activate(false) + driver.activate(true) + assertString(driver, "updated", true) + } + + @Test + fun aProgrammaticBooleanIsRememberedAcrossNull() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Boolean? by param(false) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(true) + driver.assign(null) + driver.activate(false) + driver.activate(true) + assertBoolean(driver, true, true) + } + + @Test + fun aFreeObjectValueIsRememberedAcrossNull() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(16 named "Spacing") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(24) + driver.assign(null) + driver.activate(false) + driver.activate(true) + driver.assertObject(24, "Spacing", options = emptyList(), isActive = true) + } + + @Test + fun primitiveHistoryPreviewUpdatesEvenWhenBatchedWritesFinishAtNull() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String? by param(null) + driver.ObserveInChild({ choice }, { choice = it }) + } + composeRule.runOnIdle { + driver.assignNow("updated") + driver.assignNow(null) + } + assertString(driver, null, false, preview = "updated") + driver.activate(true) + assertString(driver, "updated", true) + } + + @Test + fun changedOptionsAndBatchedWritesRestoreTheNewAssignedRole() { + var options by mutableStateOf(colors(Color.Blue, Color.Red)) + val driver = colorChoices { options } + composeRule.runOnIdle { + options = colors(Color.Green, Color.Yellow) + driver.assignNow(Color.Yellow) + driver.assignNow(null) + } + driver.assertObject(null, "background", selectedValue = Color.Green, isActive = false) + driver.activate(true) + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun nullDuringAThemeChangeKeepsThePreviouslyClickedRole() { + var options by mutableStateOf(colors(Color.Blue, Color.Red)) + val driver = colorChoices { options } + driver.activate(true) + driver.select("surface") + composeRule.runOnIdle { + options = colors(Color.Red, Color.Yellow) + driver.assignNow(null) + } + driver.activate(true) + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun immediateActivationAfterBatchedWritesKeepsThePendingAssignedRole() { + var options by mutableStateOf(colors(Color.Blue, Color.Red)) + val driver = colorChoices { options } + composeRule.runOnIdle { + options = colors(Color.Green, Color.Yellow) + driver.assignNow(Color.Yellow) + driver.assignNow(null) + driver.scope.paramsCallbacks.onParamActivationChange("choice", true) + } + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun immediateDeactivationKeepsThePendingAssignedRoleForRestoration() { + var options by mutableStateOf(colors(Color.Blue, Color.Red)) + val driver = colorChoices { options } + composeRule.runOnIdle { + options = colors(Color.Green, Color.Yellow) + driver.assignNow(Color.Yellow) + driver.scope.paramsCallbacks.onParamActivationChange("choice", false) + } + driver.assertObject(null, "background", selectedValue = Color.Green, isActive = false) + driver.activate(true) + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun emptyChoicesWithoutAFallbackCannotBeActivated() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null, options = emptyList()) + driver.Observe(choice, { choice }, { choice = it }) + } + repeat(2) { + driver.activate(true) + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + } + } + + @Test + fun removalOfAllChoicesDeactivatesAndCannotRestoreARemovedValue() { + var options by mutableStateOf(listOf(ParamOption(8, "Small"), ParamOption(24, "Large"))) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null, options = options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(24) + composeRule.runOnIdle { options = emptyList() } + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + driver.activate(true) + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + } + + @Test + fun writesBeforeFirstRegistrationPreserveRestorationHistory() { + val driver = ParamTestDriver(composeRule) + var firstDeclaration = true + driver.setContent { + var choice: Int? by param(default = 8, options = listOf(8 named "Small", 24 named "Large")) + if (firstDeclaration) { + firstDeclaration = false + choice = 24 + choice = null + } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun inferredChoicesResolveHistoryAssignedBeforeFirstRegistration() { + val driver = ParamTestDriver(composeRule) + var firstDeclaration = true + driver.setContent { + var choice: InferredChoice? by param(null) + if (firstDeclaration) { + firstDeclaration = false + choice = InferredChoice.Second + choice = null + } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(true) + driver.assertObject(InferredChoice.Second, "Second", isActive = true) + } + + @Test + fun discardedSnapshotRollsBackValueActivityAndHistoryTogether() { + val driver = intChoices() + driver.select("Large") + driver.assertObject(24, "Large", isActive = true) + val snapshot = Snapshot.takeMutableSnapshot() + try { + composeRule.runOnIdle { + snapshot.enter { + driver.assignNow(8) + driver.assignNow(null) + } + } + } finally { + snapshot.dispose() + } + driver.assertObject(24, "Large", isActive = true) + driver.assign(null) + driver.activate(false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun delayedSnapshotKeepsBatchedHistoryAcrossAnUnrelatedComposition() { + var options by mutableStateOf(colors(Color.Blue, Color.Red)) + var tick by mutableStateOf(0) + val driver = ParamTestDriver(composeRule) + driver.setContent { + @Suppress("UNUSED_VARIABLE") val observedTick = tick + var choice: Color? by param(default = null, options = options) + driver.ObserveInChild({ choice }, { choice = it }) + } + driver.activate(true) + val snapshot = Snapshot.takeMutableSnapshot() + try { + composeRule.runOnIdle { + snapshot.enter { + options = colors(Color.Green, Color.Yellow) + driver.assignNow(Color.Yellow) + driver.assignNow(null) + } + } + composeRule.runOnIdle { tick++ } + driver.assertObject(Color.Blue, "background", isActive = true) + composeRule.runOnIdle { snapshot.apply().check() } + } finally { + snapshot.dispose() + } + driver.assertObject(null, "background", selectedValue = Color.Green, isActive = false) + driver.activate(true) + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun equalValuedChoicesKeepTheClickedNameAcrossANullAssignment() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(listOf(8 named "First", 8 named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.assign(8) + driver.assign(null) + driver.activate(true) + driver.assertObject(8, "Second", isActive = true) + } + + @Test + fun repeatedNullSideEffectWritesSettle() { + composeRule.mainClock.autoAdvance = false + val driver = ParamTestDriver(composeRule) + var compositions = 0 + driver.setContent { + var choice: Int? by param(default = 8, options = listOf(8 named "Small", 24 named "Large")) + SideEffect { compositions++; choice = null } + driver.Observe(choice, { choice }, { choice = it }) + } + repeat(8) { composeRule.mainClock.advanceTimeByFrame() } + composeRule.runOnIdle { assertTrue(compositions <= 3, "compositions=$compositions") } + driver.assertObject(null, "Small", selectedValue = 8, isActive = false) + } + + @Test + fun aUiChoiceSupersedesBatchedAssignmentsAndTheirRestorationHistory() { + var options by mutableStateOf(colors(Color.Blue, Color.Red)) + val driver = colorChoices { options } + driver.activate(true) + composeRule.runOnIdle { + val surface = (driver.scope.params.single() as ObjectParamDescriptor).options.single { it.name == "surface" } + options = colors(Color.Green, Color.Yellow) + driver.assignNow(Color.Green) + driver.assignNow(null) + driver.scope.paramsCallbacks.onObjectParamChange("choice", surface) + } + driver.assertObject(Color.Yellow, "surface", isActive = true) + driver.assign(null) + driver.activate(true) + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun stringSnapshotRollbackDoesNotLeakTheAssignedHistory() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String? by param("initial") + driver.ObserveInChild({ choice }, { choice = it }) + } + val snapshot = Snapshot.takeMutableSnapshot() + try { + composeRule.runOnIdle { + snapshot.enter { driver.assignNow("updated"); driver.assignNow(null) } + } + } finally { snapshot.dispose() } + assertString(driver, "initial", true) + driver.assign(null) + assertString(driver, null, false, preview = "initial") + driver.activate(true) + assertString(driver, "initial", true) + } + + @Test + fun booleanSnapshotRollbackDoesNotLeakTheAssignedHistory() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Boolean? by param(false) + driver.Observe(choice, { choice }, { choice = it }) + } + val snapshot = Snapshot.takeMutableSnapshot() + try { + composeRule.runOnIdle { + snapshot.enter { driver.assignNow(true); driver.assignNow(null) } + } + } finally { snapshot.dispose() } + assertBoolean(driver, false, true) + driver.assign(null) + driver.activate(true) + assertBoolean(driver, false, true) + } + + @Test + fun aDelayedFreeObjectSnapshotPreservesItsHistoryAcrossAnUnrelatedComposition() { + var tick by mutableStateOf(0) + val driver = ParamTestDriver(composeRule) + driver.setContent { + @Suppress("UNUSED_VARIABLE") val observedTick = tick + var choice: Int? by param(16 named "Spacing") + driver.ObserveInChild({ choice }, { choice = it }) + } + val snapshot = Snapshot.takeMutableSnapshot() + try { + composeRule.runOnIdle { snapshot.enter { driver.assignNow(24); driver.assignNow(null) } } + composeRule.runOnIdle { tick++ } + driver.assertObject(16, "Spacing", options = emptyList(), isActive = true) + composeRule.runOnIdle { snapshot.apply().check() } + } finally { snapshot.dispose() } + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + driver.activate(true) + driver.assertObject(24, "Spacing", options = emptyList(), isActive = true) + } + + @Test + fun aUiStringEditSupersedesPendingProgrammaticRestoration() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String? by param("initial") + driver.Observe(choice, { choice }, { choice = it }) + } + composeRule.runOnIdle { + driver.assignNow("code") + driver.assignNow(null) + driver.scope.paramsCallbacks.onStringParamChange("choice", "ui") + driver.scope.paramsCallbacks.onParamActivationChange("choice", false) + driver.scope.paramsCallbacks.onParamActivationChange("choice", true) + } + assertString(driver, "ui", true) + } + + @Test + fun aUiBooleanEditSupersedesPendingProgrammaticRestoration() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Boolean? by param(false) + driver.Observe(choice, { choice }, { choice = it }) + } + composeRule.runOnIdle { + driver.assignNow(true) + driver.assignNow(null) + driver.scope.paramsCallbacks.onBooleanParamChange("choice", false) + driver.scope.paramsCallbacks.onParamActivationChange("choice", false) + driver.scope.paramsCallbacks.onParamActivationChange("choice", true) + } + assertBoolean(driver, false, true) + } + + private fun intChoices(): ParamTestDriver = ParamTestDriver(composeRule).also { driver -> + driver.setContent { + var choice: Int? by param(default = 8, options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + } + + private fun colorChoices(options: () -> List>): ParamTestDriver = + ParamTestDriver(composeRule).also { driver -> + driver.setContent { + var choice: Color? by param(default = null, options = options()) + driver.Observe(choice, { choice }, { choice = it }) + } + } + + private fun colors(background: Color, surface: Color): List> = listOf( + ParamOption(background, "background"), ParamOption(surface, "surface"), + ) + + private fun assertBoolean(driver: ParamTestDriver, value: Boolean?, active: Boolean, preview: Boolean = value ?: false) { + driver.assertValue(value) + composeRule.runOnIdle { + val descriptor = driver.scope.params.single() as BooleanParamDescriptor + assertEquals(active, descriptor.activation?.isActive) + assertEquals(preview, descriptor.value) + } + } + + private fun assertString(driver: ParamTestDriver, value: String?, active: Boolean, preview: String = value.orEmpty()) { + driver.assertValue(value) + composeRule.runOnIdle { + val descriptor = driver.scope.params.single() as StringParamDescriptor + assertEquals(active, descriptor.activation?.isActive) + assertEquals(preview, descriptor.value) + } + } + + private enum class InferredChoice { First, Second } +} diff --git a/runtime/src/test/kotlin/composium/ParamNullableTest.kt b/runtime/src/test/kotlin/composium/ParamNullableTest.kt new file mode 100644 index 0000000..d98f6fd --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamNullableTest.kt @@ -0,0 +1,287 @@ +package oleginvoke.com.composium + +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertSame + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ParamNullableTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun implicitNullFirstOptionStartsInactiveAndShowsTheFirstNonNullOption() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(listOf(null named "None", 8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Small", selectedValue = 8, isActive = false, + options = listOf(8 to "Small", 24 to "Large"), typeName = "Object?") + driver.activate(true) + driver.assertObject(8, "Small", isActive = true) + } + + @Test + fun explicitRawNullDefaultIsNotAddedToTheSelectableOptions() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null, options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Small", selectedValue = 8, isActive = false, + options = listOf(8 to "Small", 24 to "Large")) + } + + @Test + fun namedNullDefaultCurrentlyShowsTheFirstOptionButRestoresByItsExplicitName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null named "Large", options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Small", selectedValue = 8, isActive = false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun nullOptionsAreRemovedBeforeResolvingDuplicateNames() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = 24, options = listOf( + null named "Size", 8 named "Size", null named "Size1", 24 named "Size", + )) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(24, "Size1", isActive = true, options = listOf(8 to "Size", 24 to "Size1")) + } + + @Test + fun allNullOptionsCannotActivateWithoutARestorableValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(listOf(null named "None", null named "Another null")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + driver.activate(true) + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + } + + @Test + fun explicitEmptyOptionsWithNullDefaultHaveNoRestorableValue() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null, options = emptyList()) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(true) + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + } + + @Test + fun nonNullDefaultIsInsertedWhenAllExplicitOptionsAreNull() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = 16, options = listOf(null named "None")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(16, "16", options = listOf(16 to "16"), isActive = true) + } + + @Test + fun deactivationClearsTheValueAndActivationRestoresTheLastDistinctSelection() { + val driver = nullableChoices() + driver.select("Large") + driver.activate(false) + driver.assertObject(null, "Small", selectedValue = 8, isActive = false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + driver.activate(false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun equalValueSelectionIsPreservedWhenDeactivatedAndRestored() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(listOf(8 named "First", 8 named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.activate(false) + driver.activate(true) + driver.assertObject(8, "Second", isActive = true) + } + + @Test + fun reactivationOfEqualObjectsRestoresTheInstanceOfTheSelectedOption() { + val first = EqualValue(8) + val second = EqualValue(8) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: EqualValue? by param(listOf(first named "First", second named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.assertObject(EqualValue(8), "Second", isActive = true) + // The equal click keeps the existing state instance; null restoration can replace it. + composeRule.runOnIdle { assertSame(first, driver.value()) } + driver.activate(false) + driver.assertObject(null, "First", selectedValue = first, isActive = false) + driver.activate(true) + driver.assertObject(EqualValue(8), "Second", isActive = true) + composeRule.runOnIdle { + assertSame(second, driver.value()) + val descriptor = driver.scope.params.single() as ObjectParamDescriptor + assertSame(second, descriptor.selectedOption.value) + } + } + + @Test + fun directNullAssignmentDeactivatesAndCanBeRestoredWithOneActivation() { + val driver = nullableChoices() + driver.select("Large") + driver.assign(null) + driver.assertObject(null, "Small", selectedValue = 8, isActive = false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + driver.activate(false) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun directNonNullAssignmentWhileInactiveActivatesTheParameter() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null, options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(24) + driver.assertObject(24, "Large", isActive = true) + driver.activate(true) + driver.assertObject(24, "Large", isActive = true) + } + + @Test + fun nullableObjectWithoutOptionsRestoresItsNonNullDefault() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(16 named "Spacing") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(false) + driver.assertObject(null, "Not selected", options = emptyList(), isActive = false) + driver.activate(true) + driver.assertObject(16, "Spacing", options = emptyList(), isActive = true) + } + + @Test + fun nullableObjectWithoutOptionsRestoresTheLastAssignedValueBeforeItsDefault() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(16 named "Spacing") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(24) + driver.activate(false) + driver.activate(true) + driver.assertObject(24, "Spacing", options = emptyList(), isActive = true) + } + + @Test + fun nullableBooleanWithoutOptionsStartsInactiveAndRestoresFalse() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Boolean? by param(null) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertValue(null) + composeRule.runOnIdle { + assertEquals(BooleanParamDescriptor("choice", "Boolean?", ParamActivation(false), false), driver.scope.params.single()) + } + driver.activate(true) + driver.assertValue(false) + composeRule.runOnIdle { driver.scope.paramsCallbacks.onBooleanParamChange("choice", true) } + driver.assertValue(true) + driver.activate(false) + driver.assertValue(null) + driver.activate(true) + driver.assertValue(true) + } + + @Test + fun nullableStringWithoutOptionsStartsInactiveAndRestoresAnEmptyString() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String? by param(null named "Missing") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertValue(null) + composeRule.runOnIdle { + assertEquals(StringParamDescriptor("choice", "String?", ParamActivation(false), ""), driver.scope.params.single()) + } + driver.activate(true) + driver.assertValue("") + composeRule.runOnIdle { driver.scope.paramsCallbacks.onStringParamChange("choice", "Edited") } + driver.activate(false) + driver.activate(true) + driver.assertValue("Edited") + } + + @Test + fun activationCallbacksForANonNullableParameterAreIgnored() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(false) + driver.assertObject(8, "Small", isActive = null) + } + + @Test + fun nullableStringWithExplicitOptionsUsesObjectSelectionAndRestoresItsLabel() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: String? by param(default = null named "Beta", options = listOf("a" named "Alpha", "b" named "Beta")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Alpha", selectedValue = "a", isActive = false, typeName = "Object?") + driver.activate(true) + driver.assertObject("b", "Beta", isActive = true) + } + + @Test + fun nullableBooleanWithExplicitOptionsUsesObjectSelectionInsteadOfASwitch() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Boolean? by param(default = null, options = listOf(false named "Off", true named "On")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(null, "Off", selectedValue = false, isActive = false, typeName = "Object?") + driver.activate(true) + driver.select("On") + driver.assertObject(true, "On", isActive = true) + } + + private fun nullableChoices(): ParamTestDriver { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + return driver + } + + private data class EqualValue(val number: Int) +} diff --git a/runtime/src/test/kotlin/composium/ParamOptionsNormalizationTest.kt b/runtime/src/test/kotlin/composium/ParamOptionsNormalizationTest.kt new file mode 100644 index 0000000..8e6253a --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamOptionsNormalizationTest.kt @@ -0,0 +1,53 @@ +package oleginvoke.com.composium + +import org.junit.Test +import kotlin.test.assertEquals + +class ParamOptionsNormalizationTest { + @Test + fun duplicateNamesSkipSuffixesAlreadyPresentAnywhereInTheList() { + val options = listOf( + ParamOption(1, "Size"), ParamOption(2, "Size"), + ParamOption(3, "Size1"), ParamOption(4, "Size"), ParamOption(5, "Size2"), + ) + assertEquals( + listOf(1 to "Size", 2 to "Size3", 3 to "Size1", 4 to "Size4", 5 to "Size2"), + options.ensureUniqueNames().map { it.value to it.name }, + ) + } + + @Test + fun emptyLabelsAreDisambiguatedAndExplicitNumericLabelsAreReserved() { + val options = listOf(ParamOption(1, ""), ParamOption(2, ""), ParamOption(3, "1")) + assertEquals(listOf("", "2", "1"), options.ensureUniqueNames().map { it.name }) + } + + @Test + fun labelsAreCaseSensitiveAndWhitespaceIsPreserved() { + val options = listOf(ParamOption(1, "Size"), ParamOption(2, "size"), ParamOption(3, " Size ")) + assertEquals(listOf("Size", "size", " Size "), options.ensureUniqueNames().map { it.name }) + } + + @Test + fun normalizationIsIdempotentForPreviouslyDisambiguatedNames() { + val normalized = listOf(ParamOption(1, "Same"), ParamOption(2, "Same1"), ParamOption(3, "Same2")) + assertEquals(normalized, normalized.ensureUniqueNames().ensureUniqueNames()) + } + + @Test + fun generatedNamesCoverNullAndScalarValues() { + val scope = SceneScope() + val options = with(scope) { listOf(null, "Hello", 16, 2.5, false, 'x').toParamOptions() } + assertEquals(listOf("null", "Hello", "16", "2.5", "false", "x"), options.map { it.name }) + } + + @Test + fun generatedObjectNameUsesTheClassNameRatherThanToString() { + val options = with(SceneScope()) { listOf(CustomDisplayValue()).toParamOptions() } + assertEquals(listOf("CustomDisplayValue"), options.map { it.name }) + } + + private class CustomDisplayValue { + override fun toString(): String = "custom display text" + } +} diff --git a/runtime/src/test/kotlin/composium/ParamReactiveOptionsTest.kt b/runtime/src/test/kotlin/composium/ParamReactiveOptionsTest.kt new file mode 100644 index 0000000..f02cd05 --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamReactiveOptionsTest.kt @@ -0,0 +1,455 @@ +package oleginvoke.com.composium + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ParamReactiveOptionsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun selectedRoleFollowsSeveralProductThemesWithoutReclicking() { + var scheme by mutableStateOf(lightColorScheme(background = Color.Blue, surface = Color.Red)) + val driver = ParamTestDriver(composeRule) + driver.setContent { + MaterialTheme(colorScheme = scheme) { + var choice: Color by param(listOf( + MaterialTheme.colorScheme.background named "background", + MaterialTheme.colorScheme.surface named "surface", + )) + driver.Observe(choice, { choice }, { choice = it }) + } + } + driver.select("surface") + composeRule.runOnIdle { scheme = lightColorScheme(background = Color.Green, surface = Color.Yellow) } + driver.assertObject(Color.Yellow, "surface") + composeRule.runOnIdle { scheme = lightColorScheme(background = Color.Red, surface = Color.Magenta) } + driver.assertObject(Color.Magenta, "surface") + composeRule.runOnIdle { scheme = lightColorScheme(background = Color.Blue, surface = Color.Red) } + driver.assertObject(Color.Red, "surface") + } + + @Test + fun oldSelectedValueMatchingAnotherNewOptionDoesNotSwitchRoles() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + driver.select("surface") + composeRule.runOnIdle { options = colorOptions(Color.Red, Color.Yellow) } + driver.assertObject(Color.Yellow, "surface") + } + + @Test + fun validAssignmentBeforeThemeChangeSelectsTheRoleToFollow() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + driver.select("surface") + driver.assign(Color.Blue) + driver.assertObject(Color.Blue, "background") + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Blue) } + driver.assertObject(Color.Green, "background") + } + + @Test + fun sameValueAssignmentDuringThemeChangeKeepsTheSelectedRole() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + driver.select("surface") + composeRule.runOnIdle { + options = colorOptions(Color.Red, Color.Yellow) + driver.assignNow(Color.Red) + } + driver.assertObject(Color.Yellow, "surface") + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Magenta) } + driver.assertObject(Color.Magenta, "surface") + } + + @Test + fun equalAssignmentsFromSideEffectsSettleWithoutARecompositionLoop() { + composeRule.mainClock.autoAdvance = false + val driver = ParamTestDriver(composeRule) + var compositions = 0 + driver.setContent { + var choice: Color by param(colorOptions(Color.Blue, Color.Red)) + SideEffect { + compositions++ + choice = Color.Red + } + driver.Observe(choice, { choice }, { choice = it }) + } + repeat(8) { composeRule.mainClock.advanceTimeByFrame() } + composeRule.runOnIdle { assertTrue(compositions <= 3, "compositions=$compositions") } + } + + @Test + fun equalAssignmentsInTheCompositionBodySettleWithoutARecompositionLoop() { + composeRule.mainClock.autoAdvance = false + val driver = ParamTestDriver(composeRule) + var compositions = 0 + driver.setContent { + var choice: Color by param(colorOptions(Color.Blue, Color.Red)) + choice = Color.Red + SideEffect { compositions++ } + driver.Observe(choice, { choice }, { choice = it }) + } + repeat(8) { composeRule.mainClock.advanceTimeByFrame() } + composeRule.runOnIdle { assertTrue(compositions <= 3, "compositions=$compositions") } + } + + @Test + fun aSettledEqualAssignmentDoesNotOverrideALaterThemeChange() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + driver.select("surface") + driver.assign(Color.Red) + driver.assertObject(Color.Red, "surface") + composeRule.runOnIdle { options = colorOptions(Color.Red, Color.Yellow) } + driver.assertObject(Color.Yellow, "surface") + } + + @Test + fun aDiscardedSnapshotAssignmentDoesNotChangeTheRoleToFollow() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + driver.select("surface") + driver.assertObject(Color.Red, "surface") + val snapshot = Snapshot.takeMutableSnapshot() + try { + composeRule.runOnIdle { snapshot.enter { driver.assignNow(Color.Blue) } } + } finally { + snapshot.dispose() + } + driver.assertObject(Color.Red, "surface") + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Yellow) } + driver.assertObject(Color.Yellow, "surface") + } + + @Test + fun aDelayedSnapshotAssignmentRefreshesChildOnlyReadsAfterApply() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + var unrelatedTick by mutableStateOf(0) + val driver = ParamTestDriver(composeRule) + driver.setContent { + @Suppress("UNUSED_VARIABLE") + val observedTick = unrelatedTick + var choice: Color by param(options) + driver.ObserveInChild({ choice }, { choice = it }) + } + driver.select("surface") + driver.assertObject(Color.Red, "surface") + val snapshot = Snapshot.takeMutableSnapshot() + try { + composeRule.runOnIdle { + snapshot.enter { + options = colorOptions(Color.Green, Color.Blue) + driver.assignNow(Color.Blue) + } + } + composeRule.runOnIdle { unrelatedTick++ } + driver.assertObject(Color.Red, "surface") + composeRule.runOnIdle { snapshot.apply().check() } + } finally { + snapshot.dispose() + } + driver.assertObject(Color.Blue, "surface") + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Yellow) } + driver.assertObject(Color.Yellow, "surface") + } + + @Test + fun assigningANewOptionsValueDuringThemeChangeIsNotOverwritten() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + composeRule.runOnIdle { + options = colorOptions(Color.Green, Color.Yellow) + driver.assignNow(Color.Yellow) + } + driver.assertObject(Color.Yellow, "surface") + } + + @Test + fun theLastProgrammaticAssignmentWinsDuringAnOptionsUpdate() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + composeRule.runOnIdle { + options = colorOptions(Color.Green, Color.Yellow) + driver.assignNow(Color.Green) + driver.assignNow(Color.Yellow) + } + driver.assertObject(Color.Yellow, "surface") + composeRule.runOnIdle { options = colorOptions(Color.Cyan, Color.Magenta) } + driver.assertObject(Color.Magenta, "surface") + } + + @Test + fun aRepeatedOptionClickOverridesPendingProgrammaticAssignments() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + driver.select("surface") + composeRule.runOnIdle { + val surface = (driver.scope.params.single() as ObjectParamDescriptor).options.single { it.name == "surface" } + options = colorOptions(Color.Red, Color.Yellow) + driver.assignNow(Color.Blue) + driver.assignNow(Color.Red) + driver.scope.paramsCallbacks.onObjectParamChange("choice", surface) + } + driver.assertObject(Color.Yellow, "surface") + } + + @Test + fun parameterRenameAndThemeChangePreserveTheChoice() { + var name by mutableStateOf("Old") + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Color by param(options, name = name) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("surface", paramName = "Old") + composeRule.runOnIdle { + name = "New" + options = colorOptions(Color.Green, Color.Yellow) + } + driver.assertObject(Color.Yellow, "surface", paramName = "New") + composeRule.runOnIdle { assertEquals(listOf("New"), driver.scope.params.map { it.name }) } + } + + @Test + fun nullableDisabledSelectionStaysNullAndRestoresTheLatestColor() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Color? by param(default = null, options = options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(true) + driver.select("surface") + driver.activate(false) + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Yellow) } + driver.assertObject(null, "background", selectedValue = Color.Green, isActive = false) + driver.activate(true) + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun activatingAnAssignedNonNullValueKeepsTheAssignedRole() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Color? by param(default = null, options = options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(Color.Red) + driver.assertObject(Color.Red, "surface", isActive = true) + driver.activate(true) + driver.assertObject(Color.Red, "surface", isActive = true) + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Yellow) } + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun directlyAssignedNullIsNotAutomaticallyRestoredDuringAnOptionsUpdate() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Color? by param(options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(null) + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Yellow) } + driver.assertObject(null, "background", selectedValue = Color.Green, isActive = false) + } + + @Test + fun emptyNullableOptionsCannotRestoreARemovedValue() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Color? by param(default = null, options = options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(true) + driver.select("surface") + composeRule.runOnIdle { options = emptyList() } + driver.assertValue(null) + driver.activate(false) + driver.activate(true) + driver.assertValue(null) + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Yellow) } + driver.assertValue(null) + driver.activate(false) + driver.activate(true) + driver.assertObject(Color.Yellow, "surface", isActive = true) + } + + @Test + fun removedChoiceFallsBackAndFollowsItsNewValueWhenReintroduced() { + var options by mutableStateOf(listOf(ParamOption(8, "Small"), ParamOption(24, "Large"))) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8, options = options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + composeRule.runOnIdle { options = listOf(ParamOption(8, "Small")) } + driver.assertObject(8, "Small") + composeRule.runOnIdle { options = listOf(ParamOption(16, "Small"), ParamOption(32, "Large")) } + driver.assertObject(32, "Large") + } + + @Test + fun renamedChoiceCanContinueFollowingItsNewValue() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + val driver = colorDriver { options } + driver.select("surface") + composeRule.runOnIdle { options = listOf(ParamOption(Color.Blue, "background"), ParamOption(Color.Red, "renamed")) } + driver.assertObject(Color.Red, "renamed") + composeRule.runOnIdle { options = listOf(ParamOption(Color.Green, "background"), ParamOption(Color.Yellow, "renamed")) } + driver.assertObject(Color.Yellow, "renamed") + } + + @Test + fun equalContentRecompositionDoesNotWriteParameterState() { + var tick by mutableStateOf(0) + lateinit var property: ParamProperty + var compositions = 0 + val driver = ParamTestDriver(composeRule) + driver.setContent { + tick + val delegate = param(colorOptions(Color.Blue, Color.Red)) + var choice: Color by delegate + SideEffect { property = delegate; compositions++ } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("surface") + driver.assertObject(Color.Red, "surface") + var writes = 0 + val observer = Snapshot.registerApplyObserver { changed, _ -> if (property.state in changed) writes++ } + try { + repeat(5) { + composeRule.runOnIdle { tick++ } + driver.assertObject(Color.Red, "surface") + } + composeRule.runOnIdle { + Snapshot.sendApplyNotifications() + assertEquals(0, writes) + assertTrue(compositions >= 6, "The test must actually recompose the declaration") + } + } finally { + observer.dispose() + } + } + + @Test + fun changesToAnUnselectedOptionDoNotWriteTheSelectedState() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + lateinit var property: ParamProperty + val driver = ParamTestDriver(composeRule) + driver.setContent { + val delegate = param(options) + var choice: Color by delegate + SideEffect { property = delegate } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("surface") + driver.assertObject(Color.Red, "surface") + var writes = 0 + val observer = Snapshot.registerApplyObserver { changed, _ -> if (property.state in changed) writes++ } + try { + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Red) } + driver.assertObject(Color.Red, "surface") + composeRule.runOnIdle { Snapshot.sendApplyNotifications(); assertEquals(0, writes) } + } finally { + observer.dispose() + } + } + + @Test + fun changingTheSelectedValueWritesOnceAndSettles() { + var options by mutableStateOf(colorOptions(Color.Blue, Color.Red)) + lateinit var property: ParamProperty + var compositions = 0 + val driver = ParamTestDriver(composeRule) + driver.setContent { + val delegate = param(options) + var choice: Color by delegate + SideEffect { property = delegate; compositions++ } + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("surface") + driver.assertObject(Color.Red, "surface") + var writes = 0 + val observer = Snapshot.registerApplyObserver { changed, _ -> if (property.state in changed) writes++ } + try { + val before = compositions + composeRule.runOnIdle { options = colorOptions(Color.Green, Color.Yellow) } + driver.assertObject(Color.Yellow, "surface") + composeRule.runOnIdle { + Snapshot.sendApplyNotifications() + assertEquals(1, writes) + assertTrue(compositions - before in 1..2, + "The update must settle within two declaration compositions, got ${compositions - before}") + } + val settled = compositions + composeRule.mainClock.advanceTimeBy(320) + composeRule.runOnIdle { assertEquals(settled, compositions) } + } finally { + observer.dispose() + } + } + + @Test + fun delegateReadsDoNotSearchOrCompareOptions() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: CountingValue by param(listOf(CountingValue(1) named "One", CountingValue(2) named "Two")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Two") + composeRule.runOnIdle { + val expected = driver.value() + val before = CountingValue.comparisons + repeat(1000) { assertSame(expected, driver.value()) } + assertEquals(before, CountingValue.comparisons) + } + } + + private fun colorDriver(options: () -> List>): ParamTestDriver { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Color by param(options()) + driver.Observe(choice, { choice }, { choice = it }) + } + return driver + } + + private fun colorOptions(background: Color, surface: Color) = listOf( + ParamOption(background, "background"), ParamOption(surface, "surface"), + ) + + private class CountingValue(private val number: Int) { + override fun equals(other: Any?): Boolean { + comparisons++ + return other is CountingValue && other.number == number + } + override fun hashCode(): Int = number + override fun toString(): String = number.toString() + companion object { var comparisons: Int = 0 } + } +} diff --git a/runtime/src/test/kotlin/composium/ParamRecompositionTest.kt b/runtime/src/test/kotlin/composium/ParamRecompositionTest.kt new file mode 100644 index 0000000..67897bf --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamRecompositionTest.kt @@ -0,0 +1,296 @@ +package oleginvoke.com.composium + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ParamRecompositionTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun reorderingDistinctOptionsPreservesTheSelectedValueAndName() { + var reversed by mutableStateOf(false) + val driver = ParamTestDriver(composeRule) + driver.setContent { + val options = listOf(8 named "Small", 24 named "Large") + var choice: Int by param(if (reversed) options.reversed() else options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + composeRule.runOnIdle { reversed = true } + driver.assertObject(24, "Large", options = listOf(24 to "Large", 8 to "Small")) + } + + @Test + fun reorderingEqualOptionsPreservesTheSelectedName() { + var reversed by mutableStateOf(false) + val driver = ParamTestDriver(composeRule) + driver.setContent { + val options = listOf(8 named "First", 8 named "Second") + var choice: Int by param(if (reversed) options.reversed() else options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("First") + driver.assertObject(8, "First") + composeRule.runOnIdle { reversed = true } + driver.assertObject(8, "First", options = listOf(8 to "Second", 8 to "First")) + } + + @Test + fun reintroducingAnEqualOptionRestoresItsSavedSelectionName() { + var includeSecond by mutableStateOf(true) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(buildList { + add(8 named "First") + if (includeSecond) add(8 named "Second") + }) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.assertObject(8, "Second") + composeRule.runOnIdle { includeSecond = false } + driver.assertObject(8, "First", options = listOf(8 to "First")) + composeRule.runOnIdle { includeSecond = true } + driver.assertObject(8, "Second", options = listOf(8 to "First", 8 to "Second")) + } + + @Test + fun renamingTheOptionWithTheSelectedValueUpdatesItsDisplayedName() { + var label by mutableStateOf("Large") + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8, options = listOf(8 named "Small", 24 named label)) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + composeRule.runOnIdle { label = "Renamed" } + driver.assertObject(24, "Renamed", options = listOf(8 to "Small", 24 to "Renamed")) + } + + @Test + fun removingTheSelectedOptionFallsBackToTheDefaultValue() { + var includeLarge by mutableStateOf(true) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8, options = buildList { + add(8 named "Small") + if (includeLarge) add(24 named "Large") + }) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + composeRule.runOnIdle { includeLarge = false } + driver.assertObject(8, "Small", options = listOf(8 to "Small")) + } + + @Test + fun replacingTheSelectedOptionsValueUpdatesTheDelegatedValue() { + var large by mutableStateOf(24) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8, options = listOf(8 named "Small", large named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + composeRule.runOnIdle { large = 32 } + driver.assertObject(32, "Large", options = listOf(8 to "Small", 32 to "Large")) + driver.select("Large") + driver.assertObject(32, "Large") + } + + @Test + fun equalValuesBecomingDistinctFollowTheClickedName() { + var second by mutableStateOf(8) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(8 named "First", second named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + composeRule.runOnIdle { second = 24 } + driver.assertObject(24, "Second", options = listOf(8 to "First", 24 to "Second")) + driver.select("Second") + driver.assertObject(24, "Second") + } + + @Test + fun distinctValuesBecomingEqualPreserveTheSelectedName() { + var first by mutableStateOf(8) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(first named "First", 24 named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + composeRule.runOnIdle { first = 24 } + driver.assertObject(24, "Second", options = listOf(24 to "First", 24 to "Second")) + } + + @Test + fun changesToRawDefaultDoNotOverwriteAUserSelection() { + var default by mutableStateOf(8) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = default, options = listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + composeRule.runOnIdle { default = 16 } + driver.assertObject(24, "Large", options = listOf(16 to "16", 8 to "Small", 24 to "Large")) + } + + @Test + fun changingANamedDefaultWithoutOptionsCurrentlyKeepsTheOriginalSelectedName() { + var default by mutableStateOf(ParamOption(8, "Original")) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(24) + composeRule.runOnIdle { default = ParamOption(16, "Renamed default") } + driver.assertObject(24, "Original", options = emptyList()) + } + + @Test + fun themeValueChangesUpdateTheSelectedColorWithoutAnotherClick() { + var options by mutableStateOf(listOf(ParamOption(Color.Blue, "background"), ParamOption(Color.Red, "surface"))) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Color by param(options) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("surface") + composeRule.runOnIdle { options = listOf(ParamOption(Color.Green, "background"), ParamOption(Color.Yellow, "surface")) } + driver.assertObject(Color.Yellow, "surface", options = listOf(Color.Green to "background", Color.Yellow to "surface")) + driver.select("surface") + driver.assertObject(Color.Yellow, "surface") + } + + @Test + fun reactivationRestoresTheUpdatedValueForThePreviouslySelectedName() { + var large by mutableStateOf(24) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = 8, options = listOf(8 named "Small", large named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + driver.activate(false) + composeRule.runOnIdle { large = 32 } + driver.activate(true) + driver.assertObject(32, "Large", isActive = true) + } + + @Test + fun reactivationAfterSelectedOptionRemovalCurrentlyRestoresTheDefaultBeforeTheOldValue() { + var includeLarge by mutableStateOf(true) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = 8, options = buildList { + add(8 named "Small") + if (includeLarge) add(24 named "Large") + }) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + driver.activate(false) + composeRule.runOnIdle { includeLarge = false } + driver.activate(true) + driver.assertObject(8, "Small", isActive = true) + } + + @Test + fun reactivationAfterARenameCurrentlyRestoresTheDefaultBeforeMatchingTheOldValue() { + var label by mutableStateOf("Large") + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = 8, options = listOf(8 named "Small", 24 named label)) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Large") + driver.activate(false) + composeRule.runOnIdle { label = "Renamed" } + driver.activate(true) + driver.assertObject(8, "Small", isActive = true) + } + + @Test + fun reactivationWithNullDefaultAfterARenameRestoresThePreviousValueUnderItsNewName() { + var label by mutableStateOf("Large") + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null, options = listOf(8 named "Small", 24 named label)) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(true) + driver.select("Large") + driver.activate(false) + composeRule.runOnIdle { label = "Renamed" } + driver.activate(true) + driver.assertObject(24, "Renamed", isActive = true) + } + + @Test + fun reactivationWithNullDefaultAfterSelectedOptionRemovalRestoresTheFirstOption() { + var includeLarge by mutableStateOf(true) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int? by param(default = null, options = buildList { + add(8 named "Small") + if (includeLarge) add(24 named "Large") + }) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.activate(true) + driver.select("Large") + driver.activate(false) + composeRule.runOnIdle { includeLarge = false } + driver.activate(true) + driver.assertObject(8, "Small", isActive = true) + } + + @Test + fun differentValuesWithCollidingHashesUpdateRegisteredOptions() { + var second by mutableStateOf(CollidingValue(2)) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: CollidingValue by param( + default = CollidingValue(1), + options = listOf(CollidingValue(1) named "One", second named "Two"), + ) + driver.Observe(choice, { choice }, { choice = it }) + } + composeRule.runOnIdle { second = CollidingValue(3) } + driver.assertObject(CollidingValue(1), "One", options = listOf(CollidingValue(1) to "One", CollidingValue(3) to "Two")) + driver.select("Two") + driver.assertObject(CollidingValue(3), "Two") + } + + @Test + fun differentLabelsWithCollidingHashesUpdateRegisteredLabels() { + var label by mutableStateOf("Aa") + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(default = 8, options = listOf(8 named "Small", 24 named label)) + driver.Observe(choice, { choice }, { choice = it }) + } + composeRule.runOnIdle { label = "BB" } + driver.assertObject(8, "Small", options = listOf(8 to "Small", 24 to "BB")) + } + + private data class CollidingValue(val number: Int) { + override fun hashCode(): Int = 0 + } +} diff --git a/runtime/src/test/kotlin/composium/ParamSelectionTest.kt b/runtime/src/test/kotlin/composium/ParamSelectionTest.kt new file mode 100644 index 0000000..7f80b6b --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamSelectionTest.kt @@ -0,0 +1,245 @@ +package oleginvoke.com.composium + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ParamSelectionTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun selectingDistinctThemeColorsUpdatesTheValueAndTheSelectedName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + MaterialTheme(colorScheme = lightColorScheme(background = Color.Blue, surface = Color.Red)) { + var choice: Color by param(listOf( + MaterialTheme.colorScheme.background named "colorScheme.background", + MaterialTheme.colorScheme.surface named "colorScheme.surface", + )) + driver.Observe(choice, { choice }, { choice = it }) + } + } + driver.select("colorScheme.surface") + driver.assertObject(Color.Red, "colorScheme.surface") + driver.select("colorScheme.background") + driver.assertObject(Color.Blue, "colorScheme.background") + } + + @Test + fun selectingEqualThemeColorsUpdatesTheSelectedName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + MaterialTheme(colorScheme = lightColorScheme(background = Color.Blue, surface = Color.Blue)) { + var choice: Color by param(listOf( + MaterialTheme.colorScheme.background named "colorScheme.background", + MaterialTheme.colorScheme.surface named "colorScheme.surface", + )) + driver.Observe(choice, { choice }, { choice = it }) + } + } + driver.select("colorScheme.surface") + driver.assertObject(Color.Blue, "colorScheme.surface", options = listOf( + Color.Blue to "colorScheme.background", Color.Blue to "colorScheme.surface", + )) + // Repeating the click must also pin the no-op path after the saved name changed. + driver.select("colorScheme.surface") + driver.assertObject(Color.Blue, "colorScheme.surface") + } + + @Test + fun selectingAnEqualObjectUpdatesTheNameWhileKeepingTheCurrentInstance() { + val first = EqualValue(8) + val second = EqualValue(8) + assertNotSame(first, second) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: EqualValue by param(listOf(first named "First", second named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.assertObject(EqualValue(8), "Second") + composeRule.runOnIdle { assertSame(first, driver.value()) } + } + + @Test + fun referenceEqualityObjectsWithTheSameDisplayTextRemainSeparatelySelectable() { + val first = ReferenceValue() + val second = ReferenceValue() + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: ReferenceValue by param(listOf(first named "First", second named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.assertObject(second, "Second") + composeRule.runOnIdle { assertSame(second, driver.value()) } + } + + @Test + fun directAssignmentToAListedValueOverridesThePreviouslySavedName() { + val driver = numericChoices() + driver.select("Large") + driver.assign(8) + driver.assertObject(8, "Small") + } + + @Test + fun assigningAwayAndBackToEqualOptionsKeepsThePreviouslyClickedName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(8 named "First", 8 named "Second", 24 named "Third")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.assertObject(8, "Second") + driver.assign(24) + driver.assertObject(24, "Third") + driver.assign(8) + driver.assertObject(8, "Second") + } + + @Test + fun directAssignmentToAnUnlistedValueCurrentlyKeepsThePreviouslySelectedName() { + val driver = numericChoices() + driver.select("Large") + driver.assign(16) + driver.assertObject(16, "Large", options = listOf(8 to "Small", 24 to "Large")) + } + + @Test + fun directAssignmentToAnUnlistedValueBeforeAnyClickCurrentlyKeepsTheDefaultName() { + val driver = numericChoices() + driver.assign(16) + driver.assertObject(16, "Small", options = listOf(8 to "Small", 24 to "Large")) + } + + @Test + fun directAssignmentWithoutOptionsCurrentlyKeepsTheOriginalGeneratedDefaultName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(8) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(24) + driver.assertObject(24, "8", options = emptyList()) + } + + @Test + fun directAssignmentWithoutOptionsKeepsAnExplicitLabel() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(8 named "Spacing") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(24) + driver.assertObject(24, "Spacing", options = emptyList()) + } + + @Test + fun assigningAnEqualObjectCurrentlyKeepsTheExistingInstance() { + val first = EqualValue(8) + val replacement = EqualValue(8) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: EqualValue by param(first named "Original") + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assign(replacement) + driver.assertObject(EqualValue(8), "Original") + composeRule.runOnIdle { assertSame(first, driver.value()) } + } + + @Test + fun assignmentReadOnlyInAChildRefreshesTheInspectorDescriptor() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(8 named "Small", 24 named "Large")) + driver.ObserveInChild({ choice }, { choice = it }) + } + driver.assign(24) + driver.assertObject(24, "Large") + driver.select("Large") + driver.assertObject(24, "Large") + } + + @Test + fun equalNamedOptionsDoNotReplaceAnExplicitDefaultsObjectInstance() { + val default = EqualValue(8) + val option = EqualValue(8) + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: EqualValue by param(default = default, options = listOf(option named "Option")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.assertObject(EqualValue(8), "Option") + composeRule.runOnIdle { + assertSame(default, driver.value()) + val descriptor = driver.scope.params.single() as ObjectParamDescriptor + assertSame(default, descriptor.selectedOption.value) + assertSame(option, descriptor.options.single().value) + } + } + + @Test + fun boxedNumericValuesOfDifferentTypesRemainDistinctOptions() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Any by param(listOf(1 named "Int", 1L named "Long", 1.0 named "Double")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Long") + driver.assertObject(1L, "Long") + driver.select("Double") + driver.assertObject(1.0, "Double") + } + + @Test + fun equalNaNOptionsCanBeSelectedByName() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Double by param(listOf(Double.NaN named "First", Double.NaN named "Second")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Second") + driver.assertObject(Double.NaN, "Second") + } + + @Test + fun signedZeroValuesAreDistinctUnderBoxedEquality() { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Double by param(listOf(0.0 named "Positive", -0.0 named "Negative")) + driver.Observe(choice, { choice }, { choice = it }) + } + driver.select("Negative") + driver.assertObject(-0.0, "Negative") + driver.select("Positive") + driver.assertObject(0.0, "Positive") + } + + private fun numericChoices(): ParamTestDriver { + val driver = ParamTestDriver(composeRule) + driver.setContent { + var choice: Int by param(listOf(8 named "Small", 24 named "Large")) + driver.Observe(choice, { choice }, { choice = it }) + } + return driver + } + + private data class EqualValue(val number: Int) + + private class ReferenceValue { + override fun toString(): String = "Same display text" + } +} diff --git a/runtime/src/test/kotlin/composium/ParamTestDriver.kt b/runtime/src/test/kotlin/composium/ParamTestDriver.kt new file mode 100644 index 0000000..2fb284f --- /dev/null +++ b/runtime/src/test/kotlin/composium/ParamTestDriver.kt @@ -0,0 +1,91 @@ +package oleginvoke.com.composium + +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithTag +import kotlin.test.assertEquals + +/** Exercises the public delegate and the descriptor consumed by the inspector independently. */ +internal class ParamTestDriver( + private val rule: ComposeContentTestRule, + val scope: SceneScope = SceneScope(), +) { + private lateinit var readValue: () -> T + private lateinit var writeValue: (T) -> Unit + + fun setContent(content: @Composable SceneScope.() -> Unit) { + rule.setContent { scope.content() } + } + + @Composable + fun Observe(value: T, read: () -> T, write: (T) -> Unit) { + BasicText(value.toString(), Modifier.testTag("param-value")) + SideEffect { + readValue = read + writeValue = write + } + } + + /** Deliberately reads in a child composition to characterize skipped declaration recomposition. */ + @Composable + fun ObserveInChild(read: () -> T, write: (T) -> Unit) { + Observe(read(), read, write) + } + + fun value(): T = readValue() + + fun assertValue(value: T) { + rule.onNodeWithTag("param-value").assertTextEquals(value.toString()) + rule.runOnIdle { assertEquals(value, readValue(), "delegated value") } + } + + fun assign(value: T) { + rule.runOnIdle { writeValue(value) } + } + + /** Use inside runOnIdle to combine a value assignment with another state update. */ + fun assignNow(value: T) { + writeValue(value) + } + + fun select(optionName: String, paramName: String = "choice") { + rule.runOnIdle { + val descriptor = scope.params.single { it.name == paramName } as ObjectParamDescriptor + scope.paramsCallbacks.onObjectParamChange( + paramName, + descriptor.options.single { it.name == optionName }, + ) + } + } + + fun activate(isActive: Boolean, paramName: String = "choice") { + rule.runOnIdle { scope.paramsCallbacks.onParamActivationChange(paramName, isActive) } + } + + fun assertObject( + value: T, + selectedName: String, + options: List>? = null, + isActive: Boolean? = null, + selectedValue: Any? = value, + paramName: String = "choice", + typeName: String? = null, + ) { + assertValue(value) + rule.runOnIdle { + val descriptor = scope.params.single { it.name == paramName } as ObjectParamDescriptor + assertEquals(selectedName, descriptor.selectedOption.name, "inspector selection") + assertEquals(selectedValue, descriptor.selectedOption.value, "inspector value") + assertEquals(isActive, descriptor.activation?.isActive, "activation") + if (options != null) { + assertEquals(options, descriptor.options.map { it.value to it.name }, "inspector options") + } + if (typeName != null) assertEquals(typeName, descriptor.typeName) + } + } +} diff --git a/sample/src/benchmark/kotlin/com/example/app/ReactiveParametersBenchmarkScene.kt b/sample/src/benchmark/kotlin/com/example/app/ReactiveParametersBenchmarkScene.kt new file mode 100644 index 0000000..232c39c --- /dev/null +++ b/sample/src/benchmark/kotlin/com/example/app/ReactiveParametersBenchmarkScene.kt @@ -0,0 +1,141 @@ +package com.example.app + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import oleginvoke.com.composium.ComposiumScene +import oleginvoke.com.composium.SceneTools +import oleginvoke.com.composium.scene + +private val ReactiveParameterNames = List(100) { index -> "reactiveColor$index" } +private val ProductThemeA = lightColorScheme( + surface = Color(0xFFFFE0E0), + background = Color(0xFFFFE0E0), + primary = Color(0xFF1A4BA3), +) +private val ProductThemeB = lightColorScheme( + surface = Color(0xFFFFF4BA), + background = Color(0xFFFFF4BA), + primary = Color(0xFF6A1B9A), +) + +@ComposiumScene +internal val reactiveParametersBenchmark by scene( + group = "Benchmark/Parameters", + name = "Reactive parameters", + tools = SceneTools.None, + thumbnail = null, +) { contentPadding -> + var isProductB by remember { mutableStateOf(false) } + var themeSwitches by remember { mutableIntStateOf(0) } + var unrelatedRecompositions by remember { mutableIntStateOf(0) } + MaterialTheme( + colorScheme = if (isProductB) ProductThemeB else ProductThemeA, + ) { + val colorScheme = MaterialTheme.colorScheme + val options = listOf( + colorScheme.surface named "surface", + colorScheme.primary named "primary", + ) + val colors = mutableListOf() + // Every declaration has a fixed, unique explicit name. The first option selects "surface". + ReactiveParameterNames.forEach { name -> + val color: Color by param(options = options, name = name) + colors.add(color) + } + + // These reads invalidate the content, including all param calls, on an unrelated click. + val status = "Theme: product ${if (isProductB) "B" else "A"}; " + + "theme switches: $themeSwitches; " + + "unrelated recompositions: $unrelatedRecompositions; params: ${colors.size}" + + Surface( + modifier = Modifier.fillMaxSize(), + color = colorScheme.background, + ) { + Column( + modifier = Modifier + .padding(contentPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Reactive parameters", style = MaterialTheme.typography.titleLarge) + FilledTonalButton( + onClick = host.onBack, + modifier = Modifier.semantics { contentDescription = "Return to catalog" }, + ) { + Text("Back") + } + Button( + onClick = { + themeSwitches++ + isProductB = !isProductB + }, + modifier = Modifier.semantics { contentDescription = "Switch product theme" }, + ) { + Text("Switch product theme") + } + Button( + onClick = { unrelatedRecompositions++ }, + modifier = Modifier.semantics { contentDescription = "Recompose scene" }, + ) { + Text("Recompose scene") + } + Text( + text = status, + modifier = Modifier.semantics { contentDescription = "Reactive parameter status" }, + style = MaterialTheme.typography.bodySmall, + ) + // Informational only: the baseline runtime intentionally retains the old color. + Text( + text = "Observed surface: ${colors.first().argbHex()}; " + + "current surface: ${colorScheme.surface.argbHex()}", + style = MaterialTheme.typography.bodySmall, + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + repeat(10) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + repeat(10) { column -> + Box( + modifier = Modifier + .weight(1f) + .height(16.dp) + .background(colors[row * 10 + column]), + ) + } + } + } + } + } + } + } +} + +private fun Color.argbHex(): String = + "#${toArgb().toUInt().toString(16).padStart(8, '0').uppercase()}" diff --git a/sample/src/main/kotlin/com/example/app/SampleScenes.kt b/sample/src/main/kotlin/com/example/app/SampleScenes.kt index 44a4431..b7d1ce6 100644 --- a/sample/src/main/kotlin/com/example/app/SampleScenes.kt +++ b/sample/src/main/kotlin/com/example/app/SampleScenes.kt @@ -100,11 +100,17 @@ internal val tonalNestedPlayground by scene( val size: ButtonSize by param(ButtonSize.Medium) { inferred -> inferred.reversed() } + val background: Color by param( + listOf( + MaterialTheme.colorScheme.background named "colorScheme.background", + MaterialTheme.colorScheme.primary named "colorScheme.primary", + ) + ) Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(background), ) { Box( modifier = Modifier