From 2fe9b908eaaa231f3e1c0da1e07fd6d73511ff94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 17:22:14 +0000 Subject: [PATCH 1/6] Move form submission callbacks to submit and verify published consumers --- .github/workflows/verification.yml | 92 +++++ CHANGELOG.md | 8 + README.md | 94 ++++- build.gradle.kts | 11 +- gradle/libs.versions.toml | 4 + .../kotlin/com/quantipixels/ikokuko/Form.kt | 12 +- .../com/quantipixels/ikokuko/FormScope.kt | 72 ++-- .../ikokuko/ValidationRegistration.kt | 74 ++++ .../ikokuko/FormRecompositionTest.kt | 368 +++++++++++++++++- .../com/quantipixels/ikokuko/FormStateTest.kt | 16 +- .../com/quantipixels/ikokuko/sample/App.kt | 2 + .../ikokuko/sample/BackendErrorForm.kt | 80 ++++ .../quantipixels/ikokuko/sample/FormInput.kt | 20 +- .../quantipixels/ikokuko/sample/SignUpForm.kt | 34 +- .../ikokuko/sample/SubmissionPolicy.kt | 51 +++ .../ikokuko/sample/ComposeAppCommonTest.kt | 55 ++- tooling/verify-publication.py | 203 ++++++++++ verification/README.md | 100 +++++ verification/apple-ci.py | 39 ++ verification/consumer/build.gradle.kts | 148 +++++++ verification/consumer/gradle.properties | 3 + verification/consumer/settings.gradle.kts | 36 ++ .../ikokuko/verification/RestorationTest.kt | 45 +++ .../src/androidMain/AndroidManifest.xml | 5 + .../verification/VerificationActivity.kt | 68 ++++ .../ikokuko/verification/PublishedApiTest.kt | 64 +++ 26 files changed, 1605 insertions(+), 99 deletions(-) create mode 100644 .github/workflows/verification.yml create mode 100644 ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt create mode 100644 samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt create mode 100644 samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt create mode 100755 tooling/verify-publication.py create mode 100644 verification/README.md create mode 100644 verification/apple-ci.py create mode 100644 verification/consumer/build.gradle.kts create mode 100644 verification/consumer/gradle.properties create mode 100644 verification/consumer/settings.gradle.kts create mode 100644 verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt create mode 100644 verification/consumer/src/androidMain/AndroidManifest.xml create mode 100644 verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt create mode 100644 verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml new file mode 100644 index 0000000..03585a8 --- /dev/null +++ b/.github/workflows/verification.yml @@ -0,0 +1,92 @@ +name: Published artifact verification + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + apple-consumer: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: iosSimulatorArm64 + - runner: macos-15-intel + target: iosX64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 35 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v5 + - name: Verify on an owned simulator clone + run: python3 verification/apple-ci.py ${{ matrix.target }} + - name: Preserve publication and test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ikokuko-${{ matrix.target }}-publication-evidence + path: | + build/verification/*/*.log + build/verification/*/*.json + build/verification/*/maven/staged-files.sha256 + build/verification/*/consumer-build/reports/ + build/verification/*/consumer-build/test-results/ + if-no-files-found: warn + + android-consumer: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v5 + - name: Install the pinned Android test platform + run: | + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "system-images;android-35;google_apis;x86_64" + echo no | "$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd --name publication-verification --package "system-images;android-35;google_apis;x86_64" + - name: Run the packaged Android fixture + shell: bash + run: | + set -euo pipefail + mkdir -p build/verification + if [[ -e /dev/kvm ]]; then sudo chmod a+rw /dev/kvm; fi + "$ANDROID_HOME/emulator/emulator" -avd publication-verification -port 5580 -no-window -no-audio -no-snapshot -gpu swiftshader_indirect > build/verification/emulator.log 2>&1 & + emulator_pid=$! + trap '"$ANDROID_HOME/platform-tools/adb" -s emulator-5580 emu kill || true; wait "$emulator_pid" || true' EXIT + timeout 180 "$ANDROID_HOME/platform-tools/adb" -s emulator-5580 wait-for-device + booted=false + for attempt in {1..90}; do + if [[ "$("$ANDROID_HOME/platform-tools/adb" -s emulator-5580 shell getprop sys.boot_completed | tr -d '\r')" == 1 ]]; then booted=true; break; fi + sleep 2 + done + [[ "$booted" == true ]] + python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 + - name: Preserve publication and test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ikokuko-android-publication-evidence + path: | + build/verification/emulator.log + build/verification/*/*.log + build/verification/*/*.json + build/verification/*/maven/staged-files.sha256 + build/verification/*/consumer-build/reports/ + build/verification/*/consumer-build/test-results/ + build/verification/*/consumer-build/outputs/androidTest-results/ + if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e36a4..e4de32a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Ikokuko are recorded in this file. ## [Unreleased] +- Remove `onSubmit` from `Form` and require it at `submit(onInvalid = {}) { ... }`. + Old submit trailing lambdas handled invalid input; migrate them to named `onInvalid`. +- Flush pending validation synchronously on submit against one value snapshot. Preserve external + errors for unchanged inputs and propagate validator exceptions before applying submit changes. +- Clarify reset, field lifetime, and validator registration timing. +- Add sample backend-rejection handling with stale-response checks and consumed-response acknowledgment. +- Add multiline sample inputs with caller-owned counters and UTF-16 length validation, without truncation. + ## [0.2.0] - 2026-08-02 - Add strict field validation state and `Field.shouldDisplayError`. diff --git a/README.md b/README.md index c6c4de8..8c560cd 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ dependencies { } ``` +The examples below target the unreleased API. Version 0.2.0 uses different `Form` and `submit` +signatures; see [Migrating to the next release](#migrating-to-the-next-release). +For the released API, use the [0.2.0 documentation](https://github.com/quantipixels/ikokuko/blob/release-0.2.0/README.md). + --- ### 2. [FormState](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt) @@ -88,7 +92,7 @@ formState.shouldShowErrors = true // Permit dirty-field errors formState.shouldShowErrors = false // Hide errors again // From FormScope -Form(onSubmit = {}) { +Form { // ... shouldShowErrors = true // Permit dirty-field errors shouldShowErrors = false // Hide errors again @@ -96,13 +100,16 @@ Form(onSubmit = {}) { ``` #### Resetting the form -The form can be reset from either the FormState or inside the FormScope. +Reset clears values, errors, dirty flags, and error visibility. Mounted fields reinitialize on +recomposition using the current `initialValue` inputs, which may differ from their original values. +A submit before that recomposition sees no initialized fields. Unmounting a field clears its error +but retains its value and dirty flag; a different `FormState` replaces the form subtree. ```kotlin // From FormState formState.reset() // From FormScope -Form(onSubmit = {}) { +Form { // ... Button(onClick = ::reset) { Text("Reset Form") } } @@ -172,7 +179,7 @@ Use one `ValidationEffect` or `FormField` for each field in a form. - **Manual setup** — call [ValidationEffect](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt) directly to register and validate a field. ```kotlin -Form(onSubmit={ println("Email: ${EmailField.value}") }) { +Form { ValidationEffect( field = EmailField, initialValue = "", @@ -244,6 +251,23 @@ EmailField.error = null --- +### Backend rejections and multiline limits + +The sample's `BackendErrorForm` uses the existing `TextInput` adapter and field APIs. +The application owns a unique request ID, a submitted-values snapshot, the pending response, +and its consumed request ID outside the form's lifetime. It applies a rejection only when the +request is current and all submitted values still match. Known initialized fields receive +`field.error` and `field.isDirty = true`; unknown names produce a form-level fallback. +Every response is acknowledged, including stale responses, so recomposition or re-entry cannot +reapply it. Retain that acknowledgment alongside your request state across navigation. +The demo's rejection button supplies a local response; there is no network service. + +`TextInput` accepts `minLines`, `maxLines`, and caller-owned `counterText`. The sample uses +`MaxLengthValidator` with a 240-unit notes limit and shows the counter alongside errors. +Both validation and the counter use Kotlin `String.length`: UTF-16 code units, not visible +characters. An emoji may count as two units. Input, including pasted text and newlines, is kept +intact; over-limit input is rejected by validation rather than silently truncated. + ### 6. Creating Reusable Form Components ìkọkúkọ’s [FormScope](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt) lets you build reusable composable form components that automatically handle value binding, validation, and error display. @@ -261,7 +285,10 @@ fun FormScope.TextInput( initialValue: String = "", label: String = "", placeholder: String = "", - validators: List> = emptyList() + validators: List> = emptyList(), + minLines: Int = 1, + maxLines: Int = 1, + counterText: ((String) -> String)? = null ) { FormField(field, initialValue, validators) { Column(modifier = modifier) { @@ -275,9 +302,18 @@ fun FormScope.TextInput( color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f) ) }, - supportingText = field.error.takeIf { field.shouldDisplayError }?.let { { Text(it) } }, + supportingText = if (field.shouldDisplayError || counterText != null) { + { + Column { + if (field.shouldDisplayError) field.error?.let { Text(it) } + counterText?.let { Text(it(field.value)) } + } + } + } else null, onValueChange = { field.value = it }, - singleLine = true, + singleLine = maxLines == 1, + minLines = minLines, + maxLines = maxLines, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(12.dp)) @@ -314,11 +350,7 @@ val ConfirmPasswordField = Field.Text("confirm_password") fun SignUpForm() { val formState = remember { FormState() } - Form(state = formState, onSubmit = { - println("Email: ${EmailField.value}") - println("Password: ${PasswordField.value}") - println("Password Confirmation: ${ConfirmPasswordField.value}") - }) { + Form(state = formState) { Column { TextInput( field = EmailField, @@ -347,7 +379,12 @@ fun SignUpForm() { FieldEqualsValidator("passwords must match", PasswordField) ) ) - Button(onClick = ::submit, enabled = isValid) { + Button(onClick = { + submit(onInvalid = { println("Check the highlighted fields") }) { + println("Email: ${EmailField.value}") + // Send the current values to your application here. + } + }, enabled = isValid) { Text("Sign In") } } @@ -358,11 +395,21 @@ fun SignUpForm() { #### Notes - Uses the `TextInput` reusable component defined in the previous section. - `FormState` tracks and validates all registered fields automatically. -- The `onSubmit` callback executes only when all validations pass. +- The success lambda passed to `submit` runs only when the form has no stored errors after pending validation. - Cross-field validators declare the fields they read. A dependency value change revalidates the target field. - The form `isValid` property remains `true` until error reporting is enabled. After the first `submit()`, stored errors can disable UI elements such as this button. -- `submit()` uses the latest completed reactive validation cycle. Do not assign a field value and call `submit()` synchronously in the same callback. +- `submit { ... }` flushes pending validation, including value and dependency edits made in the + same callback. It copies values once, computes all pending errors, then applies them before + marking initialized fields dirty and calling exactly one callback. An unchanged field retains + its manual/server error; changed validation inputs replace that error just as reactive validation does. +- If a validator throws, the exception propagates. Submit applies no pending errors, dirty flags, + visibility changes or callbacks. Value edits made before submit remain yours. +- Validators use the currently registered list. A list replaced before recomposition takes effect + after composition commits. Replace mutable field values with new values; in-place mutation is + unsupported and may be invisible to validation. +- Callbacks run after validation commits and can reset or submit again. Your application owns + any recursion. A queued effect for already validated inputs preserves errors assigned by the callback. --- ### 8. Built-in [Validators](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt) @@ -449,6 +496,17 @@ Declare every field read during validation in `dependencies`. --- +## Migrating to the next release + +- Remove `onSubmit` from `Form`; it now accepts only `state` and `content`. +- Supply success at the action: `submit { sendCurrentValues() }`, or + `submit(onInvalid = { showSummary() }) { sendCurrentValues() }`. +- **Old `submit { ... }` trailing lambdas meant `onInvalid`. They now mean successful submission.** + Move old invalid handlers to the named `onInvalid` argument and add a success lambda. +- Replace `onClick = ::submit` with `onClick = { submit { /* success */ } }`. +- Same-callback edits now validate synchronously. Unchanged validation inputs still retain external + errors; changing a value, dependency, validators, or reset key replaces them on validation. + ## Migrating from 0.1.0 - Change nullable field types to non-null types. `Field` now requires `T : Any`. @@ -457,8 +515,7 @@ Declare every field read during validation in `dependencies`. - Give each field a unique name within its `FormScope`. Same-name fields share one stored value. - Rename the `default` argument of `ValidationEffect` and `FormField` to `initialValue`. - Replace `field.markAsDirty()` with `field.isDirty = true`. The `isDirty` property is now writable. -- Remove explicit `null` arguments from `submit(onInvalid)`. Its fallback is now a non-null no-op - callback. +- Remove explicit `null` arguments from `onInvalid`; its default is a non-null no-op callback. - Use `field.shouldDisplayError` for rendering. `field.error` and `field.isValid` expose strict stored field state. `FormState.isValid` ignores stored errors until error reporting is enabled. - Update custom validators to implement `fun ValidationScope.validate(value: T)`. Declare each @@ -479,7 +536,8 @@ Declare every field read during validation in `dependencies`. - Use `CheckedValidator` for required Boolean fields. Use `InValidator`, `NotInValidator`, and `SelectionInValidator` for membership rules. - Account for reset behavior. `reset()` now clears values, errors, dirty state, and error visibility, - then revalidates initialized effects. + then reinitializes mounted effects on recomposition using their current initial inputs. + A submit before that recomposition sees no initialized fields. --- diff --git a/build.gradle.kts b/build.gradle.kts index e68fd19..4e39960 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest + plugins { // this is necessary to avoid the plugins to be loaded multiple times // in each subproject's classloader @@ -10,4 +12,11 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.androidLint) apply false alias(libs.plugins.vanniktechMavenPublish) apply false -} \ No newline at end of file +} + +// PR verification supplies its own disposable simulator; release defaults stay unchanged. +allprojects { + tasks.withType().configureEach { + providers.gradleProperty("verificationIosDevice").orNull?.let { device.set(it) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b25c48e..fe5e9ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,10 @@ core = "1.7.0" vanniktechMavenPublish = "0.35.0" [libraries] +verification-compose-test = { module = "androidx.compose.ui:ui-test-junit4", version = "1.9.3" } +verification-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.9.3" } +verification-test-runner = { module = "androidx.test:runner", version = "1.7.0" } +verification-test-core = { module = "androidx.test:core", version = "1.7.0" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } compose-runtime-saveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "composeMultiplatform" } diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt index 84fdb2b..8fe34d8 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable @@ -24,18 +23,15 @@ import androidx.compose.runtime.setValue * * @param state The [FormState] instance backing this form. A different instance replaces the form * content subtree and disposes effects owned by the previous form. - * @param onSubmit Called when [FormScope.submit] is triggered and the form is valid. * @param content The form body, scoped to [FormScope]. */ @Composable fun Form( state: FormState = remember { FormState() }, - onSubmit: FormScope.() -> Unit, content: @Composable FormScope.() -> Unit ) { - val currentOnSubmit = rememberUpdatedState(onSubmit) val scope = remember(state) { - FormScope(state) { currentOnSubmit.value(this) } + FormScope(state) } key(state) { scope.content() @@ -69,6 +65,9 @@ class FormState(shouldShowErrors: Boolean = false) { */ internal val values = mutableStateMapOf, Any>() + // Mounted effects own these registrations; they are deliberately not saved. + internal val registrations = mutableMapOf>() + /** * Tracks all [Field]s that have been modified since form initialization or reset. * @@ -127,7 +126,8 @@ class FormState(shouldShowErrors: Boolean = false) { * reinitialize their associated [Field]s to their provided initial values and mark them * as pristine on the next recomposition. * - * This fully restores the form to its initial state. + * Reinitialization uses the initial inputs supplied by that recomposition, not a saved + * copy of the original inputs. A submit before then sees no initialized fields. */ fun reset() { dirtyFields.clear() diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt index cef9c4b..d41ac0e 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt @@ -3,17 +3,16 @@ package com.quantipixels.ikokuko import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember /** * Provides typed field data and form actions inside a [Form] composition. */ @Suppress("UNCHECKED_CAST") class FormScope internal constructor( - internal val state: FormState, - private val onSubmit: FormScope.() -> Unit + internal val state: FormState ) { - internal val validationScope: ValidationScope = StateValidationScope(this) - /** All initialized fields in this form. */ val fields: Set> get() = state.values.keys @@ -81,29 +80,36 @@ class FormScope internal constructor( } /** - * Marks all initialized fields as dirty and enables error display. - * Calls the form submit callback only when the form is valid. + * Flushes pending validation against one value snapshot, then marks initialized fields dirty + * and enables error display. Calls exactly one of [onSubmit] or [onInvalid]. + * + * Unchanged validation inputs retain manual errors. Value and dependency edits in the same + * callback are included; validators use the currently registered list until recomposition. + * A validator exception propagates before errors, dirty flags or callbacks are changed here. + * Callbacks run after validation commits and may reset or resubmit; recursion is caller-owned. */ - fun submit(onInvalid: () -> Unit = {}) { + fun submit(onInvalid: () -> Unit = {}, onSubmit: FormScope.() -> Unit) { + val snapshot = state.values.toMap() + val pending = state.registrations.values.mapNotNull { registration -> + registration.pending(snapshot, state.resetKey) + } + pending.forEach { it.apply(state) } + fields.forEach { it.isDirty = true } state.shouldShowErrors = true + if (state.isValid) onSubmit() else onInvalid() } - /** Clears all state in this form. */ + /** Clears values, errors and visibility; mounted fields reinitialize on recomposition. */ fun reset() = state.reset() } -private class StateValidationScope( - private val scope: FormScope -) : ValidationScope { - override val Field.value: T - get() = with(scope) { value } -} - /** * Initializes [field] once and validates it when its value, validators, or declared - * dependency values change. Use one ValidationEffect for each field in a form. + * dependency values change, or after reset. Use one ValidationEffect for each field in a form. + * Removing it clears its error and keeps its value and dirty flag. Registration updates commit + * with composition; submit before recomposition uses the previous validator list. */ @Composable fun FormScope.ValidationEffect( @@ -113,21 +119,29 @@ fun FormScope.ValidationEffect( ) { if (!field.isInitialized) state.values[field] = initialValue - DisposableEffect(field) { - onDispose { field.error = null } + val registration = remember(state, field) { ValidationRegistration(field) } + val value = field.value + val dependencyValues = dependencyValues(validators, state.values) + + DisposableEffect(registration) { + onDispose { + if (state.registrations[field.name] === registration) { + state.registrations.remove(field.name) + field.error = null + } + } } - val value = field.value - val dependencyValues = validators - .flatMap(Validator<*>::dependencies) - .distinct() - .mapNotNull(state.values::get) - - LaunchedEffect(value, validators, dependencyValues, state.resetKey) { - field.error = validators.firstOrNull { validator -> - validator.dependencies.all { it.isInitialized } && - !with(validator) { validationScope.validate(value) } - }?.errorMessage + SideEffect { + registration.validators = validators.toList() + state.registrations[field.name] = registration + } + + LaunchedEffect(registration, value, validators, dependencyValues, state.resetKey) { + // Submit may already have evaluated these inputs and then received a server rejection. + if (state.registrations[field.name] === registration) { + registration.pending(state.values.toMap(), state.resetKey)?.apply(state) + } } } diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt new file mode 100644 index 0000000..4cbfd7a --- /dev/null +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt @@ -0,0 +1,74 @@ +package com.quantipixels.ikokuko + +private class SnapshotValidationScope( + private val values: Map, Any> +) : ValidationScope { + @Suppress("UNCHECKED_CAST") + override val Field.value: T + get() = values[this] as? T ?: error("Field '$name' accessed before initialization") +} + +internal data class ValidationKey( + val value: Any, + val validators: List>, + val dependencyValues: List, + val resetKey: Int +) + +// Both effect restarts and completed-validation keys must observe the same dependencies. +internal fun dependencyValues( + validators: List>, + values: Map, Any> +): List = validators + .flatMap { it.dependencies } + .distinct() + .mapNotNull(values::get) + +internal class ValidationRegistration(val field: Field) { + var validators: List> = emptyList() + var evaluatedKey: ValidationKey? = null + + fun key(values: Map, Any>, resetKey: Int): ValidationKey? { + val value = values[field] ?: return null + + return ValidationKey( + value = value, + validators = validators, + dependencyValues = dependencyValues(validators, values), + resetKey = resetKey + ) + } + + @Suppress("UNCHECKED_CAST") + fun pending(values: Map, Any>, resetKey: Int): PendingValidation? { + val key = key(values, resetKey) ?: return null + if (key == evaluatedKey) return null + + val scope = SnapshotValidationScope(values) + val firstFailure = validators.firstOrNull { validator -> + val dependenciesAvailable = validator.dependencies.all { it in values } + if (!dependenciesAvailable) return@firstOrNull false + + val valid = with(validator) { scope.validate(key.value as T) } + !valid + } + + return PendingValidation(this, key, firstFailure?.errorMessage) + } +} + +internal class PendingValidation( + private val registration: ValidationRegistration<*>, + private val key: ValidationKey, + private val error: String? +) { + fun apply(state: FormState) { + if (error == null) { + state.errors.remove(registration.field.name) + } else { + state.errors[registration.field.name] = error + } + + registration.evaluatedKey = key + } +} diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt index d9a91c1..1452886 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -35,7 +35,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( field = field, initialValue = "", @@ -68,7 +68,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { val currentRecomposition = recompositionTrigger ValidationEffect( @@ -118,7 +118,7 @@ class FormRecompositionTest { lateinit var currentScope: FormScope setContent { - Form(state = currentState, onSubmit = {}) { + Form(state = currentState) { ValidationEffect( field = field, initialValue = "", @@ -152,7 +152,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect(password, initialValue = "same", validators = emptyList()) ValidationEffect( confirmation, @@ -187,7 +187,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( confirmation, initialValue = "confirmation", @@ -236,7 +236,7 @@ class FormRecompositionTest { } setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( combined, initialValue = "AB", @@ -303,7 +303,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(state = state, onSubmit = {}) { + Form(state = state) { ValidationEffect(field, initialValue = initialValue, validators = emptyList()) SideEffect { formScope = this } } @@ -343,7 +343,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( field, initialValue = "", @@ -377,7 +377,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( field, initialValue = "", @@ -411,7 +411,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { if (showField) { ValidationEffect( field, @@ -472,7 +472,7 @@ class FormRecompositionTest { } setContent { - Form(onSubmit = {}) { + Form { ValidationEffect(field, initialValue = "value", validators = listOf(validator)) } } @@ -481,6 +481,352 @@ class FormRecompositionTest { runOnIdle { assertEquals(false, receiverWasFormScope) } } + @Test + fun same_callback_edit_is_validated_before_submit() = runCompositionTest { + val field = Field.Text("immediate") + lateinit var scope: FormScope + var submitted = 0 + var invalid = 0 + setContent { + Form { + ValidationEffect(field, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + field.value = "" + submit(onInvalid = { invalid++ }) { submitted++ } + assertEquals(VALIDATION_ERROR, field.error) + assertEquals(0, submitted) + assertEquals(1, invalid) + } + } + } + + @Test + fun same_callback_correction_submits_and_queued_effect_preserves_new_rejection() = runCompositionTest { + val field = Field.Text("email") + lateinit var scope: FormScope + var submitted = 0 + setContent { + Form { + ValidationEffect(field, "", listOf(RequiredValidator(VALIDATION_ERROR))) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + field.value = "corrected" + submit { + submitted++ + field.error = EXTERNAL_ERROR + } + assertEquals(1, submitted) + } + } + waitForIdle() + runOnIdle { with(scope) { assertEquals(EXTERNAL_ERROR, field.error) } } + } + + @Test + fun unchanged_external_error_blocks_but_changed_input_replaces_it() = runCompositionTest { + val field = Field.Text("email") + lateinit var scope: FormScope + var submitted = 0 + var invalid = 0 + setContent { + Form { + ValidationEffect(field, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + field.error = EXTERNAL_ERROR + submit(onInvalid = { invalid++ }) { submitted++ } + assertEquals(EXTERNAL_ERROR, field.error) + assertEquals(1, invalid) + assertEquals(0, submitted) + field.value = "changed" + submit(onInvalid = { invalid++ }) { submitted++ } + assertNull(field.error) + assertEquals(1, invalid) + assertEquals(1, submitted) + } + } + } + + @Test + fun same_callback_dependency_edits_use_one_current_snapshot() = runCompositionTest { + val first = Field.Text("first") + val second = Field.Text("second") + lateinit var scope: FormScope + var submitted = 0 + setContent { + Form { + ValidationEffect(first, "old", listOf(FieldEqualsValidator(MISMATCH_ERROR, second))) + ValidationEffect(second, "old", listOf(FieldEqualsValidator(MISMATCH_ERROR, first))) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + first.value = "new" + second.value = "new" + submit { submitted++ } + assertNull(first.error) + assertNull(second.error) + assertEquals(1, submitted) + first.value = "different" + submit(onInvalid = { submitted-- }) { error("Must be invalid") } + assertEquals(MISMATCH_ERROR, first.error) + assertEquals(MISMATCH_ERROR, second.error) + assertEquals(0, submitted) + } + } + } + + @Test + fun thrown_validator_does_not_partially_commit_pending_errors_or_submit_flags() = runCompositionTest { + val first = Field.Text("first") + val second = Field.Text("second") + lateinit var scope: FormScope + var callbacks = 0 + val throwing = object : Validator { + override val errorMessage = "unused" + override fun ValidationScope.validate(value: String): Boolean { + if (value == "throw") error("Validator failed") + return true + } + } + setContent { + Form { + ValidationEffect(first, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) + ValidationEffect(second, "valid", listOf(throwing)) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + first.error = EXTERNAL_ERROR + first.value = "" + second.value = "throw" + first.isDirty = false + second.isDirty = false + kotlin.test.assertFailsWith { + submit(onInvalid = { callbacks++ }) { + callbacks++ + } + } + assertEquals(0, callbacks) + assertEquals(EXTERNAL_ERROR, first.error) + assertNull(second.error) + assertFalse(first.isDirty) + assertFalse(second.isDirty) + assertFalse(shouldShowErrors) + // Restore before the queued reactive effect runs. + second.value = "valid" + } + } + } + + @Test + fun submit_after_reset_before_recomposition_skips_uninitialized_fields() = runCompositionTest { + val field = Field.Text("required") + lateinit var scope: FormScope + var initial by mutableStateOf("old") + var submitted = 0 + setContent { + Form { + ValidationEffect(field, initial, listOf(RequiredValidator(VALIDATION_ERROR))) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + initial = "current" + reset() + submit { submitted++ } + assertEquals(1, submitted) + assertTrue(fields.isEmpty()) + assertFalse(field.isDirty) + } + } + waitForIdle() + runOnIdle { + with(scope) { + assertEquals("current", field.value) + assertFalse(field.isDirty) + } + } + } + + @Test + fun removed_effect_is_not_flushed_and_replaced_state_owns_its_validation() = runCompositionTest { + val field = Field.Text("conditional") + var visible by mutableStateOf(true) + var state by mutableStateOf(FormState()) + lateinit var scope: FormScope + setContent { + Form(state) { + if (visible) ValidationEffect(field, "", listOf(RequiredValidator(VALIDATION_ERROR))) + SideEffect { scope = this } + } + } + waitForIdle() + val oldScope = scope + runOnIdle { + with(scope) { field.isDirty = true } + visible = false + } + waitForIdle() + runOnIdle { + with(scope) { + submit { assertEquals("", field.value) } + assertNull(field.error) + assertTrue(field.isDirty) + } + visible = true + state = FormState() + } + waitForIdle() + runOnIdle { + with(oldScope) { submit { assertNull(field.error) } } + with(scope) { + submit(onInvalid = {}) { error("New state must be invalid") } + assertEquals(VALIDATION_ERROR, field.error) + } + } + } + + @Test + fun missing_dependency_is_skipped_during_submit_and_success_may_reset() = runCompositionTest { + val field = Field.Text("confirmation") + val absent = Field.Text("absent") + lateinit var scope: FormScope + setContent { + Form { + ValidationEffect(field, "old", listOf(FieldEqualsValidator(MISMATCH_ERROR, absent))) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + field.value = "changed" + submit { + assertNull(field.error) + reset() + } + assertTrue(fields.isEmpty()) + assertFalse(shouldShowErrors) + } + } + } + + @Test + fun disposing_a_duplicate_declaration_does_not_remove_the_other_registration() = runCompositionTest { + val field = Field.Text("shared") + var showFirst by mutableStateOf(true) + lateinit var scope: FormScope + setContent { + Form { + if (showFirst) { + androidx.compose.runtime.key("first") { + ValidationEffect(field, "valid", emptyList()) + } + } + androidx.compose.runtime.key("second") { + ValidationEffect(field, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) + } + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { field.error = EXTERNAL_ERROR } + showFirst = false + } + waitForIdle() + runOnIdle { + with(scope) { + assertEquals(EXTERNAL_ERROR, field.error) + field.value = "" + submit(onInvalid = {}) { error("Remaining registration must validate") } + assertEquals(VALIDATION_ERROR, field.error) + } + } + } + + @Test + fun submit_before_recomposition_uses_registered_validators_then_replacement_revalidates() = runCompositionTest { + val field = Field.Text("rules") + var validators by mutableStateOf>>(emptyList()) + lateinit var scope: FormScope + var submitted = 0 + setContent { + Form { + ValidationEffect(field, "", validators) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + validators = listOf(RequiredValidator(VALIDATION_ERROR)) + with(scope) { + submit { submitted++ } + assertEquals(1, submitted) + } + } + waitForIdle() + runOnIdle { + with(scope) { + submit(onInvalid = {}) { error("Replaced validators must block") } + assertEquals(VALIDATION_ERROR, field.error) + } + } + } + + @Test + fun pending_results_are_computed_before_any_error_is_applied() = runCompositionTest { + val first = Field.Text("first") + val second = Field.Text("second") + lateinit var scope: FormScope + var observedError: String? = null + val observesFirst = object : Validator { + override val errorMessage = "unused" + override fun ValidationScope.validate(value: String): Boolean { + observedError = with(scope) { first.error } + return true + } + } + setContent { + Form { + ValidationEffect(first, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) + ValidationEffect(second, "valid", listOf(observesFirst)) + SideEffect { scope = this } + } + } + waitForIdle() + runOnIdle { + with(scope) { + first.error = EXTERNAL_ERROR + first.value = "" + second.value = "changed" + submit(onInvalid = {}) { error("Required field must block") } + assertEquals(EXTERNAL_ERROR, observedError) + assertEquals(VALIDATION_ERROR, first.error) + } + } + } + private fun runCompositionTest( block: suspend CompositionFixture.() -> Unit ): TestResult = runTest { diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt index 06cb344..ebcffce 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt @@ -16,7 +16,7 @@ class FormStateTest { fun form_validity_follows_error_reporting_while_field_validity_is_strict() { val field = Field.Text("email") val state = FormState() - val scope = FormScope(state) {} + val scope = FormScope(state) state.values[field] = "" with(scope) { @@ -41,7 +41,7 @@ class FormStateTest { fun value_setter_marks_only_initialized_value_changes_dirty() { val field = Field.Text("name") val state = FormState() - val scope = FormScope(state) {} + val scope = FormScope(state) state.values[field] = "initial" with(scope) { @@ -63,7 +63,7 @@ class FormStateTest { fun reset_clears_values_errors_dirty_state_and_visibility() { val field = Field.Text("email") val state = FormState(shouldShowErrors = true) - val scope = FormScope(state) {} + val scope = FormScope(state) state.values[field] = "value" with(scope) { @@ -86,12 +86,12 @@ class FormStateTest { val state = FormState() var submitted = false var invalid = false - val scope = FormScope(state) { submitted = true } + val scope = FormScope(state) state.values[field] = "" with(scope) { field.error = "required" - submit { invalid = true } + submit(onInvalid = { invalid = true }) { submitted = true } assertFalse(submitted) assertTrue(invalid) @@ -99,7 +99,7 @@ class FormStateTest { assertTrue(field.shouldDisplayError) field.error = null - submit() + submit { submitted = true } assertTrue(submitted) } } @@ -115,7 +115,7 @@ class FormStateTest { state.errors[name.name] = "invalid" val restored = FormState.Saver.roundTrip(state) - val scope = FormScope(restored) {} + val scope = FormScope(restored) with(scope) { assertEquals("Ada", name.value) @@ -142,7 +142,7 @@ class FormStateTest { ) val restored = FormState.saver(valuesSaver).roundTrip(state) - val scope = FormScope(restored) {} + val scope = FormScope(restored) with(scope) { assertEquals(Profile("Ada"), profile.value) diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt index d9cbe7c..ae438fb 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt @@ -56,6 +56,8 @@ fun App() { } Spacer(Modifier.height(24.dp)) SignUpForm(onSubmit = { signUpData = it }) + HorizontalDivider() + BackendErrorExample() } signUpData?.let { data -> diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt new file mode 100644 index 0000000..3095928 --- /dev/null +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt @@ -0,0 +1,80 @@ +package com.quantipixels.ikokuko.sample + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.quantipixels.ikokuko.Field +import com.quantipixels.ikokuko.Form +import com.quantipixels.ikokuko.MaxLengthValidator +import com.quantipixels.ikokuko.RequiredValidator + +private val ContactEmail = Field.Text("email") +private val Notes = Field.Text("notes") + +/** A local response control demonstrates the policy without a network dependency. */ +@Composable +fun BackendErrorExample() { + var sequence by remember { mutableStateOf(0L) } + var request by remember { mutableStateOf(null) } + var consumedRequestId by remember { mutableStateOf(null) } + var response by remember { mutableStateOf(null) } + Column { + Text("Backend rejection and multiline input") + BackendErrorForm( + request = request, + response = response, + consumedRequestId = consumedRequestId, + onConsumed = { consumed -> + if (request?.id == consumed.requestId) consumedRequestId = consumed.requestId + if (response == consumed) response = null + }, + onSubmit = { values -> request = SubmittedRequest(++sequence, values) } + ) + Button(enabled = request != null, onClick = { + response = FieldRejection(request!!.id, mapOf("email" to "This email is already registered")) + }) { Text("Reject the last submitted email") } + } +} + +/** The caller retains request identity and removes acknowledged responses across screen entries. */ +@Composable +fun BackendErrorForm( + request: SubmittedRequest?, + response: FieldRejection?, + consumedRequestId: Long?, + onConsumed: (FieldRejection) -> Unit, + onSubmit: (Map) -> Unit +) { + var fallback by remember { mutableStateOf(null) } + Form { + TextInput(ContactEmail, label = "Contact email", + validators = listOf(RequiredValidator("Email is required"))) + TextInput(Notes, label = "Notes", minLines = 3, maxLines = 6, + counterText = ::notesCounter, + validators = listOf(MaxLengthValidator("Use at most $NotesLimit UTF-16 units", NotesLimit))) + + LaunchedEffect(response) { + response?.let { + val decision = rejectionDecision(request, it, submittedValues(), + fields.map { field -> field.name }.toSet(), + consumed = consumedRequestId == it.requestId) + applyRejection(decision) + fallback = decision.fallback + onConsumed(it) + } + } + fallback?.let { Text(it) } + Button(onClick = { + submit { + fallback = null + onSubmit(submittedValues()) + } + }) { Text("Submit contact details") } + } +} diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt index d6ad72f..f696168 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt @@ -59,6 +59,9 @@ fun FormScope.TextInput( label: String = "", placeholder: String = "", isPassword: Boolean = false, + minLines: Int = 1, + maxLines: Int = 1, + counterText: ((String) -> String)? = null, validators: List> = emptyList(), trailingIcon: @Composable (() -> Unit)? = null, ) { @@ -74,13 +77,18 @@ fun FormScope.TextInput( color = MaterialTheme.colorScheme.secondary.copy(alpha = .7f) ) }, - supportingText = if (field.shouldDisplayError) { - field.error?.let { { Text(it) } } - } else { - null - }, + supportingText = if (field.shouldDisplayError || counterText != null) { + { + Column { + if (field.shouldDisplayError) field.error?.let { Text(it) } + counterText?.let { Text(it(field.value)) } + } + } + } else null, onValueChange = { field.value = it }, - singleLine = true, + singleLine = maxLines == 1, + minLines = minLines, + maxLines = maxLines, visualTransformation = if (isPassword) { PasswordVisualTransformation() } else { diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt index 0a40fc6..c079d08 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt @@ -65,22 +65,7 @@ fun SignUpForm( onSubmit: (SignUpData) -> Unit, modifier: Modifier = Modifier ) { - Form( - onSubmit = { - onSubmit( - SignUpData( - PhoneNumberField.value, - EmailField.value, - PasswordField.value, - ConfirmPasswordField.value, - enumValueOf(CapacityField.value), - ProjectsField.value, - TermsField.value - ) - ) - reset() - } - ) { + Form { var passwordHidden by remember { mutableStateOf(true) } var confirmationHidden by remember { mutableStateOf(true) } @@ -169,7 +154,22 @@ fun SignUpForm( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Button( - onClick = ::submit, + onClick = { + submit { + onSubmit( + SignUpData( + PhoneNumberField.value, + EmailField.value, + PasswordField.value, + ConfirmPasswordField.value, + enumValueOf(CapacityField.value), + ProjectsField.value, + TermsField.value + ) + ) + reset() + } + }, enabled = isValid, modifier = Modifier.weight(1f) ) { diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt new file mode 100644 index 0000000..2ea3194 --- /dev/null +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt @@ -0,0 +1,51 @@ +package com.quantipixels.ikokuko.sample + +import com.quantipixels.ikokuko.Field +import com.quantipixels.ikokuko.FormScope + +/** Keep this request with its response; a newer submission replaces it. */ +data class SubmittedRequest(val id: Long, val values: Map) + +data class FieldRejection(val requestId: Long, val errors: Map) + +internal data class RejectionDecision( + val fieldErrors: Map = emptyMap(), + val fallback: String? = null +) + +/** Stale responses are consumed too, so returning to the screen cannot apply them later. */ +internal fun rejectionDecision( + request: SubmittedRequest?, + response: FieldRejection, + currentValues: Map, + knownFields: Set, + consumed: Boolean +): RejectionDecision { + if (consumed || request == null || request.id != response.requestId || + request.values != currentValues + ) return RejectionDecision() + + return RejectionDecision( + fieldErrors = response.errors.filterKeys { it in knownFields }, + fallback = if (response.errors.keys.any { it !in knownFields }) { + "The request was rejected. Review your details and try again." + } else null + ) +} + +internal fun FormScope.submittedValues(): Map = + fields.associate { it.name to it.value } + +internal fun FormScope.applyRejection(decision: RejectionDecision) { + val initialized = fields.associateBy(Field<*>::name) + decision.fieldErrors.forEach { (name, message) -> + initialized[name]?.let { field -> + field.error = message + field.isDirty = true + } + } + if (decision.fieldErrors.isNotEmpty()) shouldShowErrors = true +} + +internal const val NotesLimit = 240 +internal fun notesCounter(value: String): String = "${value.length} / $NotesLimit UTF-16 units" diff --git a/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt b/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt index f47d051..acab4e2 100644 --- a/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt +++ b/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt @@ -1,12 +1,61 @@ package com.quantipixels.ikokuko.sample +import com.quantipixels.ikokuko.MaxLengthValidator +import com.quantipixels.ikokuko.ValidationScope +import com.quantipixels.ikokuko.Field import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue class ComposeAppCommonTest { + private val values = mapOf("email" to "person@example.com") + private val request = SubmittedRequest(2, values) + private val response = FieldRejection(2, mapOf("email" to "Already registered")) @Test - fun example() { - assertEquals(3, 1 + 2) + fun current_rejection_maps_only_known_initialized_fields_and_reports_unknown_fields() { + val decision = rejectionDecision( + request, response.copy(errors = response.errors + ("other" to "Rejected")), + values, setOf("email"), consumed = false + ) + assertEquals(response.errors, decision.fieldErrors) + assertNotNull(decision.fallback) } -} \ No newline at end of file + + @Test + fun stale_request_or_changed_values_do_not_apply_a_rejection() { + assertTrue(rejectionDecision(request, response.copy(requestId = 1), values, + setOf("email"), false).fieldErrors.isEmpty()) + assertTrue(rejectionDecision(request, response, mapOf("email" to "edited"), + setOf("email"), false).fieldErrors.isEmpty()) + assertTrue(rejectionDecision(null, response, values, + setOf("email"), false).fieldErrors.isEmpty()) + } + + @Test + fun consumed_response_cannot_reapply_after_editing_back_or_reentry() { + val decision = rejectionDecision(request, response, values, setOf("email"), true) + assertTrue(decision.fieldErrors.isEmpty()) + assertNull(decision.fallback) + } + + @Test + fun multiline_limit_validates_without_truncation_and_counter_uses_utf16_units() { + val validator = MaxLengthValidator("Too long", NotesLimit) + val scope = object : ValidationScope { + override val Field.value: T get() = error("No dependencies") + } + with(validator) { + assertTrue(with(scope) { validate("") }) + assertTrue(with(scope) { validate("a".repeat(NotesLimit - 1) + "\n") }) + val pasted = "a".repeat(NotesLimit) + "\n" + assertFalse(with(scope) { validate(pasted) }) + assertEquals(NotesLimit + 1, pasted.length) + } + assertEquals("2 / 240 UTF-16 units", notesCounter("😀")) + assertEquals("2 / 240 UTF-16 units", notesCounter("e\u0301")) + } +} diff --git a/tooling/verify-publication.py b/tooling/verify-publication.py new file mode 100755 index 0000000..7406cd0 --- /dev/null +++ b/tooling/verify-publication.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Stage a unique local publication and run its independent binary consumer.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import uuid +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +LIBRARY = "ikokuko" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def source_copy(destination): + # Copy current tracked and untracked source, never local machine configuration. + listing = subprocess.check_output( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=ROOT + ).decode().split("\0") + for name in listing: + relative = Path(name) + if not name or relative.parts[0] in {"verification", "tooling", ".alarina"}: + continue + if relative.name == "local.properties" or relative.name.startswith(".env"): + continue + source = ROOT / relative + if source.is_file() and not source.is_symlink(): + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def publication_manifest(stage, version): + modules = list(stage.glob("com/quantipixels/*/" + version + "/*.module")) + if not modules: + raise RuntimeError("No Gradle module metadata was published") + for module in modules: + metadata = json.loads(module.read_text()) + component = metadata["component"] + if component.get("group") != "com.quantipixels" or component.get("version") != version: + raise RuntimeError("Unexpected publication coordinates: " + str(module)) + if not component["module"].startswith(LIBRARY) or not metadata.get("variants"): + raise RuntimeError("Unexpected publication or empty variants: " + str(module)) + files = sorted(p for p in stage.rglob("*") if p.is_file()) + (stage / "staged-files.sha256").write_text("".join( + digest(p) + " " + p.relative_to(stage).as_posix() + "\n" for p in files + )) + + +def nonzero_reports(directory): + reports = list(directory.rglob("TEST-*.xml")) + tests = failures = skipped = 0 + for report in reports: + node = ET.parse(report).getroot() + suites = [node] if node.tag == "testsuite" else node.findall("testsuite") + for suite in suites: + tests += int(suite.get("tests", 0)) + failures += int(suite.get("failures", 0)) + int(suite.get("errors", 0)) + skipped += int(suite.get("skipped", 0)) + if tests == 0 or failures or skipped: + raise RuntimeError(f"Invalid consumer result: tests={tests}, failures={failures}, skipped={skipped}") + return tests + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--phase", choices=["configure", "plan", "stage", "verify"], default="verify") + parser.add_argument("--platform", choices=["android-unit", "android-device", "iosSimulatorArm64", "iosX64"], required=True) + parser.add_argument("--android-serial") + parser.add_argument("--ios-device", help="UUID of an owner-created disposable simulator") + args = parser.parse_args() + apple = args.platform.startswith("ios") + if apple and platform.system() != "Darwin": + parser.error("Apple publication and tests require macOS") + if args.phase == "verify" and apple and not args.ios_device: + parser.error("--ios-device must identify an owned simulator clone") + if args.phase == "verify" and args.platform == "android-device" and not args.android_serial: + parser.error("--android-serial is required; no default device is selected") + + run = ROOT / "build/verification" / uuid.uuid4().hex + run.mkdir(parents=True) + stage = run / "maven" + stage.mkdir() + version = "0.0.0-verification." + run.name + "-SNAPSHOT" + source = run / "source" + source_copy(source) + consumer = run / "consumer" + shutil.copytree(ROOT / "verification/consumer", consumer) + # The catalog stays the single versions source in both the checked-in and generated fixture. + (run / "gradle").mkdir() + shutil.copy2(ROOT / "gradle/libs.versions.toml", run / "gradle/libs.versions.toml") + settings = consumer / "settings.gradle.kts" + settings.write_text(settings.read_text().replace("../../gradle/libs.versions.toml", "../gradle/libs.versions.toml")) + + # No inherited Gradle properties, signing credentials, init scripts, or Java option injection. + environment = {key: os.environ[key] for key in ( + "PATH", "JAVA_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT", "SYSTEMROOT", "TMPDIR" + ) if key in os.environ} + if not environment.get("ANDROID_HOME") and not environment.get("ANDROID_SDK_ROOT"): + candidates = [Path.home() / "Library/Android/sdk", Path.home() / "Android/Sdk"] + sdk = next((path for path in candidates if (path / "platforms/android-36/android.jar").is_file()), None) + if sdk: + environment["ANDROID_HOME"] = str(sdk) + environment["KONAN_DATA_DIR"] = str(ROOT / "build/verification/konan") + environment["HOME"] = str(run / "home") + environment["GRADLE_USER_HOME"] = str(run / "gradle-user-home") + Path(environment["HOME"]).mkdir() + Path(environment["GRADLE_USER_HOME"]).mkdir(exist_ok=True) + for name in ("caches", "wrapper"): + cache = ROOT / "build/verification/gradle-user-home" / name + cache.mkdir(parents=True, exist_ok=True) + (Path(environment["GRADLE_USER_HOME"]) / name).symlink_to(cache, target_is_directory=True) + if args.android_serial: + environment["ANDROID_SERIAL"] = args.android_serial + metadata = {"library": LIBRARY, "version": version, "platform": args.platform, + "phase": args.phase, "stage": str(stage), "state": "running"} + record = run / "run.json" + record.write_text(json.dumps(metadata, indent=2) + "\n") + print("Evidence: " + str(run), flush=True) + + common = ["--no-daemon", "--no-configuration-cache", "--no-parallel", "--max-workers=1", "--console=plain", "--stacktrace"] + wrapper = str(source / "gradlew") + def execute(label, directory, tasks, properties=(), expected_failure=False, expected_marker=None): + command = [wrapper, "-p", str(directory), *common, *tasks, *properties] + (run / (label + ".command.json")).write_text(json.dumps(command, indent=2) + "\n") + with (run / (label + ".log")).open("w") as output: + try: + result = subprocess.run(command, cwd=directory, env=environment, + stdout=output, stderr=subprocess.STDOUT, + timeout=300 if args.phase in {"configure", "plan"} else 1200) + except (subprocess.TimeoutExpired, KeyboardInterrupt): + # This run has its own daemon registry; do not stop another user's Gradle work. + subprocess.run([wrapper, "--stop"], cwd=directory, env=environment, + stdout=output, stderr=subprocess.STDOUT, timeout=60) + raise + if (result.returncode != 0) != expected_failure: + raise RuntimeError(f"{label}: unexpected exit {result.returncode}; see {label}.log") + if expected_marker and expected_marker not in (run / (label + ".log")).read_text(): + raise RuntimeError(f"{label}: failed for a different reason; inspect its log") + print(f"{label}: exit {result.returncode}", flush=True) + + properties = ["-PstagedVersion=" + version, "-PstagedRepository=" + str(stage), + "-PverificationBuildDirectory=" + str(run / "consumer-build"), + "-PverificationAppleTarget=" + (args.platform if apple else "none")] + if args.ios_device: + properties += ["-PverificationIosDevice=" + args.ios_device] + try: + if args.phase == "configure": + execute("configure", consumer, ["help"], properties) + else: + # Exact local tasks only. Never accept arbitrary task names or remote publishing tasks. + target = {"iosSimulatorArm64": "IosSimulatorArm64", "iosX64": "IosX64"}.get(args.platform, "AndroidRelease") + tasks = [f":{LIBRARY}:publishKotlinMultiplatformPublicationToMavenLocal", + f":{LIBRARY}:publish{target}PublicationToMavenLocal"] + if args.phase == "plan": + tasks += ["--dry-run"] + execute("publication-plan" if args.phase == "plan" else "stage", source, tasks, + ["-PVERSION_NAME=" + version, "-Dmaven.repo.local=" + str(stage)]) + if args.phase != "plan": + publication_manifest(stage, version) + if args.phase == "verify": + execute("artifact-resolution", consumer, ["verifyStagedArtifacts"], properties) + empty = run / "empty-maven" + empty.mkdir() + negative = [p for p in properties if not p.startswith("-PstagedRepository=")] + execute("empty-stage-must-fail", consumer, ["verifyStagedArtifacts"], + [*negative, "-PstagedRepository=" + str(empty)], expected_failure=True, + expected_marker=f"Could not find com.quantipixels:{LIBRARY}") + wrong = run / "wrong-maven" + shutil.copytree(stage, wrong) + artifact = next(p for p in wrong.rglob("*") if p.suffix in {".aar", ".klib"}) + with artifact.open("ab") as output: + output.write(b"verification-negative-control") + execute("wrong-artifact-must-fail", consumer, ["verifyStagedArtifacts"], + [*negative, "-PstagedRepository=" + str(wrong)], expected_failure=True, + expected_marker="Changed staged artifact") + tests = [args.platform + "Test"] if apple else ["testDebugUnitTest"] + if args.platform == "android-device": + tests += ["connectedDebugAndroidTest"] + execute("consumer-tests", consumer, tests, properties) + metadata["executedTests"] = nonzero_reports(run / "consumer-build") + if args.platform == "android-device": + metadata["instrumentedTests"] = nonzero_reports(run / "consumer-build/outputs/androidTest-results") + metadata["state"] = "passed" + except (Exception, KeyboardInterrupt) as error: + metadata["state"] = "failed" + metadata["error"] = str(error) + raise + finally: + record.write_text(json.dumps(metadata, indent=2) + "\n") + print(json.dumps(metadata, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/verification/README.md b/verification/README.md new file mode 100644 index 0000000..76175c1 --- /dev/null +++ b/verification/README.md @@ -0,0 +1,100 @@ +# Published-artifact verification + +This standalone consumer proves the packaged `ikokuko` API. It is deliberately absent from the root build: it has its own settings, repositories, Android activity, and platform tests. The same consumer supplies Android restoration/ownership tests and Maven publication proof. + +## Commands + +Run from the repository root with JDK 17 and an installed Android SDK. Set `JAVA_HOME` to your detected JDK 17 directory and `ANDROID_HOME` or `ANDROID_SDK_ROOT` to your SDK. When no SDK variable is provided, the runner detects an installed SDK 36 at the standard macOS or Linux user SDK location. It reads no configuration file to do this. No `local.properties` file is copied or read by the fixture runner. + +```sh +# Configuration only: no publication, compilation, or tests. +python3 tooling/verify-publication.py --phase configure --platform android-unit + +# Inspect the exact local publication task graph without building. +python3 tooling/verify-publication.py --phase plan --platform android-unit + +# Stage the Android publication without running consumer tests. +python3 tooling/verify-publication.py --phase stage --platform android-unit + +# Stage, check provenance and negative controls, then run binary JVM tests. +python3 tooling/verify-publication.py --platform android-unit + +# Include real instrumentation on ONE explicitly selected, owned emulator. +python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 + +# macOS: use the UUID of an owned disposable simulator clone. +python3 tooling/verify-publication.py --platform iosSimulatorArm64 --ios-device "$owned_simulator_uuid" +python3 tooling/verify-publication.py --platform iosX64 --ios-device "$owned_simulator_uuid" + +# CI entry point: creates, boots, and deletes its own simulator clone. +python3 verification/apple-ci.py iosSimulatorArm64 +python3 verification/apple-ci.py iosX64 +``` + +Use the architecture matching the host runner. Do not pass the UUID of a user's original simulator. The helper clones an available, shut-down iPhone simulator and deletes only its own clone in `finally`. Local runs never choose a default Android serial or Apple device. The Android script leaves the selected emulator running; its owner controls its lifetime. The CI emulator job owns its process and stops it in its exit trap. + +Every invocation prints its evidence directory under `build/verification//`. `run.json` records the selected lane and outcome. Exact Gradle commands are stored as `*.command.json`; output is in `*.log`. Commands use `--no-parallel --max-workers=1 --no-daemon --no-configuration-cache`. Configuration and publication dry-runs are bounded to five minutes per Gradle invocation; publication and test invocations are bounded to twenty minutes each. A timeout attempts to stop only that run's isolated Gradle daemon registry. + +## Publication and resolution + +Each run copies the current working source into its generated directory, excluding local machine configuration, Git metadata, and build outputs. Finish concurrent library edits before staging: a source copy is not an atomic snapshot of a writer's changes. Root release configuration and tracked `VERSION_NAME` stay unchanged. Generated verification invocations disable configuration-cache reuse because each run has fresh source and its own daemon home. + +The version is `0.0.0-verification.-SNAPSHOT`. The only selected publication tasks are: + +```text +:ikokuko:publishKotlinMultiplatformPublicationToMavenLocal +:ikokuko:publishAndroidReleasePublicationToMavenLocal # Android lanes +:ikokuko:publishIosSimulatorArm64PublicationToMavenLocal # Apple Silicon lane +:ikokuko:publishIosX64PublicationToMavenLocal # Intel lane +``` + +Each invocation passes `-PVERSION_NAME=` and `-Dmaven.repo.local=/maven`. The script accepts no arbitrary task or Gradle arguments. It never calls `publish`, `publishToMavenCentral`, or a remote repository publication task. Signing tasks remain in Gradle's normal task graph. + +The standalone settings use `exclusiveContent` for all `com.quantipixels` modules, with Gradle module metadata as the only metadata source. There is no Maven Local, project dependency, composite build, or repository fallback for this group. Other dependencies use Google and Maven Central. The fixture reads `gradle/libs.versions.toml`; it adds no version matrix. Baseline: Kotlin 2.2.20, Compose Multiplatform 1.9.1 (Android Compose 1.9.3), AGP 8.11.2, Gradle 8.14.3, JVM 11. Android compile SDK is 36 and minimum SDK is 24; CI uses the API 35 Google APIs x86_64 emulator image. + +After staging, the runner validates actual `.module` coordinates and nonempty variants and records a SHA-256 manifest. + +`verifyStagedArtifacts` resolves both `debugRuntimeClasspath` and `debugAndroidTestRuntimeClasspath` for Android, or the selected native compile klibraries for Apple. The published library must be present in the app runtime or selected native configuration. The instrumentation APK has separate dependencies and drives the tested app, so it need not contain the library. Every `com.quantipixels` artifact found in either Android configuration still receives the same provenance checks. + +The task checks the exact version, published platform coordinates, corresponding staged module metadata, manifest hashes, and resolved artifact bytes against staged files. The report includes selected variant attributes, hashes, and staged paths. Gradle may cache downloaded files elsewhere; matching bytes establish correspondence without assuming the cache path is the Maven path. + +The Android debug consumer selects the published release library using `matchingFallbacks`. Apple lanes select `iosSimulatorArm64` or `iosX64` through actual Gradle metadata. Only those requested platform publications are built. + +## Signing policy + +[Vanniktech 0.35.0 source, `signAllPublications`](https://github.com/vanniktech/gradle-maven-publish-plugin/blob/0.35.0/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt#L138-L152) sets signing required to `!version.endsWith("-SNAPSHOT")`. This applies to publications signed for Maven Local too. A non-SNAPSHOT fixture therefore needs a signing key. The unique SNAPSHOT version uses the existing policy: no hook, task, signature requirement, or tracked `signAllPublications()` is disabled or changed. + +The runner builds a small allowlist of environment variables for Java/SDK discovery, uses generated Gradle user homes and project directories, and does not inherit signing, Maven Central, Gradle project-property, or Java option variables. It does not inspect a user's Gradle properties, init scripts, keyring, or environment secrets. Repository-local dependency caches and wrapper downloads are reused, while each run has a separate daemon registry. Failure evidence is retained; delete only the owned `build/verification` outputs when no proof is needed. + +## Discriminating failures and test counts + +A successful `verify` run must first resolve the real publication, then reject both negative controls: + +1. An empty staged repository must fail with `Could not find com.quantipixels:ikokuko`. Resolution happens before manifest checking, so a manifest-only error cannot satisfy this control. +2. A copied stage with one altered AAR or KLIB must fail with `Changed staged artifact`. Its original manifest is kept so the altered artifact cannot establish its own expected hash. + +The runner then executes `testDebugUnitTest`, adds `connectedDebugAndroidTest` for the device lane, or executes the selected `iosSimulatorArm64Test`/`iosX64Test`. Gradle test listeners and XML report checks reject zero tests, skipped tests, and failures. The device lane also requires nonzero instrumentation XML results independently of the JVM test count. Fresh generated build directories prevent old test reports from satisfying the checks. + +Expected tests: two common binary tests on each platform; three additional Android instrumentation tests. Negative controls are separate failed Gradle invocations, not skipped tests. + +## Native boundary and limits + +The Android tests use a real `ComponentActivity`, Compose input, and `ActivityScenario.recreate()`: + +- Enter text and select `second-id`; assert both preconditions before recreation, then assert restored values and submit in one event. +- Edit and submit in the same callback, first invalid and then valid, without allowing the validation effect to run between those operations. +- Focus a multiline field, inject Unicode text through Compose's text-input service, invoke its Done IME action, and reject an over-limit value without truncating it. The limit counts UTF-16 code units. + +The common binary tests compose `Form` and `ValidationEffect` against the published API, then edit and submit synchronously. A second test checks Unicode length behavior. They run on Android's JVM and each selected Apple simulator architecture. + +Compose text-input injection exercises the host's input connection and semantics, not a particular installed keyboard. This fixture does not prove a system clipboard paste gesture, composing-region behavior from a real keyboard, process death, or every field type. + +## CI evidence + +`.github/workflows/verification.yml` runs the two Apple consumer lanes and one Android instrumentation lane on pull requests or manual dispatch. New runs on the same workflow and ref cancel earlier in-progress runs. The Apple helper also runs the release simulator/sample checks and unsigned Xcode sample build. Release triggers and publication/signing policy are unchanged. + +CI uploads `ikokuko--publication-evidence` and `ikokuko-android-publication-evidence` from each workflow run, including on failure. Find these artifacts in the workflow run's artifact list. They contain command logs, command JSON, `run.json`, the staged SHA-256 manifest, provenance reports, and test reports. Android uploads also include emulator output and instrumentation XML results. + +Interpret `run.json` together with its `phase`: a passed `configure`, `plan`, or `stage` run does not prove consumer tests. A complete `verify` result requires successful artifact resolution, both negative controls failing with their expected markers, and nonzero test reports with no failures or skips. Check the instrumentation reports separately for an Android device run. A dry-run task marked `SKIPPED` proves only task selection, not publication, signing, or test execution. + +Use CI artifacts to assess a specific run. Generated local evidence directories are retained for diagnosis, but are not committed verification records or a standing claim that the tests passed. diff --git a/verification/apple-ci.py b/verification/apple-ci.py new file mode 100644 index 0000000..ecb9024 --- /dev/null +++ b/verification/apple-ci.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Run PR verification on a disposable Apple simulator owned by this process.""" +import json +from pathlib import Path +import subprocess +import sys +import uuid + +root = Path(__file__).resolve().parents[1] +target = sys.argv[1] if len(sys.argv) == 2 else "" +if target not in {"iosSimulatorArm64", "iosX64"}: + raise SystemExit("Usage: python3 verification/apple-ci.py iosSimulatorArm64|iosX64") +devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"])) +source = next(device["udid"] for runtime, entries in devices["devices"].items() + if "iOS" in runtime for device in entries + if device.get("isAvailable") and "iPhone" in device["name"] and device["state"] == "Shutdown") +clone = subprocess.check_output(["xcrun", "simctl", "clone", source, "Publication-" + uuid.uuid4().hex], text=True).strip() +try: + subprocess.run(["xcrun", "simctl", "boot", clone], check=True) + subprocess.run(["xcrun", "simctl", "bootstatus", clone, "-b"], check=True) + # Reuse the release simulator and sample checks before publication reaches a release tag. + suffix = "IosX64" if target == "iosX64" else "IosSimulatorArm64" + tasks = [":ikokuko:" + target + "Test", ":samples:composeApp:" + target + "Test", + ":samples:composeApp:linkDebugFramework" + suffix] + if target == "iosSimulatorArm64": + tasks += [":samples:composeApp:testDebugUnitTest", ":samples:composeApp:assembleDebug", + ":samples:composeApp:linkDebugFrameworkIosArm64"] + subprocess.run([str(root / "gradlew"), *tasks, "-PverificationIosDevice=" + clone, + "--no-parallel", "--max-workers=1", "--stacktrace"], cwd=root, check=True) + if target == "iosSimulatorArm64": + subprocess.run(["xcodebuild", "-project", "samples/iosApp/iosApp.xcodeproj", + "-scheme", "iosApp", "-configuration", "Debug", "-sdk", "iphonesimulator", + "-destination", "generic/platform=iOS Simulator", "CODE_SIGNING_ALLOWED=NO", "build"], + cwd=root, check=True) + subprocess.run([sys.executable, str(root / "tooling/verify-publication.py"), + "--platform", target, "--ios-device", clone], cwd=root, check=True) +finally: + subprocess.run(["xcrun", "simctl", "shutdown", clone], check=False) + subprocess.run(["xcrun", "simctl", "delete", clone], check=True) diff --git a/verification/consumer/build.gradle.kts b/verification/consumer/build.gradle.kts new file mode 100644 index 0000000..47fd6f4 --- /dev/null +++ b/verification/consumer/build.gradle.kts @@ -0,0 +1,148 @@ +import java.security.MessageDigest +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.tasks.testing.AbstractTestTask +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +layout.buildDirectory.set(file(providers.gradleProperty("verificationBuildDirectory").get())) +val stagedVersion = providers.gradleProperty("stagedVersion").get() +val stagedRepository = file(providers.gradleProperty("stagedRepository").get()) +val appleTarget = providers.gradleProperty("verificationAppleTarget").orElse("none").get() +require(appleTarget in setOf("none", "iosSimulatorArm64", "iosX64")) + +kotlin { + androidTarget { + compilerOptions.jvmTarget.set(JvmTarget.JVM_11) + } + when (appleTarget) { + "iosSimulatorArm64" -> iosSimulatorArm64() + "iosX64" -> iosX64() + } + sourceSets { + commonMain.dependencies { + implementation("com.quantipixels:ikokuko:$stagedVersion") + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + androidMain.dependencies { + implementation(libs.androidx.activity.compose) + } + androidInstrumentedTest.dependencies { + implementation(libs.verification.test.runner) + implementation(libs.verification.test.core) + implementation(libs.verification.compose.test) + } + } +} + +android { + namespace = "com.quantipixels.ikokuko.verification" + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { + applicationId = "com.quantipixels.ikokuko.verification" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.compileSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes.getByName("debug") { matchingFallbacks += "release" } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { debugImplementation(libs.verification.compose.manifest) } + +// Native tests must use a simulator chosen by the owner, never a user's default device. +tasks.withType().configureEach { + providers.gradleProperty("verificationIosDevice").orNull?.let { device.set(it) } + doFirst { + require(providers.gradleProperty("verificationIosDevice").isPresent) { + "Pass -PverificationIosDevice=" + } + } +} + +tasks.withType().configureEach { + afterSuite(KotlinClosure2({ descriptor, result -> + if (descriptor.parent == null) { + check(result.testCount > 0 && result.skippedTestCount == 0L) { + "Consumer tests must execute nonzero tests with no skips" + } + } + })) +} + +fun sha256(file: File): String = MessageDigest.getInstance("SHA-256") + .digest(file.readBytes()).joinToString("") { "%02x".format(it) } + +// Gradle caches artifacts outside the repository. Compare bytes, not cache path names. +tasks.register("verifyStagedArtifacts") { + group = "verification" + doLast { + val requiredConfigurations = if (appleTarget == "none") { + setOf("debugRuntimeClasspath", "debugAndroidTestRuntimeClasspath") + } else { + setOf("${appleTarget}CompileKlibraries") + } + val selected = configurations.filter { + it.isCanBeResolved && it.name in requiredConfigurations + } + check(selected.map { it.name }.toSet() == requiredConfigurations) { + "Missing resolvable verification configurations: $requiredConfigurations" + } + // Resolve before checking the manifest so an empty repository proves resolution fails. + val resolved = selected.associateWith { it.incoming.artifacts.artifacts } + val manifest = stagedRepository.resolve("staged-files.sha256") + check(manifest.isFile) { "Missing staged publication manifest" } + val expected = manifest.readLines().associate { line -> + val parts = line.split(" ", limit = 2) + check(parts.size == 2) + parts[1] to parts[0] + } + expected.forEach { (path, hash) -> + val stagedFile = stagedRepository.resolve(path) + check(stagedFile.isFile && sha256(stagedFile) == hash) { "Changed staged artifact: $path" } + } + val report = mutableListOf() + resolved.forEach { (configuration, resolvedArtifacts) -> + val artifacts = resolvedArtifacts.filter { + (it.id.componentIdentifier as? ModuleComponentIdentifier)?.group == "com.quantipixels" + } + // The instrumentation APK drives the tested app and has separate dependencies. + // Require the library in the app or native configuration; check provenance in both APKs. + if (configuration.name != "debugAndroidTestRuntimeClasspath") { + check(artifacts.isNotEmpty()) { "No published artifact in ${configuration.name}" } + } + artifacts.forEach { artifact -> + val id = artifact.id.componentIdentifier as ModuleComponentIdentifier + check(id.version == stagedVersion && id.module.startsWith("ikokuko")) { "Wrong artifact: $id" } + val directory = stagedRepository.resolve("com/quantipixels/${id.module}/${id.version}") + check(directory.resolve("${id.module}-${id.version}.module").isFile) { "Missing Gradle metadata: $id" } + val matching = directory.listFiles().orEmpty().filter { + it.isFile && it.extension in setOf("aar", "jar", "klib") && sha256(it) == sha256(artifact.file) + } + check(matching.isNotEmpty()) { "Resolved bytes differ from staged publication: ${artifact.file}" } + report += "${configuration.name}: $id ${artifact.variant.attributes} ${sha256(artifact.file)} ${matching.first()}" + } + } + layout.buildDirectory.file("reports/staged-artifacts.txt").get().asFile.apply { + parentFile.mkdirs() + writeText(report.joinToString("\n", postfix = "\n")) + } + report.forEach(::println) + } +} diff --git a/verification/consumer/gradle.properties b/verification/consumer/gradle.properties new file mode 100644 index 0000000..81937ef --- /dev/null +++ b/verification/consumer/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx3072M diff --git a/verification/consumer/settings.gradle.kts b/verification/consumer/settings.gradle.kts new file mode 100644 index 0000000..443e7c9 --- /dev/null +++ b/verification/consumer/settings.gradle.kts @@ -0,0 +1,36 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +val stagedRepository = providers.gradleProperty("stagedRepository").orNull + ?: error("Pass -PstagedRepository=") +val stage = file(stagedRepository) +require(stage.isDirectory) { "Staged Maven directory does not exist: $stage" } +require(providers.gradleProperty("stagedVersion").isPresent) { "Pass -PstagedVersion=" } + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + versionCatalogs { + create("libs") { from(files("../../gradle/libs.versions.toml")) } + } + repositories { + exclusiveContent { + forRepository { + maven { + name = "verificationStage" + url = stage.toURI() + metadataSources { gradleMetadata() } + } + } + filter { includeGroup("com.quantipixels") } + } + google() + mavenCentral() + } +} + +rootProject.name = "ikokuko-publication-consumer" diff --git a/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt b/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt new file mode 100644 index 0000000..e9e8d6f --- /dev/null +++ b/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt @@ -0,0 +1,45 @@ +package com.quantipixels.ikokuko.verification + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Rule +import org.junit.Test + +class RestorationTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun realRegistryRestoresTextAndSelectedIdsBeforeImmediateSubmit() { + compose.onNodeWithTag("name").performTextReplacement("Yétúndé") + compose.onNodeWithTag("select").performClick() + // These preconditions distinguish missing input from a restoration failure. + compose.onNodeWithTag("name").assertTextContains("Yétúndé") + compose.onNodeWithTag("selection").assertTextEquals("second-id") + compose.activityRule.scenario.recreate() + compose.onNodeWithTag("name").assertTextContains("Yétúndé") + compose.onNodeWithTag("selection").assertTextEquals("second-id") + compose.activityRule.scenario.onActivity { it.submitCurrent() } + compose.onNodeWithTag("outcome").assertTextEquals("submitted:Yétúndé:second-id") + } + + @Test fun sameEventEditsAreValidatedBeforeSubmit() { + compose.onNodeWithTag("select").performClick() + compose.onNodeWithTag("name").performTextReplacement("valid") + compose.onNodeWithTag("fresh-invalid").performClick() + compose.onNodeWithTag("outcome").assertTextEquals("invalid") + compose.onNodeWithTag("fresh-valid").performClick() + compose.onNodeWithTag("outcome").assertTextEquals("submitted:fresh:second-id") + } + + @Test fun multilineUnicodeInputAndImeRespectTheBoundWithoutTruncation() { + compose.onNodeWithTag("select").performClick() + compose.onNodeWithTag("name").performClick().assertIsFocused() + compose.onNodeWithTag("name").performTextInput("Ẹ káàárọ̀\n🙂") + compose.onNodeWithTag("name").performImeAction() + compose.onNodeWithTag("outcome").assertTextEquals("submitted:Ẹ káàárọ̀\n🙂:second-id") + val overLimit = "🙂".repeat(9) // 18 UTF-16 code units; the bound is 16. + compose.onNodeWithTag("name").performTextReplacement(overLimit) + compose.onNodeWithTag("name").assertTextContains(overLimit) + compose.onNodeWithTag("name").performImeAction() + compose.onNodeWithTag("outcome").assertTextEquals("invalid") + } +} diff --git a/verification/consumer/src/androidMain/AndroidManifest.xml b/verification/consumer/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..f704711 --- /dev/null +++ b/verification/consumer/src/androidMain/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt b/verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt new file mode 100644 index 0000000..4edc8fc --- /dev/null +++ b/verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt @@ -0,0 +1,68 @@ +package com.quantipixels.ikokuko.verification + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import com.quantipixels.ikokuko.* + +val Name = Field.Text("name") +val Selected = Field.List("selected") + +class VerificationActivity : ComponentActivity() { + lateinit var scope: FormScope + var outcome by mutableStateOf("idle") + private set + + fun submitCurrent() = with(scope) { + submit(onInvalid = { outcome = "invalid" }) { + outcome = "submitted:${Name.value}:${Selected.value.joinToString()}" + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val state = rememberSaveableFormState() + Form(state) { + SideEffect { scope = this } + ValidationEffect(Name, "", listOf(RequiredValidator("Required"), MaxLengthValidator("Too long", 16))) + ValidationEffect(Selected, emptyList(), listOf(SelectionRangeValidator("Select one", min = 1))) + Column { + TextField( + value = Name.value, + onValueChange = { Name.value = it }, + modifier = Modifier.testTag("name"), + minLines = 2, + maxLines = 3, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { submitCurrent() }), + ) + Button(onClick = { Selected.value = listOf("second-id") }, Modifier.testTag("select")) { + Text("Select second") + } + Text(Selected.value.joinToString(), Modifier.testTag("selection")) + Button(onClick = ::submitCurrent, Modifier.testTag("submit")) { Text("Submit") } + Button(onClick = { + Name.value = "" + submitCurrent() + }, Modifier.testTag("fresh-invalid")) { Text("Edit invalid and submit") } + Button(onClick = { + Name.value = "fresh" + submitCurrent() + }, Modifier.testTag("fresh-valid")) { Text("Edit valid and submit") } + Text(outcome, Modifier.testTag("outcome")) + } + } + } + } +} diff --git a/verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt b/verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt new file mode 100644 index 0000000..03aa34a --- /dev/null +++ b/verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt @@ -0,0 +1,64 @@ +package com.quantipixels.ikokuko.verification + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.SideEffect +import com.quantipixels.ikokuko.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.* + +class PublishedApiTest { + @OptIn(ExperimentalCoroutinesApi::class) + @Test fun publishedSubmitValidatesAnEditInTheSameEvent() = runTest { + val recomposer = Recomposer(coroutineContext) + val composition = Composition(NoNodes(), recomposer) + val name = Field.Text("name") + var form: FormScope? = null + var accepted = 0 + var rejected = 0 + try { + composition.setContent { + Form { + ValidationEffect(name, "ready", listOf(RequiredValidator("Required"))) + SideEffect { form = this } + } + } + runCurrent() + with(checkNotNull(form)) { + name.value = "" + submit(onInvalid = { rejected++ }) { accepted++ } + assertEquals(1, rejected) + assertEquals(0, accepted) + name.value = "fresh" + submit(onInvalid = { rejected++ }) { accepted++ } + assertEquals(1, rejected) + assertEquals(1, accepted) + } + } finally { + composition.dispose() + recomposer.cancel() + } + } + + @Test fun publishedValidatorsUseUtf16Bounds() { + val scope = object : ValidationScope { + override val Field.value: T get() = error("No dependencies") + } + with(MaxLengthValidator("Too long", 4)) { + assertTrue(with(scope) { validate("🙂🙂") }) + assertFalse(with(scope) { validate("🙂🙂a") }) + } + } +} + +// Form and ValidationEffect emit no UI nodes. Unexpected UI fails this runtime-only consumer. +private class NoNodes : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = error("Unexpected UI node") + override fun insertBottomUp(index: Int, instance: Unit) = error("Unexpected UI node") + override fun remove(index: Int, count: Int) = error("Unexpected UI node") + override fun move(from: Int, to: Int, count: Int) = error("Unexpected UI node") + override fun onClear() = Unit +} From ed14149e51c3c16f8bc3f1f7e44044ec2949a25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 18:49:21 +0000 Subject: [PATCH 2/6] Remove added verification infrastructure and tests --- .github/workflows/verification.yml | 92 ----- build.gradle.kts | 11 +- gradle/libs.versions.toml | 4 - .../ikokuko/FormRecompositionTest.kt | 346 ------------------ .../ikokuko/sample/ComposeAppCommonTest.kt | 55 +-- tooling/verify-publication.py | 203 ---------- verification/README.md | 100 ----- verification/apple-ci.py | 39 -- verification/consumer/build.gradle.kts | 148 -------- verification/consumer/gradle.properties | 3 - verification/consumer/settings.gradle.kts | 36 -- .../ikokuko/verification/RestorationTest.kt | 45 --- .../src/androidMain/AndroidManifest.xml | 5 - .../verification/VerificationActivity.kt | 68 ---- .../ikokuko/verification/PublishedApiTest.kt | 64 ---- 15 files changed, 4 insertions(+), 1215 deletions(-) delete mode 100644 .github/workflows/verification.yml delete mode 100755 tooling/verify-publication.py delete mode 100644 verification/README.md delete mode 100644 verification/apple-ci.py delete mode 100644 verification/consumer/build.gradle.kts delete mode 100644 verification/consumer/gradle.properties delete mode 100644 verification/consumer/settings.gradle.kts delete mode 100644 verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt delete mode 100644 verification/consumer/src/androidMain/AndroidManifest.xml delete mode 100644 verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt delete mode 100644 verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml deleted file mode 100644 index 03585a8..0000000 --- a/.github/workflows/verification.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Published artifact verification - -on: - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - apple-consumer: - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15 - target: iosSimulatorArm64 - - runner: macos-15-intel - target: iosX64 - runs-on: ${{ matrix.runner }} - timeout-minutes: 35 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - uses: gradle/actions/setup-gradle@v5 - - name: Verify on an owned simulator clone - run: python3 verification/apple-ci.py ${{ matrix.target }} - - name: Preserve publication and test evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: ikokuko-${{ matrix.target }}-publication-evidence - path: | - build/verification/*/*.log - build/verification/*/*.json - build/verification/*/maven/staged-files.sha256 - build/verification/*/consumer-build/reports/ - build/verification/*/consumer-build/test-results/ - if-no-files-found: warn - - android-consumer: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - uses: gradle/actions/setup-gradle@v5 - - name: Install the pinned Android test platform - run: | - "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "system-images;android-35;google_apis;x86_64" - echo no | "$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd --name publication-verification --package "system-images;android-35;google_apis;x86_64" - - name: Run the packaged Android fixture - shell: bash - run: | - set -euo pipefail - mkdir -p build/verification - if [[ -e /dev/kvm ]]; then sudo chmod a+rw /dev/kvm; fi - "$ANDROID_HOME/emulator/emulator" -avd publication-verification -port 5580 -no-window -no-audio -no-snapshot -gpu swiftshader_indirect > build/verification/emulator.log 2>&1 & - emulator_pid=$! - trap '"$ANDROID_HOME/platform-tools/adb" -s emulator-5580 emu kill || true; wait "$emulator_pid" || true' EXIT - timeout 180 "$ANDROID_HOME/platform-tools/adb" -s emulator-5580 wait-for-device - booted=false - for attempt in {1..90}; do - if [[ "$("$ANDROID_HOME/platform-tools/adb" -s emulator-5580 shell getprop sys.boot_completed | tr -d '\r')" == 1 ]]; then booted=true; break; fi - sleep 2 - done - [[ "$booted" == true ]] - python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 - - name: Preserve publication and test evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: ikokuko-android-publication-evidence - path: | - build/verification/emulator.log - build/verification/*/*.log - build/verification/*/*.json - build/verification/*/maven/staged-files.sha256 - build/verification/*/consumer-build/reports/ - build/verification/*/consumer-build/test-results/ - build/verification/*/consumer-build/outputs/androidTest-results/ - if-no-files-found: warn diff --git a/build.gradle.kts b/build.gradle.kts index 4e39960..e68fd19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest - plugins { // this is necessary to avoid the plugins to be loaded multiple times // in each subproject's classloader @@ -12,11 +10,4 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.androidLint) apply false alias(libs.plugins.vanniktechMavenPublish) apply false -} - -// PR verification supplies its own disposable simulator; release defaults stay unchanged. -allprojects { - tasks.withType().configureEach { - providers.gradleProperty("verificationIosDevice").orNull?.let { device.set(it) } - } -} +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe5e9ce..b25c48e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,10 +15,6 @@ core = "1.7.0" vanniktechMavenPublish = "0.35.0" [libraries] -verification-compose-test = { module = "androidx.compose.ui:ui-test-junit4", version = "1.9.3" } -verification-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.9.3" } -verification-test-runner = { module = "androidx.test:runner", version = "1.7.0" } -verification-test-core = { module = "androidx.test:core", version = "1.7.0" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } compose-runtime-saveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "composeMultiplatform" } diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt index 1452886..c292862 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -481,352 +481,6 @@ class FormRecompositionTest { runOnIdle { assertEquals(false, receiverWasFormScope) } } - @Test - fun same_callback_edit_is_validated_before_submit() = runCompositionTest { - val field = Field.Text("immediate") - lateinit var scope: FormScope - var submitted = 0 - var invalid = 0 - setContent { - Form { - ValidationEffect(field, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - field.value = "" - submit(onInvalid = { invalid++ }) { submitted++ } - assertEquals(VALIDATION_ERROR, field.error) - assertEquals(0, submitted) - assertEquals(1, invalid) - } - } - } - - @Test - fun same_callback_correction_submits_and_queued_effect_preserves_new_rejection() = runCompositionTest { - val field = Field.Text("email") - lateinit var scope: FormScope - var submitted = 0 - setContent { - Form { - ValidationEffect(field, "", listOf(RequiredValidator(VALIDATION_ERROR))) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - field.value = "corrected" - submit { - submitted++ - field.error = EXTERNAL_ERROR - } - assertEquals(1, submitted) - } - } - waitForIdle() - runOnIdle { with(scope) { assertEquals(EXTERNAL_ERROR, field.error) } } - } - - @Test - fun unchanged_external_error_blocks_but_changed_input_replaces_it() = runCompositionTest { - val field = Field.Text("email") - lateinit var scope: FormScope - var submitted = 0 - var invalid = 0 - setContent { - Form { - ValidationEffect(field, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - field.error = EXTERNAL_ERROR - submit(onInvalid = { invalid++ }) { submitted++ } - assertEquals(EXTERNAL_ERROR, field.error) - assertEquals(1, invalid) - assertEquals(0, submitted) - field.value = "changed" - submit(onInvalid = { invalid++ }) { submitted++ } - assertNull(field.error) - assertEquals(1, invalid) - assertEquals(1, submitted) - } - } - } - - @Test - fun same_callback_dependency_edits_use_one_current_snapshot() = runCompositionTest { - val first = Field.Text("first") - val second = Field.Text("second") - lateinit var scope: FormScope - var submitted = 0 - setContent { - Form { - ValidationEffect(first, "old", listOf(FieldEqualsValidator(MISMATCH_ERROR, second))) - ValidationEffect(second, "old", listOf(FieldEqualsValidator(MISMATCH_ERROR, first))) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - first.value = "new" - second.value = "new" - submit { submitted++ } - assertNull(first.error) - assertNull(second.error) - assertEquals(1, submitted) - first.value = "different" - submit(onInvalid = { submitted-- }) { error("Must be invalid") } - assertEquals(MISMATCH_ERROR, first.error) - assertEquals(MISMATCH_ERROR, second.error) - assertEquals(0, submitted) - } - } - } - - @Test - fun thrown_validator_does_not_partially_commit_pending_errors_or_submit_flags() = runCompositionTest { - val first = Field.Text("first") - val second = Field.Text("second") - lateinit var scope: FormScope - var callbacks = 0 - val throwing = object : Validator { - override val errorMessage = "unused" - override fun ValidationScope.validate(value: String): Boolean { - if (value == "throw") error("Validator failed") - return true - } - } - setContent { - Form { - ValidationEffect(first, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) - ValidationEffect(second, "valid", listOf(throwing)) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - first.error = EXTERNAL_ERROR - first.value = "" - second.value = "throw" - first.isDirty = false - second.isDirty = false - kotlin.test.assertFailsWith { - submit(onInvalid = { callbacks++ }) { - callbacks++ - } - } - assertEquals(0, callbacks) - assertEquals(EXTERNAL_ERROR, first.error) - assertNull(second.error) - assertFalse(first.isDirty) - assertFalse(second.isDirty) - assertFalse(shouldShowErrors) - // Restore before the queued reactive effect runs. - second.value = "valid" - } - } - } - - @Test - fun submit_after_reset_before_recomposition_skips_uninitialized_fields() = runCompositionTest { - val field = Field.Text("required") - lateinit var scope: FormScope - var initial by mutableStateOf("old") - var submitted = 0 - setContent { - Form { - ValidationEffect(field, initial, listOf(RequiredValidator(VALIDATION_ERROR))) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - initial = "current" - reset() - submit { submitted++ } - assertEquals(1, submitted) - assertTrue(fields.isEmpty()) - assertFalse(field.isDirty) - } - } - waitForIdle() - runOnIdle { - with(scope) { - assertEquals("current", field.value) - assertFalse(field.isDirty) - } - } - } - - @Test - fun removed_effect_is_not_flushed_and_replaced_state_owns_its_validation() = runCompositionTest { - val field = Field.Text("conditional") - var visible by mutableStateOf(true) - var state by mutableStateOf(FormState()) - lateinit var scope: FormScope - setContent { - Form(state) { - if (visible) ValidationEffect(field, "", listOf(RequiredValidator(VALIDATION_ERROR))) - SideEffect { scope = this } - } - } - waitForIdle() - val oldScope = scope - runOnIdle { - with(scope) { field.isDirty = true } - visible = false - } - waitForIdle() - runOnIdle { - with(scope) { - submit { assertEquals("", field.value) } - assertNull(field.error) - assertTrue(field.isDirty) - } - visible = true - state = FormState() - } - waitForIdle() - runOnIdle { - with(oldScope) { submit { assertNull(field.error) } } - with(scope) { - submit(onInvalid = {}) { error("New state must be invalid") } - assertEquals(VALIDATION_ERROR, field.error) - } - } - } - - @Test - fun missing_dependency_is_skipped_during_submit_and_success_may_reset() = runCompositionTest { - val field = Field.Text("confirmation") - val absent = Field.Text("absent") - lateinit var scope: FormScope - setContent { - Form { - ValidationEffect(field, "old", listOf(FieldEqualsValidator(MISMATCH_ERROR, absent))) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - field.value = "changed" - submit { - assertNull(field.error) - reset() - } - assertTrue(fields.isEmpty()) - assertFalse(shouldShowErrors) - } - } - } - - @Test - fun disposing_a_duplicate_declaration_does_not_remove_the_other_registration() = runCompositionTest { - val field = Field.Text("shared") - var showFirst by mutableStateOf(true) - lateinit var scope: FormScope - setContent { - Form { - if (showFirst) { - androidx.compose.runtime.key("first") { - ValidationEffect(field, "valid", emptyList()) - } - } - androidx.compose.runtime.key("second") { - ValidationEffect(field, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) - } - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { field.error = EXTERNAL_ERROR } - showFirst = false - } - waitForIdle() - runOnIdle { - with(scope) { - assertEquals(EXTERNAL_ERROR, field.error) - field.value = "" - submit(onInvalid = {}) { error("Remaining registration must validate") } - assertEquals(VALIDATION_ERROR, field.error) - } - } - } - - @Test - fun submit_before_recomposition_uses_registered_validators_then_replacement_revalidates() = runCompositionTest { - val field = Field.Text("rules") - var validators by mutableStateOf>>(emptyList()) - lateinit var scope: FormScope - var submitted = 0 - setContent { - Form { - ValidationEffect(field, "", validators) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - validators = listOf(RequiredValidator(VALIDATION_ERROR)) - with(scope) { - submit { submitted++ } - assertEquals(1, submitted) - } - } - waitForIdle() - runOnIdle { - with(scope) { - submit(onInvalid = {}) { error("Replaced validators must block") } - assertEquals(VALIDATION_ERROR, field.error) - } - } - } - - @Test - fun pending_results_are_computed_before_any_error_is_applied() = runCompositionTest { - val first = Field.Text("first") - val second = Field.Text("second") - lateinit var scope: FormScope - var observedError: String? = null - val observesFirst = object : Validator { - override val errorMessage = "unused" - override fun ValidationScope.validate(value: String): Boolean { - observedError = with(scope) { first.error } - return true - } - } - setContent { - Form { - ValidationEffect(first, "valid", listOf(RequiredValidator(VALIDATION_ERROR))) - ValidationEffect(second, "valid", listOf(observesFirst)) - SideEffect { scope = this } - } - } - waitForIdle() - runOnIdle { - with(scope) { - first.error = EXTERNAL_ERROR - first.value = "" - second.value = "changed" - submit(onInvalid = {}) { error("Required field must block") } - assertEquals(EXTERNAL_ERROR, observedError) - assertEquals(VALIDATION_ERROR, first.error) - } - } - } - private fun runCompositionTest( block: suspend CompositionFixture.() -> Unit ): TestResult = runTest { diff --git a/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt b/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt index acab4e2..f47d051 100644 --- a/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt +++ b/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt @@ -1,61 +1,12 @@ package com.quantipixels.ikokuko.sample -import com.quantipixels.ikokuko.MaxLengthValidator -import com.quantipixels.ikokuko.ValidationScope -import com.quantipixels.ikokuko.Field import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue class ComposeAppCommonTest { - private val values = mapOf("email" to "person@example.com") - private val request = SubmittedRequest(2, values) - private val response = FieldRejection(2, mapOf("email" to "Already registered")) @Test - fun current_rejection_maps_only_known_initialized_fields_and_reports_unknown_fields() { - val decision = rejectionDecision( - request, response.copy(errors = response.errors + ("other" to "Rejected")), - values, setOf("email"), consumed = false - ) - assertEquals(response.errors, decision.fieldErrors) - assertNotNull(decision.fallback) + fun example() { + assertEquals(3, 1 + 2) } - - @Test - fun stale_request_or_changed_values_do_not_apply_a_rejection() { - assertTrue(rejectionDecision(request, response.copy(requestId = 1), values, - setOf("email"), false).fieldErrors.isEmpty()) - assertTrue(rejectionDecision(request, response, mapOf("email" to "edited"), - setOf("email"), false).fieldErrors.isEmpty()) - assertTrue(rejectionDecision(null, response, values, - setOf("email"), false).fieldErrors.isEmpty()) - } - - @Test - fun consumed_response_cannot_reapply_after_editing_back_or_reentry() { - val decision = rejectionDecision(request, response, values, setOf("email"), true) - assertTrue(decision.fieldErrors.isEmpty()) - assertNull(decision.fallback) - } - - @Test - fun multiline_limit_validates_without_truncation_and_counter_uses_utf16_units() { - val validator = MaxLengthValidator("Too long", NotesLimit) - val scope = object : ValidationScope { - override val Field.value: T get() = error("No dependencies") - } - with(validator) { - assertTrue(with(scope) { validate("") }) - assertTrue(with(scope) { validate("a".repeat(NotesLimit - 1) + "\n") }) - val pasted = "a".repeat(NotesLimit) + "\n" - assertFalse(with(scope) { validate(pasted) }) - assertEquals(NotesLimit + 1, pasted.length) - } - assertEquals("2 / 240 UTF-16 units", notesCounter("😀")) - assertEquals("2 / 240 UTF-16 units", notesCounter("e\u0301")) - } -} +} \ No newline at end of file diff --git a/tooling/verify-publication.py b/tooling/verify-publication.py deleted file mode 100755 index 7406cd0..0000000 --- a/tooling/verify-publication.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -"""Stage a unique local publication and run its independent binary consumer.""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import platform -import shutil -import subprocess -import sys -import uuid -import xml.etree.ElementTree as ET - -ROOT = Path(__file__).resolve().parents[1] -LIBRARY = "ikokuko" - - -def digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def source_copy(destination): - # Copy current tracked and untracked source, never local machine configuration. - listing = subprocess.check_output( - ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=ROOT - ).decode().split("\0") - for name in listing: - relative = Path(name) - if not name or relative.parts[0] in {"verification", "tooling", ".alarina"}: - continue - if relative.name == "local.properties" or relative.name.startswith(".env"): - continue - source = ROOT / relative - if source.is_file() and not source.is_symlink(): - target = destination / relative - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, target) - - -def publication_manifest(stage, version): - modules = list(stage.glob("com/quantipixels/*/" + version + "/*.module")) - if not modules: - raise RuntimeError("No Gradle module metadata was published") - for module in modules: - metadata = json.loads(module.read_text()) - component = metadata["component"] - if component.get("group") != "com.quantipixels" or component.get("version") != version: - raise RuntimeError("Unexpected publication coordinates: " + str(module)) - if not component["module"].startswith(LIBRARY) or not metadata.get("variants"): - raise RuntimeError("Unexpected publication or empty variants: " + str(module)) - files = sorted(p for p in stage.rglob("*") if p.is_file()) - (stage / "staged-files.sha256").write_text("".join( - digest(p) + " " + p.relative_to(stage).as_posix() + "\n" for p in files - )) - - -def nonzero_reports(directory): - reports = list(directory.rglob("TEST-*.xml")) - tests = failures = skipped = 0 - for report in reports: - node = ET.parse(report).getroot() - suites = [node] if node.tag == "testsuite" else node.findall("testsuite") - for suite in suites: - tests += int(suite.get("tests", 0)) - failures += int(suite.get("failures", 0)) + int(suite.get("errors", 0)) - skipped += int(suite.get("skipped", 0)) - if tests == 0 or failures or skipped: - raise RuntimeError(f"Invalid consumer result: tests={tests}, failures={failures}, skipped={skipped}") - return tests - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--phase", choices=["configure", "plan", "stage", "verify"], default="verify") - parser.add_argument("--platform", choices=["android-unit", "android-device", "iosSimulatorArm64", "iosX64"], required=True) - parser.add_argument("--android-serial") - parser.add_argument("--ios-device", help="UUID of an owner-created disposable simulator") - args = parser.parse_args() - apple = args.platform.startswith("ios") - if apple and platform.system() != "Darwin": - parser.error("Apple publication and tests require macOS") - if args.phase == "verify" and apple and not args.ios_device: - parser.error("--ios-device must identify an owned simulator clone") - if args.phase == "verify" and args.platform == "android-device" and not args.android_serial: - parser.error("--android-serial is required; no default device is selected") - - run = ROOT / "build/verification" / uuid.uuid4().hex - run.mkdir(parents=True) - stage = run / "maven" - stage.mkdir() - version = "0.0.0-verification." + run.name + "-SNAPSHOT" - source = run / "source" - source_copy(source) - consumer = run / "consumer" - shutil.copytree(ROOT / "verification/consumer", consumer) - # The catalog stays the single versions source in both the checked-in and generated fixture. - (run / "gradle").mkdir() - shutil.copy2(ROOT / "gradle/libs.versions.toml", run / "gradle/libs.versions.toml") - settings = consumer / "settings.gradle.kts" - settings.write_text(settings.read_text().replace("../../gradle/libs.versions.toml", "../gradle/libs.versions.toml")) - - # No inherited Gradle properties, signing credentials, init scripts, or Java option injection. - environment = {key: os.environ[key] for key in ( - "PATH", "JAVA_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT", "SYSTEMROOT", "TMPDIR" - ) if key in os.environ} - if not environment.get("ANDROID_HOME") and not environment.get("ANDROID_SDK_ROOT"): - candidates = [Path.home() / "Library/Android/sdk", Path.home() / "Android/Sdk"] - sdk = next((path for path in candidates if (path / "platforms/android-36/android.jar").is_file()), None) - if sdk: - environment["ANDROID_HOME"] = str(sdk) - environment["KONAN_DATA_DIR"] = str(ROOT / "build/verification/konan") - environment["HOME"] = str(run / "home") - environment["GRADLE_USER_HOME"] = str(run / "gradle-user-home") - Path(environment["HOME"]).mkdir() - Path(environment["GRADLE_USER_HOME"]).mkdir(exist_ok=True) - for name in ("caches", "wrapper"): - cache = ROOT / "build/verification/gradle-user-home" / name - cache.mkdir(parents=True, exist_ok=True) - (Path(environment["GRADLE_USER_HOME"]) / name).symlink_to(cache, target_is_directory=True) - if args.android_serial: - environment["ANDROID_SERIAL"] = args.android_serial - metadata = {"library": LIBRARY, "version": version, "platform": args.platform, - "phase": args.phase, "stage": str(stage), "state": "running"} - record = run / "run.json" - record.write_text(json.dumps(metadata, indent=2) + "\n") - print("Evidence: " + str(run), flush=True) - - common = ["--no-daemon", "--no-configuration-cache", "--no-parallel", "--max-workers=1", "--console=plain", "--stacktrace"] - wrapper = str(source / "gradlew") - def execute(label, directory, tasks, properties=(), expected_failure=False, expected_marker=None): - command = [wrapper, "-p", str(directory), *common, *tasks, *properties] - (run / (label + ".command.json")).write_text(json.dumps(command, indent=2) + "\n") - with (run / (label + ".log")).open("w") as output: - try: - result = subprocess.run(command, cwd=directory, env=environment, - stdout=output, stderr=subprocess.STDOUT, - timeout=300 if args.phase in {"configure", "plan"} else 1200) - except (subprocess.TimeoutExpired, KeyboardInterrupt): - # This run has its own daemon registry; do not stop another user's Gradle work. - subprocess.run([wrapper, "--stop"], cwd=directory, env=environment, - stdout=output, stderr=subprocess.STDOUT, timeout=60) - raise - if (result.returncode != 0) != expected_failure: - raise RuntimeError(f"{label}: unexpected exit {result.returncode}; see {label}.log") - if expected_marker and expected_marker not in (run / (label + ".log")).read_text(): - raise RuntimeError(f"{label}: failed for a different reason; inspect its log") - print(f"{label}: exit {result.returncode}", flush=True) - - properties = ["-PstagedVersion=" + version, "-PstagedRepository=" + str(stage), - "-PverificationBuildDirectory=" + str(run / "consumer-build"), - "-PverificationAppleTarget=" + (args.platform if apple else "none")] - if args.ios_device: - properties += ["-PverificationIosDevice=" + args.ios_device] - try: - if args.phase == "configure": - execute("configure", consumer, ["help"], properties) - else: - # Exact local tasks only. Never accept arbitrary task names or remote publishing tasks. - target = {"iosSimulatorArm64": "IosSimulatorArm64", "iosX64": "IosX64"}.get(args.platform, "AndroidRelease") - tasks = [f":{LIBRARY}:publishKotlinMultiplatformPublicationToMavenLocal", - f":{LIBRARY}:publish{target}PublicationToMavenLocal"] - if args.phase == "plan": - tasks += ["--dry-run"] - execute("publication-plan" if args.phase == "plan" else "stage", source, tasks, - ["-PVERSION_NAME=" + version, "-Dmaven.repo.local=" + str(stage)]) - if args.phase != "plan": - publication_manifest(stage, version) - if args.phase == "verify": - execute("artifact-resolution", consumer, ["verifyStagedArtifacts"], properties) - empty = run / "empty-maven" - empty.mkdir() - negative = [p for p in properties if not p.startswith("-PstagedRepository=")] - execute("empty-stage-must-fail", consumer, ["verifyStagedArtifacts"], - [*negative, "-PstagedRepository=" + str(empty)], expected_failure=True, - expected_marker=f"Could not find com.quantipixels:{LIBRARY}") - wrong = run / "wrong-maven" - shutil.copytree(stage, wrong) - artifact = next(p for p in wrong.rglob("*") if p.suffix in {".aar", ".klib"}) - with artifact.open("ab") as output: - output.write(b"verification-negative-control") - execute("wrong-artifact-must-fail", consumer, ["verifyStagedArtifacts"], - [*negative, "-PstagedRepository=" + str(wrong)], expected_failure=True, - expected_marker="Changed staged artifact") - tests = [args.platform + "Test"] if apple else ["testDebugUnitTest"] - if args.platform == "android-device": - tests += ["connectedDebugAndroidTest"] - execute("consumer-tests", consumer, tests, properties) - metadata["executedTests"] = nonzero_reports(run / "consumer-build") - if args.platform == "android-device": - metadata["instrumentedTests"] = nonzero_reports(run / "consumer-build/outputs/androidTest-results") - metadata["state"] = "passed" - except (Exception, KeyboardInterrupt) as error: - metadata["state"] = "failed" - metadata["error"] = str(error) - raise - finally: - record.write_text(json.dumps(metadata, indent=2) + "\n") - print(json.dumps(metadata, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/verification/README.md b/verification/README.md deleted file mode 100644 index 76175c1..0000000 --- a/verification/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Published-artifact verification - -This standalone consumer proves the packaged `ikokuko` API. It is deliberately absent from the root build: it has its own settings, repositories, Android activity, and platform tests. The same consumer supplies Android restoration/ownership tests and Maven publication proof. - -## Commands - -Run from the repository root with JDK 17 and an installed Android SDK. Set `JAVA_HOME` to your detected JDK 17 directory and `ANDROID_HOME` or `ANDROID_SDK_ROOT` to your SDK. When no SDK variable is provided, the runner detects an installed SDK 36 at the standard macOS or Linux user SDK location. It reads no configuration file to do this. No `local.properties` file is copied or read by the fixture runner. - -```sh -# Configuration only: no publication, compilation, or tests. -python3 tooling/verify-publication.py --phase configure --platform android-unit - -# Inspect the exact local publication task graph without building. -python3 tooling/verify-publication.py --phase plan --platform android-unit - -# Stage the Android publication without running consumer tests. -python3 tooling/verify-publication.py --phase stage --platform android-unit - -# Stage, check provenance and negative controls, then run binary JVM tests. -python3 tooling/verify-publication.py --platform android-unit - -# Include real instrumentation on ONE explicitly selected, owned emulator. -python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 - -# macOS: use the UUID of an owned disposable simulator clone. -python3 tooling/verify-publication.py --platform iosSimulatorArm64 --ios-device "$owned_simulator_uuid" -python3 tooling/verify-publication.py --platform iosX64 --ios-device "$owned_simulator_uuid" - -# CI entry point: creates, boots, and deletes its own simulator clone. -python3 verification/apple-ci.py iosSimulatorArm64 -python3 verification/apple-ci.py iosX64 -``` - -Use the architecture matching the host runner. Do not pass the UUID of a user's original simulator. The helper clones an available, shut-down iPhone simulator and deletes only its own clone in `finally`. Local runs never choose a default Android serial or Apple device. The Android script leaves the selected emulator running; its owner controls its lifetime. The CI emulator job owns its process and stops it in its exit trap. - -Every invocation prints its evidence directory under `build/verification//`. `run.json` records the selected lane and outcome. Exact Gradle commands are stored as `*.command.json`; output is in `*.log`. Commands use `--no-parallel --max-workers=1 --no-daemon --no-configuration-cache`. Configuration and publication dry-runs are bounded to five minutes per Gradle invocation; publication and test invocations are bounded to twenty minutes each. A timeout attempts to stop only that run's isolated Gradle daemon registry. - -## Publication and resolution - -Each run copies the current working source into its generated directory, excluding local machine configuration, Git metadata, and build outputs. Finish concurrent library edits before staging: a source copy is not an atomic snapshot of a writer's changes. Root release configuration and tracked `VERSION_NAME` stay unchanged. Generated verification invocations disable configuration-cache reuse because each run has fresh source and its own daemon home. - -The version is `0.0.0-verification.-SNAPSHOT`. The only selected publication tasks are: - -```text -:ikokuko:publishKotlinMultiplatformPublicationToMavenLocal -:ikokuko:publishAndroidReleasePublicationToMavenLocal # Android lanes -:ikokuko:publishIosSimulatorArm64PublicationToMavenLocal # Apple Silicon lane -:ikokuko:publishIosX64PublicationToMavenLocal # Intel lane -``` - -Each invocation passes `-PVERSION_NAME=` and `-Dmaven.repo.local=/maven`. The script accepts no arbitrary task or Gradle arguments. It never calls `publish`, `publishToMavenCentral`, or a remote repository publication task. Signing tasks remain in Gradle's normal task graph. - -The standalone settings use `exclusiveContent` for all `com.quantipixels` modules, with Gradle module metadata as the only metadata source. There is no Maven Local, project dependency, composite build, or repository fallback for this group. Other dependencies use Google and Maven Central. The fixture reads `gradle/libs.versions.toml`; it adds no version matrix. Baseline: Kotlin 2.2.20, Compose Multiplatform 1.9.1 (Android Compose 1.9.3), AGP 8.11.2, Gradle 8.14.3, JVM 11. Android compile SDK is 36 and minimum SDK is 24; CI uses the API 35 Google APIs x86_64 emulator image. - -After staging, the runner validates actual `.module` coordinates and nonempty variants and records a SHA-256 manifest. - -`verifyStagedArtifacts` resolves both `debugRuntimeClasspath` and `debugAndroidTestRuntimeClasspath` for Android, or the selected native compile klibraries for Apple. The published library must be present in the app runtime or selected native configuration. The instrumentation APK has separate dependencies and drives the tested app, so it need not contain the library. Every `com.quantipixels` artifact found in either Android configuration still receives the same provenance checks. - -The task checks the exact version, published platform coordinates, corresponding staged module metadata, manifest hashes, and resolved artifact bytes against staged files. The report includes selected variant attributes, hashes, and staged paths. Gradle may cache downloaded files elsewhere; matching bytes establish correspondence without assuming the cache path is the Maven path. - -The Android debug consumer selects the published release library using `matchingFallbacks`. Apple lanes select `iosSimulatorArm64` or `iosX64` through actual Gradle metadata. Only those requested platform publications are built. - -## Signing policy - -[Vanniktech 0.35.0 source, `signAllPublications`](https://github.com/vanniktech/gradle-maven-publish-plugin/blob/0.35.0/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt#L138-L152) sets signing required to `!version.endsWith("-SNAPSHOT")`. This applies to publications signed for Maven Local too. A non-SNAPSHOT fixture therefore needs a signing key. The unique SNAPSHOT version uses the existing policy: no hook, task, signature requirement, or tracked `signAllPublications()` is disabled or changed. - -The runner builds a small allowlist of environment variables for Java/SDK discovery, uses generated Gradle user homes and project directories, and does not inherit signing, Maven Central, Gradle project-property, or Java option variables. It does not inspect a user's Gradle properties, init scripts, keyring, or environment secrets. Repository-local dependency caches and wrapper downloads are reused, while each run has a separate daemon registry. Failure evidence is retained; delete only the owned `build/verification` outputs when no proof is needed. - -## Discriminating failures and test counts - -A successful `verify` run must first resolve the real publication, then reject both negative controls: - -1. An empty staged repository must fail with `Could not find com.quantipixels:ikokuko`. Resolution happens before manifest checking, so a manifest-only error cannot satisfy this control. -2. A copied stage with one altered AAR or KLIB must fail with `Changed staged artifact`. Its original manifest is kept so the altered artifact cannot establish its own expected hash. - -The runner then executes `testDebugUnitTest`, adds `connectedDebugAndroidTest` for the device lane, or executes the selected `iosSimulatorArm64Test`/`iosX64Test`. Gradle test listeners and XML report checks reject zero tests, skipped tests, and failures. The device lane also requires nonzero instrumentation XML results independently of the JVM test count. Fresh generated build directories prevent old test reports from satisfying the checks. - -Expected tests: two common binary tests on each platform; three additional Android instrumentation tests. Negative controls are separate failed Gradle invocations, not skipped tests. - -## Native boundary and limits - -The Android tests use a real `ComponentActivity`, Compose input, and `ActivityScenario.recreate()`: - -- Enter text and select `second-id`; assert both preconditions before recreation, then assert restored values and submit in one event. -- Edit and submit in the same callback, first invalid and then valid, without allowing the validation effect to run between those operations. -- Focus a multiline field, inject Unicode text through Compose's text-input service, invoke its Done IME action, and reject an over-limit value without truncating it. The limit counts UTF-16 code units. - -The common binary tests compose `Form` and `ValidationEffect` against the published API, then edit and submit synchronously. A second test checks Unicode length behavior. They run on Android's JVM and each selected Apple simulator architecture. - -Compose text-input injection exercises the host's input connection and semantics, not a particular installed keyboard. This fixture does not prove a system clipboard paste gesture, composing-region behavior from a real keyboard, process death, or every field type. - -## CI evidence - -`.github/workflows/verification.yml` runs the two Apple consumer lanes and one Android instrumentation lane on pull requests or manual dispatch. New runs on the same workflow and ref cancel earlier in-progress runs. The Apple helper also runs the release simulator/sample checks and unsigned Xcode sample build. Release triggers and publication/signing policy are unchanged. - -CI uploads `ikokuko--publication-evidence` and `ikokuko-android-publication-evidence` from each workflow run, including on failure. Find these artifacts in the workflow run's artifact list. They contain command logs, command JSON, `run.json`, the staged SHA-256 manifest, provenance reports, and test reports. Android uploads also include emulator output and instrumentation XML results. - -Interpret `run.json` together with its `phase`: a passed `configure`, `plan`, or `stage` run does not prove consumer tests. A complete `verify` result requires successful artifact resolution, both negative controls failing with their expected markers, and nonzero test reports with no failures or skips. Check the instrumentation reports separately for an Android device run. A dry-run task marked `SKIPPED` proves only task selection, not publication, signing, or test execution. - -Use CI artifacts to assess a specific run. Generated local evidence directories are retained for diagnosis, but are not committed verification records or a standing claim that the tests passed. diff --git a/verification/apple-ci.py b/verification/apple-ci.py deleted file mode 100644 index ecb9024..0000000 --- a/verification/apple-ci.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""Run PR verification on a disposable Apple simulator owned by this process.""" -import json -from pathlib import Path -import subprocess -import sys -import uuid - -root = Path(__file__).resolve().parents[1] -target = sys.argv[1] if len(sys.argv) == 2 else "" -if target not in {"iosSimulatorArm64", "iosX64"}: - raise SystemExit("Usage: python3 verification/apple-ci.py iosSimulatorArm64|iosX64") -devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"])) -source = next(device["udid"] for runtime, entries in devices["devices"].items() - if "iOS" in runtime for device in entries - if device.get("isAvailable") and "iPhone" in device["name"] and device["state"] == "Shutdown") -clone = subprocess.check_output(["xcrun", "simctl", "clone", source, "Publication-" + uuid.uuid4().hex], text=True).strip() -try: - subprocess.run(["xcrun", "simctl", "boot", clone], check=True) - subprocess.run(["xcrun", "simctl", "bootstatus", clone, "-b"], check=True) - # Reuse the release simulator and sample checks before publication reaches a release tag. - suffix = "IosX64" if target == "iosX64" else "IosSimulatorArm64" - tasks = [":ikokuko:" + target + "Test", ":samples:composeApp:" + target + "Test", - ":samples:composeApp:linkDebugFramework" + suffix] - if target == "iosSimulatorArm64": - tasks += [":samples:composeApp:testDebugUnitTest", ":samples:composeApp:assembleDebug", - ":samples:composeApp:linkDebugFrameworkIosArm64"] - subprocess.run([str(root / "gradlew"), *tasks, "-PverificationIosDevice=" + clone, - "--no-parallel", "--max-workers=1", "--stacktrace"], cwd=root, check=True) - if target == "iosSimulatorArm64": - subprocess.run(["xcodebuild", "-project", "samples/iosApp/iosApp.xcodeproj", - "-scheme", "iosApp", "-configuration", "Debug", "-sdk", "iphonesimulator", - "-destination", "generic/platform=iOS Simulator", "CODE_SIGNING_ALLOWED=NO", "build"], - cwd=root, check=True) - subprocess.run([sys.executable, str(root / "tooling/verify-publication.py"), - "--platform", target, "--ios-device", clone], cwd=root, check=True) -finally: - subprocess.run(["xcrun", "simctl", "shutdown", clone], check=False) - subprocess.run(["xcrun", "simctl", "delete", clone], check=True) diff --git a/verification/consumer/build.gradle.kts b/verification/consumer/build.gradle.kts deleted file mode 100644 index 47fd6f4..0000000 --- a/verification/consumer/build.gradle.kts +++ /dev/null @@ -1,148 +0,0 @@ -import java.security.MessageDigest -import org.gradle.api.artifacts.component.ModuleComponentIdentifier -import org.gradle.api.tasks.testing.AbstractTestTask -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidApplication) - alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.composeCompiler) -} - -layout.buildDirectory.set(file(providers.gradleProperty("verificationBuildDirectory").get())) -val stagedVersion = providers.gradleProperty("stagedVersion").get() -val stagedRepository = file(providers.gradleProperty("stagedRepository").get()) -val appleTarget = providers.gradleProperty("verificationAppleTarget").orElse("none").get() -require(appleTarget in setOf("none", "iosSimulatorArm64", "iosX64")) - -kotlin { - androidTarget { - compilerOptions.jvmTarget.set(JvmTarget.JVM_11) - } - when (appleTarget) { - "iosSimulatorArm64" -> iosSimulatorArm64() - "iosX64" -> iosX64() - } - sourceSets { - commonMain.dependencies { - implementation("com.quantipixels:ikokuko:$stagedVersion") - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - } - commonTest.dependencies { - implementation(libs.kotlin.test) - implementation(libs.kotlinx.coroutines.test) - } - androidMain.dependencies { - implementation(libs.androidx.activity.compose) - } - androidInstrumentedTest.dependencies { - implementation(libs.verification.test.runner) - implementation(libs.verification.test.core) - implementation(libs.verification.compose.test) - } - } -} - -android { - namespace = "com.quantipixels.ikokuko.verification" - compileSdk = libs.versions.android.compileSdk.get().toInt() - defaultConfig { - applicationId = "com.quantipixels.ikokuko.verification" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.compileSdk.get().toInt() - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes.getByName("debug") { matchingFallbacks += "release" } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - testOptions.unitTests.isReturnDefaultValues = true -} - -dependencies { debugImplementation(libs.verification.compose.manifest) } - -// Native tests must use a simulator chosen by the owner, never a user's default device. -tasks.withType().configureEach { - providers.gradleProperty("verificationIosDevice").orNull?.let { device.set(it) } - doFirst { - require(providers.gradleProperty("verificationIosDevice").isPresent) { - "Pass -PverificationIosDevice=" - } - } -} - -tasks.withType().configureEach { - afterSuite(KotlinClosure2({ descriptor, result -> - if (descriptor.parent == null) { - check(result.testCount > 0 && result.skippedTestCount == 0L) { - "Consumer tests must execute nonzero tests with no skips" - } - } - })) -} - -fun sha256(file: File): String = MessageDigest.getInstance("SHA-256") - .digest(file.readBytes()).joinToString("") { "%02x".format(it) } - -// Gradle caches artifacts outside the repository. Compare bytes, not cache path names. -tasks.register("verifyStagedArtifacts") { - group = "verification" - doLast { - val requiredConfigurations = if (appleTarget == "none") { - setOf("debugRuntimeClasspath", "debugAndroidTestRuntimeClasspath") - } else { - setOf("${appleTarget}CompileKlibraries") - } - val selected = configurations.filter { - it.isCanBeResolved && it.name in requiredConfigurations - } - check(selected.map { it.name }.toSet() == requiredConfigurations) { - "Missing resolvable verification configurations: $requiredConfigurations" - } - // Resolve before checking the manifest so an empty repository proves resolution fails. - val resolved = selected.associateWith { it.incoming.artifacts.artifacts } - val manifest = stagedRepository.resolve("staged-files.sha256") - check(manifest.isFile) { "Missing staged publication manifest" } - val expected = manifest.readLines().associate { line -> - val parts = line.split(" ", limit = 2) - check(parts.size == 2) - parts[1] to parts[0] - } - expected.forEach { (path, hash) -> - val stagedFile = stagedRepository.resolve(path) - check(stagedFile.isFile && sha256(stagedFile) == hash) { "Changed staged artifact: $path" } - } - val report = mutableListOf() - resolved.forEach { (configuration, resolvedArtifacts) -> - val artifacts = resolvedArtifacts.filter { - (it.id.componentIdentifier as? ModuleComponentIdentifier)?.group == "com.quantipixels" - } - // The instrumentation APK drives the tested app and has separate dependencies. - // Require the library in the app or native configuration; check provenance in both APKs. - if (configuration.name != "debugAndroidTestRuntimeClasspath") { - check(artifacts.isNotEmpty()) { "No published artifact in ${configuration.name}" } - } - artifacts.forEach { artifact -> - val id = artifact.id.componentIdentifier as ModuleComponentIdentifier - check(id.version == stagedVersion && id.module.startsWith("ikokuko")) { "Wrong artifact: $id" } - val directory = stagedRepository.resolve("com/quantipixels/${id.module}/${id.version}") - check(directory.resolve("${id.module}-${id.version}.module").isFile) { "Missing Gradle metadata: $id" } - val matching = directory.listFiles().orEmpty().filter { - it.isFile && it.extension in setOf("aar", "jar", "klib") && sha256(it) == sha256(artifact.file) - } - check(matching.isNotEmpty()) { "Resolved bytes differ from staged publication: ${artifact.file}" } - report += "${configuration.name}: $id ${artifact.variant.attributes} ${sha256(artifact.file)} ${matching.first()}" - } - } - layout.buildDirectory.file("reports/staged-artifacts.txt").get().asFile.apply { - parentFile.mkdirs() - writeText(report.joinToString("\n", postfix = "\n")) - } - report.forEach(::println) - } -} diff --git a/verification/consumer/gradle.properties b/verification/consumer/gradle.properties deleted file mode 100644 index 81937ef..0000000 --- a/verification/consumer/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -android.useAndroidX=true -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -kotlin.daemon.jvmargs=-Xmx3072M diff --git a/verification/consumer/settings.gradle.kts b/verification/consumer/settings.gradle.kts deleted file mode 100644 index 443e7c9..0000000 --- a/verification/consumer/settings.gradle.kts +++ /dev/null @@ -1,36 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -val stagedRepository = providers.gradleProperty("stagedRepository").orNull - ?: error("Pass -PstagedRepository=") -val stage = file(stagedRepository) -require(stage.isDirectory) { "Staged Maven directory does not exist: $stage" } -require(providers.gradleProperty("stagedVersion").isPresent) { "Pass -PstagedVersion=" } - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - versionCatalogs { - create("libs") { from(files("../../gradle/libs.versions.toml")) } - } - repositories { - exclusiveContent { - forRepository { - maven { - name = "verificationStage" - url = stage.toURI() - metadataSources { gradleMetadata() } - } - } - filter { includeGroup("com.quantipixels") } - } - google() - mavenCentral() - } -} - -rootProject.name = "ikokuko-publication-consumer" diff --git a/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt b/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt deleted file mode 100644 index e9e8d6f..0000000 --- a/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/ikokuko/verification/RestorationTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.quantipixels.ikokuko.verification - -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.junit.Rule -import org.junit.Test - -class RestorationTest { - @get:Rule val compose = createAndroidComposeRule() - - @Test fun realRegistryRestoresTextAndSelectedIdsBeforeImmediateSubmit() { - compose.onNodeWithTag("name").performTextReplacement("Yétúndé") - compose.onNodeWithTag("select").performClick() - // These preconditions distinguish missing input from a restoration failure. - compose.onNodeWithTag("name").assertTextContains("Yétúndé") - compose.onNodeWithTag("selection").assertTextEquals("second-id") - compose.activityRule.scenario.recreate() - compose.onNodeWithTag("name").assertTextContains("Yétúndé") - compose.onNodeWithTag("selection").assertTextEquals("second-id") - compose.activityRule.scenario.onActivity { it.submitCurrent() } - compose.onNodeWithTag("outcome").assertTextEquals("submitted:Yétúndé:second-id") - } - - @Test fun sameEventEditsAreValidatedBeforeSubmit() { - compose.onNodeWithTag("select").performClick() - compose.onNodeWithTag("name").performTextReplacement("valid") - compose.onNodeWithTag("fresh-invalid").performClick() - compose.onNodeWithTag("outcome").assertTextEquals("invalid") - compose.onNodeWithTag("fresh-valid").performClick() - compose.onNodeWithTag("outcome").assertTextEquals("submitted:fresh:second-id") - } - - @Test fun multilineUnicodeInputAndImeRespectTheBoundWithoutTruncation() { - compose.onNodeWithTag("select").performClick() - compose.onNodeWithTag("name").performClick().assertIsFocused() - compose.onNodeWithTag("name").performTextInput("Ẹ káàárọ̀\n🙂") - compose.onNodeWithTag("name").performImeAction() - compose.onNodeWithTag("outcome").assertTextEquals("submitted:Ẹ káàárọ̀\n🙂:second-id") - val overLimit = "🙂".repeat(9) // 18 UTF-16 code units; the bound is 16. - compose.onNodeWithTag("name").performTextReplacement(overLimit) - compose.onNodeWithTag("name").assertTextContains(overLimit) - compose.onNodeWithTag("name").performImeAction() - compose.onNodeWithTag("outcome").assertTextEquals("invalid") - } -} diff --git a/verification/consumer/src/androidMain/AndroidManifest.xml b/verification/consumer/src/androidMain/AndroidManifest.xml deleted file mode 100644 index f704711..0000000 --- a/verification/consumer/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt b/verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt deleted file mode 100644 index 4edc8fc..0000000 --- a/verification/consumer/src/androidMain/kotlin/com/quantipixels/ikokuko/verification/VerificationActivity.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.quantipixels.ikokuko.verification - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.input.ImeAction -import com.quantipixels.ikokuko.* - -val Name = Field.Text("name") -val Selected = Field.List("selected") - -class VerificationActivity : ComponentActivity() { - lateinit var scope: FormScope - var outcome by mutableStateOf("idle") - private set - - fun submitCurrent() = with(scope) { - submit(onInvalid = { outcome = "invalid" }) { - outcome = "submitted:${Name.value}:${Selected.value.joinToString()}" - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - val state = rememberSaveableFormState() - Form(state) { - SideEffect { scope = this } - ValidationEffect(Name, "", listOf(RequiredValidator("Required"), MaxLengthValidator("Too long", 16))) - ValidationEffect(Selected, emptyList(), listOf(SelectionRangeValidator("Select one", min = 1))) - Column { - TextField( - value = Name.value, - onValueChange = { Name.value = it }, - modifier = Modifier.testTag("name"), - minLines = 2, - maxLines = 3, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { submitCurrent() }), - ) - Button(onClick = { Selected.value = listOf("second-id") }, Modifier.testTag("select")) { - Text("Select second") - } - Text(Selected.value.joinToString(), Modifier.testTag("selection")) - Button(onClick = ::submitCurrent, Modifier.testTag("submit")) { Text("Submit") } - Button(onClick = { - Name.value = "" - submitCurrent() - }, Modifier.testTag("fresh-invalid")) { Text("Edit invalid and submit") } - Button(onClick = { - Name.value = "fresh" - submitCurrent() - }, Modifier.testTag("fresh-valid")) { Text("Edit valid and submit") } - Text(outcome, Modifier.testTag("outcome")) - } - } - } - } -} diff --git a/verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt b/verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt deleted file mode 100644 index 03aa34a..0000000 --- a/verification/consumer/src/commonTest/kotlin/com/quantipixels/ikokuko/verification/PublishedApiTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.quantipixels.ikokuko.verification - -import androidx.compose.runtime.AbstractApplier -import androidx.compose.runtime.Composition -import androidx.compose.runtime.Recomposer -import androidx.compose.runtime.SideEffect -import com.quantipixels.ikokuko.* -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlin.test.* - -class PublishedApiTest { - @OptIn(ExperimentalCoroutinesApi::class) - @Test fun publishedSubmitValidatesAnEditInTheSameEvent() = runTest { - val recomposer = Recomposer(coroutineContext) - val composition = Composition(NoNodes(), recomposer) - val name = Field.Text("name") - var form: FormScope? = null - var accepted = 0 - var rejected = 0 - try { - composition.setContent { - Form { - ValidationEffect(name, "ready", listOf(RequiredValidator("Required"))) - SideEffect { form = this } - } - } - runCurrent() - with(checkNotNull(form)) { - name.value = "" - submit(onInvalid = { rejected++ }) { accepted++ } - assertEquals(1, rejected) - assertEquals(0, accepted) - name.value = "fresh" - submit(onInvalid = { rejected++ }) { accepted++ } - assertEquals(1, rejected) - assertEquals(1, accepted) - } - } finally { - composition.dispose() - recomposer.cancel() - } - } - - @Test fun publishedValidatorsUseUtf16Bounds() { - val scope = object : ValidationScope { - override val Field.value: T get() = error("No dependencies") - } - with(MaxLengthValidator("Too long", 4)) { - assertTrue(with(scope) { validate("🙂🙂") }) - assertFalse(with(scope) { validate("🙂🙂a") }) - } - } -} - -// Form and ValidationEffect emit no UI nodes. Unexpected UI fails this runtime-only consumer. -private class NoNodes : AbstractApplier(Unit) { - override fun insertTopDown(index: Int, instance: Unit) = error("Unexpected UI node") - override fun insertBottomUp(index: Int, instance: Unit) = error("Unexpected UI node") - override fun remove(index: Int, count: Int) = error("Unexpected UI node") - override fun move(from: Int, to: Int, count: Int) = error("Unexpected UI node") - override fun onClear() = Unit -} From 436306350e1a7d3ace7a26031e661b38b2294219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 20:14:59 +0000 Subject: [PATCH 3/6] Clarify error reporting flag naming --- CHANGELOG.md | 2 ++ README.md | 23 +++++++++++-------- .../kotlin/com/quantipixels/ikokuko/Form.kt | 19 +++++++-------- .../com/quantipixels/ikokuko/FormScope.kt | 10 ++++---- .../ikokuko/FormRecompositionTest.kt | 4 ++-- .../com/quantipixels/ikokuko/FormStateTest.kt | 10 ++++---- .../ikokuko/sample/SubmissionPolicy.kt | 2 +- 7 files changed, 38 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4de32a..79d5474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Ikokuko are recorded in this file. ## [Unreleased] +- Rename `shouldShowErrors` to `isErrorReportingEnabled` to clarify that the flag controls + error reporting and form validity; reactive validation runs regardless of its value. - Remove `onSubmit` from `Form` and require it at `submit(onInvalid = {}) { ... }`. Old submit trailing lambdas handled invalid input; migrate them to named `onInvalid`. - Flush pending validation synchronously on submit against one value snapshot. Preserve external diff --git a/README.md b/README.md index 8c560cd..7b4191a 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,18 @@ For the released API, use the [0.2.0 documentation](https://github.com/quantipix It’s the single source of truth for the form’s current state. `FormState.isValid` follows the form's error-reporting state. It remains `true` while -`shouldShowErrors` is `false`. After error reporting is enabled, it is `false` while any field has +`isErrorReportingEnabled` is `false`. After error reporting is enabled, it is `false` while any field has a stored error. Individual `field.isValid` values remain strict regardless of error visibility. -> Optionally pass `shouldShowErrors` when creating the state to control its initial error visibility behavior. +> Set `isErrorReportingEnabled` to control when stored errors affect form validity and can appear +> on dirty fields. Validation runs reactively regardless of this flag. ```kotlin // Default: dirty-field errors are hidden until submit or a manual toggle val formState = remember { FormState() } // Dirty-field errors can be displayed immediately -val formState = remember { FormState(shouldShowErrors = true) } +val formState = remember { FormState(isErrorReportingEnabled = true) } ``` Use `rememberSaveableFormState()` when field values must survive supported platform state restoration: @@ -76,9 +77,9 @@ complete `Saver`. You can also use `FormState.saver(valuesSa saver for the complete `Map` of field names and values while Ikokuko saves the remaining form state. -#### shouldShowErrors -Controls whether stored errors can be displayed for dirty fields. A pristine field does not display -its error until it becomes dirty. `submit()` marks all initialized fields as dirty. +#### isErrorReportingEnabled +Controls whether stored errors affect form validity and can be displayed for dirty fields. +A pristine field does not display its error until it becomes dirty. `submit()` marks all initialized fields as dirty. |Value|Behaviour|Typical Use Case| |---|---|---| @@ -88,14 +89,14 @@ its error until it becomes dirty. `submit()` marks all initialized fields as dir You can toggle this flag at any time from either the FormState or inside the FormScope. ```kotlin // From FormState -formState.shouldShowErrors = true // Permit dirty-field errors -formState.shouldShowErrors = false // Hide errors again +formState.isErrorReportingEnabled = true // Permit dirty-field errors +formState.isErrorReportingEnabled = false // Hide errors again // From FormScope Form { // ... - shouldShowErrors = true // Permit dirty-field errors - shouldShowErrors = false // Hide errors again + isErrorReportingEnabled = true // Permit dirty-field errors + isErrorReportingEnabled = false // Hide errors again } ``` @@ -498,6 +499,8 @@ Declare every field read during validation in `dependencies`. ## Migrating to the next release +- Rename `shouldShowErrors` to `isErrorReportingEnabled` in state constructors, + `rememberSaveableFormState`, and property access. This controls error reporting, not validation execution. - Remove `onSubmit` from `Form`; it now accepts only `state` and `content`. - Supply success at the action: `submit { sendCurrentValues() }`, or `submit(onInvalid = { showSummary() }) { sendCurrentValues() }`. diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt index 8fe34d8..5531c03 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt @@ -44,10 +44,11 @@ fun Form( * Acts as the single source of truth for all form data and validation state. * Can be remembered in a composable or hoisted to persist across navigation. * - * @param shouldShowErrors Whether stored errors should affect form validity and be visible initially. + * @param isErrorReportingEnabled Whether stored errors initially affect form validity and can be shown + * for dirty fields. */ @Stable -class FormState(shouldShowErrors: Boolean = false) { +class FormState(isErrorReportingEnabled: Boolean = false) { /** * Holds the current values for all registered [Field]s in the form. * @@ -101,7 +102,7 @@ class FormState(shouldShowErrors: Boolean = false) { * Controls whether stored errors affect form validity and can be shown for dirty fields. * Validation still runs reactively regardless of this flag. */ - var shouldShowErrors by mutableStateOf(shouldShowErrors) + var isErrorReportingEnabled by mutableStateOf(isErrorReportingEnabled) /** * Indicates whether any [Field] in the form has been modified @@ -117,7 +118,7 @@ class FormState(shouldShowErrors: Boolean = false) { * returns `true` only when the form has no stored validation or external errors. */ val isValid: Boolean - get() = !shouldShowErrors || errors.isEmpty() + get() = !isErrorReportingEnabled || errors.isEmpty() /** * Clears all field values, errors, dirty state, and error visibility. @@ -133,7 +134,7 @@ class FormState(shouldShowErrors: Boolean = false) { dirtyFields.clear() values.clear() errors.clear() - shouldShowErrors = false + isErrorReportingEnabled = false resetKey++ } @@ -179,7 +180,7 @@ private fun formStateSaver( buildList { add(savedValues) - add(state.shouldShowErrors) + add(state.isErrorReportingEnabled) add(state.dirtyFields.size) state.dirtyFields.forEach { add(it.name) } add(state.errors.size) @@ -194,7 +195,7 @@ private fun formStateSaver( @Suppress("UNCHECKED_CAST") val restoredValues = valuesSaver.restore(saved[index++] as Saveable) ?: return@listSaver null - val state = FormState(shouldShowErrors = saved[index++] as Boolean) + val state = FormState(isErrorReportingEnabled = saved[index++] as Boolean) restoredValues.forEach { (name, value) -> state.values[Field(name)] = value @@ -221,8 +222,8 @@ private fun formStateSaver( */ @Composable fun rememberSaveableFormState( - shouldShowErrors: Boolean = false, + isErrorReportingEnabled: Boolean = false, saver: Saver = FormState.Saver ): FormState = rememberSaveable(saver = saver) { - FormState(shouldShowErrors) + FormState(isErrorReportingEnabled) } diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt index d41ac0e..99ef815 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt @@ -66,17 +66,17 @@ class FormScope internal constructor( /** Returns `true` when the UI must display this field's stored error. */ val Field<*>.shouldDisplayError: Boolean - get() = isDirty && state.shouldShowErrors && !isValid + get() = isDirty && state.isErrorReportingEnabled && !isValid /** Returns `true` when stored errors do not currently block the form. */ val isValid: Boolean get() = state.isValid /** Controls whether stored errors affect form validity and can be shown for dirty fields. */ - var shouldShowErrors: Boolean - get() = state.shouldShowErrors + var isErrorReportingEnabled: Boolean + get() = state.isErrorReportingEnabled set(value) { - state.shouldShowErrors = value + state.isErrorReportingEnabled = value } /** @@ -96,7 +96,7 @@ class FormScope internal constructor( pending.forEach { it.apply(state) } fields.forEach { it.isDirty = true } - state.shouldShowErrors = true + state.isErrorReportingEnabled = true if (state.isValid) onSubmit() else onInvalid() } diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt index c292862..0b97183 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -52,7 +52,7 @@ class FormRecompositionTest { assertFalse(field.isValid) assertTrue(isValid) - shouldShowErrors = true + isErrorReportingEnabled = true assertFalse(isValid) } @@ -88,7 +88,7 @@ class FormRecompositionTest { runOnIdle { with(formScope) { field.isDirty = true - shouldShowErrors = true + isErrorReportingEnabled = true field.error = EXTERNAL_ERROR assertEquals("", field.value) diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt index ebcffce..7765b59 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt @@ -30,7 +30,7 @@ class FormStateTest { field.isDirty = true assertFalse(field.shouldDisplayError) - shouldShowErrors = true + isErrorReportingEnabled = true assertTrue(field.shouldDisplayError) assertFalse(field.isValid) assertFalse(isValid) @@ -62,7 +62,7 @@ class FormStateTest { @Test fun reset_clears_values_errors_dirty_state_and_visibility() { val field = Field.Text("email") - val state = FormState(shouldShowErrors = true) + val state = FormState(isErrorReportingEnabled = true) val scope = FormScope(state) state.values[field] = "value" @@ -75,7 +75,7 @@ class FormStateTest { assertFalse(field in fields) assertFalse(field.isDirty) assertNull(field.error) - assertFalse(shouldShowErrors) + assertFalse(isErrorReportingEnabled) assertTrue(isValid) } } @@ -108,7 +108,7 @@ class FormStateTest { fun default_saver_restores_values_dirty_errors_and_visibility() { val name = Field.Text("name") val age = Field("age") - val state = FormState(shouldShowErrors = true) + val state = FormState(isErrorReportingEnabled = true) state.values[name] = "Ada" state.values[age] = 37 state.dirtyFields.add(name) @@ -122,7 +122,7 @@ class FormStateTest { assertEquals(37, age.value) assertTrue(name.isDirty) assertEquals("invalid", name.error) - assertTrue(shouldShowErrors) + assertTrue(isErrorReportingEnabled) assertFalse(isValid) } } diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt index 2ea3194..f8b2b8d 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt @@ -44,7 +44,7 @@ internal fun FormScope.applyRejection(decision: RejectionDecision) { field.isDirty = true } } - if (decision.fieldErrors.isNotEmpty()) shouldShowErrors = true + if (decision.fieldErrors.isNotEmpty()) isErrorReportingEnabled = true } internal const val NotesLimit = 240 From 5e8c5f2a08dfcc233a8409d83b9de5e4a672b7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 20:20:59 +0000 Subject: [PATCH 4/6] Restore reactive validation without registrations --- CHANGELOG.md | 4 +- README.md | 20 ++--- .../kotlin/com/quantipixels/ikokuko/Form.kt | 3 - .../com/quantipixels/ikokuko/FormScope.kt | 67 +++++++---------- .../ikokuko/ValidationRegistration.kt | 74 ------------------- 5 files changed, 34 insertions(+), 134 deletions(-) delete mode 100644 ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d5474..0949129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,7 @@ All notable changes to Ikokuko are recorded in this file. error reporting and form validity; reactive validation runs regardless of its value. - Remove `onSubmit` from `Form` and require it at `submit(onInvalid = {}) { ... }`. Old submit trailing lambdas handled invalid input; migrate them to named `onInvalid`. -- Flush pending validation synchronously on submit against one value snapshot. Preserve external - errors for unchanged inputs and propagate validator exceptions before applying submit changes. -- Clarify reset, field lifetime, and validator registration timing. +- Clarify reset and field lifetime while preserving the existing reactive validation behavior. - Add sample backend-rejection handling with stale-response checks and consumed-response acknowledgment. - Add multiline sample inputs with caller-owned counters and UTF-16 length validation, without truncation. diff --git a/README.md b/README.md index 7b4191a..2e13a83 100644 --- a/README.md +++ b/README.md @@ -396,21 +396,15 @@ fun SignUpForm() { #### Notes - Uses the `TextInput` reusable component defined in the previous section. - `FormState` tracks and validates all registered fields automatically. -- The success lambda passed to `submit` runs only when the form has no stored errors after pending validation. +- The success lambda passed to `submit` runs only when the form has no stored errors after error reporting is enabled. - Cross-field validators declare the fields they read. A dependency value change revalidates the target field. - The form `isValid` property remains `true` until error reporting is enabled. After the first `submit()`, stored errors can disable UI elements such as this button. -- `submit { ... }` flushes pending validation, including value and dependency edits made in the - same callback. It copies values once, computes all pending errors, then applies them before - marking initialized fields dirty and calling exactly one callback. An unchanged field retains - its manual/server error; changed validation inputs replace that error just as reactive validation does. -- If a validator throws, the exception propagates. Submit applies no pending errors, dirty flags, - visibility changes or callbacks. Value edits made before submit remain yours. -- Validators use the currently registered list. A list replaced before recomposition takes effect - after composition commits. Replace mutable field values with new values; in-place mutation is - unsupported and may be invisible to validation. -- Callbacks run after validation commits and can reset or submit again. Your application owns - any recursion. A queued effect for already validated inputs preserves errors assigned by the callback. +- `submit()` uses the latest completed reactive validation cycle. Do not assign a field value + and call `submit()` synchronously in the same callback. +- External errors remain stored until a value, validator, dependency, or reset change triggers + reactive validation, or the application explicitly changes the error. + --- ### 8. Built-in [Validators](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt) @@ -507,8 +501,6 @@ Declare every field read during validation in `dependencies`. - **Old `submit { ... }` trailing lambdas meant `onInvalid`. They now mean successful submission.** Move old invalid handlers to the named `onInvalid` argument and add a success lambda. - Replace `onClick = ::submit` with `onClick = { submit { /* success */ } }`. -- Same-callback edits now validate synchronously. Unchanged validation inputs still retain external - errors; changing a value, dependency, validators, or reset key replaces them on validation. ## Migrating from 0.1.0 diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt index 5531c03..c20f2cc 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt @@ -66,9 +66,6 @@ class FormState(isErrorReportingEnabled: Boolean = false) { */ internal val values = mutableStateMapOf, Any>() - // Mounted effects own these registrations; they are deliberately not saved. - internal val registrations = mutableMapOf>() - /** * Tracks all [Field]s that have been modified since form initialization or reset. * diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt index 99ef815..8a783e9 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt @@ -3,8 +3,6 @@ package com.quantipixels.ikokuko import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.remember /** * Provides typed field data and form actions inside a [Form] composition. @@ -13,6 +11,8 @@ import androidx.compose.runtime.remember class FormScope internal constructor( internal val state: FormState ) { + internal val validationScope: ValidationScope = StateValidationScope(this) + /** All initialized fields in this form. */ val fields: Set> get() = state.values.keys @@ -80,24 +80,14 @@ class FormScope internal constructor( } /** - * Flushes pending validation against one value snapshot, then marks initialized fields dirty - * and enables error display. Calls exactly one of [onSubmit] or [onInvalid]. - * - * Unchanged validation inputs retain manual errors. Value and dependency edits in the same - * callback are included; validators use the currently registered list until recomposition. - * A validator exception propagates before errors, dirty flags or callbacks are changed here. - * Callbacks run after validation commits and may reset or resubmit; recursion is caller-owned. + * Enables error reporting and marks initialized fields dirty before checking stored errors. + * Validation runs reactively; this action uses its latest completed result. Do not change a + * field value and submit in the same callback, before reactive validation has processed it. + * Calls [onSubmit] when no stored errors remain, otherwise [onInvalid]. */ fun submit(onInvalid: () -> Unit = {}, onSubmit: FormScope.() -> Unit) { - val snapshot = state.values.toMap() - val pending = state.registrations.values.mapNotNull { registration -> - registration.pending(snapshot, state.resetKey) - } - pending.forEach { it.apply(state) } - fields.forEach { it.isDirty = true } state.isErrorReportingEnabled = true - if (state.isValid) onSubmit() else onInvalid() } @@ -105,11 +95,16 @@ class FormScope internal constructor( fun reset() = state.reset() } +private class StateValidationScope( + private val scope: FormScope +) : ValidationScope { + override val Field.value: T + get() = with(scope) { value } +} + /** * Initializes [field] once and validates it when its value, validators, or declared - * dependency values change, or after reset. Use one ValidationEffect for each field in a form. - * Removing it clears its error and keeps its value and dirty flag. Registration updates commit - * with composition; submit before recomposition uses the previous validator list. + * dependency values change. Use one ValidationEffect for each field in a form. */ @Composable fun FormScope.ValidationEffect( @@ -119,29 +114,21 @@ fun FormScope.ValidationEffect( ) { if (!field.isInitialized) state.values[field] = initialValue - val registration = remember(state, field) { ValidationRegistration(field) } - val value = field.value - val dependencyValues = dependencyValues(validators, state.values) - - DisposableEffect(registration) { - onDispose { - if (state.registrations[field.name] === registration) { - state.registrations.remove(field.name) - field.error = null - } - } + DisposableEffect(field) { + onDispose { field.error = null } } - SideEffect { - registration.validators = validators.toList() - state.registrations[field.name] = registration - } - - LaunchedEffect(registration, value, validators, dependencyValues, state.resetKey) { - // Submit may already have evaluated these inputs and then received a server rejection. - if (state.registrations[field.name] === registration) { - registration.pending(state.values.toMap(), state.resetKey)?.apply(state) - } + val value = field.value + val dependencyValues = validators + .flatMap(Validator<*>::dependencies) + .distinct() + .mapNotNull(state.values::get) + + LaunchedEffect(value, validators, dependencyValues, state.resetKey) { + field.error = validators.firstOrNull { validator -> + validator.dependencies.all { it.isInitialized } && + !with(validator) { validationScope.validate(value) } + }?.errorMessage } } diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt deleted file mode 100644 index 4cbfd7a..0000000 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/ValidationRegistration.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.quantipixels.ikokuko - -private class SnapshotValidationScope( - private val values: Map, Any> -) : ValidationScope { - @Suppress("UNCHECKED_CAST") - override val Field.value: T - get() = values[this] as? T ?: error("Field '$name' accessed before initialization") -} - -internal data class ValidationKey( - val value: Any, - val validators: List>, - val dependencyValues: List, - val resetKey: Int -) - -// Both effect restarts and completed-validation keys must observe the same dependencies. -internal fun dependencyValues( - validators: List>, - values: Map, Any> -): List = validators - .flatMap { it.dependencies } - .distinct() - .mapNotNull(values::get) - -internal class ValidationRegistration(val field: Field) { - var validators: List> = emptyList() - var evaluatedKey: ValidationKey? = null - - fun key(values: Map, Any>, resetKey: Int): ValidationKey? { - val value = values[field] ?: return null - - return ValidationKey( - value = value, - validators = validators, - dependencyValues = dependencyValues(validators, values), - resetKey = resetKey - ) - } - - @Suppress("UNCHECKED_CAST") - fun pending(values: Map, Any>, resetKey: Int): PendingValidation? { - val key = key(values, resetKey) ?: return null - if (key == evaluatedKey) return null - - val scope = SnapshotValidationScope(values) - val firstFailure = validators.firstOrNull { validator -> - val dependenciesAvailable = validator.dependencies.all { it in values } - if (!dependenciesAvailable) return@firstOrNull false - - val valid = with(validator) { scope.validate(key.value as T) } - !valid - } - - return PendingValidation(this, key, firstFailure?.errorMessage) - } -} - -internal class PendingValidation( - private val registration: ValidationRegistration<*>, - private val key: ValidationKey, - private val error: String? -) { - fun apply(state: FormState) { - if (error == null) { - state.errors.remove(registration.field.name) - } else { - state.errors[registration.field.name] = error - } - - registration.evaluatedKey = key - } -} From c6263ef25c6ebec199e2eb7db8c7900508ab8a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 20:43:49 +0000 Subject: [PATCH 5/6] Align form callback scopes and validator defaults --- CHANGELOG.md | 2 ++ README.md | 3 +++ .../kotlin/com/quantipixels/ikokuko/FormScope.kt | 8 ++++++-- .../com/quantipixels/ikokuko/FormRecompositionTest.kt | 2 +- .../kotlin/com/quantipixels/ikokuko/FormStateTest.kt | 8 ++++++-- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0949129..8d29873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to Ikokuko are recorded in this file. error reporting and form validity; reactive validation runs regardless of its value. - Remove `onSubmit` from `Form` and require it at `submit(onInvalid = {}) { ... }`. Old submit trailing lambdas handled invalid input; migrate them to named `onInvalid`. +- Give `onInvalid` the same `FormScope` receiver as `onSubmit` for reusable error handlers. +- Default `ValidationEffect.validators` to an empty list, matching `FormField`. - Clarify reset and field lifetime while preserving the existing reactive validation behavior. - Add sample backend-rejection handling with stale-response checks and consumed-response acknowledgment. - Add multiline sample inputs with caller-owned counters and UTF-16 length validation, without truncation. diff --git a/README.md b/README.md index 2e13a83..6e40cb3 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ fun DemoForm() { You can connect fields to your FormState and enable validation in two ways: Use one `ValidationEffect` or `FormField` for each field in a form. +Both default `validators` to an empty list, so fields without rules can omit that argument. - **Manual setup** — call [ValidationEffect](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt) directly to register and validate a field. ```kotlin @@ -498,6 +499,8 @@ Declare every field read during validation in `dependencies`. - Remove `onSubmit` from `Form`; it now accepts only `state` and `content`. - Supply success at the action: `submit { sendCurrentValues() }`, or `submit(onInvalid = { showSummary() }) { sendCurrentValues() }`. +- Both callbacks have a `FormScope` receiver. Reusable handlers can read fields and errors directly; + wrap existing zero-argument handler variables as `onInvalid = { handler() }`. - **Old `submit { ... }` trailing lambdas meant `onInvalid`. They now mean successful submission.** Move old invalid handlers to the named `onInvalid` argument and add a success lambda. - Replace `onClick = ::submit` with `onClick = { submit { /* success */ } }`. diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt index 8a783e9..012bf81 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt @@ -84,8 +84,12 @@ class FormScope internal constructor( * Validation runs reactively; this action uses its latest completed result. Do not change a * field value and submit in the same callback, before reactive validation has processed it. * Calls [onSubmit] when no stored errors remain, otherwise [onInvalid]. + * Both callbacks receive this form's scope so reusable handlers can inspect fields and errors. */ - fun submit(onInvalid: () -> Unit = {}, onSubmit: FormScope.() -> Unit) { + fun submit( + onInvalid: FormScope.() -> Unit = {}, + onSubmit: FormScope.() -> Unit + ) { fields.forEach { it.isDirty = true } state.isErrorReportingEnabled = true if (state.isValid) onSubmit() else onInvalid() @@ -110,7 +114,7 @@ private class StateValidationScope( fun FormScope.ValidationEffect( field: Field, initialValue: T, - validators: List> + validators: List> = emptyList() ) { if (!field.isInitialized) state.values[field] = initialValue diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt index 0b97183..541d69b 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -304,7 +304,7 @@ class FormRecompositionTest { setContent { Form(state = state) { - ValidationEffect(field, initialValue = initialValue, validators = emptyList()) + ValidationEffect(field, initialValue = initialValue) SideEffect { formScope = this } } } diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt index 7765b59..004df5c 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt @@ -87,16 +87,20 @@ class FormStateTest { var submitted = false var invalid = false val scope = FormScope(state) + val onInvalid: FormScope.() -> Unit = { + invalid = true + assertTrue(isErrorReportingEnabled) + assertTrue(field.shouldDisplayError) + } state.values[field] = "" with(scope) { field.error = "required" - submit(onInvalid = { invalid = true }) { submitted = true } + submit(onInvalid = onInvalid) { submitted = true } assertFalse(submitted) assertTrue(invalid) assertTrue(field.isDirty) - assertTrue(field.shouldDisplayError) field.error = null submit { submitted = true } From af66cadaa9e6c4303ea9162360eb4ab449c2cea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 20:56:37 +0000 Subject: [PATCH 6/6] Simplify samples around the existing signup form --- CHANGELOG.md | 1 - README.md | 30 +++---- .../com/quantipixels/ikokuko/sample/App.kt | 8 +- .../ikokuko/sample/BackendErrorForm.kt | 80 ------------------- .../quantipixels/ikokuko/sample/SignUpForm.kt | 29 +++++-- .../ikokuko/sample/SubmissionPolicy.kt | 51 ------------ 6 files changed, 37 insertions(+), 162 deletions(-) delete mode 100644 samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt delete mode 100644 samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d29873..d6aa542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ All notable changes to Ikokuko are recorded in this file. - Give `onInvalid` the same `FormScope` receiver as `onSubmit` for reusable error handlers. - Default `ValidationEffect.validators` to an empty list, matching `FormField`. - Clarify reset and field lifetime while preserving the existing reactive validation behavior. -- Add sample backend-rejection handling with stale-response checks and consumed-response acknowledgment. - Add multiline sample inputs with caller-owned counters and UTF-16 length validation, without truncation. ## [0.2.0] - 2026-08-02 diff --git a/README.md b/README.md index 6e40cb3..6b61075 100644 --- a/README.md +++ b/README.md @@ -238,34 +238,24 @@ Normally, this value is updated automatically by `ValidationEffect` whenever val - Custom inline validation not covered by existing Validator classes. - Resetting or clearing errors programmatically. -#### Example: Manual error assignment +#### Example: Server error assignment ```kotlin -// Inside a FormScope +// Inside a response handler or effect with access to FormScope +EmailField.error = "This email is already registered" +EmailField.isDirty = true +isErrorReportingEnabled = true -// Assign error message -if (EmailField.value.endsWith("@test.com")) { - EmailField.error = "Test domains are not allowed" -} - -// Clear the error message +// Clear the error when appropriate EmailField.error = null ``` --- -### Backend rejections and multiline limits - -The sample's `BackendErrorForm` uses the existing `TextInput` adapter and field APIs. -The application owns a unique request ID, a submitted-values snapshot, the pending response, -and its consumed request ID outside the form's lifetime. It applies a rejection only when the -request is current and all submitted values still match. Known initialized fields receive -`field.error` and `field.isDirty = true`; unknown names produce a form-level fallback. -Every response is acknowledged, including stale responses, so recomposition or re-entry cannot -reapply it. Retain that acknowledgment alongside your request state across navigation. -The demo's rejection button supplies a local response; there is no network service. +### Multiline input and limits -`TextInput` accepts `minLines`, `maxLines`, and caller-owned `counterText`. The sample uses -`MaxLengthValidator` with a 240-unit notes limit and shows the counter alongside errors. +The signup sample reuses `TextInput` for an optional notes field. The adapter accepts +`minLines`, `maxLines`, and caller-owned `counterText`. The sample uses `MaxLengthValidator` +with a 240-unit notes limit and shows the counter alongside errors. Both validation and the counter use Kotlin `String.length`: UTF-16 code units, not visible characters. An emoji may count as two units. Input, including pasted text and newlines, is kept intact; over-limit input is rejected by validation rather than silently truncated. diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt index ae438fb..d4befee 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt @@ -56,8 +56,6 @@ fun App() { } Spacer(Modifier.height(24.dp)) SignUpForm(onSubmit = { signUpData = it }) - HorizontalDivider() - BackendErrorExample() } signUpData?.let { data -> @@ -75,7 +73,10 @@ private fun SignUpSummaryDialog( onDismissRequest = onDismiss, title = { Text("Congratulations!") }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text("You’ve successfully completed the demo signup form.") HorizontalDivider() InfoLine("Phone number", data.phoneNumber) @@ -84,6 +85,7 @@ private fun SignUpSummaryDialog( InfoLine("Password confirmed?", if (data.password == data.confirmation) "Yes" else "No") InfoLine("Capacity", data.capacity.name) InfoLine("Projects", data.projects.joinToString(", ") { it.name }) + if (data.notes.isNotBlank()) InfoLine("Notes", data.notes) InfoLine("Terms accepted", if (data.terms) "Yes" else "No") Spacer(Modifier.height(12.dp)) Text( diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt deleted file mode 100644 index 3095928..0000000 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/BackendErrorForm.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.quantipixels.ikokuko.sample - -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import com.quantipixels.ikokuko.Field -import com.quantipixels.ikokuko.Form -import com.quantipixels.ikokuko.MaxLengthValidator -import com.quantipixels.ikokuko.RequiredValidator - -private val ContactEmail = Field.Text("email") -private val Notes = Field.Text("notes") - -/** A local response control demonstrates the policy without a network dependency. */ -@Composable -fun BackendErrorExample() { - var sequence by remember { mutableStateOf(0L) } - var request by remember { mutableStateOf(null) } - var consumedRequestId by remember { mutableStateOf(null) } - var response by remember { mutableStateOf(null) } - Column { - Text("Backend rejection and multiline input") - BackendErrorForm( - request = request, - response = response, - consumedRequestId = consumedRequestId, - onConsumed = { consumed -> - if (request?.id == consumed.requestId) consumedRequestId = consumed.requestId - if (response == consumed) response = null - }, - onSubmit = { values -> request = SubmittedRequest(++sequence, values) } - ) - Button(enabled = request != null, onClick = { - response = FieldRejection(request!!.id, mapOf("email" to "This email is already registered")) - }) { Text("Reject the last submitted email") } - } -} - -/** The caller retains request identity and removes acknowledged responses across screen entries. */ -@Composable -fun BackendErrorForm( - request: SubmittedRequest?, - response: FieldRejection?, - consumedRequestId: Long?, - onConsumed: (FieldRejection) -> Unit, - onSubmit: (Map) -> Unit -) { - var fallback by remember { mutableStateOf(null) } - Form { - TextInput(ContactEmail, label = "Contact email", - validators = listOf(RequiredValidator("Email is required"))) - TextInput(Notes, label = "Notes", minLines = 3, maxLines = 6, - counterText = ::notesCounter, - validators = listOf(MaxLengthValidator("Use at most $NotesLimit UTF-16 units", NotesLimit))) - - LaunchedEffect(response) { - response?.let { - val decision = rejectionDecision(request, it, submittedValues(), - fields.map { field -> field.name }.toSet(), - consumed = consumedRequestId == it.requestId) - applyRejection(decision) - fallback = decision.fallback - onConsumed(it) - } - } - fallback?.let { Text(it) } - Button(onClick = { - submit { - fallback = null - onSubmit(submittedValues()) - } - }) { Text("Submit contact details") } - } -} diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt index c079d08..488dded 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt @@ -23,6 +23,7 @@ import com.quantipixels.ikokuko.Field import com.quantipixels.ikokuko.FieldEqualsValidator import com.quantipixels.ikokuko.Form import com.quantipixels.ikokuko.MatchPatternValidator +import com.quantipixels.ikokuko.MaxLengthValidator import com.quantipixels.ikokuko.MinLengthValidator import com.quantipixels.ikokuko.RequiredValidator import com.quantipixels.ikokuko.SelectionRangeValidator @@ -34,6 +35,7 @@ data class SignUpData( val confirmation: String, val capacity: Capacity, val projects: List, + val notes: String, val terms: Boolean ) @@ -43,8 +45,10 @@ private val PasswordField = Field.Text("password") private val ConfirmPasswordField = Field.Text("confirm_password") private val CapacityField = Field.Text("capacity") private val ProjectsField = Field.List("projects") +private val NotesField = Field.Text("notes") private val TermsField = Field.Boolean("terms") +private const val NotesLimit = 240 private const val SymbolPattern = "[^A-Za-z0-9 ]" private const val DigitPattern = "\\d" private const val UppercasePattern = "[A-Z]" @@ -140,6 +144,16 @@ fun SignUpForm( ) ) ) + TextInput( + field = NotesField, + label = "Notes (optional)", + minLines = 3, + maxLines = 6, + counterText = { "${it.length} / $NotesLimit UTF-16 units" }, + validators = listOf( + MaxLengthValidator("Use at most $NotesLimit UTF-16 units", NotesLimit) + ) + ) CheckBox( field = TermsField, label = "I agree to the Terms & Conditions", @@ -158,13 +172,14 @@ fun SignUpForm( submit { onSubmit( SignUpData( - PhoneNumberField.value, - EmailField.value, - PasswordField.value, - ConfirmPasswordField.value, - enumValueOf(CapacityField.value), - ProjectsField.value, - TermsField.value + phoneNumber = PhoneNumberField.value, + email = EmailField.value, + password = PasswordField.value, + confirmation = ConfirmPasswordField.value, + capacity = enumValueOf(CapacityField.value), + projects = ProjectsField.value, + notes = NotesField.value, + terms = TermsField.value ) ) reset() diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt deleted file mode 100644 index f8b2b8d..0000000 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SubmissionPolicy.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.quantipixels.ikokuko.sample - -import com.quantipixels.ikokuko.Field -import com.quantipixels.ikokuko.FormScope - -/** Keep this request with its response; a newer submission replaces it. */ -data class SubmittedRequest(val id: Long, val values: Map) - -data class FieldRejection(val requestId: Long, val errors: Map) - -internal data class RejectionDecision( - val fieldErrors: Map = emptyMap(), - val fallback: String? = null -) - -/** Stale responses are consumed too, so returning to the screen cannot apply them later. */ -internal fun rejectionDecision( - request: SubmittedRequest?, - response: FieldRejection, - currentValues: Map, - knownFields: Set, - consumed: Boolean -): RejectionDecision { - if (consumed || request == null || request.id != response.requestId || - request.values != currentValues - ) return RejectionDecision() - - return RejectionDecision( - fieldErrors = response.errors.filterKeys { it in knownFields }, - fallback = if (response.errors.keys.any { it !in knownFields }) { - "The request was rejected. Review your details and try again." - } else null - ) -} - -internal fun FormScope.submittedValues(): Map = - fields.associate { it.name to it.value } - -internal fun FormScope.applyRejection(decision: RejectionDecision) { - val initialized = fields.associateBy(Field<*>::name) - decision.fieldErrors.forEach { (name, message) -> - initialized[name]?.let { field -> - field.error = message - field.isDirty = true - } - } - if (decision.fieldErrors.isNotEmpty()) isErrorReportingEnabled = true -} - -internal const val NotesLimit = 240 -internal fun notesCounter(value: String): String = "${value.length} / $NotesLimit UTF-16 units"